Skip to content

Publish the port: main becomes parity - #11

Merged
myobie merged 120 commits into
mainfrom
parity
Sep 4, 2026
Merged

Publish the port: main becomes parity#11
myobie merged 120 commits into
mainfrom
parity

Conversation

@myobie

@myobie myobie commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

main becomes what parity is today.

This is a fast-forward: main is an ancestor of parity, so all 120 commits
arrive 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 pty command line tool in Rust, against
the Node implementation as its specification. The documented command surface
matches: both pty help outputs are 122 lines and every command appears in
both.

pty-core holds the registry, client and protocol; pty-terminal wraps
libghostty-vt; pty is the binary and daemon; pty-tui is the session picker;
pty-testkit drives a real pty for tests; pty-conformance runs one suite
against 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
setsid escapes pty kill; sixteen call sites still use a liveness check that
counts an unreaped process as alive; pty up refuses a session id whose
recorded 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.

myobie added 30 commits August 29, 2026 12:11
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.
@myobie
myobie merged commit a8e9a3e into main Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant