Conversation
docs/parity.md lists every surface a drop-in replacement for the Node pty must carry: commands and flags, the socket protocol, the registry on disk, terminal emulation, remote access, the testing library, the TUI, and the embedding API. For each it says what this port has today, what is missing, and a rough size. It also lists who depends on pty in our network, the items that could be left off, and the work in flight on both repositories that affects parity. Claude-Session: https://claude.ai/code/session_017R4j2MS6pBwt1jSHGuzN3k
Section 12 now states what is dropped and what is deferred: gc respawn, flapping, and abandoned reap; recover; evidence; pty test; the socket form of remote-serve; the legacy positional display name. The version string, the TUI base, and the engine of the TypeScript testing package are decided. Claude-Session: https://claude.ai/code/session_017R4j2MS6pBwt1jSHGuzN3k
docs/parity-plan.md turns docs/parity.md into work: a six-crate workspace, a conformance suite that runs against any pty binary, thirteen work packages with done conditions, the order they run in, and the cutover steps with a rollback at each one. Claude-Session: https://claude.ai/code/session_017R4j2MS6pBwt1jSHGuzN3k
The pty-testkit crate held everything: the wire protocol, the registry, the libghostty screenshot code, the test Session, the daemon, and the CLI. The parity work (docs/parity-plan.md, WP1) needs those pieces as separate crates so that later packages can be built in parallel and so a consumer can use the protocol and registry without a Zig toolchain. New layout under crates/, all sharing version 0.13.0-rust, edition 2024, rust-version 1.88 from [workspace.package]: - pty-core: protocol, registry, client, stats, keys, paste, duration, input, queries, ptyfile. No libghostty dependency, so `cargo build -p pty-core` needs no zig. - pty-terminal: screenshot capture and replay serialization on libghostty. The terminal actor and handle come later (WP4). - pty-testkit: Session, SpawnOptions, build_spawn_env; re-exports Screenshot. The demo example moves here. - pty: the binary. main.rs dispatches on argv, cli/mod.rs holds the command functions (still one file; per-command split is WP7), daemon/mod.rs is the per-session daemon. - pty-tui and pty-conformance: empty skeletons with a doc comment pointing at their work packages. Source files move with `git mv` and are otherwise unchanged except for the crate paths they import. Tests move to the crate that owns the code they exercise; tests/terminal_queries.rs is cut in two (the pure strip_terminal_queries half goes to pty-core, the Session-driven response half to pty-testkit). The e2e and fixture suites stay with the binary because they rely on CARGO_BIN_EXE_pty. The shared fixtures under tests/fixtures/parity stay at the repository root and the loader resolves them relative to the crate manifest. The one behavior change is the version string. `pty version` now prints `0.13.0-rust+<short-sha>` (docs/parity.md section 14), stamped by crates/pty/build.rs from `git rev-parse --short HEAD`, or from PTY_BUILD_SHA when set (nix builds have no .git), or `unknown`. The e2e test that pinned a bare semver now checks that shape. README: the Layout and Build requirements sections describe the workspace and the correct toolchain (edition 2024, Rust 1.88 or newer). Claude-Session: https://claude.ai/code/session_017R4j2MS6pBwt1jSHGuzN3k
The pty-conformance crate now has a Rig: a temp PTY_ROOT under /tmp/pc-*, a scrubbed environment, CLI runners with a 30 s timeout, a tty runner via pty-testkit, a daemon launcher, and a raw socket client for the wire protocol. The binary under test comes from PTY_TEST_BIN (default: this workspace's target/<profile>/pty). First suites ported from the Node tests: help (top-level usage), version, and pty-root (root precedence, notices, --root, the root length backstop). All green against the Node pty. Claude-Session: https://claude.ai/code/session_017R4j2MS6pBwt1jSHGuzN3k
Thirty-one display-name cases (auto names, --id/--name validation, rename, lookup by display name, the ambiguity guard across every verb, long labels, restart preserving labels and tags) and eighteen list-filters cases (vanished inference, list as a pure read, --status, age filters, --summary, sort order). The harness gained ISO timestamps and a daemon-less metadata writer, and teardown no longer signals the test process when a test has recorded its own pid. Claude-Session: https://claude.ai/code/session_017R4j2MS6pBwt1jSHGuzN3k
tags (16), tags-helpers CLI half (9), send-paste (12), seq-delay (2), stats-cli (7), and kill-wait (3), all green against the Node pty. The send suites read the exact bytes back from a session running 'stty raw -echo; cat > dump'. Claude-Session: https://claude.ai/code/session_017R4j2MS6pBwt1jSHGuzN3k
spawn-options CLI half (10), nesting (6), nesting-prevention (15). The harness can now run a command with a short timeout for clients that are expected to block on an attach without a tty. Claude-Session: https://claude.ai/code/session_017R4j2MS6pBwt1jSHGuzN3k
exit-reap policy half (18), rm-kill-ephemeral (10), rm-immediate-reuse CLI half (1). The daemon launcher now treats metadata with an exitCode as published, so commands that exit at once do not stall the wait on a socket the daemon already removed. Claude-Session: https://claude.ai/code/session_017R4j2MS6pBwt1jSHGuzN3k
restart-launch-parity (4), restart-env-scrub (2), restart-guardrail (4), all green against the Node pty. The guardrail suite puts a claude shim on PATH so the 'claude --resume' case never launches a real agent. Claude-Session: https://claude.ai/code/session_017R4j2MS6pBwt1jSHGuzN3k
tag-mutate (8) and the CLI half of metadata-events (24): metadata patch over JSON stdin/stdout, the display_name_change and tags_change events from rename and tag, and the event text rendering through events --recent. Claude-Session: https://claude.ai/code/session_017R4j2MS6pBwt1jSHGuzN3k
tag-bulk (29) and tag-multi (37), green against the Node pty. Claude-Session: https://claude.ai/code/session_017R4j2MS6pBwt1jSHGuzN3k
`pty help`, `pty --help`, and `pty -h` now print the top-level usage text of the Node pty at 500eab2, byte for byte, and `pty completions <shell>` prints the fish, bash, or zsh completion script copied from that repository's completions/ directory. The texts are fixtures under crates/pty/tests/fixtures/help/, captured by running the Node binary, so nothing is generated or reformatted; the binary embeds them with include_str!. help.rs exposes usage(), command_help(cmd) with the a/ls/remove aliases, tag-multi's parser help, and the evidence leaf helps for the dispatcher to use once it intercepts `pty <cmd> --help`; those per-command tests are in place but ignored until then. The deferred commands (recover, evidence, test) keep their Node help unchanged. completions.rs follows the Node command exactly: --help/-h prints the usage to stdout and exits 0, no shell prints it to stderr and exits 2, an unknown shell adds an "unknown shell" line first. The tests hold the output to the vendored files, check the exit codes, and parse each script with its shell when that shell is installed. The old hand-written help in cli/mod.rs is gone with it. Claude-Session: https://claude.ai/code/session_017R4j2MS6pBwt1jSHGuzN3k
`nix build` produces the pty binary with the three completion files installed, `nix flake check` also runs the workspace tests and proves the installed completions and the usage text are what the binary prints, and `nix develop` gives a shell with the Rust toolchain and Zig. libghostty-vt-sys clones Ghostty and lets Zig fetch Ghostty's packages while cargo builds, which a sandboxed build cannot do. The flake fetches both up front as fixed-output derivations and hands them to the build script through GHOSTTY_SOURCE_DIR and GHOSTTY_ZIG_SYSTEM_DIR. The Zig packages come from Ghostty's own fetch-zig-cache.sh because `zig build --fetch` skips transitive dependencies. The version stamp comes from the flake's commit through PTY_BUILD_SHA, since a flake source has no .git. The tests run under a short TMPDIR because a unix socket path is capped at 108 bytes, and with bashInteractive on PATH because the line-editing tests need readline. nixpkgs is locked to the same revision st2 uses. Claude-Session: https://claude.ai/code/session_017R4j2MS6pBwt1jSHGuzN3k
Describe what pty is for a newcomer, how to install it with Nix or Cargo, the six crates, the build requirements (Rust 1.88 with edition 2024, Zig 0.15.2, and the Ghostty fetch on the first build), how to run the tests, and which Node commands this build does not carry. The direction section stays as it was. Claude-Session: https://claude.ai/code/session_017R4j2MS6pBwt1jSHGuzN3k
events-emit CLI half (8) and peek-wait (10), green against the Node pty. Claude-Session: https://claude.ai/code/session_017R4j2MS6pBwt1jSHGuzN3k
up-down (25) and up-name-decouple (6), green against the Node pty. Claude-Session: https://claude.ai/code/session_017R4j2MS6pBwt1jSHGuzN3k
list-purity CLI half (7): list creates and deletes nothing, gc --dry-run is non-mutating, gc cleans stale sessions and raw debris unless a creation lock or a reachable socket guards the name. Claude-Session: https://claude.ai/code/session_017R4j2MS6pBwt1jSHGuzN3k
Replace the single registry.rs with a module directory that follows src/sessions.ts rule for rule, so Rust and Node daemons and CLIs can share one $PTY_ROOT: - metadata: every Node field (generation, daemonPid, recovery kept opaque, rows, cols, ephemeral, isolateEnv, extraEnv, unsetEnv, env, createdAt with milliseconds, lastAttachAt, ...) plus a flattened map so unknown fields survive a rewrite. Publication writes use the daemon's key order; rewrites go through the parsed object so a Node-written file keeps its key order and only the touched keys move. Tags and env maps keep insertion order (serde_json preserve_order, IndexMap). - atomic writes: <path>.tmp.<pid>.<16 hex> plus rename, unlinked on failure; readers skip names containing ".tmp.". The old .json.tmp and .pid.tmp scheme is gone. - locks: O_CREAT|O_EXCL with the holder pid, one stale steal, unlink to release; the event lock with its 5 s waiting variant and the exact "event log is busy" / "metadata is busy" texts; both locks always taken event first, then creation. - mutate_metadata_under_lock with busy / missing / generation-mismatch / stale / unchanged / changed, and the presentation patches (metadata patch, rename, tag) that emit metadata_change / display_name_change / tags_change with Node's payloads and never on a no-op. - list_sessions per Node: .sock scan first, orphan .json second, daemonPid trusted only with a matching process start token, dead pids probed by a concurrent socket connect under one 500 ms budget, running / exited / vanished exactly as Node, sorted by name, never mutating. get_session with the ambiguity text, all_session_names, reap and wait helpers. - names, tags, cleanup (generation-checked owned cleanup), and the root resolution with the PTY_SESSION_DIR notices and the root length check. - the event-log append primitive the patches need (envelope, retention, lock protocol), with the follower and the rest of the log to follow. The <name>.screen sidecar and FinalScreen are removed: post-exit peek will come from lastLines when the client is rewritten, so client::peek now returns the connect error, one end-to-end test is ignored and one shared fixture is skipped until then. SessionInfo now carries Node's shape (pid, status, optional metadata); the two CLI call sites are adapted. Tests port the Node lock, atomic-write, metadata-events, display-name and list-liveness cases, and round-trip a file written by the Node daemon. Claude-Session: https://claude.ai/code/session_017R4j2MS6pBwt1jSHGuzN3k
The events module now matches src/events.ts end to end: the
{session, type, ts, ...payload} envelope with ts as an ISO-8601 string,
typed builders for every system event and user.* events, one-shot appends
that wait up to 5 s for the event lock or fail fast with the busy text,
the daemon's queued EventWriter with flush, retention (1000 lines keeps
the newest 500; the daemon checks every 100 appends, one-shot writers once
the file passes 40000 bytes) as an atomic rewrite, clear_events,
read_recent_events, validate_user_event_type with its four messages, and
format_event with the local HH:MM:SS prefix and Node's bodies.
EventFollower tails one session, a named set, or every log in the root:
existing files start at their end, files that appear while following
replay from offset 0 so session_start is not skipped, and a shrink
restarts at 0. It watches the registry directory with notify and also
polls every 250 ms, so delivery is deterministic in tests.
Tests port tests/events.test.ts and tests/events-emit.test.ts, plus the
formatEvent pins from tests/metadata-events.test.ts.
Claude-Session: https://claude.ai/code/session_017R4j2MS6pBwt1jSHGuzN3k
Replace the single client.rs with a module that reproduces the Node client byte for byte: the interactive attach loop (raw mode only on a tty, ATTACH with the stdout size, Ctrl-\ detach with the 300 ms double-tap window, SIGWINCH to RESIZE, the exact exit/detach texts, the not-found and connection-error mapping), the --attach-stream-fd-v1 machine stream (GEOMETRY-then-SCREEN ordering checks, byte-exact re-framing to the inherited descriptor, empty DETACH on a local detach, truncation and descriptor-failure texts), the --remote reconnect loop with the backoff table and PTY_RECONNECT_MAX_ATTEMPTS, peek (one-shot, follow, --wait with the lastLines fallback and its diagnostics), send framing and pacing, SessionConnection (plus an AsyncConnection behind the optional tokio feature), the 2 s stats query, and the fabric dial + route handshake. The wire protocol gains GEOMETRY (type 10) and drops the ATTACH flag byte that no Node build ever spoke; the oversize-packet message now matches Node's. StatsResult.clients gains the optional connections list and loses geometry_neutral; uptimeSeconds is an integer as in Node. Every operation is tested against a scripted fake daemon on a unix socket, so the tests do not need a real daemon. The old CLI and daemon in crates/pty are adjusted only enough to keep building on the new API. Claude-Session: https://claude.ai/code/session_017R4j2MS6pBwt1jSHGuzN3k
parity-fixtures (screens.json) and parity-shapes (shapes.json) now run against either binary through the rig; with PTY_NODE_CHECKOUT set they also check the vendored fixtures are byte-identical to the Node copies. Claude-Session: https://claude.ai/code/session_017R4j2MS6pBwt1jSHGuzN3k
# Conflicts: # Cargo.lock # crates/pty-core/src/client.rs # crates/pty/src/cli/mod.rs
…nd handle pty-terminal now owns the libghostty terminal end to end instead of only capturing screenshots: - actor.rs: TerminalActor, the single synchronous owner of the terminal. write() tokenizes the child's output, feeds libghostty, answers queries, tracks the mode flags and kitty keyboard stack the daemon replays, and returns the bytes safe to broadcast to clients. - strip.rs: a streaming CSI/OSC tokenizer, so a query split across two PTY reads is still recognised. Keeps DA1/DA2/DSR/XTVERSION and the OSC 10/11/4 colour queries out of the broadcast; also picks out DEC mode set/reset, kitty push/pop, and OSC 9/99/777 notifications. - queries.rs: DA1/DA2/XTVERSION are answered by libghostty through its callbacks (bytes identical to the Node daemon); DSR natively. The colour queries are answered here because libghostty echoes the query's terminator while the Node daemon always replies with ST. - serialize.rs: the SCREEN replay = the Node mode prefix (1049/1000/1002/ 1003/1006/25l/kitty pushes, in Node's order) + libghostty's VT output, plus padding for the trailing blank rows libghostty omits when scrollback exists, and ECH-based background restoration for text-less rows it drops. plain_viewport/plain_full follow the Node peek row selection exactly. - snapshot.rs: CellGrid, the typed cell grid (grapheme, palette index or RGB, attributes, wide/spacer, wrapped flags, cursor, base_y, length) for renderers, with scroll offsets clamped like Node's readCells. - handle.rs: TerminalHandle, a Send + Sync handle over an actor thread that either spawns a child in a PTY or attaches to a session daemon. Every frame carries an attempt id so a reconnect drops anything still in flight from the old socket; readiness is the first SCREEN, not a fixed delay. pty-testkit's Session now runs on the actor, so there is one serializer and one set of query answers (its DA2 expectation moves from libghostty's default to the Node daemon's 382). Two rendering differences that cannot be hidden are recorded under docs/decisions: the VT replay bytes differ from xterm's serializer but restore the same picture (proved by re-parsing the payload), and emoji are two cells wide where xterm-headless made them one. Claude-Session: https://claude.ai/code/session_017R4j2MS6pBwt1jSHGuzN3k
Seven Rust-owned fixtures under crates/pty-conformance/fixtures (bytes and escape sequences split across reads, raw DATA bytes, attach identity across a replacement, late frames from an old socket, framing limits, a slow reader) with loaders that drive the daemon over its socket. Green against the Node pty; the raw-bytes and split-input cases are gated per binary and record what Node does. Claude-Session: https://claude.ai/code/session_017R4j2MS6pBwt1jSHGuzN3k
The handle was written against a protocol that had no GEOMETRY type and no read-only role. It now matches the typed frame and, for a read-only attach, sends PEEK, which is how a read-only client joins a session. Claude-Session: https://claude.ai/code/session_017R4j2MS6pBwt1jSHGuzN3k
conformance-map reads the /// node: comments in the conformance tests, joins them with a classification of all 120 Node suites, and writes docs/conformance.md (with the to-do list for the next pass). Decision 0001 records that Node re-encodes inbound DATA as UTF-8 text while Rust writes the bytes through; 0000 is the template. Claude-Session: https://claude.ai/code/session_017R4j2MS6pBwt1jSHGuzN3k
An independent read of every assertion in the suite that looks for a short common word turned up six that could hold while the behaviour they describe was broken. Each is confirmed, and two were measured rather than reasoned about. - The test that says `kill` stops a session only checked that the listing lacked the word "running". The listing never prints that word for a live session, so a `kill` that did nothing would have passed. It now checks that the session is listed under exited and that nothing is listed as active. - Two tests about a finished session's stats matched the bare word "exited", which appears both in the sentence they mean and in the daemon's own reading block. This is the same mistake the note at the top of that file already describes. They now match the whole sentence. - The test that says `attach --no-restart` reports a vanished session used a session whose id is "vanished-target", so the word came from the id and not from the status. It now matches the whole sentence. - The test that says clearing a display name renders it as null was already satisfied by the rename before it, which writes `display_name -> "old" (was null)`. Measured: both of its checks pass before the clear runs at all. It now matches the clear's own line. - Two `exec` tests matched "Usage" and "not found", which every command's usage line and two different exec errors satisfy. They now match the exact text. All six pass against the Node tool as well, so they describe shared behaviour rather than one implementation's.
One test pins the exact keys the exit write appends, in order. Recording when a session last printed adds a fourth, and this child printed and exited inside the one-second window, so the exit write carried the stamp itself. The conformance map is regenerated: 646 tests across the same 120 Node suites.
The plan's status said only that it was approved. It now says which work packages are built, what was measured to check them, and what is left. The one thing that must be fixed before this binary runs anywhere that runs evals is written down here too, because it is in a different repository and somebody reading this plan is the person most likely to need to know.
`pty remote-serve --socket` is not implemented here — it prints a usage line naming only `--stdio` and exits 1. The help texts are copied from the Node tool byte for byte so a test can compare them, so `pty remote-serve --help` still describes the socket form. Somebody reading the help and then running the command deserves to find that written down.
The synchronous attach was fixed for this earlier today and its async twin was not. It has the same defect and no timeout of its own, so it spins at full CPU forever rather than for a bounded time. The regression test runs the attach on a real thread and times it from outside the runtime. That is not a style choice. A spin that never awaits anything pending never returns to the scheduler, so nothing inside the runtime can interrupt it: a timeout wrapped around the work is never polled, and moving the work to its own task on a two-worker runtime does not help either. Both were tried against the defect and both hung the test run instead of reporting it.
This file promised that two processes stealing the same stale lock cannot both win. They can, and usually do. Eight threads racing for one stale lock, four hundred rounds: 386 rounds had more than one winner. The steal reads the lock file, decides its owner is dead, then unlinks and creates. A second process making the same decision from the same file unlinks what the first one has already put there. The loser removes the winner's lock and then takes it, and both hold what each believes is exclusive. The Node tool has the same sequence and the same defect, so a shared directory is no worse than either tool alone, and its comment makes the same claim this file did. Nothing is fixed here on purpose. A correct steal needs one exclusive create that only one process can win, which means a second lock file, and that changes a protocol both implementations share. Two tests carry the old belief in their names. Neither establishes it, and the one that races two processes now says so.
`pty peek --wait text --timeout inf` ended with a Rust panic. The value parses as a floating point number and is greater than zero, so it reached the conversion to a duration, which panics on anything that is not finite. `--timeout NaN` did the opposite and was quieter: it failed the same comparison, took the wait-forever branch, and turned a bounded wait into an unbounded one without saying so. Both now wait, which is what the Node tool does with the same input. A finite positive number is still a deadline.
Taking a stale lock means removing it and creating a new one. When the remove failed for any reason other than the file already being gone, the code returned "not acquired", which the caller reports as a busy lock and an instruction to retry. A registry this process may not write is not busy and the retry can never work. That was the one path left where an I/O error still arrived as contention. Every writing command now names the file and the reason, which is what the Node tool does with the same directory.
The acceptor left its loop on any error at all. The daemon stayed up, the child stayed up, and the registry went on saying the session was running, while nothing could ever attach, peek or ask for stats again. Freeing whatever caused it did not bring the acceptor back, because it was gone. A machine short of file descriptors is the ordinary way to reach it, and a descriptor shortage ends. So a failure that can pass is reported and retried after a short wait, and only a listener that cannot work shuts the session down, where somebody can see it. The tests cover the decision, not the loop. Nothing here proves the acceptor keeps serving through a real descriptor shortage; that needs a daemon under a lowered limit and is not written.
`pty exec` took the event lock, then unlinked the lock file by path while its guard was still armed. The guard unlinked again when the function returned, and by then the file at that path could be a lock another process had taken in the meantime. The window is as long as the command `pty exec` runs. `pty run` did the same at four points, with shorter windows. Both now release through the guard, which unlinks once and disarms.
A lock that is not exclusive is the last thing anyone expects, and the people most likely to be caught by this one will never open the hardening notes. So the warning now sits on the crate's front page, on the registry module, on every function that can steal a lock, and in the README beside the other things a reader needs before they start. Each place says the same three things in a sentence or two: stealing a stale lock is not exclusive, the Node tool shares the defect so neither can be relied on, and these locks still do their ordinary job of keeping two live processes apart. The measurement and the interleaving stay in one place. It also says what a correct fix would need — an exclusive create that only one process can win, so a second file in a directory both implementations read, so a protocol change agreed between them. Whoever picks this up later should not have to work that out again.
`pipe2` creates a pipe and sets its flags in one call. macOS has no such system call, so the port did not compile there at all, and this was the only thing stopping one of the two crates that hold the portable code. Where `pipe2` exists it is still used. Where it does not, the pipe is created and the flags are set afterwards with `fcntl`, which is not atomic: between the two calls a fork on another thread inherits descriptors that are not yet close-on-exec. That is the whole reason `pipe2` exists, which is why the atomic call is kept rather than using the fallback everywhere for the sake of one platform. The exposure is small here and it is written down beside the code rather than smoothed over. All four callers now go through one helper, so there is one place to look. The README explains how to catch this class of mistake without a Mac: `cargo check -p pty-core --target aarch64-apple-darwin`. That crate has no Zig dependency by design, so any machine can run it, and it reproduces the failure exactly. It is how this fix was checked.
Three changes, all found by building this on Apple silicon for the first time. The flake needs three more build inputs there, each removing a named failure: without them the Ghostty build script asks for a native libc installation inside a sandbox that has none, and no build flag avoids it because that script constructs the target unconditionally and only gates installing it. They sit behind a darwin guard, so the Linux build is untouched. The SDK version is pinned at the one that was proved to work, with a note not to raise it without building on a Mac. A test asserted that closing a socket with unread data reaches the peer as a reset. Linux does that; Apple silicon ends the stream instead. The test now asks the kernel which it does and pins that answer. Neither pty implementation can report a reset its kernel never delivered — the Node client branches on the same error code — so this is a difference between the machines rather than between the two tools. What it costs a person watching a session is written down beside the test and in the parity map. One test gave a handshake and the process spawn before it a single 200 millisecond budget. A slow spawn spent the whole budget first, so the right failure came back under the wrong name. It never failed here in twenty runs, including under load, which is what a race that is tight everywhere and lucky on one machine looks like. The two ends are now a second and five seconds apart.
Four changes, all from somebody building this on Apple silicon and reporting what went wrong. The check phase had no `ps`. The only two functional platform-specific paths in the whole port both shell out to it, so a darwin build could have passed its checks without ever testing the macOS-specific code — green for the worst possible reason. It now has one. The development shell was missing what the package build already had: a bash with readline, which the line-editing tests drive, and the darwin build inputs without which `cargo test --workspace` on a Mac never reaches the tests at all. `xcbuild` now carries a note saying why it is there, because the reason is not the obvious one. It is the input that makes a plain `zig build` link, and it does that by breaking SDK detection: with it, `xcrun` finds no SDK, so Zig stops looking at the host's and falls back to its own bundled stub. If that ever starts working, this breaks again, and the failure will look like a compiler problem rather than a packaging one. Three test rigs now say why a long temp directory breaks them, instead of letting twenty tests fail with a message about socket path limits that reads like a defect in the software. macOS allows four fewer bytes than Linux and spends about half of them on its own temp directory before anything nests inside it. The budget is the rig's rather than the product's, because a test picks its own session name and `pty` generates a short one — a root that satisfies the product's check can still be too long here, which is what made this confusing.
…ocess `pty stats` returned no memory and no CPU on anything that is not Linux. The numbers came from `/proc`, which a Mac does not have, so the reading was simply absent: no CPU line at all, and `"resources": null` in the JSON. It failed quietly, which is why it surfaced as five unrelated-looking test failures rather than as one missing feature. The Node tool asks `ps` for those two numbers on every platform, and now so does this, keeping `/proc` where there is one because it costs no subprocess and stats are asked for every session in a listing. Separately: a process was called dead when `ps` said nothing about it. `ps` prints an empty state for a pid that has left AND for a state it does not report, and only the first means the process is gone. The caller has already established the pid was alive one line earlier, so silence there is not evidence of death — the kernel is asked again instead. The Node tool has the same shape and the same fault; it goes unnoticed because a Mac's own `ps` answers properly, while the one in a build sandbox prints a blank state for a live process and this branch then reaped it. The asymmetry is what decides it. Saying "not yet" about a process that has gone costs one more poll. Saying "gone" about one that is running reaps a live session. Both decisions are now split out from the code that calls a program, so they are tested on every machine that runs this suite rather than only on the one that needs them. The `ps` reading path is exercised for real too, because Linux takes the same fields even though it does not use them. The development shell also sets a short temp directory. `nix develop` appends to whatever it inherits, and on a Mac that pushed session sockets past the kernel's path limit: 172 tests failed for that reason alone, in this very shell.
A session's child was told its working directory by whichever shell happened to launch it. If the launcher stood somewhere else, the child's shell worked the directory out for itself and got the resolved path; if the launcher stood in the session directory, the child got the path as written. Same command, same argument, two answers depending on where a person was standing. The child is now told the directory as the caller wrote it, which is what node-pty does and therefore what the Node tool has always done. It hides on a machine whose temp directories are real directories, and shows on one where they are symlinks — which is why it was found on a Mac, where both `/tmp` and `/var` are links. It is a symlink difference and not a platform one, so the test makes its own symlink and would have caught this anywhere. Found by building this on a Mac. Reproduced here in a minute once the right question was asked, and the wrong answer before that was mine: I reported the variable absent on Linux when my probe had read a session that was already reaped, so it was measuring nothing.
The daemon had no name on macOS. `ps` and `top` showed the binary's whole path where the Node tool shows `pty-daemon`, because the naming call was written as Linux-only and the comment beside it said macOS had no equivalent. It does: Apple's libc offers a one-argument `pthread_setname_np`, which is what the Node tool gets from its own runtime. Both places that name a process now share one implementation, so this cannot drift apart again. The test that should have caught it read `/proc` and nothing else, so on a Mac it panicked with a bare "No such file or directory". Gating it there would have made the suite green and left the gap. It now asks each machine the way that machine answers, and returns nothing when a machine will not say — which is a different answer from a name that is wrong. The `ps` added to the darwin check inputs this morning is removed. It is entitlement-limited: it refuses `rss` outright, returns a blank state for a live process, and only gets the start time right. So it defeated the memory reading while appearing to test it, and it was the third wrong answer from one package in a day. The tests that need a working `ps` now ask whether they have one and say when they do not. A skip that names its reason is worth more than a green that measured nothing, and more than a red that blames the code. One test compared the spelling of a directory rather than the directory. The tool reports where it looked, which is `getcwd()` and therefore resolved, while the rig built its path without resolving. Those are the same string on Linux and different on a Mac. Also: the whole `pty` crate cross-checks for Apple silicon from any machine, not just `pty-core`. Confirmed by putting a deliberate error inside a macOS branch and watching that check alone report it.
Naming the daemon on macOS did not work. `pthread_setname_np` compiles, and it names the calling thread for debuggers; macOS does not surface thread names through `ps` at all, so nothing the tool displays moved. What the Node tool actually does is rewrite the process-argument region, which is where `ps` reads its answer — its daemon's arguments are the bare string `pty-daemon` rather than a command line, which is the tell. Doing that means overwriting the memory the arguments live in, in place, within the space the kernel gave us, and getting it wrong corrupts the arguments of a running process. So it is left undone rather than half done, and both dead ends are written beside the function, including the sentence that used to claim macOS had no way to do this at all. It is cosmetic: nothing reads the name. The test pins both platforms rather than skipping the one that lacks it, so implementing it later turns the test green instead of leaving a gap nobody watches. Separately, the split-character test now says what it saw. It concatenated the frames before printing, so a failure could not tell a character split across two frames from one mangled inside a single frame — and those want completely different searches. It now prints the wanted bytes, the received bytes and each frame separately. That question is worth asking here too, and the answer is good: on this machine the sample really does arrive as two frames, one byte each, so the split these tests exist to create is genuinely happening and a pass is not an accident of chunking.
A test had a child print one byte at a time, to check that a character split across several frames still arrives whole. The child slept for a third of a second first, to let the client attach. On a slower machine the first byte of the sample never appeared, while every other byte arrived correctly, one per frame. That looks exactly like a decoder losing its place at a character boundary and is nothing of the kind: anything the child writes before the daemon has processed an attach reaches that client in the first screen rather than as a data frame, and the test collected frames only. The byte was not late, it was invisible. The sleep was the fault. It made the race rare enough to look like something else, and rare enough that the same test passed under load — a busy machine attaches at a different point in that third of a second. A test that passes when the machine is busy and fails when it is idle is the shape to recognise; the usual one is the other way round. The child now waits for a file the test creates once the screen has arrived, so the stream cannot begin before the watcher is watching. Proven twice: never open the gate and no bytes arrive at all, and delaying the attach by more than twice the old sleep still passes. The reason this took an afternoon is in the same commit's spirit as the fix. The assertion concatenated the frames and printed them as text, so a character split across two frames and one mangled inside a single frame produced the same message, and those want completely different searches. It now prints what was wanted, what arrived, and each frame on its own. The rewritten message found the cause in one run.
A client that asks to leave had its socket closed at once but stayed counted until the close came back around from the reader thread. Anything asking the daemon a question in between was told about a client that had already gone. The Node tool has the same shape and the same window. Neither shows it on Linux: 60 detach-and-immediately-reattach cycles gave no stale reading for either. It is wide enough to see on Apple silicon, where a daemon learns of a departure from an ordinary end of stream rather than from a reset, and a test that detaches and immediately reattaches has been failing there every time. There was never anything to wait for. The client said it was going and its socket was being closed in the same breath. Recorded as a deliberate difference rather than slipped in, because it is one: decision 0007. It cannot be demonstrated on the machine that wrote it, which is also said there rather than left for somebody to discover.
Two tests slept a quarter of a second, then typed, hoping the command had already refused by then. That is a guess about how fast a machine is, and a slower one lost it twice — a different test inside the same binary each time, which is what a load-sensitive wait looks like rather than a flaky assertion. The point of both tests is that input arriving after the refusal cannot bring a dead session back, so waiting for the refusal is what they meant to do. The wait is proven to gate: point it at text that never appears and both fail at once, quoting the refusal they should have waited for. Also written down: `run` and `attach` refuse to nest, `--force` is how you say you meant it, and the refusal prints to standard output and exits 0 — so a script that captures output and checks the status cannot tell it from success. That matches the Node tool and is not changing quietly, but it cost somebody an afternoon of hunting a terminal problem that was not there, and it was nowhere in these docs.
The suite runs 139 binaries in parallel, each driving real processes through real terminals, and on a slow enough machine one or two lose a race per run. Which ones varies across the whole suite, so an unfamiliar name is the normal case rather than a new regression. Counted on 2026-09-02, and the two machines differ sharply. Seventeen whole-workspace runs on one Linux host: fifteen completely clean, and the two that were not each named a real defect that was then fixed — no run there lost a race. Four runs on an Apple silicon laptop: one or two lost races every time, never the same ones, all green alone. So the advice is not to chase them by name. A failure worth fixing has a cause somebody can state, and the two in this repository that looked like this both did: a sleep standing in for a handshake, which then failed reliably once the timing was turned up. Whether a slower machine is the whole explanation is not established, and that is said rather than assumed — there is one laptop and nothing to compare it against.
It is left out of the snapshot entirely and therefore never signalled. Its children are still walked, so only that one process escapes and not its subtree. This records the behaviour rather than endorsing it. Reading a token is a `/proc` read on Linux and a separate `ps` call per descendant on macOS, so a `ps` that answers slowly or not at all silently drops that process from a teardown — and today a packaged `ps` was found returning nothing for fields it does not support. The Node tool omits it the same way, so this is shared rather than a difference. It is written down because it is a candidate mechanism for a harness that survived a kill on a Mac, and because the shape is one this repository has now met four times: a failure to look folded into an answer about what is there.
It said the refusal prints to standard output and exits 0. It does neither: it goes to standard error and exits 1, in this tool and in the Node one. Every way `attach` can refuse was checked — nested, missing session, exited with no restart, a bad or unwritable stream descriptor, no argument — and all six exit 1 to standard error in both. The claim came from a report that a capture had come back empty, and the empty capture had a simpler cause: the message was on the other stream. Writing it down without running it was the mistake, and it is the one this repository has spent the day learning to catch. `run` and `restart` really do exit 0 from inside a session, and that is correct rather than an oversight: neither refuses. `run` runs the command directly and `restart` restarts without attaching. Both are now described as what they are.
A daemon that has signalled its child's process tree with TERM and then KILL already collected the processes still alive, and reported them on its own standard error. That stream has had no reader since the command that launched it stopped listening, so the one moment the daemon had something worth saying was the one moment nobody was there. `pty kill` waits on the daemon and cannot see a surviving child, so the fact reached no one at all. It now also goes into the session's event log, with the pids, so `pty events` shows it and anything following the log sees it. Nothing else changes. `pty kill` prints and returns exactly what it did before, and the daemon's own warning is untouched. This adds a record, not a guarantee — decision 0008, which also says what is tested and what is not: the event's shape is pinned, and the trigger is not, because a process that survives SIGKILL cannot be manufactured in a test. The type is new and the Node tool never writes it. Its reader renders an unknown type through the same path it uses for user events, printing the data object, so a reader on a shared root shows the line rather than failing on it. Found because a coding agent survived a kill on a Mac, a supervisor then started a second one on the same session id, and two processes wrote to one transcript.
…ne place, and stop waiting to say a name is taken (#7) * Say what the kill verified, not what it hoped `pty kill` signals the daemon and waits for that one pid. It then prints `Session "X" killed.` The child, and everything the child started, is never looked at. The word is a claim about a session made on evidence about a daemon. This takes a snapshot of the daemon's process tree before the signal, and re-checks it after the daemon exits. The success line now appears only when every process in the snapshot is gone. Otherwise the command prints `Session "X" daemon stopped.`, which is the part it verified, and names the survivors on standard error. The snapshot must come first. After the daemon exits its children reparent away, so the links that identify them are gone. The pre-kill snapshot is also taken at a calm moment, while the daemon takes its own during shutdown, so the command can see a process the daemon's teardown skipped. A pid is reported as surviving only when its start token still matches. A token that cannot be read on a process that has not exited is reported separately as undecided, rather than being folded into either answer. A zombie is not a survivor. It answers `kill(pid, 0)` and keeps its start token, so the check reads the process state through `has_process_exited_for_reap`. This sends no additional signals and waits no longer. Decision record: docs/decisions/0009-kill-reports-what-it-verified.md * Make the exit status agree with the words `pty kill` printed a survivor report and exited 0. A caller that reads only the status reached the opposite conclusion from one that reads the output, which leaves the honest line as decoration. It now exits non-zero when anything survived, and when a start token could not be read so the outcome is undecided. "I could not confirm the tree is empty" is not success. This is a compatibility break. A script that checks the status of `pty kill` will fail where it used to pass, because it was passing on a false success. The fix for such a caller is to stop treating an unverified kill as a completed one. Nathan decided the survivor case on 2026-09-03. Silber.cos decided the undecidable one. * Finish the kill instead of reporting that it did not The daemon tears down the child's tree on its way out, so the teardown races its own exit. Whatever it does not manage is nobody's work after that, and the command that outlives it does nothing about it. `pty kill` now re-reads the process table after the daemon has gone. If anything from its pre-kill snapshot is still alive, it signals the process groups the session left behind, waits, escalates to SIGKILL, reads the table again, and reports what is still there. It never reports the sending. Process groups rather than pids, because a group signal needs no identity. The snapshot drops a descendant whose start token cannot be read, and that process is then never signalled; groups are collected from the raw listing, so it is reached anyway. The blind spot is not solved, it is made irrelevant. A sweep also costs one `ps` in total, against one per descendant for tokens. The daemon's own group is never a target. The pty child calls setsid, so the daemon sits alone in its group and signalling it reaches the daemon only. The running process's own group is never a target either, so the command survives to print its result. A zombie is not a group member. `ps` lists it with its group, so counting it makes the sweep report a group it has already emptied. A test against a real process group found this; inspection did not. `pty kill` now exits non-zero when anything survived, and when a start token could not be read so the outcome is undecided. This is a compatibility break: a script that checked the status will fail where it used to pass, because it was passing on a false success. Decision record: docs/decisions/0010-kill-finishes-the-job.md * Do not call a tree empty while the escalation left something running The success line and the exit status read only the pre-kill snapshot. That snapshot drops a descendant whose start token could not be read, and it never contained a process spawned after it was taken. So a process the sweep found and could not kill can be absent from it entirely, and the command would print `killed` and exit 0 over a process that had just survived SIGKILL. That is the defect this command exists to stop making, reintroduced by the escalation that was supposed to end it. Both halves are now required: the snapshot must be clear AND the sweep must have left nothing behind. Found by reading the diff rather than by a failing test, so the test came after; it fails without the fix. * Read the process table in one place, without a subprocess Every caller that needed a fact about a process ran its own `ps` and treated the output as fact. A subprocess can be slow, truncated or silent, and all three look exactly like "the process is gone". Three wrong answers came from that shape, and none of them was a parsing bug. The arithmetic settled it. The teardown polls every 25 ms and asked about each surviving descendant separately, which on macOS was a `ps` spawn each time. Inside a 1500 ms budget that is 240 spawns for four descendants, and at the 10.9 ms a spawn was measured to take, 2.6 seconds of spawning inside a 1.5 second deadline. The teardown could not meet its own deadline for a tree of four, on an idle machine. `pty_core::proctable` now answers those questions from `/proc` on Linux and from `proc_listpids` / `proc_pidinfo` on macOS. Neither spawns anything. Not sysctl: `kinfo_proc` is not in the libc crate, and libproc needs no new dependency. Silence is a third answer. Every query returns `Answer`, separating the fact from "not there" from "I could not find out", with no `Default`, no `unwrap_or`, and no lossy conversion to `Option`. Treating silence as death requires writing `or_absent_when_unknown()`, which greps in one command. That makes the mistake visible rather than impossible, and saying so is the honest version. A table that does not contain the process that read it was truncated, not empty. `ps` always lists itself. Single questions use a single read. A full table read costs about as much as 473 single reads, so putting one in the poll loops would have fixed macOS by making Linux several hundred times slower. `LiveIdentity` is a separate type from the registry's stored start token, because that token's text is a contract with the Node tool through a shared registry. The one remaining `ps` produces it, and says so in place. Decision record: docs/decisions/0011-one-reader-for-the-process-table.md * Do not lose a zombie on macOS, or count a corpse as a survivor on Linux Two defects, opposite directions, found because the two platforms disagreed about the same process on the same machine at the same moment. macOS was losing zombies. `proc_pidinfo` refuses an unreaped child with ESRCH while `proc_listpids` still lists it, and that refusal was read as "the process does not exist". `Silber.pty` measured it on a real Mac for pid 92893: `PROC_PIDTBSDINFO` returned 0 of 136 bytes, `PROC_PIDT_SHORTBSDINFO` 0 of 64, and `sysctl KERN_PROC_PID` returned 648 bytes carrying status 5, ppid 92892, pgid 92540 and a microsecond start time. `/bin/ps` agreed with sysctl. So `KERN_PROC_PID` is the fallback, and `kinfo_proc` is hand-declared because the libc crate does not expose it. The field offsets were measured with an `offsetof` probe compiled warnings-as-errors and checked identical against two SDKs. The offsets cannot be verified where this was written, so they are not trusted blindly: before any of them is believed, the buffer must contain the pid it was fetched for. A wrong offset returns "I could not find out" rather than feeding a garbage ppid into a kill path. A layout that cannot find itself is not a layout. Linux was counting corpses as survivors. An unreaped descendant keeps its `/proc` row and its identity, so matching on identity alone said it was alive. The teardown waited out its whole TERM budget for a process that could not respond, then reported it as having survived a SIGKILL. That is a dishonest kill in the other direction, and it was pre-existing. macOS never had it, because libproc drops the corpse. Also from the same Mac run: The `wait_for_identities_to_exit` heading claimed one table read per iteration while the code reads each survivor separately. The code is right; the heading survived an edit that rewrote the paragraph under it. macOS has the `setsid` system call but no `setsid` executable. The real process-group test spawned the binary, so on the one platform where process groups are the whole escalation story, the test could not run at all. It now uses `process_group(0)`. * Stop rustdoc reading a measurement table as Rust The `kinfo_proc` offsets are an indented block in a doc comment, which rustdoc treats as a code block and tries to compile. The module is `#[cfg(target_os = "macos")]`, so on Linux it is compiled out and produces no doctest at all: `cargo test --doc` reports zero tests for the whole crate. The block only becomes a doctest on the platform where the module exists. It is fenced as `text` now. `cargo test --doc --target aarch64-apple-darwin` does catch this from Linux — it fails on the unfenced block and passes on the fenced one. That check was available and was not being run. A scan of every doc comment in the workspace finds no other indented block outside a fence. Found on a real Mac by Silber.pty. * Say "is already running" at once instead of after thirty seconds A `pty run` that loses a creation race waited out the whole start budget and then reported a generic publication timeout. `wait_for_publication` compares the published metadata against its OWN pid, so for a loser that check is false for the rest of the budget. Its only other way out of the loop is noticing its own daemon die. When that is slow — a loaded machine, a daemon still starting up — the loop spends the entire thirty second default and reports a timeout, when the true answer was on disk in the first iteration. Measured on a Mac by Silber.pty on 2026-09-03: 30.06 s against a 30 s budget, saying "Timed out waiting for daemon publication" instead of "is already running". The loop now checks whether the name is published by a live process that is not us, and stops with the sentence `pty run` already prints when it sees a running session before it spawns. Losing the race later should not produce a different explanation of the same situation. All three conditions matter and each is tested. Published, or a name whose metadata is still being written would be refused. A different pid, or a successful spawn would refuse itself. A live one, or stale metadata from a dead daemon would make the name permanently unusable. The safety property was never in question. The test that found this asserts exactly one winner before it checks the loser message, and that assertion passed every time. What was wrong is what the loser said, and how long it took. * Do not call a zombie daemon a live owner The new check refuses a session name when it is published by a live process that is not us. It asked `pid_alive` / `isProcessAlive`, and a zombie answers `kill(pid, 0)`. So an unreaped daemon would have counted as live and the name would have been refused for as long as the corpse went unreaped. That is the exact failure the liveness condition exists to prevent, arrived at by the check that was supposed to prevent it. An unreaped daemon is the precise case that matters here: dead, not reaped, still in the process list. Both now ask `has_process_exited_for_reap` / `hasProcessExitedForReap`, which reads the process state and counts a zombie as gone. Tested against a real corpse in both languages, with the predicate the production path actually passes. Reverting to the cheap predicate fails both. Node also exports `hasProcessExitedForReap`, which was private. Silber.cos asked what this check does with a zombie daemon on macOS. The answer was worse than the question: it was wrong on both platforms. * Wait for the child's escapes instead of assuming a screen frame implies them `status_after_exit_and_mode_flags` waited for one Screen message and then asserted the terminal modes the child sets. Attach publishes the current screen, which can be empty and can arrive before the child's escapes have been read and parsed, so the assertion was a race that only loses when the child is slow. Silber.pty hit it on a Mac under suite load, at daemon_roles.rs:317, with `sgrMouse` still false. Reproduced on Linux by giving the child a `sleep 0.4` before its `printf`, which is what the load was doing: the same assertion, the same `left: Bool(false)`. It now waits for the mode the child sets, then asserts. With the simulated delay in place the test passes; with the delay removed it still passes; and it passed 15 times while a full workspace run was competing with it. * Remove a line of trailing whitespace Four spaces on their own line inside the tree import, left by an edit during the consolidation that inserted a name and then removed it again. git diff --check against origin/parity is clean at this head.
The session picker defaulted to `coolBlue` when no theme was saved, so a first run painted its own palette over whatever the user had configured. On a light terminal that is a near-black panel: measured, the default emitted 144 colour sequences including `\x1b[48;2;15;17;26m` as the background. The Node tool has defaulted to `terminal` since the picker was written — `loadSavedThemeIndex` returns `terminalIdx` when nothing is saved, and `themes.terminal` has all thirteen slots `null`. That is the specification here rather than a comparison; it was a deliberate decision. Everything needed was already ported. `Theme` holds `Option<Rgb>`, `TERMINAL` exists with every slot `None`, it sits at the same index in `THEMES` as Node's, and a test already asserted its slots are all empty. Only the fallback was wrong, and this changes that one line. Tested by what reaches the terminal rather than by what a config value equals. The picker runs in a real pty, its output is parsed by a real VT, and the serialized state is asserted to contain no colour-setting sequence. Resets to the terminal default -- 39, 49, 59 -- are excluded, because resetting to the user's colour is the opposite of painting over it. Two of the three tests exist to stop the first one passing vacuously: a saved `coolBlue` must be seen to paint, or the check cannot tell the two cases apart, and the classifier is tested against every way of setting a colour. Reverting the fallback fails the first test with the 144 sequences it emitted. A light terminal and a dark one need no separate cases. Emitting no colour is what makes both work, and that is the property under test.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
mainbecomes whatparityis today.This is a fast-forward:
mainis an ancestor ofparity, so all 120 commitsarrive with their history intact and nothing is rewritten or squashed. The
repository goes from 43 files to 358 — from a pre-workspace skeleton to the
whole port.
What lands
A six-crate workspace implementing the
ptycommand line tool in Rust, againstthe Node implementation as its specification. The documented command surface
matches: both
pty helpoutputs are 122 lines and every command appears inboth.
pty-coreholds the registry, client and protocol;pty-terminalwrapslibghostty-vt;
ptyis the binary and daemon;pty-tuiis the session picker;pty-testkitdrives a real pty for tests;pty-conformanceruns one suiteagainst either binary, so the two tools are held to the same behaviour
rather than compared by eye.
Where it runs today
It has been the daily driver on two machines: an Apple arm64 Mac and x86_64
Linux. Those are the two targets prebuilt binaries will cover first.
State
1357 tests pass on Linux at this tip. macOS is checked by cross-compilation
here and exercised on a real Mac by a second pair of hands before anything
merges.
Known limits are documented rather than implied: a descendant that calls
setsidescapespty kill; sixteen call sites still use a liveness check thatcounts an unreaped process as alive;
pty uprefuses a session id whoserecorded owner is a corpse. Each has a plan and none of them block daily use.
What this repository does not have yet
No CI. That arrives with the release workflow rather than before it.