SugarCraft is a PHP monorepo porting the Charmbracelet TUI ecosystem. PHP has a reputation for poor PTY support β the goal of this plan is to close that gap and reach feature/quality parity with node-pty (Node), creack/pty (Go), portable-pty (Rust/wezterm) on Linux + macOS.
Key existing-state finding (candy-pty/src/*, ~1024 LOC): a working FFI-based PTY library already exists. It allocates master/slave via posix_openpt β grantpt β unlockpt β ptsname_r, spawns children via proc_open with explicit slave descriptors, forwards SIGWINCH, supports controlling-terminal via a bin/pty-shim.php shim (setsid + ioctl(TIOCSCTTY)), and is integration-tested against real PTYs.
This is not a from-scratch build. It is a consolidation + gap-close + hardening pass that pulls duplicated PTY/TTY plumbing out of candy-core, candy-shell, and candy-wish into candy-pty, then adds the missing pieces (FFI termios, reusable Pump abstraction, portable-pty-style trait architecture, comprehensive test matrix).
User decisions (confirmed up front):
- Skip Windows ConPTY for v1. Linux + macOS only. Document the Windows path as a deferred follow-up.
- Termios: FFI to libc primary,
sttyshell-out as fallback when FFI unavailable. - Full consolidation scope: move raw-mode handling, process spawning, and the byte-pump loop out of consumer libs and into
candy-pty. - Full quality push: ~4β6 weeks of work, ending at "as good as
node-pty/creackon Linux + macOS".
| Concern | Location | Status |
|---|---|---|
| PTY allocation (posix_openpt/grantpt/unlockpt/ptsname_r) | candy-pty/src/Pty.php, candy-pty/src/Libc.php |
β Solid, FFI-based |
proc_open with slave descriptors |
candy-pty/src/Spawn.php |
β Solid |
| Controlling-terminal claim (setsid + TIOCSCTTY) | candy-pty/bin/pty-shim.php (proc_open-prepended shim) |
β Solid; 5β50ms startup cost |
| Resize ioctl (TIOCSWINSZ / TIOCGWINSZ) | candy-pty/src/SizeIoctl.php (Linux + macOS branched constants) |
β Solid |
| SIGWINCH forwarder | candy-pty/src/SignalForwarder.php |
β
Solid; supports pcntl_async_signals or polled mode |
| EINTR-safe read with deadline retry | candy-pty/src/Pty.php (read loop) |
β Solid |
| Child lifecycle (proc_get_status poll, EOF, exit code capture) | candy-pty/src/Child.php |
β Solid |
php://fd/N stream wrapping |
candy-pty/src/Pty.php (stream cache) |
β Solid |
| Real-PTY integration tests | candy-pty/tests/* (8 files) |
β Solid |
| Raw mode (termios) | candy-core/src/Util/Tty/PosixBackend.php via stty -g / stty -icanon -echo shell-out |
|
| Bidirectional byte pump (stdinβmaster, with backpressure + EOF grace) | candy-wish/src/Transport/InProcessTransport.php |
|
| Non-PTY process spawning | candy-shell/src/Process/RealProcess.php (proc_open pipes) |
candy-pty::Child (proc_get_status polling pattern) |
| Terminal size query | candy-core/src/Util/Tty/PosixBackend.php (stty size + env fallback) |
|
| Windows ConPTY | not implemented | β Deferred (v2) |
| Cross-OS test coverage | Linux primary; macOS CI runs candy-pty only |
- Single namespace owns PTY/process/termios plumbing.
SugarCraft\Pty\*is the source of truth for: opening PTYs, spawning processes (with or without PTY), termios state, byte pumping, signal forwarding. candy-core::Util\Tty::PosixBackendbecomes a thin delegate tocandy-ptyfor raw mode and size β no moresttyshell-out on the primary code path.candy-wish::InProcessTransportpump loop is extracted tocandy-pty::Pumpso any consumer (recording, scripting, REPLs, candy-vcr playback) reuses the same backpressure/EOF-grace/SIGWINCH-aware pump.candy-shell::RealProcessmigrates tocandy-pty::Process(non-PTY spawn) sharing lifecycle code withChild.- Portable-pty-style interfaces (
PtySystem,MasterPty,SlavePty,Child,Process) are introduced so future backends (ConPTY, sidecar) can slot in without touching consumers. - Comprehensive test matrix β every public method has Linux + macOS coverage; a representative subset runs against bash, zsh, fish,
dash(POSIX baseline),python -i,vim, and a SIGWINCH-aware test harness. - Documentation parity β public API doc-comments cite the equivalent
creack/ptyorportable-ptymethod;candy-pty/README.mdincludes a "compared to node-pty / creack" feature table.
- Windows ConPTY backend (tracked separately;
plans/x-windows.md). - Replacing ReactPHP as the event loop primitive.
- Pure-PHP termios via ext-readline or custom PECL extension.
- Sidecar binary distribution.
- ANSI parsing (lives in
candy-core::InputReader+candy-vt, stays there). - VT-state recording semantics (lives in
candy-vcr, stays there). - Changes to
candy-core::Util\Tty::WindowsBackend(it handles native Windows console mode, not PTY allocation; out of scope for this plan).
candy-pty/src/Contract/ β pure interfaces, no logic:
SugarCraft\Pty\Contract\
βββ PtySystem.php // factory: open() β PtyPair, capabilities()
βββ PtyPair.php // master() + slave() accessors
βββ MasterPty.php // read/write/resize/size/stream/close
βββ SlavePty.php // path(), spawn(cmd, opts) β Child
βββ Child.php // pid, exited, wait, kill, exitCode
βββ Process.php // non-PTY equivalent of Child (for candy-shell)
βββ Termios.php // get/set, makeRaw, restore, isatty
βββ Pump.php // run(master, stdin, stdout, opts) β exit code
candy-pty/src/Posix/ β Linux/macOS implementation:
SugarCraft\Pty\Posix\
βββ PosixPtySystem.php // PtySystem; current Pty::open() logic
βββ PosixPtyPair.php // wraps Master + slave path
βββ PosixMasterPty.php // current Pty.php read/write/resize/stream
βββ PosixSlavePty.php // current Spawn.php logic
βββ PosixChild.php // current Child.php
βββ PosixProcess.php // migrated RealProcess.php (no-PTY proc_open)
βββ PosixTermios.php // NEW β FFI tcgetattr/tcsetattr
βββ SttyTermios.php // NEW β stty shell-out fallback
βββ PosixPump.php // NEW β extracted from InProcessTransport
SugarCraft\Pty\
βββ Libc.php // extend cdef: add tcgetattr, tcsetattr, struct termios, cfmakeraw
βββ SizeIoctl.php // unchanged
βββ SignalForwarder.php // unchanged
βββ bin/pty-shim.php // unchanged
candy-pty/src/Pty.php stays as a convenience facade for backwards compatibility β its current static methods (Pty::open(), etc.) delegate to PosixPtySystem. Existing call sites in candy-wish keep working without changes. New code is encouraged to inject PtySystem via DI.
candy-pty/src/TermiosFactory.php (new):
- Try
PosixTermiosβ attempt to load libc, cdeftcgetattr/tcsetattr/cfmakeraw/struct termios. If FFI is disabled or libc load fails, catch and fall through. - Fall back to
SttyTermiosβ invokesstty -g(save) /stty raw -echo(apply) /stty $saved(restore) viaproc_open. Same surface, different mechanism. - Expose a
capabilities()method on the returned instance so consumers can log/warn when the fallback is in use.
candy-pty (no internal deps; ext-ffi, ext-pcntl optional)
β
candy-core (uses candy-pty for Termios + size queries; Util\Tty\PosixBackend becomes thin)
β
candy-shell, candy-wish, candy-vcr, ...
candy-pty stays dependency-free at the SugarCraft level. candy-core adds "sugarcraft/candy-pty": "@dev" and the path-repo entry. Every consuming lib already has candy-pty in its transitive closure via candy-core β only need to verify path repos.
Goal: introduce the interface layer without breaking any callers.
- Add
SugarCraft\Pty\Contract\*interfaces. - Refactor existing
Pty.php/Spawn.php/Child.phpto implement them, in-place underSugarCraft\Pty\Posix\*. KeepSugarCraft\Pty\Ptyas a backward-compatible static facade that delegates toPosixPtySystem. - Extend
Libc.phpcdef with the termios surface:tcgetattr,tcsetattr,cfmakeraw,cfgetospeed, plusstruct termios(use 64-byte buffer + opaque; we don't need to read individual c_cflag bits in PHP βcfmakerawdoes it for us). - Add
SizeIoctl::winsizedoc-comment cross-reference tocreack/pty.GetsizeFull. - Confirm
candy-wish::InProcessTransportstill passes its full test suite unchanged. - Acceptance:
vendor/bin/phpunitgreen for candy-pty, candy-wish, candy-shell, candy-core. No public API removed.
Goal: eliminate stty from the hot path.
- Implement
Posix\PosixTermios(candy-pty/src/Posix/PosixTermios.php):- Constructor takes an int fd or PHP stream resource.
current(): Termiossnapshot β callstcgetattr(fd, &buf).makeRaw(): selfβ clones, callscfmakeraw(&buf), returns new instance.apply(int $when = TCSANOW): voidβ callstcsetattr(fd, when, &buf).restore(): voidβ re-applies the saved snapshot.isAtty(): boolβposix_isatty($fd)+stream_isattyfor the stream variant.
- Implement
Posix\SttyTermios(candy-pty/src/Posix/SttyTermios.php):- Same surface but
current()runsproc_open(['stty','-g']), captures the saved-mode string. makeRaw()records the intent;apply()runsstty raw -echo;restore()runsstty <saved>.- Acceptable for environments without ext-ffi (CI containers, restrictive shared hosts).
- Same surface but
TermiosFactory::open(int|resource $fd): Termiosβ try FFI first, log + downgrade on failure. HonorSUGARCRAFT_TERMIOS=sttyenv var to force the fallback (for testing).- Refactor
candy-core/src/Util/Tty/PosixBackend.php:- Replace
enableRawMode()body with$this->termios = TermiosFactory::open($this->stream); $this->saved = $this->termios->current(); $this->termios->makeRaw()->apply(); - Replace
restore()body with$this->saved->apply(); - Replace
size()body with a call toSizeIoctl::query($this->stream)(new helper, wrapsTIOCGWINSZ) β fall back to env vars /stty sizeifposix_isattyis false. - Keep
onResize()/drainSignals()unchanged (they useSignalForwarderalready).
- Replace
- Acceptance:
candy-core::PosixBackendno longer invokessttywhen ext-ffi is available.SUGARCRAFT_TERMIOS=sttyenv var forces the legacy path; both pass identical input/output snapshot tests.- macOS BSD-stty quirks are no longer reachable on the primary path.
Goal: make the byte-pump loop reusable.
- Create
Posix\PosixPump(candy-pty/src/Posix/PosixPump.php) by moving:InProcessTransport::pump()and helpers (writeFully,drainMaster,forwardStdinToMaster) β currently incandy-wish/src/Transport/InProcessTransport.php.- The
PUMP_TIMEOUT_USEC,PUMP_CHUNK,FLUSH_DEADLINE_SEC,STDIN_EOF_GRACE_SEC,VEOFconstants β make them configurable via aPumpOptionsvalue object. - The keepalive callback hook.
- Define
PumpOptions(readonly DTO):chunkBytes,selectTimeoutUs,flushDeadlineSec,stdinEofGraceSec,veof,keepalive(callable),onSigwinch(callable),onChildExit(callable). PosixPump::run(MasterPty $master, $stdinStream, $stdoutStream, ?Child $child, PumpOptions $opts): intreturns the child's exit code (or 0 if no child).- Refactor
candy-wish/src/Transport/InProcessTransport.php:- Delete pump internals.
runChild()becomes: open pty, spawn child, attach SIGWINCH forwarder, instantiatePosixPump, callrun(), return result. ~30 lines instead of 200+.
- Acceptance:
candy-wishfull suite green (especiallyInProcessTransportRunChildTest,InProcessTransportSigwinchTest).- New
candy-pty/tests/Posix/PosixPumpTest.phpexercises pump in isolation: large writes, EOF grace, partial writes, SIGWINCH mid-stream, keepalive cadence.
Goal: unify non-PTY subprocess handling with the PTY child lifecycle.
- Create
Posix\PosixProcess(candy-pty/src/Posix/PosixProcess.php) by moving and generalizingcandy-shell/src/Process/RealProcess.php. Public API mirrorsChild:pid(),exited(),wait(),kill($signal),exitCode(), plusstdoutBytes(),stderrBytes()for the capture case. - Extract the shared
proc_get_status()poll pattern into a package-private traitChildPollTraitused by bothPosixChildandPosixProcess. Handles:running=falsedetection,exitcodecapture before close,-1post-reap guard. - Move the destructor zombie-reaper safety net from
Childinto the trait soPosixProcessinherits it. - Replace
candy-shell/src/Process/RealProcess.phpwith a thin alias classextends PosixProcess(or just update callers and delete it; the class is internal, so an alias may be unnecessary β verify). - Acceptance:
candy-shellfull suite green.proc_get_status()polling lives in exactly one place.candy-pty/tests/Posix/PosixProcessTest.phpcovers capture/no-capture, exit-code propagation, signal kill, stdin closure.
Goal: consumers use the new contracts; legacy facades documented as deprecated for next major.
- Add
PtySystemFactory::default(): PtySystemreturningPosixPtySystemon Linux/macOS; throwUnsupportedPlatformExceptionon Windows for now (caught + documented). - Update
candy-wishto constructInProcessTransportwith aPtySystem $pty = nullconstructor parameter (defaulting toPtySystemFactory::default()) β supports test injection. - Update
candy-wish::Spawnmiddleware to depend onPtySysteminstead of staticPty::open(). - Update
candy-core::Programto accept an optionalTermios $termios = nullconstructor argument; if null, thePosixBackendresolves one viaTermiosFactory. - Add
@deprecateddoc-comments to the legacy static facades (Pty::open()etc.) pointing at the DI-friendly equivalents. Do not remove them β they ship intact through v1.x. - Update root
composer.jsonrepositories[]entries β verifycandy-corenow correctly path-repscandy-pty. Runcomposer validate(without--strict). - Acceptance:
- Every consumer compiles and tests green with the new constructor signatures.
- A demo script
candy-pty/examples/spawn-bash.phpshows the canonical usage ($pty = PtySystemFactory::default()->open(80, 24); $child = $pty->slave()->spawn(['bash'], ...)).
Goal: be honestly "as good as creack/pty on Linux + macOS".
Cross-shell integration tests under candy-pty/tests/Integration/:
BashInteractiveTestβ spawnbash -i, send commands, assert prompt + output.ZshTest,FishTest,DashTestβ same script, different shells. Skip gracefully if shell absent (require($this)->markTestSkipped('zsh not installed')).PythonReplTestβ spawnpython3 -u -i, exercise multi-line input.VimSmokeTestβ spawnvim, send:q!, assert clean exit. (Pulls in vt-parsing viacandy-vtfor screen state.)ResizeRaceTestβ spawntput colsin a loop while resizing the PTY; assert no torn reads.LargeBufferTestβ write 1 MB through master, read on slave, assert byte-identical (catches non-blocking write loops).EOFGraceTestβ close stdin while child is mid-write; assert pump drains output before returning.SIGINTForwardingTestβ spawnsleep 30withcontrollingTerminal: true, write\x03to master, assert child exits within 1s.OrphanedChildReapTestβ exit the parent withoutwait(); assert no zombies viaps.
Cross-platform CI:
- Extend
scripts/affected-libs.phpMACOS_LIBSarray to include candy-pty (already there), candy-core, candy-wish. - Add a
pty-matrixGitHub workflow that runs the integration suite on Ubuntu 22.04 + macOS 14 against PHP 8.3 and 8.4. - Verify
ext-ffiis present in both CI images (it's bundled in php-cli on standard images).
Documentation parity:
candy-pty/README.mdβ add "Compared to node-pty / creack/pty / portable-pty" feature table.- Doc-comments on every public method cite the equivalent upstream method (
Mirrors creack/pty.Startstyle β matches the convention inCLAUDE.md). candy-pty/docs/CONCEPTS.mdβ short explainer covering: what a PTY is, master vs slave, controlling-terminal semantics, why the shim exists, how SIGWINCH propagates.
Acceptance:
- 100% of public methods have β₯1 test.
- Integration suite runs on Linux + macOS CI without
markTestSkippedfor the standard tools. - README feature table shows β₯90% checkmark parity vs
node-pty/creack/ptyfor Unix concerns.
Only if P0βP5 land on time:
- Resource pooling β
PtyPoolfor SSH server scenarios (candy-wish) that spawn many short-lived sessions; reuse libc FFI handles across opens. Profile first; only build if measurable. - Pty multiplexing helper β
MultiPumpthat supervises N pumps for split-pane / tmux-like scenarios. Driven by candy-zone if it needs it. - Higher-level Expect-style API β
Expect::on($pty)->send("login: ")->expect("password:", timeout: 5)->...for scripting / test fixtures. Modeled onpexpect(Python). candy-vcrRecorder tap inPosixPumpβ add a?Recorder $recorder = nullfield toPumpOptions. When set, the pump tees stdin βrecordInputBytes()and master-read βrecordOutput()directly, and feedsWindowSizeMsgresizes throughrecordResize(). Makes byte-level cassette capture "free" for any pump-based consumer (candy-wish SSH sessions, the Shirley CLI below, REPL fixtures). Acceptance: existingcandy-vcrround-trip tests pass; newPumpRecorderTapTestasserts the tap captures abashsession andPlayer::play()reproduces it.
Goal: standalone candy-vcr record -- <cmd> CLI that records command execution at the PTY level, equivalent to asciinema rec or the hypothetical charmbracelet/shirley described in docs/research/libraries/candy-vcr-research.md:110-127. The PTY consolidation makes this nearly free β it's the canonical first external user of the new PosixPump + Recorder-tap combination.
The current candy-vcr only records sessions attached as a library to a SugarCraft\Core\Program (API-mode capture). A Shirley-style recorder closes the gap so users can record any terminal program β bash, vim, htop, python -i, an external CLI under test β without that program needing to know about SugarCraft.
Architecture (lives in candy-vcr, not candy-pty):
SugarCraft\Vcr\Cli\RecordCommand.php(new) β Symfony Console command. Argv:candy-vcr record [--output session.cas] [--shell] [--idle-trim] -- <cmd> [args...].- On invocation:
- Resolve
PtySystemviaPtySystemFactory::default(). - Query host TTY size via
SizeIoctl::query(STDIN); default 80Γ24 if not a tty. $pair = $pty->open($cols, $rows).$child = $pair->slave()->spawn($cmd, $env, controllingTerminal: true)β controlling-terminal so Ctrl+C reaches the recorded program, not the recorder.- Save host termios via
TermiosFactory::open(STDIN), switch host stdin to raw mode (transparent passthrough). - Open a streaming
Recorderagainst the output cassette path; write the header (cols, rows, env-snapshot if--envflag). - Attach
SignalForwarder::attachSigwinch()to propagate host resizes into the master. - Build
PumpOptions(recorder: $recorder, onSigwinch: β¦)and runPosixPump::run($pair->master(), STDIN, STDOUT, $child, $opts). - On child exit: restore host termios, flush + close recorder, exit with child's exit code.
- Resolve
--shellshorthand: if no command given, spawns$SHELL(or/bin/sh) with-l.--idle-trim <seconds>: if a gap >N seconds appears between events, the pump emits a synthetic shortened timestamp delta. Modeled onasciinema --idle-time-limit(research doc Β§4.1).--env: capture allowedENVkeys (allowlist with secret-name regex filter) into the cassette header for deterministic replay.- Cassette format: existing JSONL β no new schema, just exercised more thoroughly than the API-mode recorder hits today.
Why this fits the PTY plan:
- Validates
PosixPump's Recorder tap in a real user-facing path (not just unit tests). - Validates
TermiosFactoryhost raw-mode switching with an interactive shell β the highest-stakes termios consumer. - Validates
SignalForwarderacross two boundaries (host SIGWINCH β master, child Ctrl+C β controlling tty). - Validates the deferred-Windows decision: this is the feature that would most benefit from ConPTY; if it ships smoothly on Linux/macOS, the demand for Windows is concrete and prioritization for v2 has evidence.
Acceptance:
candy-vcr record --output /tmp/bash.cas -- bash -c 'echo hello; sleep 0.2; echo world'produces a cassette withoutputevents totalling "hello\nworld\n" (after CR/LF normalization) and aquitevent with exit 0.candy-vcr record --output /tmp/vim.cas -- vim /tmp/scratch.txtthen<Esc>:wq<CR>produces a cassette that replays into acandy-vtTerminal showing an empty buffer post-replay.candy-vcr replay /tmp/bash.casround-trips on the same host (timing tolerance applied).candy-vcr stats /tmp/bash.cas(from P6 stats CLI incandy-vcr-research.mdH1) reports plausible event counts.- Recording overhead measured at β€2% wall-clock vs running the same command without
record(ontime bash -c 'seq 100000'). - Documented in
candy-vcr/README.mdwith the asciinema comparison from research doc Β§4.1.
Scope discipline:
- Format conversion to/from
.cast(asciinema) β explicitly out of scope for P6.5; tracked as candy-vcr research Β§13 item #L2. - Hook system, custom matchers, gzip compression β out of scope; tracked separately in candy-vcr research Β§13.
- SVG rendering output (term-transcript style) β out of scope.
Dependencies on the rest of the PTY plan: P0βP5 must land first. The Recorder tap in PumpOptions (above P6 bullet) is the precondition. If P6.5 is started before that bullet, both expand into a single ~3-day effort.
To create:
candy-pty/src/Contract/PtySystem.phpcandy-pty/src/Contract/PtyPair.phpcandy-pty/src/Contract/MasterPty.phpcandy-pty/src/Contract/SlavePty.phpcandy-pty/src/Contract/Child.phpcandy-pty/src/Contract/Process.phpcandy-pty/src/Contract/Termios.phpcandy-pty/src/Contract/Pump.phpcandy-pty/src/Posix/PosixPtySystem.phpcandy-pty/src/Posix/PosixPtyPair.phpcandy-pty/src/Posix/PosixMasterPty.phpcandy-pty/src/Posix/PosixSlavePty.phpcandy-pty/src/Posix/PosixChild.phpcandy-pty/src/Posix/PosixProcess.phpcandy-pty/src/Posix/PosixTermios.phpcandy-pty/src/Posix/SttyTermios.phpcandy-pty/src/Posix/PosixPump.phpcandy-pty/src/Posix/ChildPollTrait.phpcandy-pty/src/TermiosFactory.phpcandy-pty/src/PtySystemFactory.phpcandy-pty/src/PumpOptions.phpcandy-pty/src/Exception/UnsupportedPlatformException.phpcandy-pty/examples/spawn-bash.phpcandy-pty/docs/CONCEPTS.mdcandy-pty/tests/Integration/*Test.php(~9 files per P5)candy-pty/tests/Posix/PosixTermiosTest.phpcandy-pty/tests/Posix/SttyTermiosTest.phpcandy-pty/tests/Posix/PosixPumpTest.phpcandy-pty/tests/Posix/PosixProcessTest.php.github/workflows/pty-matrix.ymlcandy-pty/tests/Posix/PumpRecorderTapTest.php(P6 β pump-side Recorder tap)candy-vcr/src/Cli/RecordCommand.php(P6.5 β Shirley-style CLI)candy-vcr/tests/Cli/RecordCommandTest.php(P6.5)candy-vcr/tests/Integration/ShirleyBashTest.php(P6.5 β recordbash -c '...', replay, assert)candy-vcr/tests/Integration/ShirleyVimTest.php(P6.5 β recordvim, screen-assert via candy-vt)
To modify:
candy-pty/src/Pty.phpβ refactor to delegate toPosixPtySystem; keep public surface stable; add@deprecatedmarkers on static helpers.candy-pty/src/Spawn.phpβ move logic intoPosixSlavePty; keep as thin alias.candy-pty/src/Child.phpβ move logic intoPosixChild; keep as thin alias.candy-pty/src/Libc.phpβ extend cdef with termios surface.candy-pty/src/SizeIoctl.phpβ addquery($fd): arrayhelper (TIOCGWINSZ readback).candy-pty/composer.jsonβ bump description, keywords, addexamples/to autoload-dev.candy-vcr/src/Cli/Application.phpβ registerRecordCommandalongside existinginspect/replay/diff(P6.5).candy-vcr/src/Recorder.phpβ verifyrecordInputBytes()/recordOutput()/recordResize()signatures match whatPosixPump's tap will call; widen if needed without breaking existing callers (P6).candy-vcr/composer.jsonβ add"sugarcraft/candy-pty": "@dev"+ path repo (P6.5; was already needed transitively, now direct).candy-vcr/README.mdβ documentcandy-vcr recordand compare toasciinema rec(P6.5).candy-pty/README.mdβ add feature parity table + concept link.candy-pty/CALIBER_LEARNINGS.mdβ log new patterns as they emerge.candy-core/src/Util/Tty/PosixBackend.phpβ delegate tocandy-pty::Termios+SizeIoctl.candy-core/composer.jsonβ add"sugarcraft/candy-pty": "@dev"+ path repo (verify not already present).candy-shell/src/Process/RealProcess.phpβ either delete and update callers, or convert to alias ofPosixProcess.candy-shell/composer.jsonβ depend on candy-pty if not already (transitive via candy-core probably suffices; verify).candy-wish/src/Transport/InProcessTransport.phpβ shrink to ~30 lines; delegate toPosixPump.candy-wish/src/Middleware/Spawn.phpβ accept injectedPtySystem.MATCHUPS.mdβ update candy-pty row(s) to reflect P1βP5 scope.scripts/affected-libs.phpβ ensure candy-core/candy-wish included in macOS pool for the duration of P5.composer.json(root) β verifyrepositories[]entries.
To leave alone:
candy-pty/src/SignalForwarder.phpβ already correctcandy-pty/src/Master.phpβ already correctcandy-pty/bin/pty-shim.phpβ already correctcandy-core/src/Util/Tty/WindowsBackend.phpβ out of scopecandy-core/src/InputReader.phpβ ANSI parsing, not PTY plumbingcandy-vt/β VT-state machine, not PTY plumbingcandy-vcr/β recording semantics, not PTY plumbing
- EINTR + deadline read loop β
candy-pty/src/Pty.phpread()body. Lift verbatim intoPosixMasterPty::read(). - SIGWINCH forwarder β
candy-pty/src/SignalForwarder.php. Use as-is. - Controlling-terminal shim β
candy-pty/bin/pty-shim.php. Use as-is. - proc_get_status polling β
candy-pty/src/Child.phpexited()/wait(). Move intoChildPollTrait. php://fd/Nstream wrapping βcandy-pty/src/Pty.phpstream()cache. Move intoPosixMasterPty::stream().SizeIoctlLinux/macOS branch βcandy-pty/src/SizeIoctl.php. Keep; addquery()helper.- Libc lazy-load + override β
candy-pty/src/Libc.php. Keep; extend cdef.
End-to-end smoke (manual):
cd candy-pty
composer install
vendor/bin/phpunit # unit + integration
php examples/spawn-bash.php # spawn bash interactively, type `echo hi`, see "hi"Termios fallback exercise:
SUGARCRAFT_TERMIOS=stty vendor/bin/phpunit --testsuite=integrationConsumer regression:
for d in candy-pty candy-core candy-shell candy-wish candy-vcr; do
(cd "$d" && composer install --quiet && vendor/bin/phpunit) || { echo "FAIL: $d"; exit 1; }
doneCI matrix:
.github/workflows/ci.yml(auto-discovered viascripts/affected-libs.php) β should already run candy-pty on macOS..github/workflows/pty-matrix.yml(new) β runs integration tests on Linux + macOS, PHP 8.3 + 8.4, with and withoutSUGARCRAFT_TERMIOS=stty.
Parity check (manual, end of P5):
- Open
candy-pty/README.mdfeature table side-by-side with the matrix increack/ptyREADME andnode-ptyREADME. - For any unchecked row in our column, either: implement, justify as "PHP-irrelevant" in the column, or move to deferred-roadmap with a tracking issue.
- FFI termios struct layout differences between glibc / musl / Darwin libc.
struct termiossize differs (glibc=60, Darwin=72) andcfmakerawexists on both but isn't POSIX-mandated. Mitigation: treat the struct as opaque β₯80-byte buffer and only callcfmakeraw/tcgetattr/tcsetattrβ never read individual fields from PHP. Test on alpine (musl) explicitly if shipping container support. ext-ffinot always enabled. Shared hosts, locked-down corporate PHP. Termios fallback addresses raw-mode case. For PTY allocation itself there's no fallback β documentext-ffias a hard requirement, throw a clearUnsupportedPlatformExceptionif missing.pcntl_async_signalsand ReactPHP loop interaction.SignalForwarderdefaults to async; the ReactPHP loop's ownpcntl_signal_dispatchticking may double-handle. Verify withSignalForwarderTeston a ReactPHP-driven scenario before declaring P4 done.- Migration of
candy-wish::InProcessTransportβ the existingSTDIN_EOF_GRACE_SEC/FLUSH_DEADLINE_SECconstants encode subtle SSH protocol assumptions. Keep them as the defaults ofPumpOptionsto avoid behavioural regression; verify the SSH end-to-end suite before merging P2. - macOS CI runner cost. macOS GitHub runners are 10x cost of Linux. P5's integration matrix should be limited to the necessary tests on macOS β not the full suite. Use
markTestSkippedon Linux-only edge cases. - Composer path-repo closure. Adding candy-pty as a dependency of candy-core means every lib that depends on candy-core must also list candy-pty in its
repositories[]. Mechanically tedious but the CLAUDE.md "Gotchas" section explicitly warns about this. Usescripts/affected-libs.phpoutput to enumerate before merge. candy-shell::RealProcessdeletion vs alias. If it'sinternaland zero external callers, prefer deletion. GrepRealProcessacross the monorepo before P3 to decide.- Shirley CLI and host terminal hygiene. The recorder switches the host's stdin into raw mode while a child program runs β if it crashes mid-record, the user is left in a broken terminal. Mitigation: register a
register_shutdown_function+pcntl_signal(SIGTERM)handler that always callsTermiosFactory::open(STDIN)->saved->apply(). Test by killing the recorder with SIGKILL during avimsession and verifying the host shell is usable after. - Idle-trim timestamp semantics.
--idle-trimrewrites event timestamps, which breaks naive byte-exact replay if a downstream consumer expects original timing. Mitigation: store botht(trimmed) andtRaw(original) on the event when--idle-trimwas active;PlayerhonorstRawif present and--no-trimis passed. Document the trade-off incandy-vcr/README.md.
- Windows ConPTY backend. Implement as
SugarCraft\Pty\Windows\ConPtySystemusing FFI tokernel32.dll(CreatePseudoConsole,ResizePseudoConsole,ClosePseudoConsole,CreateProcessWwithSTARTUPINFOEXWextended attributes). Threading model TBD β ConPTY's input/output pipes are blocking, may need to be polled via async ReadFile/WriteFile or run on a sidecar process. Tracked inplans/x-windows.md. - Sidecar binary fallback for Windows. Small Go program using
creack/pty-equivalent orptycrate, talks to PHP over stdio framed messages. Same interface as FFI backend. Avoids ConPTY threading entirely. Cost: Composer post-install download of a per-OS binary. - PECL extension.
php_ptyextension that wrapsforkpty(3)/openpty(3)natively, skipping FFI. Best perf, deployment burden. Likely never necessary if FFI continues to work. - AIX / Solaris / OpenBSD / FreeBSD ioctl constants. Currently only Linux + Darwin in
SizeIoctl.phpand the plannedPosixTermios. Each BSD has slightly different ioctl numbers. Pull fromnixcrate'sioctl.rsper-platform files when a user reports needing one. - Pty multiplexer / tmux-style sessions.
MultiPumpfrom P6, promoted to a first-class API ifcandy-zoneneeds it. - Expect-style scripting API. P6 item, if there's demand from
candy-vcr/candy-skate.