PHPackages                             sugarcraft/candy-pty - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [CLI &amp; Console](/categories/cli)
4. /
5. sugarcraft/candy-pty

ActiveLibrary[CLI &amp; Console](/categories/cli)

sugarcraft/candy-pty
====================

PHP port of charmbracelet/x/xpty — pseudo-terminal abstraction (open / spawn / resize) for SugarCraft. Linux + macOS via FFI to libc.

013.7k↑2193.8%PHP

Since Jun 29Pushed 1mo agoCompare

[ Source](https://github.com/sugarcraft/candy-pty)[ Packagist](https://packagist.org/packages/sugarcraft/candy-pty)[ RSS](/packages/sugarcraft-candy-pty/feed)WikiDiscussions master Synced 3w ago

READMEChangelogDependenciesVersions (1)Used By (0)

candy-pty
=========

[](#candy-pty)

[![Tests](https://github.com/detain/sugarcraft/actions/workflows/ci.yml/badge.svg)](https://github.com/detain/sugarcraft/actions/workflows/ci.yml)[![Coverage](https://camo.githubusercontent.com/159d75a0ae95e555f948e8d0be087e6708d250d16a9a45002331289ed04a4da0/68747470733a2f2f636f6465636f762e696f2f67682f64657461696e2f737567617263726166742f6272616e63682f6d61737465722f67726170682f62616467652e7376673f666c61673d63616e64792d707479)](https://codecov.io/gh/detain/sugarcraft)[![License](https://camo.githubusercontent.com/7013272bd27ece47364536a221edb554cd69683b68a46fc0ee96881174c4214c/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d626c75652e737667)](../LICENSE)

PHP port of [`charmbracelet/x/xpty`](https://github.com/charmbracelet/x/tree/main/xpty) — the pseudo-terminal primitive Charm uses to drive child processes inside their TUIs. Open a master/slave PTY pair, spawn a child with its stdio wired to the slave, pump bytes between the host and the child, and forward host resizes into the child via `TIOCSWINSZ`.

**Status**: Linux + macOS. Windows ConPTY is a separate concern tracked in `plans/x-windows.md`.

Install
-------

[](#install)

```
composer require sugarcraft/candy-pty
```

Requires PHP 8.1+ with `ext-ffi`. `ext-pcntl` is optional — the lib polls `waitpid()` when pcntl is absent and `SignalForwarder` degrades to a no-op.

Quickstart
----------

[](#quickstart)

```
use SugarCraft\Pty\Pty;

$pty   = Pty::open();
$child = $pty->spawn(
    ['/bin/bash', '-c', 'echo $TERM; uname -s; date'],
    ['TERM' => 'xterm-256color'],
    100, 30,                              // cols × rows
);

$pty->setBlocking(false);
$out = '';
while (!$child->exited()) {
    $chunk = $pty->read(4096, 0.05);     // 50 ms timeout
    if ($chunk === null || $chunk === '') continue;
    $out .= $chunk;
}
$exit = $child->wait();
$pty->close();

echo $out;
```

### DI-friendly (preferred for libraries)

[](#di-friendly-preferred-for-libraries)

For consumers that want to stay decoupled from the POSIX backend (useful in tests + when a Windows ConPTY sidecar lands in v2), resolve the `PtySystem` through the factory:

```
use SugarCraft\Pty\PtySystemFactory;

$system = PtySystemFactory::default();   // throws UnsupportedPlatformException on Windows
$pair   = $system->open(100, 30);
$master = $pair->master();
$child  = $pair->slave()->spawn(['/bin/bash', '-l'], null, 100, 30, controllingTerminal: true);
```

Tests can swap in a stub `PtySystem` without touching libc.

API at a glance
---------------

[](#api-at-a-glance)

CallWhat it does`Pty::open(): Pty`On Darwin: `openpty()` first (single call), falls back to quartet on -1. On Linux: `posix_openpt + grantpt + unlockpt + ptsname_r`. Returns a Pty exposing `master` (readonly fd + slavePath).`$pty->spawn(array $cmd, ?array $env, int $cols=80, int $rows=24, bool $controllingTerminal=false): Child``proc_open` with slave-path descriptors + initial TIOCSWINSZ. Pass `controllingTerminal: true` to route through `bin/pty-shim.php` so the child claims the slave PTY as its ctty (Ctrl+C → SIGINT, job control); requires ext-pcntl.`$pty->read(int $len=8192, ?float $timeout=null): ?string``null` on timeout, `''` on EOF, bytes otherwise. EINTR-safe.`$pty->write(string $bytes): int`Returns bytes written.`$pty->setBlocking(bool $blocking): void`Toggles non-blocking mode on the master fd.`$pty->resize(int $cols, int $rows): void`TIOCSWINSZ on the master fd.`$pty->size(): array{cols,rows,xpix,ypix}`TIOCGWINSZ readback.`$pty->stream(): resource`Cached `php://fd/` wrapper around the master fd for direct PHP-stream use.`$pty->close(): void`Idempotent. Routes through `fclose` if `stream()` was materialised, else `close(2)` via FFI.`$child->pid: int`OS process id.`$child->wait(): int`Waits via `waitpid` FFI (sub-ms) first, falls back to 10ms `proc_get_status` poll. Returns exit code (signal death = 128+signal). Idempotent.`$child->exited(): bool`Non-blocking probe.`ControllingTerminal::claim(int $fd): void`Calls `setsid()` + `ioctl(TIOCSCTTY)` to claim `$fd` as the session's controlling terminal. Used internally by the `controllingTerminal: true` shim path; callable directly from FFI-heavy contexts that want the same effect without the PHP shim. Throws `PtyException` on failure.Non-PTY processes
-----------------

[](#non-pty-processes)

For child processes that don't need a PTY (e.g. a sub-step in a spinner overlay), `PosixProcess` shares the same lifecycle (`pid/exited/wait/exitCode/kill`) but binds stdin to `/dev/null`and lets you capture stdout/stderr into in-memory buffers:

```
use SugarCraft\Pty\Posix\PosixProcess;

$proc = PosixProcess::spawn(
    ['/bin/sh', '-c', 'echo out; echo err >&2'],
    env: null,
    captureStdout: true,
    captureStderr: true,
);
$exit = $proc->wait();
echo $proc->stdoutBytes();   // "out\n"
echo $proc->stderrBytes();   // "err\n"
```

When a capture flag is `false`, the corresponding stream is inherited from the parent's `STDOUT` / `STDERR` and the matching `*Bytes()` accessor returns `''`.

Resize forwarding
-----------------

[](#resize-forwarding)

```
use SugarCraft\Pty\SignalForwarder;
use SugarCraft\Core\Util\Tty;

SignalForwarder::attachSigwinch(
    $pty,
    fn () => Tty::size(),                 // returns ['cols' => N, 'rows' => N]
);
// Now every host SIGWINCH triggers $pty->resize($cols, $rows).
```

For callers that hold a raw `/dev/tty` fd (no `MasterPty` object), `attachSigwinchToFd()` calls `TIOCSWINSZ` directly on the fd:

```
use SugarCraft\Pty\SignalForwarder;
use SugarCraft\Pty\Libc;

$ttyFd = Libc::lib()->open('/dev/tty', 0x0002);   // O_RDWR
SignalForwarder::attachSigwinchToFd(
    $ttyFd,
    fn () => Tty::size(),
    fn (int $cols, int $rows) => null,     // optional post-resize callback
);
```

Both methods default to `pcntl_async_signals(true)` so handlers fire between PHP opcodes; pass `async: false` if your event loop already polls `pcntl_signal_dispatch()` itself.

Recording sessions (Recorder tap)
---------------------------------

[](#recording-sessions-recorder-tap)

`PumpOptions` accepts an optional `SugarCraft\Core\Recorder` — when set, `PosixPump` tees stdin chunks (`recordInputBytes`) and master-read chunks (`recordOutput`) into the recorder on the same loop iteration as the read. Null = zero overhead.

```
use SugarCraft\Pty\Posix\PosixPump;
use SugarCraft\Pty\PumpOptions;
use SugarCraft\Vcr\Recorder;

$recorder = Recorder::open('/tmp/session.cas');
$opts     = (new PumpOptions())->withRecorder($recorder);
$exit     = (new PosixPump())->run($master, STDIN, STDOUT, $child, $opts);
$recorder->recordQuit();
$recorder->close();
// /tmp/session.cas can now be walked via SugarCraft\Vcr\Format\JsonlFormat
// or driven through SugarCraft\Vcr\Player::play() against a candy-core Program.
```

Resize events still need a separate `SignalForwarder` callback that chains into `$recorder->recordResize($cols, $rows)` — the pump itself stays clear of SIGWINCH detection so it can be reused for non-interactive recordings.

Multi-pump
----------

[](#multi-pump)

`MultiPump` demuxes multiple PTY sessions from a single `stream_select` loop — a split-pane viewer, parallel test harness, or tmux-style supervisor use-case:

```
use SugarCraft\Pty\PtySystemFactory;
use SugarCraft\Pty\Posix\MultiPump;

$system = PtySystemFactory::default();
$mp     = new MultiPump();

// Register two shells and tee each master to stdout with a prefix.
foreach ([0, 1] as $idx) {
    $pair   = $system->open(80, 24);
    $prefix = "[session-{$idx}] ";
    $sink   = fopen('php://memory', 'w+b');

    $child = $pair->slave()->spawn(
        ['/bin/bash', '-i'],
        ['TERM' => 'xterm-256color'],
        80, 24,
        controllingTerminal: true,
    );

    $mp->add($pair->master(), $sink, $child);
}

// Drive the multiplexer until every session exits.
while (!$mp->allDone()) {
    $drained = $mp->tick();
    if ($drained > 0) {
        // Drain each per-session sink, tag with prefix, print.
    }
}
```

See [`examples/multi-pump.php`](examples/multi-pump.php) for the full interactive demo — two shells teed to stdout with per-session prefixes, quit on Ctrl-C.

Pump callbacks
--------------

[](#pump-callbacks)

`PumpOptions` exposes four optional callbacks covering the pump loop lifecycle, plus a `sshDefault()` named constructor for SSH-session-tuned behaviour:

CallbackFires whenUse for`keepalive`idle tick (stream\_select → 0)Legacy heartbeat alias; prefer `onIdle``onIdle` (step 01.03)idle tick — every `stream_select` timeoutKeepalive pings, polling housekeeping, any periodic task`onSigwinch`terminal dimensions changeForward host resize into child's TIOCSWINSZ`onChildExit`child process exitsResource cleanup, notification`PumpOptions::sshDefault(): self` — SSH-session-tuned preset. Returns a `PumpOptions` with the same defaults (`chunkBytes`, `selectTimeoutUs`, `flushDeadlineSec`, `stdinEofGraceSec`, `veof`) previously hardcoded in `InProcessTransport`. Use when you need SSH-session behaviour outside the `candy-wish` transport layer:

```
$opts = PumpOptions::sshDefault()->withOnIdle(fn () => error_log('alive'));
```

**`onIdle` vs `onSigwinch`:** These are independent hooks. `onIdle` fires on *every* idle tick (no I/O ready). `onSigwinch`fires only when `MasterPty::size()` differs from the last known size — driven by the consumer's `SignalForwarder` callback. Do not conflate them: if you were passing `onSigwinch(0, 0)` as a fake idle tick, migrate to `onIdle` (step 01.03).

```
use SugarCraft\Pty\Posix\PosixPump;
use SugarCraft\Pty\PumpOptions;

$opts = (new PumpOptions())
    ->withOnIdle(fn () => error_log('still alive'))        // every ~50 ms
    ->withOnSigwinch(fn (int $cols, int $rows) => null)   // on dimension change
    ->withOnChildExit(fn (int $code) => error_log("exited $code"));

(new PosixPump())->run($master, STDIN, STDOUT, $child, $opts);
```

Examples
--------

[](#examples)

- [`examples/spawn-bash.php`](examples/spawn-bash.php) — The simplest end-to-end slice: `PtySystemFactory::default()->open()` → `$pair->slave()->spawn(['bash', ...])` → drain master → reap. Start here.
- [`examples/pump-output.php`](examples/pump-output.php) — Long-running counter; demonstrates non-blocking read with timeout, line-by-line pumping.
- [`examples/resize-forwarding.php`](examples/resize-forwarding.php) — Wire `SignalForwarder` to deliver host SIGWINCH into the child PTY's TIOCSWINSZ; observe the child's `tput cols / lines` flip mid-stream.

Library lookup
--------------

[](#library-lookup)

Defaults: `libc.so.6` on Linux, `/usr/lib/libSystem.B.dylib` on macOS. Override via the `SUGARCRAFT_LIBC` env var for unusual setups (musl, Alpine, custom sysroots).

Mirrors
-------

[](#mirrors)

Charm symbolcandy-pty`xpty.Open()``Pty::open()``xpty.Pty.Start(cmd)``Pty::spawn(cmd, env, cols, rows)``xpty.Pty.Read(buf)``Pty::read($len, $timeout)``xpty.Pty.Write(buf)``Pty::write($bytes)``xpty.Pty.Resize(cols, rows)``Pty::resize(cols, rows)``xpty.Pty.Size()``Pty::size()``signalpty.NotifyResize(c, pty)``SignalForwarder::attachSigwinch($pty, $sizeProvider)`*fd-based variant*`SignalForwarder::attachSigwinchToFd($fd, $sizeProvider)``xpty.claimControllingTerminal``ControllingTerminal::claim(int $fd)`Compared to node-pty / creack/pty / portable-pty
------------------------------------------------

[](#compared-to-node-pty--creackpty--portable-pty)

Cross-ecosystem parity table for the dominant PTY libraries in Go, Rust, and Node.js. The goal is "as good as `creack/pty` on Linux and macOS" — not a kitchen-sink port. This table is deliberately honest about gaps: Windows ConPTY, foreground-job control, and worker-thread support are flagged as planned-or-missing rather than papered over.

Featurecandy-ptycreack/pty (Go)portable-pty (Rust)node-pty (Node.js)Open / close PTY pair✅ `PtySystemFactory::default()->open()`✅ `pty.Open()`✅ `native_pty_system().openpty()`✅ `pty.spawn()`Master read✅ `MasterPty::read($len, $timeout)`✅ `Pty.Read([]byte)`✅ `MasterPty::try_clone_reader()`✅ `pty.onData()`Master write✅ `MasterPty::write($bytes)`✅ `Pty.Write([]byte)`✅ `MasterPty::take_writer()`✅ `pty.write()`Resize (TIOCSWINSZ)✅ `MasterPty::resize($cols, $rows)`✅ `pty.Setsize()`✅ `MasterPty::resize()`✅ `pty.resize()`Get size (TIOCGWINSZ)✅ `MasterPty::size()`✅ `pty.GetsizeFull()`✅ `MasterPty::get_size()`⚠️ via `cols`/`rows` propsSlave device path✅ `SlavePty::path()`✅ `Pty.Name()`✅ `SlavePty::as_raw_fd()`❌ hiddenChild spawn on slave✅ `SlavePty::spawn($cmd, $env, ...)`✅ `pty.Start(cmd)`✅ `SlavePty::spawn_command()`✅ `pty.spawn(file, args)`Controlling terminal (TIOCSCTTY)✅ opt-in via `controllingTerminal: true`✅ implicit in `Start()`✅ implicit in `spawn_command()`✅ implicitTermios raw mode✅ `Termios::makeRaw()` (FFI + stty fallback)✅ via `golang.org/x/term`✅ `Termios` in `nix` crate⚠️ caller's responsibilityTermios get / restore✅ `Termios::current()` / `restore()`✅ `term.MakeRaw()` / `Restore()`✅ `Termios::set_termios()`❌ caller's responsibilitySIGWINCH forwarding✅ `SignalForwarder::attachSigwinch()`✅ `pty.InheritSize()`⚠️ caller wires signal handler✅ implicitExit code retrieval✅ `Child::wait()` / `exitCode()`✅ `cmd.Wait()` / `ProcessState`✅ `Child::wait()`✅ `pty.onExit()`Signal injection (SIGINT/TERM/KILL)✅ `Child::kill($signal)`✅ `cmd.Process.Signal()`✅ `Child::kill()`✅ `pty.kill(signal)`EOF / VEOF handling✅ `PosixPump` writes VEOF on stdin EOF⚠️ caller drives termios⚠️ caller drives termios⚠️ caller writes `\x04`Non-blocking master I/O✅ `setBlocking(false)` + `stream_select`✅ `os.File` non-blocking✅ `set_nonblocking()`✅ event-drivenByte pump abstraction✅ `PosixPump::run()` w/ EOF grace + keepalive❌ caller copies bytes❌ caller copies bytes✅ libuv-drivenAsync / threaded operation⚠️ single-loop pump only (ReactPHP-friendly)✅ goroutines built-in✅ thread / async runtime✅ libuv worker threadWindows ConPTY❌ planned (v2 sidecar; see `plans/x-windows.md`)❌ Linux/macOS only✅ `ConPtySystem`✅ `winpty` / ConPTYDependency-free DI seam✅ `Contract\PtySystem` interface❌ concrete `*Pty` only✅ `PtySystem` trait❌ concrete bindingsLegend: ✅ shipping today · ⚠️ partial / opt-in / caller-driven · ❌ not implemented. Method names cite real upstream symbols — see `docs/CONCEPTS.md` for the porting rationale behind each row.

Controlling terminal (Ctrl+C, job control)
------------------------------------------

[](#controlling-terminal-ctrlc-job-control)

Pass `controllingTerminal: true` to `spawn()` when you need `Ctrl+C` typed at the master to deliver `SIGINT` to the child — required for interactive shells (`bash -i`), editors (`vim`, `less`), and anything else that uses tty-driven job control.

```
$child = $pty->spawn(
    ['/bin/bash', '-i'],
    env: [...],
    controllingTerminal: true,    // claim slave as the child's ctty
);
$pty->write("\x03");              // Ctrl+C → SIGINT to the child
```

Routes the spawn through `bin/pty-shim.php`, which does `setsid()` + `ioctl(0, TIOCSCTTY, 0)` + `pcntl_exec()` between `proc_open` and the actual cmd. The `setsid()` + `ioctl(TIOCSCTTY)` logic is also available directly as `ControllingTerminal::claim(int $fd)` — usable from FFI-heavy contexts that want the same effect without the shim overhead. Requires `ext-pcntl`. Costs ~5-50 ms of shim startup per spawn — opt-in because non-interactive spawns (`echo`, `tput`, `bash -c '…'`) don't benefit.

Architecture
------------

[](#architecture)

### Backend selection

[](#backend-selection)

`PtySystemFactory::default()` respects the `SUGARCRAFT_PTY_BACKEND`environment variable to select which PTY backend to use:

ValueBehaviour*(unset)* / `auto`Platform-appropriate default (same as `posix-ffi` on POSIX)`posix-ffi``PosixPtySystem` — FFI into libc `posix_openpt` etc. (Linux / macOS)`sidecar`Not implemented in v1 — throws `UnsupportedPlatformException` (deferred to phase 12)`pecl`Not implemented in v1 — throws `UnsupportedPlatformException` (deferred to phase 12)Unrecognised values throw `\InvalidArgumentException` naming the valid options.

The termios backend follows the same pattern via `SUGARCRAFT_TERMIOS`(`posix-ffi` / `stty`).

### Platform behaviour — `openpty` vs quartet

[](#platform-behaviour--openpty-vs-quartet)

On macOS (Darwin), `PosixPtySystem::openPtyMaster()` attempts `openpty(3)`first — a single libc call that opens both master and slave and returns both file descriptors. If it returns -1 (symbol unavailable on a given Darwin build), the code falls back to the standard POSIX quartet: `posix_openpt + grantpt + unlockpt + ptsname_r`.

On Linux, the quartet is used directly. The `openpty` symbol on Linux lives in `libutil.so.1` rather than `libc.so.6`; `Libc::libutil()` loads it as a fallback specifically for this symbol. Most Linux systems have both, so the Darwin-first path is not normally exercised on Linux.

This behaviour is internal to `PosixPtySystem` and requires no consumer action — `Pty::open()` abstracts it away completely.

### Library layout

[](#library-layout)

The library is organised in two layers:

### Contract interfaces (`src/Contract/`) — pure signatures, no logic

[](#contract-interfaces-srccontract--pure-signatures-no-logic)

ContractUpstream mirrorUpcoming POSIX implementation`PtySystem``portable-pty.PtySystem``PosixPtySystem``PtyPair``portable-pty.PtyPair``PosixPtyPair``MasterPty``creack/pty.Pty` / `portable-pty.MasterPTY``PosixMasterPty``SlavePty``creack/pty.Pty` / `portable-pty.SlavePty``PosixSlavePty``Child``creack/pty.Cmd` / `portable-pty.Process``PosixChild``Process``creack/pty.Cmd` (non-PTY spawn)`PosixProcess``Termios``portable-pty.Termios``PosixTermios` / `SttyTermios``Pump``candy-wish.InProcessTransport``PosixPump`### POSIX implementation (`src/Posix/`) — implementation layer

[](#posix-implementation-srcposix--implementation-layer)

The `Posix*` classes implement the contracts above using Linux/macOS syscalls: FFI into libc for `posix_openpt/grantpt/unlockpt/ptsname_r`, `proc_open`for child spawning, `ioctl(TIOCSWINSZ/TIOCGWINSZ)` for resize, and `FFI::cdef()` tcgetattr/tcsetattr/cfmakeraw for termios. The factory `TermiosFactory` selects `PosixTermios` (FFI) when `ext-ffi` is available and falls back to `SttyTermios` (shell-out `stty`) otherwise.

Shared foundations
------------------

[](#shared-foundations)

candy-pty builds on two ports for input/output escape-sequence handling:

- **[candy-input](https://github.com/detain/sugarcraft/tree/master/candy-input)** — `EscapeDecoder` consumes raw bytes from the PTY master and emits keyboard/mouse/osc events. The pty-read loop feeds `Pty::read()` chunks directly into `EscapeDecoder`; no private arrow-key or Ctrl-key buffering.
- **[candy-ansi](https://github.com/detain/sugarcraft/tree/master/candy-ansi)** — `Parser` handles ANSI escape sequences in PTY output when inspection is needed (cursor-position reports, SGR state, OSC strings). PTY output that only needs pass-through to the terminal skips this layer entirely.

Known limitations
-----------------

[](#known-limitations)

- **Linux + macOS only.** Windows ConPTY is a separate port.

License
-------

[](#license)

MIT — see [LICENSE](../LICENSE).

###  Health Score

27

—

LowBetter than 46% of packages

Maintenance59

Moderate activity, may be stable

Popularity27

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity11

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

### Community

Maintainers

![](https://www.gravatar.com/avatar/b1036e0717211b8030b83cbe729e8ba6ba442fdbd5285fb97a39d7dcfe339342?d=identicon)[detain](/maintainers/detain)

---

Top Contributors

[![detain](https://avatars.githubusercontent.com/u/1364504?v=4)](https://github.com/detain "detain (81 commits)")

### Embed Badge

![Health badge](/badges/sugarcraft-candy-pty/health.svg)

```
[![Health](https://phpackages.com/badges/sugarcraft-candy-pty/health.svg)](https://phpackages.com/packages/sugarcraft-candy-pty)
```

###  Alternatives

[illuminate/console

The Illuminate Console package.

13046.0M6.8k](/packages/illuminate-console)[styleci/cli

The CLI tool for StyleCI

71470.5k9](/packages/styleci-cli)[winbox/args

Windows command-line formatter

20720.9k21](/packages/winbox-args)[mallardduck/laravel-traits

A collection of useful Laravel snippets in the form of easy to use traits.

136.2k2](/packages/mallardduck-laravel-traits)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
