Skip to content
compoundingtechPublic

About

shell wrapper server for detach, attach, and following of long running programs from anywhere

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

pty-rust

pty keeps terminal sessions alive after you walk away. pty run -- <command> starts the command inside a real pseudo-terminal that a small per-session daemon owns; you can detach, come back with pty attach, read the screen from a script with pty peek --plain, type into it with pty send, and list, tag, restart, or kill sessions from any shell. Programs and agents drive sessions through the same commands and JSON output. This repository is a Rust port of the Node pty, with libghostty (the terminal core extracted from Ghostty) in place of @xterm/headless. The port is meant as a drop-in: same commands, flags, texts, JSON shapes, exit codes, files under $PTY_ROOT, and socket protocol, so the two implementations can share an ordinary unmanaged registry while a fleet migrates. Managed publication-revocation roots exclude legacy publishers; see the compatibility boundary below.

Where it stands

It is the daily driver on two machines — an arm64 Mac and an x86_64 Linux box — and has been since 2026-09-02. That is the honest measure of "does it work": it is what those machines run, not a demo.

The documented command surface matches the Node tool exactly. Both pty help and pty --help document the same command surface. Two are deferred rather than missing — pty recover and pty test keep their help text and print pty <cmd>: not available in this build. See docs/parity.md.

1357 tests pass, including a conformance suite that runs against either binary. That is the part worth knowing: the two implementations are held to the same assertions rather than compared by hand, so a behavioural difference fails a test instead of surfacing later on somebody's machine.

What is not finished is written down rather than implied: the surface-by- surface state is in docs/parity.md, the decisions where the two tools deliberately differ are in docs/decisions/, and the limits worth knowing before you rely on it are below.

There are no prebuilt binaries yet. You build it — see Install — and Which systems it runs on says what a built binary needs.

Direction: compatibility and embedding

The long-term target is a behavior-compatible Rust implementation of the Node pty, plus a first-class Rust API for embedding a live terminal in clients such as Fractal. The Node implementation is the behavioral reference while the port converges. This README does not claim full parity, and docs/parity.md is where the remaining gaps are named surface by surface.

Compatibility means that the same user-visible operations and wire messages have the same result. It does not require identical source code or internal design. Rust, portable-pty, and libghostty can require a different implementation. When that difference changes behavior, record a decision that states the Node behavior, the Rust behavior, the reason, the client effect, and the conformance test.

Transport failures are an explicit exception: the 2026-10-08 PTY.TRL-R11 decision preserves banner, successful-detach and received-EXIT bytes, while EOF without EXIT reports connection loss with a non-zero status. The socket close-path contract defines the optional rejection reason and the EOF fallback.

The embedding API should serve the Rust CLI and other Rust clients through one implementation. Its terminal handle should eventually provide the capabilities that Node's @myobie/pty/tui PtyHandle provides: attach lifecycle, input, resize, typed cell-grid and wrapped-line reads, cursor and terminal-mode state, scrollback access, and activity or exit events. libghostty::Terminal is not Send, so one clear actor must own it and publish typed events or snapshots to consumers.

Rust renderers can use TerminalHandle::subscribe(), then retain an observe_frames() lease and read frame() on HandleEvent::Dirty. Each immutable Arc<Frame> contains one consistent grid, mode state, graphics placement set and owned image generations; readers do not wait for the actor. Use request_frame(0) on a preparation worker for initial readiness, or a nonzero offset for an asynchronous history window. Without an observer, the last published frame can be stale; lifecycle subscribers do not incur capture cost. Publication coalesces queued updates and advances during sustained output at an inline 16 ms interval, not a hard realtime deadline. See architecture.md for ownership and publication details. Closing a handle disconnects its event subscribers even if clients retain the handle: after queued events are drained, recv() returns an error. Subscribing after close returns an already-disconnected receiver.

The daemon publishes event-log snapshots in 250 ms batches. Each batch takes the cross-process event lock once and reads the log once. Normal batches use one atomic replacement; retention publishes the shrink separately from later records so offset-based readers can recognize it. Records keep their exact bytes and order; retention still checks every 100 daemon records and keeps the newest 500 at the 1000-line boundary. The Rust follower also checks the consumed prefix when snapshots change, covering coalesced retention and regrowth. Delivery takes up to one batch window plus the follower poll interval and event-lock wait. Each window closes on its own timer, and session exit and orderly shutdown flush pending records; SIGKILL, abort, writer panic, or the forced exit at the shutdown deadline (PTY_SHUTDOWN_DEADLINE_MS) can lose the current buffered window. One-shot writers (pty emit, metadata, tags, display name, pty exec, and gc) retain their existing event-lock behavior, so their records can land before daemon records from up to one window earlier.

pty events NAME --wait session_exit --timeout SECONDS checks recorded exit state before and after subscribing, so an already-exited retained session returns immediately (exit status 0), even if its event log was removed. Other event types wait for a new matching event. A missing session returns a not-found error (exit status 1); a vanished target with no recorded exit also fails promptly, including a launcher that never started. A session removed while waiting without an observed matching event returns 1. So does any wait whose daemon dies without recording an exit, before or after the wait starts; a named follow ends there with 0. A record no daemon has claimed yet keeps other waits waiting, since its launcher may still start it. The timeout is an independent deadline and returns 1 even when no filesystem events arrive. Options may follow the session reference, and any other argument is an error (exit status 1) rather than silently dropping the options after it.

Followers ignore read-only filesystem notifications and coalesce change notifications, avoiding feedback from their own registry reads. Every session writes into the one registry directory, so a named follower wakes only for its own sessions' logs and metadata, and --all only for logs; the 250 ms poll covers anything a notification misses.

Cursor visibility and window titles are live terminal state, not durable event history: TUI redraws and title spinners can change them dozens of times per second, so daemons do not persist cursor_visible or title_change transitions. The actor retains the current title, and attached clients still receive the original OSC output. Existing records remain readable and formattable. Daemons automatically remove recognized atomic .tmp.<pid>.<16hex> files on entering their serving loop and every 30 seconds thereafter; explicit GC runs the same narrow sweep. Cleanup covers the registry and activity/recovery directories only after the writer PID dies; live or reused PIDs, malformed names, directories and symlinks are left alone. Automatic cleanup does not run full session GC.

Finished records stay until pty gc removes them, unless a daemon starts with PTY_RETAIN_FINISHED set to a window such as 7d (Ns, Nm, Nh or Nd). Such a daemon then sweeps its root once, on a background thread after it has published its own session. It removes exited and vanished records that have been dead for longer than the window, together with their event logs. It removes only what pty gc's sweep would remove: the recorded owner must be proven gone, and strategy=permanent records are left to gc. It never signals, respawns or abandons anything, and it skips any record whose locks are held. An exited record's age counts from exitedAt. A vanished record has no exit time, so the first sweep that sees it stamps the tag retention.lost-at, and the record goes one window after that stamp. A keep tag holds a record for the longer of the window and gc's keep window. Unset, empty, off, and any value that is not a positive duration all mean off, which is the default. If daemons on one root set different windows, the shortest wins. pty gc is unchanged.

Keep the current protocol as the baseline. Add a protocol feature only after a real failing use case shows that the current byte-framed messages cannot express the required behavior. Track the compatibility matrix, crate boundaries, and acceptance tests in issue #1.

Where the port stands against the Node pty, surface by surface, is in docs/parity.md; how the workspace is laid out, and why, is in docs/architecture.md.

What must stay true of terminal images — the Kitty graphics protocol state a session holds and replays — is in docs/vrs/01-images/requirements.md; how the session meets it is in docs/vrs/01-images/spec.md.

Install

With Nix (flakes), from a checkout or straight from GitHub:

nix build                                   # ./result/bin/pty, completions under ./result/share
nix run . -- help
nix profile install github:compoundingtech/pty-rust

The flake builds hermetically: Ghostty's source and Zig packages are fixed-output fetches, and one native libghostty-vt package builds the C library. The source comes from a GitHub archive, without anonymous Git transport; its revision and unpacked source hash are pinned in libghostty-vt-contract.json. Cargo links that archive through pkg-config. nix flake check also verifies the native compatibility contract and runtime closure, runs the package tests, and checks installed completions. nix develop supplies the Rust toolchain and the same native artifact, without Zig.

With Cargo (see the build requirements below):

cargo install --path crates/pty

On macOS, use Nix instead. The Cargo route fails on macOS 26.6 with the 26.5 SDK. Ghostty pins Zig 0.15.2, and that Zig cannot link the 26.5 SDK: it reports undefined macOS system symbols. A newer Zig does not help, because the Ghostty build requires 0.15.2 and refuses 0.16.0. Fresh checkouts and fresh Zig caches give the same result. The Nix route builds and runs on the same machine.

If you have no Nix, use the Node implementation that this port tracks. It runs on macOS and builds from a checkout in seconds:

git clone https://github.com/compoundingtech/pty-original-experiment node-pty && cd node-pty
npm install && npm run build
./bin/pty --version                         # 0.12.0+<short-sha>

Build it from the checkout. The package is not on npm, so npm install -g does not work. The two tools read and write the same on-disk session registry, so you can move between them later.

The Cargo route is verified on Linux: measured on x86_64 at commit 5d0d674 with Zig 0.15.2.

Shell completions ship in completions/ and are also printed by pty completions <fish|bash|zsh>.

Which systems it runs on

There are no prebuilt binaries yet. Today you build it. Nix works on Linux and macOS. Cargo works on Linux; on macOS it fails, and the Install section above says why. This section says what a built binary needs, because that is the question a release has to answer.

Linux: glibc 2.34 or newer, and the floor depends on where you build

A binary links against the glibc it was built with, and refuses to start on anything older:

$ ./pty --version
./pty: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.39' not found (required by ./pty)

The floor is not set by this codebase. It is set by two symbols the Rust standard library uses for process spawning, pidfd_getpid and pidfd_spawnp, which appear in any Rust program that calls Command::status(). Nothing here references them.

That means the build host decides the floor, and the difference is large:

built on floor runs on
glibc 2.43 (Ubuntu 25.10) GLIBC_2.39 Ubuntu 24.04+, Debian 13+, Fedora 40+
glibc 2.36 (Debian 12) GLIBC_2.34 the above, plus Ubuntu 22.04, Debian 12, RHEL 9, Rocky 9, Amazon Linux 2023

Measured on 2026-09-05 by running the same binary in each distribution's container. Build releases on the oldest glibc you intend to support; no source change is involved.

macOS: it runs, and here is exactly what was tested

Gatekeeper does not refuse the binary. Tested on one machine: arm64, macOS 26.6, build 25G72, Darwin 25.6.0, with Gatekeeper assessments enabled.

The com.apple.quarantine attribute was written onto the real binary, confirmed present, and the binary ran and exited 0. The attribute was then removed and the run repeated with an identical result — without that control the first run would prove nothing about the attribute.

Three conditions travel with that result:

  • It is one macOS version. A bare "Gatekeeper does not apply" would outlive the release it was true of.
  • The binary carried an ad-hoc linker signature, which macOS applies automatically on Apple silicon. That may be why it passed. A release built somewhere that strips or omits it is a different case, and an untested one, so the release process must preserve it.
  • This result does not transfer to a published asset. The same test, with its removal control, has to run against the first one we ship.

Pull-request CI is Linux only, on purpose

Pull-request workflows run on Namespace's namespace-profile-linux-x86-64. Release builds run on Namespace's Linux and macOS profiles. Linux still builds inside Debian 12 to preserve the glibc floor, and macOS still uses the pinned Nix SDK before relocation and ad-hoc signing. This does not provide macOS coverage for each pull request.

The release workflow's manual dispatch builds and verifies both platforms without publishing, even when dispatched against a tag. Only a pushed v* tag can run the publishing job; dispatch jobs have read-only repository permissions.

That matters more here than it usually would. crates/pty-core/src/proctable.rs carries a macOS process-table reader that no Linux job ever compiles: libproc, plus a sysctl fallback with hand-declared kinfo_proc struct offsets. Offsets are exactly the kind of thing a new macOS moves. So a change to that reader, or a macOS SDK change, can break the Mac build without pull-request CI noticing.

This is a decision, not an oversight. The tool runs on two Macs every day, so a broken Mac build surfaces immediately in use, and human use is the detection mechanism. That trade holds while the daily users are the affected users. If this ever ships to people who are not in the room, the trade changes and a pull-request macOS job comes back — via Nix, because Cargo cannot build it there.

Each pull-request workflow cancels older runs of the same workflow on the same pull request. Other pull requests, pushes to main, and releases run independently.

All workflows and .github/repo-settings.json are generated from their neighboring .genie.ts sources. The generator has its own shell so ordinary nix develop and Rust CI do not build generator tooling:

nix develop .#genie -c genie
nix develop .#genie -c genie --check
python3 scripts/check-workflow-policy.py

That shell links the pinned effect-utils flake source into repos/effect-utils; the generators import it directly, without installing npm dependencies. The public effect-utils binary cache is read-only for this repository. Formatting and clippy remain report-only until their existing debt is addressed. The declared required checks are Nix build, Private names, Test, Conformance gate, and genie freshness. An administrator must apply the generated settings only after the Namespace jobs have passed once; generating the settings does not change GitHub's live configuration.

Usage

pty run -d --name "API" -- node server.js    # start a session in the background
pty list                                     # sessions (--json for programs)
pty peek --plain API                         # the screen as plain text
pty send API --seq "npm test" --seq key:return
pty attach API                               # interactive; Ctrl+\ detaches
pty kill API && pty rm API
pty help                                     # every command; pty <cmd> --help for one

Sessions live under $PTY_ROOT (default ~/.local/state/pty/<hostname>): one unix socket, pid file, and metadata file per session. The hostname keeps registries apart on machines that share a home directory. Set PTY_ROOT to isolate a registry, for example in tests. Set it to the former default path if you need access to sessions created there.

Session ids remain independent of Unix socket pathname limits. A socket uses <id>.sock when that address fits; longer addresses use an @-prefixed digest filename within the same registry. Metadata, pid files and events keep their id-based names. Socket clients use SessionInfo.socket_path or pty_core::registry::socket_path_in, rather than constructing a pathname. Existing readable socket addresses are unchanged. Rust IDs are limited to 209 ASCII characters to leave room for registry sidecars and atomic publication.

pty list --json --strict reports one registry as {"root":"…","complete":true,"entries":[…],"errors":[]}. The root is resolved through global --root, PTY_ROOT, or the default above and returned verbatim: there is no canonicalization, and path aliases (including symlinks) are not folded into one registry identity. Entries preserve ordinary --json row fields, ordering, tags, and tag/status/age filters (including optional --clients). Strict entries additionally expose recorded generation and daemonStartToken when present; a token recorded in recovery.processStartToken is exposed explicitly as processStartToken, not relabeled as daemonStartToken. Absent identity fields remain absent. All retained metadata records are included, including Starting, Ready, and Terminal lifecycle tags, subject only to the selected filters; lifecycle state is not itself a visibility filter. The entire root is scanned before filtering: complete describes the full scan, not just matching entries. Errors identify the affected path, a kebab-case kind, and optional detail. Kinds are root-missing, root-unreadable, entry-unreadable, metadata-unreadable, metadata-malformed, pid-unreadable, pid-malformed, and probe-timeout. Missing roots, unreadable or malformed existing registry files, and socket probes that cannot finish within the scan deadline make the inventory incomplete; absent optional files and definitively missing sockets do not. Connection refusal is ambiguous (a full accept queue can cause it on macOS): on every platform, it requires a positive dead PID, or recorded exit evidence plus a positive dead daemonPid, before being treated as ended. Otherwise it reports entry-unreadable and exit 3. Strict companion files are opened non-blocking and checked through the opened descriptor; FIFOs, devices, and other non-regular files report pid-unreadable or metadata-unreadable without waiting for a writer. A definitively dead socket reports a retained entry as exited when exit evidence is recorded, otherwise vanished, rather than defensively running or omitted. Exit status is 0 for a complete inventory and 3 for an incomplete one; stdout still contains the envelope. --strict requires --json and cannot be combined with --summary or --remote. Ordinary listings keep their existing bytes and best-effort behavior. An ordinary listing skips invalid metadata-only records before reading their PID companions; strict inventory instead validates those companions and reports special files as unreadable without blocking.

pty root --json reports root resolution without creating directories, reading registry records, or contacting daemons: {"effective":{"path":"/var/lib/pty","source":"PTY_ROOT"},"nativeDefault":{"path":"/home/example/.local/state/pty/h-<hostname-hash>"}}. The effective source is flag for global --root, otherwise PTY_ROOT, PTY_SESSION_DIR, or default, in that order (empty environment values are ignored). The native default always comes from default_session_dir() and does not inherit any registry override, even inside a managed PTY_ROOT. Paths are returned as resolved, with no canonicalization or alias folding. Plain pty root prints two labelled lines, Effective (including the source) and Native default. Existing legacy-variable notices remain on stderr, including with JSON. The report alone is exempt from the socket root-length backstop so an over-long root can be diagnosed; other commands remain guarded.

pty list --json --clients adds clients to each running session: an array of { "pid": 1234, "tty": "/dev/pts/3", "attachedAt": "2026-09-25T12:00:00.000Z" }. It is opt-in because it asks every running daemon (up to 16 at a time, with one 500 ms budget for the whole listing); plain pty list --json contacts no daemon and has no clients key. --clients is ignored without --json, like the other view flags. The array is empty when no client is attached, null when the daemon did not answer in time or predates the query, and the key is absent on exited or vanished sessions. A client without a terminal reports tty: null; older clients also report pid: null because they do not send their identity.

pty version prints 0.13.<n>-rust+<short-sha>: one minor above the Node line, a rust pre-release tag, and the commit it was built from.

Library API

pty-client exposes the same listing to Rust consumers through pty_client::list. pty list --json --clients uses this implementation too. The module is unstable: it will move to SessionRef/PtyRoot (#1, #3), and SessionInfo currently exposes the on-disk session metadata as-is.

use pty_client::list::{ClientQuery, ClientSet, ListOptions, list};
use pty_core::registry::session_dir;

let sessions = list(
    &session_dir(),
    &ListOptions {
        clients: Some(ClientQuery::default()), // 500 ms total, 16 at a time
        ..Default::default()
    },
);
for s in &sessions {
    match &s.clients {
        Some(ClientSet::Known(clients)) => {
            for c in clients {
                println!("{} <- pid {:?} on {:?}", s.info.name, c.pid, c.tty);
            }
        }
        Some(ClientSet::Unknown) => println!("{}: clients unknown", s.info.name),
        None => {} // not running, or clients not requested
    }
}

Repeated observers can retain a pty_client::list::RegistryListCache and call cache.list(&root, &options) instead. This explicit stateful API reuses parsed regular-file metadata only when path, device/inode, size, and nanosecond mtime/ctime match, and retains a record only once its ctime is older than the filesystem clock's tick (a record changed in the last 50 ms is parsed again). Each call still opens metadata to check readability, lists the directory, reads PID sidecars, checks exact process identity and lifecycle, and queries attached clients when requested. Ordinary listing does not probe sockets for liveness: Unknown identity conservatively retains the named PID, status and metadata, while socket reachability cannot rescue Gone or Mismatch. New, changed, and removed sessions are observed on the next call; read/parse failures are not cached. The existing stateless list API is unchanged. The handle is internally synchronized and list takes &self: share an Arc<RegistryListCache> across threads or cloned runtimes, without an outer mutex. Only cache lookup/insert/eviction operations take its lock; metadata reads/parsing, PID/start-token checks, socket probes, and attached-client queries run without that lock, so a slow probe does not serialize concurrent observers. The lower-level equivalent is pty_core::registry::list::RegistryListCache.

The rest of what the CLI does to a session is there too, for a registry the caller names rather than $PTY_ROOT: send_in, peek_screen_in, query_stats_in, stop_in (pty kill), remove_in (pty rm), and signal_in, which signals the program a session runs rather than its daemon and can be fenced to one session generation. The crate docs list where each operation lives.

pty_client::history::read_in(root, name, &request, timeout) reads a bounded backwards page of retained primary-buffer rows without attaching, resizing, or sending input. Construct pty_core::protocol::HistoryRequest with the exact session metadata generation, a limit from 1 to 200, and before: None for the newest retained page. Rows exclude the live viewport and are ordered oldest to newest within each page; pass next_before unchanged to read older rows. Each row contains self-contained ANSI text (including styles and OSC 8 links) and its wrapping flag. History retained before a reader connects is available; this is terminal scrollback, not a transcript or an unlimited archive.

Continuation boundaries remain anchored while output appends. Pruning, terminal reset, geometry changes, expired cursors (five minutes), and evicted cursor leases return CursorGap, rather than silently treating a cursor as an offset. At most 256 cursor leases are retained. StaleGeneration fences same-name session replacement even on an initial read; AlternateScreen does not consume a primary-buffer cursor. InvalidRequest, Unavailable, and TooLarge are explicit failures. Rows are limited to 64 KiB and pages to 2 MiB; oversized styled content is not truncated into a successful result.

attached_clients(&sessions, &ClientQuery) queries an already-filtered &[SessionInfo] and returns one ClientSet per session, in the same order.

If you are already inside a session

run and attach refuse to nest, and --force is how you say you meant it. A session inside a session is usually a mistake — a detach key press then reaches the wrong one — so both commands stop and explain instead.

pty attach --force API      # attach from inside another session
pty run --force -- <cmd>    # create one from inside another session

The refusal goes to standard error and exits 1, in this tool and in the Node one. A script that captures only standard output sees an empty result and can mistake that for success, which is what happens if you forget --force; the exit code tells you the truth.

attach also accepts live pre-boot-ID daemons when their recorded daemon PID and start token verify against the live leader, using the same legacy authority as explicit controls such as kill. Missing, unreadable or mismatching tokens remain refused; --force never bypasses identity verification. This does not write a synthetic daemonBootId or upgrade the session's inventory identity. A legacy record that already has an exit while its daemon is still finishing is refused the same way, before any restart prompt: a restart waits for proof that the daemon is gone, and a live legacy daemon cannot give it.

A dead session restarts only on an answer. attach on a session that has exited shows its last lines and asks Restart? [Y/n]. A lone n or no declines, and attach exits 0. At a terminal any other line restarts it, Enter included. Without a terminal only y or yes does. At EOF, or on any other input, attach exits 1 and the stored command does not run. -r restarts without asking, --no-restart never restarts, and --force answers nothing. A revoked managed launch is refused before the question is asked. restart asks Kill and restart? [Y/n] before it stops a running session, by the same rules: with no answer it exits 1 and leaves the session running, and -y restarts without asking. The Node tool differs here (decision 0020).

run and restart are different and deliberately so: from inside a session run runs the command directly and restart restarts without attaching. Both did what was asked, so both exit 0.

Restarted sessions do not inherit the restarter's ST_AGENT, ST_ROOT, FRACTAL_AGENT, FRACTAL_ROOT, or AGENT_TRACE_FILE. The CLI applies the same scrub to attach-triggered and picker-triggered restarts. Fresh run commands still inherit these variables. Explicit --env assignments survive restart in either namespace.

Commands not in this build

Two Node commands are deferred (see docs/parity.md §12 for the reasoning): pty recover and pty test. Their help texts are kept verbatim so --help still describes them, but running them prints pty <cmd>: not available in this build. See docs/parity.md. and exits 1.

Lock compatibility boundary

Rust publishes each 0600 lock owner record under a unique sibling name and hard-links it into the canonical path with no replacement. The canonical path is therefore never visible before its complete decimal pid. A stale-lock stealer also locks the inode it inspected and verifies that the path still names that inode before unlinking it, so a delayed Rust stealer cannot remove a newer owner's lock.

The Node tool still creates the canonical file before writing its pid and steals with an unbound read-then-unlink sequence. Rust safely respects a live Node lock after its complete pid is visible, and Rust-only stale recovery is exclusive. If any concurrent stale-recovery path involves Node, a delayed Node contender can still unlink a newer Rust or Node claim. Such recovery must be externally serialized in a mixed registry. crates/pty-core/src/registry/lock.rs and docs/hardening.md describe the boundary in full.

Durable publication revocation (version 1)

Publication revocation fences one exact stable session ID and start operation in one registry root. It is not a stop, remove, or recovery command:

pty publication-revocation identity --json
pty publication-revocation install --id worker --operation start-operation \
  --binding <64-lowercase-hex-launch-binding> \
  --intent <64-lowercase-hex-repair-intent-digest> \
  --root-id <enrolled-root-id> --json
pty publication-revocation read --id worker --operation start-operation --json

All three forms require the exact flags shown (--json is required); duplicate or unknown flags are errors. There is no clear verb. IDs are stable IDs, never display-name lookups, and operation IDs are exact, not prefixes. Install returns {record, occupancy, alreadyInstalled}: the durable tombstone, the current strict occupancy observation, and whether this exact record was already installed. Read returns the exact record, or exits nonzero if absent. Read does not infer current occupancy. Conflicting binding or intent digests fail rather than overwrite a record. Installing a fence against a live session reports its occupancy without signaling, replacing, or changing its metadata. Installation requires the expected enrolled root identity and refuses a mismatch before writing a record. Records include capabilityVersion: 1, revoked: true, attestationReceiptOrIntentId (the supplied intent digest), writerUid, and recordedAt, alongside the schema, exact root/name/binding, and intent digest.

identity --json establishes or validates the root's durable enrollment identity and returns {"root_id":"…"} without targeting or altering any session. Capture this identity before managed launch, then supply all three immutable launch flags together:

pty run -d --id worker --publication-operation start-operation \
  --publication-binding <64-lowercase-hex-launch-binding> \
  --publication-root-id <enrolled-root-id> \
  --tag st3.start-operation=start-operation -- command

Partial flag sets are refused, and the daemon checks the supplied root identity against the actual enrolled root before publication. Managed metadata carries publicationBinding, publicationCapability: 1, and publicationRootId; mutable tags are not substitutes for this immutable launch provenance. The st3.start-operation tag is required and must exactly match the binding's operation. Recreating an ended managed row requires an explicit new binding; an ordinary unbound run or library launch cannot overwrite its retained provenance or events. The common final creation-claim admission enforces this rule before any publication effect, regardless of the caller.

Managed restart, picker restart, recreation and permanent GC prepare the publisher before predecessor cleanup. Linux probes and executes its pinned /proc/self/fd image. Darwin cannot execute /dev/fd vnodes, so it probes and executes a private 0500 snapshot in a 0700 compoundingtech-pty-publishers directory beneath Darwin's per-user cache (confstr(_CS_DARWIN_USER_CACHE_DIR)), never beneath an arbitrary registry root. Every component of the resolved cache path is checked with lstat: no symlinks, root/effective-UID ownership only, and no group/other writes unless a sticky ancestor protects the next trusted-owned component. The private leaf itself must not allow group/other writes, so another UID cannot rename an ancestor and substitute code between admission and execution. The cache key includes the image digest and the full raw source quarantine value, including every flag: user approval and restriction states never share an identity. Reuse verifies exact quarantine equality, bytes and permissions. A changed cached quarantine is a cache miss, yielding a fresh immutable entry; the already-assessed entry is neither modified nor made writable. One shared execution boundary rechecks policy for both ordinary and gated operation-mode launches, since the capability probe is also an exec. If quarantine was newly added to an initially unquarantined image, launch refuses rather than stripping it. Native copying preserves embedded code signatures and source xattrs; because Darwin can synthesize quarantine provenance during copy, each fresh snapshot restores and verifies the captured full source value before becoming 0500. Admission never strips quarantine or re-signs the image. Unchanged copies remain cached for reuse; a policy change may require a new first-exec assessment rather than silently reusing a more permissive copy. An unsupported/downgraded publisher or script wrapper refuses without changing the predecessor; atomic install-path replacement or unlink cannot switch the verified image. These guarantees exclude hostile same-UID cache mutation. Library callers preparing their own cleanup use pty_lifecycle::prepare_daemon(executable, &params) and retain the returned PreparedDaemon through its spawn(params) call. Publisher images must still come from the trusted immutable deployment; capability output is not executable authentication against a malicious same-UID native binary.

Permanent GC cannot reuse a managed binding after pty.toml changes the stored command, arguments or cwd. Manifest environment exports are part of the compared shell arguments. Such changes require a new operation/binding, not an ordinary respawn under the old request digest. Unbound legacy sessions retain manifest reload and operation-tag mutation behavior.

Rust callers use pty_core::registry::revocation::{root_id, install, read, check, PublicationBinding, PublicationRevocation, RevocationInstall}. root_id() returns io::Result<String> for prelaunch enrollment. install(name, &binding, intent_digest, expected_root_id) serializes with publication using the name creation lock and durably installs the immutable record; read(name, operation) returns io::Result<Option<PublicationRevocation>>. check(name, binding, tag_operation) must be called while the publisher holds that same creation lock and never acquires it itself. A revoked operation is refused with PermissionDenied / operation-revoked, even if a caller supplies a different binding digest. Binding and operation-tag disagreement is also refused. None/None retains ordinary unmanaged publication behavior; it is not proof of capable managed launch.

Operation-mode publication drops its inode pin before reporting completion to the actor, so the actor can release the creation claim before handing off to child creation. Child creation rechecks the fence and exact published generation under a fresh creation claim owned by the daemon itself. Ordinary publication pins the delegated launcher's inode through publication, then atomically replaces its owner record with the daemon's PID before spawning the child. The launcher's expected-owner release cannot unlink this transferred claim. Both child paths hold descriptor-free daemon-owned claims across fork: a child stopped before descriptor cleanup cannot retain a dead daemon's flock or prevent recovery. The earlier delegated publication boundary still pins its inode while a separate launcher may die; after handover, daemon death leaves no surviving publisher. After ordinary child spawn returns, a still-live delegated coordinator receives its claim back only while its guard has not released it, preserving its original PID and pathname until release. A hand-back I/O failure is diagnosed without killing the already-spawned serving session; the daemon's guard still performs owner-checked cleanup. A revoked managed binding is rejected before spawning a publisher, so a live survivor cannot mask that refusal as an already-running error; this deny-only preflight does not replace the final serialized publication check. Publication clears the event log using the event writer's five-second lock deadline, allowing a predecessor's shutdown append to finish during restart. Other callers of clear_events retain fail-fast event-lock behavior. Ordinary, undelegated event claims wait for a transient stale-checker's inode inspection when releasing, so a live daemon cannot strand its event lock. Delegated event claims retain the nonblocking withdrawal protocol.

Creation claims carry a decimal PID prefix plus a random per-claim nonce. Library launchers that already own the name must explicitly set SpawnParams::creation_lock_delegate = Some(creation_guard.delegate()?); neither the library nor daemon borrows a claim merely because its PID matches. LockGuard::pin() takes a nonblocking inode flock while the guard remains held; revocation installation holds that pin through its final effects. Delegated publication validates the nonce and owner on the same inode under flock, so an in-process install or gc claim cannot authorize an unrelated library launch. Delegation lazily creates a private per-claim token whose hashed filename keeps the session-ID boundary valid for sidecars and their atomic temporary files. Ordinary event writes do not create or remove token sidecars. Release withdraws that token before a nonblocking inode-lock attempt, so a stopped pinned daemon cannot hang the launcher, restart, or gc coordinator. A withdrawn token cannot be reactivated, and return checks it both before and after replacing the owner record. Publication pin drop finishes a withdrawn original claim's cleanup if no transfer occurs, preventing a live coordinator PID from leaving the name busy. Advisory inode leases explicitly unlock after their critical section, rather than relying on descriptor close: an unrelated concurrent fork may still hold an inherited close-on-exec descriptor before it reaches exec. The CLI alone upgrades the legacy external PTY_CREATION_LOCK_OWNER_PID contract to nonce delegation and restores a PID-only coordinator's original encoding after ordinary child spawn. Daemon nonce/owner control values are not inherited by the session command. Successful PID-only hand-back removes its now-unreferenced legacy token.

Version-1 records live at $PTY_ROOT/.publication-revocations/v1/<sha256(id)>/<sha256(operation)>.json, bound to a persistent root identity and the canonical ID and operation in the record. Enrollment validates the existing root too: roots/directories must be owner-private 0700, files 0600, owned by the effective UID. Ancestor symlinks (including macOS /tmp) are canonicalized before descriptor-relative no-follow traversal; the selected root's final component and every store component must not be symlinks. Publication uses atomic no-replace rename (renameat2 with RENAME_NOREPLACE on Linux, renameatx_np with RENAME_EXCL on macOS) and file/directory durability barriers. A crash before rename may leave an ignored .tmp.* file; after rename the canonical file has exactly one link, with no link/unlink crash gap. These records survive rm, GC, session recreation, display-name rename, and daemon replacement. Never delete them to roll back an intent, downgrade, or restore metadata; disaster restoration must restore the durable fences and the caller's intent/receipt state together before publication resumes. Replacing or reinitializing a fenced root is not an unrevocation mechanism.

Publication root re-enrollment after a device-number change: root identity validation includes canonical path, inode and st_dev. Device numbers can change across reboot/remount even when the filesystem and root are unchanged; PTY fails closed on this mismatch and never silently resets enrollment. Recovery is an explicit same-UID operator action, not identity enrolling a new root:

  1. Quiesce all publishers, launchers, installers and GC for this root. Back up the complete .publication-revocations tree and the matching caller intent/receipt state before editing anything.
  2. Independently verify this is the same filesystem and root directory (using the volume's persistent identity or mount configuration), that canonical path and inode still match v1/root.json, and that the complete fence tree was retained. A changed device number alone is not proof. If these conditions cannot be established, keep publication disabled and restore trusted state; do not use this procedure to migrate to another root.
  3. Replace only device in v1/root.json with the verified root's current st_dev, using an owner-private 0600 single-link temporary file, file fsync, atomic rename and parent-directory fsync. Keep schema, rootId, path, inode and every revocation record unchanged. Never delete or recreate the store, remove a fence, or generate a new root ID.
  4. Query publication-revocation identity --json and the retained exact fences; verify the original root ID and revoked operations before resuming publishers. Preserve the backup until those checks succeed.

This is a same-UID OS-principal trust boundary, not per-agent cryptography. Supported executable selection must exclude legacy/downgraded publishers for a managed root; an old binary cannot be trusted to obey a new fence. Existing serving daemons remain untouched, but installing a new binary does not upgrade their at-launch proof. Missing binding/root identity, old binaries or intermediaries, and mixed uncontrolled publishers are UNPROVEN, not retroactively authorized repair. New consumers must refuse attested repair in those cases; ordinary unmanaged use remains compatible. In particular, this feature provides no recovery claim for Fractal operation 01a145e9-f5e1-73a0-90d7-8c9abd6fb98c: its exact scope is unidentified and producer closure remains UNPROVEN.

For legacy tag-only launches, an absent revocation store does not impose enrollment restrictions on the existing root mode or symlinked ancestors. Absence must be proved without following the store entry; any present, malformed, symlinked or uninspectable store remains fail-closed under the strict enrolled-root checks.

The crates

A Cargo workspace of nine crates under crates/:

  • pty-core — the wire protocol, session registry and locks, events, metadata, names and tags, key/paste/duration/input parsing, pty.toml manifests, and the process table and process-tree termination. No terminal emulator, no Zig.
  • pty-client — the typed operations over a session's socket: list, attach, peek, screen and retained history reads, send, stats, signal, stop and remove, each also against a registry root the caller names. The pty binary's client commands print what these return. No terminal emulator, no Zig.
  • pty-spawn — open a PTY and start a child in it, and the typed owner that is the sole reader and reaper of that child. No terminal emulator, no Zig.
  • pty-lifecycle — daemon launch, startup leases and garbage collection, importable by other programs. No terminal emulator, no Zig.
  • pty-terminal — terminal state and nothing else: bytes in; libghostty's screen, retained history, cells, cursor, modes, kitty graphics, input encoding, query answers and VT/plain serializations out. It spawns no process and opens no PTY or socket; dependencies are libghostty, a PNG decoder, and pty-core for the shared typed history request and response.
  • pty-testkit — Playwright-style test sessions: spawn a process in a real PTY, feed it to libghostty, take screenshots, wait for text, send named keys.
  • pty-tui — the TUI library (ratatui + crossterm): pane, theme, focus, widgets, and the app runner behind the interactive session manager. The pane draws any LiveTerminal, such as a TerminalHandle.
  • pty-conformance — the black-box suite that runs against any pty binary, Node or Rust, chosen with PTY_TEST_BIN.
  • pty — the pty binary: the command-line interface, the per-session daemon, and the remote bridge. Its library is TerminalHandle, the embedding handle that spawns a child in a PTY or attaches to a session daemon and keeps a pty-terminal terminal on its own thread.

Building from source

  • Rust 1.90 or newer (edition 2024; rust-version is pinned in Cargo.toml, and libghostty-vt-sys 0.2.1 needs 1.90).
  • In the Nix shell: pkg-config and the shared native artifact are supplied automatically; Rust builds do not require Zig.
  • Outside Nix: either use the prebuilt release library below, or put Zig 0.15.2 and git on PATH. Without a pkg-config archive, libghostty-vt-sys builds Ghostty from source. Ghostty requires exactly 0.15.2, which cannot link the macOS 26.5 SDK; use Nix or a release archive on current macOS.
  • Unmanaged source builds clone the sys crate's pinned Ghostty commit and cache Zig packages under target/. GHOSTTY_SOURCE_DIR and GHOSTTY_ZIG_SYSTEM_DIR can override these inputs, but must be unset for pkg-config consumers because source overrides take precedence.
cargo build --release                        # target/release/pty

Shared native Nix artifact

nix build .#libghostty-vt builds the native library once, separately from Cargo, with static/shared libraries, C headers, and pkg-config metadata. overlays.default exposes this exact output as pkgs.libghostty-vt. lib.libghosttyContract names its Ghostty commit, Rust binding version, and toolchain pins; checks.libghostty-contract checks the actual locked sys crate's source against it. checks.libghostty-runtime-closure rejects accidental Zig/source/cache references in the artifact's runtime closure.

On Linux the package names its Zig target (-Dtarget=<arch>-linux-gnu -Dcpu=baseline) instead of building for the host. The libraries therefore run on any machine of their architecture and record the standard dynamic linker path, not the build host's /nix/store glibc. The build fails if any file under lib/ contains a /nix/store/ string, so a consumer that rejects store paths can link the archive. On macOS, Ghostty already builds for a generic target.

The default developer shell and libghostty-consumer shell supply the native artifact and pkg-config without Zig. The latter is a minimal Rust consumer environment, including Linux's mold linker. Clear ambient GHOSTTY_SOURCE_DIR and GHOSTTY_ZIG_SYSTEM_DIR before entering either shell. The native package installs its compatibility contract and license under share/; the macOS release job packages this output rather than compiling Ghostty again through Cargo.

Depending on pty-terminal or pty-testkit without Zig

Each release from v0.13.0-rust.2 on carries the libghostty-vt static library that pty-terminal, and so pty-testkit, links. The release workflow builds it for two targets:

Asset Built on
libghostty-vt-x86_64-unknown-linux-gnu.tar.gz Debian 12, Zig 0.15.2
libghostty-vt-aarch64-apple-darwin.tar.gz Namespace macOS arm64, in the pinned Nix shell

pty-terminal turns on libghostty-vt-sys's pkg-config feature. When pkg-config can find libghostty-vt-static, cargo links that archive and never runs Zig; when it cannot, the build falls back to Zig as before. Before an archive is attached to a release, the workflow builds and tests pty-terminal and pty-testkit against it with Zig off PATH, and the macOS archive outside Nix, with the runner's own toolchain.

You need Rust 1.90 or newer, pkg-config (or pkgconf) and a C linker:

tag=v0.13.0-rust.2
triple=x86_64-unknown-linux-gnu               # or aarch64-apple-darwin
base=https://github.com/compoundingtech/pty/releases/download/$tag
curl -sSfLO "$base/libghostty-vt-$triple.tar.gz"
curl -sSfLO "$base/libghostty-vt-$triple.tar.gz.sha256"
sha256sum -c "libghostty-vt-$triple.tar.gz.sha256"   # shasum -a 256 -c on macOS
mkdir -p ~/.local/lib && tar -xzf "libghostty-vt-$triple.tar.gz" -C ~/.local/lib
export PKG_CONFIG_PATH=~/.local/lib/libghostty-vt-$triple/share/pkgconfig
[dev-dependencies]
pty-testkit = { git = "https://github.com/compoundingtech/pty", tag = "v0.13.0-rust.2" }

Take the archive from the same release as the tag you depend on. The library and the Rust bindings come from one pinned Ghostty commit, which the archive's SOURCE file names, and libghostty's C API is not stable between commits. The workspace pins libghostty-vt and libghostty-vt-sys exactly for that reason, so a git dependency cannot resolve to bindings the archive was not built for. The pkg-config file's prefix is relative to the file, so the directory can live anywhere.

On macOS this is how these crates build with plain cargo: Zig 0.15.2 cannot link the current SDK, but the prebuilt archive is linked by the system linker.

Running the tests

cargo test --workspace                       # every crate's suite
PTY_TEST_BIN=target/release/pty cargo test -p pty-conformance   # black-box, any binary
./scripts/conformance-both.sh                # both binaries, side by side
python3 scripts/check-divergences.py         # fail on an unrecorded difference

conformance-both.sh runs every conformance file against both binaries and writes target/conformance/red.txt: the tests whose result differs. check-divergences.py compares that against crates/pty-conformance/divergences.toml and fails both when a difference is unrecorded and when a record no longer happens, so the ledger cannot drift into a list of stale claims. CI runs both. The Node commit it compares against is pinned in crates/pty-conformance/node-ref; a stale reference invents differences that are not there.

The workspace tests drive real programs through real PTYs and real daemons, with each test on its own PTY_ROOT under the temp dir. The conformance suite runs the same way against whichever binary PTY_TEST_BIN names, so it can be pointed at the Node pty to check the reference itself. Help texts and completion scripts are vendored byte for byte from the Node repository (crates/pty/tests/fixtures/help/, completions/) and the tests hold the binary to them.

Reading a single failure from a full run

One test failing in a whole-workspace run is not yet a defect. Re-run it alone before treating it as one.

cargo test -p <crate> --test <binary> -- --exact --test-threads=1 <name>

The suite runs 139 binaries in parallel, and each one drives real processes through real terminals. On a machine slow enough, one or two of them lose a race per run — and which ones varies across the whole suite, so a name you have never seen before is the normal case rather than a new regression.

Measured 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 when run alone.

So do not chase these by name. A failure worth fixing has a cause you can state — the two in this repository's history that looked like this both did: a sleep standing in for a handshake, in a test that then failed reliably once the timing was turned up. A name that passes alone and has no such cause is a scheduling accident, and hunting them one at a time is unbounded work.

Whether a slower machine is the whole explanation is not established; there is one laptop and nothing to compare it against.

Checking the macOS build without a Mac

pty-core and pty-client deliberately have no Zig dependency, so they can be type-checked for Apple silicon from any machine:

rustup target add aarch64-apple-darwin
cargo check -p pty-core -p pty-client --all-targets --target aarch64-apple-darwin

This is worth running before you touch anything platform-specific. It caught a call to pipe2, which Linux has and macOS does not, and it produced the same error a Mac did.

The check really does compile the macOS branches — a deliberate error inside one is reported, and the host build is unaffected by it. It no longer reaches pty itself: since 2026-09-22 its build script compiles a small C shim (crates/pty/native/darwin_socket_owner.c) against the macOS SDK's libproc.h, so the daemon's macOS code needs a Mac, as does running the whole workspace's TESTS.

About

shell wrapper server for detach, attach, and following of long running programs from anywhere

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages