···2233## Unreleased
4455+### Breaking changes — supervisor replaced by `pty gc`
66+- **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.
77+- **`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.
88+- **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.
99+- **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`).
1010+- **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`.
1111+- **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`.
1212+- **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.
1313+- **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.
1414+- **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:`.
1515+- **`gc()` client API** now returns `GcResult` (`{ removed, killedOrphanChildren, respawned, respawnFailed }`) instead of `string[]`. `pruneOrphanLayoutTags` is unchanged. New `GcResult` type exported from `@myobie/pty/client`.
1616+- **Migration** — if you installed via `pty supervisor launchd install`:
1717+ ```sh
1818+ launchctl unload ~/Library/LaunchAgents/com.myobie.pty.supervisor.plist
1919+ rm ~/Library/LaunchAgents/com.myobie.pty.supervisor.plist
2020+ rm -rf ~/.local/pty/launchd ~/.local/state/pty/supervisor
2121+ pty gc --print-launchd-plist > ~/Library/LaunchAgents/com.myobie.pty.gc.plist
2222+ launchctl load ~/Library/LaunchAgents/com.myobie.pty.gc.plist
2323+ ```
2424+ 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`.
2525+526### Breaking: on-disk name decoupled from display label
627- **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).
728- **`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
···111111pty restart myserver # restart an exited session
112112pty kill myserver # terminate a running session
113113pty rm myserver # remove an exited session's metadata
114114-pty gc # remove all exited sessions
114114+pty gc # reconcile sessions: kill orphan children, respawn permanents, sweep exited
115115+pty gc --dry-run # preview what gc would do without changing anything
116116+pty gc --print-launchd-plist > ~/Library/LaunchAgents/com.myobie.pty.gc.plist # install macOS auto-gc
115117pty tag myserver role=web env=prod # set one or more tags on a session
116118pty tag myserver --rm role --rm env # remove one or more tags
117119pty tag-multi --filter-tag role=web env=prod # bulk write across matching sessions
118120pty tag-multi --all --json # bulk read tags across every session
119121pty tag-multi --all --yes audit=today # write to every session (--yes required)
120120-121121-pty supervisor start # start the session supervisor
122122-pty supervisor stop # stop the supervisor
123123-pty supervisor status # show supervised sessions
124124-pty supervisor forget myserver # stop supervising a session
125125-pty supervisor reset myserver # reset a failed session for retry
126126-pty supervisor launchd install # install launchd auto-start (macOS)
127127-pty supervisor systemd install # install user-level systemd auto-start (Linux)
128128-pty supervisor runit install # install runit service files
129122130123pty up # start all sessions from ./pty.toml
131124pty up ./backend # start sessions from ./backend/pty.toml
···222215LOG_LEVEL = "debug"
223216```
224217225225-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.
218218+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.
226219227227-### Supervisor
220220+### Permanent sessions
228221229229-The supervisor keeps sessions alive by watching for the `strategy` tag:
222222+Tag a session with `strategy=permanent` and `pty gc` will respawn it whenever its daemon exits or vanishes:
230223231224```sh
232232-# Tag a session as permanent
233225pty tag myserver strategy=permanent
234226235235-# Start the supervisor
236236-pty supervisor start
237237-238238-# If myserver exits, the supervisor restarts it with exponential backoff
239239-# Max 5 restarts in 60 seconds before marking as [failed]
240240-241241-pty supervisor status # show supervised sessions
242242-pty supervisor forget myserver # stop supervising
243243-pty supervisor stop # stop the supervisor
227227+# After myserver exits — manually or by crash — the next `pty gc` run
228228+# brings it back. No backoff, no retry budget; the cron interval below
229229+# is the rate limit. Sessions managed by pty.toml re-read the toml on
230230+# respawn so command/env edits take effect immediately.
231231+pty gc
244232```
245233246246-Sessions can be supervised from `pty.toml` by setting the `strategy` tag:
234234+From `pty.toml`:
247235248236```toml
249237[sessions.serve]
···251239tags = { strategy = "permanent" }
252240```
253241254254-For auto-start:
242242+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.
255243256256-- macOS: `pty supervisor launchd install` — compiles a small wrapper binary and prompts for Full Disk Access (required for sessions on external/removable volumes)
257257-- 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`.
258258-- 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`.
244244+### Parent-child sessions
245245+246246+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:
247247+248248+```sh
249249+pty run -d --name webserver -- bin/serve
250250+pty run -d --name webserver-tail --tag parent=webserver -- tail -f log/web.log
251251+# If `webserver` dies, the next `pty gc` SIGTERMs `webserver-tail`.
252252+```
253253+254254+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).
255255+256256+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.
257257+258258+### Auto-running gc
259259+260260+`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:
261261+262262+```sh
263263+pty gc --print-launchd-plist > ~/Library/LaunchAgents/com.myobie.pty.gc.plist
264264+launchctl load ~/Library/LaunchAgents/com.myobie.pty.gc.plist
265265+```
266266+267267+Default interval is 30 seconds; tune with `pty gc --print-launchd-plist --interval=15` etc. Output goes to `~/.local/state/pty/gc.log`.
268268+269269+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.
270270+271271+For other systems:
272272+273273+- Linux + cron: `* * * * * pty gc >> ~/.local/state/pty/gc.log 2>&1` (one-minute resolution; tune to taste).
274274+- Linux + systemd-timer: `OnUnitActiveSec=30s` on a `pty.gc.service` that `ExecStart=pty gc`.
275275+- runit: a `run` script that loops `pty gc` with a `sleep 30` between iterations.
276276+277277+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.
259278260279### Plugins
261280
+4-11
completions/pty.bash
···66 COMPREPLY=()
77 cur="${COMP_WORDS[COMP_CWORD]}"
88 prev="${COMP_WORDS[COMP_CWORD-1]}"
99- commands="run attach a exec peek send events list ls stats restart kill rm remove gc tag supervisor up down wrap unwrap test help"
99+ commands="run attach a exec peek send events list ls stats restart kill rm remove gc tag up down wrap unwrap test help"
10101111 # Complete subcommand
1212 if [[ ${COMP_CWORD} -eq 1 ]]; then
···4141 fi
4242 ;;
4343 gc)
4444- # No arguments
4444+ if [[ "${cur}" == -* ]]; then
4545+ COMPREPLY=($(compgen -W "--dry-run -n --print-launchd-plist --interval" -- "${cur}"))
4646+ fi
4547 ;;
4648 wrap)
4749 if [[ "${cur}" == -* ]]; then
···7173 # Before --, complete flags
7274 if [[ "${cur}" == -* ]]; then
7375 COMPREPLY=($(compgen -W "--detach -d --attach -a --ephemeral -e --name --cwd --tag" -- "${cur}"))
7474- fi
7575- ;;
7676- supervisor)
7777- if [[ ${COMP_CWORD} -eq 2 ]]; then
7878- COMPREPLY=($(compgen -W "start stop status forget reset launchd" -- "${cur}"))
7979- elif [[ "${COMP_WORDS[2]}" == "forget" || "${COMP_WORDS[2]}" == "reset" ]]; then
8080- COMPREPLY=($(compgen -W "${names}" -- "${cur}"))
8181- elif [[ "${COMP_WORDS[2]}" == "launchd" ]]; then
8282- COMPREPLY=($(compgen -W "install uninstall" -- "${cur}"))
8376 fi
8477 ;;
8578 esac
+5-4
completions/pty.fish
···5858complete -c pty -n __pty_needs_command -a kill -d 'Kill a running session'
5959complete -c pty -n __pty_needs_command -a rm -d 'Remove an exited session'
6060complete -c pty -n __pty_needs_command -a remove -d 'Remove an exited session'
6161-complete -c pty -n __pty_needs_command -a gc -d 'Remove all exited sessions'
6161+complete -c pty -n __pty_needs_command -a gc -d 'Reconcile sessions (kill orphan children, respawn permanents, sweep exited)'
6262complete -c pty -n __pty_needs_command -a tag -d 'Show or modify session tags'
6363-complete -c pty -n __pty_needs_command -a supervisor -d 'Manage the session supervisor'
6463complete -c pty -n __pty_needs_command -a up -d 'Start sessions from pty.toml'
6564complete -c pty -n __pty_needs_command -a down -d 'Stop sessions from pty.toml'
6665complete -c pty -n __pty_needs_command -a wrap -d 'Auto-wrap a command in pty sessions'
···137136complete -c pty -n '__pty_using_command tag' -a '(__pty_sessions)' -d 'Session'
138137complete -c pty -n '__pty_using_command tag' -l rm -d 'Remove a tag'
139138140140-# supervisor: subcommands
141141-complete -c pty -n '__pty_using_command supervisor' -a 'start stop status forget reset launchd' -d 'Subcommand'
139139+# gc: flags
140140+complete -c pty -n '__pty_using_command gc' -s n -l dry-run -d 'Preview without changing anything'
141141+complete -c pty -n '__pty_using_command gc' -l print-launchd-plist -d 'Print a macOS launchd plist that runs pty gc'
142142+complete -c pty -n '__pty_using_command gc' -l interval -x -d 'Plist StartInterval in seconds (default 30)'
142143
+5-23
completions/pty.zsh
···3838 'kill:Kill a running session'
3939 'rm:Remove an exited session'
4040 'remove:Remove an exited session'
4141- 'gc:Remove all exited sessions'
4141+ 'gc:Reconcile sessions (kill orphan children, respawn permanents, sweep exited)'
4242 'tag:Show or modify session tags'
4343- 'supervisor:Manage the session supervisor'
4443 'up:Start sessions from pty.toml'
4544 'down:Stop sessions from pty.toml'
4645 'wrap:Auto-wrap a command in pty sessions'
···108107 '--tags[Show tags as #key=value]'
109108 ;;
110109 gc)
111111- # No arguments
110110+ _arguments \
111111+ '(-n --dry-run)'{-n,--dry-run}'[Preview without changing anything]' \
112112+ '--print-launchd-plist[Print a macOS launchd plist that runs pty gc]' \
113113+ '--interval[Plist StartInterval in seconds (default 30)]:seconds:'
112114 ;;
113115 tag)
114116 _arguments '1:session:_pty_sessions'
115115- ;;
116116- supervisor)
117117- local -a subcmds
118118- subcmds=(
119119- 'start:Start the supervisor'
120120- 'stop:Stop the supervisor'
121121- 'status:Show supervised sessions'
122122- 'forget:Stop supervising a session'
123123- 'reset:Reset a failed session for retry'
124124- 'launchd:Register/unregister with macOS launchd'
125125- )
126126- _arguments '1:subcommand:->subcmd' '*::arg:->subargs'
127127- case $state in
128128- subcmd) _describe 'subcommand' subcmds ;;
129129- subargs)
130130- case ${words[1]} in
131131- forget|reset) _arguments '1:session:_pty_sessions' ;;
132132- launchd) _arguments '1:action:(install uninstall)' ;;
133133- esac ;;
134134- esac
135117 ;;
136118 wrap)
137119 _arguments \
+2
docs/SKILL.md
···15151616## Session lifecycle
17171818+`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.
1919+1820### 1. Create a detached session
19212022```bash
+6-6
docs/disk-layout.md
···1616| `<name>.pid` | daemon pid (decimal) | 2 |
1717| `<name>.lock` | creation-race lock | 2 |
1818| `theme` | last-selected TUI theme | 2 |
1919-| `supervisor/state.json` | supervisor state | 2 (internal) |
1919+| `gc.log` | stdout/stderr of `pty gc` when run by launchd/cron (only present after auto-running gc is installed) | 2 |
2020| `<name>.json.tmp.<pid>.<rand>` | atomic-write tmp — readers MUST ignore | n/a |
2121| `<name>.events.jsonl.tmp.<pid>.<rand>` | same | n/a |
2222···4848```
49495050- Status (`running` / `exited` / `vanished`) is *derived* from socket + pid, not stored.
5151-- Reserved tag keys (`ptyfile*`, `strategy`, `supervisor.status`, anything starting with `:`) are pty/tool-internal; hidden from `pty list` unless `--tags`.
5151+- Reserved tag keys (`ptyfile*`, `strategy`, anything starting with `:`) are pty/tool-internal; hidden from `pty list` unless `--tags`.
5252+- User-facing tags that drive pty behavior but are visible by default:
5353+ - `strategy=permanent` — `pty gc` respawns the session when its daemon exits (the historic supervisor's role; now stateless and run on a cron).
5454+ - `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.
5255- Concurrent writers: last-write-wins; readers never see torn files. Cross-process writers can lose updates to the read-modify-write window.
53565457## `<name>.events.jsonl` (tier 1)
···6770| `session_start` | `tags?` |
6871| `session_exit` | `exitCode` |
6972| `session_exec` | `previousCommand, command` |
7070-| `session_restart` | `restartCount, backoffMs` |
7171-| `session_failed` | `restartCount, reason` |
7272-| `supervisor_start` | — |
7373-| `supervisor_stop` | — |
7373+| `session_respawn` | — (`pty gc` respawned a `strategy=permanent` session) |
7474| `display_name_change` | `previous: string\|null, value: string\|null` |
7575| `tags_change` | `previous, value` (full snapshots) |
7676| `state.set` | `key, value` |
-129
scripts/supervisor-wrapper.c
···11-/*
22- * FDA wrapper for the pty supervisor.
33- * Compiled during `pty supervisor launchd install` with paths and PATH
44- * baked in via -D flags. Grant this binary Full Disk Access so the
55- * supervisor (and sessions it spawns) can access external/removable volumes.
66- *
77- * Usage:
88- * pty-supervisor Run the supervisor (exec node with bundle)
99- * pty-supervisor --check Validate FDA, node binary, bundle file, and PATH
1010- *
1111- * Compile:
1212- * cc -O2 -o pty-supervisor \
1313- * -DNODE_PATH='"/path/to/node"' \
1414- * -DBUNDLE_PATH='"/path/to/supervisor.bundle.js"' \
1515- * -DUSER_PATH='"..."' \
1616- * scripts/supervisor-wrapper.c
1717- */
1818-#include <unistd.h>
1919-#include <stdio.h>
2020-#include <stdlib.h>
2121-#include <string.h>
2222-2323-#ifndef NODE_PATH
2424-#error "NODE_PATH must be defined at compile time"
2525-#endif
2626-2727-#ifndef BUNDLE_PATH
2828-#error "BUNDLE_PATH must be defined at compile time"
2929-#endif
3030-3131-#ifndef USER_PATH
3232-#error "USER_PATH must be defined at compile time"
3333-#endif
3434-3535-/* Test FDA by reading a TCC-protected file */
3636-static int check_fda(void) {
3737- const char *home = getenv("HOME");
3838- if (!home) {
3939- fprintf(stderr, " ✗ HOME not set\n");
4040- return 0;
4141- }
4242- char path[1024];
4343- snprintf(path, sizeof(path), "%s/Library/Safari/History.db", home);
4444- if (access(path, R_OK) == 0) {
4545- return 1;
4646- }
4747- return 0;
4848-}
4949-5050-static int check_file(const char *label, const char *path) {
5151- if (access(path, R_OK) == 0) {
5252- printf(" ✓ %s: %s\n", label, path);
5353- return 1;
5454- }
5555- fprintf(stderr, " ✗ %s not found: %s\n", label, path);
5656- return 0;
5757-}
5858-5959-static int check_executable(const char *label, const char *path) {
6060- if (access(path, X_OK) == 0) {
6161- printf(" ✓ %s: %s\n", label, path);
6262- return 1;
6363- }
6464- fprintf(stderr, " ✗ %s not executable: %s\n", label, path);
6565- return 0;
6666-}
6767-6868-int main(int argc, char *argv[]) {
6969- /* Set PATH before anything else so child processes can find commands */
7070- setenv("PATH", USER_PATH, 1);
7171-7272- int check_mode = 0;
7373- for (int i = 1; i < argc; i++) {
7474- if (strcmp(argv[i], "--check") == 0) {
7575- check_mode = 1;
7676- }
7777- }
7878-7979- if (check_mode) {
8080- printf("pty supervisor wrapper check\n\n");
8181- int ok = 1;
8282-8383- if (!check_executable("node", NODE_PATH)) ok = 0;
8484- if (!check_file("bundle", BUNDLE_PATH)) ok = 0;
8585- printf(" ✓ PATH: set (%zu chars)\n", strlen(USER_PATH));
8686-8787- if (check_fda()) {
8888- printf(" ✓ Full Disk Access: granted\n");
8989- } else {
9090- fprintf(stderr, " ✗ Full Disk Access: not granted\n");
9191- fprintf(stderr, "\n");
9292- fprintf(stderr, "Grant Full Disk Access to this binary:\n");
9393- fprintf(stderr, " System Settings > Privacy & Security > Full Disk Access\n");
9494- fprintf(stderr, " Add: %s\n", argv[0]);
9595- ok = 0;
9696- }
9797-9898- printf("\n");
9999- if (ok) {
100100- printf("All checks passed.\n");
101101- return 0;
102102- } else {
103103- fprintf(stderr, "Some checks failed.\n");
104104- return 1;
105105- }
106106- }
107107-108108- /* Normal mode: validate before exec */
109109- if (access(NODE_PATH, X_OK) != 0) {
110110- fprintf(stderr, "[pty-supervisor] node not found: %s\n", NODE_PATH);
111111- return 1;
112112- }
113113- if (access(BUNDLE_PATH, R_OK) != 0) {
114114- fprintf(stderr, "[pty-supervisor] bundle not found: %s\n", BUNDLE_PATH);
115115- return 1;
116116- }
117117- if (!check_fda()) {
118118- fprintf(stderr, "[pty-supervisor] Full Disk Access not granted.\n");
119119- fprintf(stderr, "[pty-supervisor] Grant FDA to: %s\n", argv[0]);
120120- fprintf(stderr, "[pty-supervisor] System Settings > Privacy & Security > Full Disk Access\n");
121121- return 1;
122122- }
123123-124124- char *exec_argv[] = { NODE_PATH, BUNDLE_PATH, NULL };
125125- execv(NODE_PATH, exec_argv);
126126- /* execv only returns on error */
127127- perror("[pty-supervisor] execv");
128128- return 1;
129129-}
+130-723
src/cli.ts
···3535 emitUserEvent,
3636} from "./events.ts";
3737import { readPtyFile, commandWithEnvExports, type PtySessionDef } from "./ptyfile.ts";
3838-import { getSupervisorDir } from "./supervisor.ts";
3938import { extractFilterTags as extractFilterTagsImpl, matchesAllTags, isReservedTagKey } from "./tags.ts";
4039import { parseDuration, formatDuration } from "./duration.ts";
4140···117116 pty tag <name> key=value [key=value...] Set tags
118117 pty tag <name> --rm key [--rm key...] Remove tags
119118 pty tag-multi <selector> [ops...] Read/write tags across multiple sessions (--all / --filter-tag k=v / <name>...)
120120- pty supervisor start Start the session supervisor
121121- pty supervisor stop Stop the supervisor
122122- pty supervisor status Show supervised sessions
123123- pty supervisor forget <name> Stop supervising a session
124124- pty supervisor reset <name> Reset a failed session for retry
125125- pty supervisor launchd install [--path PATH] Register with macOS launchd
126126- pty supervisor launchd uninstall Remove launchd registration
127127- pty supervisor systemd install [--name NAME] [--path PATH] Register with user systemd
128128- pty supervisor systemd uninstall [--name NAME] Remove user systemd registration
129129- pty supervisor runit install [--name NAME] [--svdir PATH] [--service-dir PATH] [--path PATH] Register with runit
130130- pty supervisor runit uninstall [--name NAME] [--svdir PATH] [--service-dir PATH] Remove runit registration
119119+ pty gc --print-launchd-plist [--interval=N] Print a launchd plist that runs 'pty gc' every N seconds (default 30)
131120 pty wrap <command> Auto-wrap a command in pty sessions
132121 pty unwrap <command> Remove a wrap
133122 pty wrap --list List wrapped commands
···737726 }
738727739728 case "gc": {
740740- const dryRun = args.slice(1).some((a) => a === "--dry-run" || a === "-n");
729729+ const gcArgs = args.slice(1);
730730+ const dryRun = gcArgs.some((a) => a === "--dry-run" || a === "-n");
731731+ const printPlist = gcArgs.includes("--print-launchd-plist");
732732+ let interval = 30;
733733+ for (let i = 0; i < gcArgs.length; i++) {
734734+ const a = gcArgs[i];
735735+ if (a === "--interval" && i + 1 < gcArgs.length) {
736736+ const v = parseInt(gcArgs[i + 1], 10);
737737+ if (!Number.isFinite(v) || v <= 0) {
738738+ console.error(`pty gc: --interval expects a positive integer (got "${gcArgs[i + 1]}")`);
739739+ process.exit(1);
740740+ }
741741+ interval = v;
742742+ i++;
743743+ } else if (a.startsWith("--interval=")) {
744744+ const v = parseInt(a.slice("--interval=".length), 10);
745745+ if (!Number.isFinite(v) || v <= 0) {
746746+ console.error(`pty gc: --interval expects a positive integer (got "${a.slice("--interval=".length)}")`);
747747+ process.exit(1);
748748+ }
749749+ interval = v;
750750+ }
751751+ }
752752+ if (printPlist) {
753753+ printLaunchdPlist(interval);
754754+ break;
755755+ }
741756 await cmdGc(dryRun);
742757 break;
743758 }
···905920 break;
906921 }
907922908908- case "supervisor": {
909909- const subCmd = args[1];
910910- if (!subCmd || subCmd === "-h" || subCmd === "--help") {
911911- console.log(`Usage:
912912- pty supervisor start Start the supervisor
913913- pty supervisor stop Stop the supervisor
914914- pty supervisor status Show supervised sessions
915915- pty supervisor forget <name> Stop supervising a session
916916- pty supervisor reset <name> Reset a failed session for retry
917917- pty supervisor launchd install [--path PATH] Register with macOS launchd (requires FDA)
918918- pty supervisor launchd uninstall Remove from launchd
919919- pty supervisor systemd install [--name NAME] [--path PATH] Register with user systemd
920920- pty supervisor systemd uninstall [--name NAME] Remove from user systemd
921921- pty supervisor runit install [--name NAME] [--svdir PATH] [--service-dir PATH] [--path PATH] Register with runit
922922- pty supervisor runit uninstall [--name NAME] [--svdir PATH] [--service-dir PATH] Remove from runit`);
923923- break;
924924- }
925925- switch (subCmd) {
926926- case "start":
927927- await cmdSupervisorStart();
928928- break;
929929- case "stop":
930930- await cmdSupervisorStop();
931931- break;
932932- case "status":
933933- await cmdSupervisorStatus();
934934- break;
935935- case "forget": {
936936- const forgetName = args[2];
937937- if (!forgetName) {
938938- console.error("Usage: pty supervisor forget <name>");
939939- process.exit(1);
940940- }
941941- const resolvedForgetName = await resolveRef(forgetName);
942942- await cmdSupervisorForget(resolvedForgetName);
943943- break;
944944- }
945945- case "reset": {
946946- const resetName = args[2];
947947- if (!resetName) {
948948- console.error("Usage: pty supervisor reset <name>");
949949- process.exit(1);
950950- }
951951- const resolvedResetName = await resolveRef(resetName);
952952- await cmdSupervisorReset(resolvedResetName);
953953- break;
954954- }
955955- case "launchd": {
956956- const launchdCmd = args[2];
957957- if (launchdCmd === "install") {
958958- let userPath: string | undefined;
959959- for (let li = 3; li < args.length; li++) {
960960- if (args[li] === "--path" && li + 1 < args.length) {
961961- userPath = args[li + 1];
962962- break;
963963- }
964964- }
965965- await cmdSupervisorLaunchdInstall(userPath);
966966- } else if (launchdCmd === "uninstall") {
967967- cmdSupervisorLaunchdUninstall();
968968- } else {
969969- console.error("Usage: pty supervisor launchd install|uninstall");
970970- process.exit(1);
971971- }
972972- break;
973973- }
974974- case "systemd": {
975975- const systemdCmd = args[2];
976976- let unitName: string | undefined;
977977- let userPath: string | undefined;
978978- for (let si = 3; si < args.length; si++) {
979979- if (args[si] === "--name" && si + 1 < args.length) {
980980- unitName = args[++si];
981981- } else if (args[si] === "--path" && si + 1 < args.length) {
982982- userPath = args[++si];
983983- }
984984- }
985985- if (systemdCmd === "install") {
986986- cmdSupervisorSystemdInstall(unitName, userPath);
987987- } else if (systemdCmd === "uninstall") {
988988- cmdSupervisorSystemdUninstall(unitName);
989989- } else {
990990- console.error("Usage: pty supervisor systemd install|uninstall");
991991- process.exit(1);
992992- }
993993- break;
994994- }
995995- case "runit": {
996996- const runitCmd = args[2];
997997- let serviceName: string | undefined;
998998- let svDir: string | undefined;
999999- let serviceDir: string | undefined;
10001000- let userPath: string | undefined;
10011001- for (let ri = 3; ri < args.length; ri++) {
10021002- if (args[ri] === "--name" && ri + 1 < args.length) {
10031003- serviceName = args[++ri];
10041004- } else if (args[ri] === "--svdir" && ri + 1 < args.length) {
10051005- svDir = args[++ri];
10061006- } else if (args[ri] === "--service-dir" && ri + 1 < args.length) {
10071007- serviceDir = args[++ri];
10081008- } else if (args[ri] === "--path" && ri + 1 < args.length) {
10091009- userPath = args[++ri];
10101010- }
10111011- }
10121012- if (runitCmd === "install") {
10131013- cmdSupervisorRunitInstall(serviceName, svDir, serviceDir, userPath);
10141014- } else if (runitCmd === "uninstall") {
10151015- cmdSupervisorRunitUninstall(serviceName, svDir, serviceDir);
10161016- } else {
10171017- console.error("Usage: pty supervisor runit install|uninstall");
10181018- process.exit(1);
10191019- }
10201020- break;
10211021- }
10221022- default:
10231023- console.error(`Unknown supervisor command: ${subCmd}`);
10241024- process.exit(1);
10251025- }
10261026- break;
10271027- }
10281028-1029923 case "wrap": {
1030924 if (args[1] === "--list" || args[1] === "-l") {
1031925 cmdWrapList();
···18171711 process.exit(1);
18181712 }
1819171318201820- // Remove supervision tags so the supervisor doesn't restart it
18211821- const wasSupervised = session.metadata?.tags?.strategy === "permanent" || session.metadata?.tags?.strategy === "temporary";
18221822- if (wasSupervised) {
17141714+ // Strip the `strategy` tag so `pty gc` doesn't respawn the session on
17151715+ // its next tick. The `supervisor.status` tag is no longer a thing.
17161716+ const wasPermanent = session.metadata?.tags?.strategy === "permanent";
17171717+ if (wasPermanent) {
18231718 try {
18241824- const removals = ["strategy"];
18251825- if (session.metadata?.tags?.["supervisor.status"]) removals.push("supervisor.status");
18261826- updateTags(name, {}, removals);
17191719+ updateTags(name, {}, ["strategy"]);
18271720 } catch {}
18281721 }
18291722···18351728 }
18361729 cleanupSocket(name);
1837173018381838- if (wasSupervised && session.metadata?.tags?.ptyfile) {
17311731+ if (wasPermanent && session.metadata?.tags?.ptyfile) {
18391732 console.error(`Note: this session is managed by ${session.metadata.tags.ptyfile}`);
18401733 console.error("The strategy tag will be restored on the next 'pty up'.");
18411734 }
···19951888}
1996188919971890async function cmdGc(dryRun: boolean): Promise<void> {
19981998- const removed = await gc({ dryRun });
18911891+ const result = await gc({ dryRun });
19991892 const prunedTags = await pruneOrphanLayoutTags({ dryRun });
2000189320012001- const verb = dryRun ? "Would remove" : "Removed";
18941894+ const killedVerb = dryRun ? "Would kill orphan child" : "Killed orphan child";
18951895+ const respawnVerb = dryRun ? "Would respawn" : "Respawned";
18961896+ const removeVerb = dryRun ? "Would remove" : "Removed";
20021897 const prunedVerb = dryRun ? "Would prune" : "Pruned";
2003189820042004- for (const name of removed) {
20052005- console.log(`${verb}: ${name}`);
18991899+ for (const k of result.killedOrphanChildren) {
19001900+ console.log(`${killedVerb}: ${k.name} (parent ${k.parent} ${k.reason})`);
19011901+ }
19021902+ for (const r of result.respawned) {
19031903+ const note = r.ptyfileReread ? " (pty.toml re-read)" : "";
19041904+ console.log(`${respawnVerb}: ${r.name}${note}`);
19051905+ }
19061906+ for (const f of result.respawnFailed) {
19071907+ console.log(`Respawn failed: ${f.name} — ${f.error}`);
19081908+ }
19091909+ for (const name of result.removed) {
19101910+ console.log(`${removeVerb}: ${name}`);
20061911 }
20071912 for (const { name, removedKeys } of prunedTags) {
20081913 console.log(
···20111916 }
2012191720131918 const totalTags = prunedTags.reduce((sum, r) => sum + r.removedKeys.length, 0);
20142014- if (removed.length === 0 && totalTags === 0) {
19191919+ const totalActions =
19201920+ result.killedOrphanChildren.length +
19211921+ result.respawned.length +
19221922+ result.respawnFailed.length +
19231923+ result.removed.length +
19241924+ totalTags;
19251925+19261926+ if (totalActions === 0) {
20151927 console.log(dryRun ? "Nothing would be cleaned up." : "Nothing to clean up.");
20161928 return;
20171929 }
2018193020191931 const parts: string[] = [];
20202020- if (removed.length > 0) {
20212021- parts.push(`${removed.length} stale session${removed.length === 1 ? "" : "s"}`);
19321932+ if (result.killedOrphanChildren.length > 0) {
19331933+ parts.push(`${result.killedOrphanChildren.length} orphan child${result.killedOrphanChildren.length === 1 ? "" : "ren"}`);
19341934+ }
19351935+ if (result.respawned.length > 0) {
19361936+ parts.push(`${result.respawned.length} respawn${result.respawned.length === 1 ? "" : "s"}`);
19371937+ }
19381938+ if (result.respawnFailed.length > 0) {
19391939+ parts.push(`${result.respawnFailed.length} respawn failure${result.respawnFailed.length === 1 ? "" : "s"}`);
19401940+ }
19411941+ if (result.removed.length > 0) {
19421942+ parts.push(`${result.removed.length} stale session${result.removed.length === 1 ? "" : "s"}`);
20221943 }
20231944 if (totalTags > 0) {
20241945 parts.push(`${totalTags} orphan tag${totalTags === 1 ? "" : "s"}`);
20251946 }
20261947 console.log(
20271948 dryRun
20282028- ? `Would clean up ${parts.join(" and ")}. (Dry run — no changes made.)`
20292029- : `Cleaned up ${parts.join(" and ")}.`,
19491949+ ? `Would clean up ${parts.join(", ")}. (Dry run — no changes made.)`
19501950+ : `Cleaned up ${parts.join(", ")}.`,
20301951 );
19521952+}
19531953+19541954+/** Print a minimal launchd plist that runs `pty gc` every `interval`
19551955+ * seconds. Pure stdout — the caller redirects to
19561956+ * `~/Library/LaunchAgents/com.myobie.pty.gc.plist` and `launchctl load`s
19571957+ * it themselves. No FDA dance, no bundled binary; just node + the gc
19581958+ * command, inheriting PATH and PTY_SESSION_DIR. If the SSD where node
19591959+ * lives isn't mounted at boot, the invocation fails — the next tick
19601960+ * tries again. That's the whole point. */
19611961+function printLaunchdPlist(interval: number): void {
19621962+ const logPath = path.join(os.homedir(), ".local", "state", "pty", "gc.log");
19631963+ const ptyBin = process.argv[1];
19641964+ const envPath = process.env.PATH ?? "/usr/bin:/bin:/usr/sbin:/sbin";
19651965+ const sessionDir = getSessionDir();
19661966+ const escape = (s: string) =>
19671967+ s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
19681968+19691969+ // We point ProgramArguments at node + the resolved CLI script so the
19701970+ // plist doesn't depend on the `pty` shim staying on PATH at launchd's
19711971+ // (minimal) shell. EnvironmentVariables still carries PATH so the
19721972+ // spawned children (and any `which` inside pty itself) find the user's
19731973+ // tools.
19741974+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
19751975+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
19761976+<plist version="1.0">
19771977+<dict>
19781978+ <key>Label</key>
19791979+ <string>com.myobie.pty.gc</string>
19801980+ <key>ProgramArguments</key>
19811981+ <array>
19821982+ <string>${escape(process.execPath)}</string>
19831983+ <string>${escape(ptyBin)}</string>
19841984+ <string>gc</string>
19851985+ </array>
19861986+ <key>StartInterval</key>
19871987+ <integer>${interval}</integer>
19881988+ <key>RunAtLoad</key>
19891989+ <true/>
19901990+ <key>StandardOutPath</key>
19911991+ <string>${escape(logPath)}</string>
19921992+ <key>StandardErrorPath</key>
19931993+ <string>${escape(logPath)}</string>
19941994+ <key>EnvironmentVariables</key>
19951995+ <dict>
19961996+ <key>PATH</key>
19971997+ <string>${escape(envPath)}</string>
19981998+ <key>PTY_SESSION_DIR</key>
19991999+ <string>${escape(sessionDir)}</string>
20002000+ </dict>
20012001+</dict>
20022002+</plist>
20032003+`;
20042004+ process.stdout.write(plist);
20312005}
2032200620332007// --- tag-multi: read/write tags across multiple sessions in one call ---
···24892463 });
24902464}
2491246524922492-async function cmdSupervisorStart(): Promise<void> {
24932493- // Run the supervisor in the foreground (not in a pty session).
24942494- // This makes it work with launchd KeepAlive since launchd owns the process.
24952495- // Use `pty events supervisor` to follow activity, `pty supervisor status` for state.
24962496- const { Supervisor } = await import("./supervisor.ts");
24972497-24982498- const sup = new Supervisor("supervisor");
24992499- sup.start();
25002500-25012501- console.log(`[supervisor] started (pid ${process.pid})`);
25022502- console.log(`[supervisor] watching ${getSessionDir()}`);
25032503-25042504- process.on("SIGTERM", () => {
25052505- console.log("[supervisor] stopping...");
25062506- sup.stop();
25072507- process.exit(0);
25082508- });
25092509- process.on("SIGINT", () => {
25102510- console.log("[supervisor] stopping...");
25112511- sup.stop();
25122512- process.exit(0);
25132513- });
25142514-25152515- // Keep the process alive
25162516- await new Promise(() => {});
25172517-}
25182518-25192519-async function cmdSupervisorStop(): Promise<void> {
25202520- const pidPath = path.join(getSupervisorDir(), "supervisor.pid");
25212521- let pid: number;
25222522- try {
25232523- pid = parseInt(fs.readFileSync(pidPath, "utf-8").trim(), 10);
25242524- } catch {
25252525- console.error("Supervisor is not running.");
25262526- process.exit(1);
25272527- }
25282528- try {
25292529- process.kill(pid, 0); // check if alive
25302530- process.kill(pid, "SIGTERM");
25312531- console.log("Supervisor stopped.");
25322532- } catch {
25332533- console.error("Supervisor is not running (stale pid file).");
25342534- try { fs.unlinkSync(pidPath); } catch {}
25352535- process.exit(1);
25362536- }
25372537-}
25382538-25392539-async function cmdSupervisorStatus(): Promise<void> {
25402540- const sessions = await listSessions();
25412541- const supervised = sessions.filter((s) =>
25422542- s.metadata?.tags?.strategy === "permanent" || s.metadata?.tags?.strategy === "temporary"
25432543- );
25442544-25452545- if (supervised.length === 0) {
25462546- console.log("No supervised sessions.");
25472547- return;
25482548- }
25492549-25502550- // Try to read supervisor state for restart counts
25512551- let state: Record<string, any> = {};
25522552- try {
25532553- const content = fs.readFileSync(path.join(getSupervisorDir(), "state.json"), "utf-8");
25542554- state = JSON.parse(content).sessions ?? {};
25552555- } catch {}
25562556-25572557- let supervisorRunning = false;
25582558- try {
25592559- const pid = parseInt(fs.readFileSync(path.join(getSupervisorDir(), "supervisor.pid"), "utf-8").trim(), 10);
25602560- process.kill(pid, 0);
25612561- supervisorRunning = true;
25622562- } catch {}
25632563- console.log(`Supervisor: ${supervisorRunning ? "\x1b[32mrunning\x1b[0m" : "\x1b[31mnot running\x1b[0m"}`);
25642564- console.log("");
25652565-25662566- for (const s of supervised) {
25672567- const strategy = s.metadata!.tags!.strategy;
25682568- const supStatus = s.metadata?.tags?.["supervisor.status"];
25692569- const stateInfo = state[s.name];
25702570- const restarts = stateInfo?.restartCount ?? 0;
25712571-25722572- let status = s.status === "running" ? "\x1b[32mrunning\x1b[0m" : "\x1b[33mexited\x1b[0m";
25732573- if (supStatus === "failed") status = "\x1b[31mfailed\x1b[0m";
25742574-25752575- console.log(` \x1b[1m${s.name}\x1b[0m [${strategy}] — ${status}${restarts > 0 ? ` (${restarts} restarts)` : ""}`);
25762576- }
25772577-}
25782578-25792579-async function cmdSupervisorForget(name: string): Promise<void> {
25802580- const meta = readMetadata(name);
25812581- if (!meta) {
25822582- console.error(`Session "${name}" not found.`);
25832583- process.exit(1);
25842584- }
25852585-25862586- const removals: string[] = [];
25872587- if (meta.tags?.strategy) removals.push("strategy");
25882588- if (meta.tags?.["supervisor.status"]) removals.push("supervisor.status");
25892589-25902590- if (removals.length === 0) {
25912591- console.log(`Session "${name}" is not supervised.`);
25922592- return;
25932593- }
25942594-25952595- updateTags(name, {}, removals);
25962596- console.log(`Removed supervision from "${name}".`);
25972597-25982598- if (meta.tags?.ptyfile) {
25992599- console.error(`\nWarning: this session is managed by ${meta.tags.ptyfile}`);
26002600- console.error("The strategy tag will be restored on the next 'pty up'.");
26012601- console.error("Edit the pty.toml to make this permanent.");
26022602- }
26032603-}
26042604-26052605-async function cmdSupervisorReset(name: string): Promise<void> {
26062606- const meta = readMetadata(name);
26072607- if (!meta) {
26082608- console.error(`Session "${name}" not found.`);
26092609- process.exit(1);
26102610- }
26112611-26122612- if (meta.tags?.["supervisor.status"] !== "failed") {
26132613- console.log(`Session "${name}" is not in failed state.`);
26142614- return;
26152615- }
26162616-26172617- // Remove the failed tag
26182618- updateTags(name, {}, ["supervisor.status"]);
26192619-26202620- // Reset restart counter in supervisor state
26212621- const statePath = path.join(getSupervisorDir(), "state.json");
26222622- try {
26232623- const content = fs.readFileSync(statePath, "utf-8");
26242624- const state = JSON.parse(content);
26252625- if (state.sessions?.[name]) {
26262626- state.sessions[name].restartCount = 0;
26272627- state.sessions[name].restartWindowStart = 0;
26282628- state.sessions[name].nextBackoffMs = 1000;
26292629- state.sessions[name].failed = false;
26302630- atomicWriteFileSync(statePath, JSON.stringify(state, null, 2));
26312631- }
26322632- } catch {}
26332633-26342634- console.log(`Reset "${name}". The supervisor will try restarting it.`);
26352635-}
26362636-26372637-function stopExistingSupervisorIfRunning(): void {
26382638- const pidPath = path.join(getSupervisorDir(), "supervisor.pid");
26392639- try {
26402640- const pid = parseInt(fs.readFileSync(pidPath, "utf-8").trim(), 10);
26412641- try { process.kill(pid, "SIGTERM"); } catch {}
26422642- try { fs.unlinkSync(pidPath); } catch {}
26432643- console.log("Stopped existing supervisor.");
26442644- } catch {}
26452645-}
26462646-26472647-function getSourceRoot(): string {
26482648- return path.join(
26492649- import.meta.dirname ?? path.dirname(new URL(import.meta.url).pathname),
26502650- "..",
26512651- );
26522652-}
26532653-26542654-function getSupervisorEntryPoint(): string {
26552655- return path.join(getSourceRoot(), "dist", "supervisor-entry.js");
26562656-}
26572657-26582658-function ensureSupervisorBuildExists(): void {
26592659- const entryPoint = getSupervisorEntryPoint();
26602660- if (!fs.existsSync(entryPoint)) {
26612661- console.error(`Missing ${entryPoint}. Run: npm run build`);
26622662- process.exit(1);
26632663- }
26642664-}
26652665-26662666-function getConfigHome(): string {
26672667- return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
26682668-}
26692669-26702670-function shellQuote(value: string): string {
26712671- return `'${value.replace(/'/g, `'"'"'`)}'`;
26722672-}
26732673-26742674-function systemdQuote(value: string): string {
26752675- return `"${value.replace(/["\\$`]/g, "\\$&")}"`;
26762676-}
26772677-26782678-function normalizeServiceName(name: string | undefined, suffix: string): string {
26792679- const raw = (name && name.trim()) ? name.trim() : `pty-supervisor${suffix}`;
26802680- return raw.endsWith(suffix) ? raw : `${raw}${suffix}`;
26812681-}
26822682-26832683-function runChecked(command: string, args: string[], options: Parameters<typeof spawnSync>[2] = {}): ReturnType<typeof spawnSync> {
26842684- const result = spawnSync(command, args, { encoding: "utf-8", ...options });
26852685- if (result.status !== 0) {
26862686- const stderr = String(result.stderr ?? "").trim();
26872687- const stdout = String(result.stdout ?? "").trim();
26882688- console.error(`Failed: ${command} ${args.join(" ")}`);
26892689- if (stderr) console.error(stderr);
26902690- else if (stdout) console.error(stdout);
26912691- process.exit(result.status ?? 1);
26922692- }
26932693- return result;
26942694-}
26952695-26962696-function maybePrintSystemdLingerHint(): void {
26972697- const user = os.userInfo().username;
26982698- const result = spawnSync("loginctl", ["show-user", user, "-p", "Linger", "--value"], {
26992699- encoding: "utf-8",
27002700- });
27012701- if (result.status === 0 && result.stdout.trim().toLowerCase() !== "yes") {
27022702- console.log("");
27032703- console.log(`Note: loginctl linger is disabled for ${user}.`);
27042704- console.log("This user service will start while you're logged in, but not at boot.");
27052705- console.log(`To keep it running across reboots, run: sudo loginctl enable-linger ${user}`);
27062706- }
27072707-}
27082708-27092709-function cmdSupervisorSystemdInstall(unitName?: string, userPath?: string): void {
27102710- ensureSupervisorBuildExists();
27112711- stopExistingSupervisorIfRunning();
27122712-27132713- const envPath = userPath ?? process.env.PATH ?? "/usr/bin:/bin:/usr/sbin:/sbin";
27142714- const entryPoint = getSupervisorEntryPoint();
27152715- const resolvedUnit = normalizeServiceName(unitName, ".service");
27162716- const unitDir = path.join(getConfigHome(), "systemd", "user");
27172717- const unitPath = path.join(unitDir, resolvedUnit);
27182718- const sessionDir = getSessionDir();
27192719-27202720- fs.mkdirSync(unitDir, { recursive: true });
27212721- fs.writeFileSync(unitPath, `[Unit]
27222722-Description=pty supervisor
27232723-27242724-[Service]
27252725-Type=simple
27262726-Environment=${systemdQuote(`PATH=${envPath}`)}
27272727-Environment=${systemdQuote(`TERM=xterm-256color`)}
27282728-Environment=${systemdQuote(`COLORTERM=truecolor`)}
27292729-Environment=${systemdQuote(`PTY_SESSION_DIR=${sessionDir}`)}
27302730-WorkingDirectory=${os.homedir()}
27312731-ExecStart=${process.execPath} ${entryPoint}
27322732-Restart=always
27332733-RestartSec=5
27342734-27352735-[Install]
27362736-WantedBy=default.target
27372737-`);
27382738-27392739- runChecked("systemctl", ["--user", "daemon-reload"]);
27402740- runChecked("systemctl", ["--user", "enable", "--now", resolvedUnit]);
27412741-27422742- console.log(`Installed systemd user unit: ${unitPath}`);
27432743- console.log(`Manage it with: systemctl --user status ${resolvedUnit}`);
27442744- maybePrintSystemdLingerHint();
27452745-}
27462746-27472747-function cmdSupervisorSystemdUninstall(unitName?: string): void {
27482748- const resolvedUnit = normalizeServiceName(unitName, ".service");
27492749- const unitPath = path.join(getConfigHome(), "systemd", "user", resolvedUnit);
27502750-27512751- spawnSync("systemctl", ["--user", "disable", "--now", resolvedUnit], { encoding: "utf-8" });
27522752- try { fs.unlinkSync(unitPath); } catch {}
27532753- runChecked("systemctl", ["--user", "daemon-reload"]);
27542754- spawnSync("systemctl", ["--user", "reset-failed", resolvedUnit], { encoding: "utf-8" });
27552755- stopExistingSupervisorIfRunning();
27562756-27572757- console.log(`Removed systemd user unit: ${unitPath}`);
27582758-}
27592759-27602760-function cmdSupervisorRunitInstall(
27612761- serviceName?: string,
27622762- svDir?: string,
27632763- serviceDir?: string,
27642764- userPath?: string,
27652765-): void {
27662766- ensureSupervisorBuildExists();
27672767- stopExistingSupervisorIfRunning();
27682768-27692769- const resolvedName = normalizeServiceName(serviceName, "");
27702770- const resolvedSvDir = path.resolve(svDir ?? path.join(getConfigHome(), "runit", "sv"));
27712771- const resolvedServiceDir = path.resolve(serviceDir ?? path.join(getConfigHome(), "runit", "service"));
27722772- const servicePath = path.join(resolvedSvDir, resolvedName);
27732773- const runPath = path.join(servicePath, "run");
27742774- const linkPath = path.join(resolvedServiceDir, resolvedName);
27752775- const envPath = userPath ?? process.env.PATH ?? "/usr/bin:/bin:/usr/sbin:/sbin";
27762776- const sessionDir = getSessionDir();
27772777- const entryPoint = getSupervisorEntryPoint();
27782778-27792779- fs.mkdirSync(resolvedSvDir, { recursive: true });
27802780- fs.mkdirSync(resolvedServiceDir, { recursive: true });
27812781- fs.rmSync(servicePath, { recursive: true, force: true });
27822782- fs.mkdirSync(servicePath, { recursive: true, mode: 0o755 });
27832783-27842784- fs.writeFileSync(runPath, `#!/bin/sh
27852785-export PATH=${shellQuote(envPath)}
27862786-export TERM=${shellQuote("xterm-256color")}
27872787-export COLORTERM=${shellQuote("truecolor")}
27882788-export PTY_SESSION_DIR=${shellQuote(sessionDir)}
27892789-exec ${shellQuote(process.execPath)} ${shellQuote(entryPoint)}
27902790-`);
27912791- fs.chmodSync(runPath, 0o755);
27922792-27932793- try {
27942794- const stat = fs.lstatSync(linkPath);
27952795- if (stat.isSymbolicLink() || stat.isFile()) fs.unlinkSync(linkPath);
27962796- else {
27972797- console.error(`Refusing to replace non-symlink path: ${linkPath}`);
27982798- process.exit(1);
27992799- }
28002800- } catch {}
28012801- fs.symlinkSync(servicePath, linkPath);
28022802-28032803- console.log(`Installed runit service: ${servicePath}`);
28042804- console.log(`Enabled via symlink: ${linkPath}`);
28052805- console.log(`Start it with: runsvdir ${shellQuote(resolvedServiceDir)}`);
28062806-}
28072807-28082808-function cmdSupervisorRunitUninstall(serviceName?: string, svDir?: string, serviceDir?: string): void {
28092809- const resolvedName = normalizeServiceName(serviceName, "");
28102810- const resolvedSvDir = path.resolve(svDir ?? path.join(getConfigHome(), "runit", "sv"));
28112811- const resolvedServiceDir = path.resolve(serviceDir ?? path.join(getConfigHome(), "runit", "service"));
28122812- const servicePath = path.join(resolvedSvDir, resolvedName);
28132813- const linkPath = path.join(resolvedServiceDir, resolvedName);
28142814-28152815- try { fs.unlinkSync(linkPath); } catch {}
28162816- try { fs.rmSync(servicePath, { recursive: true, force: true }); } catch {}
28172817- stopExistingSupervisorIfRunning();
28182818-28192819- console.log(`Removed runit service: ${servicePath}`);
28202820- console.log(`Removed symlink: ${linkPath}`);
28212821-}
28222822-28232823-async function cmdSupervisorLaunchdInstall(userPath?: string): Promise<void> {
28242824- const launchdDir = path.join(os.homedir(), ".local", "pty", "launchd");
28252825- const wrapperPath = path.join(launchdDir, "pty-supervisor");
28262826- const bundlePath = path.join(launchdDir, "supervisor.bundle.js");
28272827- const logPath = path.join(os.homedir(), ".local", "state", "pty", "supervisor.log");
28282828- const plistDir = path.join(os.homedir(), "Library", "LaunchAgents");
28292829- const plistPath = path.join(plistDir, "com.myobie.pty.supervisor.plist");
28302830-28312831- // Stop existing supervisor if running
28322832- const pidPath = path.join(getSupervisorDir(), "supervisor.pid");
28332833- try {
28342834- const pid = parseInt(fs.readFileSync(pidPath, "utf-8").trim(), 10);
28352835- try { process.kill(pid, "SIGTERM"); } catch {}
28362836- try { fs.unlinkSync(pidPath); } catch {}
28372837- console.log("Stopped existing supervisor.");
28382838- } catch {}
28392839-28402840- // Unload existing plist if present
28412841- if (fs.existsSync(plistPath)) {
28422842- spawnSync("launchctl", ["unload", plistPath], { encoding: "utf-8" });
28432843- }
28442844-28452845- const srcRoot = path.join(
28462846- import.meta.dirname ?? path.dirname(new URL(import.meta.url).pathname),
28472847- ".."
28482848- );
28492849- const distDir = path.join(srcRoot, "dist");
28502850- const nodeBin = process.execPath;
28512851- const wrapperSrc = path.join(srcRoot, "scripts", "supervisor-wrapper.c");
28522852-28532853- // Clean and create launchd directory
28542854- fs.rmSync(launchdDir, { recursive: true, force: true });
28552855- fs.mkdirSync(launchdDir, { recursive: true });
28562856-28572857- // Bundle the supervisor
28582858- const entryPoint = path.join(distDir, "supervisor-entry.js");
28592859- const serverModule = path.join(distDir, "server.js");
28602860-28612861- console.log("Bundling supervisor...");
28622862- const esbuildResult = spawnSync("npx", ["esbuild", entryPoint, "--bundle", "--platform=node", "--format=esm", `--outfile=${bundlePath}`, `--define:SERVER_MODULE_PATH="${serverModule.replace(/\\/g, "\\\\")}"`], {
28632863- encoding: "utf-8",
28642864- cwd: distDir,
28652865- });
28662866- if (esbuildResult.status !== 0) {
28672867- console.error(`Failed to bundle supervisor: ${esbuildResult.stderr}`);
28682868- process.exit(1);
28692869- }
28702870-28712871- // Compile the FDA wrapper binary with PATH baked in
28722872- const envPath = userPath ?? process.env.PATH ?? "/usr/bin:/bin:/usr/sbin:/sbin";
28732873- console.log("Compiling FDA wrapper...");
28742874- const ccResult = spawnSync("cc", [
28752875- "-O2", "-o", wrapperPath,
28762876- `-DNODE_PATH="${nodeBin}"`,
28772877- `-DBUNDLE_PATH="${bundlePath}"`,
28782878- `-DUSER_PATH="${envPath}"`,
28792879- wrapperSrc,
28802880- ], { encoding: "utf-8" });
28812881- if (ccResult.status !== 0) {
28822882- console.error(`Failed to compile wrapper: ${ccResult.stderr}`);
28832883- process.exit(1);
28842884- }
28852885-28862886- // Run --check via a one-shot launchd job to test under launchd's actual
28872887- // permission scope (not the terminal's). This is the only reliable way to
28882888- // know if the wrapper binary has FDA.
28892889- function checkFDAViaLaunchd(): boolean {
28902890- const checkLabel = "com.myobie.pty.fda-check";
28912891- const checkOutput = path.join(launchdDir, "fda-check.log");
28922892- const checkPlist = path.join(launchdDir, "fda-check.plist");
28932893-28942894- try { fs.unlinkSync(checkOutput); } catch {}
28952895-28962896- fs.writeFileSync(checkPlist, `<?xml version="1.0" encoding="UTF-8"?>
28972897-<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
28982898-<plist version="1.0">
28992899-<dict>
29002900- <key>Label</key>
29012901- <string>${checkLabel}</string>
29022902- <key>ProgramArguments</key>
29032903- <array>
29042904- <string>${wrapperPath}</string>
29052905- <string>--check</string>
29062906- </array>
29072907- <key>StandardOutPath</key>
29082908- <string>${checkOutput}</string>
29092909- <key>StandardErrorPath</key>
29102910- <string>${checkOutput}</string>
29112911- <key>RunAtLoad</key>
29122912- <true/>
29132913-</dict>
29142914-</plist>
29152915-`);
29162916-29172917- // Unload any previous check job
29182918- spawnSync("launchctl", ["unload", checkPlist], { encoding: "utf-8" });
29192919-29202920- // Load — runs immediately due to RunAtLoad
29212921- spawnSync("launchctl", ["load", checkPlist], { encoding: "utf-8" });
29222922-29232923- // Wait for the job to finish (it's a one-shot, exits quickly)
29242924- const start = Date.now();
29252925- while (Date.now() - start < 5000) {
29262926- const list = spawnSync("launchctl", ["list"], { encoding: "utf-8" });
29272927- const line = list.stdout.split("\n").find((l: string) => l.includes(checkLabel));
29282928- // Format: "PID\tExitCode\tLabel" — if PID is "-", the job has exited
29292929- if (line && line.startsWith("-")) break;
29302930- spawnSync("sleep", ["0.2"]);
29312931- }
29322932-29332933- // Unload the check job
29342934- spawnSync("launchctl", ["unload", checkPlist], { encoding: "utf-8" });
29352935- try { fs.unlinkSync(checkPlist); } catch {}
29362936-29372937- // Read the output
29382938- try {
29392939- const output = fs.readFileSync(checkOutput, "utf-8");
29402940- try { fs.unlinkSync(checkOutput); } catch {}
29412941- return output.includes("All checks passed");
29422942- } catch {
29432943- return false;
29442944- }
29452945- }
29462946-29472947- console.log("Checking Full Disk Access via launchd...");
29482948- let hasFDA = checkFDAViaLaunchd();
29492949-29502950- if (!hasFDA) {
29512951- console.log("");
29522952- console.log("The supervisor wrapper needs Full Disk Access to manage");
29532953- console.log("sessions on external/removable volumes.");
29542954- console.log("");
29552955- console.log("1. Open System Settings > Privacy & Security > Full Disk Access");
29562956- console.log(`2. Click + and add: ${wrapperPath}`);
29572957- console.log("");
29582958-29592959- // Open the folder in Finder so they can find the binary
29602960- spawnSync("open", [launchdDir]);
29612961-29622962- const rl = await import("node:readline/promises");
29632963- const prompt = rl.createInterface({ input: process.stdin, output: process.stdout });
29642964- const answer = await prompt.question("Have you granted Full Disk Access? [y/N] ");
29652965- prompt.close();
29662966-29672967- if (answer.trim().toLowerCase() !== "y") {
29682968- console.error("Aborted. Full Disk Access is required for launchd.");
29692969- process.exit(1);
29702970- }
29712971-29722972- // Re-check via launchd
29732973- console.log("Verifying...");
29742974- hasFDA = checkFDAViaLaunchd();
29752975- if (!hasFDA) {
29762976- console.error("");
29772977- console.error("Full Disk Access check failed under launchd.");
29782978- console.error("The plist was NOT loaded. Grant FDA and try again:");
29792979- console.error(` ${wrapperPath}`);
29802980- process.exit(1);
29812981- }
29822982- console.log("Full Disk Access verified.");
29832983- } else {
29842984- console.log("Full Disk Access: granted.");
29852985- }
29862986-29872987- // Write and load the plist — FDA is confirmed
29882988- const plist = `<?xml version="1.0" encoding="UTF-8"?>
29892989-<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
29902990-<plist version="1.0">
29912991-<dict>
29922992- <key>Label</key>
29932993- <string>com.myobie.pty.supervisor</string>
29942994- <key>ProgramArguments</key>
29952995- <array>
29962996- <string>${wrapperPath}</string>
29972997- </array>
29982998- <key>StandardOutPath</key>
29992999- <string>${logPath}</string>
30003000- <key>StandardErrorPath</key>
30013001- <string>${logPath}</string>
30023002- <key>RunAtLoad</key>
30033003- <true/>
30043004- <key>KeepAlive</key>
30053005- <true/>
30063006- <key>ThrottleInterval</key>
30073007- <integer>5</integer>
30083008-</dict>
30093009-</plist>
30103010-`;
30113011-30123012- fs.mkdirSync(plistDir, { recursive: true });
30133013- fs.writeFileSync(plistPath, plist);
30143014-30153015- const result = spawnSync("launchctl", ["load", plistPath], { encoding: "utf-8" });
30163016- if (result.status !== 0) {
30173017- console.error(`Failed to load plist: ${result.stderr}`);
30183018- process.exit(1);
30193019- }
30203020-30213021- console.log("");
30223022- console.log(`Wrapper: ${wrapperPath}`);
30233023- console.log(`Bundle: ${bundlePath}`);
30243024- console.log(`Plist: ${plistPath}`);
30253025- console.log(`Log: ${logPath}`);
30263026- console.log("Supervisor will start on login and restart if it exits.");
30273027-}
30283028-30293029-function cmdSupervisorLaunchdUninstall(): void {
30303030- const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", "com.myobie.pty.supervisor.plist");
30313031- const launchdDir = path.join(os.homedir(), ".local", "pty", "launchd");
30323032-30333033- if (!fs.existsSync(plistPath)) {
30343034- console.error("Supervisor is not registered with launchd.");
30353035- process.exit(1);
30363036- }
30373037-30383038- spawnSync("launchctl", ["unload", plistPath], { encoding: "utf-8" });
30393039- try { fs.unlinkSync(plistPath); } catch {}
30403040-30413041- // Clean up bundled files
30423042- try { fs.rmSync(launchdDir, { recursive: true, force: true }); } catch {}
30433043-30443044- // Stop supervisor if running
30453045- const pidPath = path.join(getSupervisorDir(), "supervisor.pid");
30463046- try {
30473047- const pid = parseInt(fs.readFileSync(pidPath, "utf-8").trim(), 10);
30483048- try { process.kill(pid, "SIGTERM"); } catch {}
30493049- try { fs.unlinkSync(pidPath); } catch {}
30503050- } catch {}
30513051-30523052- console.log("Supervisor removed from launchd.");
30533053- console.log(`Cleaned up ${launchdDir}`);
30543054-}
3055246630562467function hasPtyFile(dir: string): boolean {
30572468 try {
···3250266132512662 const label = existingSession.metadata?.displayName ?? existingSession.name;
3252266332533253- // Remove supervision tags so the supervisor doesn't restart it
32543254- const wasSupervised = existingSession.metadata?.tags?.strategy === "permanent" || existingSession.metadata?.tags?.strategy === "temporary";
32553255- if (wasSupervised) {
26642664+ // Strip the `strategy` tag so `pty gc` doesn't respawn the session
26652665+ // on its next tick. The `supervisor.status` tag is no longer a thing.
26662666+ const wasPermanent = existingSession.metadata?.tags?.strategy === "permanent";
26672667+ if (wasPermanent) {
32562668 try {
32573257- const removals = ["strategy"];
32583258- if (existingSession.metadata?.tags?.["supervisor.status"]) removals.push("supervisor.status");
32593259- updateTags(existingSession.name, {}, removals);
26692669+ updateTags(existingSession.name, {}, ["strategy"]);
32602670 } catch {}
32612671 }
3262267232632673 if (existingSession.status === "running" && existingSession.pid) {
32642674 try {
32652675 process.kill(existingSession.pid, "SIGTERM");
32663266- console.log(` ○ ${label} (stopped${wasSupervised ? ", removed from supervision" : ""})`);
26762676+ console.log(` ○ ${label} (stopped${wasPermanent ? ", removed from supervision" : ""})`);
32672677 stopped++;
32682678 } catch {
32692679 console.error(` ✗ ${label}: failed to stop`);
···3455286534562866function strategyMarker(tags?: Record<string, string>): string {
34572867 if (!tags) return "";
34583458- const supStatus = tags["supervisor.status"];
34593459- if (supStatus === "failed") return " \x1b[31m[failed]\x1b[0m";
34602868 if (tags.strategy === "permanent") return " \x1b[33m[permanent]\x1b[0m";
34613461- if (tags.strategy === "temporary") return " \x1b[2m[temporary]\x1b[0m";
34622869 return "";
34632870}
34642871
+2-3
src/client-api.ts
···88 getSessionDir, getSocketPath,
99 cleanupSocket, cleanupAll,
1010 getState, getStateKey, setState, deleteState, listStateKeys,
1111- type SessionInfo, type SessionMetadata, type PrunedTagResult,
1111+ type SessionInfo, type SessionMetadata, type PrunedTagResult, type GcResult,
1212} from "./sessions.ts";
13131414// Session creation
···3737 type BellEvent, type TitleChangeEvent, type NotificationEvent,
3838 type FocusRequestEvent, type CursorVisibleEvent,
3939 type SessionStartEvent, type SessionExitEvent, type SessionExecEvent,
4040- type SessionRestartEvent, type SessionFailedEvent,
4141- type SupervisorStartEvent, type SupervisorStopEvent,
4040+ type SessionRespawnEvent,
4241 type UserEvent, type StateSetEvent, type StateDeleteEvent,
4342 type DisplayNameChangeEvent, type TagsChangeEvent,
4443 type FollowerOptions,
+10-34
src/events.ts
···1414 SESSION_START: "session_start",
1515 SESSION_EXIT: "session_exit",
1616 SESSION_EXEC: "session_exec",
1717- SESSION_RESTART: "session_restart",
1818- SESSION_FAILED: "session_failed",
1919- SUPERVISOR_START: "supervisor_start",
2020- SUPERVISOR_STOP: "supervisor_stop",
1717+ SESSION_RESPAWN: "session_respawn",
2118} as const;
22192320export type EventType = (typeof EventType)[keyof typeof EventType];
···7875 command: string;
7976}
80778181-export interface SessionRestartEvent extends EventBase {
8282- type: "session_restart";
8383- restartCount: number;
8484- backoffMs: number;
8585-}
8686-8787-export interface SessionFailedEvent extends EventBase {
8888- type: "session_failed";
8989- restartCount: number;
9090- reason: string;
9191-}
9292-9393-export interface SupervisorStartEvent extends EventBase {
9494- type: "supervisor_start";
9595-}
9696-9797-export interface SupervisorStopEvent extends EventBase {
9898- type: "supervisor_stop";
7878+/** Emitted by `pty gc` whenever it respawns a `strategy=permanent`
7979+ * session that's exited/vanished. Carries no payload beyond the
8080+ * envelope — the restart is stateless, there is no attempt counter,
8181+ * and the cron interval is the rate limit. */
8282+export interface SessionRespawnEvent extends EventBase {
8383+ type: "session_respawn";
9984}
1008510186/** User-published event. `type` must begin with `user.` — the CLI
···148133 | SessionStartEvent
149134 | SessionExitEvent
150135 | SessionExecEvent
151151- | SessionRestartEvent
152152- | SessionFailedEvent
153153- | SupervisorStartEvent
154154- | SupervisorStopEvent
136136+ | SessionRespawnEvent
155137 | UserEvent
156138 | StateSetEvent
157139 | StateDeleteEvent
···496478 return `${prefix} exited (code ${event.exitCode})`;
497479 case "session_exec":
498480 return `${prefix} exec ${event.command} (was ${event.previousCommand})`;
499499- case "session_restart":
500500- return `${prefix} restarted (attempt ${event.restartCount}, backoff ${event.backoffMs}ms)`;
501501- case "session_failed":
502502- return `${prefix} failed — ${event.reason}`;
503503- case "supervisor_start":
504504- return `${prefix} supervisor started`;
505505- case "supervisor_stop":
506506- return `${prefix} supervisor stopped`;
481481+ case "session_respawn":
482482+ return `${prefix} respawned`;
507483 case "state.set":
508484 return `${prefix} state.set ${event.key} = ${JSON.stringify(event.value)}`;
509485 case "state.delete":
+183-9
src/sessions.ts
···464464 return refs;
465465}
466466467467-/** Remove all exited **and** vanished sessions. Returns the names of removed
468468- * sessions. `dryRun: true` performs the same walk but doesn't delete — useful
469469- * for preview UIs. */
470470-export async function gc(opts: { dryRun?: boolean } = {}): Promise<string[]> {
471471- const sessions = await listSessions();
472472- const gone = sessions.filter((s) => isGone(s.status));
473473- if (!opts.dryRun) {
474474- for (const s of gone) cleanupAll(s.name);
467467+/** Result of a `gc()` reconciliation pass. The four buckets correspond
468468+ * to the three reconciliation steps: orphan-kill (step 1), permanent
469469+ * respawn success / failure (step 2), and the sweep of exited
470470+ * non-permanent sessions (step 3 — the historic `gc()` behavior). */
471471+export interface GcResult {
472472+ /** Names of exited/vanished non-permanent sessions whose metadata was
473473+ * removed. Empty under `dryRun: true` callers should treat the same
474474+ * list as the preview. */
475475+ removed: string[];
476476+ /** Children killed because their `parent=` referent is dead or missing. */
477477+ killedOrphanChildren: { name: string; parent: string; reason: "missing" | "dead" }[];
478478+ /** Permanent sessions respawned this pass. `ptyfileReread` indicates
479479+ * whether the spawn used a fresh `pty.toml` read (when the session
480480+ * carries `ptyfile` + `ptyfile.session` tags) or its stored metadata. */
481481+ respawned: { name: string; ptyfileReread: boolean }[];
482482+ /** Permanent sessions where respawn was attempted but failed (e.g. the
483483+ * binary is on an unmounted volume). Cron interval is the rate limit;
484484+ * next tick tries again. */
485485+ respawnFailed: { name: string; error: string }[];
486486+}
487487+488488+/** Reconciliation pass driven by `pty gc`. Stateless: every invocation
489489+ * re-derives intent from on-disk metadata. Three steps run in order:
490490+ *
491491+ * 1. Orphan-kill: children with a `parent=<name>` tag whose parent's
492492+ * metadata is gone OR whose parent's pid isn't alive get SIGTERM'd
493493+ * and `cleanupAll`'d. Runs first so a permanent child whose parent
494494+ * has died isn't immediately respawned by step 2.
495495+ * 2. Permanent respawn: every `strategy=permanent` session that's
496496+ * exited/vanished is respawned via `spawnDaemon` (lazy-imported to
497497+ * avoid the `sessions ↔ spawn` cycle). Sessions with `ptyfile` +
498498+ * `ptyfile.session` tags re-read the toml to pick up any edits.
499499+ * 3. Existing sweep: the historic behavior — exited/vanished sessions
500500+ * that aren't permanent get `cleanupAll`'d. */
501501+export async function gc(opts: { dryRun?: boolean } = {}): Promise<GcResult> {
502502+ const dryRun = !!opts.dryRun;
503503+ // First call to `listSessions` is intentionally throwaway — it has a
504504+ // side effect (`cleanupSocket`) on sessions whose daemon SIGKILL'd
505505+ // without writing an exit record, and those sessions are then *missing*
506506+ // from the returned array (their entry is dropped because `seen` set
507507+ // contained the name but the alive checks failed). A second call sees
508508+ // them via the `.json` files loop as `status=vanished`. Without this
509509+ // priming pass, step 1's orphan-kill misses vanished sessions whose
510510+ // sockets were still on disk when gc started.
511511+ await listSessions();
512512+ const initial = await listSessions();
513513+514514+ // STEP 1: orphan-children. Sort by name so cycles (A→B, B→A) resolve
515515+ // deterministically — whichever name sorts first wins this tick; the
516516+ // loser dies; on the next tick the winner has no live parent either
517517+ // and dies too. No cycle detection needed.
518518+ const killedOrphanChildren: GcResult["killedOrphanChildren"] = [];
519519+ const withParent = initial
520520+ .filter((s) => s.metadata?.tags?.parent)
521521+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
522522+ for (const s of withParent) {
523523+ const parentRef = s.metadata!.tags!.parent;
524524+ const parentMeta = readMetadata(parentRef);
525525+ const parentPid = parentMeta ? readPid(parentRef) : null;
526526+ const parentAlive = parentMeta != null && parentPid !== null && isProcessAlive(parentPid);
527527+ if (parentAlive) continue;
528528+ const reason: "missing" | "dead" = parentMeta ? "dead" : "missing";
529529+ if (!dryRun) {
530530+ if (s.status === "running" && s.pid != null) {
531531+ // SIGTERM the live daemon, then wait briefly for it to exit so
532532+ // its shutdown handler doesn't race our cleanupAll by writing
533533+ // metadata back to disk after we've removed it. We poll the
534534+ // pid (up to ~1s) and fall through whether or not the daemon
535535+ // shut down in time — cleanupAll wipes whatever remains.
536536+ try { process.kill(s.pid, "SIGTERM"); } catch {}
537537+ const deadline = Date.now() + 1000;
538538+ while (Date.now() < deadline) {
539539+ if (!isProcessAlive(s.pid)) break;
540540+ await new Promise((r) => setTimeout(r, 25));
541541+ }
542542+ }
543543+ cleanupAll(s.name);
544544+ }
545545+ killedOrphanChildren.push({ name: s.name, parent: parentRef, reason });
475546 }
476476- return gone.map((s) => s.name);
547547+548548+ // STEP 2: permanent respawn. Re-list since step 1 may have removed
549549+ // some metadata (an orphan-killed permanent child should not also
550550+ // appear here). In dryRun mode the initial list is fine — step 1
551551+ // didn't mutate anything.
552552+ const afterStep1 = dryRun ? initial : await listSessions();
553553+ const respawned: GcResult["respawned"] = [];
554554+ const respawnFailed: GcResult["respawnFailed"] = [];
555555+ for (const s of afterStep1) {
556556+ if (s.metadata?.tags?.strategy !== "permanent") continue;
557557+ if (!isGone(s.status)) continue;
558558+ const ptyfileReread = !!s.metadata?.tags?.ptyfile;
559559+ if (dryRun) {
560560+ respawned.push({ name: s.name, ptyfileReread });
561561+ continue;
562562+ }
563563+ try {
564564+ await respawnPermanent(s.name, s.metadata!);
565565+ respawned.push({ name: s.name, ptyfileReread });
566566+ } catch (err: any) {
567567+ respawnFailed.push({ name: s.name, error: err?.message ?? String(err) });
568568+ }
569569+ }
570570+571571+ // STEP 3: historic sweep. Exited/vanished non-permanent sessions get
572572+ // their metadata removed. Permanent sessions are handled by step 2 —
573573+ // if their respawn succeeded they're back to `running` and skipped;
574574+ // if it failed we leave the metadata around so the next tick can try
575575+ // again.
576576+ const finalList = dryRun ? initial : await listSessions();
577577+ const removed: string[] = [];
578578+ for (const s of finalList) {
579579+ if (!isGone(s.status)) continue;
580580+ if (s.metadata?.tags?.strategy === "permanent") continue;
581581+ if (!dryRun) cleanupAll(s.name);
582582+ removed.push(s.name);
583583+ }
584584+585585+ return { removed, killedOrphanChildren, respawned, respawnFailed };
586586+}
587587+588588+/** Restart a `strategy=permanent` session whose daemon is gone. If the
589589+ * session was toml-managed (`ptyfile` + `ptyfile.session` tags), re-read
590590+ * the pty.toml so the new daemon picks up command/env edits since the
591591+ * last spawn. On any read error fall back to the stored metadata
592592+ * verbatim (last-known-good) so a temporarily-missing toml doesn't
593593+ * prevent restart.
594594+ *
595595+ * Lazy-imports `spawn.ts` so the `sessions.ts ↔ spawn.ts` cycle doesn't
596596+ * bite at module-init time. After spawn, appends a `session_respawn`
597597+ * event to the session's event log so consumers see the restart. */
598598+async function respawnPermanent(name: string, metadata: SessionMetadata): Promise<void> {
599599+ let command = metadata.command;
600600+ let args = metadata.args;
601601+ let displayCommand = metadata.displayCommand;
602602+ let cwd = metadata.cwd;
603603+ let tags: Record<string, string> | undefined = metadata.tags;
604604+ const displayName = metadata.displayName;
605605+606606+ const ptyfilePath = metadata.tags?.ptyfile;
607607+ const ptyfileSession = metadata.tags?.["ptyfile.session"];
608608+ if (ptyfilePath && ptyfileSession) {
609609+ try {
610610+ const { readPtyFile, commandWithEnvExports } = await import("./ptyfile.ts");
611611+ const dir = path.dirname(ptyfilePath);
612612+ const ptyFile = readPtyFile(dir);
613613+ const sessDef = ptyFile.sessions.find((s) => s.shortName === ptyfileSession);
614614+ if (sessDef) {
615615+ command = "/bin/sh";
616616+ args = ["-c", commandWithEnvExports(sessDef)];
617617+ displayCommand = sessDef.command;
618618+ cwd = ptyFile.dir;
619619+ tags = {
620620+ ...sessDef.tags,
621621+ ptyfile: ptyfilePath,
622622+ "ptyfile.session": ptyfileSession,
623623+ };
624624+ }
625625+ } catch {
626626+ // pty.toml unreadable (volume not mounted yet, file deleted, parse
627627+ // error). Fall back to stored metadata — better to respawn with
628628+ // last-known-good than to give up.
629629+ }
630630+ }
631631+632632+ // Wipe stale socket/pid/events before respawn so spawnDaemon doesn't
633633+ // trip over leftovers from the dead daemon. Metadata is recreated by
634634+ // spawnDaemon.
635635+ cleanupAll(name);
636636+637637+ const { spawnDaemon } = await import("./spawn.ts");
638638+ await spawnDaemon({
639639+ name, command, args, displayCommand, cwd, tags,
640640+ ...(displayName ? { displayName } : {}),
641641+ });
642642+643643+ // Best-effort event; respawn already succeeded if we got here.
644644+ try {
645645+ appendEventSync(name, {
646646+ session: name,
647647+ type: "session_respawn",
648648+ ts: new Date().toISOString(),
649649+ });
650650+ } catch {}
477651}
478652479653/**
-30
src/supervisor-entry.ts
···11-// Standalone entry point for the supervisor.
22-// Bundled by `pty supervisor launchd install` into a single portable JS file.
33-// SERVER_MODULE_PATH is injected at bundle time by esbuild --define.
44-55-import { Supervisor } from "./supervisor.ts";
66-import { getSessionDir } from "./sessions.ts";
77-import { setServerModulePath } from "./spawn.ts";
88-99-declare const SERVER_MODULE_PATH: string;
1010-if (typeof SERVER_MODULE_PATH !== "undefined") {
1111- setServerModulePath(SERVER_MODULE_PATH);
1212-}
1313-1414-const supervisor = new Supervisor("supervisor");
1515-supervisor.start();
1616-1717-console.log(`[supervisor] started (pid ${process.pid})`);
1818-console.log(`[supervisor] watching ${getSessionDir()}`);
1919-2020-process.on("SIGTERM", () => {
2121- console.log("[supervisor] stopping...");
2222- supervisor.stop();
2323- process.exit(0);
2424-});
2525-2626-process.on("SIGINT", () => {
2727- console.log("[supervisor] stopping...");
2828- supervisor.stop();
2929- process.exit(0);
3030-});
-557
src/supervisor.ts
···11-import * as fs from "node:fs";
22-import * as path from "node:path";
33-import {
44- getSessionDir, ensureSessionDir, readMetadata, cleanupAll,
55- acquireLock, releaseLock, updateTags,
66- atomicWriteFileSync,
77- type SessionMetadata,
88-} from "./sessions.ts";
99-import { spawnDaemon } from "./spawn.ts";
1010-import { readPtyFile, commandWithEnvExports } from "./ptyfile.ts";
1111-import { EventWriter, EventType, type EventRecord } from "./events.ts";
1212-1313-/** Supervisor state lives in its own subdirectory to avoid polluting the session dir. */
1414-export function getSupervisorDir(): string {
1515- const dir = path.join(getSessionDir(), "supervisor");
1616- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
1717- return dir;
1818-}
1919-2020-const MAX_RESTARTS = 5;
2121-const RESTART_WINDOW_MS = 60_000;
2222-const INITIAL_BACKOFF_MS = 1_000;
2323-const MAX_BACKOFF_MS = 16_000;
2424-const BACKOFF_MULTIPLIER = 2;
2525-const SCAN_INTERVAL_MS = 10_000;
2626-const TEMPORARY_CLEANUP_DELAY_MS = 1_000;
2727-2828-/** Spawn parameters cached on each SupervisedSession so a restart can
2929- * retry even after `cleanupAll` has removed the on-disk metadata.
3030- * Without this, the second restart attempt of a slow-starting session
3131- * hits "no metadata" and the supervisor silently gives up. */
3232-interface CachedSpawnConfig {
3333- command: string;
3434- args: string[];
3535- displayCommand: string;
3636- cwd: string;
3737- tags?: Record<string, string>;
3838-}
3939-4040-interface SupervisedSession {
4141- name: string;
4242- strategy: "permanent" | "temporary";
4343- restartCount: number;
4444- restartWindowStart: number;
4545- lastRestartAt: number;
4646- nextBackoffMs: number;
4747- failed: boolean;
4848- pendingTimer: ReturnType<typeof setTimeout> | null;
4949- /** Last-known spawn parameters. Cached so a restart attempt can
5050- * succeed even if `cleanupAll` has already removed the on-disk
5151- * metadata file. Populated whenever `evaluateSession` reads fresh
5252- * metadata. */
5353- spawnConfig?: CachedSpawnConfig;
5454-}
5555-5656-interface PersistedState {
5757- sessions: Record<string, {
5858- restartCount: number;
5959- restartWindowStart: number;
6060- lastRestartAt: number;
6161- nextBackoffMs: number;
6262- failed: boolean;
6363- }>;
6464- savedAt: string;
6565-}
6666-6767-export class Supervisor {
6868- private sessions = new Map<string, SupervisedSession>();
6969- private dirWatcher: fs.FSWatcher | null = null;
7070- private scanInterval: ReturnType<typeof setInterval> | null = null;
7171- private eventWriter: EventWriter;
7272- private stopping = false;
7373- private lockAcquired = false;
7474-7575- constructor(private supervisorName: string) {
7676- this.eventWriter = new EventWriter(supervisorName);
7777- }
7878-7979- start(): void {
8080- ensureSessionDir();
8181-8282- // launchd starts us with a minimal env — no TERM, no COLORTERM. That
8383- // propagates all the way down into each supervised session's child PTY,
8484- // which makes TUIs like Claude Code fall back to legacy key encoding
8585- // where Shift+Enter is indistinguishable from Enter. server.ts now
8686- // defaults TERM at the PTY boundary, but we also seed it here so the
8787- // supervisor's own process.env looks consistent (useful for any
8888- // subprocess spawned outside the spawnDaemon path).
8989- if (!process.env.TERM) process.env.TERM = "xterm-256color";
9090- if (!process.env.COLORTERM) process.env.COLORTERM = "truecolor";
9191-9292- // Acquire lock to prevent multiple supervisors
9393- if (!acquireLock("supervisor")) {
9494- console.error("[supervisor] another supervisor is already running");
9595- process.exit(1);
9696- }
9797- this.lockAcquired = true;
9898-9999- // Write PID file so `pty supervisor stop` can find us
100100- const pidPath = path.join(getSupervisorDir(), "supervisor.pid");
101101- fs.writeFileSync(pidPath, process.pid.toString());
102102-103103- this.loadState();
104104- this.scanAllSessions();
105105- this.startWatching();
106106-107107- this.scanInterval = setInterval(() => {
108108- if (!this.stopping) this.scanAllSessions();
109109- }, SCAN_INTERVAL_MS);
110110-111111- this.emitEvent(EventType.SUPERVISOR_START);
112112- }
113113-114114- stop(): void {
115115- this.stopping = true;
116116-117117- for (const tracked of this.sessions.values()) {
118118- if (tracked.pendingTimer) clearTimeout(tracked.pendingTimer);
119119- }
120120-121121- this.dirWatcher?.close();
122122- this.dirWatcher = null;
123123-124124- if (this.scanInterval) {
125125- clearInterval(this.scanInterval);
126126- this.scanInterval = null;
127127- }
128128-129129- this.persistState();
130130- this.emitEvent(EventType.SUPERVISOR_STOP);
131131-132132- // Clean up PID file
133133- try { fs.unlinkSync(path.join(getSupervisorDir(), "supervisor.pid")); } catch {}
134134-135135- if (this.lockAcquired) {
136136- releaseLock("supervisor");
137137- }
138138- }
139139-140140- private scanAllSessions(): void {
141141- const sessionDir = getSessionDir();
142142- let entries: string[];
143143- try {
144144- entries = fs.readdirSync(sessionDir);
145145- } catch {
146146- return;
147147- }
148148-149149- const jsonFiles = entries.filter((e) => e.endsWith(".json"));
150150- const seenNames = new Set<string>();
151151-152152- for (const file of jsonFiles) {
153153- const name = file.replace(/\.json$/, "");
154154- if (name === this.supervisorName) continue;
155155- seenNames.add(name);
156156- this.evaluateSession(name);
157157- }
158158-159159- // Remove tracked sessions whose metadata no longer exists
160160- for (const name of this.sessions.keys()) {
161161- if (!seenNames.has(name)) {
162162- const tracked = this.sessions.get(name)!;
163163- if (tracked.pendingTimer) clearTimeout(tracked.pendingTimer);
164164- this.sessions.delete(name);
165165- }
166166- }
167167- }
168168-169169- private evaluateSession(name: string): void {
170170- const metadata = readMetadata(name);
171171- if (!metadata) return;
172172-173173- const strategy = metadata.tags?.strategy;
174174-175175- // No strategy tag — stop tracking if previously tracked
176176- if (strategy !== "permanent" && strategy !== "temporary") {
177177- const existing = this.sessions.get(name);
178178- if (existing) {
179179- if (existing.pendingTimer) clearTimeout(existing.pendingTimer);
180180- this.sessions.delete(name);
181181- console.log(`[supervisor] stopped tracking ${name} (strategy removed)`);
182182- }
183183- return;
184184- }
185185-186186- // Check both metadata and whether the process is actually alive.
187187- // If the daemon was killed externally, exitedAt may not be set.
188188- let isExited = !!metadata.exitedAt;
189189- if (!isExited) {
190190- const sockPath = path.join(getSessionDir(), `${name}.sock`);
191191- const pidPath = path.join(getSessionDir(), `${name}.pid`);
192192- try {
193193- const pid = parseInt(fs.readFileSync(pidPath, "utf-8").trim(), 10);
194194- process.kill(pid, 0); // throws if not alive
195195- } catch {
196196- // Process is dead but metadata doesn't know — treat as exited
197197- if (!fs.existsSync(sockPath)) {
198198- isExited = true;
199199- }
200200- }
201201- }
202202-203203- if (strategy === "permanent") {
204204- if (!this.sessions.has(name)) {
205205- this.sessions.set(name, {
206206- name,
207207- strategy: "permanent",
208208- restartCount: 0,
209209- restartWindowStart: 0,
210210- lastRestartAt: 0,
211211- nextBackoffMs: INITIAL_BACKOFF_MS,
212212- failed: false,
213213- pendingTimer: null,
214214- });
215215- console.log(`[supervisor] tracking ${name} (permanent)`);
216216- }
217217-218218- const tracked = this.sessions.get(name)!;
219219- tracked.strategy = "permanent";
220220- tracked.spawnConfig = {
221221- command: metadata.command,
222222- args: metadata.args,
223223- displayCommand: metadata.displayCommand,
224224- cwd: metadata.cwd,
225225- tags: metadata.tags,
226226- };
227227-228228- if (isExited && !tracked.failed && !tracked.pendingTimer) {
229229- this.scheduleRestart(name);
230230- }
231231-232232- // If it's running, reset the restart window if enough time has passed
233233- if (!isExited && tracked.restartWindowStart > 0) {
234234- const elapsed = Date.now() - tracked.restartWindowStart;
235235- if (elapsed > RESTART_WINDOW_MS) {
236236- tracked.restartCount = 0;
237237- tracked.restartWindowStart = 0;
238238- tracked.nextBackoffMs = INITIAL_BACKOFF_MS;
239239- }
240240- }
241241- }
242242-243243- if (strategy === "temporary") {
244244- if (isExited) {
245245- // Schedule cleanup
246246- if (!this.sessions.has(name) || !this.sessions.get(name)!.pendingTimer) {
247247- console.log(`[supervisor] cleaning up temporary session ${name}`);
248248- const timer = setTimeout(() => {
249249- cleanupAll(name);
250250- this.sessions.delete(name);
251251- console.log(`[supervisor] removed temporary session ${name}`);
252252- }, TEMPORARY_CLEANUP_DELAY_MS);
253253- this.sessions.set(name, {
254254- name,
255255- strategy: "temporary",
256256- restartCount: 0,
257257- restartWindowStart: 0,
258258- lastRestartAt: 0,
259259- nextBackoffMs: 0,
260260- failed: false,
261261- pendingTimer: timer,
262262- });
263263- }
264264- } else if (!this.sessions.has(name)) {
265265- this.sessions.set(name, {
266266- name,
267267- strategy: "temporary",
268268- restartCount: 0,
269269- restartWindowStart: 0,
270270- lastRestartAt: 0,
271271- nextBackoffMs: 0,
272272- failed: false,
273273- pendingTimer: null,
274274- });
275275- console.log(`[supervisor] tracking ${name} (temporary)`);
276276- }
277277- }
278278- }
279279-280280- private scheduleRestart(name: string): void {
281281- const tracked = this.sessions.get(name);
282282- if (!tracked || tracked.strategy !== "permanent") return;
283283-284284- const now = Date.now();
285285-286286- // Reset window if expired
287287- if (tracked.restartWindowStart > 0 && now - tracked.restartWindowStart > RESTART_WINDOW_MS) {
288288- tracked.restartCount = 0;
289289- tracked.restartWindowStart = 0;
290290- tracked.nextBackoffMs = INITIAL_BACKOFF_MS;
291291- }
292292-293293- // Check restart limit
294294- if (tracked.restartCount >= MAX_RESTARTS) {
295295- this.markFailed(name);
296296- return;
297297- }
298298-299299- // Start window on first restart
300300- if (tracked.restartWindowStart === 0) {
301301- tracked.restartWindowStart = now;
302302- }
303303-304304- const backoff = tracked.nextBackoffMs;
305305- console.log(`[supervisor] scheduling restart for ${name} in ${backoff}ms (attempt ${tracked.restartCount + 1}/${MAX_RESTARTS})`);
306306-307307- tracked.pendingTimer = setTimeout(() => {
308308- tracked.pendingTimer = null;
309309- console.log(`[supervisor] attempting restart for ${name}...`);
310310- this.doRestart(name).catch((err) => {
311311- console.log(`[supervisor] restart failed for ${name}: ${err.message}`);
312312- tracked.restartCount++;
313313- tracked.nextBackoffMs = Math.min(tracked.nextBackoffMs * BACKOFF_MULTIPLIER, MAX_BACKOFF_MS);
314314- this.persistState();
315315- // Try again if under limit
316316- if (tracked.restartCount < MAX_RESTARTS) {
317317- this.scheduleRestart(name);
318318- } else {
319319- this.markFailed(name);
320320- }
321321- });
322322- }, backoff);
323323- }
324324-325325- private async doRestart(name: string): Promise<void> {
326326- if (this.stopping) {
327327- console.log(`[supervisor] skipping restart for ${name} (stopping)`);
328328- return;
329329- }
330330-331331- const metadata = readMetadata(name);
332332- const tracked = this.sessions.get(name);
333333-334334- // Recovery for the "metadata file already removed" case:
335335- // cleanupAll runs before spawnDaemon during a restart, so if the
336336- // first attempt's spawn times out the metadata is gone for the
337337- // second attempt's readMetadata. Fall back to the cached
338338- // spawnConfig on the in-memory SupervisedSession when that
339339- // happens — without this, a single slow start permanently drops
340340- // a permanent session.
341341- if (!metadata) {
342342- if (tracked?.strategy === "permanent" && tracked.spawnConfig && !tracked.failed) {
343343- console.log(`[supervisor] no on-disk metadata for ${name}; recovering from cached spawnConfig`);
344344- await this.respawnFromCachedConfig(name, tracked);
345345- } else {
346346- console.log(`[supervisor] skipping restart for ${name} (no metadata)`);
347347- }
348348- return;
349349- }
350350- if (metadata.tags?.strategy !== "permanent") {
351351- console.log(`[supervisor] skipping restart for ${name} (strategy removed)`);
352352- return;
353353- }
354354-355355- // Check if actually still dead (exitedAt may be missing if killed externally)
356356- if (!metadata.exitedAt) {
357357- const pidPath = path.join(getSessionDir(), `${name}.pid`);
358358- try {
359359- const pid = parseInt(fs.readFileSync(pidPath, "utf-8").trim(), 10);
360360- process.kill(pid, 0);
361361- console.log(`[supervisor] skipping restart for ${name} (pid ${pid} still alive)`);
362362- return;
363363- } catch {
364364- // dead — proceed with restart
365365- }
366366- }
367367-368368- if (!tracked) {
369369- console.log(`[supervisor] skipping restart for ${name} (not tracked)`);
370370- return;
371371- }
372372-373373- // If session was started from a pty.toml, re-read it for the latest config
374374- let command = metadata.command;
375375- let args = metadata.args;
376376- let displayCommand = metadata.displayCommand;
377377- let cwd = metadata.cwd;
378378- let tags = metadata.tags;
379379-380380- const ptyfilePath = metadata.tags?.ptyfile;
381381- const ptyfileSession = metadata.tags?.["ptyfile.session"];
382382- if (ptyfilePath && ptyfileSession) {
383383- try {
384384- const dir = path.dirname(ptyfilePath);
385385- const ptyFile = readPtyFile(dir);
386386- const sessDef = ptyFile.sessions.find((s) => s.shortName === ptyfileSession);
387387- if (sessDef) {
388388- command = "/bin/sh";
389389- args = ["-c", commandWithEnvExports(sessDef)];
390390- displayCommand = sessDef.command;
391391- cwd = ptyFile.dir;
392392- tags = { ...sessDef.tags, ptyfile: ptyfilePath, "ptyfile.session": ptyfileSession };
393393- console.log(`[supervisor] re-read pty.toml for ${name}`);
394394- }
395395- } catch (err: any) {
396396- console.log(`[supervisor] could not re-read pty.toml for ${name}: ${err.message}, using stored metadata`);
397397- }
398398- }
399399-400400- // Cache the spawn config before cleanup so a slow start that
401401- // wipes the metadata file can still recover on the next attempt.
402402- tracked.spawnConfig = { command, args, displayCommand, cwd, tags };
403403-404404- // Preserve the existing displayName across the respawn so the user-visible
405405- // label survives. (`name` is the immutable on-disk id; displayName is the
406406- // pretty label that pty.toml sets.)
407407- const displayName = metadata.displayName;
408408-409409- // Clean up the dead session
410410- cleanupAll(name);
411411-412412- // Respawn
413413- await spawnDaemon({ name, command, args, displayCommand, cwd, tags, ...(displayName ? { displayName } : {}) });
414414-415415- tracked.restartCount++;
416416- tracked.lastRestartAt = Date.now();
417417- tracked.nextBackoffMs = Math.min(tracked.nextBackoffMs * BACKOFF_MULTIPLIER, MAX_BACKOFF_MS);
418418-419419- console.log(`[supervisor] restarted ${name} (attempt ${tracked.restartCount}/${MAX_RESTARTS})`);
420420-421421- this.emitEvent(EventType.SESSION_RESTART, {
422422- session: name,
423423- restartCount: tracked.restartCount,
424424- backoffMs: tracked.nextBackoffMs,
425425- });
426426-427427- this.persistState();
428428- }
429429-430430- /** Restart path used when the on-disk metadata is missing. The
431431- * cached spawnConfig on the SupervisedSession was populated last
432432- * time evaluateSession saw fresh metadata, so it's still valid as
433433- * a recovery source. */
434434- private async respawnFromCachedConfig(name: string, tracked: SupervisedSession): Promise<void> {
435435- const cfg = tracked.spawnConfig;
436436- if (!cfg) return; // caller checked already, but be defensive
437437-438438- cleanupAll(name);
439439- await spawnDaemon({
440440- name,
441441- command: cfg.command,
442442- args: cfg.args,
443443- displayCommand: cfg.displayCommand,
444444- cwd: cfg.cwd,
445445- tags: cfg.tags,
446446- });
447447-448448- tracked.restartCount++;
449449- tracked.lastRestartAt = Date.now();
450450- tracked.nextBackoffMs = Math.min(tracked.nextBackoffMs * BACKOFF_MULTIPLIER, MAX_BACKOFF_MS);
451451-452452- console.log(`[supervisor] restarted ${name} from cached config (attempt ${tracked.restartCount}/${MAX_RESTARTS})`);
453453-454454- this.emitEvent(EventType.SESSION_RESTART, {
455455- session: name,
456456- restartCount: tracked.restartCount,
457457- backoffMs: tracked.nextBackoffMs,
458458- });
459459-460460- this.persistState();
461461- }
462462-463463- private markFailed(name: string): void {
464464- const tracked = this.sessions.get(name);
465465- if (!tracked) return;
466466-467467- tracked.failed = true;
468468- if (tracked.pendingTimer) {
469469- clearTimeout(tracked.pendingTimer);
470470- tracked.pendingTimer = null;
471471- }
472472-473473- console.log(`[supervisor] ${name} marked as FAILED (${tracked.restartCount} restarts in window)`);
474474-475475- // Set failed tag on the session
476476- try {
477477- updateTags(name, { "supervisor.status": "failed" }, []);
478478- } catch {
479479- // Metadata may have been cleaned up
480480- }
481481-482482- this.emitEvent(EventType.SESSION_FAILED, {
483483- session: name,
484484- restartCount: tracked.restartCount,
485485- reason: `exceeded ${MAX_RESTARTS} restarts in ${RESTART_WINDOW_MS / 1000}s`,
486486- });
487487-488488- this.persistState();
489489- }
490490-491491- private emitEvent(type: string, fields?: Record<string, unknown>): void {
492492- this.eventWriter.append({
493493- session: this.supervisorName,
494494- type: type as EventRecord["type"],
495495- ts: new Date().toISOString(),
496496- ...fields,
497497- } as EventRecord);
498498- }
499499-500500- private startWatching(): void {
501501- const sessionDir = getSessionDir();
502502- try {
503503- this.dirWatcher = fs.watch(sessionDir, (eventType, filename) => {
504504- if (this.stopping) return;
505505- if (!filename || !filename.endsWith(".json")) return;
506506- const name = filename.replace(/\.json$/, "");
507507- if (name === this.supervisorName) return;
508508- // Debounce: defer evaluation to next tick to let writes complete
509509- setImmediate(() => this.evaluateSession(name));
510510- });
511511- } catch (err) {
512512- console.error(`[supervisor] failed to watch session directory: ${err}`);
513513- }
514514- }
515515-516516- private persistState(): void {
517517- const state: PersistedState = {
518518- sessions: {},
519519- savedAt: new Date().toISOString(),
520520- };
521521- for (const [name, tracked] of this.sessions) {
522522- if (tracked.strategy !== "permanent") continue;
523523- state.sessions[name] = {
524524- restartCount: tracked.restartCount,
525525- restartWindowStart: tracked.restartWindowStart,
526526- lastRestartAt: tracked.lastRestartAt,
527527- nextBackoffMs: tracked.nextBackoffMs,
528528- failed: tracked.failed,
529529- };
530530- }
531531- const filePath = path.join(getSupervisorDir(), "state.json");
532532- try {
533533- atomicWriteFileSync(filePath, JSON.stringify(state, null, 2));
534534- } catch {
535535- // Non-fatal
536536- }
537537- }
538538-539539- private loadState(): void {
540540- const filePath = path.join(getSupervisorDir(), "state.json");
541541- try {
542542- const content = fs.readFileSync(filePath, "utf-8");
543543- const state: PersistedState = JSON.parse(content);
544544- for (const [name, saved] of Object.entries(state.sessions)) {
545545- this.sessions.set(name, {
546546- name,
547547- strategy: "permanent",
548548- ...saved,
549549- pendingTimer: null,
550550- });
551551- }
552552- console.log(`[supervisor] loaded state: ${Object.keys(state.sessions).length} tracked sessions`);
553553- } catch {
554554- // No state file or invalid — start fresh
555555- }
556556- }
557557-}
+6-5
src/tags.ts
···4646}
47474848/**
4949- * Keys that pty itself treats as internal bookkeeping. These drive
5050- * dedicated markers (`[permanent]`, `[failed]`) or wire up the
5151- * supervisor / ptyfile plumbing — they're visible in `pty list --tags`
5252- * but hidden from the default listing.
4949+ * Keys that pty itself treats as internal bookkeeping. `strategy` drives
5050+ * the `[permanent]` marker and tells `pty gc` to respawn the session on
5151+ * exit; `ptyfile*` keys wire up the toml-managed-session plumbing. The
5252+ * user-facing `parent=<name>` orphan-kill tag is intentionally NOT
5353+ * reserved — it's a regular tag visible in `pty list`. Reserved keys
5454+ * are visible in `pty list --tags` but hidden from the default listing.
5355 */
5456const EXACT_RESERVED = new Set([
5557 "ptyfile",
5658 "ptyfile.session",
5759 "ptyfile.tags",
5858- "supervisor.status",
5960 "strategy",
6061]);
6162
···11-import { describe, it, expect, afterEach, afterAll } from "vitest";
22-import * as fs from "node:fs";
33-import * as os from "node:os";
44-import * as path from "node:path";
55-import { fileURLToPath } from "node:url";
66-import { spawn, spawnSync } from "node:child_process";
77-88-const __dirname = path.dirname(fileURLToPath(import.meta.url));
99-const nodeBin = process.execPath;
1010-const cliPath = path.join(__dirname, "..", "dist", "cli.js");
1111-const serverModule = path.join(__dirname, "..", "dist", "server.js");
1212-1313-const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-sup-hard-"));
1414-afterAll(() => {
1515- fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
1616-});
1717-1818-let bgPids: number[] = [];
1919-let sessionDirs: string[] = [];
2020-2121-function makeSessionDir(): string {
2222- const dir = fs.mkdtempSync(path.join(testRoot, "d-"));
2323- sessionDirs.push(dir);
2424- return dir;
2525-}
2626-2727-let nameCounter = 0;
2828-function uniqueName(): string {
2929- return `sh${++nameCounter}-${Math.random().toString(36).slice(2, 6)}`;
3030-}
3131-3232-async function startDaemon(
3333- sessionDir: string,
3434- name: string,
3535- command: string,
3636- args: string[] = [],
3737- tags?: Record<string, string>,
3838-): Promise<number> {
3939- const config = JSON.stringify({
4040- name, command, args, displayCommand: command,
4141- cwd: os.tmpdir(), rows: 24, cols: 80, tags,
4242- });
4343- const child = spawn(nodeBin, [serverModule], {
4444- detached: true,
4545- stdio: ["ignore", "ignore", "pipe"],
4646- env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir },
4747- });
4848- let stderr = "";
4949- child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); });
5050- let exitCode: number | null = null;
5151- child.on("exit", (code) => { exitCode = code; });
5252- (child.stderr as any)?.unref?.();
5353- child.unref();
5454-5555- const socketPath = path.join(sessionDir, `${name}.sock`);
5656- const start = Date.now();
5757- while (Date.now() - start < 5000) {
5858- if (exitCode !== null) throw new Error(`Daemon exited: ${stderr}`);
5959- try {
6060- fs.statSync(socketPath);
6161- await new Promise((r) => setTimeout(r, 100));
6262- bgPids.push(child.pid!);
6363- return child.pid!;
6464- } catch {}
6565- await new Promise((r) => setTimeout(r, 50));
6666- }
6767- throw new Error("Timeout waiting for daemon");
6868-}
6969-7070-function runCli(sessionDir: string, ...args: string[]) {
7171- return spawnSync(nodeBin, [cliPath, ...args], {
7272- env: { ...process.env, PTY_SESSION_DIR: sessionDir },
7373- encoding: "utf-8",
7474- timeout: 15000,
7575- });
7676-}
7777-7878-function readMeta(sessionDir: string, name: string): any {
7979- try {
8080- return JSON.parse(fs.readFileSync(path.join(sessionDir, `${name}.json`), "utf-8"));
8181- } catch { return null; }
8282-}
8383-8484-afterEach(() => {
8585- for (const pid of bgPids) { try { process.kill(pid, "SIGTERM"); } catch {} }
8686- bgPids = [];
8787- for (const dir of sessionDirs) {
8888- try {
8989- for (const e of fs.readdirSync(dir)) { try { fs.unlinkSync(path.join(dir, e)); } catch {} }
9090- } catch {}
9191- }
9292- sessionDirs = [];
9393-});
9494-9595-describe("displayCommand formatting", () => {
9696- it("pty run shows command with args in list", () => {
9797- const dir = makeSessionDir();
9898- const id = uniqueName();
9999-100100- runCli(dir, "run", "-d", "--id", id, "--", "echo", "hello", "world");
101101- // Wait for session to start
102102- const start = Date.now();
103103- while (Date.now() - start < 3000) {
104104- const meta = readMeta(dir, id);
105105- if (meta) break;
106106- }
107107-108108- const meta = readMeta(dir, id);
109109- expect(meta).not.toBeNull();
110110- expect(meta.displayCommand).toBe("echo hello world");
111111- }, 15000);
112112-113113- it("pty up shows toml command without duplication in list", () => {
114114- const projDir = fs.mkdtempSync(path.join(testRoot, "proj-"));
115115- const dir = makeSessionDir();
116116- fs.writeFileSync(path.join(projDir, "pty.toml"), `
117117-[sessions.serve]
118118-command = "echo server running"
119119-`);
120120-121121- runCli(dir, "up", projDir);
122122-123123- // pty up gives the session a random short id as the on-disk name and
124124- // sets `serve` as the displayName. Wait for the session to register, then
125125- // look up the on-disk name by displayName.
126126- const start = Date.now();
127127- let sess: any = undefined;
128128- while (Date.now() - start < 3000) {
129129- const listResult = JSON.parse(runCli(dir, "list", "--json").stdout) as any[];
130130- sess = listResult.find((s: any) => s.displayName === "serve");
131131- if (sess) break;
132132- }
133133- expect(sess).toBeDefined();
134134- const meta = readMeta(dir, sess.name);
135135- expect(meta).not.toBeNull();
136136- expect(meta.displayCommand).toBe("echo server running");
137137- // command should be /bin/sh, args should be ["-c", "echo server running"]
138138- expect(meta.command).toBe("/bin/sh");
139139-140140- // List output should show the command once, not duplicated
141141- const list = runCli(dir, "list");
142142- const lines = list.stdout.split("\n").filter((l: string) => l.includes("serve"));
143143- expect(lines.length).toBeGreaterThan(0);
144144- // Count occurrences of "echo server running" — should be 1
145145- const matches = lines[0].match(/echo server running/g);
146146- expect(matches).toHaveLength(1);
147147- }, 15000);
148148-});
149149-150150-describe("pty kill on supervised sessions", () => {
151151- it("removes strategy tag when killing a supervised session", async () => {
152152- const dir = makeSessionDir();
153153- const name = uniqueName();
154154- await startDaemon(dir, name, "cat", [], { strategy: "permanent" });
155155-156156- const result = runCli(dir, "kill", name);
157157- expect(result.status).toBe(0);
158158-159159- // Strategy tag should be removed
160160- const meta = readMeta(dir, name);
161161- // Meta might be null if cleanup happened, or present without strategy
162162- if (meta?.tags) {
163163- expect(meta.tags.strategy).toBeUndefined();
164164- }
165165- }, 15000);
166166-167167- it("warns about toml-managed sessions when killing", async () => {
168168- const dir = makeSessionDir();
169169- const name = uniqueName();
170170- await startDaemon(dir, name, "cat", [], {
171171- strategy: "permanent",
172172- ptyfile: "/some/path/pty.toml",
173173- });
174174-175175- const result = runCli(dir, "kill", name);
176176- expect(result.stderr).toContain("pty.toml");
177177- expect(result.stderr).toContain("pty up");
178178- }, 15000);
179179-});
180180-181181-describe("pty supervisor reset", () => {
182182- it("clears failed status and restart counter", async () => {
183183- const dir = makeSessionDir();
184184- const name = uniqueName();
185185- await startDaemon(dir, name, "true", [], {
186186- strategy: "permanent",
187187- "supervisor.status": "failed",
188188- });
189189- await new Promise((r) => setTimeout(r, 1000)); // wait for exit
190190-191191- // Write fake supervisor state
192192- const supDir = path.join(dir, "supervisor");
193193- fs.mkdirSync(supDir, { recursive: true });
194194- fs.writeFileSync(path.join(supDir, "state.json"), JSON.stringify({
195195- sessions: { [name]: { restartCount: 5, restartWindowStart: Date.now(), failed: true, nextBackoffMs: 16000, lastRestartAt: Date.now() } },
196196- savedAt: new Date().toISOString(),
197197- }));
198198-199199- const result = runCli(dir, "supervisor", "reset", name);
200200- expect(result.status).toBe(0);
201201- expect(result.stdout).toContain("Reset");
202202-203203- // Failed tag should be removed
204204- const meta = readMeta(dir, name);
205205- expect(meta.tags["supervisor.status"]).toBeUndefined();
206206-207207- // State file should have reset counters
208208- const state = JSON.parse(fs.readFileSync(path.join(supDir, "state.json"), "utf-8"));
209209- expect(state.sessions[name].restartCount).toBe(0);
210210- expect(state.sessions[name].failed).toBe(false);
211211- }, 15000);
212212-213213- it("reports when session is not failed", async () => {
214214- const dir = makeSessionDir();
215215- const name = uniqueName();
216216- await startDaemon(dir, name, "cat", [], { strategy: "permanent" });
217217-218218- const result = runCli(dir, "supervisor", "reset", name);
219219- expect(result.stdout).toContain("not in failed state");
220220- }, 15000);
221221-});
222222-223223-describe("pty supervisor forget", () => {
224224- it("warns about toml-managed sessions", async () => {
225225- const dir = makeSessionDir();
226226- const name = uniqueName();
227227- await startDaemon(dir, name, "cat", [], {
228228- strategy: "permanent",
229229- ptyfile: "/some/path/pty.toml",
230230- });
231231-232232- const result = runCli(dir, "supervisor", "forget", name);
233233- expect(result.status).toBe(0);
234234- expect(result.stdout).toContain("Removed supervision");
235235- expect(result.stderr).toContain("pty.toml");
236236- expect(result.stderr).toContain("pty up");
237237- }, 15000);
238238-});
239239-240240-describe("pty tag warning", () => {
241241- it("warns when modifying tags on toml-managed sessions", async () => {
242242- const dir = makeSessionDir();
243243- const name = uniqueName();
244244- await startDaemon(dir, name, "cat", [], {
245245- ptyfile: "/some/path/pty.toml",
246246- "ptyfile.session": "test",
247247- });
248248-249249- const result = runCli(dir, "tag", name, "custom=value");
250250- expect(result.status).toBe(0);
251251- expect(result.stderr).toContain("pty.toml");
252252- expect(result.stderr).toContain("pty up");
253253- }, 15000);
254254-});
255255-256256-describe("spawnDaemon process leak", () => {
257257- it("kills orphaned daemon on waitForSocket timeout", () => {
258258- const dir = makeSessionDir();
259259-260260- // Try to spawn with a nonexistent command — daemon will crash
261261- const result = runCli(dir, "run", "-d", "--id", "leak-test", "--", "/nonexistent/command");
262262-263263- // The command should have failed
264264- expect(result.status).not.toBe(0);
265265-266266- // No PID file should exist (daemon was cleaned up)
267267- const pidFile = path.join(dir, "leak-test.pid");
268268- let leaked = false;
269269- try {
270270- const pid = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10);
271271- // Check if this process is still alive
272272- try { process.kill(pid, 0); leaked = true; } catch {}
273273- } catch {}
274274- expect(leaked).toBe(false);
275275- }, 15000);
276276-});
277277-278278-describe("supervisor re-reads pty.toml on restart", () => {
279279- it("uses updated toml command when restarting", async () => {
280280- const projDir = fs.mkdtempSync(path.join(testRoot, "proj-"));
281281- const dir = makeSessionDir();
282282-283283- // Create initial toml
284284- fs.writeFileSync(path.join(projDir, "pty.toml"), `
285285-[sessions.reread]
286286-command = "echo original"
287287-tags = { strategy = "permanent" }
288288-`);
289289-290290- // Start via pty up
291291- runCli(dir, "up", projDir);
292292- await new Promise((r) => setTimeout(r, 1500)); // wait for exit + metadata
293293-294294- // Update the toml command
295295- fs.writeFileSync(path.join(projDir, "pty.toml"), `
296296-[sessions.reread]
297297-command = "echo updated"
298298-tags = { strategy = "permanent" }
299299-`);
300300-301301- // Start supervisor to restart the session
302302- const sup = spawn(nodeBin, [cliPath, "supervisor", "start"], {
303303- detached: true,
304304- stdio: ["ignore", "ignore", "ignore"],
305305- env: { ...process.env, PTY_SESSION_DIR: dir },
306306- });
307307- sup.unref();
308308- bgPids.push(sup.pid!);
309309-310310- // Wait for supervisor to restart the session
311311- await new Promise((r) => setTimeout(r, 5000));
312312-313313- // Check the restarted session's metadata
314314- const meta = readMeta(dir, "reread");
315315- if (meta) {
316316- // displayCommand should reflect the updated toml
317317- expect(meta.displayCommand).toBe("echo updated");
318318- }
319319- }, 20000);
320320-});
-149
tests/supervisor-service-install.test.ts
···11-import { describe, it, expect, afterEach } from "vitest";
22-import * as fs from "node:fs";
33-import * as os from "node:os";
44-import * as path from "node:path";
55-import { fileURLToPath } from "node:url";
66-import { spawn, spawnSync } from "node:child_process";
77-88-const __dirname = path.dirname(fileURLToPath(import.meta.url));
99-const nodeBin = process.execPath;
1010-const cliPath = path.join(__dirname, "..", "dist", "cli.js");
1111-1212-// Platform gates. Both installers are Linux-only — macOS has no systemd or
1313-// runit. Rather than fail the whole suite on the wrong platform, skip the
1414-// individual tests so developers on Mac see a clean "skipped" rather than
1515-// red "runsvdir is not installed" errors. CI runs on Linux and exercises
1616-// the real code path.
1717-function hasCommand(cmd: string): boolean {
1818- return spawnSync("sh", ["-lc", `command -v ${cmd} >/dev/null 2>&1`]).status === 0;
1919-}
2020-const hasSystemdUser = process.platform === "linux" && hasCommand("systemctl");
2121-const hasRunit = process.platform === "linux" && hasCommand("runsvdir");
2222-2323-const bgPids: number[] = [];
2424-const cleanupUnits: string[] = [];
2525-const cleanupPaths: string[] = [];
2626-2727-function runCli(env: NodeJS.ProcessEnv, ...args: string[]) {
2828- return spawnSync(nodeBin, [cliPath, ...args], {
2929- env,
3030- encoding: "utf-8",
3131- timeout: 20000,
3232- });
3333-}
3434-3535-function waitForFile(filePath: string, timeoutMs = 10000): Promise<void> {
3636- const start = Date.now();
3737- return new Promise((resolve, reject) => {
3838- function check() {
3939- if (fs.existsSync(filePath)) return resolve();
4040- if (Date.now() - start > timeoutMs) return reject(new Error(`Timeout waiting for ${filePath}`));
4141- setTimeout(check, 100);
4242- }
4343- check();
4444- });
4545-}
4646-4747-function killPid(pid: number | undefined) {
4848- if (!pid) return;
4949- try { process.kill(pid, "SIGTERM"); } catch {}
5050-}
5151-5252-afterEach(() => {
5353- // systemctl cleanup is a no-op on macOS where the cmd doesn't exist.
5454- // Guarding saves noisy spawn errors in the test log.
5555- if (hasSystemdUser) {
5656- for (const unit of cleanupUnits.splice(0)) {
5757- spawnSync("systemctl", ["--user", "disable", "--now", unit], { encoding: "utf-8" });
5858- spawnSync("systemctl", ["--user", "reset-failed", unit], { encoding: "utf-8" });
5959- const unitPath = path.join(os.homedir(), ".config", "systemd", "user", unit);
6060- try { fs.unlinkSync(unitPath); } catch {}
6161- }
6262- spawnSync("systemctl", ["--user", "daemon-reload"], { encoding: "utf-8" });
6363- } else {
6464- cleanupUnits.length = 0;
6565- }
6666-6767- for (const pid of bgPids.splice(0)) killPid(pid);
6868- for (const p of cleanupPaths.splice(0)) {
6969- try { fs.rmSync(p, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch {}
7070- }
7171-});
7272-7373-describe("supervisor service installers", () => {
7474- it.skipIf(!hasSystemdUser)("installs and uninstalls a user systemd service", async () => {
7575- const sessionDir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-systemd-"));
7676- cleanupPaths.push(sessionDir);
7777-7878- const unitBase = `pty-supervisor-test-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
7979- const unitFile = `${unitBase}.service`;
8080- cleanupUnits.push(unitFile);
8181-8282- const env = { ...process.env, PTY_SESSION_DIR: sessionDir };
8383- const install = runCli(env, "supervisor", "systemd", "install", "--name", unitBase);
8484- expect(install.status).toBe(0);
8585- expect(install.stdout).toContain(unitFile);
8686-8787- await waitForFile(path.join(sessionDir, "supervisor", "supervisor.pid"));
8888-8989- const active = spawnSync("systemctl", ["--user", "is-active", unitFile], {
9090- encoding: "utf-8",
9191- timeout: 10000,
9292- });
9393- expect(active.status).toBe(0);
9494- expect(active.stdout.trim()).toBe("active");
9595-9696- const uninstall = runCli(env, "supervisor", "systemd", "uninstall", "--name", unitBase);
9797- expect(uninstall.status).toBe(0);
9898-9999- const activeAfter = spawnSync("systemctl", ["--user", "is-active", unitFile], {
100100- encoding: "utf-8",
101101- timeout: 10000,
102102- });
103103- expect(activeAfter.status).not.toBe(0);
104104- }, 30000);
105105-106106- it.skipIf(!hasRunit)("installs a runit service that can be started by a private runsvdir", async () => {
107107- const root = fs.mkdtempSync(path.join(os.tmpdir(), "pty-runit-"));
108108- const sessionDir = path.join(root, "sessions");
109109- const svDir = path.join(root, "sv");
110110- const serviceDir = path.join(root, "service");
111111- fs.mkdirSync(sessionDir, { recursive: true });
112112- cleanupPaths.push(root);
113113-114114- const serviceName = `pty-supervisor-test-${Math.random().toString(36).slice(2, 8)}`;
115115- const env = { ...process.env, PTY_SESSION_DIR: sessionDir };
116116-117117- const install = runCli(
118118- env,
119119- "supervisor", "runit", "install",
120120- "--name", serviceName,
121121- "--svdir", svDir,
122122- "--service-dir", serviceDir,
123123- );
124124- expect(install.status).toBe(0);
125125- expect(fs.existsSync(path.join(svDir, serviceName, "run"))).toBe(true);
126126- expect(fs.lstatSync(path.join(serviceDir, serviceName)).isSymbolicLink()).toBe(true);
127127-128128- const child = spawn("runsvdir", [serviceDir], {
129129- detached: true,
130130- stdio: ["ignore", "ignore", "ignore"],
131131- env,
132132- });
133133- child.unref();
134134- bgPids.push(child.pid!);
135135-136136- await waitForFile(path.join(sessionDir, "supervisor", "supervisor.pid"));
137137-138138- const uninstall = runCli(
139139- env,
140140- "supervisor", "runit", "uninstall",
141141- "--name", serviceName,
142142- "--svdir", svDir,
143143- "--service-dir", serviceDir,
144144- );
145145- expect(uninstall.status).toBe(0);
146146- expect(fs.existsSync(path.join(svDir, serviceName))).toBe(false);
147147- expect(fs.existsSync(path.join(serviceDir, serviceName))).toBe(false);
148148- }, 30000);
149149-});
-270
tests/supervisor.test.ts
···11-import { describe, it, expect, afterEach, afterAll } from "vitest";
22-import * as fs from "node:fs";
33-import * as os from "node:os";
44-import * as path from "node:path";
55-import { fileURLToPath } from "node:url";
66-import { spawn, spawnSync } from "node:child_process";
77-88-const __dirname = path.dirname(fileURLToPath(import.meta.url));
99-const nodeBin = process.execPath;
1010-const cliPath = path.join(__dirname, "..", "dist", "cli.js");
1111-const serverModule = path.join(__dirname, "..", "dist", "server.js");
1212-const supervisorModule = path.join(__dirname, "..", "dist", "supervisor.js");
1313-1414-const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-sup-"));
1515-afterAll(() => {
1616- fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
1717-});
1818-1919-let bgPids: number[] = [];
2020-let sessionDirs: string[] = [];
2121-2222-function makeSessionDir(): string {
2323- const dir = fs.mkdtempSync(path.join(testRoot, "d-"));
2424- sessionDirs.push(dir);
2525- return dir;
2626-}
2727-2828-let nameCounter = 0;
2929-function uniqueName(): string {
3030- return `sup${++nameCounter}-${Math.random().toString(36).slice(2, 6)}`;
3131-}
3232-3333-async function startDaemon(
3434- sessionDir: string,
3535- name: string,
3636- command: string,
3737- args: string[] = [],
3838- tags?: Record<string, string>,
3939-): Promise<number> {
4040- const config = JSON.stringify({
4141- name, command, args, displayCommand: command,
4242- cwd: os.tmpdir(), rows: 24, cols: 80, tags,
4343- });
4444- const child = spawn(nodeBin, [serverModule], {
4545- detached: true,
4646- stdio: ["ignore", "ignore", "pipe"],
4747- env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir },
4848- });
4949- let stderr = "";
5050- child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); });
5151- let exitCode: number | null = null;
5252- child.on("exit", (code) => { exitCode = code; });
5353- (child.stderr as any)?.unref?.();
5454- child.unref();
5555-5656- const socketPath = path.join(sessionDir, `${name}.sock`);
5757- const start = Date.now();
5858- while (Date.now() - start < 5000) {
5959- if (exitCode !== null) throw new Error(`Daemon exited: ${stderr}`);
6060- try {
6161- fs.statSync(socketPath);
6262- await new Promise((r) => setTimeout(r, 100));
6363- bgPids.push(child.pid!);
6464- return child.pid!;
6565- } catch {}
6666- await new Promise((r) => setTimeout(r, 50));
6767- }
6868- throw new Error("Timeout waiting for daemon");
6969-}
7070-7171-function startSupervisor(sessionDir: string): number {
7272- const child = spawn(nodeBin, [cliPath, "supervisor", "start"], {
7373- detached: true,
7474- stdio: ["ignore", "ignore", "ignore"],
7575- env: { ...process.env, PTY_SESSION_DIR: sessionDir },
7676- });
7777- child.unref();
7878- bgPids.push(child.pid!);
7979- return child.pid!;
8080-}
8181-8282-function waitForFile(filePath: string, timeoutMs = 5000): Promise<void> {
8383- const start = Date.now();
8484- return new Promise((resolve, reject) => {
8585- function check() {
8686- if (Date.now() - start > timeoutMs) {
8787- reject(new Error(`Timeout waiting for ${filePath}`));
8888- return;
8989- }
9090- try {
9191- fs.statSync(filePath);
9292- resolve();
9393- return;
9494- } catch {}
9595- setTimeout(check, 100);
9696- }
9797- check();
9898- });
9999-}
100100-101101-function readMeta(sessionDir: string, name: string): any {
102102- try {
103103- return JSON.parse(fs.readFileSync(path.join(sessionDir, `${name}.json`), "utf-8"));
104104- } catch { return null; }
105105-}
106106-107107-afterEach(() => {
108108- for (const pid of bgPids) { try { process.kill(pid, "SIGTERM"); } catch {} }
109109- bgPids = [];
110110- // Give supervisor time to release lock
111111- const start = Date.now();
112112- while (Date.now() - start < 200) {}
113113- for (const dir of sessionDirs) {
114114- try {
115115- for (const e of fs.readdirSync(dir)) {
116116- try { fs.unlinkSync(path.join(dir, e)); } catch {}
117117- }
118118- } catch {}
119119- }
120120- sessionDirs = [];
121121-});
122122-123123-describe("supervisor", () => {
124124- it("restarts a permanent session that exits", async () => {
125125- const dir = makeSessionDir();
126126- process.env.PTY_SESSION_DIR = dir;
127127-128128- // Start a session that exits after 0.5s
129129- await startDaemon(dir, "restartable", "sh", ["-c", "sleep 0.5"], { strategy: "permanent" });
130130-131131- // Start the supervisor
132132- startSupervisor(dir);
133133- await new Promise((r) => setTimeout(r, 300)); // let supervisor start
134134-135135- // Wait for session to exit + supervisor scan + backoff + restart
136136- // Session exits after 0.5s, supervisor scans every 30s but also uses fs.watch,
137137- // initial backoff is 1s, then spawnDaemon takes a moment
138138- const sockPath = path.join(dir, "restartable.sock");
139139- const start = Date.now();
140140- let restarted = false;
141141- while (Date.now() - start < 10000) {
142142- await new Promise((r) => setTimeout(r, 500));
143143- // Check if a new socket appeared (the old one gets cleaned up by cleanupAll)
144144- const meta = readMeta(dir, "restartable");
145145- // After restart, metadata should have no exitedAt (fresh session)
146146- if (meta && !meta.exitedAt) {
147147- try { fs.statSync(sockPath); restarted = true; break; } catch {}
148148- }
149149- }
150150-151151- expect(restarted).toBe(true);
152152- }, 20000);
153153-154154- it("cleans up temporary sessions on exit", async () => {
155155- const dir = makeSessionDir();
156156- process.env.PTY_SESSION_DIR = dir;
157157-158158- // Start a temporary session that exits immediately
159159- await startDaemon(dir, "tempjob", "true", [], { strategy: "temporary" });
160160-161161- // Start the supervisor
162162- startSupervisor(dir);
163163- await new Promise((r) => setTimeout(r, 300));
164164-165165- // Wait for exit + cleanup
166166- await new Promise((r) => setTimeout(r, 2000));
167167-168168- // Metadata should be gone
169169- const meta = readMeta(dir, "tempjob");
170170- expect(meta).toBeNull();
171171- }, 15000);
172172-173173- it("does not restart sessions without strategy tag", async () => {
174174- const dir = makeSessionDir();
175175- process.env.PTY_SESSION_DIR = dir;
176176-177177- // Start a session without strategy that exits
178178- await startDaemon(dir, "nosupervise", "sh", ["-c", "sleep 0.3"]);
179179-180180- startSupervisor(dir);
181181- await new Promise((r) => setTimeout(r, 300));
182182-183183- // Wait for exit + potential restart window
184184- await new Promise((r) => setTimeout(r, 3000));
185185-186186- // Should NOT have restarted — no socket
187187- const sockPath = path.join(dir, "nosupervise.sock");
188188- let exists = false;
189189- try { fs.statSync(sockPath); exists = true; } catch {}
190190- expect(exists).toBe(false);
191191- }, 15000);
192192-193193- it("pty tag can add strategy to a running session", async () => {
194194- const dir = makeSessionDir();
195195- process.env.PTY_SESSION_DIR = dir;
196196-197197- await startDaemon(dir, "latejoin", "cat");
198198-199199- const result = spawnSync(nodeBin, [cliPath, "tag", "latejoin", "strategy=permanent"], {
200200- env: { ...process.env, PTY_SESSION_DIR: dir },
201201- encoding: "utf-8",
202202- });
203203- expect(result.status).toBe(0);
204204-205205- const meta = readMeta(dir, "latejoin");
206206- expect(meta.tags.strategy).toBe("permanent");
207207- }, 15000);
208208-209209- it("pty supervisor forget removes strategy tag", async () => {
210210- const dir = makeSessionDir();
211211- process.env.PTY_SESSION_DIR = dir;
212212-213213- await startDaemon(dir, "forgetme", "cat", [], { strategy: "permanent" });
214214-215215- const result = spawnSync(nodeBin, [cliPath, "supervisor", "forget", "forgetme"], {
216216- env: { ...process.env, PTY_SESSION_DIR: dir },
217217- encoding: "utf-8",
218218- });
219219- expect(result.status).toBe(0);
220220- expect(result.stdout).toContain("Removed supervision");
221221-222222- const meta = readMeta(dir, "forgetme");
223223- expect(meta.tags?.strategy).toBeUndefined();
224224- }, 15000);
225225-226226- it("pty down stops supervised sessions and removes strategy tag", async () => {
227227- const projDir = fs.mkdtempSync(path.join(testRoot, "proj-"));
228228- const dir = makeSessionDir();
229229- fs.writeFileSync(path.join(projDir, "pty.toml"), `
230230-[sessions.guarded]
231231-command = "cat"
232232-tags = { strategy = "permanent" }
233233-`);
234234-235235- // Start via pty up
236236- spawnSync(nodeBin, [cliPath, "up", projDir], {
237237- env: { ...process.env, PTY_SESSION_DIR: dir },
238238- encoding: "utf-8",
239239- });
240240-241241- // Stop it
242242- const result = spawnSync(nodeBin, [cliPath, "down", projDir], {
243243- env: { ...process.env, PTY_SESSION_DIR: dir },
244244- encoding: "utf-8",
245245- });
246246-247247- expect(result.status).toBe(0);
248248- expect(result.stdout).toContain("stopped");
249249- expect(result.stdout).toContain("removed from supervision");
250250-251251- // Strategy tag should be gone
252252- const meta = readMeta(dir, "guarded");
253253- expect(meta?.tags?.strategy).toBeUndefined();
254254- }, 15000);
255255-256256- it("pty list shows strategy markers", async () => {
257257- const dir = makeSessionDir();
258258- process.env.PTY_SESSION_DIR = dir;
259259-260260- await startDaemon(dir, "marked", "cat", [], { strategy: "permanent" });
261261-262262- const result = spawnSync(nodeBin, [cliPath, "list"], {
263263- env: { ...process.env, PTY_SESSION_DIR: dir },
264264- encoding: "utf-8",
265265- });
266266-267267- expect(result.stdout).toContain("marked");
268268- expect(result.stdout).toContain("[permanent]");
269269- }, 15000);
270270-});
+8-1
tests/tags-helpers.test.ts
···6464 expect(isReservedTagKey("ptyfile")).toBe(true);
6565 expect(isReservedTagKey("ptyfile.session")).toBe(true);
6666 expect(isReservedTagKey("ptyfile.tags")).toBe(true);
6767- expect(isReservedTagKey("supervisor.status")).toBe(true);
6867 expect(isReservedTagKey("strategy")).toBe(true);
6868+ });
6969+7070+ it("does NOT reserve removed/user-facing keys", () => {
7171+ // supervisor.status is gone with the supervisor — must not still be
7272+ // reserved. parent= is a user-facing tag (drives orphan-kill in
7373+ // `pty gc`) so it stays visible in `pty list` by default.
7474+ expect(isReservedTagKey("supervisor.status")).toBe(false);
7575+ expect(isReservedTagKey("parent")).toBe(false);
6976 });
70777178 it("flags any key starting with `:` (tool-owned convention)", () => {
+28-19
tests/wrapper-signal-forwarding.test.ts
···11// Verifies that bin/pty forwards SIGTERM/SIGINT to the inner cli.js child.
22-// Without forwarding, systemd's KillMode=process leaves the inner supervisor
33-// orphaned and still holding supervisor.lock — the new unit invocation then
44-// fails with "another supervisor is already running".
22+// systemd's `KillMode=process` only signals the leader (the bin/pty shell
33+// shim) and lets children become orphans unless the leader propagates the
44+// signal. Without forwarding, the inner cli.js survives a unit `stop` and
55+// the next start fails because the orphan still holds whatever resource
66+// it owned (file watchers, sockets, etc.).
5768import { describe, it, expect, afterEach } from "vitest";
79import * as fs from "node:fs";
···1719const sessionDirs: string[] = [];
1820const trackedPids: number[] = [];
1921afterEach(() => {
2020- // Belt-and-braces: if a test failed mid-way, kill any inner cli.js it left
2121- // behind so the next run isn't poisoned by an orphan holding the lock.
2222 for (const pid of trackedPids) {
2323 try { process.kill(pid, "SIGKILL"); } catch {}
2424 }
···5050}
51515252describe("bin/pty signal forwarding", () => {
5353- it("propagates SIGTERM to the inner cli.js so the supervisor releases its lock", async () => {
5353+ it("propagates SIGTERM to the inner cli.js (events --all is long-lived)", async () => {
5454 const sessionDir = makeSessionDir();
55555656- // `supervisor start` is the canonical long-lived command and the one the
5757- // dev3 regression hit. Its SIGTERM handler calls Supervisor.stop() which
5858- // releases supervisor.lock — so a clean shutdown leaves no lock file.
5959- const wrapper = spawn(nodeBin, [wrapperPath, "supervisor", "start"], {
5656+ // `events --all` is a long-lived command that registers a SIGINT
5757+ // handler and runs an EventFollower until the process is signalled.
5858+ // Same shape as the supervisor used to be: long-running, owns file
5959+ // watchers, must exit cleanly when the wrapper relays a signal.
6060+ const wrapper = spawn(nodeBin, [wrapperPath, "events", "--all"], {
6061 env: { ...process.env, PTY_SESSION_DIR: sessionDir },
6162 stdio: ["ignore", "pipe", "pipe"],
6263 });
···7071 let exitSignal: NodeJS.Signals | null = null;
7172 wrapper.on("exit", (c, s) => { exitCode = c; exitSignal = s; });
72737373- // Wait for the supervisor to fully start (it writes supervisor.pid).
7474- const pidPath = path.join(sessionDir, "supervisor", "supervisor.pid");
7575- const lockPath = path.join(sessionDir, "supervisor.lock");
7676- const ready = await waitFor(() => fs.existsSync(pidPath) && fs.existsSync(lockPath), 5000);
7777- expect(ready, `supervisor never started; stdout=${stdout} stderr=${stderr}`).toBe(true);
7474+ // Wait long enough for the wrapper to fork the inner node cli.js.
7575+ // No specific marker; sleep briefly then look at the process tree.
7676+ await new Promise((r) => setTimeout(r, 800));
7777+7878+ // Find the inner cli.js by walking the wrapper's child processes via
7979+ // /proc on Linux, or via `pgrep -P` everywhere. We use ps -o pid -p
8080+ // <wrapper-pid> first then list children with a tree walk fallback.
8181+ const psResult = (() => {
8282+ try {
8383+ const { execFileSync } = require("node:child_process") as typeof import("node:child_process");
8484+ return execFileSync("pgrep", ["-P", String(wrapper.pid)], { encoding: "utf-8" }).trim();
8585+ } catch {
8686+ return "";
8787+ }
8888+ })();
8989+ expect(psResult, `pgrep failed to find a child of pid ${wrapper.pid}; stdout=${stdout} stderr=${stderr}`).not.toBe("");
78907979- const innerPid = parseInt(fs.readFileSync(pidPath, "utf-8").trim(), 10);
9191+ const innerPid = parseInt(psResult.split("\n")[0]!.trim(), 10);
8092 trackedPids.push(innerPid);
8193 expect(innerPid).toBeGreaterThan(0);
8294 expect(innerPid).not.toBe(wrapper.pid);
···9010291103 const innerDied = await waitFor(() => !isAlive(innerPid), 5000);
92104 expect(innerDied, `inner cli.js (pid ${innerPid}) survived wrapper SIGTERM — signal not forwarded`).toBe(true);
9393-9494- // Clean shutdown should release the lock; a SIGKILLed supervisor would leave it.
9595- expect(fs.existsSync(lockPath), "supervisor.lock not released — child shutdown was not graceful").toBe(false);
96105 }, 20000);
97106});