This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

lean-core: delete pty state + pty wrap; --help + completions rewrite (#58)

Two lean-core deletes and an accuracy pass on the help / completion
surface. Nathan authorized, cos briefed, usage-check across pty,
eval-sandbox/st-evals, convoy, pty-claude-launcher, cos, pty-relay,
and pty-layout came back clean — no external consumers.

BREAKING (no back-compat shim):

- `pty state` (subcommand + programmatic API + events + metadata field)
gone. CLI subcommands `get`/`set`/`delete`/`keys` removed. Public
API exports `getState`, `getStateKey`, `setState`, `deleteState`,
`listStateKeys` dropped from `@myobie/pty/client`. Event types
`state.set` and `state.delete` removed from `EventRecord`.
`SessionMetadata.state?` field removed — Storage format change;
existing metadata files with a populated `state` object silently
drop the field on the next daemon-side rewrite.

- `pty wrap` / `pty unwrap` / `pty wrap --list` removed. `PTY_BIN_PATH`
env var no longer consumed. `~/.local/pty/bin/` no longer created;
existing shims can be `rm -rf`'d by hand.

Redundant with smalltalk's folder-and-bus persistence (for state) and
orthogonal to the session primitive contract (for wrap).

Accuracy pass:

- `usage()` rewritten. Commands grouped logically (Create / Attach &
interact / Observe / Modify / Lifecycle / Multi / Global); every
flag every current subcommand accepts listed; `<ref>` semantics and
the four env vars (PTY_ROOT / PTY_SESSION_DIR / PTY_ROOT_LEGACY_SILENT
/ PTY_SESSION) documented.

- `completions/pty.{fish,bash,zsh}` rewritten against the same surface.
Every current subcommand + every accepted flag covered; `state`,
`wrap`, `unwrap` removed. All three shells consistent.

Tests: tests/state.test.ts deleted (485 LOC). tests/atomic-writes.test.ts
and tests/events.test.ts shed their state-specific cases.

Suite: 1202 passed, 21 skipped, 0 failed (down from 1231 in main; -29
across the deleted state.test.ts + removed state.* format assertions +
one atomic-writes setState concurrency test).

Delta: +467 / -1223 across 14 files (13 modified, 1 deleted).

authored by

Nathan and committed by
GitHub
(Jul 7, 2026, 11:06 AM +0200) 377d8ccf c196230d

+462 -1218
+22
CHANGELOG.md
··· 2 2 3 3 ## Unreleased 4 4 5 + ### Lean-core: `pty state` and `pty wrap` REMOVED (BREAKING) 6 + 7 + Two subcommands and their public API surface are removed with no back-compat shim. Both were auxiliary to the session primitive contract and better served elsewhere. 8 + 9 + - **Removed `pty state` (subcommand + programmatic API + events + metadata field).** Redundant with smalltalk's folder-and-bus persistence. 10 + - CLI: `pty state {get, set, delete, keys}` gone. 11 + - Public API on `@myobie/pty/client`: `getState`, `getStateKey`, `setState`, `deleteState`, `listStateKeys` gone. Removing these is a breaking export change. 12 + - Event types `state.set` and `state.delete` gone; their interfaces (`StateSetEvent`, `StateDeleteEvent`) removed from `EventRecord`. 13 + - `SessionMetadata.state?` field gone — this is a **PUBLIC FORMAT / Storage format** change. Existing on-disk metadata files with a populated `state` object will silently drop that field on the next daemon-side rewrite. 14 + - Tests: `tests/state.test.ts` (485 LOC) deleted; `tests/atomic-writes.test.ts` and `tests/events.test.ts` shed their state-specific cases. 15 + 16 + - **Removed `pty wrap` / `pty unwrap` / `pty wrap --list`.** Orthogonal shim generation; not part of the session-primitive contract. 17 + - The `PTY_BIN_PATH` env var no longer has a consumer; treat as removed. 18 + - `~/.local/pty/bin/` (default wrap dir) is no longer created; existing shims can be `rm -rf`'d by hand. 19 + 20 + - Usage-check swept the pty repo + `eval-sandbox/st-evals` + `convoy` + `~/bin/pty-claude-launcher.sh` + `cos` + `pty-relay` + `pty-layout` before the delete: no external consumers of state helpers or the wrap surface anywhere. 21 + 22 + ### `pty` — `--help` and shell completions rewritten (accuracy pass) 23 + 24 + - New `usage()` groups commands logically (Create / Attach & interact / Observe / Modify / Lifecycle / Multi / Global), lists every flag every subcommand actually accepts (`--id`, `--name`, `--no-display-name`, `--isolate-env`, `--filter-tag`, `--remote`, `--force`, `--paste`, `--idle-days`, `--fast-fail-window`, `--fast-fail-limit`, `--print-launchd-plist`, and all the `tag-multi` selectors), and documents `<ref>` semantics + the four env vars (`PTY_ROOT`, `PTY_SESSION_DIR`, `PTY_ROOT_LEGACY_SILENT`, `PTY_SESSION`). 25 + - Completion scripts (`completions/pty.{fish,bash,zsh}`) rewritten against the same surface — every current subcommand + every accepted flag covered; `state`, `wrap`, `unwrap` removed. Fish, bash and zsh all consistent. 26 + 5 27 ### Follow-ups to #56 (fast-fail cap) + PTY_ROOT length backstop 6 28 7 29 - **`pty restart` and `pty up` now clear the `pty gc` flapping bookkeeping.** Manual restart is an operator "please try again" signal — dropping `strategy.status`, `strategy.consecutive-fast-fails`, `strategy.last-respawn-at`, and `strategy.command-hash` on the restarted session gives it a clean slate, so the next `pty gc` tick isn't a no-op (silent skip against a stale flapping flag). Auto-reset on command-hash divergence already handled the toml-edit case; this handles the operator-intervenes-without-edit case that gc otherwise can't infer. Applies to both `pty restart` and `pty up`'s "already running, tag-sync" path.
+2 -32
README.md
··· 101 101 pty emit myserver user.build.finished --json '{"ok":true}' # with JSON payload 102 102 pty emit myserver user.note --text "checkpoint reached" # with a text payload 103 103 104 - pty state set myserver port 3000 # set a JSON-parsed state key 105 - pty state set myserver config < cfg.json # pipe a bigger value from stdin 106 - pty state get myserver port # print a single key's JSON value 107 - pty state get myserver # print the whole bag (pretty JSON) 108 - pty state keys myserver # list keys (one per line) 109 - pty state delete myserver port # remove a key 110 - 111 104 pty restart myserver # restart an exited session 112 105 pty kill myserver # terminate a running session 113 106 pty rm myserver # remove an exited session's metadata ··· 125 118 pty up claude dev # start specific sessions from ./pty.toml 126 119 pty down # stop all sessions from ./pty.toml 127 120 pty down claude # stop specific sessions 128 - 129 - pty wrap claude # auto-wrap claude in pty sessions 130 - pty unwrap claude # remove the wrapper 131 - pty wrap --list # show wrapped commands 132 121 ``` 133 122 134 - ### Wrapping Commands 135 - 136 - `pty wrap` creates a small shell script that shadows a command so it always runs in a pty session: 137 - 138 - ```sh 139 - pty wrap claude 140 - # Now running "claude" anywhere automatically gets a persistent session 141 - ``` 142 - 143 - The wrapper uses `pty run -a` (create or attach if already running), so running the command twice in the same directory reattaches instead of creating a duplicate. 144 - 145 - Wrappers live in `~/.local/pty/bin/`. Add it to the front of your PATH: 146 - 147 - ```sh 148 - export PATH="$HOME/.local/pty/bin:$PATH" 149 - ``` 150 - 151 - Detach with `Ctrl+\`. (Press `Ctrl+\` twice to send it through to the process.) 152 - 153 123 ### Nesting Prevention 154 124 155 - If you run `pty run` (or a wrapped command) inside an existing pty session, pty detects the nesting via the `PTY_SESSION` environment variable and runs the command directly instead of creating a session-inside-a-session. This means wrapped commands "just work" inside pty sessions without double-wrapping. 125 + If you run `pty run` inside an existing pty session, pty detects the nesting via the `PTY_SESSION` environment variable and runs the command directly instead of creating a session-inside-a-session. 156 126 157 127 Use `pty run -d` to explicitly create a background session from inside another session. 158 128 159 129 ### Events 160 130 161 - Sessions automatically log terminal events — bell, title changes, desktop notifications (OSC 9/99/777), focus requests, and cursor visibility transitions — plus metadata mutations: `display_name_change` on rename, `tags_change` on tag updates, `state.set` / `state.delete` on state bag writes, and any `user.*` events published via `pty emit`. Everything goes into per-session JSONL files. 131 + Sessions automatically log terminal events — bell, title changes, desktop notifications (OSC 9/99/777), focus requests, and cursor visibility transitions — plus metadata mutations: `display_name_change` on rename, `tags_change` on tag updates, and any `user.*` events published via `pty emit`. Everything goes into per-session JSONL files. 162 132 163 133 ```sh 164 134 pty events myserver # follow events live (like tail -f)
+95 -30
completions/pty.bash
··· 1 1 # Bash completion for pty 2 2 # Source this file or copy to /etc/bash_completion.d/pty 3 + # (or install via scripts/install-completions.sh). 3 4 4 5 _pty() { 5 6 local cur prev commands 6 7 COMPREPLY=() 7 8 cur="${COMP_WORDS[COMP_CWORD]}" 8 9 prev="${COMP_WORDS[COMP_CWORD-1]}" 9 - commands="run attach a exec peek send events list ls stats restart kill rm remove gc tag up down wrap unwrap test help" 10 + commands="run attach a exec peek send events list ls stats restart kill rm remove gc tag tag-multi emit rename up down test help" 10 11 11 - # Complete subcommand 12 + # Complete subcommand (first positional). 12 13 if [[ ${COMP_CWORD} -eq 1 ]]; then 13 - COMPREPLY=($(compgen -W "${commands}" -- "${cur}")) 14 + if [[ "${cur}" == -* ]]; then 15 + COMPREPLY=($(compgen -W "--root --preselect-new --filter-tag --help -h" -- "${cur}")) 16 + else 17 + COMPREPLY=($(compgen -W "${commands}" -- "${cur}")) 18 + fi 14 19 return 15 20 fi 16 21 17 - local session_dir="${PTY_SESSION_DIR:-${HOME}/.local/state/pty}" 22 + # Session-name provider — used by verbs that take a <ref>. 23 + local root="${PTY_ROOT:-${PTY_SESSION_DIR:-${HOME}/.local/state/pty}}" 18 24 local names="" 19 - if [[ -d "${session_dir}" ]]; then 20 - names=$(ls "${session_dir}"/*.json 2>/dev/null | xargs -I{} basename {} .json) 25 + if [[ -d "${root}" ]]; then 26 + names=$(ls "${root}"/*.json 2>/dev/null | xargs -I{} basename {} .json) 21 27 fi 22 28 23 29 case "${COMP_WORDS[1]}" in 24 - attach|a|peek|send|kill|restart|events|rm|remove|stats|tag) 30 + attach|a) 31 + if [[ "${cur}" == -* ]]; then 32 + COMPREPLY=($(compgen -W "-r --force" -- "${cur}")) 33 + else 34 + COMPREPLY=($(compgen -W "${names}" -- "${cur}")) 35 + fi 36 + ;; 37 + peek) 38 + if [[ "${cur}" == -* ]]; then 39 + COMPREPLY=($(compgen -W "-f --plain --full --wait -t" -- "${cur}")) 40 + else 41 + COMPREPLY=($(compgen -W "${names}" -- "${cur}")) 42 + fi 43 + ;; 44 + send) 45 + if [[ "${cur}" == -* ]]; then 46 + COMPREPLY=($(compgen -W "--seq --with-delay --paste" -- "${cur}")) 47 + else 48 + COMPREPLY=($(compgen -W "${names}" -- "${cur}")) 49 + fi 50 + ;; 51 + events) 52 + if [[ "${cur}" == -* ]]; then 53 + COMPREPLY=($(compgen -W "--all --recent --json --wait -t" -- "${cur}")) 54 + else 55 + COMPREPLY=($(compgen -W "${names}" -- "${cur}")) 56 + fi 57 + ;; 58 + stats) 59 + if [[ "${cur}" == -* ]]; then 60 + COMPREPLY=($(compgen -W "--json" -- "${cur}")) 61 + else 62 + COMPREPLY=($(compgen -W "${names}" -- "${cur}")) 63 + fi 64 + ;; 65 + restart) 66 + if [[ "${cur}" == -* ]]; then 67 + COMPREPLY=($(compgen -W "-y --force" -- "${cur}")) 68 + else 69 + COMPREPLY=($(compgen -W "${names}" -- "${cur}")) 70 + fi 71 + ;; 72 + kill|rm|remove) 73 + COMPREPLY=($(compgen -W "${names}" -- "${cur}")) 74 + ;; 75 + rename) 25 76 if [[ "${cur}" == -* ]]; then 26 - case "${COMP_WORDS[1]}" in 27 - attach|a) COMPREPLY=($(compgen -W "--auto-restart -r" -- "${cur}")) ;; 28 - peek) COMPREPLY=($(compgen -W "--follow -f --plain --full --wait --timeout -t" -- "${cur}")) ;; 29 - send) COMPREPLY=($(compgen -W "--seq --with-delay" -- "${cur}")) ;; 30 - restart) COMPREPLY=($(compgen -W "--yes -y" -- "${cur}")) ;; 31 - events) COMPREPLY=($(compgen -W "--all --recent --json --wait --timeout -t" -- "${cur}")) ;; 32 - stats) COMPREPLY=($(compgen -W "--json --all" -- "${cur}")) ;; 33 - esac 77 + COMPREPLY=($(compgen -W "--show --clear" -- "${cur}")) 34 78 else 35 79 COMPREPLY=($(compgen -W "${names}" -- "${cur}")) 36 80 fi 37 81 ;; 38 82 list|ls) 39 83 if [[ "${cur}" == -* ]]; then 40 - COMPREPLY=($(compgen -W "--json --tags" -- "${cur}")) 84 + COMPREPLY=($(compgen -W "--json --tags --filter-tag --remote" -- "${cur}")) 41 85 fi 42 86 ;; 43 87 gc) 44 88 if [[ "${cur}" == -* ]]; then 45 - COMPREPLY=($(compgen -W "--dry-run -n --print-launchd-plist --interval" -- "${cur}")) 89 + COMPREPLY=($(compgen -W "-n --dry-run --idle-days --fast-fail-window --fast-fail-limit --print-launchd-plist --interval" -- "${cur}")) 46 90 fi 47 91 ;; 48 - wrap) 92 + tag) 49 93 if [[ "${cur}" == -* ]]; then 50 - COMPREPLY=($(compgen -W "--list -l" -- "${cur}")) 94 + COMPREPLY=($(compgen -W "--rm" -- "${cur}")) 51 95 else 52 - COMPREPLY=($(compgen -c -- "${cur}")) 96 + COMPREPLY=($(compgen -W "${names}" -- "${cur}")) 53 97 fi 54 98 ;; 55 - unwrap) 56 - # Complete from wrapped commands in ~/.local/pty/bin (or $PTY_BIN_PATH) 57 - local wrap_dir="${PTY_BIN_PATH:-${HOME}/.local/pty/bin}" 58 - if [[ -d "${wrap_dir}" ]]; then 59 - local wrapped 60 - wrapped=$(ls "${wrap_dir}" 2>/dev/null) 61 - COMPREPLY=($(compgen -W "${wrapped}" -- "${cur}")) 99 + tag-multi) 100 + if [[ "${cur}" == -* ]]; then 101 + COMPREPLY=($(compgen -W "--all --filter-tag --rm --json -y --yes" -- "${cur}")) 102 + else 103 + COMPREPLY=($(compgen -W "${names}" -- "${cur}")) 62 104 fi 63 105 ;; 106 + emit) 107 + if [[ "${cur}" == -* ]]; then 108 + COMPREPLY=($(compgen -W "--json --text" -- "${cur}")) 109 + else 110 + COMPREPLY=($(compgen -W "${names}" -- "${cur}")) 111 + fi 112 + ;; 113 + up|down) 114 + # Complete directories (containing pty.toml) or session names from an 115 + # already-loaded toml. Directories are more common; keep it simple. 116 + COMPREPLY=($(compgen -o dirnames -- "${cur}")) 117 + ;; 118 + exec) 119 + # After --, fall through to default (command + args) completion. 120 + COMPREPLY=($(compgen -o default -- "${cur}")) 121 + ;; 64 122 run) 65 - # After --, fall back to default file completion 123 + # After --, fall back to default file completion for the command. 66 124 local i 67 125 for (( i=2; i < COMP_CWORD; i++ )); do 68 126 if [[ "${COMP_WORDS[i]}" == "--" ]]; then ··· 70 128 return 71 129 fi 72 130 done 73 - # Before --, complete flags 131 + # Before --, complete flags. 132 + if [[ "${cur}" == -* ]]; then 133 + COMPREPLY=($(compgen -W "-d -a -e --id --name --no-display-name --tag --cwd --isolate-env" -- "${cur}")) 134 + fi 135 + ;; 136 + test) 74 137 if [[ "${cur}" == -* ]]; then 75 - COMPREPLY=($(compgen -W "--detach -d --attach -a --ephemeral -e --name --cwd --tag" -- "${cur}")) 138 + COMPREPLY=($(compgen -W "-t" -- "${cur}")) 139 + else 140 + COMPREPLY=($(compgen -W "watch" -- "${cur}")) 76 141 fi 77 142 ;; 78 143 esac
+121 -93
completions/pty.fish
··· 1 1 # Fish completion for pty 2 - # Persistent terminal sessions with detach/attach support 3 - # Place in ~/.config/fish/completions/ or install via: npm run install-completions 2 + # Persistent terminal sessions with detach/attach support. 3 + # Install: `cp completions/pty.fish ~/.config/fish/completions/` or 4 + # run `scripts/install-completions.sh`. 4 5 5 - function __pty_sessions 6 - set -l session_dir "$PTY_SESSION_DIR" 7 - if test -z "$session_dir" 8 - set session_dir "$HOME/.local/state/pty" 9 - end 10 - if test -d "$session_dir" 11 - for f in $session_dir/*.json 12 - if test -f "$f" 13 - basename $f .json 14 - end 15 - end 6 + function __pty_root 7 + if set -q PTY_ROOT 8 + echo $PTY_ROOT 9 + else if set -q PTY_SESSION_DIR 10 + echo $PTY_SESSION_DIR 11 + else 12 + echo "$HOME/.local/state/pty" 16 13 end 17 14 end 18 15 19 - function __pty_wrapped 20 - set -l wrap_dir "$PTY_BIN_PATH" 21 - if test -z "$wrap_dir" 22 - set wrap_dir "$HOME/.local/pty/bin" 23 - end 24 - if test -d "$wrap_dir" 25 - for f in $wrap_dir/* 16 + function __pty_sessions 17 + set -l dir (__pty_root) 18 + if test -d "$dir" 19 + for f in $dir/*.json 26 20 if test -f "$f" 27 - basename $f 21 + basename $f .json 28 22 end 29 23 end 30 24 end ··· 43 37 # Disable file completions by default 44 38 complete -c pty -f 45 39 46 - # Subcommands 47 - complete -c pty -n __pty_needs_command -a run -d 'Create a session and attach' 48 - complete -c pty -n __pty_needs_command -a attach -d 'Attach to an existing session' 49 - complete -c pty -n __pty_needs_command -a a -d 'Attach to an existing session' 50 - complete -c pty -n __pty_needs_command -a exec -d 'Replace the current session command' 51 - complete -c pty -n __pty_needs_command -a peek -d 'Print current screen or follow output' 52 - complete -c pty -n __pty_needs_command -a send -d 'Send text or keys to a session' 53 - complete -c pty -n __pty_needs_command -a events -d 'Follow terminal events from sessions' 54 - complete -c pty -n __pty_needs_command -a list -d 'List active sessions' 55 - complete -c pty -n __pty_needs_command -a ls -d 'List active sessions' 56 - complete -c pty -n __pty_needs_command -a stats -d 'Show live session metrics' 57 - complete -c pty -n __pty_needs_command -a restart -d 'Restart an exited session' 58 - complete -c pty -n __pty_needs_command -a kill -d 'Kill a running session' 59 - complete -c pty -n __pty_needs_command -a rm -d 'Remove an exited session' 60 - complete -c pty -n __pty_needs_command -a remove -d 'Remove an exited session' 61 - complete -c pty -n __pty_needs_command -a gc -d 'Reconcile sessions (kill orphan children, respawn permanents, sweep exited)' 62 - complete -c pty -n __pty_needs_command -a tag -d 'Show or modify session tags' 63 - complete -c pty -n __pty_needs_command -a up -d 'Start sessions from pty.toml' 64 - complete -c pty -n __pty_needs_command -a down -d 'Stop sessions from pty.toml' 65 - complete -c pty -n __pty_needs_command -a wrap -d 'Auto-wrap a command in pty sessions' 66 - complete -c pty -n __pty_needs_command -a unwrap -d 'Remove a wrapper' 67 - complete -c pty -n __pty_needs_command -a test -d 'Run tests (vitest)' 68 - complete -c pty -n __pty_needs_command -a help -d 'Show usage information' 40 + # ── Global flags ─────────────────────────────────────────────────────── 41 + complete -c pty -n __pty_needs_command -l root -x -a '(__fish_complete_directories)' -d 'Pin PTY_ROOT for this call' 42 + complete -c pty -n __pty_needs_command -l preselect-new -d 'TUI: pre-select "Create new session..."' 43 + complete -c pty -n __pty_needs_command -l filter-tag -x -d 'TUI: filter to k=v (repeatable)' 44 + 45 + # ── Subcommands ──────────────────────────────────────────────────────── 46 + complete -c pty -n __pty_needs_command -a run -d 'Create a session and attach' 47 + complete -c pty -n __pty_needs_command -a attach -d 'Attach to an existing session' 48 + complete -c pty -n __pty_needs_command -a a -d 'Alias for attach' 49 + complete -c pty -n __pty_needs_command -a exec -d 'Replace the current session process' 50 + complete -c pty -n __pty_needs_command -a peek -d 'Print current screen (or follow / wait-for-text)' 51 + complete -c pty -n __pty_needs_command -a send -d 'Send text or key events' 52 + complete -c pty -n __pty_needs_command -a events -d 'Follow event log' 53 + complete -c pty -n __pty_needs_command -a list -d 'List sessions' 54 + complete -c pty -n __pty_needs_command -a ls -d 'Alias for list' 55 + complete -c pty -n __pty_needs_command -a stats -d 'Live CPU / memory / PIDs' 56 + complete -c pty -n __pty_needs_command -a restart -d 'SIGTERM + respawn' 57 + complete -c pty -n __pty_needs_command -a kill -d 'SIGTERM a running session' 58 + complete -c pty -n __pty_needs_command -a rm -d 'Remove exited metadata' 59 + complete -c pty -n __pty_needs_command -a remove -d 'Alias for rm' 60 + complete -c pty -n __pty_needs_command -a gc -d 'Reconciliation pass' 61 + complete -c pty -n __pty_needs_command -a tag -d 'Read / write tags on one session' 62 + complete -c pty -n __pty_needs_command -a tag-multi -d 'Bulk tag ops across sessions' 63 + complete -c pty -n __pty_needs_command -a emit -d 'Publish a user.* event' 64 + complete -c pty -n __pty_needs_command -a rename -d 'Set / show / clear displayName' 65 + complete -c pty -n __pty_needs_command -a up -d 'Start sessions from pty.toml' 66 + complete -c pty -n __pty_needs_command -a down -d 'Stop sessions from pty.toml' 67 + complete -c pty -n __pty_needs_command -a test -d 'Run the pty test suite (vitest)' 68 + complete -c pty -n __pty_needs_command -a help -d 'Show usage' 69 69 70 - # run: flags and file completion for the command argument 71 - complete -c pty -n '__pty_using_command run' -s d -l detach -d 'Create in background' 72 - complete -c pty -n '__pty_using_command run' -s a -l attach -d 'Attach if already running' 73 - complete -c pty -n '__pty_using_command run' -s e -l ephemeral -d 'Auto-remove on exit' 74 - complete -c pty -n '__pty_using_command run' -l name -x -d 'Session name (auto-generated if omitted)' 70 + # ── run ──────────────────────────────────────────────────────────────── 71 + complete -c pty -n '__pty_using_command run' -s d -d 'Create in background (detached)' 72 + complete -c pty -n '__pty_using_command run' -s a -d 'Create OR attach if id already exists' 73 + complete -c pty -n '__pty_using_command run' -s e -d 'Ephemeral: auto-remove metadata on clean exit' 74 + complete -c pty -n '__pty_using_command run' -l id -x -d 'Pin on-disk id (charset-validated)' 75 + complete -c pty -n '__pty_using_command run' -l name -x -d 'Display label (any printable, ≤ 500 chars)' 76 + complete -c pty -n '__pty_using_command run' -l no-display-name -d 'Skip the auto-generated label' 77 + complete -c pty -n '__pty_using_command run' -l tag -x -d 'Tag session (k=v, repeatable)' 75 78 complete -c pty -n '__pty_using_command run' -l cwd -x -a '(__fish_complete_directories)' -d 'Working directory' 76 - complete -c pty -n '__pty_using_command run' -l tag -x -d 'Tag session (key=value, repeatable)' 79 + complete -c pty -n '__pty_using_command run' -l isolate-env -d 'Scrub env to a safe allow-list' 77 80 complete -c pty -n '__pty_using_command run' -F 78 81 79 - # attach: session names and flags 80 - complete -c pty -n '__pty_using_command attach' -a '(__pty_sessions)' -d 'Session' 81 - complete -c pty -n '__pty_using_command attach' -s r -l auto-restart -d 'Auto-restart if exited' 82 - complete -c pty -n '__pty_using_command a' -a '(__pty_sessions)' -d 'Session' 83 - complete -c pty -n '__pty_using_command a' -s r -l auto-restart -d 'Auto-restart if exited' 82 + # ── attach / a ───────────────────────────────────────────────────────── 83 + for verb in attach a 84 + complete -c pty -n "__pty_using_command $verb" -a '(__pty_sessions)' -d 'Session' 85 + complete -c pty -n "__pty_using_command $verb" -s r -d 'Auto-restart if the session is exited' 86 + complete -c pty -n "__pty_using_command $verb" -l force -d 'Attach even from inside another pty' 87 + end 84 88 85 - # peek: session names and flags 89 + # ── exec ─────────────────────────────────────────────────────────────── 90 + complete -c pty -n '__pty_using_command exec' -F 91 + 92 + # ── peek ─────────────────────────────────────────────────────────────── 86 93 complete -c pty -n '__pty_using_command peek' -a '(__pty_sessions)' -d 'Session' 87 - complete -c pty -n '__pty_using_command peek' -s f -l follow -d 'Follow output read-only' 88 - complete -c pty -n '__pty_using_command peek' -l plain -d 'Output plain text without ANSI' 94 + complete -c pty -n '__pty_using_command peek' -s f -d 'Follow output read-only' 95 + complete -c pty -n '__pty_using_command peek' -l plain -d 'Plain text (no ANSI)' 89 96 complete -c pty -n '__pty_using_command peek' -l full -d 'Include full scrollback' 90 97 complete -c pty -n '__pty_using_command peek' -l wait -x -d 'Wait until text appears' 91 - complete -c pty -n '__pty_using_command peek' -s t -l timeout -x -d 'Timeout in seconds' 98 + complete -c pty -n '__pty_using_command peek' -s t -x -d 'Timeout (seconds) for --wait' 92 99 93 - # send: session names and flags 100 + # ── send ─────────────────────────────────────────────────────────────── 94 101 complete -c pty -n '__pty_using_command send' -a '(__pty_sessions)' -d 'Session' 95 - complete -c pty -n '__pty_using_command send' -l seq -d 'Send a sequence item' -r 96 - complete -c pty -n '__pty_using_command send' -l with-delay -d 'Delay between --seq items (seconds)' -r 102 + complete -c pty -n '__pty_using_command send' -l seq -x -d 'Ordered chunk / key event (repeatable)' 103 + complete -c pty -n '__pty_using_command send' -l with-delay -x -d 'Delay between --seq items (sec)' 104 + complete -c pty -n '__pty_using_command send' -l paste -x -d 'Wrap in bracketed-paste markers' 97 105 98 - # events: session names and flags 106 + # ── events ───────────────────────────────────────────────────────────── 99 107 complete -c pty -n '__pty_using_command events' -a '(__pty_sessions)' -d 'Session' 100 - complete -c pty -n '__pty_using_command events' -l all -d 'Follow events from all sessions' 101 - complete -c pty -n '__pty_using_command events' -l recent -d 'Show recent events and exit' 102 - complete -c pty -n '__pty_using_command events' -l json -d 'Output raw JSONL' 108 + complete -c pty -n '__pty_using_command events' -l all -d 'Follow every session, interleaved' 109 + complete -c pty -n '__pty_using_command events' -l recent -d 'Print recent + exit' 110 + complete -c pty -n '__pty_using_command events' -l json -d 'Emit raw JSONL' 103 111 complete -c pty -n '__pty_using_command events' -l wait -x -d 'Wait for a specific event type' 104 - complete -c pty -n '__pty_using_command events' -s t -l timeout -x -d 'Timeout in seconds' 112 + complete -c pty -n '__pty_using_command events' -s t -x -d 'Timeout (seconds) for --wait' 105 113 106 - # list: flags 107 - complete -c pty -n '__pty_using_command list' -l json -d 'Output as JSON' 108 - complete -c pty -n '__pty_using_command list' -l tags -d 'Show tags as #key=value' 109 - complete -c pty -n '__pty_using_command ls' -l json -d 'Output as JSON' 110 - complete -c pty -n '__pty_using_command ls' -l tags -d 'Show tags as #key=value' 114 + # ── list / ls ────────────────────────────────────────────────────────── 115 + for verb in list ls 116 + complete -c pty -n "__pty_using_command $verb" -l json -d 'Emit JSON' 117 + complete -c pty -n "__pty_using_command $verb" -l tags -d 'Include internal bookkeeping tags' 118 + complete -c pty -n "__pty_using_command $verb" -l filter-tag -x -d 'Filter to k=v (repeatable, ALL match)' 119 + complete -c pty -n "__pty_using_command $verb" -l remote -d 'Include remote sessions via pty-relay' 120 + end 111 121 112 - # stats: session names and flags 122 + # ── stats ────────────────────────────────────────────────────────────── 113 123 complete -c pty -n '__pty_using_command stats' -a '(__pty_sessions)' -d 'Session' 114 - complete -c pty -n '__pty_using_command stats' -l json -d 'Output as JSON' 115 - complete -c pty -n '__pty_using_command stats' -l all -d 'Include exited sessions' 124 + complete -c pty -n '__pty_using_command stats' -l json -d 'Emit JSON' 116 125 117 - # restart: session names and flags 126 + # ── restart / kill / rm ──────────────────────────────────────────────── 118 127 complete -c pty -n '__pty_using_command restart' -a '(__pty_sessions)' -d 'Session' 119 - complete -c pty -n '__pty_using_command restart' -s y -l yes -d 'Skip confirmation' 128 + complete -c pty -n '__pty_using_command restart' -s y -d 'Skip confirmation' 129 + complete -c pty -n '__pty_using_command restart' -l force -d 'Attach after restart even from inside another pty' 120 130 121 - # kill: session names 122 131 complete -c pty -n '__pty_using_command kill' -a '(__pty_sessions)' -d 'Session' 132 + complete -c pty -n '__pty_using_command rm' -a '(__pty_sessions)' -d 'Session' 133 + complete -c pty -n '__pty_using_command remove' -a '(__pty_sessions)' -d 'Session' 134 + 135 + # ── gc ───────────────────────────────────────────────────────────────── 136 + complete -c pty -n '__pty_using_command gc' -s n -l dry-run -d 'Preview without changing anything' 137 + complete -c pty -n '__pty_using_command gc' -l idle-days -x -d 'Reap permanents with no attach in N days' 138 + complete -c pty -n '__pty_using_command gc' -l fast-fail-window -x -d 'Fast-fail window (seconds; default 60)' 139 + complete -c pty -n '__pty_using_command gc' -l fast-fail-limit -x -d 'Consecutive fast fails before flapping (default 3)' 140 + complete -c pty -n '__pty_using_command gc' -l print-launchd-plist -d 'Emit a launchd plist that runs pty gc' 141 + complete -c pty -n '__pty_using_command gc' -l interval -x -d 'Plist StartInterval seconds (default 30)' 123 142 124 - # rm/remove: session names 125 - complete -c pty -n '__pty_using_command rm' -a '(__pty_sessions)' -d 'Session' 126 - complete -c pty -n '__pty_using_command remove' -a '(__pty_sessions)' -d 'Session' 143 + # ── tag / tag-multi ──────────────────────────────────────────────────── 144 + complete -c pty -n '__pty_using_command tag' -a '(__pty_sessions)' -d 'Session' 145 + complete -c pty -n '__pty_using_command tag' -l rm -x -d 'Remove tag key (repeatable)' 127 146 128 - # wrap: commands in PATH, and --list flag 129 - complete -c pty -n '__pty_using_command wrap' -s l -l list -d 'List all wrapped commands' 130 - complete -c pty -n '__pty_using_command wrap' -a '(__fish_complete_command)' -d 'Command' 147 + complete -c pty -n '__pty_using_command tag-multi' -a '(__pty_sessions)' -d 'Explicit session' 148 + complete -c pty -n '__pty_using_command tag-multi' -l all -d 'Selector: every session' 149 + complete -c pty -n '__pty_using_command tag-multi' -l filter-tag -x -d 'Selector: k=v (repeatable)' 150 + complete -c pty -n '__pty_using_command tag-multi' -l rm -x -d 'Remove tag key (repeatable)' 151 + complete -c pty -n '__pty_using_command tag-multi' -l json -d 'Read mode: emit JSON' 152 + complete -c pty -n '__pty_using_command tag-multi' -s y -l yes -d 'Confirm --all + write' 131 153 132 - # unwrap: previously wrapped commands 133 - complete -c pty -n '__pty_using_command unwrap' -a '(__pty_wrapped)' -d 'Wrapped command' 154 + # ── emit ─────────────────────────────────────────────────────────────── 155 + complete -c pty -n '__pty_using_command emit' -a '(__pty_sessions)' -d 'Session (omit inside a session)' 156 + complete -c pty -n '__pty_using_command emit' -l json -x -d 'JSON payload' 157 + complete -c pty -n '__pty_using_command emit' -l text -x -d 'Text payload' 134 158 135 - # tag: session names 136 - complete -c pty -n '__pty_using_command tag' -a '(__pty_sessions)' -d 'Session' 137 - complete -c pty -n '__pty_using_command tag' -l rm -d 'Remove a tag' 159 + # ── rename ───────────────────────────────────────────────────────────── 160 + complete -c pty -n '__pty_using_command rename' -a '(__pty_sessions)' -d 'Session' 161 + complete -c pty -n '__pty_using_command rename' -l show -d 'Print current displayName' 162 + complete -c pty -n '__pty_using_command rename' -l clear -d 'Remove displayName' 138 163 139 - # gc: flags 140 - complete -c pty -n '__pty_using_command gc' -s n -l dry-run -d 'Preview without changing anything' 141 - complete -c pty -n '__pty_using_command gc' -l print-launchd-plist -d 'Print a macOS launchd plist that runs pty gc' 142 - complete -c pty -n '__pty_using_command gc' -l interval -x -d 'Plist StartInterval in seconds (default 30)' 164 + # ── up / down ────────────────────────────────────────────────────────── 165 + for verb in up down 166 + complete -c pty -n "__pty_using_command $verb" -a '(__fish_complete_directories)' -d 'Directory containing pty.toml' 167 + end 143 168 169 + # ── test ─────────────────────────────────────────────────────────────── 170 + complete -c pty -n '__pty_using_command test' -a 'watch' -d 'Watch mode' 171 + complete -c pty -n '__pty_using_command test' -s t -x -d 'Run matching tests'
+98 -67
completions/pty.zsh
··· 1 1 #compdef pty 2 2 # Zsh completion for pty 3 - # Place in your fpath or source directly 3 + # Place in your fpath (e.g. ~/.zsh/completions/_pty) or install via 4 + # scripts/install-completions.sh. 4 5 5 6 _pty() { 6 - local session_dir="${PTY_SESSION_DIR:-${HOME}/.local/state/pty}" 7 - local wrap_dir="${PTY_BIN_PATH:-${HOME}/.local/pty/bin}" 7 + local root="${PTY_ROOT:-${PTY_SESSION_DIR:-${HOME}/.local/state/pty}}" 8 8 9 9 _pty_sessions() { 10 10 local -a sessions 11 - if [[ -d "${session_dir}" ]]; then 12 - sessions=(${session_dir}/*.json(N:t:r)) 11 + if [[ -d "${root}" ]]; then 12 + sessions=(${root}/*.json(N:t:r)) 13 13 fi 14 14 _describe 'session' sessions 15 15 } 16 16 17 - _pty_wrapped() { 18 - local -a wrapped 19 - if [[ -d "${wrap_dir}" ]]; then 20 - wrapped=(${wrap_dir}/*(N:t)) 21 - fi 22 - _describe 'wrapped command' wrapped 23 - } 24 - 25 17 local -a commands 26 18 commands=( 27 19 'run:Create a session and attach' 28 20 'attach:Attach to an existing session' 29 - 'a:Attach to an existing session' 30 - 'exec:Replace the current session command' 31 - 'peek:Print current screen or follow output' 32 - 'send:Send text or keys to a session' 33 - 'events:Follow terminal events from sessions' 34 - 'list:List active sessions' 35 - 'ls:List active sessions' 36 - 'stats:Show live session metrics' 37 - 'restart:Restart an exited session' 38 - 'kill:Kill a running session' 39 - 'rm:Remove an exited session' 40 - 'remove:Remove an exited session' 41 - 'gc:Reconcile sessions (kill orphan children, respawn permanents, sweep exited)' 42 - 'tag:Show or modify session tags' 21 + 'a:Alias for attach' 22 + 'exec:Replace the current session process' 23 + 'peek:Print current screen (or follow / wait-for-text)' 24 + 'send:Send text or key events' 25 + 'events:Follow event log' 26 + 'list:List sessions' 27 + 'ls:Alias for list' 28 + 'stats:Live CPU / memory / PIDs' 29 + 'restart:SIGTERM + respawn' 30 + 'kill:SIGTERM a running session' 31 + 'rm:Remove exited metadata' 32 + 'remove:Alias for rm' 33 + 'gc:Reconciliation pass' 34 + 'tag:Read / write tags on one session' 35 + 'tag-multi:Bulk tag ops across sessions' 36 + 'emit:Publish a user.* event' 37 + 'rename:Set / show / clear displayName' 43 38 'up:Start sessions from pty.toml' 44 39 'down:Stop sessions from pty.toml' 45 - 'wrap:Auto-wrap a command in pty sessions' 46 - 'unwrap:Remove a wrapper' 47 - 'test:Run tests (vitest)' 48 - 'help:Show usage information' 40 + 'test:Run the pty test suite (vitest)' 41 + 'help:Show usage' 49 42 ) 50 43 51 44 _arguments -C \ 45 + '(--root)--root[Pin PTY_ROOT for this call]:path:_directories' \ 46 + '(--preselect-new)--preselect-new[TUI: pre-select "Create new session..."]' \ 47 + '(--filter-tag)*--filter-tag[TUI: filter to k=v (repeatable)]:tag:' \ 52 48 '1:command:->command' \ 53 49 '*::arg:->args' 54 50 ··· 60 56 case ${words[1]} in 61 57 attach|a) 62 58 _arguments \ 63 - '(-r --auto-restart)'{-r,--auto-restart}'[Auto-restart if exited]' \ 59 + '-r[Auto-restart if the session is exited]' \ 60 + '--force[Attach even from inside another pty]' \ 64 61 '1:session:_pty_sessions' 65 62 ;; 63 + exec) 64 + _normal 65 + ;; 66 66 peek) 67 67 _arguments \ 68 - '(-f --follow)'{-f,--follow}'[Follow output read-only]' \ 69 - '--plain[Output plain text without ANSI codes]' \ 68 + '-f[Follow output read-only]' \ 69 + '--plain[Plain text (no ANSI)]' \ 70 70 '--full[Include full scrollback]' \ 71 71 '--wait[Wait until text appears]:pattern:' \ 72 - '(-t --timeout)'{-t,--timeout}'[Timeout in seconds]:seconds:' \ 72 + '-t[Timeout in seconds for --wait]:seconds:' \ 73 73 '1:session:_pty_sessions' 74 74 ;; 75 75 send) 76 76 _arguments \ 77 77 '1:session:_pty_sessions' \ 78 - '--with-delay[Delay between --seq items (seconds)]:seconds:' \ 79 - '*--seq[Send a sequence item]:value:' 80 - ;; 81 - kill|rm|remove) 82 - _arguments '1:session:_pty_sessions' 78 + '*--seq[Ordered chunk / key event]:value:' \ 79 + '--with-delay[Delay between --seq items (sec)]:seconds:' \ 80 + '--paste[Wrap in bracketed-paste markers]:text:' 83 81 ;; 84 - restart) 82 + events) 85 83 _arguments \ 86 - '(-y --yes)'{-y,--yes}'[Skip confirmation for running sessions]' \ 84 + '--all[Follow every session, interleaved]' \ 85 + '--recent[Print recent + exit]' \ 86 + '--json[Emit raw JSONL]' \ 87 + '--wait[Wait for a specific event type]:type:' \ 88 + '-t[Timeout in seconds for --wait]:seconds:' \ 87 89 '1:session:_pty_sessions' 88 90 ;; 89 - events) 91 + list|ls) 90 92 _arguments \ 91 - '--all[Follow events from all sessions]' \ 92 - '--recent[Show recent events and exit]' \ 93 - '--json[Output raw JSONL]' \ 94 - '--wait[Wait for a specific event type]:type:' \ 95 - '(-t --timeout)'{-t,--timeout}'[Timeout in seconds]:seconds:' \ 96 - '1:session:_pty_sessions' 93 + '--json[Emit JSON]' \ 94 + '--tags[Include internal bookkeeping tags]' \ 95 + '*--filter-tag[Filter to k=v (ALL must match)]:tag:' \ 96 + '--remote[Include remote sessions via pty-relay]' 97 97 ;; 98 98 stats) 99 99 _arguments \ 100 - '--json[Output as JSON]' \ 101 - '--all[Include exited sessions]' \ 100 + '--json[Emit JSON]' \ 102 101 '1:session:_pty_sessions' 103 102 ;; 104 - list|ls) 103 + restart) 105 104 _arguments \ 106 - '--json[Output as JSON]' \ 107 - '--tags[Show tags as #key=value]' 105 + '-y[Skip confirmation]' \ 106 + '--force[Attach after restart even from inside another pty]' \ 107 + '1:session:_pty_sessions' 108 + ;; 109 + kill|rm|remove) 110 + _arguments '1:session:_pty_sessions' 108 111 ;; 109 112 gc) 110 113 _arguments \ 111 114 '(-n --dry-run)'{-n,--dry-run}'[Preview without changing anything]' \ 112 - '--print-launchd-plist[Print a macOS launchd plist that runs pty gc]' \ 113 - '--interval[Plist StartInterval in seconds (default 30)]:seconds:' 115 + '--idle-days[Reap permanents with no attach in N days]:days:' \ 116 + '--fast-fail-window[Fast-fail window in seconds (default 60)]:seconds:' \ 117 + '--fast-fail-limit[Consecutive fast fails before flapping (default 3)]:count:' \ 118 + '--print-launchd-plist[Emit a launchd plist that runs pty gc]' \ 119 + '--interval[Plist StartInterval seconds (default 30)]:seconds:' 114 120 ;; 115 121 tag) 116 - _arguments '1:session:_pty_sessions' 122 + _arguments \ 123 + '1:session:_pty_sessions' \ 124 + '*--rm[Remove tag key]:key:' 117 125 ;; 118 - wrap) 126 + tag-multi) 119 127 _arguments \ 120 - '(-l --list)'{-l,--list}'[List all wrapped commands]' \ 121 - '1:command:_command_names -e' 128 + '--all[Selector: every session]' \ 129 + '*--filter-tag[Selector: k=v (repeatable)]:tag:' \ 130 + '*--rm[Remove tag key]:key:' \ 131 + '--json[Read mode: emit JSON]' \ 132 + '(-y --yes)'{-y,--yes}'[Confirm --all + write]' 133 + ;; 134 + emit) 135 + _arguments \ 136 + '--json[JSON payload]:payload:' \ 137 + '--text[Text payload]:text:' \ 138 + '1:session:_pty_sessions' 122 139 ;; 123 - unwrap) 124 - _arguments '1:wrapped:_pty_wrapped' 140 + rename) 141 + _arguments \ 142 + '--show[Print current displayName]' \ 143 + '--clear[Remove displayName]' \ 144 + '1:session:_pty_sessions' 145 + ;; 146 + up|down) 147 + _arguments '1:directory:_directories' 125 148 ;; 126 149 run) 127 150 # After --, fall back to normal (command + file) completion ··· 135 158 fi 136 159 done 137 160 _arguments \ 138 - '(-d --detach)'{-d,--detach}'[Create in background]' \ 139 - '(-a --attach)'{-a,--attach}'[Attach if already running]' \ 140 - '(-e --ephemeral)'{-e,--ephemeral}'[Auto-remove on exit]' \ 141 - '--name[Session name]:name:' \ 161 + '-d[Create in background (detached)]' \ 162 + '-a[Create OR attach if id already exists]' \ 163 + '-e[Ephemeral: auto-remove on clean exit]' \ 164 + '--id[Pin on-disk id]:id:' \ 165 + '--name[Display label]:label:' \ 166 + '--no-display-name[Skip the auto-generated label]' \ 167 + '*--tag[Tag session with k=v]:tag:' \ 142 168 '--cwd[Working directory]:dir:_directories' \ 143 - '*--tag[Tag session with key=value]:tag:' 169 + '--isolate-env[Scrub env to a safe allow-list]' 170 + ;; 171 + test) 172 + _arguments \ 173 + '-t[Run matching tests]:pattern:' \ 174 + '1:mode:(watch)' 144 175 ;; 145 176 esac 146 177 ;;
+2 -5
docs/disk-layout.md
··· 42 42 exitedAt?: string; 43 43 lastLines?: string[]; // snapshotted at exit 44 44 tags?: { [k: string]: string }; 45 - state?: { [k: string]: unknown }; 46 45 displayName?: string; 47 46 lastAttachAt?: string; // ISO 8601 — set by the daemon on every non-readonly ATTACH 48 47 } ··· 78 77 | `session_flapping` | `counter, limit, window` — (`pty gc` flipped a permanent session to `strategy.status=flapping` after N consecutive fast-fail respawns; subsequent ticks skip it) | 79 78 | `display_name_change` | `previous: string\|null, value: string\|null` | 80 79 | `tags_change` | `previous, value` (full snapshots) | 81 - | `state.set` | `key, value` | 82 - | `state.delete` | `key` | 83 80 | `user.<name>` | `data?, text?` — free-form, via `pty emit` | 84 81 85 - A single line ≤ `PIPE_BUF` (~4 KB) is atomic per POSIX `O_APPEND`. Built-ins are well under. Keep large `user.*` / `state.set` payloads in `state` (atomic-rename), not events. 82 + A single line ≤ `PIPE_BUF` (~4 KB) is atomic per POSIX `O_APPEND`. Built-ins are well under. Keep large `user.*` payloads out of the event stream. 86 83 87 84 ## Reading from outside pty 88 85 ··· 90 87 jq -r '.tags["role"] // empty' "$PTY_ROOT/myserver.json" 91 88 ``` 92 89 93 - For live updates, tail `<name>.events.jsonl` via `inotify` / `kqueue`. Subscribe instead of polling — `state.set` / `tags_change` / `display_name_change` fire on every mutation. 90 + For live updates, tail `<name>.events.jsonl` via `inotify` / `kqueue`. Subscribe instead of polling — `tags_change` / `display_name_change` / `session_*` fire on every mutation.
+105 -334
src/cli.ts
··· 26 26 atomicWriteFileSync, 27 27 getSessionDir, 28 28 DEFAULT_SESSION_DIR, 29 - getState, getStateKey, setState, deleteState, listStateKeys, 30 29 type SessionInfo, 31 30 } from "./sessions.ts"; 32 31 import { spawnDaemon, resolveCommand } from "./spawn.ts"; ··· 73 72 74 73 function usage(): void { 75 74 console.log(`Usage: 76 - pty Interactive session manager 77 - pty --preselect-new Open the TUI with "Create new session..." pre-selected 78 - pty --filter-tag key=value Filter TUI to sessions with a tag; auto-applied to new sessions 79 - pty --root <path> <subcommand> [...] Pin the state registry for this call (same as PTY_ROOT env) 80 - pty run -- <command> [args...] Create a session and attach (random id + auto label) 81 - pty run --id <id> -- <command> Pin the on-disk id (sock + json filename) 82 - pty run --name <dn> -- <command> Set the display label (arbitrary length / chars) 83 - pty run --id <id> --name <dn> -- <cmd> Pin both id and display label 84 - pty run -d -- <command> [args...] Create in the background 85 - pty run -a -- <command> [args...] Create or attach if already running 86 - pty run --tag key=value -- <command> Tag a session with metadata 87 - pty run --cwd /path -- <command> Run in a specific directory 88 - pty run --isolate-env -- <command> Scrub env down to a safe allow-list (for remote-reachable sessions) 89 - pty run --no-display-name -- <command> Skip the friendly cwd+command label (just an id) 90 - pty rename <new> Inside a session, set its displayName 91 - pty rename <ref> <new> Outside, set displayName on <ref> 92 - pty rename --show <ref> Show current displayName for <ref> 93 - pty rename --clear [ref] Remove displayName (ref required outside a session) 94 - pty attach <name> Attach to an existing session 95 - pty attach --force <name> Attach even when already inside a pty session (nested) 96 - pty exec -- <command> [args...] Replace the current session's command 97 - pty attach -r <name> Attach, auto-restart if exited 98 - pty peek <name> Print current screen and exit 99 - pty peek --plain <name> Print current screen as plain text (no ANSI) 100 - pty peek --full <name> Print full scrollback (not just viewport) 101 - pty peek --wait "text" <name> Wait until text appears on screen 102 - pty peek --wait "text" -t 10 <name> Wait with timeout (seconds) 103 - pty peek -f <name> Follow output read-only (Ctrl+\\ to stop) 104 - pty send <name> "text" Send text to a session 105 - pty send <name> --seq "text" --seq key:return Send an ordered sequence 106 - pty send <name> --with-delay 0.5 --seq ... Delay between each --seq item 107 - pty send <name> --paste "<big text>" Wrap payload in bracketed-paste markers 108 - pty restart <name> Restart a session (prompts if running) 109 - pty restart -y <name> Restart without confirmation 110 - pty events <name> Follow events from a session 111 - pty events --all Follow events from all sessions 112 - pty events --recent <name> Show recent events and exit 113 - pty events --json <name> Output raw JSONL 114 - pty list List active sessions (with tags) 115 - pty list --tags Show all tags including internal bookkeeping (ptyfile*, strategy, etc.) 116 - pty list --json List sessions as JSON 117 - pty list --filter-tag key=value List only sessions matching the tag (repeatable) 118 - pty up Start all sessions from pty.toml 119 - pty up <dir> Start sessions from <dir>/pty.toml 120 - pty up <name> [<name>...] Start specific sessions from pty.toml 121 - pty down Stop all sessions from pty.toml 122 - pty down <dir> Stop sessions from <dir>/pty.toml 123 - pty down <name> [<name>...] Stop specific sessions from pty.toml 124 - pty kill <name> Kill or remove a session 125 - pty gc Remove all exited sessions 126 - pty tag <name> Show tags on a session 127 - pty tag <name> key=value [key=value...] Set tags 128 - pty tag <name> --rm key [--rm key...] Remove tags 129 - pty tag-multi <selector> [ops...] Read/write tags across multiple sessions (--all / --filter-tag k=v / <name>...) 130 - pty gc --print-launchd-plist [--interval=N] Print a launchd plist that runs 'pty gc' every N seconds (default 30) 131 - pty gc [--dry-run] [--idle-days N] Reconciliation pass; --idle-days N reaps permanent sessions with no attach in N days 132 - pty gc --fast-fail-window=N Fast-fail window (seconds) for the respawn cap (default 60; per-session tag wins) 133 - pty gc --fast-fail-limit=N Consecutive fast fails before a permanent session is flagged flapping (default 3) 134 - pty wrap <command> Auto-wrap a command in pty sessions 135 - pty unwrap <command> Remove a wrap 136 - pty wrap --list List wrapped commands 137 - pty test Run tests (vitest) 138 - pty test watch Watch mode 139 - pty test -t "pattern" Run matching tests 75 + pty Interactive session manager (fullscreen TUI) 76 + pty --preselect-new Open the TUI with "Create new session..." pre-selected 77 + pty --filter-tag key=value Filter the TUI to sessions matching the tag (repeatable); 78 + new sessions inherit the tag 79 + 80 + Create sessions: 81 + pty run -- <command> [args...] Create a session and attach (random id + auto display label) 82 + pty run --id <id> -- <command> Pin the on-disk id (sock / json filename; charset-validated) 83 + pty run --name <label> -- <command> Set an explicit display label (any printable, ≤ 500 chars) 84 + pty run --no-display-name -- <cmd> Skip the friendly cwd+command label (just an id) 85 + pty run -d -- <command> Create in the background (detached) 86 + pty run -a -- <command> Create OR attach if a session with the same id already exists 87 + pty run -e -- <command> Ephemeral: auto-remove metadata on clean exit 88 + pty run --tag key=value -- <command> Tag a session (repeatable) 89 + pty run --cwd /path -- <command> Run in a specific directory 90 + pty run --isolate-env -- <command> Scrub the child env to a safe allow-list 91 + (intended for remote-reachable sessions) 140 92 141 - Detach from a session with Ctrl+\\ (press twice to send Ctrl+\\ to the process)`); 93 + Attach & interact: 94 + pty attach <ref> Attach to an existing session (alias: pty a) 95 + pty attach --force <ref> Attach even from inside another pty session (nested) 96 + pty attach -r <ref> Attach, auto-restart if the session is exited 97 + pty exec -- <command> [args...] Replace the current session's process (inside a session) 98 + pty send <ref> "text" Send raw text (no implicit newline) 99 + pty send <ref> --seq "text" --seq key:return Send an ordered sequence of chunks / key events 100 + pty send <ref> --with-delay 0.5 --seq ... Insert a delay (seconds) between --seq items 101 + pty send <ref> --paste "<big text>" Wrap the payload in bracketed-paste markers 102 + 103 + Observe: 104 + pty peek <ref> Print current screen and exit 105 + pty peek --plain <ref> Print current screen as plain text (no ANSI) 106 + pty peek --full <ref> Print full scrollback (not just the viewport) 107 + pty peek --wait "text" [-t N] <ref> Wait until text appears (optional timeout in seconds) 108 + pty peek -f <ref> Follow output read-only (Ctrl+\\ to stop) 109 + pty events <ref> Follow events from a session 110 + pty events --all Follow events from every session, interleaved 111 + pty events --recent <ref> Print recent events and exit 112 + pty events --json <ref> Emit raw JSONL 113 + pty stats Live CPU / memory / PIDs for every session 114 + pty stats <ref> Live metrics for a single session 115 + pty stats --json Emit stats as JSON (one snapshot) 116 + pty list List sessions (text; alias: pty ls) 117 + pty list --json List sessions as JSON 118 + pty list --tags Include internal bookkeeping tags (ptyfile*, strategy.*) 119 + pty list --filter-tag key=value Filter to sessions with the tag (repeatable, ALL must match) 120 + pty list --remote Include remote sessions via pty-relay (when installed) 121 + 122 + Modify: 123 + pty rename <label> Inside a session: set its displayName 124 + pty rename <ref> <label> Outside: set displayName on <ref> 125 + pty rename --show <ref> Print the current displayName 126 + pty rename --clear [ref] Remove the displayName 127 + pty tag <ref> Show tags on a session 128 + pty tag <ref> key=value [key=value...] Set tags 129 + pty tag <ref> --rm key [--rm key...] Remove tags 130 + pty tag-multi <selector> [ops...] Bulk read / write tags across sessions 131 + Selector (one of): --all | --filter-tag k=v | <ref>... 132 + Ops (any of): key=value | --rm key 133 + --all + write requires --yes 134 + pty emit user.<type> [--json <p>] [--text <s>] Publish a user.* event (inside a session) 135 + pty emit <ref> user.<type> [...] Same, targeting a specific session 136 + 137 + Lifecycle: 138 + pty restart <ref> SIGTERM + respawn using stored metadata (prompts if running) 139 + pty restart -y <ref> Same, no prompt 140 + pty kill <ref> SIGTERM a running session's daemon 141 + pty rm <ref> Remove an exited session's metadata (alias: pty remove) 142 + pty gc Reconciliation pass: orphan-kill, abandoned-reap, 143 + permanent-respawn, exited-sweep 144 + pty gc --dry-run Preview without changing anything (alias: -n) 145 + pty gc --idle-days N Also reap permanents with no attach in N days 146 + pty gc --fast-fail-window=N Fast-fail window (seconds) for the respawn cap 147 + (default 60; per-session strategy.fast-fail-window wins) 148 + pty gc --fast-fail-limit=N Consecutive fast fails before a permanent is flagged 149 + flapping (default 3; per-session tag wins) 150 + pty gc --print-launchd-plist [--interval=N] 151 + Print a launchd plist that runs 'pty gc' every N seconds 152 + (default 30); Label + logPath derived from PTY_ROOT 153 + 154 + Multi (pty.toml): 155 + pty up Start every session in ./pty.toml 156 + pty up <dir> Start sessions in <dir>/pty.toml 157 + pty up <name> [<name>...] Start specific sessions from ./pty.toml 158 + pty down Stop every session in ./pty.toml 159 + pty down <dir> Stop sessions in <dir>/pty.toml 160 + pty down <name> [<name>...] Stop specific sessions 161 + 162 + Global: 163 + pty --root <path> <subcommand> [...] Pin the state registry for this call (== PTY_ROOT env) 164 + pty help | pty --help | pty -h Show this usage 165 + pty test [watch | -t "pattern"] Run the pty test suite (vitest passthrough) 166 + 167 + Session references (<ref>): the on-disk id (validated: [A-Za-z0-9._-], ≤ 255 chars, 168 + socket path ≤ 104 bytes), or a displayName. Inside a session, most commands default 169 + to $PTY_SESSION when the ref is omitted (see 'pty rename', 'pty exec', 'pty emit'). 170 + 171 + Env: 172 + PTY_ROOT Registry dir (default ~/.local/state/pty). Canonical. 173 + PTY_SESSION_DIR Deprecated alias for PTY_ROOT; still works, one-time notice. 174 + PTY_ROOT_LEGACY_SILENT Suppress the PTY_SESSION_DIR deprecation notice. 175 + PTY_SESSION Set by the daemon inside a session; drives nesting detection. 176 + 177 + Detach from an attached session with Ctrl+\\ (press twice to send Ctrl+\\ to the child).`); 142 178 } 143 179 144 180 /** Resolve a user-supplied session reference (name OR displayName) to the ··· 925 961 break; 926 962 } 927 963 928 - case "state": { 929 - await cmdState(args.slice(1)); 930 - break; 931 - } 932 - 933 964 case "up": { 934 965 if (args[1] === "-h" || args[1] === "--help") { 935 966 console.log("Usage: pty up [dir] [name...]\n\nStart sessions defined in pty.toml."); ··· 989 1020 } 990 1021 const resolvedRmName = await resolveRef(args[1]); 991 1022 await cmdRm(resolvedRmName); 992 - break; 993 - } 994 - 995 - case "wrap": { 996 - if (args[1] === "--list" || args[1] === "-l") { 997 - cmdWrapList(); 998 - } else if (!args[1]) { 999 - console.error("Usage: pty wrap <command>"); 1000 - process.exit(1); 1001 - } else { 1002 - cmdWrap(args[1]); 1003 - } 1004 - break; 1005 - } 1006 - 1007 - case "unwrap": { 1008 - if (!args[1]) { 1009 - console.error("Usage: pty unwrap <command>"); 1010 - process.exit(1); 1011 - } 1012 - cmdUnwrap(args[1]); 1013 1023 break; 1014 1024 } 1015 1025 ··· 2446 2456 pty emit myserver user.tests-passed --json '{"n": 42}'`); 2447 2457 } 2448 2458 2449 - // --- state: per-session JSON data bag --- 2450 - 2451 - async function cmdState(argv: string[]): Promise<void> { 2452 - const sub = argv[0]; 2453 - if (!sub || sub === "-h" || sub === "--help") { 2454 - printStateHelp(); 2455 - return; 2456 - } 2457 - 2458 - // pty state <sub> [ref] [key] [value] 2459 - // ref defaults to $PTY_SESSION inside a session. 2460 - const rest = argv.slice(1); 2461 - 2462 - // Figure out whether the first positional is a session-ref or a key. 2463 - // Heuristic: if it names an existing session, treat it as the ref; 2464 - // otherwise fall back to $PTY_SESSION. This matches the `pty exec`, 2465 - // `pty rename` etc. pattern. 2466 - async function resolveStateTarget(): Promise<{ name: string; rest: string[] }> { 2467 - const inside = process.env.PTY_SESSION; 2468 - if (rest.length > 0) { 2469 - const candidate = rest[0]; 2470 - try { 2471 - validateName(candidate); 2472 - const existing = await getSession(candidate); 2473 - if (existing) return { name: candidate, rest: rest.slice(1) }; 2474 - } catch { 2475 - // Not a valid session name — treat as the key instead. 2476 - } 2477 - } 2478 - if (!inside) { 2479 - console.error(`pty state ${sub}: no session ref given and not running inside a pty session`); 2480 - console.error(` tip: run inside a pty session, or pass the session-ref: pty state ${sub} <session-ref> ...`); 2481 - process.exit(1); 2482 - } 2483 - return { name: await resolveRef(inside), rest }; 2484 - } 2485 - 2486 - try { 2487 - switch (sub) { 2488 - case "get": { 2489 - const { name, rest: r } = await resolveStateTarget(); 2490 - if (r.length === 0) { 2491 - const bag = getState(name); 2492 - console.log(JSON.stringify(bag, null, 2)); 2493 - return; 2494 - } 2495 - if (r.length > 1) { 2496 - console.error("pty state get: unexpected extra args"); 2497 - process.exit(1); 2498 - } 2499 - const value = getStateKey(name, r[0]); 2500 - if (value === undefined) { 2501 - process.exit(1); // "missing key" — silent, non-zero exit 2502 - } 2503 - console.log(JSON.stringify(value, null, 2)); 2504 - return; 2505 - } 2506 - case "set": { 2507 - const { name, rest: r } = await resolveStateTarget(); 2508 - if (r.length < 1) { 2509 - console.error("pty state set: expected <key> [value]. If value is omitted, JSON is read from stdin."); 2510 - process.exit(1); 2511 - } 2512 - if (r.length > 2) { 2513 - console.error("pty state set: too many positional arguments. Quote the JSON value so the shell keeps it as one argument: pty state set <ref> <key> '<json>'."); 2514 - process.exit(1); 2515 - } 2516 - const key = r[0]; 2517 - let raw: string; 2518 - if (r.length === 2) { 2519 - raw = r[1]; 2520 - } else { 2521 - raw = await readAllStdin(); 2522 - } 2523 - let value: unknown; 2524 - try { value = JSON.parse(raw); } 2525 - catch (e: any) { 2526 - console.error(`pty state set: value is not valid JSON: ${e.message}`); 2527 - process.exit(1); 2528 - } 2529 - setState(name, key, value); 2530 - // state.set event is emitted by setState itself. 2531 - return; 2532 - } 2533 - case "delete": 2534 - case "rm": { 2535 - const { name, rest: r } = await resolveStateTarget(); 2536 - if (r.length !== 1) { 2537 - console.error(`pty state ${sub}: expected <key>`); 2538 - process.exit(1); 2539 - } 2540 - const key = r[0]; 2541 - deleteState(name, key); 2542 - // state.delete event is emitted by deleteState when a key was 2543 - // actually removed; a delete on a missing key is a silent no-op. 2544 - return; 2545 - } 2546 - case "keys": { 2547 - const { name, rest: r } = await resolveStateTarget(); 2548 - if (r.length > 0) { 2549 - console.error("pty state keys: unexpected extra args"); 2550 - process.exit(1); 2551 - } 2552 - const keys = listStateKeys(name); 2553 - for (const k of keys) console.log(k); 2554 - return; 2555 - } 2556 - default: 2557 - printStateHelp(); 2558 - process.exit(1); 2559 - } 2560 - } catch (e: any) { 2561 - console.error(e.message); 2562 - process.exit(1); 2563 - } 2564 - } 2565 - 2566 - function printStateHelp(): void { 2567 - console.log(`Usage: 2568 - pty state get [<ref>] [<key>] # print full bag or one key (JSON) 2569 - pty state set [<ref>] <key> [v] # set <key> to JSON value v (or stdin) 2570 - pty state delete [<ref>] <key> # remove a key 2571 - pty state keys [<ref>] # list keys 2572 - 2573 - Inside a pty session, <ref> defaults to $PTY_SESSION. Every set/delete 2574 - emits a matching state.set / state.delete event so followers of 2575 - 'pty events <ref>' see state transitions live. 2576 - 2577 - Values are JSON — "42" is the number 42, '"42"' is the string "42". 2578 - Use 'pty state set foo "$(cat payload.json)"' for larger payloads, 2579 - or pipe via stdin: cat payload.json | pty state set foo`); 2580 - } 2581 - 2582 - async function readAllStdin(): Promise<string> { 2583 - // Collect all of stdin. Used by 'pty state set <key>' when no value arg 2584 - // is given — allows shell piping for large JSON payloads. 2585 - return await new Promise((resolve, reject) => { 2586 - let data = ""; 2587 - process.stdin.setEncoding("utf8"); 2588 - process.stdin.on("data", (chunk) => { data += chunk; }); 2589 - process.stdin.on("end", () => resolve(data)); 2590 - process.stdin.on("error", reject); 2591 - }); 2592 - } 2459 + // (state / wrap / unwrap removed in the lean-core pass — smalltalk's 2460 + // folder + bus own agent state; wrap was orthogonal shim generation 2461 + // that isn't part of the session-primitive contract.) 2593 2462 2594 2463 2595 2464 function hasPtyFile(dir: string): boolean { ··· 3061 2930 if (hours < 24) return `${hours}h ago`; 3062 2931 const days = Math.floor(hours / 24); 3063 2932 return `${days}d ago`; 3064 - } 3065 - 3066 - 3067 - // ── Wrap/Unwrap ── 3068 - 3069 - const DEFAULT_WRAP_DIR = path.join(os.homedir(), ".local", "pty", "bin"); 3070 - 3071 - function getWrapDir(): string { 3072 - return process.env.PTY_BIN_PATH ?? DEFAULT_WRAP_DIR; 3073 - } 3074 - 3075 - function ensureWrapDir(): void { 3076 - fs.mkdirSync(getWrapDir(), { recursive: true, mode: 0o700 }); 3077 - } 3078 - 3079 - function checkWrapInPath(): void { 3080 - const wrapDir = getWrapDir(); 3081 - const pathDirs = (process.env.PATH ?? "").split(":"); 3082 - if (!pathDirs.includes(wrapDir)) { 3083 - console.error(`\nAdd this to your shell profile to use wrapped commands:\n`); 3084 - console.error(` export PATH="${wrapDir}:$PATH"\n`); 3085 - } 3086 - } 3087 - 3088 - /** 3089 - * Resolve the real binary path, skipping our wrap directory. 3090 - * Searches PATH with the wrap dir excluded so we find the original binary. 3091 - */ 3092 - function resolveRealBinary(name: string): string | null { 3093 - const wrapDir = getWrapDir(); 3094 - const pathDirs = (process.env.PATH ?? "").split(":").filter(d => d !== wrapDir); 3095 - for (const dir of pathDirs) { 3096 - const candidate = path.join(dir, name); 3097 - try { 3098 - fs.accessSync(candidate, fs.constants.X_OK); 3099 - return candidate; 3100 - } catch {} 3101 - } 3102 - return null; 3103 - } 3104 - 3105 - function cmdWrap(command: string): void { 3106 - const cmdName = path.basename(command); 3107 - const realPath = resolveRealBinary(cmdName); 3108 - if (!realPath) { 3109 - console.error(`Command not found in PATH: ${cmdName}`); 3110 - process.exit(1); 3111 - } 3112 - 3113 - ensureWrapDir(); 3114 - const wrapPath = path.join(getWrapDir(), cmdName); 3115 - 3116 - // The wrapper script uses pty run -a (create or attach) with auto-naming. 3117 - // It resolves pty from PATH (excluding the wrap dir to avoid recursion). 3118 - const script = `#!/bin/sh 3119 - # Generated by: pty wrap ${cmdName} 3120 - # Wraps ${realPath} in a pty session 3121 - exec pty run -a -- ${realPath} "$@" 3122 - `; 3123 - 3124 - fs.writeFileSync(wrapPath, script, { mode: 0o755 }); 3125 - console.log(`Wrapped: ${cmdName} → ${realPath}`); 3126 - console.log(`Wrapper: ${wrapPath}`); 3127 - checkWrapInPath(); 3128 - } 3129 - 3130 - function cmdUnwrap(command: string): void { 3131 - const cmdName = path.basename(command); 3132 - const wrapPath = path.join(getWrapDir(), cmdName); 3133 - if (!fs.existsSync(wrapPath)) { 3134 - console.error(`Not wrapped: ${cmdName}`); 3135 - process.exit(1); 3136 - } 3137 - fs.unlinkSync(wrapPath); 3138 - console.log(`Unwrapped: ${cmdName}`); 3139 - } 3140 - 3141 - function cmdWrapList(): void { 3142 - const wrapDir = getWrapDir(); 3143 - let files: string[]; 3144 - try { 3145 - files = fs.readdirSync(wrapDir); 3146 - } catch { 3147 - console.log("No wrapped commands."); 3148 - return; 3149 - } 3150 - if (files.length === 0) { 3151 - console.log("No wrapped commands."); 3152 - return; 3153 - } 3154 - console.log("Wrapped commands:"); 3155 - for (const file of files.sort()) { 3156 - const content = fs.readFileSync(path.join(wrapDir, file), "utf-8"); 3157 - const match = content.match(/exec pty run -a -- (.+) "\$@"/); 3158 - const target = match ? match[1] : "?"; 3159 - console.log(` ${file} → ${target}`); 3160 - } 3161 - checkWrapInPath(); 3162 2933 } 3163 2934 3164 2935 main().catch((err) => {
+1 -2
src/client-api.ts
··· 7 7 validateName, updateTags, setDisplayName, 8 8 getSessionDir, getSocketPath, 9 9 cleanupSocket, cleanupAll, 10 - getState, getStateKey, setState, deleteState, listStateKeys, 11 10 type SessionInfo, type SessionMetadata, type PrunedTagResult, type GcResult, 12 11 } from "./sessions.ts"; 13 12 ··· 38 37 type FocusRequestEvent, type CursorVisibleEvent, 39 38 type SessionStartEvent, type SessionExitEvent, type SessionExecEvent, 40 39 type SessionRespawnEvent, 41 - type UserEvent, type StateSetEvent, type StateDeleteEvent, 40 + type UserEvent, 42 41 type DisplayNameChangeEvent, type TagsChangeEvent, 43 42 type FollowerOptions, 44 43 } from "./events.ts";
+10 -30
src/events.ts
··· 30 30 export interface EventBase { 31 31 session: string; 32 32 /** Type string. Known system types live in the `EventType` enum; user 33 - * events are `user.*`; state bag changes are `state.set` / `state.delete`. 34 - * Kept as a plain `string` here so subtype interfaces can carry their 35 - * own literal types without fighting the compiler. */ 33 + * events are `user.*`. Kept as a plain `string` here so subtype 34 + * interfaces can carry their own literal types without fighting 35 + * the compiler. */ 36 36 type: string; 37 37 ts: string; 38 38 } ··· 134 134 text?: string; 135 135 } 136 136 137 - /** Emitted automatically whenever `setState` writes a key. Mirrors 138 - * what `pty state set` records. Consumers of the event stream can 139 - * react to state changes without polling the metadata file. */ 140 - export interface StateSetEvent extends EventBase { 141 - type: "state.set"; 142 - key: string; 143 - value: unknown; 144 - } 145 - 146 - export interface StateDeleteEvent extends EventBase { 147 - type: "state.delete"; 148 - key: string; 149 - } 150 - 151 137 /** Emitted whenever `setDisplayName` actually changes the stored value. 152 138 * `previous` / `value` are `null` when absent. Skipped on no-op writes 153 139 * so consumers don't get spurious refresh pings. */ ··· 179 165 | SessionAbandonedEvent 180 166 | SessionFlappingEvent 181 167 | UserEvent 182 - | StateSetEvent 183 - | StateDeleteEvent 184 168 | DisplayNameChangeEvent 185 169 | TagsChangeEvent; 186 170 ··· 216 200 217 201 /** One-shot helper to append a single event to a session's events log 218 202 * without keeping an EventWriter around. Used by CLI subcommands like 219 - * `pty emit` and `pty state set` that run outside the daemon process. 203 + * `pty emit` that run outside the daemon process. 220 204 * Applies the same MAX_LINES/KEEP_LINES retention as `EventWriter` so 221 205 * scripts that write in a loop don't grow the log unbounded — the 222 206 * truncate path is skipped via a cheap stat when the file is small. */ ··· 229 213 } 230 214 231 215 /** Synchronous twin of `appendEvent` — lets synchronous metadata-mutation 232 - * helpers (setDisplayName, updateTags, setState, deleteState) emit their 233 - * change events inline without forcing their signatures to go async. 234 - * Uses the same retention path with a sync stat fast-path. 216 + * helpers (setDisplayName, updateTags) emit their change events inline 217 + * without forcing their signatures to go async. Uses the same retention 218 + * path with a sync stat fast-path. 235 219 * 236 220 * Concurrency note: `fs.appendFileSync` issues a single `write()` with 237 221 * `O_APPEND`, which POSIX guarantees is atomic for payloads up to 238 222 * `PIPE_BUF` bytes (typically 4096 on Linux/macOS). All built-in events 239 - * are well under that. If a caller passes a `user.*` event or 240 - * `state.set` with a > 4KB payload, concurrent appends could interleave 241 - * — keep large payloads out of the event stream and in state. */ 223 + * are well under that. If a caller passes a large `user.*` event 224 + * payload, concurrent appends could interleave — keep large payloads 225 + * out of the event stream. */ 242 226 export function appendEventSync(name: string, event: EventRecord): void { 243 227 ensureSessionDir(); 244 228 const filePath = getEventsPath(name); ··· 528 512 return event.reason === "idle" && event.idleDays !== undefined 529 513 ? `${prefix} abandoned (idle ${event.idleDays}d)` 530 514 : `${prefix} abandoned (${event.reason})`; 531 - case "state.set": 532 - return `${prefix} state.set ${event.key} = ${JSON.stringify(event.value)}`; 533 - case "state.delete": 534 - return `${prefix} state.delete ${event.key}`; 535 515 case "display_name_change": 536 516 return `${prefix} display_name -> ${JSON.stringify(event.value)} (was ${JSON.stringify(event.previous)})`; 537 517 case "tags_change": {
-81
src/sessions.ts
··· 119 119 exitedAt?: string; 120 120 lastLines?: string[]; 121 121 tags?: Record<string, string>; 122 - /** Free-form per-session data bag. Separate from `tags` (which are 123 - * string-valued, filterable, and rendered in `pty list`) — `state` 124 - * holds complex JSON values a session or a consumer wants to track 125 - * (web server port, agent turn count, cached result, etc.). Mutated 126 - * via `setState` / `deleteState`, which emit `state.set` / 127 - * `state.delete` events automatically. Keep to small-to-medium JSON 128 - * — the metadata file is rewritten on every update. */ 129 - state?: Record<string, unknown>; 130 122 /** Optional human-friendly alias for the session. Mutable via `pty rename`. 131 123 * The immutable stable id is always `SessionInfo.name`. Most code should 132 124 * keep using `name`; `displayName` is purely for presentation and as an ··· 287 279 if (!Object.prototype.hasOwnProperty.call(b, k) || a[k] !== b[k]) return false; 288 280 } 289 281 return true; 290 - } 291 - 292 - /** Read a session's state bag. Returns an empty object when the session 293 - * has no state. Throws if the session doesn't exist. */ 294 - export function getState(name: string): Record<string, unknown> { 295 - const metadata = readMetadata(name); 296 - if (!metadata) throw new Error(`Session "${name}" not found.`); 297 - return { ...(metadata.state ?? {}) }; 298 - } 299 - 300 - /** Read a single state key. Returns `undefined` if the key isn't set. 301 - * Uses own-property lookup so prototype names like `toString` / 302 - * `hasOwnProperty` return `undefined` instead of leaking inherited 303 - * methods. */ 304 - export function getStateKey(name: string, key: string): unknown { 305 - const metadata = readMetadata(name); 306 - if (!metadata) throw new Error(`Session "${name}" not found.`); 307 - const state = metadata.state; 308 - if (!state || !Object.prototype.hasOwnProperty.call(state, key)) return undefined; 309 - return state[key]; 310 - } 311 - 312 - /** Set a key on the state bag. Atomic read-modify-write of the metadata 313 - * file. Emits a `state.set` event on every successful write — callers 314 - * that want the full reactive signal (pty-layout, activity viewers, 315 - * etc.) get it whether they use the CLI or the programmatic API. */ 316 - export function setState(name: string, key: string, value: unknown): void { 317 - const metadata = readMetadata(name); 318 - if (!metadata) throw new Error(`Session "${name}" not found.`); 319 - const state = { ...(metadata.state ?? {}) }; 320 - state[key] = value; 321 - metadata.state = state; 322 - writeMetadata(name, metadata); 323 - appendEventSync(name, { 324 - session: name, 325 - type: "state.set", 326 - ts: new Date().toISOString(), 327 - key, 328 - value, 329 - }); 330 - } 331 - 332 - /** Delete a key from the state bag. Returns `true` when the key existed and 333 - * was removed, `false` when the key wasn't set (no write performed). 334 - * Emits a `state.delete` event only when something was actually removed, 335 - * so a delete on a missing key is a true no-op (no ghost event). 336 - * Uses own-property lookup — inherited names like `toString` never match. */ 337 - export function deleteState(name: string, key: string): boolean { 338 - const metadata = readMetadata(name); 339 - if (!metadata) throw new Error(`Session "${name}" not found.`); 340 - if (!metadata.state || !Object.prototype.hasOwnProperty.call(metadata.state, key)) return false; 341 - const state = { ...metadata.state }; 342 - delete state[key]; 343 - if (Object.keys(state).length > 0) { 344 - metadata.state = state; 345 - } else { 346 - delete metadata.state; 347 - } 348 - writeMetadata(name, metadata); 349 - appendEventSync(name, { 350 - session: name, 351 - type: "state.delete", 352 - ts: new Date().toISOString(), 353 - key, 354 - }); 355 - return true; 356 - } 357 - 358 - /** List every key currently set on the state bag. */ 359 - export function listStateKeys(name: string): string[] { 360 - const metadata = readMetadata(name); 361 - if (!metadata) throw new Error(`Session "${name}" not found.`); 362 - return Object.keys(metadata.state ?? {}); 363 282 } 364 283 365 284 export function readMetadata(name: string): SessionMetadata | null {
+1 -21
tests/atomic-writes.test.ts
··· 18 18 import { spawn, spawnSync } from "node:child_process"; 19 19 import { 20 20 atomicWriteFileSync, atomicWriteFile, 21 - updateTags, setState, readMetadata, 21 + updateTags, readMetadata, 22 22 } from "../src/sessions.ts"; 23 23 import { appendEventSync, readRecentEvents } from "../src/events.ts"; 24 24 ··· 199 199 expect(leftover).toEqual([]); 200 200 }, 30000); 201 201 202 - it("in-process Promise.all of setState calls leaves metadata valid", async () => { 203 - const dir = makeSessionDir(); 204 - const name = uniqueName(); 205 - await startDaemon(dir, name); 206 - process.env.PTY_SESSION_DIR = dir; 207 - 208 - // 50 concurrent setState calls in the same process — stress-tests the 209 - // atomic helper under the sync Promise.all-of-sync-functions pattern. 210 - await Promise.all( 211 - Array.from({ length: 50 }, (_, i) => 212 - Promise.resolve().then(() => setState(name, `k${i}`, i)), 213 - ), 214 - ); 215 - 216 - const meta = readMetadata(name); 217 - expect(meta).not.toBeNull(); 218 - // All 50 should have landed — Node's single-threaded eventloop + sync 219 - // setState means writes serialize, no losses. 220 - expect(Object.keys(meta!.state ?? {}).sort()).toHaveLength(50); 221 - }, 15000); 222 202 }); 223 203 224 204 describe("event log truncation vs concurrent reader", () => {
+5 -6
tests/events-emit.test.ts
··· 234 234 235 235 describe("appendEvent retention", () => { 236 236 it("caps the events log when scripts write in a loop", async () => { 237 - // Regression: previously `appendEvent` (used by `pty emit` and the 238 - // `pty state set/delete` event emissions) did a raw fsp.appendFile 239 - // with no truncation, while the daemon's EventWriter enforces a 240 - // 1000-line cap. A script that ran `pty state set` in a tight loop 241 - // would grow events.jsonl without bound. Now appendEvent calls the 242 - // same retention path. 237 + // Regression: previously `appendEvent` (used by `pty emit`) did a 238 + // raw fsp.appendFile with no truncation, while the daemon's 239 + // EventWriter enforces a 1000-line cap. A script that emitted in a 240 + // tight loop would grow events.jsonl without bound. Now appendEvent 241 + // calls the same retention path. 243 242 process.env.PTY_SESSION_DIR = makeSessionDir(); 244 243 const name = uniqueName(); 245 244
-32
tests/events.test.ts
··· 264 264 expect(result).toContain("cursor restored"); 265 265 }); 266 266 267 - it("formats state.set with JSON-encoded value", () => { 268 - const result = formatEvent({ 269 - session: "test", 270 - type: "state.set", 271 - ts: "2026-04-05T10:15:03.000Z", 272 - key: "port", 273 - value: 3000, 274 - }); 275 - expect(result).toContain("state.set port = 3000"); 276 - }); 277 - 278 - it("formats state.set with complex value (JSON, no pretty-printing)", () => { 279 - const result = formatEvent({ 280 - session: "test", 281 - type: "state.set", 282 - ts: "2026-04-05T10:15:03.000Z", 283 - key: "config", 284 - value: { host: "localhost", tls: { cert: "x" } }, 285 - }); 286 - expect(result).toContain('state.set config = {"host":"localhost","tls":{"cert":"x"}}'); 287 - }); 288 - 289 - it("formats state.delete naming the removed key", () => { 290 - const result = formatEvent({ 291 - session: "test", 292 - type: "state.delete", 293 - ts: "2026-04-05T10:15:03.000Z", 294 - key: "port", 295 - }); 296 - expect(result).toContain("state.delete port"); 297 - }); 298 - 299 267 it("formats user.* with a text payload (quoted)", () => { 300 268 const result = formatEvent({ 301 269 session: "test",
-485
tests/state.test.ts
··· 1 - // Tests for `pty state` and the underlying sessions.ts state helpers. 2 - 3 - import { describe, it, expect, afterEach, afterAll } from "vitest"; 4 - import * as fs from "node:fs"; 5 - import * as os from "node:os"; 6 - import * as path from "node:path"; 7 - import { fileURLToPath } from "node:url"; 8 - import { spawn, spawnSync } from "node:child_process"; 9 - import { 10 - getState, getStateKey, setState, deleteState, listStateKeys, 11 - } from "../src/sessions.ts"; 12 - import { EventFollower, type EventRecord } from "../src/events.ts"; 13 - 14 - const __dirname = path.dirname(fileURLToPath(import.meta.url)); 15 - const nodeBin = process.execPath; 16 - const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 17 - const serverModule = path.join(__dirname, "..", "dist", "server.js"); 18 - 19 - const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-state-")); 20 - afterAll(() => { 21 - fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 22 - }); 23 - 24 - let bgPids: number[] = []; 25 - let sessionDirs: string[] = []; 26 - 27 - function makeSessionDir(): string { 28 - const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 29 - sessionDirs.push(dir); 30 - return dir; 31 - } 32 - 33 - let nameCounter = 0; 34 - function uniqueName(): string { 35 - return `st${++nameCounter}-${Math.random().toString(36).slice(2, 6)}`; 36 - } 37 - 38 - async function startDaemon(sessionDir: string, name: string): Promise<number> { 39 - const config = JSON.stringify({ 40 - name, command: "cat", args: [], displayCommand: "cat", 41 - cwd: os.tmpdir(), rows: 24, cols: 80, 42 - }); 43 - const child = spawn(nodeBin, [serverModule], { 44 - detached: true, 45 - stdio: ["ignore", "ignore", "pipe"], 46 - env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir }, 47 - }); 48 - let stderr = ""; 49 - child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); 50 - let exitCode: number | null = null; 51 - child.on("exit", (code) => { exitCode = code; }); 52 - (child.stderr as any)?.unref?.(); 53 - child.unref(); 54 - const socketPath = path.join(sessionDir, `${name}.sock`); 55 - const start = Date.now(); 56 - while (Date.now() - start < 5000) { 57 - if (exitCode !== null) throw new Error(`Daemon exited: ${stderr}`); 58 - try { 59 - fs.statSync(socketPath); 60 - await new Promise((r) => setTimeout(r, 100)); 61 - bgPids.push(child.pid!); 62 - return child.pid!; 63 - } catch {} 64 - await new Promise((r) => setTimeout(r, 50)); 65 - } 66 - throw new Error("Timeout waiting for daemon"); 67 - } 68 - 69 - function runCli(sessionDir: string, env: Record<string, string>, ...args: string[]) { 70 - return spawnSync(nodeBin, [cliPath, ...args], { 71 - env: { ...process.env, PTY_SESSION_DIR: sessionDir, ...env }, 72 - encoding: "utf-8", 73 - input: env.STDIN ?? undefined, 74 - timeout: 10_000, 75 - }); 76 - } 77 - 78 - afterEach(() => { 79 - for (const pid of bgPids) { try { process.kill(pid, "SIGTERM"); } catch {} } 80 - bgPids = []; 81 - for (const dir of sessionDirs) { 82 - try { 83 - for (const e of fs.readdirSync(dir)) { try { fs.unlinkSync(path.join(dir, e)); } catch {} } 84 - } catch {} 85 - } 86 - sessionDirs = []; 87 - }); 88 - 89 - function readEvents(dir: string, name: string): any[] { 90 - try { 91 - const content = fs.readFileSync(path.join(dir, `${name}.events.jsonl`), "utf-8"); 92 - return content.trimEnd().split("\n").filter(Boolean).map(l => JSON.parse(l)); 93 - } catch { return []; } 94 - } 95 - 96 - describe("state helpers (direct API)", () => { 97 - it("setState / getStateKey round-trip complex values", async () => { 98 - const dir = makeSessionDir(); 99 - const name = uniqueName(); 100 - await startDaemon(dir, name); 101 - process.env.PTY_SESSION_DIR = dir; 102 - 103 - setState(name, "port", 3000); 104 - setState(name, "config", { host: "localhost", tls: { cert: "x.pem" } }); 105 - setState(name, "peers", ["a", "b", "c"]); 106 - 107 - expect(getStateKey(name, "port")).toBe(3000); 108 - expect(getStateKey(name, "config")).toEqual({ host: "localhost", tls: { cert: "x.pem" } }); 109 - expect(getStateKey(name, "peers")).toEqual(["a", "b", "c"]); 110 - }, 15000); 111 - 112 - it("getState returns the whole bag", async () => { 113 - const dir = makeSessionDir(); 114 - const name = uniqueName(); 115 - await startDaemon(dir, name); 116 - process.env.PTY_SESSION_DIR = dir; 117 - 118 - setState(name, "a", 1); 119 - setState(name, "b", "two"); 120 - const bag = getState(name); 121 - expect(bag).toEqual({ a: 1, b: "two" }); 122 - }, 15000); 123 - 124 - it("getState returns a fresh copy — mutating doesn't affect stored state", async () => { 125 - const dir = makeSessionDir(); 126 - const name = uniqueName(); 127 - await startDaemon(dir, name); 128 - process.env.PTY_SESSION_DIR = dir; 129 - 130 - setState(name, "a", 1); 131 - const bag = getState(name); 132 - bag.a = 999; 133 - expect(getStateKey(name, "a")).toBe(1); 134 - }, 15000); 135 - 136 - it("deleteState removes the key; deleting the last key drops the field", async () => { 137 - const dir = makeSessionDir(); 138 - const name = uniqueName(); 139 - await startDaemon(dir, name); 140 - process.env.PTY_SESSION_DIR = dir; 141 - 142 - setState(name, "only", 1); 143 - deleteState(name, "only"); 144 - const meta = JSON.parse(fs.readFileSync(path.join(dir, `${name}.json`), "utf-8")); 145 - expect(meta.state).toBeUndefined(); 146 - }, 15000); 147 - 148 - it("listStateKeys returns the keys", async () => { 149 - const dir = makeSessionDir(); 150 - const name = uniqueName(); 151 - await startDaemon(dir, name); 152 - process.env.PTY_SESSION_DIR = dir; 153 - 154 - setState(name, "a", 1); 155 - setState(name, "b", 2); 156 - expect(listStateKeys(name).sort()).toEqual(["a", "b"]); 157 - }, 15000); 158 - 159 - it("throws on unknown session", () => { 160 - process.env.PTY_SESSION_DIR = makeSessionDir(); 161 - expect(() => getState("no-such-session")).toThrow(/not found/); 162 - expect(() => setState("no-such-session", "k", 1)).toThrow(/not found/); 163 - }); 164 - }); 165 - 166 - describe("pty state CLI", () => { 167 - it("set then get round-trips JSON", async () => { 168 - const dir = makeSessionDir(); 169 - const name = uniqueName(); 170 - await startDaemon(dir, name); 171 - 172 - const setR = runCli(dir, {}, "state", "set", name, "port", "3000"); 173 - expect(setR.status).toBe(0); 174 - 175 - const getR = runCli(dir, {}, "state", "get", name, "port"); 176 - expect(getR.status).toBe(0); 177 - expect(getR.stdout.trim()).toBe("3000"); 178 - }, 15000); 179 - 180 - it("set emits a state.set event; delete emits state.delete", async () => { 181 - const dir = makeSessionDir(); 182 - const name = uniqueName(); 183 - await startDaemon(dir, name); 184 - 185 - runCli(dir, {}, "state", "set", name, "foo", '"bar"'); 186 - runCli(dir, {}, "state", "delete", name, "foo"); 187 - 188 - const events = readEvents(dir, name); 189 - const set = events.find(e => e.type === "state.set" && e.key === "foo"); 190 - const del = events.find(e => e.type === "state.delete" && e.key === "foo"); 191 - expect(set).toBeTruthy(); 192 - expect(set.value).toBe("bar"); 193 - expect(del).toBeTruthy(); 194 - }, 15000); 195 - 196 - it("get with no key prints the whole bag as pretty JSON", async () => { 197 - const dir = makeSessionDir(); 198 - const name = uniqueName(); 199 - await startDaemon(dir, name); 200 - 201 - runCli(dir, {}, "state", "set", name, "a", "1"); 202 - runCli(dir, {}, "state", "set", name, "b", "2"); 203 - 204 - const r = runCli(dir, {}, "state", "get", name); 205 - expect(r.status).toBe(0); 206 - expect(JSON.parse(r.stdout)).toEqual({ a: 1, b: 2 }); 207 - }, 15000); 208 - 209 - it("get on a missing key exits non-zero without printing undefined", async () => { 210 - const dir = makeSessionDir(); 211 - const name = uniqueName(); 212 - await startDaemon(dir, name); 213 - 214 - const r = runCli(dir, {}, "state", "get", name, "missing"); 215 - expect(r.status).not.toBe(0); 216 - expect(r.stdout).toBe(""); 217 - }, 15000); 218 - 219 - it("keys lists keys one per line", async () => { 220 - const dir = makeSessionDir(); 221 - const name = uniqueName(); 222 - await startDaemon(dir, name); 223 - 224 - runCli(dir, {}, "state", "set", name, "alpha", "1"); 225 - runCli(dir, {}, "state", "set", name, "beta", "2"); 226 - 227 - const r = runCli(dir, {}, "state", "keys", name); 228 - expect(r.status).toBe(0); 229 - expect(r.stdout.trim().split("\n").sort()).toEqual(["alpha", "beta"]); 230 - }, 15000); 231 - 232 - it("resolves to $PTY_SESSION when ref is omitted", async () => { 233 - const dir = makeSessionDir(); 234 - const name = uniqueName(); 235 - await startDaemon(dir, name); 236 - 237 - const setR = runCli(dir, { PTY_SESSION: name }, "state", "set", "inside-port", "4242"); 238 - expect(setR.status).toBe(0); 239 - 240 - const getR = runCli(dir, { PTY_SESSION: name }, "state", "get", "inside-port"); 241 - expect(getR.status).toBe(0); 242 - expect(getR.stdout.trim()).toBe("4242"); 243 - }, 15000); 244 - 245 - it("set value from stdin when not given as an arg", async () => { 246 - const dir = makeSessionDir(); 247 - const name = uniqueName(); 248 - await startDaemon(dir, name); 249 - 250 - const r = runCli(dir, { STDIN: '{"big":{"payload":[1,2,3]}}' }, "state", "set", name, "config"); 251 - expect(r.status).toBe(0); 252 - 253 - const getR = runCli(dir, {}, "state", "get", name, "config"); 254 - expect(JSON.parse(getR.stdout)).toEqual({ big: { payload: [1, 2, 3] } }); 255 - }, 15000); 256 - 257 - it("invalid JSON value rejected with a helpful message", async () => { 258 - const dir = makeSessionDir(); 259 - const name = uniqueName(); 260 - await startDaemon(dir, name); 261 - 262 - const r = runCli(dir, {}, "state", "set", name, "k", "not-json"); 263 - expect(r.status).not.toBe(0); 264 - expect(r.stderr).toMatch(/not valid JSON/); 265 - }, 15000); 266 - 267 - it("prototype-chain names don't leak through get/delete/keys", async () => { 268 - // Regression: the original code used `metadata.state?.[key]` (inherits 269 - // through the prototype chain) and `key in metadata.state` (same). 270 - // Meant `getStateKey(name, "toString")` would return the Function 271 - // prototype method, and `pty state delete name toString` would emit a 272 - // ghost `state.delete` event because `in` said the key "existed." 273 - const dir = makeSessionDir(); 274 - const name = uniqueName(); 275 - await startDaemon(dir, name); 276 - process.env.PTY_SESSION_DIR = dir; 277 - 278 - // Prime the bag with one real key so metadata.state exists. 279 - setState(name, "real", 1); 280 - 281 - for (const name2 of ["toString", "hasOwnProperty", "constructor", "__proto__"]) { 282 - expect(getStateKey(name, name2)).toBeUndefined(); 283 - } 284 - 285 - const before = readEvents(dir, name).filter(e => e.type === "state.delete").length; 286 - const r = runCli(dir, {}, "state", "delete", name, "toString"); 287 - expect(r.status).toBe(0); 288 - const after = readEvents(dir, name).filter(e => e.type === "state.delete").length; 289 - expect(after).toBe(before); // no ghost event for inherited name 290 - 291 - // And `keys` still reports only real own-properties. 292 - expect(listStateKeys(name)).toEqual(["real"]); 293 - }, 15000); 294 - 295 - it("keys rejects extra positional args (consistent with get/set/delete)", async () => { 296 - const dir = makeSessionDir(); 297 - const name = uniqueName(); 298 - await startDaemon(dir, name); 299 - 300 - const r = runCli(dir, {}, "state", "keys", name, "typo"); 301 - expect(r.status).not.toBe(0); 302 - expect(r.stderr).toMatch(/unexpected/i); 303 - }, 15000); 304 - 305 - it("rejects too many positional args (forces the user to quote their JSON)", async () => { 306 - const dir = makeSessionDir(); 307 - const name = uniqueName(); 308 - await startDaemon(dir, name); 309 - 310 - // Unquoted JSON gets split by the shell and joined by the CLI into garbage. 311 - // Previous behavior silently joined `extra1 extra2` with a space and tried 312 - // JSON.parse on it. Now we reject the whole shape with a helpful message. 313 - const r = runCli(dir, {}, "state", "set", name, "key", "extra1", "extra2"); 314 - expect(r.status).not.toBe(0); 315 - expect(r.stderr).toMatch(/too many positional|quote/i); 316 - }, 15000); 317 - 318 - it("delete on a missing key is a quiet no-op — no ghost state.delete event", async () => { 319 - const dir = makeSessionDir(); 320 - const name = uniqueName(); 321 - await startDaemon(dir, name); 322 - 323 - const before = readEvents(dir, name).filter(e => e.type === "state.delete").length; 324 - 325 - const r = runCli(dir, {}, "state", "delete", name, "never-existed"); 326 - expect(r.status).toBe(0); 327 - 328 - const after = readEvents(dir, name).filter(e => e.type === "state.delete").length; 329 - expect(after).toBe(before); // no new state.delete event fired 330 - }, 15000); 331 - 332 - it("heuristic: first positional that names an existing session wins as ref, not key", async () => { 333 - // Documents current resolveStateTarget() behavior. Two sessions in the 334 - // same dir: `inside` (the session we're pretending to be running under) 335 - // and `other`. Running `pty state set other 42` with PTY_SESSION=inside 336 - // targets session `other` with key=... wait, there's no key. That'd error. 337 - // Instead use: `pty state set other mykey 1`. The heuristic sees `other` 338 - // is a known session → treats it as ref, key=mykey, value=1. 339 - // 340 - // The footgun: if `mykey` collides with a session name, a future 341 - // refactor might re-shuffle positional resolution. This test locks the 342 - // current rule in place so the ambiguity can't silently drift. 343 - const dir = makeSessionDir(); 344 - const inside = uniqueName(); 345 - const other = uniqueName(); 346 - await startDaemon(dir, inside); 347 - await startDaemon(dir, other); 348 - 349 - // With PTY_SESSION=inside, `state set <other's real name> mykey 1` 350 - // should target `other` (heuristic: first positional is a known session). 351 - const r = runCli(dir, { PTY_SESSION: inside }, "state", "set", other, "mykey", "1"); 352 - expect(r.status).toBe(0); 353 - 354 - // Wrong interpretation (key="other" on `inside`) would put { other: "mykey" } 355 - // somewhere. Verify that didn't happen. 356 - process.env.PTY_SESSION_DIR = dir; 357 - expect(getStateKey(other, "mykey")).toBe(1); 358 - expect(getState(inside)).toEqual({}); 359 - }, 20000); 360 - 361 - it("heuristic: an unknown-session-named first arg falls back to $PTY_SESSION and is treated as a key", async () => { 362 - // Mirror of the above: when the first positional is NOT a known session, 363 - // it's interpreted as the key instead. Same resolveStateTarget() path — 364 - // different branch. 365 - const dir = makeSessionDir(); 366 - const inside = uniqueName(); 367 - await startDaemon(dir, inside); 368 - 369 - // `notasession` isn't the name of any session in `dir`, so this becomes 370 - // `state set <inside> notasession "42"`. 371 - const r = runCli(dir, { PTY_SESSION: inside }, "state", "set", "notasession", "42"); 372 - expect(r.status).toBe(0); 373 - 374 - process.env.PTY_SESSION_DIR = dir; 375 - expect(getStateKey(inside, "notasession")).toBe(42); 376 - }, 15000); 377 - 378 - it("setState under in-process Promise.all: every update lands", async () => { 379 - // Node's main loop is single-threaded and setState is synchronous — 380 - // Promise.all serializes these on the event loop with no interleaving 381 - // between a read and its matching write. Any lost update here means a 382 - // subtle bug (e.g., async RMW leaking in) we want to catch immediately. 383 - const dir = makeSessionDir(); 384 - const name = uniqueName(); 385 - await startDaemon(dir, name); 386 - process.env.PTY_SESSION_DIR = dir; 387 - 388 - const KEYS = 20; 389 - await Promise.all( 390 - Array.from({ length: KEYS }, (_, i) => Promise.resolve().then(() => setState(name, `k${i}`, i))) 391 - ); 392 - 393 - const bag = getState(name); 394 - expect(Object.keys(bag).length).toBe(KEYS); 395 - for (let i = 0; i < KEYS; i++) { 396 - expect(bag[`k${i}`]).toBe(i); 397 - } 398 - }, 15000); 399 - }); 400 - 401 - describe("state helpers emit events from the programmatic API", () => { 402 - // Regression: pty-layout et al. use setState/deleteState via the client 403 - // API, not via the CLI. Prior to this fix, events only fired from the 404 - // CLI wrapper — programmatic callers got silent writes. 405 - it("setState emits state.set even when called directly (no CLI involved)", async () => { 406 - const dir = makeSessionDir(); 407 - const name = uniqueName(); 408 - await startDaemon(dir, name); 409 - process.env.PTY_SESSION_DIR = dir; 410 - 411 - setState(name, "port", 9000); 412 - 413 - const events = readEvents(dir, name); 414 - const set = events.find(e => e.type === "state.set" && e.key === "port"); 415 - expect(set).toBeTruthy(); 416 - expect(set.value).toBe(9000); 417 - }, 15000); 418 - 419 - it("deleteState emits state.delete when something was removed", async () => { 420 - const dir = makeSessionDir(); 421 - const name = uniqueName(); 422 - await startDaemon(dir, name); 423 - process.env.PTY_SESSION_DIR = dir; 424 - 425 - setState(name, "x", 1); 426 - const before = readEvents(dir, name).filter(e => e.type === "state.delete").length; 427 - const removed = deleteState(name, "x"); 428 - expect(removed).toBe(true); 429 - const after = readEvents(dir, name).filter(e => e.type === "state.delete").length; 430 - expect(after).toBe(before + 1); 431 - }, 15000); 432 - 433 - it("deleteState on a missing key writes no event and returns false", async () => { 434 - const dir = makeSessionDir(); 435 - const name = uniqueName(); 436 - await startDaemon(dir, name); 437 - process.env.PTY_SESSION_DIR = dir; 438 - 439 - const before = readEvents(dir, name).filter(e => e.type === "state.delete").length; 440 - const removed = deleteState(name, "never-existed"); 441 - expect(removed).toBe(false); 442 - const after = readEvents(dir, name).filter(e => e.type === "state.delete").length; 443 - expect(after).toBe(before); 444 - }, 15000); 445 - }); 446 - 447 - describe("EventFollower — state.* streaming", () => { 448 - it("delivers state.set and state.delete events live", async () => { 449 - const dir = makeSessionDir(); 450 - const name = uniqueName(); 451 - await startDaemon(dir, name); 452 - 453 - process.env.PTY_SESSION_DIR = dir; 454 - 455 - const received: EventRecord[] = []; 456 - const follower = new EventFollower({ 457 - names: [name], 458 - onEvent: (e) => { if (e.type === "state.set" || e.type === "state.delete") received.push(e); }, 459 - }); 460 - follower.start(); 461 - 462 - try { 463 - await new Promise((r) => setTimeout(r, 100)); 464 - 465 - // Drive mutations through the CLI so we test the actual shipping path, 466 - // not an internal shortcut that bypasses event emission. 467 - const s = runCli(dir, {}, "state", "set", name, "port", "3000"); 468 - expect(s.status).toBe(0); 469 - const d = runCli(dir, {}, "state", "delete", name, "port"); 470 - expect(d.status).toBe(0); 471 - 472 - const deadline = Date.now() + 2000; 473 - while (received.length < 2 && Date.now() < deadline) { 474 - await new Promise((r) => setTimeout(r, 50)); 475 - } 476 - 477 - expect(received.map(e => e.type)).toEqual(["state.set", "state.delete"]); 478 - expect((received[0] as any).key).toBe("port"); 479 - expect((received[0] as any).value).toBe(3000); 480 - expect((received[1] as any).key).toBe("port"); 481 - } finally { 482 - follower.stop(); 483 - } 484 - }, 20000); 485 - });