Commits
Nathan wants Nix docs out of the READMEs into a dedicated nix.md across the
convoy/pty ecosystem; pty is the only core repo with Nix doc content, so this
is a pty-only change.
Move the "Or with Nix:" install block (nix profile install + nix develop) out
of README.md into a new nix.md at the repo root, and leave a one-line pointer
("Or install with Nix — see nix.md.") where the block was. nix.md structures
the Nix install (profile install / nix run) and the dev shell (nix develop +
the clone workflow), and notes flake.nix is the source of truth. Every command
is accurate to the flake — packages.default has meta.mainProgram = "pty" so
`nix run` works, and the dev shell provides nodejs_22/python3/pkg-config.
Doc-only and reversible: flake.nix is untouched.
Incident follow-up #3 — the deeper root cause. `pty restart` (and the dead-
session "Restart? [Y/n]" path) re-run a session's stored command under the
RESTARTER's shell environment. When cos was restarted from smalltalk's shell,
that shell's ST_AGENT=smalltalk-claude (and ST_ROOT) leaked into the re-exec,
so cos came back under the wrong bus identity and died (exit 129).
Strip the bus-identity vars (ST_AGENT/ST_ROOT) from an operator-initiated
restart's environment so a session re-exec'd from a different shell can never
inherit that shell's identity. Mechanism: a new `scrubEnv?: string[]` on
spawnDaemon deletes the named keys from the daemon's env before it spawns — and
therefore before the session child inherits them (spawnViaNode path). The two
operator-restart call sites (cmdRestart, handleDeadSession) pass
RESTART_SCRUBBED_ENV.
Scoped deliberately to restart: a fresh `pty run` is unaffected, because a
convoy-launched create legitimately inherits its own identity. And this keeps
`pty restart` appropriately un-blessed for agents (consistent with the #73
guardrail): it's now safe — it won't resurrect a session under the wrong
identity — but the correct way to restart an agent with a valid identity is
still its supervisor (convoy). pty itself reads neither var, so scrubbing them
changes only what the child inherits.
tests/restart-env-scrub.test.ts: a restart run from a shell carrying
ST_AGENT=smalltalk-claude / ST_ROOT=/leaked yields a child that records
UNSET|UNSET (identity scrubbed, not leaked); a fresh `pty run` still inherits
its creator's ST_AGENT/ST_ROOT (create path unaffected).
Incident follow-up #2. When cos's `claude --resume` froze on its exit screen,
the daemon was left alive+orphaned (ppid=1) needing kill -9. Cause: cleanShutdown
awaits server.close() then process.exit() with NO overall deadline. close()'s
child-exit wait is internally bounded (~2s), but the outer promise can still hang
indefinitely — socketServer.close()'s callback never fires for a lingering/
untracked socket, or eventWriter.flush() stalls — so the daemon never reaches
process.exit() and lingers forever.
Add a hard deadline (default 5s, PTY_SHUTDOWN_DEADLINE_MS-overridable) armed by
cleanShutdown: if the graceful close() hasn't completed by the deadline, the
daemon force-exits regardless AND SIGKILLs its child (PtyServer.forceKillChild)
so a frozen child isn't left orphaned to init still alive. cleanShutdown is now
idempotent too — SIGTERM/SIGINT/onExit/spawner-watchdog can overlap; only the
first arms the deadline and drives close(), the rest get the same in-flight
promise. Same class as #69/#72 but the frozen-child / stuck-close case.
tests/shutdown-backstop.test.ts: a child that traps SIGHUP wedges the graceful
path -> backstop force-exits the daemon and reaps the child (timing-independent
proof: only the backstop's SIGKILL can kill a SIGHUP-trapping child; the graceful
path only ever sends SIGHUP). A normal session still shuts down promptly, well
under the deadline. All existing lifecycle tests (spawner-watchdog, kill-wait,
exit-event-race, exit-signal, rm-kill-ephemeral, up-down) intact.
feat(restart): refuse to restart a stateful agent session unless --force
Incident: `pty restart -y org.myobie.cos` on a live `claude --resume` agent
wedged it — the abrupt kill + re-exec of the stored argv killed in-progress work
and left claude frozen on its exit screen with an orphaned daemon (and, since
restart re-execs under the OPERATOR's shell env, the respawn inherited the wrong
ST_AGENT and came back under the wrong bus identity, exit 129).
`pty restart` is a dumb "SIGTERM the daemon + re-run the stored argv" — great for
a stateless daemon, a footgun for a stateful interactive agent. Refuse it for
agent-shaped sessions unless --force, converting "claude agents cycle via their
supervisor, not pty restart" from convention into a CLI-enforced guardrail.
Detection (statefulAgentReason): a `role=agent` tag, or `claude --resume` in the
stored command. On a match, `pty restart` exits nonzero pointing at `convoy up`
and `--force` to override; non-agent sessions are unaffected.
tests/restart-guardrail.test.ts: role=agent refuses; `claude --resume` argv
refuses; a normal session restarts fine; --force overrides. Existing restart
tests (displayName preservation, gc-flap clear) still pass.
fix(server): surface a child's signal death (OOM SIGKILL) instead of recording exit 0
node-pty's onExit reports `{ exitCode, signal }`, but the daemon destructured
only `exitCode` — so a child killed by a signal (e.g. an OS OOM SIGKILL, which
arrives as signal=9 / exitCode=0) was recorded as a clean exit 0. A consumer
gating on "nonzero exit" (convoy's crash→ding) then mistook a real OOM death for
a clean finish and stayed silent.
Capture the signal and surface it the way a shell does — exitCode = 128 + signal
(SIGKILL 9 → 137) — so the existing nonzero gate catches it, and also carry the
raw `signal` on the session_exit event (+ render "killed by signal N"). Normal
exits are unchanged (no signal → raw exitCode).
- server.ts onExit: `{ exitCode, signal }` → effective code 128+signal.
- events.ts: SessionExitEvent gains optional `signal`; formatEvent shows it.
- docs/disk-layout.md: document the session_exit `signal` field + encoding.
- tests/exit-signal.test.ts: SIGKILL'd child records 137 + signal 9; clean exit
stays as its raw code with no signal.
Low-pri teardown cleanup. On teardown the daemon re-flushes exit metadata during
its (possibly watchdog-delayed) shutdown; a caller returning before that finishes
could see a stray registry temp file. Diagnosed: the ~8s residue is the
spawner-watchdog path (5s poll), not explicit `pty kill` (which re-flushes in
~50ms).
(A) `pty kill` now waits for the daemon pid to fully exit (bounded 3s) before
returning, so a following `pty rm` can't race the daemon's late write.
- sessions.ts: export isProcessAlive + add waitForProcessExit().
(ii) The daemon's saveExitMetadata() is now a no-op when the session's `.json`
was already removed — a late shutdown/watchdog flush won't resurrect a
`pty rm`'d session's file (or leave its atomic tmp behind). Normal exits are
unaffected (the metadata exists at exit).
Tests: tests/kill-wait.test.ts — daemon gone by the time kill returns; kill→rm
leaves no stray files; daemon shutdown doesn't resurrect removed metadata. Also
hardened tests/seq-delay.test.ts's timing assertion (bigger delay signal) so it
doesn't false-fail under a saturated parallel run.
Not done (per cos): didn't shorten the watchdog poll (behavior change for a
cosmetic gain).
Three bundled improvements toward "pty is independently useful: a great --help
(the reference) + a thin SKILL.md (the judgment)".
--help (the reference):
- Every subcommand now ships focused `--help` (usage + every flag + >=1 example)
via a central COMMAND_HELP registry + a single interceptor. Previously most
subcommands errored or silently ran on `--help`. renameUsage/printEmitHelp now
read from the same source, so error-usage and --help can't drift.
- Top-level `usage()` already listed every subcommand; added the missing
`run --force` line.
- tests/help.test.ts asserts every subcommand has working `--help`, and that no
dispatch `case` is undocumented (drift guard).
--seq default delay (decision from Nathan):
- `pty send --seq` now inserts a 0.3s gap between items by default so a trailing
key:return doesn't race ahead of the program parsing the typed text.
`--with-delay 0` = straight stream (opt-out); `--with-delay N` honored.
- resolveSeqDelayMs() (pure, in client.ts) is the single decision point; the
library send() still treats delayMs literally, so programmatic callers are
unchanged.
- tests/seq-delay.test.ts: deterministic (a) default=300ms, (b) 0=0, (c) N=N,
plus a delta-timed end-to-end check that 0 = no spacing.
- `pty send --help` + top-level usage state the 0.3 default and the 0 opt-out.
SKILL.md (the judgment): thinned to the routing-hook + what/when/idiom/footguns/
delegate shape. Footguns captured: broken global `pty` on PATH silently breaks
the whole message bus (st shells to `pty send`); PTY_ROOT is the isolation
mechanism (PTY_SESSION_DIR is masked under an ambient PTY_ROOT); and the --seq
timing footgun with the WHY (byte-burst vs spaced input; Enter racing the
program's parse/render), now mostly handled by the 0.3s default.
Follow-up to the scratch-session leak smalltalk-claude hit: a harness that
sets the deprecated PTY_SESSION_DIR to isolate, while running in an env that
already exports PTY_ROOT (e.g. a supervised session tree), had its
PTY_SESSION_DIR silently ignored — getSessionDir() prefers the canonical
PTY_ROOT — so its scratch sessions landed in the ambient registry.
Precedence is correct and unchanged (canonical PTY_ROOT wins over the
deprecated legacy var). The fix is visibility: getSessionDir() now warns once
(unless PTY_ROOT_LEGACY_SILENT) when BOTH are set, naming both dirs and
pointing at PTY_ROOT as the isolation mechanism — so the masking is obvious
instead of an invisible leak.
- src/sessions.ts: one-time masking warning when PTY_ROOT + PTY_SESSION_DIR
are both set.
- tests/pty-root.test.ts: regression that a `run -d` session with PTY_ROOT set
lands ONLY under it (not the co-set PTY_SESSION_DIR, not the default), plus
warning visible / PTY_ROOT_LEGACY_SILENT suppresses it.
- README: PTY_ROOT is the isolation mechanism; document the masking + warning.
Verified: pty-root suite 16/16; npm run typecheck clean.
Fresh-clone onboarding papercut: `pty --version` printed 'Unknown command'.
Add a version command (`version`, `--version`, `-v`, `-V`) that prints the
version per house convention '<semver>+<short-sha>' — semver from package.json,
short sha from git when this is a pty checkout, gracefully omitted otherwise
(e.g. an npm install prints bare '<semver>').
- src/version.ts: import-safe module with readPackageVersion / readGitShortSha /
formatVersion / printVersion. The sha lookup is gated on a .git in pty's own
package root, so an npm-installed copy inside an unrelated parent repo never
reports that repo's sha as pty's version.
- cli.ts: version switch cases + a Global help line.
- completions: add `version` (+ --version/-v) to bash & zsh.
- tests/version.test.ts: unit tests for formatVersion (with/without sha) +
integration tests that each form prints the version and exits 0.
Verified: all forms print e.g. 0.11.0+c87a6c6; npm run typecheck clean; tests green.
Fresh-clone sweep (thanks cos) found repo URLs still pointing at the old org:
- package.json repository.url -> https://github.com/compoundingtech/pty.git
- flake.nix meta.homepage -> https://github.com/compoundingtech/pty
- tests/list-filters.test.ts issue-ref comment -> compoundingtech/pty/issues/34
After this there are zero github.com/myobie repo URLs left in the tree. The
@myobie/pty npm package scope is intentionally kept (the package is still
published under that name; package.json "name" is @myobie/pty), as are the
com.myobie.pty.* launchd Labels (reverse-DNS ids, backwards-compatible).
The suite passes at runtime (vitest strips types), but `npm run typecheck`
(tsc over src+tests) was red with 6 errors that CI never caught — nix.yml
only runs `nix build` (src-only), not typecheck or vitest.
- tests/tui-framework.test.ts (x4): ScreenContext render mocks predated the
focus-manager work and were missing the now-required quit/focus fields.
Completed each mock (quit no-op + createFocusManager()).
- tests/exec.test.ts, tests/nesting.test.ts: `{ ...process.env, PTY_SESSION_DIR }`
loses the index signature, so `delete env.PTY_SESSION` didn't typecheck.
Annotated env as Record<string, string | undefined> (same pattern the other
test helpers already use).
Verified: `npm run typecheck` clean (0 errors); the 3 files pass at runtime
(95 tests).
All repos moved to the compoundingtech org (only homebrew-convoy stays
myobie). Switch the GitHub repo URLs in the docs:
- README nix install: github:myobie/pty -> github:compoundingtech/pty
- CHANGELOG pty-relay link -> github.com/compoundingtech/pty-relay
Left unchanged (NOT repo URLs): the npm package scope @myobie/pty (the
package is still published under that name — package.json name is
@myobie/pty) and the com.myobie.pty.* launchd Labels (reverse-DNS
identifiers, backwards-compatible with existing installs).
Audited README + all repo docs against the code on main. Two files carried
factual errors; the rest (README, CHANGELOG, disk-layout, SKILL, testing)
verified accurate.
docs/client.md:
- gc() documented as `Promise<string[]>` returning removed names; it actually
returns a `GcResult` object. Corrected the signature, example, and added the
GcResult shape.
- getSessionDir() described only `PTY_SESSION_DIR`; it prefers the canonical
`PTY_ROOT` (legacy name still honored).
- isReservedTagKey() listed `supervisor.status`, which is no longer a reserved
key (the supervisor was replaced by cron gc). Removed it.
DEVELOPMENT.md:
- Said the project "ships TypeScript directly, no build step, run via tsx".
Actually: tsx is not a dependency, `npm run build` compiles src/ to dist/
(rewriteRelativeImportExtensions), the published package ships dist/, and
bin/pty runs dist/cli.js with Node. Rewrote the build section, the design-
decision note, the architecture line, and the bin/pty entry; dev-usage
examples now use `node --experimental-strip-types src/cli.ts`.
- `pty run <name> <command>` -> `pty run -- <command>` (actual syntax).
- Socket-override env `$PTY_SESSION_DIR` -> canonical `$PTY_ROOT`.
verify-docs (docs/testing.md executable examples) still passes: 13/13.
When the vitest process itself runs inside a pty session, its ambient
PTY_ROOT / PTY_SESSION / PTY_SESSION_DIR leaked into every spawned `pty`.
getSessionDir() prefers PTY_ROOT over the per-test PTY_SESSION_DIR that the
helpers pass, so the spawned CLI read the developer's REAL live session dir
instead of the test tmpdir — non-deterministic failures, and a risk of
mutating live sessions.
- tests/setup/isolate-env.ts: new per-worker setupFile (registered in
vitest.config) that scrubs the three ambient vars before any test module
runs — the hard guard.
- Session.spawn: extract buildSpawnEnv() (pure, exported) and also scrub
ambient PTY_ROOT / PTY_SESSION_DIR there, so external users of the shipped
testing library get the same isolation. Guarded: an explicit root passed via
opts.env still wins.
- tests/env-isolation.test.ts: unit tests for buildSpawnEnv + a guard test
asserting the setupFile scrubbed the worker env.
- gc-flap-clear-badge-root-len: the restart test was passing only because an
ambient PTY_SESSION made `pty restart` skip its attach; set PTY_SESSION
explicitly so it no longer depends on the leak.
Verified: full suite green (1213 passed) WITH ambient PTY_ROOT set and no
env -u / shim workarounds; unchanged in clean CI (scrub is a no-op there).
pty restart / attach-restart / interactive doRestart re-spawned the
daemon from stored metadata but dropped displayName, so a restarted
session read as its raw id (e.g. claude-203827) instead of its name —
breaking naming and the TUI peek. Forward meta.displayName at all three
restart call sites, matching the existing `run -a` re-create path.
Tags were already carried. Adds a driven regression test that fails
without the fix.
Adds a second calling shape to text() so consumers that lay out
{ fg: color, bold: true, ... } maps compose cleanly. Original
text(str, color?, opts?) positional shape unchanged — dispatch is
by second-arg TYPE (string / array → positional Color, plain
object → opts-with-fg), not arity.
Fixes the demo-blocking issue where consumers of @myobie/pty/tui
using text("x", { fg: someColor }) hit `'fg' does not exist` on tsc
and "nodes is not iterable" at render time — the object shape was
reaching the positional `color` slot and getting spread as an
object where downstream code expected a Color.
Version bump 0.10.0 → 0.11.0 to release everything queued since
the last publish: the text() overload PLUS the accumulated
BREAKING changes since 0.10.0 (removed `pty state`, `pty wrap`,
supervisor/launchd-wrapper replaced by cron-driven `pty gc`,
name/displayName decouple, PTY_ROOT canonical env, `session_flapping`
event + fast-fail cap, etc.). See CHANGELOG.md 0.11.0 section.
5 new tests in tests/tui-framework.test.ts cover the object shape
(fg + bold; fg = SemanticColor string; object without fg; no-args
after str). Full suite: 1206 passed, 21 skipped, 0 failed.
Two lean-core deletes and an accuracy pass on the help / completion
surface. Nathan authorized, cos briefed, usage-check across pty,
eval-sandbox/st-evals, convoy, pty-claude-launcher, cos, pty-relay,
and pty-layout came back clean — no external consumers.
BREAKING (no back-compat shim):
- `pty state` (subcommand + programmatic API + events + metadata field)
gone. CLI subcommands `get`/`set`/`delete`/`keys` removed. Public
API exports `getState`, `getStateKey`, `setState`, `deleteState`,
`listStateKeys` dropped from `@myobie/pty/client`. Event types
`state.set` and `state.delete` removed from `EventRecord`.
`SessionMetadata.state?` field removed — Storage format change;
existing metadata files with a populated `state` object silently
drop the field on the next daemon-side rewrite.
- `pty wrap` / `pty unwrap` / `pty wrap --list` removed. `PTY_BIN_PATH`
env var no longer consumed. `~/.local/pty/bin/` no longer created;
existing shims can be `rm -rf`'d by hand.
Redundant with smalltalk's folder-and-bus persistence (for state) and
orthogonal to the session primitive contract (for wrap).
Accuracy pass:
- `usage()` rewritten. Commands grouped logically (Create / Attach &
interact / Observe / Modify / Lifecycle / Multi / Global); every
flag every current subcommand accepts listed; `<ref>` semantics and
the four env vars (PTY_ROOT / PTY_SESSION_DIR / PTY_ROOT_LEGACY_SILENT
/ PTY_SESSION) documented.
- `completions/pty.{fish,bash,zsh}` rewritten against the same surface.
Every current subcommand + every accepted flag covered; `state`,
`wrap`, `unwrap` removed. All three shells consistent.
Tests: tests/state.test.ts deleted (485 LOC). tests/atomic-writes.test.ts
and tests/events.test.ts shed their state-specific cases.
Suite: 1202 passed, 21 skipped, 0 failed (down from 1231 in main; -29
across the deleted state.test.ts + removed state.* format assertions +
one atomic-writes setState concurrency test).
Delta: +467 / -1223 across 14 files (13 modified, 1 deleted).
Three low-priority follow-ups to #56 and #55.
1. `pty restart` and `pty up` clear the fast-fail bookkeeping.
Manual restart is an operator "please try again" signal — dropping
`strategy.status`, `strategy.consecutive-fast-fails`,
`strategy.last-respawn-at`, and `strategy.command-hash` gives the
restart a clean slate, so the next `pty gc` tick isn't a no-op
against a stale flag. Auto-reset on command-hash divergence already
handled the toml-edit case; this handles operator-intervenes-
without-edit. Applies to both `cmdRestart` and `cmdUp`'s
"already running, tag-sync" branch.
2. `pty list` renders `[flapping]` in place of `[permanent]` when a
session carries `strategy.status=flapping`. Red instead of yellow —
the operator's expectation has changed, so the badge reflects it.
3. Startup-time PTY_ROOT length backstop. When the resolved root's
byte length + a default `/<8-char-id>.sock` suffix (14 bytes)
would exceed sockaddr_un's 104-byte kernel limit, `pty` errors
before any subcommand runs. Error names the root and points the
finger at the root, not the session name — the previous spawn-time
error read as if the name were the problem. Backstop respects
`--root <shorter>` overrides.
7 new tests in tests/gc-flap-clear-badge-root-len.test.ts cover:
restart clears bookkeeping, list shows [flapping] and hides
[permanent] when both would apply, list still shows [permanent]
otherwise, backstop errors on `pty list` with too-deep root,
backstop fires before subcommand parsing, root at usable threshold
succeeds, `--root <shorter>` overrides a too-long env.
Full suite: 1231 passed, 21 skipped, 0 failed.
Fixes #54. A `strategy=permanent` session whose leaf exits within
`strategy.fast-fail-window` seconds (default 60) of its previous
`pty gc` respawn counts as a fast fail. After `strategy.fast-fail-limit`
consecutive fast fails (default 3), gc writes `strategy.status=flapping`
on the session, emits a `session_flapping` event, and stops respawning
it. Subsequent ticks print `Skipped (flapping): <name>` and take no
action. Closes the crash-loop-with-live-cwd gap that #47/#50's
cwd-gone + idle reap didn't catch.
Bookkeeping tags stamped on every respawn:
- strategy.last-respawn-at (ISO ts)
- strategy.consecutive-fast-fails (running counter)
- strategy.command-hash (16-char sha256 prefix of the respawn cmd)
The classifier compares the current command fingerprint against the
stored hash; a divergence auto-resets the counter and clears
`strategy.status=flapping`. Manual reset: `pty tag <name> --rm
strategy.status`.
Per-session overrides `strategy.fast-fail-window=<sec>` and
`strategy.fast-fail-limit=<int>` beat CLI globals
`--fast-fail-window=<sec>` / `--fast-fail-limit=<int>` (which mirror
the `--idle-days` shape).
GcResult gains `flapped` and `flappingSkipped` buckets — additive,
existing consumers unaffected. cmdGc surfaces both.
10 new tests in tests/gc-flapping.test.ts cover: dry-run preview,
at-limit persistence + session_flapping event, silent skip on
subsequent ticks, slow-fail counter reset, command-hash auto-reset,
per-session + CLI-global window/limit overrides, first-respawn
(no prior last-respawn-at) case.
Phase-2 per-namespace isolation. `PTY_SESSION_DIR` becomes a legacy
alias (still functional, emits a one-time deprecation notice per
process; suppress with PTY_ROOT_LEGACY_SILENT=1). New canonical env
`PTY_ROOT` and matching global `pty --root <path>` flag pin the
state registry per call — every subcommand transparently scopes
via getSessionDir().
`pty gc --print-launchd-plist` parameterizes off the current root:
default root keeps the pre-Phase-2 Label (com.myobie.pty.gc) for
backwards compat with existing installs; non-default roots get a
sanitized Label suffix (com.myobie.pty.gc.<basename>) and a per-root
log path (<root>/gc.log). Emitted plist writes PTY_ROOT (canonical),
not the legacy name — a mid-migration installer sees loud stderr in
gc.log until it's cleaned up.
README gains a "Namespaces" section documenting soft (tag-filter)
and hard (--root/PTY_ROOT) isolation. smalltalk's Phase-1 `st.network`
tag composes with this cleanly.
Tests in tests/pty-root.test.ts cover: env-var precedence, one-time
deprecation notice + silence hatch, --root overrides both envs,
--root value validation, default vs non-default plist Label,
per-root logPath, pathological-basename sanitization.
The interactive picker's attach-remote path built `host.url + "/" +
session.name` and shelled to `pty-relay connect <that>`. For token URLs
(http/https) that works — pty-relay's parseToken picks the session name
out of the URL path. For ssh:// peers it collapses:
pty-relay connect ssh://user@host/session-name
pty-relay's `connect(tokenUrlOrLabel)` treats non-token URLs as
known-hosts labels, does `hosts.find(h => h.label === that-string)`,
misses, and exits 1 with "No known host". Even if resolveHost matched
by URL, the ssh branch takes the session name from `--session` /
`--spawn`, not from the URL path — the path segment is invisible to it.
Route around the seam by teaching the TUI to invoke pty-relay's
canonical ssh-peer shape:
pty-relay connect <host.label> --session <session.name>
Token URLs still get the existing path-append form (parseToken owns
that convention). Extracted `buildAttachRemoteArgs` so both branches are
unit-testable next to `buildSpawnRemoteArgs`.
A parallel fix in pty-relay (parse the session name from an ssh:// URL
path so `pty-relay connect ssh://user@host/name` also works) is in
flight as a mirror — the two together make the seam robust regardless
of which side the caller is on.
* fix(tests): reap leaked pty daemons at vitest teardown
Tests spawn pty daemons into mkdtempSync-based PTY_SESSION_DIRs. The
daemons detach by design (that's how `pty attach` after a crash works)
and vitest exits without reaping them, so every test run leaked a few
ppid=1 pty daemons + their cat leaves. Over days across pty +
pty-relay, this drove /dev/ttys past kern.tty.ptmx_max=511, blocking
new spawns entirely (`posix_spawnp failed`).
Fix: add a vitest globalSetup module that
1. mkdtempSync's a per-run root under /tmp (short prefix avoids the
macOS 103-byte AF_UNIX path limit — /var/folders/.../pv-XXX would
push nested test socket paths over),
2. exports PTY_VITEST_RUN_ROOT + overrides TMPDIR so every test's
os.tmpdir() call resolves into the run root (zero per-test changes
across the 43 files that call os.tmpdir()),
3. on teardown, `lsof +D $runRoot` enumerates every pid holding a
file or socket under the tree, SIGTERMs them, waits 2s, SIGKILLs
survivors, then rm -rfs the run root.
Verified on full suite (86 files, 1197 passed, 21 skipped, 0 failed):
daemons before = daemons after; ttys before = ttys after; teardown
reports "SIGTERM N leaked pids" for the daemons that would previously
have leaked.
* fix(tests): also sweep unix sockets in teardown — `lsof +D` silent-miss
`lsof +D <dir>` on macOS walks the filesystem tree but does not index
unix-socket-name entries. A process holding ONLY a bound listen socket
inside <dir> is invisible to that sweep. That describes the pty daemon
exactly: it holds its .sock file in the session dir, but no other open
files or cwd.
Reproducer:
mkdir -p /tmp/probe/x && node -e '
const s=require("net").createServer();
s.listen("/tmp/probe/x/leak.sock", () => setInterval(()=>{},1e9));
' &
lsof -Fpn +D /tmp/probe # → empty
lsof -Fpn -U | grep probe # → finds it
Add a second sweep: `lsof -Fpn -U` and match sockets by name prefix
(runRoot/ and /private<runRoot>/ — macOS symlinks /tmp -> /private/tmp
and the daemon's socket name resolves to the /private twin). Union the
pids with the +D sweep, then SIGTERM/SIGKILL as before.
Verified: on tests/nesting-prevention.test.ts the previous teardown
reported "SIGTERM 3 leaked pids"; with the -U sweep added it reports 6.
The 3 the +D-only sweep missed were the ones holding only sockets.
Full suite still 1197 passed / 21 skipped / 0 failed; daemon + tty
counts before == after (delta 0).
Credit: pty-relay-claude flagged the silent miss while implementing the
mirror fix in pty-relay (PR #25).
Under Node 24+, V8 names the main thread "MainThread", so every pty
process (the bin/pty signal-forwarding wrapper, the CLI, each session
daemon, and the supervisor) shows up as `MainThread` in ps/top/htop/btm.
That makes them indistinguishable from any other Node process and hurts
observability and leak triage.
Set `process.title` early in each entry point so they report a
meaningful name instead:
- bin/pty -> "pty" (signal-forwarding wrapper)
- src/cli.ts -> "pty" (CLI)
- src/server.ts -> "pty-daemon" (session daemon, guard-scoped)
- src/supervisor-entry.ts-> "pty-supervisor" (supervisor)
`process.title` is the only thing that overrides /proc/<pid>/comm, and
only when set from within the running process after V8 init — launch
flags (`node --title`, `exec -a`) do not work. Linux caps comm at 15
chars (TASK_COMM_LEN); all titles stay well under. Each assignment is
wrapped in try/catch so it can never throw. server.ts only sets the
title inside its `argv[1].endsWith("/server.js")` daemon-entry guard,
since the module is also imported as a library (PtyServer).
Adds a Linux-only test asserting a spawned daemon's /proc/<pid>/comm is
"pty-daemon".
Claude-Session: https://claude.ai/code/session_01FKmH5V4zLM7HXsbNohqPV9
Co-authored-by: schickling-assistant <schickling.ventures@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Two bugs in src/tui/buffer.ts making width-2 emoji (📬, 📭, 📫 —
astral-plane codepoints U+1F4XX) fossilize under app() incremental
re-render, observed by tui-sup while building agent-viz on top of
@myobie/pty/tui: a `📬` on a cards grid navigating until it scrolled
would end up displayed as `📬📬` (fossil + new) with shifted digits.
Root causes:
1. writeAnsi iterated the input by UTF-16 code unit (`ansi[i]`), so
astral-plane codepoints stored as surrogate pairs got split into
two lone surrogate halves in separate width-1 cells. Downstream
diff() and fullRender() then emitted the halves independently.
Modern host terminals (kitty, iTerm2, Ghostty) recombined them as
one wide glyph, but the CellBuffer's cell-index → terminal-column
mapping was off by one on every emoji, cascading into misaligned
writes as the row mutated. Fixed by detecting the high-surrogate
/ low-surrogate pattern and storing the full 2-code-unit string
in one Cell.
2. diff()'s `lastCol = c + 1` cursor-position tracker didn't account
for wide chars advancing the terminal cursor by 2. After emitting
a width-2 glyph at column c, the terminal cursor is at c+2, not
c+1. The adjacency check on the next emit then mispredicted,
causing an extra cursor move (efficiency loss) that combined with
the surrogate-split bug above produced positional drift on rows
with mixed content. Fixed by `lastCol = c + charWidth(nc.char)`.
Refactored the per-cell emit block into an `emit()` closure so both
the wide-char path and any future placeholder-clearing path share the
style-reset + SGR emit logic.
Test coverage: tests/buffer-wide-char-diff.test.ts — 10 cases using
xterm-headless + @xterm/addon-unicode11 as the oracle. The unicode11
addon is required because default xterm.js width tables treat all
astral-plane codepoints as width-1, which hides the bug behind an
environment quirk; with unicode11 the harness matches real modern
terminal semantics.
Fixes the framework bug tui-sup filed via coord; unblocks agent-viz
using `📬` directly without a width-1 workaround.
Adds a new `pty gc` step 1.5 between orphan-kill (step 1) and permanent
respawn (step 2) that reaps live `strategy=permanent` sessions detected
as abandoned. Fixes #47.
Two reap shapes today:
- cwd-gone (on-by-default) — the session's recorded cwd no longer
resolves on disk (fs.statSync throws ENOENT). Strong low-false-
positive signal. Escape hatch: `strategy.abandon-if-cwd-gone=false`
tag opts a session out.
- idle (opt-in) — the session's lastAttachAt is older than the
configured threshold. Enabled via `pty gc --idle-days N` (global)
or a per-session `strategy.idle-days=N` tag (per-session wins).
Sessions with no lastAttachAt (never attached) are excluded — a
session just spawned but never used isn't "idle."
cwd-gone takes precedence over idle when both fire (cwd is the stronger
signal). Reaps SIGTERM the daemon, append a `session_abandoned` event
BEFORE cleanupAll unlinks the events file, then cleanupAll.
Public surface changes:
- SessionMetadata gains `lastAttachAt?: string` (ISO 8601), written by
the daemon on every non-readonly ATTACH.
- EventType.SESSION_ABANDONED + SessionAbandonedEvent {reason, idleDays?}.
- GcResult gains `abandoned: {name, reason, idleDays?}[]`.
- CLI: `pty gc --idle-days N` flag (positive integer required; 0/neg
rejected). Output row `Abandoned: <name> (<reason>)` and dry-run
mirror `Would abandon:`. Summary counts abandoned sessions.
- Tags: `strategy.abandon-if-cwd-gone=false`, `strategy.idle-days=N`.
Docs: disk-layout.md updated with the new metadata field, event, and
tags. CHANGELOG entry under Unreleased.
Tests: 11 new in tests/gc-abandoned.test.ts covering all six paths
(cwd-gone live reap, non-permanent unaffected, opt-out tag honored,
--dry-run preview, idle reap on old lastAttachAt, under-threshold
preserved, never-attached preserved, per-session tag opt-in without
CLI flag, cwd-gone > idle precedence, flag validation) plus one
mixed-bucket test proving abandoned-reap composes with the existing
respawn/sweep in the same pass.
Track whether the child has entered the alternate screen buffer (DEC
private modes ?1049 / ?1047 / ?47) via a new altScreenActive flag on
PtyServer, and prepend \x1b[?1049h to the SCREEN packet payload sent
on ATTACH when the child is currently in alt-screen. PEEK does not
get the prefix — a non-follow peek prints its snapshot into the
caller's shell and exits, so entering alt-screen would hide the
output when the client's TERMINAL_SANITIZE (?1049l) fires on close.
Fixes #41. Before this, a full-screen TUI (vim/htop/codex) attached
through pty would paint into the host's main-screen buffer, which
under tmux meant every full repaint entered scrollback. Observed
impact: 8.7 GB scrollback + 9.1 GB tmux server RSS over 9.5h on a
long-lived codex pane, with 0.4-2.3s server-loop stalls.
The wire format is unchanged — the prefix rides on the existing SCREEN
payload as a strict superset. Old clients (pre-fix) receiving the
prefixed payload just write it verbatim to their terminal and enter
alt-screen correctly. Client-side detach already emits ?1049l via
TERMINAL_SANITIZE, so the round-trip is symmetric.
feat(gc): replace long-running supervisor with cron-driven `pty gc`
The launchd-managed `pty-supervisor` daemon raced /Volumes/SSD's mount
on Mac boot: permanent sessions whose `dist/server.js` lived under the
external volume failed restart with MODULE_NOT_FOUND, exhausted 5
retries x 2s backoff within 10s, then were marked "no metadata" and
never came back without manual `pty up`. A long-running supervisor will
always have this class of bug — it can't be sure its dependencies are
ready when launchd fires it.
Replace it with a stateless reconciliation pass:
pty gc
invoked periodically by launchd (`StartInterval=30` by default) or any
other cron-driver. Three steps:
1. Orphan-children: sessions tagged `parent=<name>` whose parent's
metadata is gone OR whose parent's pid isn't alive get SIGTERM'd
and cleaned up. New user-facing tag.
2. Permanent respawn: every `strategy=permanent` session that's
exited/vanished is respawned via `spawnDaemon`. Sessions tagged
`ptyfile` re-read pty.toml to pick up edits.
3. Sweep: exited/vanished non-permanent sessions get `cleanupAll`'d
(the historic gc behavior).
Each run is independent — no persistent restart bookkeeping, no
MAX_RESTARTS, no backoff state to drift out of sync with reality.
Cron interval IS the rate limit; if /Volumes/SSD isn't mounted, the
invocation exits with ENOENT and the next tick tries again.
Install helper: `pty gc --print-launchd-plist [--interval=N]` prints
a minimal plist to stdout. No FDA wrapper, no compiled C binary, no
esbuild bundling — `<ProgramArguments>[pty, gc]</ProgramArguments>`
with `<StartInterval>`. User redirects to ~/Library/LaunchAgents/
and `launchctl load`s themselves.
Breaking changes:
- Deleted `pty supervisor *` commands entirely. Users who installed
the old supervisor should `launchctl unload` + remove the old
plist (see CHANGELOG for the exact commands).
- Deleted event types `session_restart`, `session_failed`,
`supervisor_start`, `supervisor_stop`. Added `session_respawn`.
- Deleted reserved tag `supervisor.status`. Added user-facing tag
`parent=<name>`.
- Dropped `strategy=temporary` (was functionally identical to no
strategy tag under the new sweep behavior).
- `gc()`'s return shape changed from `string[]` to a structured
`GcResult` with four buckets (`removed`, `killedOrphanChildren`,
`respawned`, `respawnFailed`). Public API.
Net delta: ~1830 lines deleted, ~485 added.
pty is a public repo; PR #46 committed a `.mcp.json` with a
machine-specific absolute path (`/Volumes/SSD/.../coord/bin/st`).
That leaked one of myobie's local paths into public history and the
file isn't portable to any contributor who clones — they don't have
that path on disk.
Matches the smalltalk + transplant conventions: `.mcp.json` is
machine-local config, kept out of git via `.git/info/exclude`
(per-machine, not in .gitignore so the policy doesn't get committed).
The on-disk file is preserved — the agent's running MCP server reads
it on next session start as before. Future contributors set up their
own `.mcp.json` per-machine.
History note: the absolute path is still in older commits on `main`.
That's a benign disclosure (one filesystem layout, no secrets), so
no history rewrite — only future commits stop carrying the leak.
* feat(name): decouple on-disk identifier from display label
Sessions now have a short random on-disk identifier (sock + json filename)
that is independent of the user-visible display label. This unblocks long
descriptive labels — both via `pty.toml` prefixes and via `pty run --name`
— which previously hit the macOS `sockaddr_un.sun_path` 104-byte limit.
Breaking changes
- `pty run --name <X>` now sets the displayName (any length, any printable
chars). The on-disk id is set by `--id <X>`. Both omitted → random short
id + auto-generated displayName. Both can be combined.
- `pty up` (pty.toml) gives every session a random short on-disk id. The
toml-derived `<prefix>-<sessionKey>` becomes the displayName, not the
filename. `pty up` re-run detection now matches by `(ptyfile,
ptyfile.session)` tag pair instead of by name.
- `pty.toml` gains two optional per-session fields: `id = "..."` to pin the
on-disk id and `display_name = "..."` to override the default label.
- `pty rename` validation: displayName uses the new permissive
`validateDisplayName` (≤ 500 chars, no slashes / null / newlines /
control bytes) instead of the strict `validateName` (sock-filename
charset, sock-path length). Strict validation is reserved for ids.
- `SessionMetadata.displayName` is now preserved through exit (previously
`saveExitMetadata` dropped it).
- `PtySessionDef.name` replaced by `displayName` + optional `id`;
`shortName` unchanged. External callers (`pty kill`, `peek`, `send`,
`attach`) are unaffected — they already resolved by either field via
`resolveRef`.
- `spawnDaemon`'s bundled-context fallback now passes `--id` (instead of
`--name`) to the CLI delegation path and explicitly threads
`displayName` / `--no-display-name`.
Tests
- New `tests/up-name-decouple.test.ts` covers random id, long-prefix
support, `(ptyfile, ptyfile.session)` re-run lookup, pty.toml `id` and
`display_name` overrides, and operations resolving by displayName.
- Existing tests migrated from `--name <id>` to `--id <id>` for tests
whose intent was pinning the on-disk identifier. `display-name.test.ts`
rewritten to test the new `--id` / `--name` semantics including
long-label, kernel-limit rejection, and id/name collision rejection.
Full suite green (1168 passed / 22 skipped / 1190 total).
* fix(cli): drop validateName from session-resolution paths
Long displayNames could be created via `pty run --name <long>` but
operating on them (`pty peek|send|kill|tag|events|attach|restart|rm`)
failed with the `validateName` sock-path kernel-limit error — the
strict validator was running on the user-supplied ref BEFORE
resolveRef did the lookup. The strict validator's job is to gate
on-disk id creation, not session-reference resolution.
Fix per schickling-assistant's PR #45 review:
- Drop the validateName(ref) block from 7 sites in cli.ts: attach,
peek, send, events, tag, kill, rm, cmdRestart, and tag-multi by-name
selector.
- Add resolveRef at the supervisor forget/reset dispatch sites so
cmdSupervisorForget/Reset receive a resolved name (their bodies
drop their own redundant validateName as a consequence).
- Drop the redundant validateName inside cmdRestart — its dispatcher
already resolved the ref.
validateName remains in place where it belongs: on-disk id creation
in `pty run --id`, pty.toml session id, and `pty rename` displayName
candidate paths.
Tests: 6 new regression tests in tests/display-name.test.ts covering
a 110-char displayName roundtripped through peek/send/tag/events/kill
plus the create case. Full suite: 1174 passed / 22 skipped / 0 failures.
Switches pty's MCP server registration from `coord` to `st`, the
canonical short name from brief-005. Binary path moves from `bin/coord`
(back-compat shim) to `bin/st` (canonical entry).
Both forms continue to work post-Phase-0 — the coord repo's PR #14
shipped a 5-alias backward-compat layer. This commit just opts pty into
the canonical names now that they exist. Takes effect on next
pty-claude restart.
Local-only configs (pty.toml `ST_IDENTITY`, hook paths) are gitignored
and updated out-of-band on each machine.
Adds an optional `env` table to each pty.toml session entry. The parser
collects it into PtySessionDef.env, and a new commandWithEnvExports()
helper builds an `export K='V'; <command>` prefix that's prepended when
the supervisor wraps the command in `/bin/sh -c`. Single-quote escaping
keeps values with `'` and shell metacharacters safe.
Both the initial spawn path (cmdUp) and the restart re-read path
(supervisor.evaluateSession) call the helper, so env survives session
restarts and pty.toml edits.
Tests: tests/ptyfile.test.ts (parsing + export-string shape, including
single-quote and metacharacter escapes) and a new integration case in
tests/up-down.test.ts that spawns a session, redirects $MY_VAR to a probe
file, and asserts the value landed inside the session.
Repo-local MCP registration so Claude Code in this repo's pty
session boots with the coord channel-mode MCP server connected.
Identical contents to the coord repo's .mcp.json — coord is the
producer of the MCP server, pty just consumes it. Points at the
coord repo's `bin/coord` directly.
env is empty: COORD_IDENTITY=pty-claude is set in pty.toml (next
commit), COORD_ROOT defaults to ~/.local/state/coord.
Part of brief-018 (switchover package) — see
github.com/myobie/coord notes/brief-018-switchover-package.md.
`spawnDaemon` produces a detached daemon process that has no mechanism to
self-terminate when its spawner exits without calling `disconnect()` /
`kill()`. Because `detached: true` puts the daemon in its own session,
the kernel sends no signal when the spawner dies — the daemon is
reparented to init and survives forever.
In practice this leaks daemons whenever a short-lived script, test
harness, or scoped resource (e.g. `@overeng/pty-effect`) spawns a daemon
and exits. We observed hundreds of `dist/server.js` processes
accumulating multi-GiB RSS over days. See effect-utils#677.
Add a new `bindToSpawnerLifetime` option (opt-in, default off). When set,
`spawn.ts` injects `PTY_SPAWNER_PID=<pid>` into the daemon's env, and the
daemon polls `process.kill(pid, 0)` every 5 s. ESRCH triggers a clean
shutdown via the existing `cleanShutdown(0)` path. An invalid or
already-dead PID at startup causes immediate shutdown.
Long-lived supervisors that want daemons to outlive them simply omit
the option — full backwards compatibility.
Refs: overengineeringstudio/effect-utils#677
The PR's new tests generated session names like `bundle-fb1-ov5o` (15 chars).
Combined with macOS's tempdir under /var/folders/.../T/ (~52 chars) plus the
test root prefix and per-test "d-XXXXXX/", the resulting socket path tipped
over the 104-byte Unix-socket kernel limit by 1 byte.
Shorten the name format to ~6 chars (b<counter><rand3>); leaves enough margin
on /var/folders/... tempdirs.
`spawnDaemon` previously did `spawn('node', [<__dirname>/server.js])`.
Under bundlers that virtualise the filesystem (`bun build --compile`,
esbuild single-file, etc.) `import.meta.url` resolves to a `bunfs:`-style
path the spawned `node` child can't read, and the daemon fails to start.
The first iteration of this PR addressed that with an embedded server-
source bundle materialised to `os.tmpdir()`. That worked for resolving
server.js itself but exposed a deeper failure mode: the materialised
file lives outside the consumer's `node_modules`, and ESM resolution
does not honour `NODE_PATH` — so the daemon can't find its own external
deps (`node-pty`, `@xterm/*`) either, and every consumer would need a
bespoke `node_modules`-symlink dance to recover.
Replace the embedded-source approach with CLI delegation. The resolution
strategy becomes:
1. `setServerModulePath()` override — wins over everything (test
harnesses, supervisors with custom paths).
2. Sibling `__dirname/server.js` readable on disk — direct
`node <server.js>` (existing fast path for ordinary npm installs).
3. Bundled context — sibling unreadable. Shell out to `pty run -d
--name <name> --cwd <cwd> [--isolate-env] [--tag k=v]... -- <cmd>
<args>`. The CLI binary is always a real on-disk file with intact
module resolution, so it sidesteps every bundling failure mode at
once: spawning, server materialisation, daemon module resolution,
native-binding loading.
Tradeoff: the CLI path doesn't surface every `SpawnDaemonOptions` field
(`rows`, `cols`, `displayCommand`, `displayName`, `ephemeral`,
`extraEnv`, `env`, `launcher`). For the dominant consumer pattern
(spawn a shell, attach a UI, resize after attach), only `cwd`, `name`,
`tags`, and `isolateEnv` are load-bearing at spawn time — all
supported. Add CLI flags upstream as concrete needs arise. Consumers
that need full fidelity in a bundled context can still call
`setServerModulePath()` with a real on-disk server.
Drops `scripts/embed-server-source.js`, the `dist/server-source.txt`
artifact, and the `esbuild` dev-dep that the embed pipeline required.
Tests cover all three resolution strategies (on-disk, CLI delegation,
explicit override) and the no-CLI-on-PATH error path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related issues caused permanent supervised sessions to be silently
dropped after a single slow startup (observed on reboot with heavy
claude --resume sessions, see ~/.local/state/pty/supervisor.log).
1. waitForSocket timeout was hardcoded to 3000ms in spawn.ts. Heavy
children like `claude --resume` of a large conversation routinely
take 5-15s to open their socket, so the supervisor's first restart
attempt would time out before the child was ready.
2. doRestart's recovery path on retry was broken. The flow:
- First attempt: readMetadata(name) → fresh data; cleanupAll(name)
removes the metadata file; spawnDaemon throws on timeout.
- scheduleRestart catches, bumps restartCount, schedules retry.
- Second attempt: readMetadata(name) → null (file gone); doRestart
hits "skipping restart for X (no metadata)" and the session is
permanently abandoned.
Fix:
- Bump waitForSocket default to 30000ms via a new
DEFAULT_START_TIMEOUT_MS constant. Add an optional startTimeoutMs
field on SpawnDaemonOptions for callers that want tighter or looser
bounds. Earlier-exit detection still surfaces genuine failures
within milliseconds; this only governs the "alive but slow" case.
- Cache the spawn config on SupervisedSession.spawnConfig at
evaluateSession time and update it again immediately before
cleanupAll + spawnDaemon. When doRestart's readMetadata returns
null but the session is still tracked as permanent with a cached
config and not yet marked failed, fall back to
respawnFromCachedConfig instead of bailing.
Existing 17 supervisor tests stay green. A targeted regression test
for the slow-start-then-retry sequence would need spawnDaemon mocking
infrastructure that the test suite doesn't currently have; deferred.
Add a readonly bracketedPasteMode: boolean to PtyHandle, parallel to the
existing alternateScreen / mouseMode / kittyKeyboardFlags getters.
Delegates to xterm-headless's native terminal.modes.bracketedPasteMode —
no manual CSI parser tracking needed.
Use case: pty-layout currently enables \\e[?2004h on its outer terminal
unconditionally so editors like helix get paste-no-indent. That forwards
paste markers to any focused pane regardless of whether the program
consuming stdin handles them; a CLI using gets() can receive literal ESC
sequences in pasted content. With this getter, pty-layout can proxy the
mode by-pane: when the focused pane has it on, write \\e[?2004h to outer;
when it doesn't, write \\e[?2004l.
Allows downstream consumers (e.g. apps bundled with `bun build --compile`)
to override the resolved server module path without deep-importing
`./dist/spawn.js`, which the package's `exports` field forbids.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bin/pty was using spawnSync, which doesn't forward signals to the
child process. When systemd (KillMode=process) sent SIGTERM to the
wrapper during a service restart, the wrapper exited but the inner
supervisor cli kept running, holding supervisor.lock. The next
service start then failed with "another supervisor is already
running" — and systemd's Restart=on-failure looped that forever.
Fix: switch to spawn so the wrapper stays event-loop-live, register
forwarders for SIGTERM/SIGINT/SIGHUP/SIGQUIT/SIGUSR1/SIGUSR2, and
drive wrapper exit from the child's exit event so the child has
time to flush. Signal-death maps to 128 + signum per shell
convention. spawn-failure prints a clear stderr message and exits 1.
Reported and fixed by @schickling-assistant. Includes
tests/wrapper-signal-forwarding.test.ts which spawns the real
wrapper running supervisor start, SIGTERMs the wrapper, and asserts
the inner cli pid dies and supervisor.lock is cleared (proving
graceful shutdown rather than wrapper-level SIGKILL). The reporter
verified the test fails when the old spawnSync wrapper is reapplied.
listSessions() had two cleanup paths that ran regardless of whether
the recorded pid was still alive:
1. The .sock branch deleted the socket file whenever isSocketReachable
failed (500ms probe trips on busy daemon, transient EAGAIN, race
with a service restart). Once .sock was gone, the still-alive
daemon became invisible to every future scan with no recovery
short of kill -9.
2. The .json branch unconditionally deleted metadata older than 24h.
Long-lived daemons whose metadata wasn't refreshed (because nothing
refreshes it) had their .json removed after a day even though the
process kept consuming memory.
Both paths now gate on isProcessAlive(pid). If the pid is alive, the
state is kept. The TTL is meant to age out known-dead sessions, not
unknown-state ones; the kill(pid, 0) probe is one cheap syscall per
dead-looking session per scan.
Reported and fixed by @schickling-assistant. Includes regression
tests in tests/list-filters.test.ts that exercise both the live-pid
guard and the matched negative case where the 24h TTL still fires
when the pid is dead.
The bin/pty wrapper used spawnSync, which (a) blocks the wrapper's event
loop so any signal handlers we'd register would never run, and
(b) leaves no path for SIGTERM/SIGINT/etc. to reach the cli.js child.
Under systemd with KillMode=process, the wrapper is the unit's MainPID.
systemd SIGTERMs the wrapper, the wrapper dies, but the inner cli.js
supervisor — which has its own SIGTERM handler that calls
Supervisor.stop() and releases supervisor.lock — never sees the signal.
It gets reparented and keeps running. Every subsequent unit invocation
then exits with 'another supervisor is already running' because the
orphan still holds the lock. Reproduced on a NixOS host where the
restart counter climbed past 500 with the original supervisor still
alive after an hour.
The fix switches to spawn() and forwards SIGTERM/SIGINT/SIGHUP/SIGQUIT/
SIGUSR1/SIGUSR2 to the child, then waits for the child's exit event
before propagating its status. Normal exit-code passthrough is
unchanged. Signal-death is mapped to 128+signum, matching shell
convention.
Tests in tests/wrapper-signal-forwarding.test.ts spawn the wrapper
running 'supervisor start' against an isolated PTY_SESSION_DIR, SIGTERM
the wrapper, and assert the inner cli.js dies and supervisor.lock is
released. Verified the test fails against the old spawnSync wrapper
(inner pid survives, lock leaks).
listSessions() previously deleted .sock files whenever a socket was
unreachable, even if the recorded pid was still alive. That made any
transient socket-reachable failure (busy daemon, EAGAIN, race with a
service restart) permanent: once the .sock is removed, the still-alive
daemon becomes invisible to all future scans.
It also called cleanupAll() on .json files older than 24h without
checking whether the daemon process was still running, so long-lived
daemons silently lost their metadata after a day and disappeared from
'pty list' even though they kept consuming RAM.
This commit makes both checks gated on isProcessAlive(pid):
- if pid is alive but socket is unreachable, keep both .sock and metadata,
report status as running
- if .json is older than 24h, only cleanupAll() when pid is dead
Refs: https://github.com/myobie/pty/issues/34
The interactive overview was a snapshot taken on screen entry — new
sessions, exits, tag changes, and rename events stayed invisible
until the user navigated away and back. Reported by
@schickling-assistant.
Implementation: poll listSessions() once a second while the home
screen is the active view, push the result through the existing
`sessions` signal. The framework's effect()-wrapped renderFrame
re-runs because it tracks signal reads — no other plumbing needed.
Polling pauses around attach / spawn handoffs (pauseApp /
resumeApp wrap myApp.pause / myApp.resume so the polling toggles
in lockstep), and the interval is .unref()-ed so the event loop
isn't held alive by it (when stdin closes the process exits
cleanly).
Tried fs.watch + EventFollower first; both were silent in the
test harness because the TUI runs in a node-pty child process and
the file changes happen in a sibling process — that combination
isn't reliably picked up on macOS. Existing EventFollower tests
all run in-process so the cross-process case wasn't covered. A 1-
second poll is responsive enough for the "did a session appear
or exit" UX, costs a single readdir + per-file stat per tick,
and works on every platform regardless of fd / IPC quirks.
Tests (tests/tui.test.ts) cover three trigger types end-to-end
through a real PTY: session created externally appears in the
list; session that exits externally shows up as exited (or its
absence is reflected); tag added externally with `pty tag`
renders inline as `#k=v`. Each waits up to 5-8s for the next
poll tick to fire.
Filing in / out of sessions cleared the overview's filter on every
return (and reset the selection), which made navigating multiple
sessions feel disorienting per @schickling-assistant's report.
Mechanism: doAttach's onDetach/onExit handlers, doAttachRemote's
post-attach refresh, and doSpawnRemote's post-spawn refresh all
explicitly called filterField.set({ text: "", cursor: 0 }) before
resuming the app. Removing those clears makes the module-level
signals naturally persist across the attach round-trip.
The selection clamp logic is intentionally kept — when the list
shrinks (e.g., the session you were attached to exits and gets
reaped before you return), selectedIndex still snaps in-bounds via
the existing Math.max(0, totalItems - 1) check. That's the right
behavior either way and is independent of the filter-preservation
fix.
Test (tests/tui.test.ts) creates three sessions, types a filter
that narrows to one, attaches to it, detaches, and asserts: the
"Filter: ..." line still shows the typed value, the matched
session is still visible, the other two are still hidden by the
filter. Then ctrl+u clears it and the other two reappear, which
also confirms the filter cursor is preserved (ctrl+u operates on
the live cursor position).
New sibling command pty tag-multi for multi-session work. Single-
session pty tag stays as-is; pty tag-multi lives next to it so a
fingerslip cant accidentally rewrite the wrong scope.
Three mutually-exclusive selectors:
<name>... explicit list (resolved up-front; if any
name is unresolvable the command aborts
before any write — no partial application)
--filter-tag k=v sessions matching tag(s); repeatable AND
--all every session
No ops = read mode (per-session tag dump, text or --json returning
{ name → tags }). Any ops (k=v / --rm k) flips to write mode. --all
writes are destructive across the whole session dir, so they require
--yes / -y; without it the command prints the matched count and
exits non-zero, no writes performed. Empty match (filter or --all
over an empty dir) is exit 0 with "0 sessions matched."
Each successful write goes through the existing atomic updateTags
+ tags_change event flow per-session; per-session no-ops emit
nothing. Selector resolution is upfront for explicit lists, lazy
for filter / --all (just listSessions).
Also hardens the existing single-session pty tag while we're here:
- rejects empty key in `=value`
- rejects --rm "" (empty key for removal)
- clearer error for --rm at end of argv with no key
- tightened parsing comments
Tests:
tests/tag-multi.test.ts (38 tests) — read explicit list, read
selectors (--filter-tag single/repeated/empty, --all on empty
dir), write explicit list (events fire per session, no-op on
one peer doesnt suppress the others, upfront-unresolvable
aborts), write selectors (--filter-tag, --all without --yes
rejected, --all --yes applied, -y short form), selector mutex
(--all+filter, --all+names, filter+names, no selector, ops
without selector), ops parsing errors (empty key, --rm at end,
--rm "", --filter-tag without value, --filter-tag without =,
multi-= splits on first =, set+rm same key wins for rm to
match pty tag).
tests/tag-bulk.test.ts (29 tests) — pin the existing pty tag
contract: bulk set, bulk rm, combined set+rm, position
independence, empty key/value edges, atomic events (one
tags_change per call), no-op detection, partial no-op + real
change, displayName resolution.
CHANGELOG and README reflect both.
For non-Node consumers that want to read pty's session state directly
without paying Node CLI startup. Covers PTY_SESSION_DIR, the file
table with stability tiers (tier 1 = <name>.json, .events.jsonl;
tier 2 = .sock, .pid, .lock, theme, supervisor/), the SessionMetadata
TS shape, the events JSONL envelope and every concrete event type,
and the <target>.tmp.<pid>.<rand> atomic-write convention with an
explicit "third-party readers MUST ignore these" note.
Pre-1.0 banner: schema may change in any release; pin to a pty
version. CHANGELOG carries a "### Storage format" heading whenever
a tier-1 file shape changes.
Drift guard: tests/disk-layout-docs.test.ts asserts every
SessionMetadata field name, every concrete event-type literal, and
every file extension we write under PTY_SESSION_DIR appears in the
doc. SessionMetadata and EventBase carry "PUBLIC FORMAT" comment
markers pointing at the doc + the smoke test.
README's Events section gains a one-paragraph pointer to the doc and
mentions git-style command forwarding (pty <subcommand> ->
pty-<subcommand> on $PATH) as the recommended path for native
fast-path readers.
The pty / pty i / pty interactive picker's filter input was append-
only — backspace + printable chars, no cursor, no word motion, no
kill-line. Editing longer queries was awkward compared to the shell
prompts people build muscle memory for.
Fixed at two layers:
1. applyTextKey (src/tui/widgets/form.ts) — the shared text-field
helper — gains ctrl+w (delete previous word) and ctrl+k (kill to
end of line). It already had backspace / delete / arrows / home
/ end / ctrl+a / ctrl+e / ctrl+u / alt+arrow and alt+b/f word
motion. So every consumer (interactive filter, form widget,
command palette, prompt bar) picks up the new shortcuts for free.
2. Interactive filter (src/tui/interactive.ts) — replaces the hand-
rolled append/slice key handling with a TextFieldState signal
routed through applyTextKey. The filter line renders via
renderFieldNodes so the cursor paints on top of the char under
it (inverse-video) without shoving neighbors sideways. Typing
resets the selection to 0 as before; pure cursor motion (home,
end, arrows) preserves the current selection.
Tests:
- widgets-form.test.ts: 8 new unit tests for ctrl+a, ctrl+e, ctrl+w
(end-of-line, mid-string, trailing-whitespace boundary, empty
field) and ctrl+k (mid-cursor, at-end).
- tui.test.ts: 2 new end-to-end tests through a real PTY confirming
ctrl+u clears the filter in one keystroke and ctrl+w removes the
trailing word, updating the rendered filter line.
Reported against pty tag by schickling-assistant. Root cause: four
write sites (writeMetadata, supervisor state save, pty supervisor
reset, event-log retention) all used `target + ".tmp"` — a single
fixed path per target. Two concurrent writers truncated and
overwrote each others tmp file, and whichever rename ran during a
mid-write moment could publish half-written bytes as the target.
That explained both symptoms in #22:
- Stable: 9 of 10 concurrent writes lost ("last-write-wins" was
not the behavior; worse, the file could be left in a torn state).
- Transient: pty tag <session> returning "Session not found" even
while pty list --json showed the session alive — readMetadata
silently catches JSON.parse failure and returns null, which
propagates as "not found."
Fix: new atomicWriteFileSync / atomicWriteFile helpers in
sessions.ts that write to `<target>.tmp.<pid>.<rand>` (unique per
writer) and then rename into place. POSIX same-filesystem rename
is atomic — readers always see either the old file or the new
one, never an intermediate. All four call sites now route through
the helper. Event-log retention also uses tmp+rename so pty events
readers never see a mid-rewrite file.
Contract: last-write-wins under concurrent writers; lost updates
are possible, corruption is not. Matches the reporters explicit
"last-write-wins is fine" in the Expected section of #22.
Known limit documented in events.ts: very large (> PIPE_BUF ~4KB)
user.* / state.set event payloads can still interleave at the
POSIX O_APPEND level. Keep large blobs in state, not events.
Tests (tests/atomic-writes.test.ts): reader-during-writer for both
helpers, tmp-file cleanup check, concurrent pty tag via 10 and 20
child processes (asserts metadata parses after the race),
Promise.all setState x 50 in-process, event-log truncation vs
concurrent reader (asserts every recovered event has a valid
type).
The systemd and runit installer tests exist to exercise real system
integration, but the two tests fail hard on macOS and on Linux boxes
that dont have runit installed — not because the code is broken,
just because the required binaries arent there. Converted the
runtime-throw (runsvdir case) and the platform-mismatch (systemd
case) into it.skipIf gates so developers on Mac see "skipped"
instead of "runsvdir is not installed" red errors.
CI on Linux still runs both; its where the real validation lives.
Request from pty-layout-claude: every pty-layout pane runs a shell
with PTY_SESSION set, so commands that silently started a nested pty
client routed detach keybindings to the outer client and left users
tangled with no clean way to exit.
New helper ensureNotNested(cmd, { force?, hint? }) in cli.ts. Called
from:
- pty attach / pty a / pty attach -r: refuse early (before
resolveRef) so even a mistyped ref yields the nesting hint instead
of a "not found" error. Hint mentions Ctrl+\\ to detach and pty-
layouts ^]n picker.
- pty restart <ref>: restart still runs (its independent of attach),
but the trailing doAttach is skipped and a one-line "(not attached:
already inside pty session X)" notice prints. -y / --yes still
skips the kill-confirm prompt; --force restores restart+attach.
- pty / pty i / pty interactive: refuse to open the TUI picker;
picker rendering inside a session with broken detach was the worst
footgun of the bunch.
- pty run -a: narrows the existing "already inside pty session,
running directly" behavior — if the target is already running,
refuse rather than silently dropping -a. Target-not-running case
keeps the run-directly behavior; -a is a no-op in that situation
anyway.
--force across all four to opt back into the old behavior (nested
clients are occasionally intentional: debugging, screen-sharing
demos).
Latent test-harness bug surfaced by the guard: Session.spawn
(@myobie/pty/testing) was leaking the harness's own PTY_SESSION into
the child env, so every interactive-TUI test in tui.test.ts tripped
the new guard. Scrub PTY_SESSION alongside the existing
PTY_SERVER_CONFIG scrubbing so Session.spawn simulates a clean user
shell regardless of what the harness is running inside.
Without an explicit sort, cmdList iterates listSessions() in whatever
order fs.readdirSync returns for the session dir — on APFS thats
roughly insertion order, which drifts as sessions come and go and
reads like accidental chaos. Now the output is stable and
predictable: alphabetical by the user-visible label (displayName when
set, stable id otherwise), applied to the JSON output and all three
status buckets (running/exited/vanished). Remote host sessions sort
the same way.
Scope is pty list only — the interactive TUI has its own ordering
semantics and is unaffected.
Part 2 of the palette-preservation work — first half was on
PtyHandle.readCells (commit 6248390). That change landed fgIndex /
bgIndex on the PtyCell shape but pty-layout tracing showed the round
trip broke inside the CellBuffer pipeline: setCell -> Cell (in
types.ts) had no index fields, so writeAnsi / diff / fullRender
silently dropped the index and re-emitted as truecolor.
Now the buffer-side Cell carries fgIndex / bgIndex (required,
number | null) and the pipeline plumbs them through:
- writeAnsi captures the index on SGR 30-37 / 90-97 / 38;5;N /
48;5;N, and clears it on 38;2 / 48;2 truecolor and on SGR 0 / 39 /
49 resets.
- cellsEqual compares the index so diff re-emits correctly when only
the index changed (same flattened RGB, different palette slot).
- diff + fullRender use shared emitFg / emitBg helpers that prefer
indexed SGR when fgIndex is non-null (SGR 30-37 for 0-7, 90-97 for
8-15, 38;5;N for 16-255) and fall back to truecolor SGR 38;2 /
48;2 otherwise.
- ptyView rendering path forwards cell.fgIndex / bgIndex from the
upstream PtyCell, so embedded panes re-emitted by consumers like
pty-layout keep the outer terminal's theme for indexed cells while
still supporting truecolor for programs that emit it.
Required rather than optional on Cell — nothing outside this repo
constructs Cell objects in our tree, and strict typing keeps the few
internal construction sites (emptyCell, makeCell, writeAnsi, FPS
counter literal in app.ts) honest. pty-layout's one selection-
highlight construction site will need a trivial fgIndex/bgIndex: null
addition.
pty-layout composites panes by reading cells and re-emitting them as
truecolor SGR bytes. readXtermCells was flattening palette-indexed
cells to a hardcoded VGA RGB via paletteToRgb(), so SGR 34 always
came out [0,0,204] even when the outer terminal (kitty, wezterm,
iterm2) had its own theme blue. pty attach is unaffected — that path
pipes raw bytes and the SGR codes pass through.
Cells returned from PtyHandle.readCells() now carry:
fgIndex: number | null // 0-255 when isFgPalette(), else null
bgIndex: number | null // 0-255 when isBgPalette(), else null
Re-emitters check the index first and round-trip as SGR 30-37/90-97
or 38;5;N / 48;5;N; the outer terminals palette then wins. Existing
fg/bg RGB fields unchanged — consumers that dont know about fgIndex
fall back to the flattened RGB and still work.
The inline readCells return type is now a named PtyCell interface
exported from @myobie/pty/tui, so consumers can import the shape
without a ReturnType<...> dance.
underlineColor deliberately skipped — the current Cell shape only
tracks underline: boolean with no color at all, so adding palette-
aware underline is a bigger change than this non-breaking addition.
Separate request if pty-layout wants it.
Nathan wants Nix docs out of the READMEs into a dedicated nix.md across the
convoy/pty ecosystem; pty is the only core repo with Nix doc content, so this
is a pty-only change.
Move the "Or with Nix:" install block (nix profile install + nix develop) out
of README.md into a new nix.md at the repo root, and leave a one-line pointer
("Or install with Nix — see nix.md.") where the block was. nix.md structures
the Nix install (profile install / nix run) and the dev shell (nix develop +
the clone workflow), and notes flake.nix is the source of truth. Every command
is accurate to the flake — packages.default has meta.mainProgram = "pty" so
`nix run` works, and the dev shell provides nodejs_22/python3/pkg-config.
Doc-only and reversible: flake.nix is untouched.
Incident follow-up #3 — the deeper root cause. `pty restart` (and the dead-
session "Restart? [Y/n]" path) re-run a session's stored command under the
RESTARTER's shell environment. When cos was restarted from smalltalk's shell,
that shell's ST_AGENT=smalltalk-claude (and ST_ROOT) leaked into the re-exec,
so cos came back under the wrong bus identity and died (exit 129).
Strip the bus-identity vars (ST_AGENT/ST_ROOT) from an operator-initiated
restart's environment so a session re-exec'd from a different shell can never
inherit that shell's identity. Mechanism: a new `scrubEnv?: string[]` on
spawnDaemon deletes the named keys from the daemon's env before it spawns — and
therefore before the session child inherits them (spawnViaNode path). The two
operator-restart call sites (cmdRestart, handleDeadSession) pass
RESTART_SCRUBBED_ENV.
Scoped deliberately to restart: a fresh `pty run` is unaffected, because a
convoy-launched create legitimately inherits its own identity. And this keeps
`pty restart` appropriately un-blessed for agents (consistent with the #73
guardrail): it's now safe — it won't resurrect a session under the wrong
identity — but the correct way to restart an agent with a valid identity is
still its supervisor (convoy). pty itself reads neither var, so scrubbing them
changes only what the child inherits.
tests/restart-env-scrub.test.ts: a restart run from a shell carrying
ST_AGENT=smalltalk-claude / ST_ROOT=/leaked yields a child that records
UNSET|UNSET (identity scrubbed, not leaked); a fresh `pty run` still inherits
its creator's ST_AGENT/ST_ROOT (create path unaffected).
Incident follow-up #2. When cos's `claude --resume` froze on its exit screen,
the daemon was left alive+orphaned (ppid=1) needing kill -9. Cause: cleanShutdown
awaits server.close() then process.exit() with NO overall deadline. close()'s
child-exit wait is internally bounded (~2s), but the outer promise can still hang
indefinitely — socketServer.close()'s callback never fires for a lingering/
untracked socket, or eventWriter.flush() stalls — so the daemon never reaches
process.exit() and lingers forever.
Add a hard deadline (default 5s, PTY_SHUTDOWN_DEADLINE_MS-overridable) armed by
cleanShutdown: if the graceful close() hasn't completed by the deadline, the
daemon force-exits regardless AND SIGKILLs its child (PtyServer.forceKillChild)
so a frozen child isn't left orphaned to init still alive. cleanShutdown is now
idempotent too — SIGTERM/SIGINT/onExit/spawner-watchdog can overlap; only the
first arms the deadline and drives close(), the rest get the same in-flight
promise. Same class as #69/#72 but the frozen-child / stuck-close case.
tests/shutdown-backstop.test.ts: a child that traps SIGHUP wedges the graceful
path -> backstop force-exits the daemon and reaps the child (timing-independent
proof: only the backstop's SIGKILL can kill a SIGHUP-trapping child; the graceful
path only ever sends SIGHUP). A normal session still shuts down promptly, well
under the deadline. All existing lifecycle tests (spawner-watchdog, kill-wait,
exit-event-race, exit-signal, rm-kill-ephemeral, up-down) intact.
Incident: `pty restart -y org.myobie.cos` on a live `claude --resume` agent
wedged it — the abrupt kill + re-exec of the stored argv killed in-progress work
and left claude frozen on its exit screen with an orphaned daemon (and, since
restart re-execs under the OPERATOR's shell env, the respawn inherited the wrong
ST_AGENT and came back under the wrong bus identity, exit 129).
`pty restart` is a dumb "SIGTERM the daemon + re-run the stored argv" — great for
a stateless daemon, a footgun for a stateful interactive agent. Refuse it for
agent-shaped sessions unless --force, converting "claude agents cycle via their
supervisor, not pty restart" from convention into a CLI-enforced guardrail.
Detection (statefulAgentReason): a `role=agent` tag, or `claude --resume` in the
stored command. On a match, `pty restart` exits nonzero pointing at `convoy up`
and `--force` to override; non-agent sessions are unaffected.
tests/restart-guardrail.test.ts: role=agent refuses; `claude --resume` argv
refuses; a normal session restarts fine; --force overrides. Existing restart
tests (displayName preservation, gc-flap clear) still pass.
node-pty's onExit reports `{ exitCode, signal }`, but the daemon destructured
only `exitCode` — so a child killed by a signal (e.g. an OS OOM SIGKILL, which
arrives as signal=9 / exitCode=0) was recorded as a clean exit 0. A consumer
gating on "nonzero exit" (convoy's crash→ding) then mistook a real OOM death for
a clean finish and stayed silent.
Capture the signal and surface it the way a shell does — exitCode = 128 + signal
(SIGKILL 9 → 137) — so the existing nonzero gate catches it, and also carry the
raw `signal` on the session_exit event (+ render "killed by signal N"). Normal
exits are unchanged (no signal → raw exitCode).
- server.ts onExit: `{ exitCode, signal }` → effective code 128+signal.
- events.ts: SessionExitEvent gains optional `signal`; formatEvent shows it.
- docs/disk-layout.md: document the session_exit `signal` field + encoding.
- tests/exit-signal.test.ts: SIGKILL'd child records 137 + signal 9; clean exit
stays as its raw code with no signal.
Low-pri teardown cleanup. On teardown the daemon re-flushes exit metadata during
its (possibly watchdog-delayed) shutdown; a caller returning before that finishes
could see a stray registry temp file. Diagnosed: the ~8s residue is the
spawner-watchdog path (5s poll), not explicit `pty kill` (which re-flushes in
~50ms).
(A) `pty kill` now waits for the daemon pid to fully exit (bounded 3s) before
returning, so a following `pty rm` can't race the daemon's late write.
- sessions.ts: export isProcessAlive + add waitForProcessExit().
(ii) The daemon's saveExitMetadata() is now a no-op when the session's `.json`
was already removed — a late shutdown/watchdog flush won't resurrect a
`pty rm`'d session's file (or leave its atomic tmp behind). Normal exits are
unaffected (the metadata exists at exit).
Tests: tests/kill-wait.test.ts — daemon gone by the time kill returns; kill→rm
leaves no stray files; daemon shutdown doesn't resurrect removed metadata. Also
hardened tests/seq-delay.test.ts's timing assertion (bigger delay signal) so it
doesn't false-fail under a saturated parallel run.
Not done (per cos): didn't shorten the watchdog poll (behavior change for a
cosmetic gain).
Three bundled improvements toward "pty is independently useful: a great --help
(the reference) + a thin SKILL.md (the judgment)".
--help (the reference):
- Every subcommand now ships focused `--help` (usage + every flag + >=1 example)
via a central COMMAND_HELP registry + a single interceptor. Previously most
subcommands errored or silently ran on `--help`. renameUsage/printEmitHelp now
read from the same source, so error-usage and --help can't drift.
- Top-level `usage()` already listed every subcommand; added the missing
`run --force` line.
- tests/help.test.ts asserts every subcommand has working `--help`, and that no
dispatch `case` is undocumented (drift guard).
--seq default delay (decision from Nathan):
- `pty send --seq` now inserts a 0.3s gap between items by default so a trailing
key:return doesn't race ahead of the program parsing the typed text.
`--with-delay 0` = straight stream (opt-out); `--with-delay N` honored.
- resolveSeqDelayMs() (pure, in client.ts) is the single decision point; the
library send() still treats delayMs literally, so programmatic callers are
unchanged.
- tests/seq-delay.test.ts: deterministic (a) default=300ms, (b) 0=0, (c) N=N,
plus a delta-timed end-to-end check that 0 = no spacing.
- `pty send --help` + top-level usage state the 0.3 default and the 0 opt-out.
SKILL.md (the judgment): thinned to the routing-hook + what/when/idiom/footguns/
delegate shape. Footguns captured: broken global `pty` on PATH silently breaks
the whole message bus (st shells to `pty send`); PTY_ROOT is the isolation
mechanism (PTY_SESSION_DIR is masked under an ambient PTY_ROOT); and the --seq
timing footgun with the WHY (byte-burst vs spaced input; Enter racing the
program's parse/render), now mostly handled by the 0.3s default.
Follow-up to the scratch-session leak smalltalk-claude hit: a harness that
sets the deprecated PTY_SESSION_DIR to isolate, while running in an env that
already exports PTY_ROOT (e.g. a supervised session tree), had its
PTY_SESSION_DIR silently ignored — getSessionDir() prefers the canonical
PTY_ROOT — so its scratch sessions landed in the ambient registry.
Precedence is correct and unchanged (canonical PTY_ROOT wins over the
deprecated legacy var). The fix is visibility: getSessionDir() now warns once
(unless PTY_ROOT_LEGACY_SILENT) when BOTH are set, naming both dirs and
pointing at PTY_ROOT as the isolation mechanism — so the masking is obvious
instead of an invisible leak.
- src/sessions.ts: one-time masking warning when PTY_ROOT + PTY_SESSION_DIR
are both set.
- tests/pty-root.test.ts: regression that a `run -d` session with PTY_ROOT set
lands ONLY under it (not the co-set PTY_SESSION_DIR, not the default), plus
warning visible / PTY_ROOT_LEGACY_SILENT suppresses it.
- README: PTY_ROOT is the isolation mechanism; document the masking + warning.
Verified: pty-root suite 16/16; npm run typecheck clean.
Fresh-clone onboarding papercut: `pty --version` printed 'Unknown command'.
Add a version command (`version`, `--version`, `-v`, `-V`) that prints the
version per house convention '<semver>+<short-sha>' — semver from package.json,
short sha from git when this is a pty checkout, gracefully omitted otherwise
(e.g. an npm install prints bare '<semver>').
- src/version.ts: import-safe module with readPackageVersion / readGitShortSha /
formatVersion / printVersion. The sha lookup is gated on a .git in pty's own
package root, so an npm-installed copy inside an unrelated parent repo never
reports that repo's sha as pty's version.
- cli.ts: version switch cases + a Global help line.
- completions: add `version` (+ --version/-v) to bash & zsh.
- tests/version.test.ts: unit tests for formatVersion (with/without sha) +
integration tests that each form prints the version and exits 0.
Verified: all forms print e.g. 0.11.0+c87a6c6; npm run typecheck clean; tests green.
Fresh-clone sweep (thanks cos) found repo URLs still pointing at the old org:
- package.json repository.url -> https://github.com/compoundingtech/pty.git
- flake.nix meta.homepage -> https://github.com/compoundingtech/pty
- tests/list-filters.test.ts issue-ref comment -> compoundingtech/pty/issues/34
After this there are zero github.com/myobie repo URLs left in the tree. The
@myobie/pty npm package scope is intentionally kept (the package is still
published under that name; package.json "name" is @myobie/pty), as are the
com.myobie.pty.* launchd Labels (reverse-DNS ids, backwards-compatible).
The suite passes at runtime (vitest strips types), but `npm run typecheck`
(tsc over src+tests) was red with 6 errors that CI never caught — nix.yml
only runs `nix build` (src-only), not typecheck or vitest.
- tests/tui-framework.test.ts (x4): ScreenContext render mocks predated the
focus-manager work and were missing the now-required quit/focus fields.
Completed each mock (quit no-op + createFocusManager()).
- tests/exec.test.ts, tests/nesting.test.ts: `{ ...process.env, PTY_SESSION_DIR }`
loses the index signature, so `delete env.PTY_SESSION` didn't typecheck.
Annotated env as Record<string, string | undefined> (same pattern the other
test helpers already use).
Verified: `npm run typecheck` clean (0 errors); the 3 files pass at runtime
(95 tests).
All repos moved to the compoundingtech org (only homebrew-convoy stays
myobie). Switch the GitHub repo URLs in the docs:
- README nix install: github:myobie/pty -> github:compoundingtech/pty
- CHANGELOG pty-relay link -> github.com/compoundingtech/pty-relay
Left unchanged (NOT repo URLs): the npm package scope @myobie/pty (the
package is still published under that name — package.json name is
@myobie/pty) and the com.myobie.pty.* launchd Labels (reverse-DNS
identifiers, backwards-compatible with existing installs).
Audited README + all repo docs against the code on main. Two files carried
factual errors; the rest (README, CHANGELOG, disk-layout, SKILL, testing)
verified accurate.
docs/client.md:
- gc() documented as `Promise<string[]>` returning removed names; it actually
returns a `GcResult` object. Corrected the signature, example, and added the
GcResult shape.
- getSessionDir() described only `PTY_SESSION_DIR`; it prefers the canonical
`PTY_ROOT` (legacy name still honored).
- isReservedTagKey() listed `supervisor.status`, which is no longer a reserved
key (the supervisor was replaced by cron gc). Removed it.
DEVELOPMENT.md:
- Said the project "ships TypeScript directly, no build step, run via tsx".
Actually: tsx is not a dependency, `npm run build` compiles src/ to dist/
(rewriteRelativeImportExtensions), the published package ships dist/, and
bin/pty runs dist/cli.js with Node. Rewrote the build section, the design-
decision note, the architecture line, and the bin/pty entry; dev-usage
examples now use `node --experimental-strip-types src/cli.ts`.
- `pty run <name> <command>` -> `pty run -- <command>` (actual syntax).
- Socket-override env `$PTY_SESSION_DIR` -> canonical `$PTY_ROOT`.
verify-docs (docs/testing.md executable examples) still passes: 13/13.
When the vitest process itself runs inside a pty session, its ambient
PTY_ROOT / PTY_SESSION / PTY_SESSION_DIR leaked into every spawned `pty`.
getSessionDir() prefers PTY_ROOT over the per-test PTY_SESSION_DIR that the
helpers pass, so the spawned CLI read the developer's REAL live session dir
instead of the test tmpdir — non-deterministic failures, and a risk of
mutating live sessions.
- tests/setup/isolate-env.ts: new per-worker setupFile (registered in
vitest.config) that scrubs the three ambient vars before any test module
runs — the hard guard.
- Session.spawn: extract buildSpawnEnv() (pure, exported) and also scrub
ambient PTY_ROOT / PTY_SESSION_DIR there, so external users of the shipped
testing library get the same isolation. Guarded: an explicit root passed via
opts.env still wins.
- tests/env-isolation.test.ts: unit tests for buildSpawnEnv + a guard test
asserting the setupFile scrubbed the worker env.
- gc-flap-clear-badge-root-len: the restart test was passing only because an
ambient PTY_SESSION made `pty restart` skip its attach; set PTY_SESSION
explicitly so it no longer depends on the leak.
Verified: full suite green (1213 passed) WITH ambient PTY_ROOT set and no
env -u / shim workarounds; unchanged in clean CI (scrub is a no-op there).
pty restart / attach-restart / interactive doRestart re-spawned the
daemon from stored metadata but dropped displayName, so a restarted
session read as its raw id (e.g. claude-203827) instead of its name —
breaking naming and the TUI peek. Forward meta.displayName at all three
restart call sites, matching the existing `run -a` re-create path.
Tags were already carried. Adds a driven regression test that fails
without the fix.
Adds a second calling shape to text() so consumers that lay out
{ fg: color, bold: true, ... } maps compose cleanly. Original
text(str, color?, opts?) positional shape unchanged — dispatch is
by second-arg TYPE (string / array → positional Color, plain
object → opts-with-fg), not arity.
Fixes the demo-blocking issue where consumers of @myobie/pty/tui
using text("x", { fg: someColor }) hit `'fg' does not exist` on tsc
and "nodes is not iterable" at render time — the object shape was
reaching the positional `color` slot and getting spread as an
object where downstream code expected a Color.
Version bump 0.10.0 → 0.11.0 to release everything queued since
the last publish: the text() overload PLUS the accumulated
BREAKING changes since 0.10.0 (removed `pty state`, `pty wrap`,
supervisor/launchd-wrapper replaced by cron-driven `pty gc`,
name/displayName decouple, PTY_ROOT canonical env, `session_flapping`
event + fast-fail cap, etc.). See CHANGELOG.md 0.11.0 section.
5 new tests in tests/tui-framework.test.ts cover the object shape
(fg + bold; fg = SemanticColor string; object without fg; no-args
after str). Full suite: 1206 passed, 21 skipped, 0 failed.
Two lean-core deletes and an accuracy pass on the help / completion
surface. Nathan authorized, cos briefed, usage-check across pty,
eval-sandbox/st-evals, convoy, pty-claude-launcher, cos, pty-relay,
and pty-layout came back clean — no external consumers.
BREAKING (no back-compat shim):
- `pty state` (subcommand + programmatic API + events + metadata field)
gone. CLI subcommands `get`/`set`/`delete`/`keys` removed. Public
API exports `getState`, `getStateKey`, `setState`, `deleteState`,
`listStateKeys` dropped from `@myobie/pty/client`. Event types
`state.set` and `state.delete` removed from `EventRecord`.
`SessionMetadata.state?` field removed — Storage format change;
existing metadata files with a populated `state` object silently
drop the field on the next daemon-side rewrite.
- `pty wrap` / `pty unwrap` / `pty wrap --list` removed. `PTY_BIN_PATH`
env var no longer consumed. `~/.local/pty/bin/` no longer created;
existing shims can be `rm -rf`'d by hand.
Redundant with smalltalk's folder-and-bus persistence (for state) and
orthogonal to the session primitive contract (for wrap).
Accuracy pass:
- `usage()` rewritten. Commands grouped logically (Create / Attach &
interact / Observe / Modify / Lifecycle / Multi / Global); every
flag every current subcommand accepts listed; `<ref>` semantics and
the four env vars (PTY_ROOT / PTY_SESSION_DIR / PTY_ROOT_LEGACY_SILENT
/ PTY_SESSION) documented.
- `completions/pty.{fish,bash,zsh}` rewritten against the same surface.
Every current subcommand + every accepted flag covered; `state`,
`wrap`, `unwrap` removed. All three shells consistent.
Tests: tests/state.test.ts deleted (485 LOC). tests/atomic-writes.test.ts
and tests/events.test.ts shed their state-specific cases.
Suite: 1202 passed, 21 skipped, 0 failed (down from 1231 in main; -29
across the deleted state.test.ts + removed state.* format assertions +
one atomic-writes setState concurrency test).
Delta: +467 / -1223 across 14 files (13 modified, 1 deleted).
Three low-priority follow-ups to #56 and #55.
1. `pty restart` and `pty up` clear the fast-fail bookkeeping.
Manual restart is an operator "please try again" signal — dropping
`strategy.status`, `strategy.consecutive-fast-fails`,
`strategy.last-respawn-at`, and `strategy.command-hash` gives the
restart a clean slate, so the next `pty gc` tick isn't a no-op
against a stale flag. Auto-reset on command-hash divergence already
handled the toml-edit case; this handles operator-intervenes-
without-edit. Applies to both `cmdRestart` and `cmdUp`'s
"already running, tag-sync" branch.
2. `pty list` renders `[flapping]` in place of `[permanent]` when a
session carries `strategy.status=flapping`. Red instead of yellow —
the operator's expectation has changed, so the badge reflects it.
3. Startup-time PTY_ROOT length backstop. When the resolved root's
byte length + a default `/<8-char-id>.sock` suffix (14 bytes)
would exceed sockaddr_un's 104-byte kernel limit, `pty` errors
before any subcommand runs. Error names the root and points the
finger at the root, not the session name — the previous spawn-time
error read as if the name were the problem. Backstop respects
`--root <shorter>` overrides.
7 new tests in tests/gc-flap-clear-badge-root-len.test.ts cover:
restart clears bookkeeping, list shows [flapping] and hides
[permanent] when both would apply, list still shows [permanent]
otherwise, backstop errors on `pty list` with too-deep root,
backstop fires before subcommand parsing, root at usable threshold
succeeds, `--root <shorter>` overrides a too-long env.
Full suite: 1231 passed, 21 skipped, 0 failed.
Fixes #54. A `strategy=permanent` session whose leaf exits within
`strategy.fast-fail-window` seconds (default 60) of its previous
`pty gc` respawn counts as a fast fail. After `strategy.fast-fail-limit`
consecutive fast fails (default 3), gc writes `strategy.status=flapping`
on the session, emits a `session_flapping` event, and stops respawning
it. Subsequent ticks print `Skipped (flapping): <name>` and take no
action. Closes the crash-loop-with-live-cwd gap that #47/#50's
cwd-gone + idle reap didn't catch.
Bookkeeping tags stamped on every respawn:
- strategy.last-respawn-at (ISO ts)
- strategy.consecutive-fast-fails (running counter)
- strategy.command-hash (16-char sha256 prefix of the respawn cmd)
The classifier compares the current command fingerprint against the
stored hash; a divergence auto-resets the counter and clears
`strategy.status=flapping`. Manual reset: `pty tag <name> --rm
strategy.status`.
Per-session overrides `strategy.fast-fail-window=<sec>` and
`strategy.fast-fail-limit=<int>` beat CLI globals
`--fast-fail-window=<sec>` / `--fast-fail-limit=<int>` (which mirror
the `--idle-days` shape).
GcResult gains `flapped` and `flappingSkipped` buckets — additive,
existing consumers unaffected. cmdGc surfaces both.
10 new tests in tests/gc-flapping.test.ts cover: dry-run preview,
at-limit persistence + session_flapping event, silent skip on
subsequent ticks, slow-fail counter reset, command-hash auto-reset,
per-session + CLI-global window/limit overrides, first-respawn
(no prior last-respawn-at) case.
Phase-2 per-namespace isolation. `PTY_SESSION_DIR` becomes a legacy
alias (still functional, emits a one-time deprecation notice per
process; suppress with PTY_ROOT_LEGACY_SILENT=1). New canonical env
`PTY_ROOT` and matching global `pty --root <path>` flag pin the
state registry per call — every subcommand transparently scopes
via getSessionDir().
`pty gc --print-launchd-plist` parameterizes off the current root:
default root keeps the pre-Phase-2 Label (com.myobie.pty.gc) for
backwards compat with existing installs; non-default roots get a
sanitized Label suffix (com.myobie.pty.gc.<basename>) and a per-root
log path (<root>/gc.log). Emitted plist writes PTY_ROOT (canonical),
not the legacy name — a mid-migration installer sees loud stderr in
gc.log until it's cleaned up.
README gains a "Namespaces" section documenting soft (tag-filter)
and hard (--root/PTY_ROOT) isolation. smalltalk's Phase-1 `st.network`
tag composes with this cleanly.
Tests in tests/pty-root.test.ts cover: env-var precedence, one-time
deprecation notice + silence hatch, --root overrides both envs,
--root value validation, default vs non-default plist Label,
per-root logPath, pathological-basename sanitization.
The interactive picker's attach-remote path built `host.url + "/" +
session.name` and shelled to `pty-relay connect <that>`. For token URLs
(http/https) that works — pty-relay's parseToken picks the session name
out of the URL path. For ssh:// peers it collapses:
pty-relay connect ssh://user@host/session-name
pty-relay's `connect(tokenUrlOrLabel)` treats non-token URLs as
known-hosts labels, does `hosts.find(h => h.label === that-string)`,
misses, and exits 1 with "No known host". Even if resolveHost matched
by URL, the ssh branch takes the session name from `--session` /
`--spawn`, not from the URL path — the path segment is invisible to it.
Route around the seam by teaching the TUI to invoke pty-relay's
canonical ssh-peer shape:
pty-relay connect <host.label> --session <session.name>
Token URLs still get the existing path-append form (parseToken owns
that convention). Extracted `buildAttachRemoteArgs` so both branches are
unit-testable next to `buildSpawnRemoteArgs`.
A parallel fix in pty-relay (parse the session name from an ssh:// URL
path so `pty-relay connect ssh://user@host/name` also works) is in
flight as a mirror — the two together make the seam robust regardless
of which side the caller is on.
* fix(tests): reap leaked pty daemons at vitest teardown
Tests spawn pty daemons into mkdtempSync-based PTY_SESSION_DIRs. The
daemons detach by design (that's how `pty attach` after a crash works)
and vitest exits without reaping them, so every test run leaked a few
ppid=1 pty daemons + their cat leaves. Over days across pty +
pty-relay, this drove /dev/ttys past kern.tty.ptmx_max=511, blocking
new spawns entirely (`posix_spawnp failed`).
Fix: add a vitest globalSetup module that
1. mkdtempSync's a per-run root under /tmp (short prefix avoids the
macOS 103-byte AF_UNIX path limit — /var/folders/.../pv-XXX would
push nested test socket paths over),
2. exports PTY_VITEST_RUN_ROOT + overrides TMPDIR so every test's
os.tmpdir() call resolves into the run root (zero per-test changes
across the 43 files that call os.tmpdir()),
3. on teardown, `lsof +D $runRoot` enumerates every pid holding a
file or socket under the tree, SIGTERMs them, waits 2s, SIGKILLs
survivors, then rm -rfs the run root.
Verified on full suite (86 files, 1197 passed, 21 skipped, 0 failed):
daemons before = daemons after; ttys before = ttys after; teardown
reports "SIGTERM N leaked pids" for the daemons that would previously
have leaked.
* fix(tests): also sweep unix sockets in teardown — `lsof +D` silent-miss
`lsof +D <dir>` on macOS walks the filesystem tree but does not index
unix-socket-name entries. A process holding ONLY a bound listen socket
inside <dir> is invisible to that sweep. That describes the pty daemon
exactly: it holds its .sock file in the session dir, but no other open
files or cwd.
Reproducer:
mkdir -p /tmp/probe/x && node -e '
const s=require("net").createServer();
s.listen("/tmp/probe/x/leak.sock", () => setInterval(()=>{},1e9));
' &
lsof -Fpn +D /tmp/probe # → empty
lsof -Fpn -U | grep probe # → finds it
Add a second sweep: `lsof -Fpn -U` and match sockets by name prefix
(runRoot/ and /private<runRoot>/ — macOS symlinks /tmp -> /private/tmp
and the daemon's socket name resolves to the /private twin). Union the
pids with the +D sweep, then SIGTERM/SIGKILL as before.
Verified: on tests/nesting-prevention.test.ts the previous teardown
reported "SIGTERM 3 leaked pids"; with the -U sweep added it reports 6.
The 3 the +D-only sweep missed were the ones holding only sockets.
Full suite still 1197 passed / 21 skipped / 0 failed; daemon + tty
counts before == after (delta 0).
Credit: pty-relay-claude flagged the silent miss while implementing the
mirror fix in pty-relay (PR #25).
Under Node 24+, V8 names the main thread "MainThread", so every pty
process (the bin/pty signal-forwarding wrapper, the CLI, each session
daemon, and the supervisor) shows up as `MainThread` in ps/top/htop/btm.
That makes them indistinguishable from any other Node process and hurts
observability and leak triage.
Set `process.title` early in each entry point so they report a
meaningful name instead:
- bin/pty -> "pty" (signal-forwarding wrapper)
- src/cli.ts -> "pty" (CLI)
- src/server.ts -> "pty-daemon" (session daemon, guard-scoped)
- src/supervisor-entry.ts-> "pty-supervisor" (supervisor)
`process.title` is the only thing that overrides /proc/<pid>/comm, and
only when set from within the running process after V8 init — launch
flags (`node --title`, `exec -a`) do not work. Linux caps comm at 15
chars (TASK_COMM_LEN); all titles stay well under. Each assignment is
wrapped in try/catch so it can never throw. server.ts only sets the
title inside its `argv[1].endsWith("/server.js")` daemon-entry guard,
since the module is also imported as a library (PtyServer).
Adds a Linux-only test asserting a spawned daemon's /proc/<pid>/comm is
"pty-daemon".
Claude-Session: https://claude.ai/code/session_01FKmH5V4zLM7HXsbNohqPV9
Co-authored-by: schickling-assistant <schickling.ventures@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Two bugs in src/tui/buffer.ts making width-2 emoji (📬, 📭, 📫 —
astral-plane codepoints U+1F4XX) fossilize under app() incremental
re-render, observed by tui-sup while building agent-viz on top of
@myobie/pty/tui: a `📬` on a cards grid navigating until it scrolled
would end up displayed as `📬📬` (fossil + new) with shifted digits.
Root causes:
1. writeAnsi iterated the input by UTF-16 code unit (`ansi[i]`), so
astral-plane codepoints stored as surrogate pairs got split into
two lone surrogate halves in separate width-1 cells. Downstream
diff() and fullRender() then emitted the halves independently.
Modern host terminals (kitty, iTerm2, Ghostty) recombined them as
one wide glyph, but the CellBuffer's cell-index → terminal-column
mapping was off by one on every emoji, cascading into misaligned
writes as the row mutated. Fixed by detecting the high-surrogate
/ low-surrogate pattern and storing the full 2-code-unit string
in one Cell.
2. diff()'s `lastCol = c + 1` cursor-position tracker didn't account
for wide chars advancing the terminal cursor by 2. After emitting
a width-2 glyph at column c, the terminal cursor is at c+2, not
c+1. The adjacency check on the next emit then mispredicted,
causing an extra cursor move (efficiency loss) that combined with
the surrogate-split bug above produced positional drift on rows
with mixed content. Fixed by `lastCol = c + charWidth(nc.char)`.
Refactored the per-cell emit block into an `emit()` closure so both
the wide-char path and any future placeholder-clearing path share the
style-reset + SGR emit logic.
Test coverage: tests/buffer-wide-char-diff.test.ts — 10 cases using
xterm-headless + @xterm/addon-unicode11 as the oracle. The unicode11
addon is required because default xterm.js width tables treat all
astral-plane codepoints as width-1, which hides the bug behind an
environment quirk; with unicode11 the harness matches real modern
terminal semantics.
Fixes the framework bug tui-sup filed via coord; unblocks agent-viz
using `📬` directly without a width-1 workaround.
Adds a new `pty gc` step 1.5 between orphan-kill (step 1) and permanent
respawn (step 2) that reaps live `strategy=permanent` sessions detected
as abandoned. Fixes #47.
Two reap shapes today:
- cwd-gone (on-by-default) — the session's recorded cwd no longer
resolves on disk (fs.statSync throws ENOENT). Strong low-false-
positive signal. Escape hatch: `strategy.abandon-if-cwd-gone=false`
tag opts a session out.
- idle (opt-in) — the session's lastAttachAt is older than the
configured threshold. Enabled via `pty gc --idle-days N` (global)
or a per-session `strategy.idle-days=N` tag (per-session wins).
Sessions with no lastAttachAt (never attached) are excluded — a
session just spawned but never used isn't "idle."
cwd-gone takes precedence over idle when both fire (cwd is the stronger
signal). Reaps SIGTERM the daemon, append a `session_abandoned` event
BEFORE cleanupAll unlinks the events file, then cleanupAll.
Public surface changes:
- SessionMetadata gains `lastAttachAt?: string` (ISO 8601), written by
the daemon on every non-readonly ATTACH.
- EventType.SESSION_ABANDONED + SessionAbandonedEvent {reason, idleDays?}.
- GcResult gains `abandoned: {name, reason, idleDays?}[]`.
- CLI: `pty gc --idle-days N` flag (positive integer required; 0/neg
rejected). Output row `Abandoned: <name> (<reason>)` and dry-run
mirror `Would abandon:`. Summary counts abandoned sessions.
- Tags: `strategy.abandon-if-cwd-gone=false`, `strategy.idle-days=N`.
Docs: disk-layout.md updated with the new metadata field, event, and
tags. CHANGELOG entry under Unreleased.
Tests: 11 new in tests/gc-abandoned.test.ts covering all six paths
(cwd-gone live reap, non-permanent unaffected, opt-out tag honored,
--dry-run preview, idle reap on old lastAttachAt, under-threshold
preserved, never-attached preserved, per-session tag opt-in without
CLI flag, cwd-gone > idle precedence, flag validation) plus one
mixed-bucket test proving abandoned-reap composes with the existing
respawn/sweep in the same pass.
Track whether the child has entered the alternate screen buffer (DEC
private modes ?1049 / ?1047 / ?47) via a new altScreenActive flag on
PtyServer, and prepend \x1b[?1049h to the SCREEN packet payload sent
on ATTACH when the child is currently in alt-screen. PEEK does not
get the prefix — a non-follow peek prints its snapshot into the
caller's shell and exits, so entering alt-screen would hide the
output when the client's TERMINAL_SANITIZE (?1049l) fires on close.
Fixes #41. Before this, a full-screen TUI (vim/htop/codex) attached
through pty would paint into the host's main-screen buffer, which
under tmux meant every full repaint entered scrollback. Observed
impact: 8.7 GB scrollback + 9.1 GB tmux server RSS over 9.5h on a
long-lived codex pane, with 0.4-2.3s server-loop stalls.
The wire format is unchanged — the prefix rides on the existing SCREEN
payload as a strict superset. Old clients (pre-fix) receiving the
prefixed payload just write it verbatim to their terminal and enter
alt-screen correctly. Client-side detach already emits ?1049l via
TERMINAL_SANITIZE, so the round-trip is symmetric.
The launchd-managed `pty-supervisor` daemon raced /Volumes/SSD's mount
on Mac boot: permanent sessions whose `dist/server.js` lived under the
external volume failed restart with MODULE_NOT_FOUND, exhausted 5
retries x 2s backoff within 10s, then were marked "no metadata" and
never came back without manual `pty up`. A long-running supervisor will
always have this class of bug — it can't be sure its dependencies are
ready when launchd fires it.
Replace it with a stateless reconciliation pass:
pty gc
invoked periodically by launchd (`StartInterval=30` by default) or any
other cron-driver. Three steps:
1. Orphan-children: sessions tagged `parent=<name>` whose parent's
metadata is gone OR whose parent's pid isn't alive get SIGTERM'd
and cleaned up. New user-facing tag.
2. Permanent respawn: every `strategy=permanent` session that's
exited/vanished is respawned via `spawnDaemon`. Sessions tagged
`ptyfile` re-read pty.toml to pick up edits.
3. Sweep: exited/vanished non-permanent sessions get `cleanupAll`'d
(the historic gc behavior).
Each run is independent — no persistent restart bookkeeping, no
MAX_RESTARTS, no backoff state to drift out of sync with reality.
Cron interval IS the rate limit; if /Volumes/SSD isn't mounted, the
invocation exits with ENOENT and the next tick tries again.
Install helper: `pty gc --print-launchd-plist [--interval=N]` prints
a minimal plist to stdout. No FDA wrapper, no compiled C binary, no
esbuild bundling — `<ProgramArguments>[pty, gc]</ProgramArguments>`
with `<StartInterval>`. User redirects to ~/Library/LaunchAgents/
and `launchctl load`s themselves.
Breaking changes:
- Deleted `pty supervisor *` commands entirely. Users who installed
the old supervisor should `launchctl unload` + remove the old
plist (see CHANGELOG for the exact commands).
- Deleted event types `session_restart`, `session_failed`,
`supervisor_start`, `supervisor_stop`. Added `session_respawn`.
- Deleted reserved tag `supervisor.status`. Added user-facing tag
`parent=<name>`.
- Dropped `strategy=temporary` (was functionally identical to no
strategy tag under the new sweep behavior).
- `gc()`'s return shape changed from `string[]` to a structured
`GcResult` with four buckets (`removed`, `killedOrphanChildren`,
`respawned`, `respawnFailed`). Public API.
Net delta: ~1830 lines deleted, ~485 added.
pty is a public repo; PR #46 committed a `.mcp.json` with a
machine-specific absolute path (`/Volumes/SSD/.../coord/bin/st`).
That leaked one of myobie's local paths into public history and the
file isn't portable to any contributor who clones — they don't have
that path on disk.
Matches the smalltalk + transplant conventions: `.mcp.json` is
machine-local config, kept out of git via `.git/info/exclude`
(per-machine, not in .gitignore so the policy doesn't get committed).
The on-disk file is preserved — the agent's running MCP server reads
it on next session start as before. Future contributors set up their
own `.mcp.json` per-machine.
History note: the absolute path is still in older commits on `main`.
That's a benign disclosure (one filesystem layout, no secrets), so
no history rewrite — only future commits stop carrying the leak.
* feat(name): decouple on-disk identifier from display label
Sessions now have a short random on-disk identifier (sock + json filename)
that is independent of the user-visible display label. This unblocks long
descriptive labels — both via `pty.toml` prefixes and via `pty run --name`
— which previously hit the macOS `sockaddr_un.sun_path` 104-byte limit.
Breaking changes
- `pty run --name <X>` now sets the displayName (any length, any printable
chars). The on-disk id is set by `--id <X>`. Both omitted → random short
id + auto-generated displayName. Both can be combined.
- `pty up` (pty.toml) gives every session a random short on-disk id. The
toml-derived `<prefix>-<sessionKey>` becomes the displayName, not the
filename. `pty up` re-run detection now matches by `(ptyfile,
ptyfile.session)` tag pair instead of by name.
- `pty.toml` gains two optional per-session fields: `id = "..."` to pin the
on-disk id and `display_name = "..."` to override the default label.
- `pty rename` validation: displayName uses the new permissive
`validateDisplayName` (≤ 500 chars, no slashes / null / newlines /
control bytes) instead of the strict `validateName` (sock-filename
charset, sock-path length). Strict validation is reserved for ids.
- `SessionMetadata.displayName` is now preserved through exit (previously
`saveExitMetadata` dropped it).
- `PtySessionDef.name` replaced by `displayName` + optional `id`;
`shortName` unchanged. External callers (`pty kill`, `peek`, `send`,
`attach`) are unaffected — they already resolved by either field via
`resolveRef`.
- `spawnDaemon`'s bundled-context fallback now passes `--id` (instead of
`--name`) to the CLI delegation path and explicitly threads
`displayName` / `--no-display-name`.
Tests
- New `tests/up-name-decouple.test.ts` covers random id, long-prefix
support, `(ptyfile, ptyfile.session)` re-run lookup, pty.toml `id` and
`display_name` overrides, and operations resolving by displayName.
- Existing tests migrated from `--name <id>` to `--id <id>` for tests
whose intent was pinning the on-disk identifier. `display-name.test.ts`
rewritten to test the new `--id` / `--name` semantics including
long-label, kernel-limit rejection, and id/name collision rejection.
Full suite green (1168 passed / 22 skipped / 1190 total).
* fix(cli): drop validateName from session-resolution paths
Long displayNames could be created via `pty run --name <long>` but
operating on them (`pty peek|send|kill|tag|events|attach|restart|rm`)
failed with the `validateName` sock-path kernel-limit error — the
strict validator was running on the user-supplied ref BEFORE
resolveRef did the lookup. The strict validator's job is to gate
on-disk id creation, not session-reference resolution.
Fix per schickling-assistant's PR #45 review:
- Drop the validateName(ref) block from 7 sites in cli.ts: attach,
peek, send, events, tag, kill, rm, cmdRestart, and tag-multi by-name
selector.
- Add resolveRef at the supervisor forget/reset dispatch sites so
cmdSupervisorForget/Reset receive a resolved name (their bodies
drop their own redundant validateName as a consequence).
- Drop the redundant validateName inside cmdRestart — its dispatcher
already resolved the ref.
validateName remains in place where it belongs: on-disk id creation
in `pty run --id`, pty.toml session id, and `pty rename` displayName
candidate paths.
Tests: 6 new regression tests in tests/display-name.test.ts covering
a 110-char displayName roundtripped through peek/send/tag/events/kill
plus the create case. Full suite: 1174 passed / 22 skipped / 0 failures.
Switches pty's MCP server registration from `coord` to `st`, the
canonical short name from brief-005. Binary path moves from `bin/coord`
(back-compat shim) to `bin/st` (canonical entry).
Both forms continue to work post-Phase-0 — the coord repo's PR #14
shipped a 5-alias backward-compat layer. This commit just opts pty into
the canonical names now that they exist. Takes effect on next
pty-claude restart.
Local-only configs (pty.toml `ST_IDENTITY`, hook paths) are gitignored
and updated out-of-band on each machine.
Adds an optional `env` table to each pty.toml session entry. The parser
collects it into PtySessionDef.env, and a new commandWithEnvExports()
helper builds an `export K='V'; <command>` prefix that's prepended when
the supervisor wraps the command in `/bin/sh -c`. Single-quote escaping
keeps values with `'` and shell metacharacters safe.
Both the initial spawn path (cmdUp) and the restart re-read path
(supervisor.evaluateSession) call the helper, so env survives session
restarts and pty.toml edits.
Tests: tests/ptyfile.test.ts (parsing + export-string shape, including
single-quote and metacharacter escapes) and a new integration case in
tests/up-down.test.ts that spawns a session, redirects $MY_VAR to a probe
file, and asserts the value landed inside the session.
Repo-local MCP registration so Claude Code in this repo's pty
session boots with the coord channel-mode MCP server connected.
Identical contents to the coord repo's .mcp.json — coord is the
producer of the MCP server, pty just consumes it. Points at the
coord repo's `bin/coord` directly.
env is empty: COORD_IDENTITY=pty-claude is set in pty.toml (next
commit), COORD_ROOT defaults to ~/.local/state/coord.
Part of brief-018 (switchover package) — see
github.com/myobie/coord notes/brief-018-switchover-package.md.
`spawnDaemon` produces a detached daemon process that has no mechanism to
self-terminate when its spawner exits without calling `disconnect()` /
`kill()`. Because `detached: true` puts the daemon in its own session,
the kernel sends no signal when the spawner dies — the daemon is
reparented to init and survives forever.
In practice this leaks daemons whenever a short-lived script, test
harness, or scoped resource (e.g. `@overeng/pty-effect`) spawns a daemon
and exits. We observed hundreds of `dist/server.js` processes
accumulating multi-GiB RSS over days. See effect-utils#677.
Add a new `bindToSpawnerLifetime` option (opt-in, default off). When set,
`spawn.ts` injects `PTY_SPAWNER_PID=<pid>` into the daemon's env, and the
daemon polls `process.kill(pid, 0)` every 5 s. ESRCH triggers a clean
shutdown via the existing `cleanShutdown(0)` path. An invalid or
already-dead PID at startup causes immediate shutdown.
Long-lived supervisors that want daemons to outlive them simply omit
the option — full backwards compatibility.
Refs: overengineeringstudio/effect-utils#677
The PR's new tests generated session names like `bundle-fb1-ov5o` (15 chars).
Combined with macOS's tempdir under /var/folders/.../T/ (~52 chars) plus the
test root prefix and per-test "d-XXXXXX/", the resulting socket path tipped
over the 104-byte Unix-socket kernel limit by 1 byte.
Shorten the name format to ~6 chars (b<counter><rand3>); leaves enough margin
on /var/folders/... tempdirs.
`spawnDaemon` previously did `spawn('node', [<__dirname>/server.js])`.
Under bundlers that virtualise the filesystem (`bun build --compile`,
esbuild single-file, etc.) `import.meta.url` resolves to a `bunfs:`-style
path the spawned `node` child can't read, and the daemon fails to start.
The first iteration of this PR addressed that with an embedded server-
source bundle materialised to `os.tmpdir()`. That worked for resolving
server.js itself but exposed a deeper failure mode: the materialised
file lives outside the consumer's `node_modules`, and ESM resolution
does not honour `NODE_PATH` — so the daemon can't find its own external
deps (`node-pty`, `@xterm/*`) either, and every consumer would need a
bespoke `node_modules`-symlink dance to recover.
Replace the embedded-source approach with CLI delegation. The resolution
strategy becomes:
1. `setServerModulePath()` override — wins over everything (test
harnesses, supervisors with custom paths).
2. Sibling `__dirname/server.js` readable on disk — direct
`node <server.js>` (existing fast path for ordinary npm installs).
3. Bundled context — sibling unreadable. Shell out to `pty run -d
--name <name> --cwd <cwd> [--isolate-env] [--tag k=v]... -- <cmd>
<args>`. The CLI binary is always a real on-disk file with intact
module resolution, so it sidesteps every bundling failure mode at
once: spawning, server materialisation, daemon module resolution,
native-binding loading.
Tradeoff: the CLI path doesn't surface every `SpawnDaemonOptions` field
(`rows`, `cols`, `displayCommand`, `displayName`, `ephemeral`,
`extraEnv`, `env`, `launcher`). For the dominant consumer pattern
(spawn a shell, attach a UI, resize after attach), only `cwd`, `name`,
`tags`, and `isolateEnv` are load-bearing at spawn time — all
supported. Add CLI flags upstream as concrete needs arise. Consumers
that need full fidelity in a bundled context can still call
`setServerModulePath()` with a real on-disk server.
Drops `scripts/embed-server-source.js`, the `dist/server-source.txt`
artifact, and the `esbuild` dev-dep that the embed pipeline required.
Tests cover all three resolution strategies (on-disk, CLI delegation,
explicit override) and the no-CLI-on-PATH error path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related issues caused permanent supervised sessions to be silently
dropped after a single slow startup (observed on reboot with heavy
claude --resume sessions, see ~/.local/state/pty/supervisor.log).
1. waitForSocket timeout was hardcoded to 3000ms in spawn.ts. Heavy
children like `claude --resume` of a large conversation routinely
take 5-15s to open their socket, so the supervisor's first restart
attempt would time out before the child was ready.
2. doRestart's recovery path on retry was broken. The flow:
- First attempt: readMetadata(name) → fresh data; cleanupAll(name)
removes the metadata file; spawnDaemon throws on timeout.
- scheduleRestart catches, bumps restartCount, schedules retry.
- Second attempt: readMetadata(name) → null (file gone); doRestart
hits "skipping restart for X (no metadata)" and the session is
permanently abandoned.
Fix:
- Bump waitForSocket default to 30000ms via a new
DEFAULT_START_TIMEOUT_MS constant. Add an optional startTimeoutMs
field on SpawnDaemonOptions for callers that want tighter or looser
bounds. Earlier-exit detection still surfaces genuine failures
within milliseconds; this only governs the "alive but slow" case.
- Cache the spawn config on SupervisedSession.spawnConfig at
evaluateSession time and update it again immediately before
cleanupAll + spawnDaemon. When doRestart's readMetadata returns
null but the session is still tracked as permanent with a cached
config and not yet marked failed, fall back to
respawnFromCachedConfig instead of bailing.
Existing 17 supervisor tests stay green. A targeted regression test
for the slow-start-then-retry sequence would need spawnDaemon mocking
infrastructure that the test suite doesn't currently have; deferred.
Add a readonly bracketedPasteMode: boolean to PtyHandle, parallel to the
existing alternateScreen / mouseMode / kittyKeyboardFlags getters.
Delegates to xterm-headless's native terminal.modes.bracketedPasteMode —
no manual CSI parser tracking needed.
Use case: pty-layout currently enables \\e[?2004h on its outer terminal
unconditionally so editors like helix get paste-no-indent. That forwards
paste markers to any focused pane regardless of whether the program
consuming stdin handles them; a CLI using gets() can receive literal ESC
sequences in pasted content. With this getter, pty-layout can proxy the
mode by-pane: when the focused pane has it on, write \\e[?2004h to outer;
when it doesn't, write \\e[?2004l.
bin/pty was using spawnSync, which doesn't forward signals to the
child process. When systemd (KillMode=process) sent SIGTERM to the
wrapper during a service restart, the wrapper exited but the inner
supervisor cli kept running, holding supervisor.lock. The next
service start then failed with "another supervisor is already
running" — and systemd's Restart=on-failure looped that forever.
Fix: switch to spawn so the wrapper stays event-loop-live, register
forwarders for SIGTERM/SIGINT/SIGHUP/SIGQUIT/SIGUSR1/SIGUSR2, and
drive wrapper exit from the child's exit event so the child has
time to flush. Signal-death maps to 128 + signum per shell
convention. spawn-failure prints a clear stderr message and exits 1.
Reported and fixed by @schickling-assistant. Includes
tests/wrapper-signal-forwarding.test.ts which spawns the real
wrapper running supervisor start, SIGTERMs the wrapper, and asserts
the inner cli pid dies and supervisor.lock is cleared (proving
graceful shutdown rather than wrapper-level SIGKILL). The reporter
verified the test fails when the old spawnSync wrapper is reapplied.
listSessions() had two cleanup paths that ran regardless of whether
the recorded pid was still alive:
1. The .sock branch deleted the socket file whenever isSocketReachable
failed (500ms probe trips on busy daemon, transient EAGAIN, race
with a service restart). Once .sock was gone, the still-alive
daemon became invisible to every future scan with no recovery
short of kill -9.
2. The .json branch unconditionally deleted metadata older than 24h.
Long-lived daemons whose metadata wasn't refreshed (because nothing
refreshes it) had their .json removed after a day even though the
process kept consuming memory.
Both paths now gate on isProcessAlive(pid). If the pid is alive, the
state is kept. The TTL is meant to age out known-dead sessions, not
unknown-state ones; the kill(pid, 0) probe is one cheap syscall per
dead-looking session per scan.
Reported and fixed by @schickling-assistant. Includes regression
tests in tests/list-filters.test.ts that exercise both the live-pid
guard and the matched negative case where the 24h TTL still fires
when the pid is dead.
The bin/pty wrapper used spawnSync, which (a) blocks the wrapper's event
loop so any signal handlers we'd register would never run, and
(b) leaves no path for SIGTERM/SIGINT/etc. to reach the cli.js child.
Under systemd with KillMode=process, the wrapper is the unit's MainPID.
systemd SIGTERMs the wrapper, the wrapper dies, but the inner cli.js
supervisor — which has its own SIGTERM handler that calls
Supervisor.stop() and releases supervisor.lock — never sees the signal.
It gets reparented and keeps running. Every subsequent unit invocation
then exits with 'another supervisor is already running' because the
orphan still holds the lock. Reproduced on a NixOS host where the
restart counter climbed past 500 with the original supervisor still
alive after an hour.
The fix switches to spawn() and forwards SIGTERM/SIGINT/SIGHUP/SIGQUIT/
SIGUSR1/SIGUSR2 to the child, then waits for the child's exit event
before propagating its status. Normal exit-code passthrough is
unchanged. Signal-death is mapped to 128+signum, matching shell
convention.
Tests in tests/wrapper-signal-forwarding.test.ts spawn the wrapper
running 'supervisor start' against an isolated PTY_SESSION_DIR, SIGTERM
the wrapper, and assert the inner cli.js dies and supervisor.lock is
released. Verified the test fails against the old spawnSync wrapper
(inner pid survives, lock leaks).
listSessions() previously deleted .sock files whenever a socket was
unreachable, even if the recorded pid was still alive. That made any
transient socket-reachable failure (busy daemon, EAGAIN, race with a
service restart) permanent: once the .sock is removed, the still-alive
daemon becomes invisible to all future scans.
It also called cleanupAll() on .json files older than 24h without
checking whether the daemon process was still running, so long-lived
daemons silently lost their metadata after a day and disappeared from
'pty list' even though they kept consuming RAM.
This commit makes both checks gated on isProcessAlive(pid):
- if pid is alive but socket is unreachable, keep both .sock and metadata,
report status as running
- if .json is older than 24h, only cleanupAll() when pid is dead
Refs: https://github.com/myobie/pty/issues/34
The interactive overview was a snapshot taken on screen entry — new
sessions, exits, tag changes, and rename events stayed invisible
until the user navigated away and back. Reported by
@schickling-assistant.
Implementation: poll listSessions() once a second while the home
screen is the active view, push the result through the existing
`sessions` signal. The framework's effect()-wrapped renderFrame
re-runs because it tracks signal reads — no other plumbing needed.
Polling pauses around attach / spawn handoffs (pauseApp /
resumeApp wrap myApp.pause / myApp.resume so the polling toggles
in lockstep), and the interval is .unref()-ed so the event loop
isn't held alive by it (when stdin closes the process exits
cleanly).
Tried fs.watch + EventFollower first; both were silent in the
test harness because the TUI runs in a node-pty child process and
the file changes happen in a sibling process — that combination
isn't reliably picked up on macOS. Existing EventFollower tests
all run in-process so the cross-process case wasn't covered. A 1-
second poll is responsive enough for the "did a session appear
or exit" UX, costs a single readdir + per-file stat per tick,
and works on every platform regardless of fd / IPC quirks.
Tests (tests/tui.test.ts) cover three trigger types end-to-end
through a real PTY: session created externally appears in the
list; session that exits externally shows up as exited (or its
absence is reflected); tag added externally with `pty tag`
renders inline as `#k=v`. Each waits up to 5-8s for the next
poll tick to fire.
Filing in / out of sessions cleared the overview's filter on every
return (and reset the selection), which made navigating multiple
sessions feel disorienting per @schickling-assistant's report.
Mechanism: doAttach's onDetach/onExit handlers, doAttachRemote's
post-attach refresh, and doSpawnRemote's post-spawn refresh all
explicitly called filterField.set({ text: "", cursor: 0 }) before
resuming the app. Removing those clears makes the module-level
signals naturally persist across the attach round-trip.
The selection clamp logic is intentionally kept — when the list
shrinks (e.g., the session you were attached to exits and gets
reaped before you return), selectedIndex still snaps in-bounds via
the existing Math.max(0, totalItems - 1) check. That's the right
behavior either way and is independent of the filter-preservation
fix.
Test (tests/tui.test.ts) creates three sessions, types a filter
that narrows to one, attaches to it, detaches, and asserts: the
"Filter: ..." line still shows the typed value, the matched
session is still visible, the other two are still hidden by the
filter. Then ctrl+u clears it and the other two reappear, which
also confirms the filter cursor is preserved (ctrl+u operates on
the live cursor position).
New sibling command pty tag-multi for multi-session work. Single-
session pty tag stays as-is; pty tag-multi lives next to it so a
fingerslip cant accidentally rewrite the wrong scope.
Three mutually-exclusive selectors:
<name>... explicit list (resolved up-front; if any
name is unresolvable the command aborts
before any write — no partial application)
--filter-tag k=v sessions matching tag(s); repeatable AND
--all every session
No ops = read mode (per-session tag dump, text or --json returning
{ name → tags }). Any ops (k=v / --rm k) flips to write mode. --all
writes are destructive across the whole session dir, so they require
--yes / -y; without it the command prints the matched count and
exits non-zero, no writes performed. Empty match (filter or --all
over an empty dir) is exit 0 with "0 sessions matched."
Each successful write goes through the existing atomic updateTags
+ tags_change event flow per-session; per-session no-ops emit
nothing. Selector resolution is upfront for explicit lists, lazy
for filter / --all (just listSessions).
Also hardens the existing single-session pty tag while we're here:
- rejects empty key in `=value`
- rejects --rm "" (empty key for removal)
- clearer error for --rm at end of argv with no key
- tightened parsing comments
Tests:
tests/tag-multi.test.ts (38 tests) — read explicit list, read
selectors (--filter-tag single/repeated/empty, --all on empty
dir), write explicit list (events fire per session, no-op on
one peer doesnt suppress the others, upfront-unresolvable
aborts), write selectors (--filter-tag, --all without --yes
rejected, --all --yes applied, -y short form), selector mutex
(--all+filter, --all+names, filter+names, no selector, ops
without selector), ops parsing errors (empty key, --rm at end,
--rm "", --filter-tag without value, --filter-tag without =,
multi-= splits on first =, set+rm same key wins for rm to
match pty tag).
tests/tag-bulk.test.ts (29 tests) — pin the existing pty tag
contract: bulk set, bulk rm, combined set+rm, position
independence, empty key/value edges, atomic events (one
tags_change per call), no-op detection, partial no-op + real
change, displayName resolution.
CHANGELOG and README reflect both.
For non-Node consumers that want to read pty's session state directly
without paying Node CLI startup. Covers PTY_SESSION_DIR, the file
table with stability tiers (tier 1 = <name>.json, .events.jsonl;
tier 2 = .sock, .pid, .lock, theme, supervisor/), the SessionMetadata
TS shape, the events JSONL envelope and every concrete event type,
and the <target>.tmp.<pid>.<rand> atomic-write convention with an
explicit "third-party readers MUST ignore these" note.
Pre-1.0 banner: schema may change in any release; pin to a pty
version. CHANGELOG carries a "### Storage format" heading whenever
a tier-1 file shape changes.
Drift guard: tests/disk-layout-docs.test.ts asserts every
SessionMetadata field name, every concrete event-type literal, and
every file extension we write under PTY_SESSION_DIR appears in the
doc. SessionMetadata and EventBase carry "PUBLIC FORMAT" comment
markers pointing at the doc + the smoke test.
README's Events section gains a one-paragraph pointer to the doc and
mentions git-style command forwarding (pty <subcommand> ->
pty-<subcommand> on $PATH) as the recommended path for native
fast-path readers.
The pty / pty i / pty interactive picker's filter input was append-
only — backspace + printable chars, no cursor, no word motion, no
kill-line. Editing longer queries was awkward compared to the shell
prompts people build muscle memory for.
Fixed at two layers:
1. applyTextKey (src/tui/widgets/form.ts) — the shared text-field
helper — gains ctrl+w (delete previous word) and ctrl+k (kill to
end of line). It already had backspace / delete / arrows / home
/ end / ctrl+a / ctrl+e / ctrl+u / alt+arrow and alt+b/f word
motion. So every consumer (interactive filter, form widget,
command palette, prompt bar) picks up the new shortcuts for free.
2. Interactive filter (src/tui/interactive.ts) — replaces the hand-
rolled append/slice key handling with a TextFieldState signal
routed through applyTextKey. The filter line renders via
renderFieldNodes so the cursor paints on top of the char under
it (inverse-video) without shoving neighbors sideways. Typing
resets the selection to 0 as before; pure cursor motion (home,
end, arrows) preserves the current selection.
Tests:
- widgets-form.test.ts: 8 new unit tests for ctrl+a, ctrl+e, ctrl+w
(end-of-line, mid-string, trailing-whitespace boundary, empty
field) and ctrl+k (mid-cursor, at-end).
- tui.test.ts: 2 new end-to-end tests through a real PTY confirming
ctrl+u clears the filter in one keystroke and ctrl+w removes the
trailing word, updating the rendered filter line.
Reported against pty tag by schickling-assistant. Root cause: four
write sites (writeMetadata, supervisor state save, pty supervisor
reset, event-log retention) all used `target + ".tmp"` — a single
fixed path per target. Two concurrent writers truncated and
overwrote each others tmp file, and whichever rename ran during a
mid-write moment could publish half-written bytes as the target.
That explained both symptoms in #22:
- Stable: 9 of 10 concurrent writes lost ("last-write-wins" was
not the behavior; worse, the file could be left in a torn state).
- Transient: pty tag <session> returning "Session not found" even
while pty list --json showed the session alive — readMetadata
silently catches JSON.parse failure and returns null, which
propagates as "not found."
Fix: new atomicWriteFileSync / atomicWriteFile helpers in
sessions.ts that write to `<target>.tmp.<pid>.<rand>` (unique per
writer) and then rename into place. POSIX same-filesystem rename
is atomic — readers always see either the old file or the new
one, never an intermediate. All four call sites now route through
the helper. Event-log retention also uses tmp+rename so pty events
readers never see a mid-rewrite file.
Contract: last-write-wins under concurrent writers; lost updates
are possible, corruption is not. Matches the reporters explicit
"last-write-wins is fine" in the Expected section of #22.
Known limit documented in events.ts: very large (> PIPE_BUF ~4KB)
user.* / state.set event payloads can still interleave at the
POSIX O_APPEND level. Keep large blobs in state, not events.
Tests (tests/atomic-writes.test.ts): reader-during-writer for both
helpers, tmp-file cleanup check, concurrent pty tag via 10 and 20
child processes (asserts metadata parses after the race),
Promise.all setState x 50 in-process, event-log truncation vs
concurrent reader (asserts every recovered event has a valid
type).
The systemd and runit installer tests exist to exercise real system
integration, but the two tests fail hard on macOS and on Linux boxes
that dont have runit installed — not because the code is broken,
just because the required binaries arent there. Converted the
runtime-throw (runsvdir case) and the platform-mismatch (systemd
case) into it.skipIf gates so developers on Mac see "skipped"
instead of "runsvdir is not installed" red errors.
CI on Linux still runs both; its where the real validation lives.
Request from pty-layout-claude: every pty-layout pane runs a shell
with PTY_SESSION set, so commands that silently started a nested pty
client routed detach keybindings to the outer client and left users
tangled with no clean way to exit.
New helper ensureNotNested(cmd, { force?, hint? }) in cli.ts. Called
from:
- pty attach / pty a / pty attach -r: refuse early (before
resolveRef) so even a mistyped ref yields the nesting hint instead
of a "not found" error. Hint mentions Ctrl+\\ to detach and pty-
layouts ^]n picker.
- pty restart <ref>: restart still runs (its independent of attach),
but the trailing doAttach is skipped and a one-line "(not attached:
already inside pty session X)" notice prints. -y / --yes still
skips the kill-confirm prompt; --force restores restart+attach.
- pty / pty i / pty interactive: refuse to open the TUI picker;
picker rendering inside a session with broken detach was the worst
footgun of the bunch.
- pty run -a: narrows the existing "already inside pty session,
running directly" behavior — if the target is already running,
refuse rather than silently dropping -a. Target-not-running case
keeps the run-directly behavior; -a is a no-op in that situation
anyway.
--force across all four to opt back into the old behavior (nested
clients are occasionally intentional: debugging, screen-sharing
demos).
Latent test-harness bug surfaced by the guard: Session.spawn
(@myobie/pty/testing) was leaking the harness's own PTY_SESSION into
the child env, so every interactive-TUI test in tui.test.ts tripped
the new guard. Scrub PTY_SESSION alongside the existing
PTY_SERVER_CONFIG scrubbing so Session.spawn simulates a clean user
shell regardless of what the harness is running inside.
Without an explicit sort, cmdList iterates listSessions() in whatever
order fs.readdirSync returns for the session dir — on APFS thats
roughly insertion order, which drifts as sessions come and go and
reads like accidental chaos. Now the output is stable and
predictable: alphabetical by the user-visible label (displayName when
set, stable id otherwise), applied to the JSON output and all three
status buckets (running/exited/vanished). Remote host sessions sort
the same way.
Scope is pty list only — the interactive TUI has its own ordering
semantics and is unaffected.
Part 2 of the palette-preservation work — first half was on
PtyHandle.readCells (commit 6248390). That change landed fgIndex /
bgIndex on the PtyCell shape but pty-layout tracing showed the round
trip broke inside the CellBuffer pipeline: setCell -> Cell (in
types.ts) had no index fields, so writeAnsi / diff / fullRender
silently dropped the index and re-emitted as truecolor.
Now the buffer-side Cell carries fgIndex / bgIndex (required,
number | null) and the pipeline plumbs them through:
- writeAnsi captures the index on SGR 30-37 / 90-97 / 38;5;N /
48;5;N, and clears it on 38;2 / 48;2 truecolor and on SGR 0 / 39 /
49 resets.
- cellsEqual compares the index so diff re-emits correctly when only
the index changed (same flattened RGB, different palette slot).
- diff + fullRender use shared emitFg / emitBg helpers that prefer
indexed SGR when fgIndex is non-null (SGR 30-37 for 0-7, 90-97 for
8-15, 38;5;N for 16-255) and fall back to truecolor SGR 38;2 /
48;2 otherwise.
- ptyView rendering path forwards cell.fgIndex / bgIndex from the
upstream PtyCell, so embedded panes re-emitted by consumers like
pty-layout keep the outer terminal's theme for indexed cells while
still supporting truecolor for programs that emit it.
Required rather than optional on Cell — nothing outside this repo
constructs Cell objects in our tree, and strict typing keeps the few
internal construction sites (emptyCell, makeCell, writeAnsi, FPS
counter literal in app.ts) honest. pty-layout's one selection-
highlight construction site will need a trivial fgIndex/bgIndex: null
addition.
pty-layout composites panes by reading cells and re-emitting them as
truecolor SGR bytes. readXtermCells was flattening palette-indexed
cells to a hardcoded VGA RGB via paletteToRgb(), so SGR 34 always
came out [0,0,204] even when the outer terminal (kitty, wezterm,
iterm2) had its own theme blue. pty attach is unaffected — that path
pipes raw bytes and the SGR codes pass through.
Cells returned from PtyHandle.readCells() now carry:
fgIndex: number | null // 0-255 when isFgPalette(), else null
bgIndex: number | null // 0-255 when isBgPalette(), else null
Re-emitters check the index first and round-trip as SGR 30-37/90-97
or 38;5;N / 48;5;N; the outer terminals palette then wins. Existing
fg/bg RGB fields unchanged — consumers that dont know about fgIndex
fall back to the flattened RGB and still work.
The inline readCells return type is now a named PtyCell interface
exported from @myobie/pty/tui, so consumers can import the shape
without a ReturnType<...> dance.
underlineColor deliberately skipped — the current Cell shape only
tracks underline: boolean with no color at all, so adding palette-
aware underline is a bigger change than this non-breaking addition.
Separate request if pty-layout wants it.