This repository has no description
0

Configure Feed

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

feat(gc): replace long-running supervisor with cron-driven `pty gc`

The launchd-managed `pty-supervisor` daemon raced /Volumes/SSD's mount
on Mac boot: permanent sessions whose `dist/server.js` lived under the
external volume failed restart with MODULE_NOT_FOUND, exhausted 5
retries x 2s backoff within 10s, then were marked "no metadata" and
never came back without manual `pty up`. A long-running supervisor will
always have this class of bug — it can't be sure its dependencies are
ready when launchd fires it.

Replace it with a stateless reconciliation pass:

pty gc

invoked periodically by launchd (`StartInterval=30` by default) or any
other cron-driver. Three steps:

1. Orphan-children: sessions tagged `parent=<name>` whose parent's
metadata is gone OR whose parent's pid isn't alive get SIGTERM'd
and cleaned up. New user-facing tag.
2. Permanent respawn: every `strategy=permanent` session that's
exited/vanished is respawned via `spawnDaemon`. Sessions tagged
`ptyfile` re-read pty.toml to pick up edits.
3. Sweep: exited/vanished non-permanent sessions get `cleanupAll`'d
(the historic gc behavior).

Each run is independent — no persistent restart bookkeeping, no
MAX_RESTARTS, no backoff state to drift out of sync with reality.
Cron interval IS the rate limit; if /Volumes/SSD isn't mounted, the
invocation exits with ENOENT and the next tick tries again.

Install helper: `pty gc --print-launchd-plist [--interval=N]` prints
a minimal plist to stdout. No FDA wrapper, no compiled C binary, no
esbuild bundling — `<ProgramArguments>[pty, gc]</ProgramArguments>`
with `<StartInterval>`. User redirects to ~/Library/LaunchAgents/
and `launchctl load`s themselves.

Breaking changes:

- Deleted `pty supervisor *` commands entirely. Users who installed
the old supervisor should `launchctl unload` + remove the old
plist (see CHANGELOG for the exact commands).
- Deleted event types `session_restart`, `session_failed`,
`supervisor_start`, `supervisor_stop`. Added `session_respawn`.
- Deleted reserved tag `supervisor.status`. Added user-facing tag
`parent=<name>`.
- Dropped `strategy=temporary` (was functionally identical to no
strategy tag under the new sweep behavior).
- `gc()`'s return shape changed from `string[]` to a structured
`GcResult` with four buckets (`removed`, `killedOrphanChildren`,
`respawned`, `respawnFailed`). Public API.

Net delta: ~1830 lines deleted, ~485 added.

Nathan Herald (Jun 30, 2026, 4:46 PM +0200) 77bccb19 6fd5c4ef

+945 -2323
+21
CHANGELOG.md
··· 2 2 3 3 ## Unreleased 4 4 5 + ### Breaking changes — supervisor replaced by `pty gc` 6 + - **Removed the long-running `pty supervisor` command and the launchd FDA wrapper.** All `pty supervisor *` subcommands are gone (`start`, `stop`, `status`, `forget`, `reset`, `launchd install/uninstall`, `systemd install/uninstall`, `runit install/uninstall`). The bundled `src/supervisor.ts`, `src/supervisor-entry.ts`, and `scripts/supervisor-wrapper.c` are deleted. So are the three supervisor test files (`tests/supervisor.test.ts`, `tests/supervisor-hardening.test.ts`, `tests/supervisor-service-install.test.ts`). Net: about 600 lines of code and a separate long-lived process removed. 7 + - **`pty gc` now does the supervisor's work**, statelessly, as a one-shot reconciliation pass. Every invocation re-derives intent from on-disk metadata; there's no in-memory restart counter, no persisted bookkeeping, no backoff state, no `[failed]` mark. Three steps run in order on each call: (1) walk sessions with a `parent=<name>` tag and SIGTERM + clean up any whose parent is gone; (2) respawn every `strategy=permanent` session that's exited or vanished (re-reading `pty.toml` when the session is toml-managed); (3) sweep exited/vanished non-permanent sessions (the historic `pty gc` behavior). The intended deployment is to run it on a short cron (default 30 s); failures (binary not on PATH, volume not mounted at boot) are non-events because the next tick tries again. This fixes the boot-time mount race where a supervised session under `/Volumes/SSD` would exhaust its 5-retry budget within 10 s of launchd firing before the volume even mounted. 8 + - **New tag `parent=<name>`** — user-facing, not reserved. When `pty gc` notices the parent's daemon is gone (metadata removed OR pid file gone OR process dead), it SIGTERMs the child and `cleanupAll`s it. Combinator with `strategy=permanent` is well-defined: orphan-kill wins (the child is removed, not respawned). Cycles (A→B, B→A) resolve deterministically by name-sorted iteration. 9 + - **New event `session_respawn`.** Emitted to a session's `<name>.events.jsonl` whenever `pty gc` respawns it. No payload beyond the envelope — the restart is stateless. Replaces the old `session_restart` (which carried `restartCount` / `backoffMs`). 10 + - **Removed event types**: `session_restart`, `session_failed`, `supervisor_start`, `supervisor_stop`. Their corresponding TypeScript types (`SessionRestartEvent`, `SessionFailedEvent`, `SupervisorStartEvent`, `SupervisorStopEvent`) are no longer exported from `@myobie/pty/client`. 11 + - **Removed reserved tag `supervisor.status`.** It was only used to mark a session `[failed]` after exhausting retries — that concept doesn't exist anymore. `isReservedTagKey("supervisor.status")` now returns `false`. 12 + - **Removed `strategy=temporary`.** It was a no-op identical to the default sweep path. Sessions previously tagged that way will fall through `pty gc`'s exited-sweep on the next tick. 13 + - **New `pty gc --print-launchd-plist [--interval=N]`** — emits a macOS launchd plist to stdout that runs `pty gc` every N seconds (default 30). Pure stdout; no FDA wrapper, no compiled binary, no bundling. Install it yourself: `pty gc --print-launchd-plist > ~/Library/LaunchAgents/com.myobie.pty.gc.plist && launchctl load ~/Library/LaunchAgents/com.myobie.pty.gc.plist`. Other systems use one-liners documented in the README (cron, systemd-timer, runit) — no equivalent helper flags for those in v1. 14 + - **New `pty gc --dry-run` output buckets.** The CLI now prints up to four sections per pass: `Killed orphan child:`, `Respawned:`, `Respawn failed:`, `Removed:`, plus the existing `Pruned orphan tags on … :` line. `--dry-run` mirrors with `Would kill orphan child:`, `Would respawn:`, `Would remove:`. 15 + - **`gc()` client API** now returns `GcResult` (`{ removed, killedOrphanChildren, respawned, respawnFailed }`) instead of `string[]`. `pruneOrphanLayoutTags` is unchanged. New `GcResult` type exported from `@myobie/pty/client`. 16 + - **Migration** — if you installed via `pty supervisor launchd install`: 17 + ```sh 18 + launchctl unload ~/Library/LaunchAgents/com.myobie.pty.supervisor.plist 19 + rm ~/Library/LaunchAgents/com.myobie.pty.supervisor.plist 20 + rm -rf ~/.local/pty/launchd ~/.local/state/pty/supervisor 21 + pty gc --print-launchd-plist > ~/Library/LaunchAgents/com.myobie.pty.gc.plist 22 + launchctl load ~/Library/LaunchAgents/com.myobie.pty.gc.plist 23 + ``` 24 + systemd users: `systemctl --user disable --now pty-supervisor.service && rm ~/.config/systemd/user/pty-supervisor.service && systemctl --user daemon-reload`, then install a cron / timer that runs `pty gc`. runit users: remove the symlink under `~/.config/runit/service` and the service dir under `~/.config/runit/sv`, then add a `run` script that loops `pty gc` with `sleep 30`. 25 + 5 26 ### Breaking: on-disk name decoupled from display label 6 27 - **The session `name` (sock + json filename) is now always a short identifier, separate from the user-visible display label.** Before: `pty run --name X -- cmd` made `X` the on-disk identifier (sock file = `X.sock`); pty.toml sessions used `<prefix>-<sessionKey>` as the on-disk name. The result: long pretty labels hit macOS's 104-byte `sockaddr_un.sun_path` limit (~68 chars of usable name with the default session dir), so Nathan's friend trying to give sessions long descriptive names ran straight into `EINVAL`/`ENAMETOOLONG` in spawn. Now: the on-disk name is always a short Crockford-base32 id (8 chars) unless explicitly pinned, and the display label is a separate `displayName` field with permissive validation (≤ 500 chars, any printable text except `/ \ \0 \n \r \t` and control bytes). 7 28 - **`pty run` flag rename: `--name <X>` now sets the displayName; the on-disk id is set by `--id <X>`.** Old `pty run --name long-pretty-label -- cmd` silently broke on long values; new `pty run --name "My Long Pretty Label" -- cmd` works. To pin an explicit on-disk id (for deterministic scripting / reproducible automation), use `pty run --id <id>`. Both omitted → random id + auto-generated displayName. Both can be combined: `pty run --id svc --name "My Service" -- cmd`. The id is validated up front (charset, sock-path length, uniqueness) so automation fails loudly rather than producing a cryptic `EINVAL` deep in spawn. `--no-display-name` still works for "id only, no label."
+47 -28
README.md
··· 111 111 pty restart myserver # restart an exited session 112 112 pty kill myserver # terminate a running session 113 113 pty rm myserver # remove an exited session's metadata 114 - pty gc # remove all exited sessions 114 + pty gc # reconcile sessions: kill orphan children, respawn permanents, sweep exited 115 + pty gc --dry-run # preview what gc would do without changing anything 116 + pty gc --print-launchd-plist > ~/Library/LaunchAgents/com.myobie.pty.gc.plist # install macOS auto-gc 115 117 pty tag myserver role=web env=prod # set one or more tags on a session 116 118 pty tag myserver --rm role --rm env # remove one or more tags 117 119 pty tag-multi --filter-tag role=web env=prod # bulk write across matching sessions 118 120 pty tag-multi --all --json # bulk read tags across every session 119 121 pty tag-multi --all --yes audit=today # write to every session (--yes required) 120 - 121 - pty supervisor start # start the session supervisor 122 - pty supervisor stop # stop the supervisor 123 - pty supervisor status # show supervised sessions 124 - pty supervisor forget myserver # stop supervising a session 125 - pty supervisor reset myserver # reset a failed session for retry 126 - pty supervisor launchd install # install launchd auto-start (macOS) 127 - pty supervisor systemd install # install user-level systemd auto-start (Linux) 128 - pty supervisor runit install # install runit service files 129 122 130 123 pty up # start all sessions from ./pty.toml 131 124 pty up ./backend # start sessions from ./backend/pty.toml ··· 222 215 LOG_LEVEL = "debug" 223 216 ``` 224 217 225 - The values are exported into the session's shell before the command runs (the supervisor wraps every session command in `/bin/sh -c`). They take effect on the next `pty up` after the session has stopped — restarting a still-running session via `pty restart` reuses the existing spawn args, so `pty kill <name>` followed by `pty up` is the way to pick up a changed env block on an already-running session. 218 + The values are exported into the session's shell before the command runs — `pty up` wraps every toml-managed session in `/bin/sh -c` so the `export K='V'; …` prefix is honored. They take effect on the next `pty up` after the session has stopped — restarting a still-running session via `pty restart` reuses the existing spawn args, so `pty kill <name>` followed by `pty up` is the way to pick up a changed env block on an already-running session. 226 219 227 - ### Supervisor 220 + ### Permanent sessions 228 221 229 - The supervisor keeps sessions alive by watching for the `strategy` tag: 222 + Tag a session with `strategy=permanent` and `pty gc` will respawn it whenever its daemon exits or vanishes: 230 223 231 224 ```sh 232 - # Tag a session as permanent 233 225 pty tag myserver strategy=permanent 234 226 235 - # Start the supervisor 236 - pty supervisor start 237 - 238 - # If myserver exits, the supervisor restarts it with exponential backoff 239 - # Max 5 restarts in 60 seconds before marking as [failed] 240 - 241 - pty supervisor status # show supervised sessions 242 - pty supervisor forget myserver # stop supervising 243 - pty supervisor stop # stop the supervisor 227 + # After myserver exits — manually or by crash — the next `pty gc` run 228 + # brings it back. No backoff, no retry budget; the cron interval below 229 + # is the rate limit. Sessions managed by pty.toml re-read the toml on 230 + # respawn so command/env edits take effect immediately. 231 + pty gc 244 232 ``` 245 233 246 - Sessions can be supervised from `pty.toml` by setting the `strategy` tag: 234 + From `pty.toml`: 247 235 248 236 ```toml 249 237 [sessions.serve] ··· 251 239 tags = { strategy = "permanent" } 252 240 ``` 253 241 254 - For auto-start: 242 + Restart is stateless — every `pty gc` invocation re-derives intent from on-disk metadata. There's no in-memory restart counter, no `[failed]` state, no persisted bookkeeping. If a session's binary isn't reachable (volume not mounted, broken symlink), `pty gc` reports `Respawn failed:` and the next tick tries again. 255 243 256 - - macOS: `pty supervisor launchd install` — compiles a small wrapper binary and prompts for Full Disk Access (required for sessions on external/removable volumes) 257 - - Linux/systemd: `pty supervisor systemd install` — installs a user service in `~/.config/systemd/user/` and enables it immediately. If you want it to start at boot before login, enable linger with `sudo loginctl enable-linger $USER`. 258 - - runit: `pty supervisor runit install` — writes a `run` script and symlinkable service directory. By default it uses `~/.config/runit/{sv,service}`; on systems like Void you can point it at `/etc/sv` and `/var/service`. 244 + ### Parent-child sessions 245 + 246 + Tag a session with `parent=<name>` and `pty gc` will SIGTERM it (and clean up its metadata) when the referenced parent's daemon is no longer alive — useful for sidecar workers that shouldn't outlive their primary: 247 + 248 + ```sh 249 + pty run -d --name webserver -- bin/serve 250 + pty run -d --name webserver-tail --tag parent=webserver -- tail -f log/web.log 251 + # If `webserver` dies, the next `pty gc` SIGTERMs `webserver-tail`. 252 + ``` 253 + 254 + What triggers the kill: the parent's metadata file is gone OR the parent's pid file is gone OR the parent's process isn't alive. What doesn't: the parent's exit code, the parent's `exitedAt` timestamp. Combinator with `strategy=permanent` is well-defined — orphan-kill wins (the child is removed, not respawned). 255 + 256 + Cycles (A→B, B→A) resolve deterministically by name-sorted iteration: whichever name sorts first dies first on the tick where both parents are gone; the loser dies the same tick because its parent (the just-killed winner) is also dead. No cycle detection needed. 257 + 258 + ### Auto-running gc 259 + 260 + `pty gc` is a one-shot reconciliation pass. The intended deployment is to run it on a short interval so permanent sessions come back quickly and orphans get cleaned promptly. The CLI ships an install helper for macOS: 261 + 262 + ```sh 263 + pty gc --print-launchd-plist > ~/Library/LaunchAgents/com.myobie.pty.gc.plist 264 + launchctl load ~/Library/LaunchAgents/com.myobie.pty.gc.plist 265 + ``` 266 + 267 + Default interval is 30 seconds; tune with `pty gc --print-launchd-plist --interval=15` etc. Output goes to `~/.local/state/pty/gc.log`. 268 + 269 + Statelessness is the whole point of running it on a cron rather than as a long-lived daemon. At boot, if the volume containing the `pty` binary isn't mounted yet, the invocation fails — the next tick tries again. The historic long-running supervisor would burn through its 5-retry budget in the first 10 seconds of boot and never come back; this design just shrugs and reconciles on the next tick. 270 + 271 + For other systems: 272 + 273 + - Linux + cron: `* * * * * pty gc >> ~/.local/state/pty/gc.log 2>&1` (one-minute resolution; tune to taste). 274 + - Linux + systemd-timer: `OnUnitActiveSec=30s` on a `pty.gc.service` that `ExecStart=pty gc`. 275 + - runit: a `run` script that loops `pty gc` with a `sleep 30` between iterations. 276 + 277 + The macOS `pty gc --print-launchd-plist` helper is the only one bundled today; the others are one-liners and easy enough to write yourself. File an issue if you'd like a built-in install command for systemd/runit. 259 278 260 279 ### Plugins 261 280
+4 -11
completions/pty.bash
··· 6 6 COMPREPLY=() 7 7 cur="${COMP_WORDS[COMP_CWORD]}" 8 8 prev="${COMP_WORDS[COMP_CWORD-1]}" 9 - commands="run attach a exec peek send events list ls stats restart kill rm remove gc tag supervisor up down wrap unwrap test help" 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 10 11 11 # Complete subcommand 12 12 if [[ ${COMP_CWORD} -eq 1 ]]; then ··· 41 41 fi 42 42 ;; 43 43 gc) 44 - # No arguments 44 + if [[ "${cur}" == -* ]]; then 45 + COMPREPLY=($(compgen -W "--dry-run -n --print-launchd-plist --interval" -- "${cur}")) 46 + fi 45 47 ;; 46 48 wrap) 47 49 if [[ "${cur}" == -* ]]; then ··· 71 73 # Before --, complete flags 72 74 if [[ "${cur}" == -* ]]; then 73 75 COMPREPLY=($(compgen -W "--detach -d --attach -a --ephemeral -e --name --cwd --tag" -- "${cur}")) 74 - fi 75 - ;; 76 - supervisor) 77 - if [[ ${COMP_CWORD} -eq 2 ]]; then 78 - COMPREPLY=($(compgen -W "start stop status forget reset launchd" -- "${cur}")) 79 - elif [[ "${COMP_WORDS[2]}" == "forget" || "${COMP_WORDS[2]}" == "reset" ]]; then 80 - COMPREPLY=($(compgen -W "${names}" -- "${cur}")) 81 - elif [[ "${COMP_WORDS[2]}" == "launchd" ]]; then 82 - COMPREPLY=($(compgen -W "install uninstall" -- "${cur}")) 83 76 fi 84 77 ;; 85 78 esac
+5 -4
completions/pty.fish
··· 58 58 complete -c pty -n __pty_needs_command -a kill -d 'Kill a running session' 59 59 complete -c pty -n __pty_needs_command -a rm -d 'Remove an exited session' 60 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 'Remove all exited sessions' 61 + complete -c pty -n __pty_needs_command -a gc -d 'Reconcile sessions (kill orphan children, respawn permanents, sweep exited)' 62 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 supervisor -d 'Manage the session supervisor' 64 63 complete -c pty -n __pty_needs_command -a up -d 'Start sessions from pty.toml' 65 64 complete -c pty -n __pty_needs_command -a down -d 'Stop sessions from pty.toml' 66 65 complete -c pty -n __pty_needs_command -a wrap -d 'Auto-wrap a command in pty sessions' ··· 137 136 complete -c pty -n '__pty_using_command tag' -a '(__pty_sessions)' -d 'Session' 138 137 complete -c pty -n '__pty_using_command tag' -l rm -d 'Remove a tag' 139 138 140 - # supervisor: subcommands 141 - complete -c pty -n '__pty_using_command supervisor' -a 'start stop status forget reset launchd' -d 'Subcommand' 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)' 142 143
+5 -23
completions/pty.zsh
··· 38 38 'kill:Kill a running session' 39 39 'rm:Remove an exited session' 40 40 'remove:Remove an exited session' 41 - 'gc:Remove all exited sessions' 41 + 'gc:Reconcile sessions (kill orphan children, respawn permanents, sweep exited)' 42 42 'tag:Show or modify session tags' 43 - 'supervisor:Manage the session supervisor' 44 43 'up:Start sessions from pty.toml' 45 44 'down:Stop sessions from pty.toml' 46 45 'wrap:Auto-wrap a command in pty sessions' ··· 108 107 '--tags[Show tags as #key=value]' 109 108 ;; 110 109 gc) 111 - # No arguments 110 + _arguments \ 111 + '(-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:' 112 114 ;; 113 115 tag) 114 116 _arguments '1:session:_pty_sessions' 115 - ;; 116 - supervisor) 117 - local -a subcmds 118 - subcmds=( 119 - 'start:Start the supervisor' 120 - 'stop:Stop the supervisor' 121 - 'status:Show supervised sessions' 122 - 'forget:Stop supervising a session' 123 - 'reset:Reset a failed session for retry' 124 - 'launchd:Register/unregister with macOS launchd' 125 - ) 126 - _arguments '1:subcommand:->subcmd' '*::arg:->subargs' 127 - case $state in 128 - subcmd) _describe 'subcommand' subcmds ;; 129 - subargs) 130 - case ${words[1]} in 131 - forget|reset) _arguments '1:session:_pty_sessions' ;; 132 - launchd) _arguments '1:action:(install uninstall)' ;; 133 - esac ;; 134 - esac 135 117 ;; 136 118 wrap) 137 119 _arguments \
+2
docs/SKILL.md
··· 15 15 16 16 ## Session lifecycle 17 17 18 + `strategy=permanent` sessions are restarted by `pty gc` (typically a launchd `StartInterval=30` task — `pty gc --print-launchd-plist > ~/Library/LaunchAgents/com.myobie.pty.gc.plist`). Restarts are stateless — no backoff tracking, no retry budget. Expect up to one interval of latency before a session comes back. 19 + 18 20 ### 1. Create a detached session 19 21 20 22 ```bash
+6 -6
docs/disk-layout.md
··· 16 16 | `<name>.pid` | daemon pid (decimal) | 2 | 17 17 | `<name>.lock` | creation-race lock | 2 | 18 18 | `theme` | last-selected TUI theme | 2 | 19 - | `supervisor/state.json` | supervisor state | 2 (internal) | 19 + | `gc.log` | stdout/stderr of `pty gc` when run by launchd/cron (only present after auto-running gc is installed) | 2 | 20 20 | `<name>.json.tmp.<pid>.<rand>` | atomic-write tmp — readers MUST ignore | n/a | 21 21 | `<name>.events.jsonl.tmp.<pid>.<rand>` | same | n/a | 22 22 ··· 48 48 ``` 49 49 50 50 - Status (`running` / `exited` / `vanished`) is *derived* from socket + pid, not stored. 51 - - Reserved tag keys (`ptyfile*`, `strategy`, `supervisor.status`, anything starting with `:`) are pty/tool-internal; hidden from `pty list` unless `--tags`. 51 + - Reserved tag keys (`ptyfile*`, `strategy`, anything starting with `:`) are pty/tool-internal; hidden from `pty list` unless `--tags`. 52 + - User-facing tags that drive pty behavior but are visible by default: 53 + - `strategy=permanent` — `pty gc` respawns the session when its daemon exits (the historic supervisor's role; now stateless and run on a cron). 54 + - `parent=<name>` — `pty gc` orphan-kills this session (SIGTERM + cleanup) when the referenced session's daemon is no longer alive. Combinator with `strategy=permanent` is well-defined: orphan-kill wins. 52 55 - Concurrent writers: last-write-wins; readers never see torn files. Cross-process writers can lose updates to the read-modify-write window. 53 56 54 57 ## `<name>.events.jsonl` (tier 1) ··· 67 70 | `session_start` | `tags?` | 68 71 | `session_exit` | `exitCode` | 69 72 | `session_exec` | `previousCommand, command` | 70 - | `session_restart` | `restartCount, backoffMs` | 71 - | `session_failed` | `restartCount, reason` | 72 - | `supervisor_start` | — | 73 - | `supervisor_stop` | — | 73 + | `session_respawn` | — (`pty gc` respawned a `strategy=permanent` session) | 74 74 | `display_name_change` | `previous: string\|null, value: string\|null` | 75 75 | `tags_change` | `previous, value` (full snapshots) | 76 76 | `state.set` | `key, value` |
-129
scripts/supervisor-wrapper.c
··· 1 - /* 2 - * FDA wrapper for the pty supervisor. 3 - * Compiled during `pty supervisor launchd install` with paths and PATH 4 - * baked in via -D flags. Grant this binary Full Disk Access so the 5 - * supervisor (and sessions it spawns) can access external/removable volumes. 6 - * 7 - * Usage: 8 - * pty-supervisor Run the supervisor (exec node with bundle) 9 - * pty-supervisor --check Validate FDA, node binary, bundle file, and PATH 10 - * 11 - * Compile: 12 - * cc -O2 -o pty-supervisor \ 13 - * -DNODE_PATH='"/path/to/node"' \ 14 - * -DBUNDLE_PATH='"/path/to/supervisor.bundle.js"' \ 15 - * -DUSER_PATH='"..."' \ 16 - * scripts/supervisor-wrapper.c 17 - */ 18 - #include <unistd.h> 19 - #include <stdio.h> 20 - #include <stdlib.h> 21 - #include <string.h> 22 - 23 - #ifndef NODE_PATH 24 - #error "NODE_PATH must be defined at compile time" 25 - #endif 26 - 27 - #ifndef BUNDLE_PATH 28 - #error "BUNDLE_PATH must be defined at compile time" 29 - #endif 30 - 31 - #ifndef USER_PATH 32 - #error "USER_PATH must be defined at compile time" 33 - #endif 34 - 35 - /* Test FDA by reading a TCC-protected file */ 36 - static int check_fda(void) { 37 - const char *home = getenv("HOME"); 38 - if (!home) { 39 - fprintf(stderr, " ✗ HOME not set\n"); 40 - return 0; 41 - } 42 - char path[1024]; 43 - snprintf(path, sizeof(path), "%s/Library/Safari/History.db", home); 44 - if (access(path, R_OK) == 0) { 45 - return 1; 46 - } 47 - return 0; 48 - } 49 - 50 - static int check_file(const char *label, const char *path) { 51 - if (access(path, R_OK) == 0) { 52 - printf(" ✓ %s: %s\n", label, path); 53 - return 1; 54 - } 55 - fprintf(stderr, " ✗ %s not found: %s\n", label, path); 56 - return 0; 57 - } 58 - 59 - static int check_executable(const char *label, const char *path) { 60 - if (access(path, X_OK) == 0) { 61 - printf(" ✓ %s: %s\n", label, path); 62 - return 1; 63 - } 64 - fprintf(stderr, " ✗ %s not executable: %s\n", label, path); 65 - return 0; 66 - } 67 - 68 - int main(int argc, char *argv[]) { 69 - /* Set PATH before anything else so child processes can find commands */ 70 - setenv("PATH", USER_PATH, 1); 71 - 72 - int check_mode = 0; 73 - for (int i = 1; i < argc; i++) { 74 - if (strcmp(argv[i], "--check") == 0) { 75 - check_mode = 1; 76 - } 77 - } 78 - 79 - if (check_mode) { 80 - printf("pty supervisor wrapper check\n\n"); 81 - int ok = 1; 82 - 83 - if (!check_executable("node", NODE_PATH)) ok = 0; 84 - if (!check_file("bundle", BUNDLE_PATH)) ok = 0; 85 - printf(" ✓ PATH: set (%zu chars)\n", strlen(USER_PATH)); 86 - 87 - if (check_fda()) { 88 - printf(" ✓ Full Disk Access: granted\n"); 89 - } else { 90 - fprintf(stderr, " ✗ Full Disk Access: not granted\n"); 91 - fprintf(stderr, "\n"); 92 - fprintf(stderr, "Grant Full Disk Access to this binary:\n"); 93 - fprintf(stderr, " System Settings > Privacy & Security > Full Disk Access\n"); 94 - fprintf(stderr, " Add: %s\n", argv[0]); 95 - ok = 0; 96 - } 97 - 98 - printf("\n"); 99 - if (ok) { 100 - printf("All checks passed.\n"); 101 - return 0; 102 - } else { 103 - fprintf(stderr, "Some checks failed.\n"); 104 - return 1; 105 - } 106 - } 107 - 108 - /* Normal mode: validate before exec */ 109 - if (access(NODE_PATH, X_OK) != 0) { 110 - fprintf(stderr, "[pty-supervisor] node not found: %s\n", NODE_PATH); 111 - return 1; 112 - } 113 - if (access(BUNDLE_PATH, R_OK) != 0) { 114 - fprintf(stderr, "[pty-supervisor] bundle not found: %s\n", BUNDLE_PATH); 115 - return 1; 116 - } 117 - if (!check_fda()) { 118 - fprintf(stderr, "[pty-supervisor] Full Disk Access not granted.\n"); 119 - fprintf(stderr, "[pty-supervisor] Grant FDA to: %s\n", argv[0]); 120 - fprintf(stderr, "[pty-supervisor] System Settings > Privacy & Security > Full Disk Access\n"); 121 - return 1; 122 - } 123 - 124 - char *exec_argv[] = { NODE_PATH, BUNDLE_PATH, NULL }; 125 - execv(NODE_PATH, exec_argv); 126 - /* execv only returns on error */ 127 - perror("[pty-supervisor] execv"); 128 - return 1; 129 - }
+130 -723
src/cli.ts
··· 35 35 emitUserEvent, 36 36 } from "./events.ts"; 37 37 import { readPtyFile, commandWithEnvExports, type PtySessionDef } from "./ptyfile.ts"; 38 - import { getSupervisorDir } from "./supervisor.ts"; 39 38 import { extractFilterTags as extractFilterTagsImpl, matchesAllTags, isReservedTagKey } from "./tags.ts"; 40 39 import { parseDuration, formatDuration } from "./duration.ts"; 41 40 ··· 117 116 pty tag <name> key=value [key=value...] Set tags 118 117 pty tag <name> --rm key [--rm key...] Remove tags 119 118 pty tag-multi <selector> [ops...] Read/write tags across multiple sessions (--all / --filter-tag k=v / <name>...) 120 - pty supervisor start Start the session supervisor 121 - pty supervisor stop Stop the supervisor 122 - pty supervisor status Show supervised sessions 123 - pty supervisor forget <name> Stop supervising a session 124 - pty supervisor reset <name> Reset a failed session for retry 125 - pty supervisor launchd install [--path PATH] Register with macOS launchd 126 - pty supervisor launchd uninstall Remove launchd registration 127 - pty supervisor systemd install [--name NAME] [--path PATH] Register with user systemd 128 - pty supervisor systemd uninstall [--name NAME] Remove user systemd registration 129 - pty supervisor runit install [--name NAME] [--svdir PATH] [--service-dir PATH] [--path PATH] Register with runit 130 - pty supervisor runit uninstall [--name NAME] [--svdir PATH] [--service-dir PATH] Remove runit registration 119 + pty gc --print-launchd-plist [--interval=N] Print a launchd plist that runs 'pty gc' every N seconds (default 30) 131 120 pty wrap <command> Auto-wrap a command in pty sessions 132 121 pty unwrap <command> Remove a wrap 133 122 pty wrap --list List wrapped commands ··· 737 726 } 738 727 739 728 case "gc": { 740 - const dryRun = args.slice(1).some((a) => a === "--dry-run" || a === "-n"); 729 + const gcArgs = args.slice(1); 730 + const dryRun = gcArgs.some((a) => a === "--dry-run" || a === "-n"); 731 + const printPlist = gcArgs.includes("--print-launchd-plist"); 732 + let interval = 30; 733 + for (let i = 0; i < gcArgs.length; i++) { 734 + const a = gcArgs[i]; 735 + if (a === "--interval" && i + 1 < gcArgs.length) { 736 + const v = parseInt(gcArgs[i + 1], 10); 737 + if (!Number.isFinite(v) || v <= 0) { 738 + console.error(`pty gc: --interval expects a positive integer (got "${gcArgs[i + 1]}")`); 739 + process.exit(1); 740 + } 741 + interval = v; 742 + i++; 743 + } else if (a.startsWith("--interval=")) { 744 + const v = parseInt(a.slice("--interval=".length), 10); 745 + if (!Number.isFinite(v) || v <= 0) { 746 + console.error(`pty gc: --interval expects a positive integer (got "${a.slice("--interval=".length)}")`); 747 + process.exit(1); 748 + } 749 + interval = v; 750 + } 751 + } 752 + if (printPlist) { 753 + printLaunchdPlist(interval); 754 + break; 755 + } 741 756 await cmdGc(dryRun); 742 757 break; 743 758 } ··· 905 920 break; 906 921 } 907 922 908 - case "supervisor": { 909 - const subCmd = args[1]; 910 - if (!subCmd || subCmd === "-h" || subCmd === "--help") { 911 - console.log(`Usage: 912 - pty supervisor start Start the supervisor 913 - pty supervisor stop Stop the supervisor 914 - pty supervisor status Show supervised sessions 915 - pty supervisor forget <name> Stop supervising a session 916 - pty supervisor reset <name> Reset a failed session for retry 917 - pty supervisor launchd install [--path PATH] Register with macOS launchd (requires FDA) 918 - pty supervisor launchd uninstall Remove from launchd 919 - pty supervisor systemd install [--name NAME] [--path PATH] Register with user systemd 920 - pty supervisor systemd uninstall [--name NAME] Remove from user systemd 921 - pty supervisor runit install [--name NAME] [--svdir PATH] [--service-dir PATH] [--path PATH] Register with runit 922 - pty supervisor runit uninstall [--name NAME] [--svdir PATH] [--service-dir PATH] Remove from runit`); 923 - break; 924 - } 925 - switch (subCmd) { 926 - case "start": 927 - await cmdSupervisorStart(); 928 - break; 929 - case "stop": 930 - await cmdSupervisorStop(); 931 - break; 932 - case "status": 933 - await cmdSupervisorStatus(); 934 - break; 935 - case "forget": { 936 - const forgetName = args[2]; 937 - if (!forgetName) { 938 - console.error("Usage: pty supervisor forget <name>"); 939 - process.exit(1); 940 - } 941 - const resolvedForgetName = await resolveRef(forgetName); 942 - await cmdSupervisorForget(resolvedForgetName); 943 - break; 944 - } 945 - case "reset": { 946 - const resetName = args[2]; 947 - if (!resetName) { 948 - console.error("Usage: pty supervisor reset <name>"); 949 - process.exit(1); 950 - } 951 - const resolvedResetName = await resolveRef(resetName); 952 - await cmdSupervisorReset(resolvedResetName); 953 - break; 954 - } 955 - case "launchd": { 956 - const launchdCmd = args[2]; 957 - if (launchdCmd === "install") { 958 - let userPath: string | undefined; 959 - for (let li = 3; li < args.length; li++) { 960 - if (args[li] === "--path" && li + 1 < args.length) { 961 - userPath = args[li + 1]; 962 - break; 963 - } 964 - } 965 - await cmdSupervisorLaunchdInstall(userPath); 966 - } else if (launchdCmd === "uninstall") { 967 - cmdSupervisorLaunchdUninstall(); 968 - } else { 969 - console.error("Usage: pty supervisor launchd install|uninstall"); 970 - process.exit(1); 971 - } 972 - break; 973 - } 974 - case "systemd": { 975 - const systemdCmd = args[2]; 976 - let unitName: string | undefined; 977 - let userPath: string | undefined; 978 - for (let si = 3; si < args.length; si++) { 979 - if (args[si] === "--name" && si + 1 < args.length) { 980 - unitName = args[++si]; 981 - } else if (args[si] === "--path" && si + 1 < args.length) { 982 - userPath = args[++si]; 983 - } 984 - } 985 - if (systemdCmd === "install") { 986 - cmdSupervisorSystemdInstall(unitName, userPath); 987 - } else if (systemdCmd === "uninstall") { 988 - cmdSupervisorSystemdUninstall(unitName); 989 - } else { 990 - console.error("Usage: pty supervisor systemd install|uninstall"); 991 - process.exit(1); 992 - } 993 - break; 994 - } 995 - case "runit": { 996 - const runitCmd = args[2]; 997 - let serviceName: string | undefined; 998 - let svDir: string | undefined; 999 - let serviceDir: string | undefined; 1000 - let userPath: string | undefined; 1001 - for (let ri = 3; ri < args.length; ri++) { 1002 - if (args[ri] === "--name" && ri + 1 < args.length) { 1003 - serviceName = args[++ri]; 1004 - } else if (args[ri] === "--svdir" && ri + 1 < args.length) { 1005 - svDir = args[++ri]; 1006 - } else if (args[ri] === "--service-dir" && ri + 1 < args.length) { 1007 - serviceDir = args[++ri]; 1008 - } else if (args[ri] === "--path" && ri + 1 < args.length) { 1009 - userPath = args[++ri]; 1010 - } 1011 - } 1012 - if (runitCmd === "install") { 1013 - cmdSupervisorRunitInstall(serviceName, svDir, serviceDir, userPath); 1014 - } else if (runitCmd === "uninstall") { 1015 - cmdSupervisorRunitUninstall(serviceName, svDir, serviceDir); 1016 - } else { 1017 - console.error("Usage: pty supervisor runit install|uninstall"); 1018 - process.exit(1); 1019 - } 1020 - break; 1021 - } 1022 - default: 1023 - console.error(`Unknown supervisor command: ${subCmd}`); 1024 - process.exit(1); 1025 - } 1026 - break; 1027 - } 1028 - 1029 923 case "wrap": { 1030 924 if (args[1] === "--list" || args[1] === "-l") { 1031 925 cmdWrapList(); ··· 1817 1711 process.exit(1); 1818 1712 } 1819 1713 1820 - // Remove supervision tags so the supervisor doesn't restart it 1821 - const wasSupervised = session.metadata?.tags?.strategy === "permanent" || session.metadata?.tags?.strategy === "temporary"; 1822 - if (wasSupervised) { 1714 + // Strip the `strategy` tag so `pty gc` doesn't respawn the session on 1715 + // its next tick. The `supervisor.status` tag is no longer a thing. 1716 + const wasPermanent = session.metadata?.tags?.strategy === "permanent"; 1717 + if (wasPermanent) { 1823 1718 try { 1824 - const removals = ["strategy"]; 1825 - if (session.metadata?.tags?.["supervisor.status"]) removals.push("supervisor.status"); 1826 - updateTags(name, {}, removals); 1719 + updateTags(name, {}, ["strategy"]); 1827 1720 } catch {} 1828 1721 } 1829 1722 ··· 1835 1728 } 1836 1729 cleanupSocket(name); 1837 1730 1838 - if (wasSupervised && session.metadata?.tags?.ptyfile) { 1731 + if (wasPermanent && session.metadata?.tags?.ptyfile) { 1839 1732 console.error(`Note: this session is managed by ${session.metadata.tags.ptyfile}`); 1840 1733 console.error("The strategy tag will be restored on the next 'pty up'."); 1841 1734 } ··· 1995 1888 } 1996 1889 1997 1890 async function cmdGc(dryRun: boolean): Promise<void> { 1998 - const removed = await gc({ dryRun }); 1891 + const result = await gc({ dryRun }); 1999 1892 const prunedTags = await pruneOrphanLayoutTags({ dryRun }); 2000 1893 2001 - const verb = dryRun ? "Would remove" : "Removed"; 1894 + const killedVerb = dryRun ? "Would kill orphan child" : "Killed orphan child"; 1895 + const respawnVerb = dryRun ? "Would respawn" : "Respawned"; 1896 + const removeVerb = dryRun ? "Would remove" : "Removed"; 2002 1897 const prunedVerb = dryRun ? "Would prune" : "Pruned"; 2003 1898 2004 - for (const name of removed) { 2005 - console.log(`${verb}: ${name}`); 1899 + for (const k of result.killedOrphanChildren) { 1900 + console.log(`${killedVerb}: ${k.name} (parent ${k.parent} ${k.reason})`); 1901 + } 1902 + for (const r of result.respawned) { 1903 + const note = r.ptyfileReread ? " (pty.toml re-read)" : ""; 1904 + console.log(`${respawnVerb}: ${r.name}${note}`); 1905 + } 1906 + for (const f of result.respawnFailed) { 1907 + console.log(`Respawn failed: ${f.name} — ${f.error}`); 1908 + } 1909 + for (const name of result.removed) { 1910 + console.log(`${removeVerb}: ${name}`); 2006 1911 } 2007 1912 for (const { name, removedKeys } of prunedTags) { 2008 1913 console.log( ··· 2011 1916 } 2012 1917 2013 1918 const totalTags = prunedTags.reduce((sum, r) => sum + r.removedKeys.length, 0); 2014 - if (removed.length === 0 && totalTags === 0) { 1919 + const totalActions = 1920 + result.killedOrphanChildren.length + 1921 + result.respawned.length + 1922 + result.respawnFailed.length + 1923 + result.removed.length + 1924 + totalTags; 1925 + 1926 + if (totalActions === 0) { 2015 1927 console.log(dryRun ? "Nothing would be cleaned up." : "Nothing to clean up."); 2016 1928 return; 2017 1929 } 2018 1930 2019 1931 const parts: string[] = []; 2020 - if (removed.length > 0) { 2021 - parts.push(`${removed.length} stale session${removed.length === 1 ? "" : "s"}`); 1932 + if (result.killedOrphanChildren.length > 0) { 1933 + parts.push(`${result.killedOrphanChildren.length} orphan child${result.killedOrphanChildren.length === 1 ? "" : "ren"}`); 1934 + } 1935 + if (result.respawned.length > 0) { 1936 + parts.push(`${result.respawned.length} respawn${result.respawned.length === 1 ? "" : "s"}`); 1937 + } 1938 + if (result.respawnFailed.length > 0) { 1939 + parts.push(`${result.respawnFailed.length} respawn failure${result.respawnFailed.length === 1 ? "" : "s"}`); 1940 + } 1941 + if (result.removed.length > 0) { 1942 + parts.push(`${result.removed.length} stale session${result.removed.length === 1 ? "" : "s"}`); 2022 1943 } 2023 1944 if (totalTags > 0) { 2024 1945 parts.push(`${totalTags} orphan tag${totalTags === 1 ? "" : "s"}`); 2025 1946 } 2026 1947 console.log( 2027 1948 dryRun 2028 - ? `Would clean up ${parts.join(" and ")}. (Dry run — no changes made.)` 2029 - : `Cleaned up ${parts.join(" and ")}.`, 1949 + ? `Would clean up ${parts.join(", ")}. (Dry run — no changes made.)` 1950 + : `Cleaned up ${parts.join(", ")}.`, 2030 1951 ); 1952 + } 1953 + 1954 + /** Print a minimal launchd plist that runs `pty gc` every `interval` 1955 + * seconds. Pure stdout — the caller redirects to 1956 + * `~/Library/LaunchAgents/com.myobie.pty.gc.plist` and `launchctl load`s 1957 + * it themselves. No FDA dance, no bundled binary; just node + the gc 1958 + * command, inheriting PATH and PTY_SESSION_DIR. If the SSD where node 1959 + * lives isn't mounted at boot, the invocation fails — the next tick 1960 + * tries again. That's the whole point. */ 1961 + function printLaunchdPlist(interval: number): void { 1962 + const logPath = path.join(os.homedir(), ".local", "state", "pty", "gc.log"); 1963 + const ptyBin = process.argv[1]; 1964 + const envPath = process.env.PATH ?? "/usr/bin:/bin:/usr/sbin:/sbin"; 1965 + const sessionDir = getSessionDir(); 1966 + const escape = (s: string) => 1967 + s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); 1968 + 1969 + // We point ProgramArguments at node + the resolved CLI script so the 1970 + // plist doesn't depend on the `pty` shim staying on PATH at launchd's 1971 + // (minimal) shell. EnvironmentVariables still carries PATH so the 1972 + // spawned children (and any `which` inside pty itself) find the user's 1973 + // tools. 1974 + const plist = `<?xml version="1.0" encoding="UTF-8"?> 1975 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 1976 + <plist version="1.0"> 1977 + <dict> 1978 + <key>Label</key> 1979 + <string>com.myobie.pty.gc</string> 1980 + <key>ProgramArguments</key> 1981 + <array> 1982 + <string>${escape(process.execPath)}</string> 1983 + <string>${escape(ptyBin)}</string> 1984 + <string>gc</string> 1985 + </array> 1986 + <key>StartInterval</key> 1987 + <integer>${interval}</integer> 1988 + <key>RunAtLoad</key> 1989 + <true/> 1990 + <key>StandardOutPath</key> 1991 + <string>${escape(logPath)}</string> 1992 + <key>StandardErrorPath</key> 1993 + <string>${escape(logPath)}</string> 1994 + <key>EnvironmentVariables</key> 1995 + <dict> 1996 + <key>PATH</key> 1997 + <string>${escape(envPath)}</string> 1998 + <key>PTY_SESSION_DIR</key> 1999 + <string>${escape(sessionDir)}</string> 2000 + </dict> 2001 + </dict> 2002 + </plist> 2003 + `; 2004 + process.stdout.write(plist); 2031 2005 } 2032 2006 2033 2007 // --- tag-multi: read/write tags across multiple sessions in one call --- ··· 2489 2463 }); 2490 2464 } 2491 2465 2492 - async function cmdSupervisorStart(): Promise<void> { 2493 - // Run the supervisor in the foreground (not in a pty session). 2494 - // This makes it work with launchd KeepAlive since launchd owns the process. 2495 - // Use `pty events supervisor` to follow activity, `pty supervisor status` for state. 2496 - const { Supervisor } = await import("./supervisor.ts"); 2497 - 2498 - const sup = new Supervisor("supervisor"); 2499 - sup.start(); 2500 - 2501 - console.log(`[supervisor] started (pid ${process.pid})`); 2502 - console.log(`[supervisor] watching ${getSessionDir()}`); 2503 - 2504 - process.on("SIGTERM", () => { 2505 - console.log("[supervisor] stopping..."); 2506 - sup.stop(); 2507 - process.exit(0); 2508 - }); 2509 - process.on("SIGINT", () => { 2510 - console.log("[supervisor] stopping..."); 2511 - sup.stop(); 2512 - process.exit(0); 2513 - }); 2514 - 2515 - // Keep the process alive 2516 - await new Promise(() => {}); 2517 - } 2518 - 2519 - async function cmdSupervisorStop(): Promise<void> { 2520 - const pidPath = path.join(getSupervisorDir(), "supervisor.pid"); 2521 - let pid: number; 2522 - try { 2523 - pid = parseInt(fs.readFileSync(pidPath, "utf-8").trim(), 10); 2524 - } catch { 2525 - console.error("Supervisor is not running."); 2526 - process.exit(1); 2527 - } 2528 - try { 2529 - process.kill(pid, 0); // check if alive 2530 - process.kill(pid, "SIGTERM"); 2531 - console.log("Supervisor stopped."); 2532 - } catch { 2533 - console.error("Supervisor is not running (stale pid file)."); 2534 - try { fs.unlinkSync(pidPath); } catch {} 2535 - process.exit(1); 2536 - } 2537 - } 2538 - 2539 - async function cmdSupervisorStatus(): Promise<void> { 2540 - const sessions = await listSessions(); 2541 - const supervised = sessions.filter((s) => 2542 - s.metadata?.tags?.strategy === "permanent" || s.metadata?.tags?.strategy === "temporary" 2543 - ); 2544 - 2545 - if (supervised.length === 0) { 2546 - console.log("No supervised sessions."); 2547 - return; 2548 - } 2549 - 2550 - // Try to read supervisor state for restart counts 2551 - let state: Record<string, any> = {}; 2552 - try { 2553 - const content = fs.readFileSync(path.join(getSupervisorDir(), "state.json"), "utf-8"); 2554 - state = JSON.parse(content).sessions ?? {}; 2555 - } catch {} 2556 - 2557 - let supervisorRunning = false; 2558 - try { 2559 - const pid = parseInt(fs.readFileSync(path.join(getSupervisorDir(), "supervisor.pid"), "utf-8").trim(), 10); 2560 - process.kill(pid, 0); 2561 - supervisorRunning = true; 2562 - } catch {} 2563 - console.log(`Supervisor: ${supervisorRunning ? "\x1b[32mrunning\x1b[0m" : "\x1b[31mnot running\x1b[0m"}`); 2564 - console.log(""); 2565 - 2566 - for (const s of supervised) { 2567 - const strategy = s.metadata!.tags!.strategy; 2568 - const supStatus = s.metadata?.tags?.["supervisor.status"]; 2569 - const stateInfo = state[s.name]; 2570 - const restarts = stateInfo?.restartCount ?? 0; 2571 - 2572 - let status = s.status === "running" ? "\x1b[32mrunning\x1b[0m" : "\x1b[33mexited\x1b[0m"; 2573 - if (supStatus === "failed") status = "\x1b[31mfailed\x1b[0m"; 2574 - 2575 - console.log(` \x1b[1m${s.name}\x1b[0m [${strategy}] — ${status}${restarts > 0 ? ` (${restarts} restarts)` : ""}`); 2576 - } 2577 - } 2578 - 2579 - async function cmdSupervisorForget(name: string): Promise<void> { 2580 - const meta = readMetadata(name); 2581 - if (!meta) { 2582 - console.error(`Session "${name}" not found.`); 2583 - process.exit(1); 2584 - } 2585 - 2586 - const removals: string[] = []; 2587 - if (meta.tags?.strategy) removals.push("strategy"); 2588 - if (meta.tags?.["supervisor.status"]) removals.push("supervisor.status"); 2589 - 2590 - if (removals.length === 0) { 2591 - console.log(`Session "${name}" is not supervised.`); 2592 - return; 2593 - } 2594 - 2595 - updateTags(name, {}, removals); 2596 - console.log(`Removed supervision from "${name}".`); 2597 - 2598 - if (meta.tags?.ptyfile) { 2599 - console.error(`\nWarning: this session is managed by ${meta.tags.ptyfile}`); 2600 - console.error("The strategy tag will be restored on the next 'pty up'."); 2601 - console.error("Edit the pty.toml to make this permanent."); 2602 - } 2603 - } 2604 - 2605 - async function cmdSupervisorReset(name: string): Promise<void> { 2606 - const meta = readMetadata(name); 2607 - if (!meta) { 2608 - console.error(`Session "${name}" not found.`); 2609 - process.exit(1); 2610 - } 2611 - 2612 - if (meta.tags?.["supervisor.status"] !== "failed") { 2613 - console.log(`Session "${name}" is not in failed state.`); 2614 - return; 2615 - } 2616 - 2617 - // Remove the failed tag 2618 - updateTags(name, {}, ["supervisor.status"]); 2619 - 2620 - // Reset restart counter in supervisor state 2621 - const statePath = path.join(getSupervisorDir(), "state.json"); 2622 - try { 2623 - const content = fs.readFileSync(statePath, "utf-8"); 2624 - const state = JSON.parse(content); 2625 - if (state.sessions?.[name]) { 2626 - state.sessions[name].restartCount = 0; 2627 - state.sessions[name].restartWindowStart = 0; 2628 - state.sessions[name].nextBackoffMs = 1000; 2629 - state.sessions[name].failed = false; 2630 - atomicWriteFileSync(statePath, JSON.stringify(state, null, 2)); 2631 - } 2632 - } catch {} 2633 - 2634 - console.log(`Reset "${name}". The supervisor will try restarting it.`); 2635 - } 2636 - 2637 - function stopExistingSupervisorIfRunning(): void { 2638 - const pidPath = path.join(getSupervisorDir(), "supervisor.pid"); 2639 - try { 2640 - const pid = parseInt(fs.readFileSync(pidPath, "utf-8").trim(), 10); 2641 - try { process.kill(pid, "SIGTERM"); } catch {} 2642 - try { fs.unlinkSync(pidPath); } catch {} 2643 - console.log("Stopped existing supervisor."); 2644 - } catch {} 2645 - } 2646 - 2647 - function getSourceRoot(): string { 2648 - return path.join( 2649 - import.meta.dirname ?? path.dirname(new URL(import.meta.url).pathname), 2650 - "..", 2651 - ); 2652 - } 2653 - 2654 - function getSupervisorEntryPoint(): string { 2655 - return path.join(getSourceRoot(), "dist", "supervisor-entry.js"); 2656 - } 2657 - 2658 - function ensureSupervisorBuildExists(): void { 2659 - const entryPoint = getSupervisorEntryPoint(); 2660 - if (!fs.existsSync(entryPoint)) { 2661 - console.error(`Missing ${entryPoint}. Run: npm run build`); 2662 - process.exit(1); 2663 - } 2664 - } 2665 - 2666 - function getConfigHome(): string { 2667 - return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"); 2668 - } 2669 - 2670 - function shellQuote(value: string): string { 2671 - return `'${value.replace(/'/g, `'"'"'`)}'`; 2672 - } 2673 - 2674 - function systemdQuote(value: string): string { 2675 - return `"${value.replace(/["\\$`]/g, "\\$&")}"`; 2676 - } 2677 - 2678 - function normalizeServiceName(name: string | undefined, suffix: string): string { 2679 - const raw = (name && name.trim()) ? name.trim() : `pty-supervisor${suffix}`; 2680 - return raw.endsWith(suffix) ? raw : `${raw}${suffix}`; 2681 - } 2682 - 2683 - function runChecked(command: string, args: string[], options: Parameters<typeof spawnSync>[2] = {}): ReturnType<typeof spawnSync> { 2684 - const result = spawnSync(command, args, { encoding: "utf-8", ...options }); 2685 - if (result.status !== 0) { 2686 - const stderr = String(result.stderr ?? "").trim(); 2687 - const stdout = String(result.stdout ?? "").trim(); 2688 - console.error(`Failed: ${command} ${args.join(" ")}`); 2689 - if (stderr) console.error(stderr); 2690 - else if (stdout) console.error(stdout); 2691 - process.exit(result.status ?? 1); 2692 - } 2693 - return result; 2694 - } 2695 - 2696 - function maybePrintSystemdLingerHint(): void { 2697 - const user = os.userInfo().username; 2698 - const result = spawnSync("loginctl", ["show-user", user, "-p", "Linger", "--value"], { 2699 - encoding: "utf-8", 2700 - }); 2701 - if (result.status === 0 && result.stdout.trim().toLowerCase() !== "yes") { 2702 - console.log(""); 2703 - console.log(`Note: loginctl linger is disabled for ${user}.`); 2704 - console.log("This user service will start while you're logged in, but not at boot."); 2705 - console.log(`To keep it running across reboots, run: sudo loginctl enable-linger ${user}`); 2706 - } 2707 - } 2708 - 2709 - function cmdSupervisorSystemdInstall(unitName?: string, userPath?: string): void { 2710 - ensureSupervisorBuildExists(); 2711 - stopExistingSupervisorIfRunning(); 2712 - 2713 - const envPath = userPath ?? process.env.PATH ?? "/usr/bin:/bin:/usr/sbin:/sbin"; 2714 - const entryPoint = getSupervisorEntryPoint(); 2715 - const resolvedUnit = normalizeServiceName(unitName, ".service"); 2716 - const unitDir = path.join(getConfigHome(), "systemd", "user"); 2717 - const unitPath = path.join(unitDir, resolvedUnit); 2718 - const sessionDir = getSessionDir(); 2719 - 2720 - fs.mkdirSync(unitDir, { recursive: true }); 2721 - fs.writeFileSync(unitPath, `[Unit] 2722 - Description=pty supervisor 2723 - 2724 - [Service] 2725 - Type=simple 2726 - Environment=${systemdQuote(`PATH=${envPath}`)} 2727 - Environment=${systemdQuote(`TERM=xterm-256color`)} 2728 - Environment=${systemdQuote(`COLORTERM=truecolor`)} 2729 - Environment=${systemdQuote(`PTY_SESSION_DIR=${sessionDir}`)} 2730 - WorkingDirectory=${os.homedir()} 2731 - ExecStart=${process.execPath} ${entryPoint} 2732 - Restart=always 2733 - RestartSec=5 2734 - 2735 - [Install] 2736 - WantedBy=default.target 2737 - `); 2738 - 2739 - runChecked("systemctl", ["--user", "daemon-reload"]); 2740 - runChecked("systemctl", ["--user", "enable", "--now", resolvedUnit]); 2741 - 2742 - console.log(`Installed systemd user unit: ${unitPath}`); 2743 - console.log(`Manage it with: systemctl --user status ${resolvedUnit}`); 2744 - maybePrintSystemdLingerHint(); 2745 - } 2746 - 2747 - function cmdSupervisorSystemdUninstall(unitName?: string): void { 2748 - const resolvedUnit = normalizeServiceName(unitName, ".service"); 2749 - const unitPath = path.join(getConfigHome(), "systemd", "user", resolvedUnit); 2750 - 2751 - spawnSync("systemctl", ["--user", "disable", "--now", resolvedUnit], { encoding: "utf-8" }); 2752 - try { fs.unlinkSync(unitPath); } catch {} 2753 - runChecked("systemctl", ["--user", "daemon-reload"]); 2754 - spawnSync("systemctl", ["--user", "reset-failed", resolvedUnit], { encoding: "utf-8" }); 2755 - stopExistingSupervisorIfRunning(); 2756 - 2757 - console.log(`Removed systemd user unit: ${unitPath}`); 2758 - } 2759 - 2760 - function cmdSupervisorRunitInstall( 2761 - serviceName?: string, 2762 - svDir?: string, 2763 - serviceDir?: string, 2764 - userPath?: string, 2765 - ): void { 2766 - ensureSupervisorBuildExists(); 2767 - stopExistingSupervisorIfRunning(); 2768 - 2769 - const resolvedName = normalizeServiceName(serviceName, ""); 2770 - const resolvedSvDir = path.resolve(svDir ?? path.join(getConfigHome(), "runit", "sv")); 2771 - const resolvedServiceDir = path.resolve(serviceDir ?? path.join(getConfigHome(), "runit", "service")); 2772 - const servicePath = path.join(resolvedSvDir, resolvedName); 2773 - const runPath = path.join(servicePath, "run"); 2774 - const linkPath = path.join(resolvedServiceDir, resolvedName); 2775 - const envPath = userPath ?? process.env.PATH ?? "/usr/bin:/bin:/usr/sbin:/sbin"; 2776 - const sessionDir = getSessionDir(); 2777 - const entryPoint = getSupervisorEntryPoint(); 2778 - 2779 - fs.mkdirSync(resolvedSvDir, { recursive: true }); 2780 - fs.mkdirSync(resolvedServiceDir, { recursive: true }); 2781 - fs.rmSync(servicePath, { recursive: true, force: true }); 2782 - fs.mkdirSync(servicePath, { recursive: true, mode: 0o755 }); 2783 - 2784 - fs.writeFileSync(runPath, `#!/bin/sh 2785 - export PATH=${shellQuote(envPath)} 2786 - export TERM=${shellQuote("xterm-256color")} 2787 - export COLORTERM=${shellQuote("truecolor")} 2788 - export PTY_SESSION_DIR=${shellQuote(sessionDir)} 2789 - exec ${shellQuote(process.execPath)} ${shellQuote(entryPoint)} 2790 - `); 2791 - fs.chmodSync(runPath, 0o755); 2792 - 2793 - try { 2794 - const stat = fs.lstatSync(linkPath); 2795 - if (stat.isSymbolicLink() || stat.isFile()) fs.unlinkSync(linkPath); 2796 - else { 2797 - console.error(`Refusing to replace non-symlink path: ${linkPath}`); 2798 - process.exit(1); 2799 - } 2800 - } catch {} 2801 - fs.symlinkSync(servicePath, linkPath); 2802 - 2803 - console.log(`Installed runit service: ${servicePath}`); 2804 - console.log(`Enabled via symlink: ${linkPath}`); 2805 - console.log(`Start it with: runsvdir ${shellQuote(resolvedServiceDir)}`); 2806 - } 2807 - 2808 - function cmdSupervisorRunitUninstall(serviceName?: string, svDir?: string, serviceDir?: string): void { 2809 - const resolvedName = normalizeServiceName(serviceName, ""); 2810 - const resolvedSvDir = path.resolve(svDir ?? path.join(getConfigHome(), "runit", "sv")); 2811 - const resolvedServiceDir = path.resolve(serviceDir ?? path.join(getConfigHome(), "runit", "service")); 2812 - const servicePath = path.join(resolvedSvDir, resolvedName); 2813 - const linkPath = path.join(resolvedServiceDir, resolvedName); 2814 - 2815 - try { fs.unlinkSync(linkPath); } catch {} 2816 - try { fs.rmSync(servicePath, { recursive: true, force: true }); } catch {} 2817 - stopExistingSupervisorIfRunning(); 2818 - 2819 - console.log(`Removed runit service: ${servicePath}`); 2820 - console.log(`Removed symlink: ${linkPath}`); 2821 - } 2822 - 2823 - async function cmdSupervisorLaunchdInstall(userPath?: string): Promise<void> { 2824 - const launchdDir = path.join(os.homedir(), ".local", "pty", "launchd"); 2825 - const wrapperPath = path.join(launchdDir, "pty-supervisor"); 2826 - const bundlePath = path.join(launchdDir, "supervisor.bundle.js"); 2827 - const logPath = path.join(os.homedir(), ".local", "state", "pty", "supervisor.log"); 2828 - const plistDir = path.join(os.homedir(), "Library", "LaunchAgents"); 2829 - const plistPath = path.join(plistDir, "com.myobie.pty.supervisor.plist"); 2830 - 2831 - // Stop existing supervisor if running 2832 - const pidPath = path.join(getSupervisorDir(), "supervisor.pid"); 2833 - try { 2834 - const pid = parseInt(fs.readFileSync(pidPath, "utf-8").trim(), 10); 2835 - try { process.kill(pid, "SIGTERM"); } catch {} 2836 - try { fs.unlinkSync(pidPath); } catch {} 2837 - console.log("Stopped existing supervisor."); 2838 - } catch {} 2839 - 2840 - // Unload existing plist if present 2841 - if (fs.existsSync(plistPath)) { 2842 - spawnSync("launchctl", ["unload", plistPath], { encoding: "utf-8" }); 2843 - } 2844 - 2845 - const srcRoot = path.join( 2846 - import.meta.dirname ?? path.dirname(new URL(import.meta.url).pathname), 2847 - ".." 2848 - ); 2849 - const distDir = path.join(srcRoot, "dist"); 2850 - const nodeBin = process.execPath; 2851 - const wrapperSrc = path.join(srcRoot, "scripts", "supervisor-wrapper.c"); 2852 - 2853 - // Clean and create launchd directory 2854 - fs.rmSync(launchdDir, { recursive: true, force: true }); 2855 - fs.mkdirSync(launchdDir, { recursive: true }); 2856 - 2857 - // Bundle the supervisor 2858 - const entryPoint = path.join(distDir, "supervisor-entry.js"); 2859 - const serverModule = path.join(distDir, "server.js"); 2860 - 2861 - console.log("Bundling supervisor..."); 2862 - const esbuildResult = spawnSync("npx", ["esbuild", entryPoint, "--bundle", "--platform=node", "--format=esm", `--outfile=${bundlePath}`, `--define:SERVER_MODULE_PATH="${serverModule.replace(/\\/g, "\\\\")}"`], { 2863 - encoding: "utf-8", 2864 - cwd: distDir, 2865 - }); 2866 - if (esbuildResult.status !== 0) { 2867 - console.error(`Failed to bundle supervisor: ${esbuildResult.stderr}`); 2868 - process.exit(1); 2869 - } 2870 - 2871 - // Compile the FDA wrapper binary with PATH baked in 2872 - const envPath = userPath ?? process.env.PATH ?? "/usr/bin:/bin:/usr/sbin:/sbin"; 2873 - console.log("Compiling FDA wrapper..."); 2874 - const ccResult = spawnSync("cc", [ 2875 - "-O2", "-o", wrapperPath, 2876 - `-DNODE_PATH="${nodeBin}"`, 2877 - `-DBUNDLE_PATH="${bundlePath}"`, 2878 - `-DUSER_PATH="${envPath}"`, 2879 - wrapperSrc, 2880 - ], { encoding: "utf-8" }); 2881 - if (ccResult.status !== 0) { 2882 - console.error(`Failed to compile wrapper: ${ccResult.stderr}`); 2883 - process.exit(1); 2884 - } 2885 - 2886 - // Run --check via a one-shot launchd job to test under launchd's actual 2887 - // permission scope (not the terminal's). This is the only reliable way to 2888 - // know if the wrapper binary has FDA. 2889 - function checkFDAViaLaunchd(): boolean { 2890 - const checkLabel = "com.myobie.pty.fda-check"; 2891 - const checkOutput = path.join(launchdDir, "fda-check.log"); 2892 - const checkPlist = path.join(launchdDir, "fda-check.plist"); 2893 - 2894 - try { fs.unlinkSync(checkOutput); } catch {} 2895 - 2896 - fs.writeFileSync(checkPlist, `<?xml version="1.0" encoding="UTF-8"?> 2897 - <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 2898 - <plist version="1.0"> 2899 - <dict> 2900 - <key>Label</key> 2901 - <string>${checkLabel}</string> 2902 - <key>ProgramArguments</key> 2903 - <array> 2904 - <string>${wrapperPath}</string> 2905 - <string>--check</string> 2906 - </array> 2907 - <key>StandardOutPath</key> 2908 - <string>${checkOutput}</string> 2909 - <key>StandardErrorPath</key> 2910 - <string>${checkOutput}</string> 2911 - <key>RunAtLoad</key> 2912 - <true/> 2913 - </dict> 2914 - </plist> 2915 - `); 2916 - 2917 - // Unload any previous check job 2918 - spawnSync("launchctl", ["unload", checkPlist], { encoding: "utf-8" }); 2919 - 2920 - // Load — runs immediately due to RunAtLoad 2921 - spawnSync("launchctl", ["load", checkPlist], { encoding: "utf-8" }); 2922 - 2923 - // Wait for the job to finish (it's a one-shot, exits quickly) 2924 - const start = Date.now(); 2925 - while (Date.now() - start < 5000) { 2926 - const list = spawnSync("launchctl", ["list"], { encoding: "utf-8" }); 2927 - const line = list.stdout.split("\n").find((l: string) => l.includes(checkLabel)); 2928 - // Format: "PID\tExitCode\tLabel" — if PID is "-", the job has exited 2929 - if (line && line.startsWith("-")) break; 2930 - spawnSync("sleep", ["0.2"]); 2931 - } 2932 - 2933 - // Unload the check job 2934 - spawnSync("launchctl", ["unload", checkPlist], { encoding: "utf-8" }); 2935 - try { fs.unlinkSync(checkPlist); } catch {} 2936 - 2937 - // Read the output 2938 - try { 2939 - const output = fs.readFileSync(checkOutput, "utf-8"); 2940 - try { fs.unlinkSync(checkOutput); } catch {} 2941 - return output.includes("All checks passed"); 2942 - } catch { 2943 - return false; 2944 - } 2945 - } 2946 - 2947 - console.log("Checking Full Disk Access via launchd..."); 2948 - let hasFDA = checkFDAViaLaunchd(); 2949 - 2950 - if (!hasFDA) { 2951 - console.log(""); 2952 - console.log("The supervisor wrapper needs Full Disk Access to manage"); 2953 - console.log("sessions on external/removable volumes."); 2954 - console.log(""); 2955 - console.log("1. Open System Settings > Privacy & Security > Full Disk Access"); 2956 - console.log(`2. Click + and add: ${wrapperPath}`); 2957 - console.log(""); 2958 - 2959 - // Open the folder in Finder so they can find the binary 2960 - spawnSync("open", [launchdDir]); 2961 - 2962 - const rl = await import("node:readline/promises"); 2963 - const prompt = rl.createInterface({ input: process.stdin, output: process.stdout }); 2964 - const answer = await prompt.question("Have you granted Full Disk Access? [y/N] "); 2965 - prompt.close(); 2966 - 2967 - if (answer.trim().toLowerCase() !== "y") { 2968 - console.error("Aborted. Full Disk Access is required for launchd."); 2969 - process.exit(1); 2970 - } 2971 - 2972 - // Re-check via launchd 2973 - console.log("Verifying..."); 2974 - hasFDA = checkFDAViaLaunchd(); 2975 - if (!hasFDA) { 2976 - console.error(""); 2977 - console.error("Full Disk Access check failed under launchd."); 2978 - console.error("The plist was NOT loaded. Grant FDA and try again:"); 2979 - console.error(` ${wrapperPath}`); 2980 - process.exit(1); 2981 - } 2982 - console.log("Full Disk Access verified."); 2983 - } else { 2984 - console.log("Full Disk Access: granted."); 2985 - } 2986 - 2987 - // Write and load the plist — FDA is confirmed 2988 - const plist = `<?xml version="1.0" encoding="UTF-8"?> 2989 - <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 2990 - <plist version="1.0"> 2991 - <dict> 2992 - <key>Label</key> 2993 - <string>com.myobie.pty.supervisor</string> 2994 - <key>ProgramArguments</key> 2995 - <array> 2996 - <string>${wrapperPath}</string> 2997 - </array> 2998 - <key>StandardOutPath</key> 2999 - <string>${logPath}</string> 3000 - <key>StandardErrorPath</key> 3001 - <string>${logPath}</string> 3002 - <key>RunAtLoad</key> 3003 - <true/> 3004 - <key>KeepAlive</key> 3005 - <true/> 3006 - <key>ThrottleInterval</key> 3007 - <integer>5</integer> 3008 - </dict> 3009 - </plist> 3010 - `; 3011 - 3012 - fs.mkdirSync(plistDir, { recursive: true }); 3013 - fs.writeFileSync(plistPath, plist); 3014 - 3015 - const result = spawnSync("launchctl", ["load", plistPath], { encoding: "utf-8" }); 3016 - if (result.status !== 0) { 3017 - console.error(`Failed to load plist: ${result.stderr}`); 3018 - process.exit(1); 3019 - } 3020 - 3021 - console.log(""); 3022 - console.log(`Wrapper: ${wrapperPath}`); 3023 - console.log(`Bundle: ${bundlePath}`); 3024 - console.log(`Plist: ${plistPath}`); 3025 - console.log(`Log: ${logPath}`); 3026 - console.log("Supervisor will start on login and restart if it exits."); 3027 - } 3028 - 3029 - function cmdSupervisorLaunchdUninstall(): void { 3030 - const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", "com.myobie.pty.supervisor.plist"); 3031 - const launchdDir = path.join(os.homedir(), ".local", "pty", "launchd"); 3032 - 3033 - if (!fs.existsSync(plistPath)) { 3034 - console.error("Supervisor is not registered with launchd."); 3035 - process.exit(1); 3036 - } 3037 - 3038 - spawnSync("launchctl", ["unload", plistPath], { encoding: "utf-8" }); 3039 - try { fs.unlinkSync(plistPath); } catch {} 3040 - 3041 - // Clean up bundled files 3042 - try { fs.rmSync(launchdDir, { recursive: true, force: true }); } catch {} 3043 - 3044 - // Stop supervisor if running 3045 - const pidPath = path.join(getSupervisorDir(), "supervisor.pid"); 3046 - try { 3047 - const pid = parseInt(fs.readFileSync(pidPath, "utf-8").trim(), 10); 3048 - try { process.kill(pid, "SIGTERM"); } catch {} 3049 - try { fs.unlinkSync(pidPath); } catch {} 3050 - } catch {} 3051 - 3052 - console.log("Supervisor removed from launchd."); 3053 - console.log(`Cleaned up ${launchdDir}`); 3054 - } 3055 2466 3056 2467 function hasPtyFile(dir: string): boolean { 3057 2468 try { ··· 3250 2661 3251 2662 const label = existingSession.metadata?.displayName ?? existingSession.name; 3252 2663 3253 - // Remove supervision tags so the supervisor doesn't restart it 3254 - const wasSupervised = existingSession.metadata?.tags?.strategy === "permanent" || existingSession.metadata?.tags?.strategy === "temporary"; 3255 - if (wasSupervised) { 2664 + // Strip the `strategy` tag so `pty gc` doesn't respawn the session 2665 + // on its next tick. The `supervisor.status` tag is no longer a thing. 2666 + const wasPermanent = existingSession.metadata?.tags?.strategy === "permanent"; 2667 + if (wasPermanent) { 3256 2668 try { 3257 - const removals = ["strategy"]; 3258 - if (existingSession.metadata?.tags?.["supervisor.status"]) removals.push("supervisor.status"); 3259 - updateTags(existingSession.name, {}, removals); 2669 + updateTags(existingSession.name, {}, ["strategy"]); 3260 2670 } catch {} 3261 2671 } 3262 2672 3263 2673 if (existingSession.status === "running" && existingSession.pid) { 3264 2674 try { 3265 2675 process.kill(existingSession.pid, "SIGTERM"); 3266 - console.log(` ○ ${label} (stopped${wasSupervised ? ", removed from supervision" : ""})`); 2676 + console.log(` ○ ${label} (stopped${wasPermanent ? ", removed from supervision" : ""})`); 3267 2677 stopped++; 3268 2678 } catch { 3269 2679 console.error(` ✗ ${label}: failed to stop`); ··· 3455 2865 3456 2866 function strategyMarker(tags?: Record<string, string>): string { 3457 2867 if (!tags) return ""; 3458 - const supStatus = tags["supervisor.status"]; 3459 - if (supStatus === "failed") return " \x1b[31m[failed]\x1b[0m"; 3460 2868 if (tags.strategy === "permanent") return " \x1b[33m[permanent]\x1b[0m"; 3461 - if (tags.strategy === "temporary") return " \x1b[2m[temporary]\x1b[0m"; 3462 2869 return ""; 3463 2870 } 3464 2871
+2 -3
src/client-api.ts
··· 8 8 getSessionDir, getSocketPath, 9 9 cleanupSocket, cleanupAll, 10 10 getState, getStateKey, setState, deleteState, listStateKeys, 11 - type SessionInfo, type SessionMetadata, type PrunedTagResult, 11 + type SessionInfo, type SessionMetadata, type PrunedTagResult, type GcResult, 12 12 } from "./sessions.ts"; 13 13 14 14 // Session creation ··· 37 37 type BellEvent, type TitleChangeEvent, type NotificationEvent, 38 38 type FocusRequestEvent, type CursorVisibleEvent, 39 39 type SessionStartEvent, type SessionExitEvent, type SessionExecEvent, 40 - type SessionRestartEvent, type SessionFailedEvent, 41 - type SupervisorStartEvent, type SupervisorStopEvent, 40 + type SessionRespawnEvent, 42 41 type UserEvent, type StateSetEvent, type StateDeleteEvent, 43 42 type DisplayNameChangeEvent, type TagsChangeEvent, 44 43 type FollowerOptions,
+10 -34
src/events.ts
··· 14 14 SESSION_START: "session_start", 15 15 SESSION_EXIT: "session_exit", 16 16 SESSION_EXEC: "session_exec", 17 - SESSION_RESTART: "session_restart", 18 - SESSION_FAILED: "session_failed", 19 - SUPERVISOR_START: "supervisor_start", 20 - SUPERVISOR_STOP: "supervisor_stop", 17 + SESSION_RESPAWN: "session_respawn", 21 18 } as const; 22 19 23 20 export type EventType = (typeof EventType)[keyof typeof EventType]; ··· 78 75 command: string; 79 76 } 80 77 81 - export interface SessionRestartEvent extends EventBase { 82 - type: "session_restart"; 83 - restartCount: number; 84 - backoffMs: number; 85 - } 86 - 87 - export interface SessionFailedEvent extends EventBase { 88 - type: "session_failed"; 89 - restartCount: number; 90 - reason: string; 91 - } 92 - 93 - export interface SupervisorStartEvent extends EventBase { 94 - type: "supervisor_start"; 95 - } 96 - 97 - export interface SupervisorStopEvent extends EventBase { 98 - type: "supervisor_stop"; 78 + /** Emitted by `pty gc` whenever it respawns a `strategy=permanent` 79 + * session that's exited/vanished. Carries no payload beyond the 80 + * envelope — the restart is stateless, there is no attempt counter, 81 + * and the cron interval is the rate limit. */ 82 + export interface SessionRespawnEvent extends EventBase { 83 + type: "session_respawn"; 99 84 } 100 85 101 86 /** User-published event. `type` must begin with `user.` — the CLI ··· 148 133 | SessionStartEvent 149 134 | SessionExitEvent 150 135 | SessionExecEvent 151 - | SessionRestartEvent 152 - | SessionFailedEvent 153 - | SupervisorStartEvent 154 - | SupervisorStopEvent 136 + | SessionRespawnEvent 155 137 | UserEvent 156 138 | StateSetEvent 157 139 | StateDeleteEvent ··· 496 478 return `${prefix} exited (code ${event.exitCode})`; 497 479 case "session_exec": 498 480 return `${prefix} exec ${event.command} (was ${event.previousCommand})`; 499 - case "session_restart": 500 - return `${prefix} restarted (attempt ${event.restartCount}, backoff ${event.backoffMs}ms)`; 501 - case "session_failed": 502 - return `${prefix} failed — ${event.reason}`; 503 - case "supervisor_start": 504 - return `${prefix} supervisor started`; 505 - case "supervisor_stop": 506 - return `${prefix} supervisor stopped`; 481 + case "session_respawn": 482 + return `${prefix} respawned`; 507 483 case "state.set": 508 484 return `${prefix} state.set ${event.key} = ${JSON.stringify(event.value)}`; 509 485 case "state.delete":
+183 -9
src/sessions.ts
··· 464 464 return refs; 465 465 } 466 466 467 - /** Remove all exited **and** vanished sessions. Returns the names of removed 468 - * sessions. `dryRun: true` performs the same walk but doesn't delete — useful 469 - * for preview UIs. */ 470 - export async function gc(opts: { dryRun?: boolean } = {}): Promise<string[]> { 471 - const sessions = await listSessions(); 472 - const gone = sessions.filter((s) => isGone(s.status)); 473 - if (!opts.dryRun) { 474 - for (const s of gone) cleanupAll(s.name); 467 + /** Result of a `gc()` reconciliation pass. The four buckets correspond 468 + * to the three reconciliation steps: orphan-kill (step 1), permanent 469 + * respawn success / failure (step 2), and the sweep of exited 470 + * non-permanent sessions (step 3 — the historic `gc()` behavior). */ 471 + export interface GcResult { 472 + /** Names of exited/vanished non-permanent sessions whose metadata was 473 + * removed. Empty under `dryRun: true` callers should treat the same 474 + * list as the preview. */ 475 + removed: string[]; 476 + /** Children killed because their `parent=` referent is dead or missing. */ 477 + killedOrphanChildren: { name: string; parent: string; reason: "missing" | "dead" }[]; 478 + /** Permanent sessions respawned this pass. `ptyfileReread` indicates 479 + * whether the spawn used a fresh `pty.toml` read (when the session 480 + * carries `ptyfile` + `ptyfile.session` tags) or its stored metadata. */ 481 + respawned: { name: string; ptyfileReread: boolean }[]; 482 + /** Permanent sessions where respawn was attempted but failed (e.g. the 483 + * binary is on an unmounted volume). Cron interval is the rate limit; 484 + * next tick tries again. */ 485 + respawnFailed: { name: string; error: string }[]; 486 + } 487 + 488 + /** Reconciliation pass driven by `pty gc`. Stateless: every invocation 489 + * re-derives intent from on-disk metadata. Three steps run in order: 490 + * 491 + * 1. Orphan-kill: children with a `parent=<name>` tag whose parent's 492 + * metadata is gone OR whose parent's pid isn't alive get SIGTERM'd 493 + * and `cleanupAll`'d. Runs first so a permanent child whose parent 494 + * has died isn't immediately respawned by step 2. 495 + * 2. Permanent respawn: every `strategy=permanent` session that's 496 + * exited/vanished is respawned via `spawnDaemon` (lazy-imported to 497 + * avoid the `sessions ↔ spawn` cycle). Sessions with `ptyfile` + 498 + * `ptyfile.session` tags re-read the toml to pick up any edits. 499 + * 3. Existing sweep: the historic behavior — exited/vanished sessions 500 + * that aren't permanent get `cleanupAll`'d. */ 501 + export async function gc(opts: { dryRun?: boolean } = {}): Promise<GcResult> { 502 + const dryRun = !!opts.dryRun; 503 + // First call to `listSessions` is intentionally throwaway — it has a 504 + // side effect (`cleanupSocket`) on sessions whose daemon SIGKILL'd 505 + // without writing an exit record, and those sessions are then *missing* 506 + // from the returned array (their entry is dropped because `seen` set 507 + // contained the name but the alive checks failed). A second call sees 508 + // them via the `.json` files loop as `status=vanished`. Without this 509 + // priming pass, step 1's orphan-kill misses vanished sessions whose 510 + // sockets were still on disk when gc started. 511 + await listSessions(); 512 + const initial = await listSessions(); 513 + 514 + // STEP 1: orphan-children. Sort by name so cycles (A→B, B→A) resolve 515 + // deterministically — whichever name sorts first wins this tick; the 516 + // loser dies; on the next tick the winner has no live parent either 517 + // and dies too. No cycle detection needed. 518 + const killedOrphanChildren: GcResult["killedOrphanChildren"] = []; 519 + const withParent = initial 520 + .filter((s) => s.metadata?.tags?.parent) 521 + .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); 522 + for (const s of withParent) { 523 + const parentRef = s.metadata!.tags!.parent; 524 + const parentMeta = readMetadata(parentRef); 525 + const parentPid = parentMeta ? readPid(parentRef) : null; 526 + const parentAlive = parentMeta != null && parentPid !== null && isProcessAlive(parentPid); 527 + if (parentAlive) continue; 528 + const reason: "missing" | "dead" = parentMeta ? "dead" : "missing"; 529 + if (!dryRun) { 530 + if (s.status === "running" && s.pid != null) { 531 + // SIGTERM the live daemon, then wait briefly for it to exit so 532 + // its shutdown handler doesn't race our cleanupAll by writing 533 + // metadata back to disk after we've removed it. We poll the 534 + // pid (up to ~1s) and fall through whether or not the daemon 535 + // shut down in time — cleanupAll wipes whatever remains. 536 + try { process.kill(s.pid, "SIGTERM"); } catch {} 537 + const deadline = Date.now() + 1000; 538 + while (Date.now() < deadline) { 539 + if (!isProcessAlive(s.pid)) break; 540 + await new Promise((r) => setTimeout(r, 25)); 541 + } 542 + } 543 + cleanupAll(s.name); 544 + } 545 + killedOrphanChildren.push({ name: s.name, parent: parentRef, reason }); 475 546 } 476 - return gone.map((s) => s.name); 547 + 548 + // STEP 2: permanent respawn. Re-list since step 1 may have removed 549 + // some metadata (an orphan-killed permanent child should not also 550 + // appear here). In dryRun mode the initial list is fine — step 1 551 + // didn't mutate anything. 552 + const afterStep1 = dryRun ? initial : await listSessions(); 553 + const respawned: GcResult["respawned"] = []; 554 + const respawnFailed: GcResult["respawnFailed"] = []; 555 + for (const s of afterStep1) { 556 + if (s.metadata?.tags?.strategy !== "permanent") continue; 557 + if (!isGone(s.status)) continue; 558 + const ptyfileReread = !!s.metadata?.tags?.ptyfile; 559 + if (dryRun) { 560 + respawned.push({ name: s.name, ptyfileReread }); 561 + continue; 562 + } 563 + try { 564 + await respawnPermanent(s.name, s.metadata!); 565 + respawned.push({ name: s.name, ptyfileReread }); 566 + } catch (err: any) { 567 + respawnFailed.push({ name: s.name, error: err?.message ?? String(err) }); 568 + } 569 + } 570 + 571 + // STEP 3: historic sweep. Exited/vanished non-permanent sessions get 572 + // their metadata removed. Permanent sessions are handled by step 2 — 573 + // if their respawn succeeded they're back to `running` and skipped; 574 + // if it failed we leave the metadata around so the next tick can try 575 + // again. 576 + const finalList = dryRun ? initial : await listSessions(); 577 + const removed: string[] = []; 578 + for (const s of finalList) { 579 + if (!isGone(s.status)) continue; 580 + if (s.metadata?.tags?.strategy === "permanent") continue; 581 + if (!dryRun) cleanupAll(s.name); 582 + removed.push(s.name); 583 + } 584 + 585 + return { removed, killedOrphanChildren, respawned, respawnFailed }; 586 + } 587 + 588 + /** Restart a `strategy=permanent` session whose daemon is gone. If the 589 + * session was toml-managed (`ptyfile` + `ptyfile.session` tags), re-read 590 + * the pty.toml so the new daemon picks up command/env edits since the 591 + * last spawn. On any read error fall back to the stored metadata 592 + * verbatim (last-known-good) so a temporarily-missing toml doesn't 593 + * prevent restart. 594 + * 595 + * Lazy-imports `spawn.ts` so the `sessions.ts ↔ spawn.ts` cycle doesn't 596 + * bite at module-init time. After spawn, appends a `session_respawn` 597 + * event to the session's event log so consumers see the restart. */ 598 + async function respawnPermanent(name: string, metadata: SessionMetadata): Promise<void> { 599 + let command = metadata.command; 600 + let args = metadata.args; 601 + let displayCommand = metadata.displayCommand; 602 + let cwd = metadata.cwd; 603 + let tags: Record<string, string> | undefined = metadata.tags; 604 + const displayName = metadata.displayName; 605 + 606 + const ptyfilePath = metadata.tags?.ptyfile; 607 + const ptyfileSession = metadata.tags?.["ptyfile.session"]; 608 + if (ptyfilePath && ptyfileSession) { 609 + try { 610 + const { readPtyFile, commandWithEnvExports } = await import("./ptyfile.ts"); 611 + const dir = path.dirname(ptyfilePath); 612 + const ptyFile = readPtyFile(dir); 613 + const sessDef = ptyFile.sessions.find((s) => s.shortName === ptyfileSession); 614 + if (sessDef) { 615 + command = "/bin/sh"; 616 + args = ["-c", commandWithEnvExports(sessDef)]; 617 + displayCommand = sessDef.command; 618 + cwd = ptyFile.dir; 619 + tags = { 620 + ...sessDef.tags, 621 + ptyfile: ptyfilePath, 622 + "ptyfile.session": ptyfileSession, 623 + }; 624 + } 625 + } catch { 626 + // pty.toml unreadable (volume not mounted yet, file deleted, parse 627 + // error). Fall back to stored metadata — better to respawn with 628 + // last-known-good than to give up. 629 + } 630 + } 631 + 632 + // Wipe stale socket/pid/events before respawn so spawnDaemon doesn't 633 + // trip over leftovers from the dead daemon. Metadata is recreated by 634 + // spawnDaemon. 635 + cleanupAll(name); 636 + 637 + const { spawnDaemon } = await import("./spawn.ts"); 638 + await spawnDaemon({ 639 + name, command, args, displayCommand, cwd, tags, 640 + ...(displayName ? { displayName } : {}), 641 + }); 642 + 643 + // Best-effort event; respawn already succeeded if we got here. 644 + try { 645 + appendEventSync(name, { 646 + session: name, 647 + type: "session_respawn", 648 + ts: new Date().toISOString(), 649 + }); 650 + } catch {} 477 651 } 478 652 479 653 /**
-30
src/supervisor-entry.ts
··· 1 - // Standalone entry point for the supervisor. 2 - // Bundled by `pty supervisor launchd install` into a single portable JS file. 3 - // SERVER_MODULE_PATH is injected at bundle time by esbuild --define. 4 - 5 - import { Supervisor } from "./supervisor.ts"; 6 - import { getSessionDir } from "./sessions.ts"; 7 - import { setServerModulePath } from "./spawn.ts"; 8 - 9 - declare const SERVER_MODULE_PATH: string; 10 - if (typeof SERVER_MODULE_PATH !== "undefined") { 11 - setServerModulePath(SERVER_MODULE_PATH); 12 - } 13 - 14 - const supervisor = new Supervisor("supervisor"); 15 - supervisor.start(); 16 - 17 - console.log(`[supervisor] started (pid ${process.pid})`); 18 - console.log(`[supervisor] watching ${getSessionDir()}`); 19 - 20 - process.on("SIGTERM", () => { 21 - console.log("[supervisor] stopping..."); 22 - supervisor.stop(); 23 - process.exit(0); 24 - }); 25 - 26 - process.on("SIGINT", () => { 27 - console.log("[supervisor] stopping..."); 28 - supervisor.stop(); 29 - process.exit(0); 30 - });
-557
src/supervisor.ts
··· 1 - import * as fs from "node:fs"; 2 - import * as path from "node:path"; 3 - import { 4 - getSessionDir, ensureSessionDir, readMetadata, cleanupAll, 5 - acquireLock, releaseLock, updateTags, 6 - atomicWriteFileSync, 7 - type SessionMetadata, 8 - } from "./sessions.ts"; 9 - import { spawnDaemon } from "./spawn.ts"; 10 - import { readPtyFile, commandWithEnvExports } from "./ptyfile.ts"; 11 - import { EventWriter, EventType, type EventRecord } from "./events.ts"; 12 - 13 - /** Supervisor state lives in its own subdirectory to avoid polluting the session dir. */ 14 - export function getSupervisorDir(): string { 15 - const dir = path.join(getSessionDir(), "supervisor"); 16 - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); 17 - return dir; 18 - } 19 - 20 - const MAX_RESTARTS = 5; 21 - const RESTART_WINDOW_MS = 60_000; 22 - const INITIAL_BACKOFF_MS = 1_000; 23 - const MAX_BACKOFF_MS = 16_000; 24 - const BACKOFF_MULTIPLIER = 2; 25 - const SCAN_INTERVAL_MS = 10_000; 26 - const TEMPORARY_CLEANUP_DELAY_MS = 1_000; 27 - 28 - /** Spawn parameters cached on each SupervisedSession so a restart can 29 - * retry even after `cleanupAll` has removed the on-disk metadata. 30 - * Without this, the second restart attempt of a slow-starting session 31 - * hits "no metadata" and the supervisor silently gives up. */ 32 - interface CachedSpawnConfig { 33 - command: string; 34 - args: string[]; 35 - displayCommand: string; 36 - cwd: string; 37 - tags?: Record<string, string>; 38 - } 39 - 40 - interface SupervisedSession { 41 - name: string; 42 - strategy: "permanent" | "temporary"; 43 - restartCount: number; 44 - restartWindowStart: number; 45 - lastRestartAt: number; 46 - nextBackoffMs: number; 47 - failed: boolean; 48 - pendingTimer: ReturnType<typeof setTimeout> | null; 49 - /** Last-known spawn parameters. Cached so a restart attempt can 50 - * succeed even if `cleanupAll` has already removed the on-disk 51 - * metadata file. Populated whenever `evaluateSession` reads fresh 52 - * metadata. */ 53 - spawnConfig?: CachedSpawnConfig; 54 - } 55 - 56 - interface PersistedState { 57 - sessions: Record<string, { 58 - restartCount: number; 59 - restartWindowStart: number; 60 - lastRestartAt: number; 61 - nextBackoffMs: number; 62 - failed: boolean; 63 - }>; 64 - savedAt: string; 65 - } 66 - 67 - export class Supervisor { 68 - private sessions = new Map<string, SupervisedSession>(); 69 - private dirWatcher: fs.FSWatcher | null = null; 70 - private scanInterval: ReturnType<typeof setInterval> | null = null; 71 - private eventWriter: EventWriter; 72 - private stopping = false; 73 - private lockAcquired = false; 74 - 75 - constructor(private supervisorName: string) { 76 - this.eventWriter = new EventWriter(supervisorName); 77 - } 78 - 79 - start(): void { 80 - ensureSessionDir(); 81 - 82 - // launchd starts us with a minimal env — no TERM, no COLORTERM. That 83 - // propagates all the way down into each supervised session's child PTY, 84 - // which makes TUIs like Claude Code fall back to legacy key encoding 85 - // where Shift+Enter is indistinguishable from Enter. server.ts now 86 - // defaults TERM at the PTY boundary, but we also seed it here so the 87 - // supervisor's own process.env looks consistent (useful for any 88 - // subprocess spawned outside the spawnDaemon path). 89 - if (!process.env.TERM) process.env.TERM = "xterm-256color"; 90 - if (!process.env.COLORTERM) process.env.COLORTERM = "truecolor"; 91 - 92 - // Acquire lock to prevent multiple supervisors 93 - if (!acquireLock("supervisor")) { 94 - console.error("[supervisor] another supervisor is already running"); 95 - process.exit(1); 96 - } 97 - this.lockAcquired = true; 98 - 99 - // Write PID file so `pty supervisor stop` can find us 100 - const pidPath = path.join(getSupervisorDir(), "supervisor.pid"); 101 - fs.writeFileSync(pidPath, process.pid.toString()); 102 - 103 - this.loadState(); 104 - this.scanAllSessions(); 105 - this.startWatching(); 106 - 107 - this.scanInterval = setInterval(() => { 108 - if (!this.stopping) this.scanAllSessions(); 109 - }, SCAN_INTERVAL_MS); 110 - 111 - this.emitEvent(EventType.SUPERVISOR_START); 112 - } 113 - 114 - stop(): void { 115 - this.stopping = true; 116 - 117 - for (const tracked of this.sessions.values()) { 118 - if (tracked.pendingTimer) clearTimeout(tracked.pendingTimer); 119 - } 120 - 121 - this.dirWatcher?.close(); 122 - this.dirWatcher = null; 123 - 124 - if (this.scanInterval) { 125 - clearInterval(this.scanInterval); 126 - this.scanInterval = null; 127 - } 128 - 129 - this.persistState(); 130 - this.emitEvent(EventType.SUPERVISOR_STOP); 131 - 132 - // Clean up PID file 133 - try { fs.unlinkSync(path.join(getSupervisorDir(), "supervisor.pid")); } catch {} 134 - 135 - if (this.lockAcquired) { 136 - releaseLock("supervisor"); 137 - } 138 - } 139 - 140 - private scanAllSessions(): void { 141 - const sessionDir = getSessionDir(); 142 - let entries: string[]; 143 - try { 144 - entries = fs.readdirSync(sessionDir); 145 - } catch { 146 - return; 147 - } 148 - 149 - const jsonFiles = entries.filter((e) => e.endsWith(".json")); 150 - const seenNames = new Set<string>(); 151 - 152 - for (const file of jsonFiles) { 153 - const name = file.replace(/\.json$/, ""); 154 - if (name === this.supervisorName) continue; 155 - seenNames.add(name); 156 - this.evaluateSession(name); 157 - } 158 - 159 - // Remove tracked sessions whose metadata no longer exists 160 - for (const name of this.sessions.keys()) { 161 - if (!seenNames.has(name)) { 162 - const tracked = this.sessions.get(name)!; 163 - if (tracked.pendingTimer) clearTimeout(tracked.pendingTimer); 164 - this.sessions.delete(name); 165 - } 166 - } 167 - } 168 - 169 - private evaluateSession(name: string): void { 170 - const metadata = readMetadata(name); 171 - if (!metadata) return; 172 - 173 - const strategy = metadata.tags?.strategy; 174 - 175 - // No strategy tag — stop tracking if previously tracked 176 - if (strategy !== "permanent" && strategy !== "temporary") { 177 - const existing = this.sessions.get(name); 178 - if (existing) { 179 - if (existing.pendingTimer) clearTimeout(existing.pendingTimer); 180 - this.sessions.delete(name); 181 - console.log(`[supervisor] stopped tracking ${name} (strategy removed)`); 182 - } 183 - return; 184 - } 185 - 186 - // Check both metadata and whether the process is actually alive. 187 - // If the daemon was killed externally, exitedAt may not be set. 188 - let isExited = !!metadata.exitedAt; 189 - if (!isExited) { 190 - const sockPath = path.join(getSessionDir(), `${name}.sock`); 191 - const pidPath = path.join(getSessionDir(), `${name}.pid`); 192 - try { 193 - const pid = parseInt(fs.readFileSync(pidPath, "utf-8").trim(), 10); 194 - process.kill(pid, 0); // throws if not alive 195 - } catch { 196 - // Process is dead but metadata doesn't know — treat as exited 197 - if (!fs.existsSync(sockPath)) { 198 - isExited = true; 199 - } 200 - } 201 - } 202 - 203 - if (strategy === "permanent") { 204 - if (!this.sessions.has(name)) { 205 - this.sessions.set(name, { 206 - name, 207 - strategy: "permanent", 208 - restartCount: 0, 209 - restartWindowStart: 0, 210 - lastRestartAt: 0, 211 - nextBackoffMs: INITIAL_BACKOFF_MS, 212 - failed: false, 213 - pendingTimer: null, 214 - }); 215 - console.log(`[supervisor] tracking ${name} (permanent)`); 216 - } 217 - 218 - const tracked = this.sessions.get(name)!; 219 - tracked.strategy = "permanent"; 220 - tracked.spawnConfig = { 221 - command: metadata.command, 222 - args: metadata.args, 223 - displayCommand: metadata.displayCommand, 224 - cwd: metadata.cwd, 225 - tags: metadata.tags, 226 - }; 227 - 228 - if (isExited && !tracked.failed && !tracked.pendingTimer) { 229 - this.scheduleRestart(name); 230 - } 231 - 232 - // If it's running, reset the restart window if enough time has passed 233 - if (!isExited && tracked.restartWindowStart > 0) { 234 - const elapsed = Date.now() - tracked.restartWindowStart; 235 - if (elapsed > RESTART_WINDOW_MS) { 236 - tracked.restartCount = 0; 237 - tracked.restartWindowStart = 0; 238 - tracked.nextBackoffMs = INITIAL_BACKOFF_MS; 239 - } 240 - } 241 - } 242 - 243 - if (strategy === "temporary") { 244 - if (isExited) { 245 - // Schedule cleanup 246 - if (!this.sessions.has(name) || !this.sessions.get(name)!.pendingTimer) { 247 - console.log(`[supervisor] cleaning up temporary session ${name}`); 248 - const timer = setTimeout(() => { 249 - cleanupAll(name); 250 - this.sessions.delete(name); 251 - console.log(`[supervisor] removed temporary session ${name}`); 252 - }, TEMPORARY_CLEANUP_DELAY_MS); 253 - this.sessions.set(name, { 254 - name, 255 - strategy: "temporary", 256 - restartCount: 0, 257 - restartWindowStart: 0, 258 - lastRestartAt: 0, 259 - nextBackoffMs: 0, 260 - failed: false, 261 - pendingTimer: timer, 262 - }); 263 - } 264 - } else if (!this.sessions.has(name)) { 265 - this.sessions.set(name, { 266 - name, 267 - strategy: "temporary", 268 - restartCount: 0, 269 - restartWindowStart: 0, 270 - lastRestartAt: 0, 271 - nextBackoffMs: 0, 272 - failed: false, 273 - pendingTimer: null, 274 - }); 275 - console.log(`[supervisor] tracking ${name} (temporary)`); 276 - } 277 - } 278 - } 279 - 280 - private scheduleRestart(name: string): void { 281 - const tracked = this.sessions.get(name); 282 - if (!tracked || tracked.strategy !== "permanent") return; 283 - 284 - const now = Date.now(); 285 - 286 - // Reset window if expired 287 - if (tracked.restartWindowStart > 0 && now - tracked.restartWindowStart > RESTART_WINDOW_MS) { 288 - tracked.restartCount = 0; 289 - tracked.restartWindowStart = 0; 290 - tracked.nextBackoffMs = INITIAL_BACKOFF_MS; 291 - } 292 - 293 - // Check restart limit 294 - if (tracked.restartCount >= MAX_RESTARTS) { 295 - this.markFailed(name); 296 - return; 297 - } 298 - 299 - // Start window on first restart 300 - if (tracked.restartWindowStart === 0) { 301 - tracked.restartWindowStart = now; 302 - } 303 - 304 - const backoff = tracked.nextBackoffMs; 305 - console.log(`[supervisor] scheduling restart for ${name} in ${backoff}ms (attempt ${tracked.restartCount + 1}/${MAX_RESTARTS})`); 306 - 307 - tracked.pendingTimer = setTimeout(() => { 308 - tracked.pendingTimer = null; 309 - console.log(`[supervisor] attempting restart for ${name}...`); 310 - this.doRestart(name).catch((err) => { 311 - console.log(`[supervisor] restart failed for ${name}: ${err.message}`); 312 - tracked.restartCount++; 313 - tracked.nextBackoffMs = Math.min(tracked.nextBackoffMs * BACKOFF_MULTIPLIER, MAX_BACKOFF_MS); 314 - this.persistState(); 315 - // Try again if under limit 316 - if (tracked.restartCount < MAX_RESTARTS) { 317 - this.scheduleRestart(name); 318 - } else { 319 - this.markFailed(name); 320 - } 321 - }); 322 - }, backoff); 323 - } 324 - 325 - private async doRestart(name: string): Promise<void> { 326 - if (this.stopping) { 327 - console.log(`[supervisor] skipping restart for ${name} (stopping)`); 328 - return; 329 - } 330 - 331 - const metadata = readMetadata(name); 332 - const tracked = this.sessions.get(name); 333 - 334 - // Recovery for the "metadata file already removed" case: 335 - // cleanupAll runs before spawnDaemon during a restart, so if the 336 - // first attempt's spawn times out the metadata is gone for the 337 - // second attempt's readMetadata. Fall back to the cached 338 - // spawnConfig on the in-memory SupervisedSession when that 339 - // happens — without this, a single slow start permanently drops 340 - // a permanent session. 341 - if (!metadata) { 342 - if (tracked?.strategy === "permanent" && tracked.spawnConfig && !tracked.failed) { 343 - console.log(`[supervisor] no on-disk metadata for ${name}; recovering from cached spawnConfig`); 344 - await this.respawnFromCachedConfig(name, tracked); 345 - } else { 346 - console.log(`[supervisor] skipping restart for ${name} (no metadata)`); 347 - } 348 - return; 349 - } 350 - if (metadata.tags?.strategy !== "permanent") { 351 - console.log(`[supervisor] skipping restart for ${name} (strategy removed)`); 352 - return; 353 - } 354 - 355 - // Check if actually still dead (exitedAt may be missing if killed externally) 356 - if (!metadata.exitedAt) { 357 - const pidPath = path.join(getSessionDir(), `${name}.pid`); 358 - try { 359 - const pid = parseInt(fs.readFileSync(pidPath, "utf-8").trim(), 10); 360 - process.kill(pid, 0); 361 - console.log(`[supervisor] skipping restart for ${name} (pid ${pid} still alive)`); 362 - return; 363 - } catch { 364 - // dead — proceed with restart 365 - } 366 - } 367 - 368 - if (!tracked) { 369 - console.log(`[supervisor] skipping restart for ${name} (not tracked)`); 370 - return; 371 - } 372 - 373 - // If session was started from a pty.toml, re-read it for the latest config 374 - let command = metadata.command; 375 - let args = metadata.args; 376 - let displayCommand = metadata.displayCommand; 377 - let cwd = metadata.cwd; 378 - let tags = metadata.tags; 379 - 380 - const ptyfilePath = metadata.tags?.ptyfile; 381 - const ptyfileSession = metadata.tags?.["ptyfile.session"]; 382 - if (ptyfilePath && ptyfileSession) { 383 - try { 384 - const dir = path.dirname(ptyfilePath); 385 - const ptyFile = readPtyFile(dir); 386 - const sessDef = ptyFile.sessions.find((s) => s.shortName === ptyfileSession); 387 - if (sessDef) { 388 - command = "/bin/sh"; 389 - args = ["-c", commandWithEnvExports(sessDef)]; 390 - displayCommand = sessDef.command; 391 - cwd = ptyFile.dir; 392 - tags = { ...sessDef.tags, ptyfile: ptyfilePath, "ptyfile.session": ptyfileSession }; 393 - console.log(`[supervisor] re-read pty.toml for ${name}`); 394 - } 395 - } catch (err: any) { 396 - console.log(`[supervisor] could not re-read pty.toml for ${name}: ${err.message}, using stored metadata`); 397 - } 398 - } 399 - 400 - // Cache the spawn config before cleanup so a slow start that 401 - // wipes the metadata file can still recover on the next attempt. 402 - tracked.spawnConfig = { command, args, displayCommand, cwd, tags }; 403 - 404 - // Preserve the existing displayName across the respawn so the user-visible 405 - // label survives. (`name` is the immutable on-disk id; displayName is the 406 - // pretty label that pty.toml sets.) 407 - const displayName = metadata.displayName; 408 - 409 - // Clean up the dead session 410 - cleanupAll(name); 411 - 412 - // Respawn 413 - await spawnDaemon({ name, command, args, displayCommand, cwd, tags, ...(displayName ? { displayName } : {}) }); 414 - 415 - tracked.restartCount++; 416 - tracked.lastRestartAt = Date.now(); 417 - tracked.nextBackoffMs = Math.min(tracked.nextBackoffMs * BACKOFF_MULTIPLIER, MAX_BACKOFF_MS); 418 - 419 - console.log(`[supervisor] restarted ${name} (attempt ${tracked.restartCount}/${MAX_RESTARTS})`); 420 - 421 - this.emitEvent(EventType.SESSION_RESTART, { 422 - session: name, 423 - restartCount: tracked.restartCount, 424 - backoffMs: tracked.nextBackoffMs, 425 - }); 426 - 427 - this.persistState(); 428 - } 429 - 430 - /** Restart path used when the on-disk metadata is missing. The 431 - * cached spawnConfig on the SupervisedSession was populated last 432 - * time evaluateSession saw fresh metadata, so it's still valid as 433 - * a recovery source. */ 434 - private async respawnFromCachedConfig(name: string, tracked: SupervisedSession): Promise<void> { 435 - const cfg = tracked.spawnConfig; 436 - if (!cfg) return; // caller checked already, but be defensive 437 - 438 - cleanupAll(name); 439 - await spawnDaemon({ 440 - name, 441 - command: cfg.command, 442 - args: cfg.args, 443 - displayCommand: cfg.displayCommand, 444 - cwd: cfg.cwd, 445 - tags: cfg.tags, 446 - }); 447 - 448 - tracked.restartCount++; 449 - tracked.lastRestartAt = Date.now(); 450 - tracked.nextBackoffMs = Math.min(tracked.nextBackoffMs * BACKOFF_MULTIPLIER, MAX_BACKOFF_MS); 451 - 452 - console.log(`[supervisor] restarted ${name} from cached config (attempt ${tracked.restartCount}/${MAX_RESTARTS})`); 453 - 454 - this.emitEvent(EventType.SESSION_RESTART, { 455 - session: name, 456 - restartCount: tracked.restartCount, 457 - backoffMs: tracked.nextBackoffMs, 458 - }); 459 - 460 - this.persistState(); 461 - } 462 - 463 - private markFailed(name: string): void { 464 - const tracked = this.sessions.get(name); 465 - if (!tracked) return; 466 - 467 - tracked.failed = true; 468 - if (tracked.pendingTimer) { 469 - clearTimeout(tracked.pendingTimer); 470 - tracked.pendingTimer = null; 471 - } 472 - 473 - console.log(`[supervisor] ${name} marked as FAILED (${tracked.restartCount} restarts in window)`); 474 - 475 - // Set failed tag on the session 476 - try { 477 - updateTags(name, { "supervisor.status": "failed" }, []); 478 - } catch { 479 - // Metadata may have been cleaned up 480 - } 481 - 482 - this.emitEvent(EventType.SESSION_FAILED, { 483 - session: name, 484 - restartCount: tracked.restartCount, 485 - reason: `exceeded ${MAX_RESTARTS} restarts in ${RESTART_WINDOW_MS / 1000}s`, 486 - }); 487 - 488 - this.persistState(); 489 - } 490 - 491 - private emitEvent(type: string, fields?: Record<string, unknown>): void { 492 - this.eventWriter.append({ 493 - session: this.supervisorName, 494 - type: type as EventRecord["type"], 495 - ts: new Date().toISOString(), 496 - ...fields, 497 - } as EventRecord); 498 - } 499 - 500 - private startWatching(): void { 501 - const sessionDir = getSessionDir(); 502 - try { 503 - this.dirWatcher = fs.watch(sessionDir, (eventType, filename) => { 504 - if (this.stopping) return; 505 - if (!filename || !filename.endsWith(".json")) return; 506 - const name = filename.replace(/\.json$/, ""); 507 - if (name === this.supervisorName) return; 508 - // Debounce: defer evaluation to next tick to let writes complete 509 - setImmediate(() => this.evaluateSession(name)); 510 - }); 511 - } catch (err) { 512 - console.error(`[supervisor] failed to watch session directory: ${err}`); 513 - } 514 - } 515 - 516 - private persistState(): void { 517 - const state: PersistedState = { 518 - sessions: {}, 519 - savedAt: new Date().toISOString(), 520 - }; 521 - for (const [name, tracked] of this.sessions) { 522 - if (tracked.strategy !== "permanent") continue; 523 - state.sessions[name] = { 524 - restartCount: tracked.restartCount, 525 - restartWindowStart: tracked.restartWindowStart, 526 - lastRestartAt: tracked.lastRestartAt, 527 - nextBackoffMs: tracked.nextBackoffMs, 528 - failed: tracked.failed, 529 - }; 530 - } 531 - const filePath = path.join(getSupervisorDir(), "state.json"); 532 - try { 533 - atomicWriteFileSync(filePath, JSON.stringify(state, null, 2)); 534 - } catch { 535 - // Non-fatal 536 - } 537 - } 538 - 539 - private loadState(): void { 540 - const filePath = path.join(getSupervisorDir(), "state.json"); 541 - try { 542 - const content = fs.readFileSync(filePath, "utf-8"); 543 - const state: PersistedState = JSON.parse(content); 544 - for (const [name, saved] of Object.entries(state.sessions)) { 545 - this.sessions.set(name, { 546 - name, 547 - strategy: "permanent", 548 - ...saved, 549 - pendingTimer: null, 550 - }); 551 - } 552 - console.log(`[supervisor] loaded state: ${Object.keys(state.sessions).length} tracked sessions`); 553 - } catch { 554 - // No state file or invalid — start fresh 555 - } 556 - } 557 - }
+6 -5
src/tags.ts
··· 46 46 } 47 47 48 48 /** 49 - * Keys that pty itself treats as internal bookkeeping. These drive 50 - * dedicated markers (`[permanent]`, `[failed]`) or wire up the 51 - * supervisor / ptyfile plumbing — they're visible in `pty list --tags` 52 - * but hidden from the default listing. 49 + * Keys that pty itself treats as internal bookkeeping. `strategy` drives 50 + * the `[permanent]` marker and tells `pty gc` to respawn the session on 51 + * exit; `ptyfile*` keys wire up the toml-managed-session plumbing. The 52 + * user-facing `parent=<name>` orphan-kill tag is intentionally NOT 53 + * reserved — it's a regular tag visible in `pty list`. Reserved keys 54 + * are visible in `pty list --tags` but hidden from the default listing. 53 55 */ 54 56 const EXACT_RESERVED = new Set([ 55 57 "ptyfile", 56 58 "ptyfile.session", 57 59 "ptyfile.tags", 58 - "supervisor.status", 59 60 "strategy", 60 61 ]); 61 62
+1 -2
src/tui/interactive.ts
··· 319 319 ? cwdStr 320 320 : [cwdStr, exitStr].filter(Boolean).join(" "); 321 321 322 - const supStatus = s.metadata?.tags?.["supervisor.status"]; 323 322 const strategy = s.metadata?.tags?.strategy; 324 - const marker = supStatus === "failed" ? " [failed]" : strategy === "permanent" ? " [permanent]" : strategy === "temporary" ? " [temporary]" : ""; 323 + const marker = strategy === "permanent" ? " [permanent]" : ""; 325 324 const tagStr = renderTagsInline(s.metadata?.tags); 326 325 327 326 // Prefer displayName for the primary label; show the stable id secondarily
+194
tests/gc-parent-child.test.ts
··· 1 + import { describe, it, expect, afterEach, afterAll } from "vitest"; 2 + import * as fs from "node:fs"; 3 + import * as os from "node:os"; 4 + import * as path from "node:path"; 5 + import { fileURLToPath } from "node:url"; 6 + import { spawn, spawnSync } from "node:child_process"; 7 + 8 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 + const nodeBin = process.execPath; 10 + const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 11 + const serverModule = path.join(__dirname, "..", "dist", "server.js"); 12 + 13 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-gcpc-")); 14 + afterAll(() => { 15 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 16 + }); 17 + 18 + let bgPids: number[] = []; 19 + let sessionDirs: string[] = []; 20 + 21 + function makeSessionDir(): string { 22 + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 23 + sessionDirs.push(dir); 24 + return dir; 25 + } 26 + 27 + let nameCounter = 0; 28 + function uniqueName(prefix = "pc"): string { 29 + return `${prefix}${++nameCounter}${Math.random().toString(36).slice(2, 4)}`; 30 + } 31 + 32 + async function startDaemon( 33 + sessionDir: string, 34 + name: string, 35 + command: string, 36 + args: string[] = [], 37 + tags?: Record<string, string>, 38 + ): Promise<number> { 39 + const config = JSON.stringify({ 40 + name, command, args, displayCommand: command, 41 + cwd: os.tmpdir(), rows: 24, cols: 80, tags, 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 + 55 + const socketPath = path.join(sessionDir, `${name}.sock`); 56 + const start = Date.now(); 57 + while (Date.now() - start < 5000) { 58 + if (exitCode !== null) throw new Error(`Daemon exited: ${stderr}`); 59 + try { 60 + fs.statSync(socketPath); 61 + await new Promise((r) => setTimeout(r, 100)); 62 + bgPids.push(child.pid!); 63 + return child.pid!; 64 + } catch {} 65 + await new Promise((r) => setTimeout(r, 50)); 66 + } 67 + throw new Error("Timeout waiting for daemon"); 68 + } 69 + 70 + function runCli(sessionDir: string, ...args: string[]) { 71 + return spawnSync(nodeBin, [cliPath, ...args], { 72 + env: { ...process.env, PTY_SESSION_DIR: sessionDir }, 73 + encoding: "utf-8", 74 + timeout: 15000, 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 + describe("pty gc — parent-child orphan-kill", () => { 90 + it("kills a child when its parent's daemon is dead (no exit record)", async () => { 91 + const dir = makeSessionDir(); 92 + const parent = uniqueName("par"); 93 + const child = uniqueName("ch"); 94 + 95 + const parentPid = await startDaemon(dir, parent, "cat"); 96 + await startDaemon(dir, child, "cat", [], { parent }); 97 + 98 + // SIGKILL the parent daemon — vanished status (no exitedAt). 99 + try { process.kill(parentPid, "SIGKILL"); } catch {} 100 + await new Promise((r) => setTimeout(r, 300)); 101 + 102 + const result = runCli(dir, "gc"); 103 + expect(result.status).toBe(0); 104 + expect(result.stdout).toContain(`Killed orphan child: ${child} (parent ${parent}`); 105 + // Child metadata is gone after cleanupAll. 106 + expect(fs.existsSync(path.join(dir, `${child}.json`))).toBe(false); 107 + }, 15000); 108 + 109 + it("kills a child when parent metadata is missing entirely", async () => { 110 + const dir = makeSessionDir(); 111 + const child = uniqueName("ch"); 112 + // No parent ever existed. The tag points at a name that doesn't 113 + // resolve. Step 1 should still kill the child. 114 + await startDaemon(dir, child, "cat", [], { parent: "nonexistent-parent" }); 115 + 116 + const result = runCli(dir, "gc"); 117 + expect(result.status).toBe(0); 118 + expect(result.stdout).toContain(`Killed orphan child: ${child} (parent nonexistent-parent missing)`); 119 + expect(fs.existsSync(path.join(dir, `${child}.json`))).toBe(false); 120 + }, 15000); 121 + 122 + it("preserves a child whose parent is alive", async () => { 123 + const dir = makeSessionDir(); 124 + const parent = uniqueName("par"); 125 + const child = uniqueName("ch"); 126 + 127 + await startDaemon(dir, parent, "cat"); 128 + await startDaemon(dir, child, "cat", [], { parent }); 129 + 130 + const result = runCli(dir, "gc"); 131 + expect(result.status).toBe(0); 132 + expect(result.stdout).not.toContain(`Killed orphan child: ${child}`); 133 + // Both metadata files still on disk. 134 + expect(fs.existsSync(path.join(dir, `${parent}.json`))).toBe(true); 135 + expect(fs.existsSync(path.join(dir, `${child}.json`))).toBe(true); 136 + }, 15000); 137 + 138 + it("cycle A→B, B→A — name-sorted resolution kills both deterministically", async () => { 139 + // Pick names where 'a' sorts before 'b' for determinism. 140 + const dir = makeSessionDir(); 141 + const a = `a${Math.random().toString(36).slice(2, 5)}`; 142 + const b = `b${Math.random().toString(36).slice(2, 5)}`; 143 + 144 + // Spawn A pointing at B and B pointing at A. Then SIGKILL both 145 + // daemons so both look vanished — by the orphan-kill rule, since 146 + // each parent is dead, both children should be killed. 147 + const aPid = await startDaemon(dir, a, "cat", [], { parent: b }); 148 + const bPid = await startDaemon(dir, b, "cat", [], { parent: a }); 149 + try { process.kill(aPid, "SIGKILL"); } catch {} 150 + try { process.kill(bPid, "SIGKILL"); } catch {} 151 + await new Promise((r) => setTimeout(r, 300)); 152 + 153 + const result = runCli(dir, "gc"); 154 + expect(result.status).toBe(0); 155 + // Both get killed-as-orphan in the same pass — A first (sorts 156 + // earlier), B second. 157 + expect(result.stdout).toContain(`Killed orphan child: ${a}`); 158 + expect(result.stdout).toContain(`Killed orphan child: ${b}`); 159 + }, 15000); 160 + 161 + it("combined parent= AND strategy=permanent: child is killed, not respawned", async () => { 162 + // Orphan-kill (step 1) runs BEFORE permanent respawn (step 2), so a 163 + // child with both tags whose parent has died should be removed — 164 + // not respawned as a permanent. 165 + const dir = makeSessionDir(); 166 + const parent = uniqueName("par"); 167 + const child = uniqueName("ch"); 168 + 169 + const parentPid = await startDaemon(dir, parent, "cat"); 170 + await startDaemon(dir, child, "cat", [], { parent, strategy: "permanent" }); 171 + 172 + try { process.kill(parentPid, "SIGKILL"); } catch {} 173 + await new Promise((r) => setTimeout(r, 300)); 174 + 175 + const result = runCli(dir, "gc"); 176 + expect(result.status).toBe(0); 177 + expect(result.stdout).toContain(`Killed orphan child: ${child}`); 178 + expect(result.stdout).not.toContain(`Respawned: ${child}`); 179 + expect(fs.existsSync(path.join(dir, `${child}.json`))).toBe(false); 180 + }, 15000); 181 + 182 + it("--dry-run previews orphan-kill without mutating anything", async () => { 183 + const dir = makeSessionDir(); 184 + const child = uniqueName("ch"); 185 + await startDaemon(dir, child, "cat", [], { parent: "nonexistent-parent" }); 186 + 187 + const dry = runCli(dir, "gc", "--dry-run"); 188 + expect(dry.status).toBe(0); 189 + expect(dry.stdout).toContain(`Would kill orphan child: ${child} (parent nonexistent-parent missing)`); 190 + expect(dry.stdout).toContain("Dry run"); 191 + // Child still alive on disk and its daemon untouched. 192 + expect(fs.existsSync(path.join(dir, `${child}.json`))).toBe(true); 193 + }, 15000); 194 + });
+267
tests/gc-permanent.test.ts
··· 1 + import { describe, it, expect, afterEach, afterAll } from "vitest"; 2 + import * as fs from "node:fs"; 3 + import * as os from "node:os"; 4 + import * as path from "node:path"; 5 + import { fileURLToPath } from "node:url"; 6 + import { spawn, spawnSync } from "node:child_process"; 7 + 8 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 + const nodeBin = process.execPath; 10 + const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 11 + const serverModule = path.join(__dirname, "..", "dist", "server.js"); 12 + 13 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-gcp-")); 14 + afterAll(() => { 15 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 16 + }); 17 + 18 + let bgPids: number[] = []; 19 + let sessionDirs: string[] = []; 20 + 21 + function makeSessionDir(): string { 22 + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 23 + sessionDirs.push(dir); 24 + return dir; 25 + } 26 + 27 + let nameCounter = 0; 28 + function uniqueName(): string { 29 + // Short — socket paths must fit under SUN_PATH_MAX (104 bytes). 30 + return `gp${++nameCounter}${Math.random().toString(36).slice(2, 5)}`; 31 + } 32 + 33 + async function startDaemon( 34 + sessionDir: string, 35 + name: string, 36 + command: string, 37 + args: string[] = [], 38 + tags?: Record<string, string>, 39 + ): Promise<number> { 40 + const config = JSON.stringify({ 41 + name, command, args, displayCommand: command, 42 + cwd: os.tmpdir(), rows: 24, cols: 80, tags, 43 + }); 44 + const child = spawn(nodeBin, [serverModule], { 45 + detached: true, 46 + stdio: ["ignore", "ignore", "pipe"], 47 + env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir }, 48 + }); 49 + let stderr = ""; 50 + child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); 51 + let exitCode: number | null = null; 52 + child.on("exit", (code) => { exitCode = code; }); 53 + (child.stderr as any)?.unref?.(); 54 + child.unref(); 55 + 56 + const socketPath = path.join(sessionDir, `${name}.sock`); 57 + const start = Date.now(); 58 + while (Date.now() - start < 5000) { 59 + if (exitCode !== null) throw new Error(`Daemon exited: ${stderr}`); 60 + try { 61 + fs.statSync(socketPath); 62 + await new Promise((r) => setTimeout(r, 100)); 63 + bgPids.push(child.pid!); 64 + return child.pid!; 65 + } catch {} 66 + await new Promise((r) => setTimeout(r, 50)); 67 + } 68 + throw new Error("Timeout waiting for daemon"); 69 + } 70 + 71 + function runCli(sessionDir: string, ...args: string[]) { 72 + return spawnSync(nodeBin, [cliPath, ...args], { 73 + env: { ...process.env, PTY_SESSION_DIR: sessionDir }, 74 + encoding: "utf-8", 75 + timeout: 15000, 76 + }); 77 + } 78 + 79 + function readMeta(sessionDir: string, name: string) { 80 + return JSON.parse(fs.readFileSync(path.join(sessionDir, `${name}.json`), "utf-8")); 81 + } 82 + 83 + function readEvents(sessionDir: string, name: string): any[] { 84 + const filePath = path.join(sessionDir, `${name}.events.jsonl`); 85 + try { 86 + const content = fs.readFileSync(filePath, "utf-8"); 87 + return content.trimEnd().split("\n").filter((l) => l.length > 0).map((l) => JSON.parse(l)); 88 + } catch { 89 + return []; 90 + } 91 + } 92 + 93 + afterEach(() => { 94 + for (const pid of bgPids) { try { process.kill(pid, "SIGTERM"); } catch {} } 95 + bgPids = []; 96 + for (const dir of sessionDirs) { 97 + try { 98 + for (const e of fs.readdirSync(dir)) { try { fs.unlinkSync(path.join(dir, e)); } catch {} } 99 + } catch {} 100 + } 101 + sessionDirs = []; 102 + }); 103 + 104 + describe("pty gc — strategy=permanent respawn", () => { 105 + it("respawns an exited permanent session", async () => { 106 + const dir = makeSessionDir(); 107 + const name = uniqueName(); 108 + // First daemon runs `true` and exits immediately — gives us the 109 + // exited-permanent setup gc should react to. 110 + const firstPid = await startDaemon(dir, name, "true", [], { strategy: "permanent" }); 111 + await new Promise((r) => setTimeout(r, 800)); 112 + 113 + const before = readMeta(dir, name); 114 + expect(before.exitedAt).toBeTruthy(); 115 + 116 + const result = runCli(dir, "gc"); 117 + expect(result.status).toBe(0); 118 + expect(result.stdout).toContain(`Respawned: ${name}`); 119 + 120 + // session_respawn event was written before the respawned daemon's 121 + // own session_start, so the event log contains both. Verify the 122 + // respawn event regardless of whether the new `true` daemon has 123 + // also already exited by the time we read. 124 + await new Promise((r) => setTimeout(r, 200)); 125 + const events = readEvents(dir, name); 126 + expect(events.some((e) => e.type === "session_respawn")).toBe(true); 127 + 128 + // The new daemon has a different pid than the first one (proves a 129 + // real respawn happened, not just a no-op). 130 + try { 131 + const pidStr = fs.readFileSync(path.join(dir, `${name}.pid`), "utf-8").trim(); 132 + const pid = parseInt(pidStr, 10); 133 + if (Number.isFinite(pid)) { 134 + bgPids.push(pid); 135 + expect(pid).not.toBe(firstPid); 136 + } 137 + } catch { 138 + // pid file may already be cleaned by the new daemon exiting — that 139 + // just confirms a fresh daemon ran. 140 + } 141 + }, 20000); 142 + 143 + it("does NOT respawn an exited session without strategy=permanent", async () => { 144 + const dir = makeSessionDir(); 145 + const name = uniqueName(); 146 + await startDaemon(dir, name, "true"); 147 + await new Promise((r) => setTimeout(r, 800)); 148 + 149 + const result = runCli(dir, "gc"); 150 + expect(result.status).toBe(0); 151 + expect(result.stdout).not.toContain(`Respawned: ${name}`); 152 + expect(result.stdout).toContain(`Removed: ${name}`); 153 + // Metadata gone (was reaped by step 3). 154 + expect(fs.existsSync(path.join(dir, `${name}.json`))).toBe(false); 155 + }, 15000); 156 + 157 + it("--dry-run previews respawn without spawning anything", async () => { 158 + const dir = makeSessionDir(); 159 + const name = uniqueName(); 160 + await startDaemon(dir, name, "true", [], { strategy: "permanent" }); 161 + await new Promise((r) => setTimeout(r, 800)); 162 + 163 + const dry = runCli(dir, "gc", "--dry-run"); 164 + expect(dry.status).toBe(0); 165 + expect(dry.stdout).toContain(`Would respawn: ${name}`); 166 + expect(dry.stdout).toContain("Dry run"); 167 + 168 + // Still exited — no actual respawn happened. Metadata's exitedAt 169 + // should still be present. 170 + const meta = readMeta(dir, name); 171 + expect(meta.exitedAt).toBeTruthy(); 172 + expect(fs.existsSync(path.join(dir, `${name}.pid`))).toBe(false); 173 + }, 15000); 174 + 175 + it("respawn does not loop within one invocation", async () => { 176 + // If a permanent session's command exits immediately (`true`), one 177 + // gc invocation should respawn it exactly once — not loop until the 178 + // process settles or stack-overflow on a sub-second exit. 179 + const dir = makeSessionDir(); 180 + const name = uniqueName(); 181 + await startDaemon(dir, name, "true", [], { strategy: "permanent" }); 182 + await new Promise((r) => setTimeout(r, 800)); 183 + 184 + const result = runCli(dir, "gc"); 185 + expect(result.status).toBe(0); 186 + // Exactly one "Respawned:" line, regardless of whether the new 187 + // daemon has also exited by the time gc returns. 188 + const respawnLines = (result.stdout.match(/^Respawned: /gm) || []).length; 189 + expect(respawnLines).toBe(1); 190 + 191 + // Track whatever pid we ended up with so afterEach can clean up. 192 + try { 193 + const pid = parseInt(fs.readFileSync(path.join(dir, `${name}.pid`), "utf-8").trim(), 10); 194 + if (Number.isFinite(pid)) bgPids.push(pid); 195 + } catch {} 196 + }, 15000); 197 + 198 + it("pty.toml respawn re-reads the toml so command edits take effect", async () => { 199 + // Seed a pty.toml in a tmp dir and use `pty up` to spawn a permanent 200 + // session referencing it. 201 + const dir = makeSessionDir(); 202 + const projectDir = fs.mkdtempSync(path.join(testRoot, "proj-")); 203 + sessionDirs.push(projectDir); 204 + const tomlPath = path.join(projectDir, "pty.toml"); 205 + const v1Marker = `/tmp/pty-gc-permv1-${Date.now()}.flag`; 206 + const v2Marker = `/tmp/pty-gc-permv2-${Date.now()}.flag`; 207 + // v1: write v1Marker and exit. 208 + fs.writeFileSync(tomlPath, `[sessions.perm] 209 + command = "touch ${v1Marker}" 210 + tags = { strategy = "permanent" } 211 + `); 212 + 213 + const projectShort = path.basename(projectDir); 214 + void projectShort; 215 + 216 + const up = runCli(dir, "up", projectDir); 217 + expect(up.status).toBe(0); 218 + // Under the decoupled name/displayName model, the session's on-disk 219 + // name is a random id; the toml-derived "perm" is the displayName. 220 + // Resolve the actual on-disk name via `pty list --json`. 221 + const listOut = runCli(dir, "list", "--json").stdout; 222 + const sessions = JSON.parse(listOut); 223 + const found = sessions.find((s: any) => s.displayName === "perm"); 224 + expect(found).toBeDefined(); 225 + const sessionName: string = found.name; 226 + 227 + // Wait for first run to complete (it touches v1Marker and exits). 228 + const start = Date.now(); 229 + while (Date.now() - start < 5000) { 230 + if (fs.existsSync(v1Marker)) break; 231 + await new Promise((r) => setTimeout(r, 100)); 232 + } 233 + expect(fs.existsSync(v1Marker)).toBe(true); 234 + fs.unlinkSync(v1Marker); 235 + 236 + // Now wait for the daemon to write its exit record. 237 + await new Promise((r) => setTimeout(r, 800)); 238 + const meta = readMeta(dir, sessionName); 239 + expect(meta.exitedAt).toBeTruthy(); 240 + expect(meta.tags?.ptyfile).toBe(tomlPath); 241 + 242 + // Edit the toml to write v2Marker instead. 243 + fs.writeFileSync(tomlPath, `[sessions.perm] 244 + command = "touch ${v2Marker}" 245 + tags = { strategy = "permanent" } 246 + `); 247 + 248 + const result = runCli(dir, "gc"); 249 + expect(result.status).toBe(0); 250 + expect(result.stdout).toContain(`Respawned: ${sessionName} (pty.toml re-read)`); 251 + 252 + // The respawned session should write v2Marker. 253 + const start2 = Date.now(); 254 + while (Date.now() - start2 < 5000) { 255 + if (fs.existsSync(v2Marker)) break; 256 + await new Promise((r) => setTimeout(r, 100)); 257 + } 258 + expect(fs.existsSync(v2Marker)).toBe(true); 259 + try { fs.unlinkSync(v2Marker); } catch {} 260 + 261 + // Track the new pid for teardown. 262 + try { 263 + const pid = parseInt(fs.readFileSync(path.join(dir, `${sessionName}.pid`), "utf-8").trim(), 10); 264 + if (Number.isFinite(pid)) bgPids.push(pid); 265 + } catch {} 266 + }, 25000); 267 + });
+26
tests/gc.test.ts
··· 263 263 expect(result.stdout).toContain(`Removed: ${name}`); 264 264 expect(fs.existsSync(metaPath)).toBe(false); 265 265 }, 10000); 266 + 267 + it("--print-launchd-plist emits a valid-looking plist", () => { 268 + const dir = makeSessionDir(); 269 + const result = runCli(dir, "gc", "--print-launchd-plist"); 270 + expect(result.status).toBe(0); 271 + expect(result.stdout).toContain("<!DOCTYPE plist"); 272 + expect(result.stdout).toContain("<string>com.myobie.pty.gc</string>"); 273 + expect(result.stdout).toContain("<key>StartInterval</key>"); 274 + expect(result.stdout).toContain("<integer>30</integer>"); 275 + expect(result.stdout).toContain("<key>PTY_SESSION_DIR</key>"); 276 + }); 277 + 278 + it("--print-launchd-plist --interval=N sets the interval", () => { 279 + const dir = makeSessionDir(); 280 + const result = runCli(dir, "gc", "--print-launchd-plist", "--interval=15"); 281 + expect(result.status).toBe(0); 282 + expect(result.stdout).toContain("<integer>15</integer>"); 283 + }); 284 + 285 + it("rejects --interval=0 and non-numeric intervals", () => { 286 + const dir = makeSessionDir(); 287 + const r1 = runCli(dir, "gc", "--print-launchd-plist", "--interval=0"); 288 + expect(r1.status).not.toBe(0); 289 + const r2 = runCli(dir, "gc", "--print-launchd-plist", "--interval=abc"); 290 + expect(r2.status).not.toBe(0); 291 + }); 266 292 });
-320
tests/supervisor-hardening.test.ts
··· 1 - import { describe, it, expect, afterEach, afterAll } from "vitest"; 2 - import * as fs from "node:fs"; 3 - import * as os from "node:os"; 4 - import * as path from "node:path"; 5 - import { fileURLToPath } from "node:url"; 6 - import { spawn, spawnSync } from "node:child_process"; 7 - 8 - const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 - const nodeBin = process.execPath; 10 - const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 11 - const serverModule = path.join(__dirname, "..", "dist", "server.js"); 12 - 13 - const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-sup-hard-")); 14 - afterAll(() => { 15 - fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 16 - }); 17 - 18 - let bgPids: number[] = []; 19 - let sessionDirs: string[] = []; 20 - 21 - function makeSessionDir(): string { 22 - const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 23 - sessionDirs.push(dir); 24 - return dir; 25 - } 26 - 27 - let nameCounter = 0; 28 - function uniqueName(): string { 29 - return `sh${++nameCounter}-${Math.random().toString(36).slice(2, 6)}`; 30 - } 31 - 32 - async function startDaemon( 33 - sessionDir: string, 34 - name: string, 35 - command: string, 36 - args: string[] = [], 37 - tags?: Record<string, string>, 38 - ): Promise<number> { 39 - const config = JSON.stringify({ 40 - name, command, args, displayCommand: command, 41 - cwd: os.tmpdir(), rows: 24, cols: 80, tags, 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 - 55 - const socketPath = path.join(sessionDir, `${name}.sock`); 56 - const start = Date.now(); 57 - while (Date.now() - start < 5000) { 58 - if (exitCode !== null) throw new Error(`Daemon exited: ${stderr}`); 59 - try { 60 - fs.statSync(socketPath); 61 - await new Promise((r) => setTimeout(r, 100)); 62 - bgPids.push(child.pid!); 63 - return child.pid!; 64 - } catch {} 65 - await new Promise((r) => setTimeout(r, 50)); 66 - } 67 - throw new Error("Timeout waiting for daemon"); 68 - } 69 - 70 - function runCli(sessionDir: string, ...args: string[]) { 71 - return spawnSync(nodeBin, [cliPath, ...args], { 72 - env: { ...process.env, PTY_SESSION_DIR: sessionDir }, 73 - encoding: "utf-8", 74 - timeout: 15000, 75 - }); 76 - } 77 - 78 - function readMeta(sessionDir: string, name: string): any { 79 - try { 80 - return JSON.parse(fs.readFileSync(path.join(sessionDir, `${name}.json`), "utf-8")); 81 - } catch { return null; } 82 - } 83 - 84 - afterEach(() => { 85 - for (const pid of bgPids) { try { process.kill(pid, "SIGTERM"); } catch {} } 86 - bgPids = []; 87 - for (const dir of sessionDirs) { 88 - try { 89 - for (const e of fs.readdirSync(dir)) { try { fs.unlinkSync(path.join(dir, e)); } catch {} } 90 - } catch {} 91 - } 92 - sessionDirs = []; 93 - }); 94 - 95 - describe("displayCommand formatting", () => { 96 - it("pty run shows command with args in list", () => { 97 - const dir = makeSessionDir(); 98 - const id = uniqueName(); 99 - 100 - runCli(dir, "run", "-d", "--id", id, "--", "echo", "hello", "world"); 101 - // Wait for session to start 102 - const start = Date.now(); 103 - while (Date.now() - start < 3000) { 104 - const meta = readMeta(dir, id); 105 - if (meta) break; 106 - } 107 - 108 - const meta = readMeta(dir, id); 109 - expect(meta).not.toBeNull(); 110 - expect(meta.displayCommand).toBe("echo hello world"); 111 - }, 15000); 112 - 113 - it("pty up shows toml command without duplication in list", () => { 114 - const projDir = fs.mkdtempSync(path.join(testRoot, "proj-")); 115 - const dir = makeSessionDir(); 116 - fs.writeFileSync(path.join(projDir, "pty.toml"), ` 117 - [sessions.serve] 118 - command = "echo server running" 119 - `); 120 - 121 - runCli(dir, "up", projDir); 122 - 123 - // pty up gives the session a random short id as the on-disk name and 124 - // sets `serve` as the displayName. Wait for the session to register, then 125 - // look up the on-disk name by displayName. 126 - const start = Date.now(); 127 - let sess: any = undefined; 128 - while (Date.now() - start < 3000) { 129 - const listResult = JSON.parse(runCli(dir, "list", "--json").stdout) as any[]; 130 - sess = listResult.find((s: any) => s.displayName === "serve"); 131 - if (sess) break; 132 - } 133 - expect(sess).toBeDefined(); 134 - const meta = readMeta(dir, sess.name); 135 - expect(meta).not.toBeNull(); 136 - expect(meta.displayCommand).toBe("echo server running"); 137 - // command should be /bin/sh, args should be ["-c", "echo server running"] 138 - expect(meta.command).toBe("/bin/sh"); 139 - 140 - // List output should show the command once, not duplicated 141 - const list = runCli(dir, "list"); 142 - const lines = list.stdout.split("\n").filter((l: string) => l.includes("serve")); 143 - expect(lines.length).toBeGreaterThan(0); 144 - // Count occurrences of "echo server running" — should be 1 145 - const matches = lines[0].match(/echo server running/g); 146 - expect(matches).toHaveLength(1); 147 - }, 15000); 148 - }); 149 - 150 - describe("pty kill on supervised sessions", () => { 151 - it("removes strategy tag when killing a supervised session", async () => { 152 - const dir = makeSessionDir(); 153 - const name = uniqueName(); 154 - await startDaemon(dir, name, "cat", [], { strategy: "permanent" }); 155 - 156 - const result = runCli(dir, "kill", name); 157 - expect(result.status).toBe(0); 158 - 159 - // Strategy tag should be removed 160 - const meta = readMeta(dir, name); 161 - // Meta might be null if cleanup happened, or present without strategy 162 - if (meta?.tags) { 163 - expect(meta.tags.strategy).toBeUndefined(); 164 - } 165 - }, 15000); 166 - 167 - it("warns about toml-managed sessions when killing", async () => { 168 - const dir = makeSessionDir(); 169 - const name = uniqueName(); 170 - await startDaemon(dir, name, "cat", [], { 171 - strategy: "permanent", 172 - ptyfile: "/some/path/pty.toml", 173 - }); 174 - 175 - const result = runCli(dir, "kill", name); 176 - expect(result.stderr).toContain("pty.toml"); 177 - expect(result.stderr).toContain("pty up"); 178 - }, 15000); 179 - }); 180 - 181 - describe("pty supervisor reset", () => { 182 - it("clears failed status and restart counter", async () => { 183 - const dir = makeSessionDir(); 184 - const name = uniqueName(); 185 - await startDaemon(dir, name, "true", [], { 186 - strategy: "permanent", 187 - "supervisor.status": "failed", 188 - }); 189 - await new Promise((r) => setTimeout(r, 1000)); // wait for exit 190 - 191 - // Write fake supervisor state 192 - const supDir = path.join(dir, "supervisor"); 193 - fs.mkdirSync(supDir, { recursive: true }); 194 - fs.writeFileSync(path.join(supDir, "state.json"), JSON.stringify({ 195 - sessions: { [name]: { restartCount: 5, restartWindowStart: Date.now(), failed: true, nextBackoffMs: 16000, lastRestartAt: Date.now() } }, 196 - savedAt: new Date().toISOString(), 197 - })); 198 - 199 - const result = runCli(dir, "supervisor", "reset", name); 200 - expect(result.status).toBe(0); 201 - expect(result.stdout).toContain("Reset"); 202 - 203 - // Failed tag should be removed 204 - const meta = readMeta(dir, name); 205 - expect(meta.tags["supervisor.status"]).toBeUndefined(); 206 - 207 - // State file should have reset counters 208 - const state = JSON.parse(fs.readFileSync(path.join(supDir, "state.json"), "utf-8")); 209 - expect(state.sessions[name].restartCount).toBe(0); 210 - expect(state.sessions[name].failed).toBe(false); 211 - }, 15000); 212 - 213 - it("reports when session is not failed", async () => { 214 - const dir = makeSessionDir(); 215 - const name = uniqueName(); 216 - await startDaemon(dir, name, "cat", [], { strategy: "permanent" }); 217 - 218 - const result = runCli(dir, "supervisor", "reset", name); 219 - expect(result.stdout).toContain("not in failed state"); 220 - }, 15000); 221 - }); 222 - 223 - describe("pty supervisor forget", () => { 224 - it("warns about toml-managed sessions", async () => { 225 - const dir = makeSessionDir(); 226 - const name = uniqueName(); 227 - await startDaemon(dir, name, "cat", [], { 228 - strategy: "permanent", 229 - ptyfile: "/some/path/pty.toml", 230 - }); 231 - 232 - const result = runCli(dir, "supervisor", "forget", name); 233 - expect(result.status).toBe(0); 234 - expect(result.stdout).toContain("Removed supervision"); 235 - expect(result.stderr).toContain("pty.toml"); 236 - expect(result.stderr).toContain("pty up"); 237 - }, 15000); 238 - }); 239 - 240 - describe("pty tag warning", () => { 241 - it("warns when modifying tags on toml-managed sessions", async () => { 242 - const dir = makeSessionDir(); 243 - const name = uniqueName(); 244 - await startDaemon(dir, name, "cat", [], { 245 - ptyfile: "/some/path/pty.toml", 246 - "ptyfile.session": "test", 247 - }); 248 - 249 - const result = runCli(dir, "tag", name, "custom=value"); 250 - expect(result.status).toBe(0); 251 - expect(result.stderr).toContain("pty.toml"); 252 - expect(result.stderr).toContain("pty up"); 253 - }, 15000); 254 - }); 255 - 256 - describe("spawnDaemon process leak", () => { 257 - it("kills orphaned daemon on waitForSocket timeout", () => { 258 - const dir = makeSessionDir(); 259 - 260 - // Try to spawn with a nonexistent command — daemon will crash 261 - const result = runCli(dir, "run", "-d", "--id", "leak-test", "--", "/nonexistent/command"); 262 - 263 - // The command should have failed 264 - expect(result.status).not.toBe(0); 265 - 266 - // No PID file should exist (daemon was cleaned up) 267 - const pidFile = path.join(dir, "leak-test.pid"); 268 - let leaked = false; 269 - try { 270 - const pid = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10); 271 - // Check if this process is still alive 272 - try { process.kill(pid, 0); leaked = true; } catch {} 273 - } catch {} 274 - expect(leaked).toBe(false); 275 - }, 15000); 276 - }); 277 - 278 - describe("supervisor re-reads pty.toml on restart", () => { 279 - it("uses updated toml command when restarting", async () => { 280 - const projDir = fs.mkdtempSync(path.join(testRoot, "proj-")); 281 - const dir = makeSessionDir(); 282 - 283 - // Create initial toml 284 - fs.writeFileSync(path.join(projDir, "pty.toml"), ` 285 - [sessions.reread] 286 - command = "echo original" 287 - tags = { strategy = "permanent" } 288 - `); 289 - 290 - // Start via pty up 291 - runCli(dir, "up", projDir); 292 - await new Promise((r) => setTimeout(r, 1500)); // wait for exit + metadata 293 - 294 - // Update the toml command 295 - fs.writeFileSync(path.join(projDir, "pty.toml"), ` 296 - [sessions.reread] 297 - command = "echo updated" 298 - tags = { strategy = "permanent" } 299 - `); 300 - 301 - // Start supervisor to restart the session 302 - const sup = spawn(nodeBin, [cliPath, "supervisor", "start"], { 303 - detached: true, 304 - stdio: ["ignore", "ignore", "ignore"], 305 - env: { ...process.env, PTY_SESSION_DIR: dir }, 306 - }); 307 - sup.unref(); 308 - bgPids.push(sup.pid!); 309 - 310 - // Wait for supervisor to restart the session 311 - await new Promise((r) => setTimeout(r, 5000)); 312 - 313 - // Check the restarted session's metadata 314 - const meta = readMeta(dir, "reread"); 315 - if (meta) { 316 - // displayCommand should reflect the updated toml 317 - expect(meta.displayCommand).toBe("echo updated"); 318 - } 319 - }, 20000); 320 - });
-149
tests/supervisor-service-install.test.ts
··· 1 - import { describe, it, expect, afterEach } from "vitest"; 2 - import * as fs from "node:fs"; 3 - import * as os from "node:os"; 4 - import * as path from "node:path"; 5 - import { fileURLToPath } from "node:url"; 6 - import { spawn, spawnSync } from "node:child_process"; 7 - 8 - const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 - const nodeBin = process.execPath; 10 - const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 11 - 12 - // Platform gates. Both installers are Linux-only — macOS has no systemd or 13 - // runit. Rather than fail the whole suite on the wrong platform, skip the 14 - // individual tests so developers on Mac see a clean "skipped" rather than 15 - // red "runsvdir is not installed" errors. CI runs on Linux and exercises 16 - // the real code path. 17 - function hasCommand(cmd: string): boolean { 18 - return spawnSync("sh", ["-lc", `command -v ${cmd} >/dev/null 2>&1`]).status === 0; 19 - } 20 - const hasSystemdUser = process.platform === "linux" && hasCommand("systemctl"); 21 - const hasRunit = process.platform === "linux" && hasCommand("runsvdir"); 22 - 23 - const bgPids: number[] = []; 24 - const cleanupUnits: string[] = []; 25 - const cleanupPaths: string[] = []; 26 - 27 - function runCli(env: NodeJS.ProcessEnv, ...args: string[]) { 28 - return spawnSync(nodeBin, [cliPath, ...args], { 29 - env, 30 - encoding: "utf-8", 31 - timeout: 20000, 32 - }); 33 - } 34 - 35 - function waitForFile(filePath: string, timeoutMs = 10000): Promise<void> { 36 - const start = Date.now(); 37 - return new Promise((resolve, reject) => { 38 - function check() { 39 - if (fs.existsSync(filePath)) return resolve(); 40 - if (Date.now() - start > timeoutMs) return reject(new Error(`Timeout waiting for ${filePath}`)); 41 - setTimeout(check, 100); 42 - } 43 - check(); 44 - }); 45 - } 46 - 47 - function killPid(pid: number | undefined) { 48 - if (!pid) return; 49 - try { process.kill(pid, "SIGTERM"); } catch {} 50 - } 51 - 52 - afterEach(() => { 53 - // systemctl cleanup is a no-op on macOS where the cmd doesn't exist. 54 - // Guarding saves noisy spawn errors in the test log. 55 - if (hasSystemdUser) { 56 - for (const unit of cleanupUnits.splice(0)) { 57 - spawnSync("systemctl", ["--user", "disable", "--now", unit], { encoding: "utf-8" }); 58 - spawnSync("systemctl", ["--user", "reset-failed", unit], { encoding: "utf-8" }); 59 - const unitPath = path.join(os.homedir(), ".config", "systemd", "user", unit); 60 - try { fs.unlinkSync(unitPath); } catch {} 61 - } 62 - spawnSync("systemctl", ["--user", "daemon-reload"], { encoding: "utf-8" }); 63 - } else { 64 - cleanupUnits.length = 0; 65 - } 66 - 67 - for (const pid of bgPids.splice(0)) killPid(pid); 68 - for (const p of cleanupPaths.splice(0)) { 69 - try { fs.rmSync(p, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch {} 70 - } 71 - }); 72 - 73 - describe("supervisor service installers", () => { 74 - it.skipIf(!hasSystemdUser)("installs and uninstalls a user systemd service", async () => { 75 - const sessionDir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-systemd-")); 76 - cleanupPaths.push(sessionDir); 77 - 78 - const unitBase = `pty-supervisor-test-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; 79 - const unitFile = `${unitBase}.service`; 80 - cleanupUnits.push(unitFile); 81 - 82 - const env = { ...process.env, PTY_SESSION_DIR: sessionDir }; 83 - const install = runCli(env, "supervisor", "systemd", "install", "--name", unitBase); 84 - expect(install.status).toBe(0); 85 - expect(install.stdout).toContain(unitFile); 86 - 87 - await waitForFile(path.join(sessionDir, "supervisor", "supervisor.pid")); 88 - 89 - const active = spawnSync("systemctl", ["--user", "is-active", unitFile], { 90 - encoding: "utf-8", 91 - timeout: 10000, 92 - }); 93 - expect(active.status).toBe(0); 94 - expect(active.stdout.trim()).toBe("active"); 95 - 96 - const uninstall = runCli(env, "supervisor", "systemd", "uninstall", "--name", unitBase); 97 - expect(uninstall.status).toBe(0); 98 - 99 - const activeAfter = spawnSync("systemctl", ["--user", "is-active", unitFile], { 100 - encoding: "utf-8", 101 - timeout: 10000, 102 - }); 103 - expect(activeAfter.status).not.toBe(0); 104 - }, 30000); 105 - 106 - it.skipIf(!hasRunit)("installs a runit service that can be started by a private runsvdir", async () => { 107 - const root = fs.mkdtempSync(path.join(os.tmpdir(), "pty-runit-")); 108 - const sessionDir = path.join(root, "sessions"); 109 - const svDir = path.join(root, "sv"); 110 - const serviceDir = path.join(root, "service"); 111 - fs.mkdirSync(sessionDir, { recursive: true }); 112 - cleanupPaths.push(root); 113 - 114 - const serviceName = `pty-supervisor-test-${Math.random().toString(36).slice(2, 8)}`; 115 - const env = { ...process.env, PTY_SESSION_DIR: sessionDir }; 116 - 117 - const install = runCli( 118 - env, 119 - "supervisor", "runit", "install", 120 - "--name", serviceName, 121 - "--svdir", svDir, 122 - "--service-dir", serviceDir, 123 - ); 124 - expect(install.status).toBe(0); 125 - expect(fs.existsSync(path.join(svDir, serviceName, "run"))).toBe(true); 126 - expect(fs.lstatSync(path.join(serviceDir, serviceName)).isSymbolicLink()).toBe(true); 127 - 128 - const child = spawn("runsvdir", [serviceDir], { 129 - detached: true, 130 - stdio: ["ignore", "ignore", "ignore"], 131 - env, 132 - }); 133 - child.unref(); 134 - bgPids.push(child.pid!); 135 - 136 - await waitForFile(path.join(sessionDir, "supervisor", "supervisor.pid")); 137 - 138 - const uninstall = runCli( 139 - env, 140 - "supervisor", "runit", "uninstall", 141 - "--name", serviceName, 142 - "--svdir", svDir, 143 - "--service-dir", serviceDir, 144 - ); 145 - expect(uninstall.status).toBe(0); 146 - expect(fs.existsSync(path.join(svDir, serviceName))).toBe(false); 147 - expect(fs.existsSync(path.join(serviceDir, serviceName))).toBe(false); 148 - }, 30000); 149 - });
-270
tests/supervisor.test.ts
··· 1 - import { describe, it, expect, afterEach, afterAll } from "vitest"; 2 - import * as fs from "node:fs"; 3 - import * as os from "node:os"; 4 - import * as path from "node:path"; 5 - import { fileURLToPath } from "node:url"; 6 - import { spawn, spawnSync } from "node:child_process"; 7 - 8 - const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 - const nodeBin = process.execPath; 10 - const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 11 - const serverModule = path.join(__dirname, "..", "dist", "server.js"); 12 - const supervisorModule = path.join(__dirname, "..", "dist", "supervisor.js"); 13 - 14 - const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-sup-")); 15 - afterAll(() => { 16 - fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 17 - }); 18 - 19 - let bgPids: number[] = []; 20 - let sessionDirs: string[] = []; 21 - 22 - function makeSessionDir(): string { 23 - const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 24 - sessionDirs.push(dir); 25 - return dir; 26 - } 27 - 28 - let nameCounter = 0; 29 - function uniqueName(): string { 30 - return `sup${++nameCounter}-${Math.random().toString(36).slice(2, 6)}`; 31 - } 32 - 33 - async function startDaemon( 34 - sessionDir: string, 35 - name: string, 36 - command: string, 37 - args: string[] = [], 38 - tags?: Record<string, string>, 39 - ): Promise<number> { 40 - const config = JSON.stringify({ 41 - name, command, args, displayCommand: command, 42 - cwd: os.tmpdir(), rows: 24, cols: 80, tags, 43 - }); 44 - const child = spawn(nodeBin, [serverModule], { 45 - detached: true, 46 - stdio: ["ignore", "ignore", "pipe"], 47 - env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir }, 48 - }); 49 - let stderr = ""; 50 - child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); 51 - let exitCode: number | null = null; 52 - child.on("exit", (code) => { exitCode = code; }); 53 - (child.stderr as any)?.unref?.(); 54 - child.unref(); 55 - 56 - const socketPath = path.join(sessionDir, `${name}.sock`); 57 - const start = Date.now(); 58 - while (Date.now() - start < 5000) { 59 - if (exitCode !== null) throw new Error(`Daemon exited: ${stderr}`); 60 - try { 61 - fs.statSync(socketPath); 62 - await new Promise((r) => setTimeout(r, 100)); 63 - bgPids.push(child.pid!); 64 - return child.pid!; 65 - } catch {} 66 - await new Promise((r) => setTimeout(r, 50)); 67 - } 68 - throw new Error("Timeout waiting for daemon"); 69 - } 70 - 71 - function startSupervisor(sessionDir: string): number { 72 - const child = spawn(nodeBin, [cliPath, "supervisor", "start"], { 73 - detached: true, 74 - stdio: ["ignore", "ignore", "ignore"], 75 - env: { ...process.env, PTY_SESSION_DIR: sessionDir }, 76 - }); 77 - child.unref(); 78 - bgPids.push(child.pid!); 79 - return child.pid!; 80 - } 81 - 82 - function waitForFile(filePath: string, timeoutMs = 5000): Promise<void> { 83 - const start = Date.now(); 84 - return new Promise((resolve, reject) => { 85 - function check() { 86 - if (Date.now() - start > timeoutMs) { 87 - reject(new Error(`Timeout waiting for ${filePath}`)); 88 - return; 89 - } 90 - try { 91 - fs.statSync(filePath); 92 - resolve(); 93 - return; 94 - } catch {} 95 - setTimeout(check, 100); 96 - } 97 - check(); 98 - }); 99 - } 100 - 101 - function readMeta(sessionDir: string, name: string): any { 102 - try { 103 - return JSON.parse(fs.readFileSync(path.join(sessionDir, `${name}.json`), "utf-8")); 104 - } catch { return null; } 105 - } 106 - 107 - afterEach(() => { 108 - for (const pid of bgPids) { try { process.kill(pid, "SIGTERM"); } catch {} } 109 - bgPids = []; 110 - // Give supervisor time to release lock 111 - const start = Date.now(); 112 - while (Date.now() - start < 200) {} 113 - for (const dir of sessionDirs) { 114 - try { 115 - for (const e of fs.readdirSync(dir)) { 116 - try { fs.unlinkSync(path.join(dir, e)); } catch {} 117 - } 118 - } catch {} 119 - } 120 - sessionDirs = []; 121 - }); 122 - 123 - describe("supervisor", () => { 124 - it("restarts a permanent session that exits", async () => { 125 - const dir = makeSessionDir(); 126 - process.env.PTY_SESSION_DIR = dir; 127 - 128 - // Start a session that exits after 0.5s 129 - await startDaemon(dir, "restartable", "sh", ["-c", "sleep 0.5"], { strategy: "permanent" }); 130 - 131 - // Start the supervisor 132 - startSupervisor(dir); 133 - await new Promise((r) => setTimeout(r, 300)); // let supervisor start 134 - 135 - // Wait for session to exit + supervisor scan + backoff + restart 136 - // Session exits after 0.5s, supervisor scans every 30s but also uses fs.watch, 137 - // initial backoff is 1s, then spawnDaemon takes a moment 138 - const sockPath = path.join(dir, "restartable.sock"); 139 - const start = Date.now(); 140 - let restarted = false; 141 - while (Date.now() - start < 10000) { 142 - await new Promise((r) => setTimeout(r, 500)); 143 - // Check if a new socket appeared (the old one gets cleaned up by cleanupAll) 144 - const meta = readMeta(dir, "restartable"); 145 - // After restart, metadata should have no exitedAt (fresh session) 146 - if (meta && !meta.exitedAt) { 147 - try { fs.statSync(sockPath); restarted = true; break; } catch {} 148 - } 149 - } 150 - 151 - expect(restarted).toBe(true); 152 - }, 20000); 153 - 154 - it("cleans up temporary sessions on exit", async () => { 155 - const dir = makeSessionDir(); 156 - process.env.PTY_SESSION_DIR = dir; 157 - 158 - // Start a temporary session that exits immediately 159 - await startDaemon(dir, "tempjob", "true", [], { strategy: "temporary" }); 160 - 161 - // Start the supervisor 162 - startSupervisor(dir); 163 - await new Promise((r) => setTimeout(r, 300)); 164 - 165 - // Wait for exit + cleanup 166 - await new Promise((r) => setTimeout(r, 2000)); 167 - 168 - // Metadata should be gone 169 - const meta = readMeta(dir, "tempjob"); 170 - expect(meta).toBeNull(); 171 - }, 15000); 172 - 173 - it("does not restart sessions without strategy tag", async () => { 174 - const dir = makeSessionDir(); 175 - process.env.PTY_SESSION_DIR = dir; 176 - 177 - // Start a session without strategy that exits 178 - await startDaemon(dir, "nosupervise", "sh", ["-c", "sleep 0.3"]); 179 - 180 - startSupervisor(dir); 181 - await new Promise((r) => setTimeout(r, 300)); 182 - 183 - // Wait for exit + potential restart window 184 - await new Promise((r) => setTimeout(r, 3000)); 185 - 186 - // Should NOT have restarted — no socket 187 - const sockPath = path.join(dir, "nosupervise.sock"); 188 - let exists = false; 189 - try { fs.statSync(sockPath); exists = true; } catch {} 190 - expect(exists).toBe(false); 191 - }, 15000); 192 - 193 - it("pty tag can add strategy to a running session", async () => { 194 - const dir = makeSessionDir(); 195 - process.env.PTY_SESSION_DIR = dir; 196 - 197 - await startDaemon(dir, "latejoin", "cat"); 198 - 199 - const result = spawnSync(nodeBin, [cliPath, "tag", "latejoin", "strategy=permanent"], { 200 - env: { ...process.env, PTY_SESSION_DIR: dir }, 201 - encoding: "utf-8", 202 - }); 203 - expect(result.status).toBe(0); 204 - 205 - const meta = readMeta(dir, "latejoin"); 206 - expect(meta.tags.strategy).toBe("permanent"); 207 - }, 15000); 208 - 209 - it("pty supervisor forget removes strategy tag", async () => { 210 - const dir = makeSessionDir(); 211 - process.env.PTY_SESSION_DIR = dir; 212 - 213 - await startDaemon(dir, "forgetme", "cat", [], { strategy: "permanent" }); 214 - 215 - const result = spawnSync(nodeBin, [cliPath, "supervisor", "forget", "forgetme"], { 216 - env: { ...process.env, PTY_SESSION_DIR: dir }, 217 - encoding: "utf-8", 218 - }); 219 - expect(result.status).toBe(0); 220 - expect(result.stdout).toContain("Removed supervision"); 221 - 222 - const meta = readMeta(dir, "forgetme"); 223 - expect(meta.tags?.strategy).toBeUndefined(); 224 - }, 15000); 225 - 226 - it("pty down stops supervised sessions and removes strategy tag", async () => { 227 - const projDir = fs.mkdtempSync(path.join(testRoot, "proj-")); 228 - const dir = makeSessionDir(); 229 - fs.writeFileSync(path.join(projDir, "pty.toml"), ` 230 - [sessions.guarded] 231 - command = "cat" 232 - tags = { strategy = "permanent" } 233 - `); 234 - 235 - // Start via pty up 236 - spawnSync(nodeBin, [cliPath, "up", projDir], { 237 - env: { ...process.env, PTY_SESSION_DIR: dir }, 238 - encoding: "utf-8", 239 - }); 240 - 241 - // Stop it 242 - const result = spawnSync(nodeBin, [cliPath, "down", projDir], { 243 - env: { ...process.env, PTY_SESSION_DIR: dir }, 244 - encoding: "utf-8", 245 - }); 246 - 247 - expect(result.status).toBe(0); 248 - expect(result.stdout).toContain("stopped"); 249 - expect(result.stdout).toContain("removed from supervision"); 250 - 251 - // Strategy tag should be gone 252 - const meta = readMeta(dir, "guarded"); 253 - expect(meta?.tags?.strategy).toBeUndefined(); 254 - }, 15000); 255 - 256 - it("pty list shows strategy markers", async () => { 257 - const dir = makeSessionDir(); 258 - process.env.PTY_SESSION_DIR = dir; 259 - 260 - await startDaemon(dir, "marked", "cat", [], { strategy: "permanent" }); 261 - 262 - const result = spawnSync(nodeBin, [cliPath, "list"], { 263 - env: { ...process.env, PTY_SESSION_DIR: dir }, 264 - encoding: "utf-8", 265 - }); 266 - 267 - expect(result.stdout).toContain("marked"); 268 - expect(result.stdout).toContain("[permanent]"); 269 - }, 15000); 270 - });
+8 -1
tests/tags-helpers.test.ts
··· 64 64 expect(isReservedTagKey("ptyfile")).toBe(true); 65 65 expect(isReservedTagKey("ptyfile.session")).toBe(true); 66 66 expect(isReservedTagKey("ptyfile.tags")).toBe(true); 67 - expect(isReservedTagKey("supervisor.status")).toBe(true); 68 67 expect(isReservedTagKey("strategy")).toBe(true); 68 + }); 69 + 70 + it("does NOT reserve removed/user-facing keys", () => { 71 + // supervisor.status is gone with the supervisor — must not still be 72 + // reserved. parent= is a user-facing tag (drives orphan-kill in 73 + // `pty gc`) so it stays visible in `pty list` by default. 74 + expect(isReservedTagKey("supervisor.status")).toBe(false); 75 + expect(isReservedTagKey("parent")).toBe(false); 69 76 }); 70 77 71 78 it("flags any key starting with `:` (tool-owned convention)", () => {
+28 -19
tests/wrapper-signal-forwarding.test.ts
··· 1 1 // Verifies that bin/pty forwards SIGTERM/SIGINT to the inner cli.js child. 2 - // Without forwarding, systemd's KillMode=process leaves the inner supervisor 3 - // orphaned and still holding supervisor.lock — the new unit invocation then 4 - // fails with "another supervisor is already running". 2 + // systemd's `KillMode=process` only signals the leader (the bin/pty shell 3 + // shim) and lets children become orphans unless the leader propagates the 4 + // signal. Without forwarding, the inner cli.js survives a unit `stop` and 5 + // the next start fails because the orphan still holds whatever resource 6 + // it owned (file watchers, sockets, etc.). 5 7 6 8 import { describe, it, expect, afterEach } from "vitest"; 7 9 import * as fs from "node:fs"; ··· 17 19 const sessionDirs: string[] = []; 18 20 const trackedPids: number[] = []; 19 21 afterEach(() => { 20 - // Belt-and-braces: if a test failed mid-way, kill any inner cli.js it left 21 - // behind so the next run isn't poisoned by an orphan holding the lock. 22 22 for (const pid of trackedPids) { 23 23 try { process.kill(pid, "SIGKILL"); } catch {} 24 24 } ··· 50 50 } 51 51 52 52 describe("bin/pty signal forwarding", () => { 53 - it("propagates SIGTERM to the inner cli.js so the supervisor releases its lock", async () => { 53 + it("propagates SIGTERM to the inner cli.js (events --all is long-lived)", async () => { 54 54 const sessionDir = makeSessionDir(); 55 55 56 - // `supervisor start` is the canonical long-lived command and the one the 57 - // dev3 regression hit. Its SIGTERM handler calls Supervisor.stop() which 58 - // releases supervisor.lock — so a clean shutdown leaves no lock file. 59 - const wrapper = spawn(nodeBin, [wrapperPath, "supervisor", "start"], { 56 + // `events --all` is a long-lived command that registers a SIGINT 57 + // handler and runs an EventFollower until the process is signalled. 58 + // Same shape as the supervisor used to be: long-running, owns file 59 + // watchers, must exit cleanly when the wrapper relays a signal. 60 + const wrapper = spawn(nodeBin, [wrapperPath, "events", "--all"], { 60 61 env: { ...process.env, PTY_SESSION_DIR: sessionDir }, 61 62 stdio: ["ignore", "pipe", "pipe"], 62 63 }); ··· 70 71 let exitSignal: NodeJS.Signals | null = null; 71 72 wrapper.on("exit", (c, s) => { exitCode = c; exitSignal = s; }); 72 73 73 - // Wait for the supervisor to fully start (it writes supervisor.pid). 74 - const pidPath = path.join(sessionDir, "supervisor", "supervisor.pid"); 75 - const lockPath = path.join(sessionDir, "supervisor.lock"); 76 - const ready = await waitFor(() => fs.existsSync(pidPath) && fs.existsSync(lockPath), 5000); 77 - expect(ready, `supervisor never started; stdout=${stdout} stderr=${stderr}`).toBe(true); 74 + // Wait long enough for the wrapper to fork the inner node cli.js. 75 + // No specific marker; sleep briefly then look at the process tree. 76 + await new Promise((r) => setTimeout(r, 800)); 77 + 78 + // Find the inner cli.js by walking the wrapper's child processes via 79 + // /proc on Linux, or via `pgrep -P` everywhere. We use ps -o pid -p 80 + // <wrapper-pid> first then list children with a tree walk fallback. 81 + const psResult = (() => { 82 + try { 83 + const { execFileSync } = require("node:child_process") as typeof import("node:child_process"); 84 + return execFileSync("pgrep", ["-P", String(wrapper.pid)], { encoding: "utf-8" }).trim(); 85 + } catch { 86 + return ""; 87 + } 88 + })(); 89 + expect(psResult, `pgrep failed to find a child of pid ${wrapper.pid}; stdout=${stdout} stderr=${stderr}`).not.toBe(""); 78 90 79 - const innerPid = parseInt(fs.readFileSync(pidPath, "utf-8").trim(), 10); 91 + const innerPid = parseInt(psResult.split("\n")[0]!.trim(), 10); 80 92 trackedPids.push(innerPid); 81 93 expect(innerPid).toBeGreaterThan(0); 82 94 expect(innerPid).not.toBe(wrapper.pid); ··· 90 102 91 103 const innerDied = await waitFor(() => !isAlive(innerPid), 5000); 92 104 expect(innerDied, `inner cli.js (pid ${innerPid}) survived wrapper SIGTERM — signal not forwarded`).toBe(true); 93 - 94 - // Clean shutdown should release the lock; a SIGKILLed supervisor would leave it. 95 - expect(fs.existsSync(lockPath), "supervisor.lock not released — child shutdown was not graceful").toBe(false); 96 105 }, 20000); 97 106 });