Commits
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.
Requested by pty-layout-claude: `pty rename` wrote metadata atomically
but emitted no event, so EventFollower consumers (pty-layout's pane
titles) had no signal to refresh and stayed stale until re-attach.
Same bundling for tag mutations since the smell was identical —
setDisplayName and updateTags now both emit on effective change:
- display_name_change: { session, ts, previous, value } with value
string | null. Skipped on no-op writes.
- tags_change: { session, ts, previous, value } carrying full
Record<string,string> snapshots so consumers can diff without
reasoning about updates vs. removals. Skipped on no-op.
While here, moved the state.set / state.delete emission from the CLI
down into setState / deleteState themselves. The CLI was the only
emission point before, which meant @myobie/pty/client consumers
calling setState directly got silent writes. Now every metadata-
mutation helper emits uniformly regardless of caller.
Added appendEventSync so the sync helpers can emit inline without
going async. Same MAX_LINES retention as the async path, with a
stat-based fast path.
Tests: 19 new across tests/metadata-events.test.ts (new) and
tests/state.test.ts — covering both events (add / change / clear /
remove), both formatEvent cases, live EventFollower delivery, CLI
end-to-end (pty rename, pty tag), no-op suppression, and the
programmatic-emission fix for setState/deleteState.
pty emit publishes user.* events to a session's .events.jsonl; the
user.* namespace is reserved for arbitrary app events. Inside a
session the ref is omitted — $PTY_SESSION resolves it.
pty state adds a per-session JSON key/value bag with get/set/delete/
keys subcommands. Mutations auto-emit state.set / state.delete events
so downstream watchers see a full history. Writes go through the
atomic writeMetadata rename; in-process Promise.all over setState is
safe, cross-process writers can still race on the RMW window.
Both features are re-exported from @myobie/pty/client for programmatic
use (emitUserEvent, getState, setState, deleteState, listStateKeys,
plus the new event type unions).
Hardening surfaced while writing tests and during review:
- deleteState returns bool; CLI gates state.delete emission on it so a
delete of a missing key is a quiet no-op (no ghost event).
- getStateKey / deleteState use Object.prototype.hasOwnProperty so
prototype names (toString, constructor, __proto__) never match.
- appendEvent honors the same MAX_LINES retention as EventWriter with
a stat-based fast path — scripts in a loop don't grow events.jsonl
unbounded.
- pty state set rejects 3+ positionals instead of silently joining
them with spaces into JSON.parse.
- pty state keys rejects extra positionals, matching the other
subcommands.
The raw-stdin parser in src/tui/input.ts dropped the legacy xterm
encoding (ESC[Z) as unknown CSI and discarded the shift modifier in
the kitty keyboard protocol for code 9 (tab). Both paths now produce
{ name: "backtab", shift: true, ctrl: false, alt: false } so form
widgets can bind shift-tab for backward field navigation.
Also adds shift: boolean as a required field on KeyEvent, extracted
from the kitty modifier bitmask alongside ctrl and alt. Existing
consumers that only read ctrl/alt are unaffected.
Surfaced while building reminders — its form widget was using up/down
as a local workaround because backtab never reached the handler.
12 new focused tests in tests/input-parse.test.ts covering both
encodings, modifier combinations, and the existing ctrl/alt/arrow
paths.
Loggy was always intended to become its own package once the shape
settled. It has, so it now lives at github.com/myobie/loggy and
consumes pty's TUI framework via relative imports (../pty/src/tui).
Removes demos/loggy/, bin/loggy, the package.json bin entry, and
references in demos/run + README.md.
Four changes, one PR:
1. Third session status `vanished` for the "daemon is gone, no exit
record written" case (SIGKILL / OOM / crash). Listed in its own
yellow-headered bucket with a warning icon so it doesn't blend into
cleanly-exited sessions. Reapable by `pty gc` like any other dead
session. TTL anchor falls back to createdAt so they can't accumulate
indefinitely.
2. `pty list --summary` (+ `--json --summary`) — compact counts and
oldest/newest pointers, respects all other filters.
3. `pty list --status <state>` and `--older-than/--newer-than <Ns|Nm|Nh|Nd>`
filters. Compose with `--filter-tag` and `--summary`. Grammar is
deliberately single-unit (no `1h30m`) to keep --help trivial.
4. `pty gc --dry-run` (`-n`) — preview what would be removed without
mutating anything. Covers exited AND vanished sessions AND orphan
`:l<pid>-<rand>` layout tags in one pass.
Client API:
- gc({ dryRun }), pruneOrphanLayoutTags({ dryRun })
- New `isGone(status)` helper — replaces hand-rolled
`status === "exited"` checks that should have included vanished all
along. Swept existing callers (run -a, peek fallback, pty up/down
cleanup, stats --all) to use it.
- New parseDuration/formatDuration exports for downstream tools.
Tests:
- duration.test.ts — full grammar coverage.
- list-filters.test.ts — vanished inference, --status, age filters,
--summary (text + json), filter composition.
- gc.test.ts — extended with --dry-run and vanished-session reaping.
726 → 728 local tests pass (+37 new), 0 failures.
Previously `pty send <name> "text" --enter` exited 0 and silently
dropped --enter, so the text landed at the prompt but never executed.
The --seq branch already rejected unknown args; the positional branch
now mirrors that strictness.
Typos --enter / --newline / --return / --cr additionally print a hint
pointing at --seq "text" --seq key:return, which is the real syntax.
Big release. Highlights:
- Session naming refactor: every session has an immutable `name` (short
random id by default) and an optional mutable `displayName`. `pty
rename` sets/clears the displayName; every CLI ref accepts either.
- Interactive TUI "Create new session..." is now one keystroke.
- Security audit fixes: socket-path length, acquireLock atomicity,
PacketReader size cap, --isolate-env, umask tightening.
- Mouse tracking mode replay, EventFollower new-file offset, and
session_exit-on-SIGTERM race fixes.
- Remote sessions via pty-relay now render with tags + displayName in
pty list --remote.
- pty up removes tags dropped from pty.toml.
- Client API adds extractFilterTags/matchesAllTags, setDisplayName,
launcher override on SpawnDaemonOptions, and
PtyHandle.alternateScreen/kittyKeyboardFlags.
fix: resolve sendData on 'finish' instead of 'close'
sendData was hanging in Linux namespace containers (e.g. Namespace.so
CI runners) because it resolved on the socket's 'close' event, which
requires both sides to half-close. Some container network stacks don't
reliably trigger the server's auto-half-close, so the client waits
forever for a FIN that never comes.
'finish' fires when socket.end() has flushed the writable side to the
kernel — for a fire-and-forget send over a Unix domain socket that's
exactly the right guarantee: the bytes are in the server's recv buffer,
which the kernel will hold until the server reads them regardless of
what happens to our userspace socket.
Only sendData had this bug. The other socket.on('close', ...) sites
(SessionConnection lifecycle, peekScreen safety net) are correctly
waiting for the full close signal.
Ensures a fire-and-forget DATA socket (connect → write → end) gets
the 'finish' event quickly, rather than hanging waiting for the server
to send FIN back ('close' event). This pattern is unreliable in Linux
namespace containers.
Refs #18
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes #18
'close' requires both sides to send FIN (full bidirectional TCP close).
In Linux namespace containers, the server's automatic FIN via
allowHalfOpen: false is unreliable, causing send() to hang until timeout.
'finish' fires when socket.end() has been flushed to the OS kernel,
which is sufficient for Unix domain sockets.
Every session now has an immutable `name` (the stable id) and an
optional, mutable `displayName`. Default `pty run` generates a random
8-char id for `name` and the old human-friendly cwd+command label for
`displayName`. `--no-display-name` opts out of the label. `pty rename`
sets/changes/clears the displayName, either from inside a session
(using PTY_SESSION) or against an explicit ref from outside.
Every command that takes a session reference now accepts either the
name or the displayName. The list and interactive TUI render the
displayName primarily, with the stable id in parens when both exist.
Existing sessions on disk are unchanged (displayName just stays
absent). New sessions created after this release have random ids in
PTY_SESSION, events, and ptyfile.session — this is a behavioral change
for anyone comparing those values as strings.
✻ Cogitated for 13m 45s
- EventFollower.watchFile now starts at offset 0 when the directory
watcher detects a brand-new events.jsonl. session_start is almost
always already in the file by the time the dir event fires, so
starting at EOF was skipping it. Pre-existing files on startup still
start at EOF (no history replay).
- Server.close() now awaits ptyProcess.onExit (bounded at 2s) before
flushing the EventWriter. When the daemon was killed via SIGTERM,
session_exit was queued on the async write chain but the daemon
exited before the append landed. pty kill no longer loses exit
events.
- Reject session names whose Unix-socket path would exceed the 104-byte
sun_path limit. Previously validateName passed them, the daemon's
listen() failed silently inside an error handler, and every caller
awaited server.ready forever. server.ready also now rejects on listen
errors instead of hanging.
- Make acquireLock atomic via openSync(path, "wx"). Two processes racing
to steal a stale lock can no longer both win; the loser gets EEXIST
and returns false.
- Cap inbound packet length at 32 MiB in PacketReader. A malformed
[type, length=0xFFFFFFFF] header previously caused unbounded buffer
growth; oversize now throws PacketTooLargeError and every socket
handler (server, client, connection, builders, testing) destroys the
peer.
- Add pty run --isolate-env (plus isolateEnv/extraEnv on
SpawnDaemonOptions and ServerOptions) to spawn session children with
an env scrubbed down to PATH/HOME/USER/LOGNAME/SHELL/TERM/COLORTERM/
LANG/TZ/PWD/TMPDIR/LC_*/PTY_SESSION_DIR/PTY_SESSION. Opt-in; default
behaviour is unchanged. Intended for pty-relay's remote-spawn path so
operator secrets don't leak to remote clients.
- Tighten umask to 0o077 around the socket listen() to close the brief
window where the socket inode was group/world-readable before the
chmod(0o600) ran.
Hosts embedding PtyHandle (e.g. pty-layout) now have read-only access to
two modes they need to proxy to the outer terminal: whether the child is
in the alternate screen buffer (for scroll-as-arrow-keys fallback), and
the kitty keyboard protocol flag stack (so Shift+Enter and other
modified keys can be forwarded to the focused pane).
alternateScreen reads xterm's buffer.active.type directly. The serialize
addon already captures the alt buffer and emits the mode switch on
replay, so no server-side getModePrefix change is needed.
The server already replayed SGR encoding (1006), cursor visibility, and
kitty keyboard flags via getModePrefix(), but not the tracking modes
themselves. Clients attaching mid-session saw SGR on but tracking off,
so mouse forwarding decisions (e.g. in pty-layout) got it wrong.
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.
Requested by pty-layout-claude: `pty rename` wrote metadata atomically
but emitted no event, so EventFollower consumers (pty-layout's pane
titles) had no signal to refresh and stayed stale until re-attach.
Same bundling for tag mutations since the smell was identical —
setDisplayName and updateTags now both emit on effective change:
- display_name_change: { session, ts, previous, value } with value
string | null. Skipped on no-op writes.
- tags_change: { session, ts, previous, value } carrying full
Record<string,string> snapshots so consumers can diff without
reasoning about updates vs. removals. Skipped on no-op.
While here, moved the state.set / state.delete emission from the CLI
down into setState / deleteState themselves. The CLI was the only
emission point before, which meant @myobie/pty/client consumers
calling setState directly got silent writes. Now every metadata-
mutation helper emits uniformly regardless of caller.
Added appendEventSync so the sync helpers can emit inline without
going async. Same MAX_LINES retention as the async path, with a
stat-based fast path.
Tests: 19 new across tests/metadata-events.test.ts (new) and
tests/state.test.ts — covering both events (add / change / clear /
remove), both formatEvent cases, live EventFollower delivery, CLI
end-to-end (pty rename, pty tag), no-op suppression, and the
programmatic-emission fix for setState/deleteState.
pty emit publishes user.* events to a session's .events.jsonl; the
user.* namespace is reserved for arbitrary app events. Inside a
session the ref is omitted — $PTY_SESSION resolves it.
pty state adds a per-session JSON key/value bag with get/set/delete/
keys subcommands. Mutations auto-emit state.set / state.delete events
so downstream watchers see a full history. Writes go through the
atomic writeMetadata rename; in-process Promise.all over setState is
safe, cross-process writers can still race on the RMW window.
Both features are re-exported from @myobie/pty/client for programmatic
use (emitUserEvent, getState, setState, deleteState, listStateKeys,
plus the new event type unions).
Hardening surfaced while writing tests and during review:
- deleteState returns bool; CLI gates state.delete emission on it so a
delete of a missing key is a quiet no-op (no ghost event).
- getStateKey / deleteState use Object.prototype.hasOwnProperty so
prototype names (toString, constructor, __proto__) never match.
- appendEvent honors the same MAX_LINES retention as EventWriter with
a stat-based fast path — scripts in a loop don't grow events.jsonl
unbounded.
- pty state set rejects 3+ positionals instead of silently joining
them with spaces into JSON.parse.
- pty state keys rejects extra positionals, matching the other
subcommands.
The raw-stdin parser in src/tui/input.ts dropped the legacy xterm
encoding (ESC[Z) as unknown CSI and discarded the shift modifier in
the kitty keyboard protocol for code 9 (tab). Both paths now produce
{ name: "backtab", shift: true, ctrl: false, alt: false } so form
widgets can bind shift-tab for backward field navigation.
Also adds shift: boolean as a required field on KeyEvent, extracted
from the kitty modifier bitmask alongside ctrl and alt. Existing
consumers that only read ctrl/alt are unaffected.
Surfaced while building reminders — its form widget was using up/down
as a local workaround because backtab never reached the handler.
12 new focused tests in tests/input-parse.test.ts covering both
encodings, modifier combinations, and the existing ctrl/alt/arrow
paths.
Four changes, one PR:
1. Third session status `vanished` for the "daemon is gone, no exit
record written" case (SIGKILL / OOM / crash). Listed in its own
yellow-headered bucket with a warning icon so it doesn't blend into
cleanly-exited sessions. Reapable by `pty gc` like any other dead
session. TTL anchor falls back to createdAt so they can't accumulate
indefinitely.
2. `pty list --summary` (+ `--json --summary`) — compact counts and
oldest/newest pointers, respects all other filters.
3. `pty list --status <state>` and `--older-than/--newer-than <Ns|Nm|Nh|Nd>`
filters. Compose with `--filter-tag` and `--summary`. Grammar is
deliberately single-unit (no `1h30m`) to keep --help trivial.
4. `pty gc --dry-run` (`-n`) — preview what would be removed without
mutating anything. Covers exited AND vanished sessions AND orphan
`:l<pid>-<rand>` layout tags in one pass.
Client API:
- gc({ dryRun }), pruneOrphanLayoutTags({ dryRun })
- New `isGone(status)` helper — replaces hand-rolled
`status === "exited"` checks that should have included vanished all
along. Swept existing callers (run -a, peek fallback, pty up/down
cleanup, stats --all) to use it.
- New parseDuration/formatDuration exports for downstream tools.
Tests:
- duration.test.ts — full grammar coverage.
- list-filters.test.ts — vanished inference, --status, age filters,
--summary (text + json), filter composition.
- gc.test.ts — extended with --dry-run and vanished-session reaping.
726 → 728 local tests pass (+37 new), 0 failures.
Previously `pty send <name> "text" --enter` exited 0 and silently
dropped --enter, so the text landed at the prompt but never executed.
The --seq branch already rejected unknown args; the positional branch
now mirrors that strictness.
Typos --enter / --newline / --return / --cr additionally print a hint
pointing at --seq "text" --seq key:return, which is the real syntax.
Big release. Highlights:
- Session naming refactor: every session has an immutable `name` (short
random id by default) and an optional mutable `displayName`. `pty
rename` sets/clears the displayName; every CLI ref accepts either.
- Interactive TUI "Create new session..." is now one keystroke.
- Security audit fixes: socket-path length, acquireLock atomicity,
PacketReader size cap, --isolate-env, umask tightening.
- Mouse tracking mode replay, EventFollower new-file offset, and
session_exit-on-SIGTERM race fixes.
- Remote sessions via pty-relay now render with tags + displayName in
pty list --remote.
- pty up removes tags dropped from pty.toml.
- Client API adds extractFilterTags/matchesAllTags, setDisplayName,
launcher override on SpawnDaemonOptions, and
PtyHandle.alternateScreen/kittyKeyboardFlags.
sendData was hanging in Linux namespace containers (e.g. Namespace.so
CI runners) because it resolved on the socket's 'close' event, which
requires both sides to half-close. Some container network stacks don't
reliably trigger the server's auto-half-close, so the client waits
forever for a FIN that never comes.
'finish' fires when socket.end() has flushed the writable side to the
kernel — for a fire-and-forget send over a Unix domain socket that's
exactly the right guarantee: the bytes are in the server's recv buffer,
which the kernel will hold until the server reads them regardless of
what happens to our userspace socket.
Only sendData had this bug. The other socket.on('close', ...) sites
(SessionConnection lifecycle, peekScreen safety net) are correctly
waiting for the full close signal.
Fixes #18
'close' requires both sides to send FIN (full bidirectional TCP close).
In Linux namespace containers, the server's automatic FIN via
allowHalfOpen: false is unreliable, causing send() to hang until timeout.
'finish' fires when socket.end() has been flushed to the OS kernel,
which is sufficient for Unix domain sockets.
Every session now has an immutable `name` (the stable id) and an
optional, mutable `displayName`. Default `pty run` generates a random
8-char id for `name` and the old human-friendly cwd+command label for
`displayName`. `--no-display-name` opts out of the label. `pty rename`
sets/changes/clears the displayName, either from inside a session
(using PTY_SESSION) or against an explicit ref from outside.
Every command that takes a session reference now accepts either the
name or the displayName. The list and interactive TUI render the
displayName primarily, with the stable id in parens when both exist.
Existing sessions on disk are unchanged (displayName just stays
absent). New sessions created after this release have random ids in
PTY_SESSION, events, and ptyfile.session — this is a behavioral change
for anyone comparing those values as strings.
✻ Cogitated for 13m 45s
- EventFollower.watchFile now starts at offset 0 when the directory
watcher detects a brand-new events.jsonl. session_start is almost
always already in the file by the time the dir event fires, so
starting at EOF was skipping it. Pre-existing files on startup still
start at EOF (no history replay).
- Server.close() now awaits ptyProcess.onExit (bounded at 2s) before
flushing the EventWriter. When the daemon was killed via SIGTERM,
session_exit was queued on the async write chain but the daemon
exited before the append landed. pty kill no longer loses exit
events.
- Reject session names whose Unix-socket path would exceed the 104-byte
sun_path limit. Previously validateName passed them, the daemon's
listen() failed silently inside an error handler, and every caller
awaited server.ready forever. server.ready also now rejects on listen
errors instead of hanging.
- Make acquireLock atomic via openSync(path, "wx"). Two processes racing
to steal a stale lock can no longer both win; the loser gets EEXIST
and returns false.
- Cap inbound packet length at 32 MiB in PacketReader. A malformed
[type, length=0xFFFFFFFF] header previously caused unbounded buffer
growth; oversize now throws PacketTooLargeError and every socket
handler (server, client, connection, builders, testing) destroys the
peer.
- Add pty run --isolate-env (plus isolateEnv/extraEnv on
SpawnDaemonOptions and ServerOptions) to spawn session children with
an env scrubbed down to PATH/HOME/USER/LOGNAME/SHELL/TERM/COLORTERM/
LANG/TZ/PWD/TMPDIR/LC_*/PTY_SESSION_DIR/PTY_SESSION. Opt-in; default
behaviour is unchanged. Intended for pty-relay's remote-spawn path so
operator secrets don't leak to remote clients.
- Tighten umask to 0o077 around the socket listen() to close the brief
window where the socket inode was group/world-readable before the
chmod(0o600) ran.
Hosts embedding PtyHandle (e.g. pty-layout) now have read-only access to
two modes they need to proxy to the outer terminal: whether the child is
in the alternate screen buffer (for scroll-as-arrow-keys fallback), and
the kitty keyboard protocol flag stack (so Shift+Enter and other
modified keys can be forwarded to the focused pane).
alternateScreen reads xterm's buffer.active.type directly. The serialize
addon already captures the alt buffer and emits the mode switch on
replay, so no server-side getModePrefix change is needed.