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.
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.
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.
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.
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.
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.