Atomic metadata writes: unique tmp path per writer so concurrent
pty tag / state mutations cant corrupt the file (closes #22)
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).
Preserve palette index through CellBuffer so re-emitters can keep the
outer terminals theme
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.
Document the on-disk layout under docs/disk-layout.md
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.
list & gc ergonomics: vanished status, --summary/--status/age filters, gc --dry-run (closes #21)
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.
list & gc ergonomics: vanished status, --summary/--status/age filters, gc --dry-run (closes #21)
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.
Emit display_name_change + tags_change; move metadata-mutation events
into the helpers so the programmatic API emits too.
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.
Nesting prevention: refuse client-inside-a-client for attach,
interactive, restart-then-attach, and run -a when target is running
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.
Add supervisor, mutable tags, peek --wait/--full, and hardening
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.
Emit display_name_change + tags_change; move metadata-mutation events
into the helpers so the programmatic API emits too.
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.
Add supervisor, mutable tags, peek --wait/--full, and hardening
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.
pty tag-multi: multi-session tag operations + harden bulk pty tag
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.
pty tag-multi: multi-session tag operations + harden bulk pty tag
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.
Add supervisor, mutable tags, peek --wait/--full, and hardening
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.
Readline-style shortcuts in the interactive filter input (closes #24)
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.
fix(bin): forward signals from pty wrapper to inner CLI
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).