Commits
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.
Non-Node callers (Bun, Deno) can now route the detached daemon launch
through a Node binary so the PTY server can load its node-pty native
addon. Defaults to process.execPath, so existing Node callers are
unaffected.
Interactive TUI discovers pty-relay on PATH and fetches remote
sessions async via ls --json. Renders local + remote groups using
groupedSelectable. Enter on a remote session spawns pty-relay
connect with pause/resume. pty list --remote appends remote hosts.
Graceful degradation when pty-relay is not installed.
spawnDaemon now kills orphaned daemons on timeout. launchd install
compiles a C wrapper binary for FDA — grant it Full Disk Access
instead of node. Install flow validates FDA via one-shot launchd
job with interactive prompt. Fix events --wait timeout not being
cancelled on match. Fix displayCommand showing duplicated command
for toml sessions. PATH baked into wrapper at compile time.
peek and peek --wait now read saved output from exited sessions
(lastLines increased to 200). --wait accepts multiple patterns
(any match). saveExitMetadata runs twice: immediately in onExit
(for status display) and again in close() (for complete output).
Fix TUI exit race by delaying session list refresh 200ms. Fix
SKILL.md examples to use multiple --wait instead of regex.
Session supervisor watches strategy tags and restarts permanent
sessions with exponential backoff. Runs as a foreground process,
integrates with launchd via esbuild bundling. Mutable tags via
pty tag command. pty kill/down properly handle supervised sessions.
Also: peek --wait blocks until text appears, peek --full shows
scrollback, events --wait blocks for event types, interactive TUI
shows [permanent] markers with color, 10s supervisor scan interval,
supervisor state in its own subdirectory, defensive meta.args
handling, and isolated shell test sessions.
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.
Interactive TUI discovers pty-relay on PATH and fetches remote
sessions async via ls --json. Renders local + remote groups using
groupedSelectable. Enter on a remote session spawns pty-relay
connect with pause/resume. pty list --remote appends remote hosts.
Graceful degradation when pty-relay is not installed.
spawnDaemon now kills orphaned daemons on timeout. launchd install
compiles a C wrapper binary for FDA — grant it Full Disk Access
instead of node. Install flow validates FDA via one-shot launchd
job with interactive prompt. Fix events --wait timeout not being
cancelled on match. Fix displayCommand showing duplicated command
for toml sessions. PATH baked into wrapper at compile time.
peek and peek --wait now read saved output from exited sessions
(lastLines increased to 200). --wait accepts multiple patterns
(any match). saveExitMetadata runs twice: immediately in onExit
(for status display) and again in close() (for complete output).
Fix TUI exit race by delaying session list refresh 200ms. Fix
SKILL.md examples to use multiple --wait instead of regex.
Session supervisor watches strategy tags and restarts permanent
sessions with exponential backoff. Runs as a foreground process,
integrates with launchd via esbuild bundling. Mutable tags via
pty tag command. pty kill/down properly handle supervised sessions.
Also: peek --wait blocks until text appears, peek --full shows
scrollback, events --wait blocks for event types, interactive TUI
shows [permanent] markers with color, 10s supervisor scan interval,
supervisor state in its own subdirectory, defensive meta.args
handling, and isolated shell test sessions.