Problem
Today, every consumer that wants a pty session to survive a host/supervisor restart re-derives the same contract by hand:
- Pass
--tag strategy=permanent at spawn time, knowing the supervisor reads that tag (dist/supervisor.js — evaluateSession / loadState).
- After the leaf process exits, run a post-spawn cleanup that conditionally calls
pty rm <name> if the exit was clean — otherwise the supervisor's 1s-backoff restart loop fires (MAX_RESTARTS=5 in RESTART_WINDOW_MS=60_000) and the session lands at FAILED with stale metadata.
Both halves are load-bearing, but neither is discoverable from pty run --help. Spawners learn the contract by reading supervisor source, and each consumer ends up reimplementing the same shell snippet:
pty run -a --name "$name" --tag strategy=permanent -- <leaf>
rc=$?
if ! pty list --json | jq -e --arg n "$name" '.[] | select(.name==$n and .status=="running")' >/dev/null; then
pty rm "$name" || true
fi
exit "$rc"
Concrete reimplementations I'm aware of:
- Shell wrappers (
cl1/co1/oc/pi style agent launchers in schickling/dotfiles) — the snippet above, generated in nix.
- A web UI / pty manager that spawns sessions via the in-process client — needs the same
strategy=permanent tag plus a post-exit pty.rm() in its session-exit watcher.
Each spawner racing the supervisor on cleanup is a known footgun: if the supervisor's fs-watcher fires scheduleRestart before the wrapper checks pty list --json, one spurious respawn happens, then the wrapper removes the metadata anyway. Bounded, but messy.
Proposed flags
Two small additions to pty run:
--permanent
Equivalent to --tag strategy=permanent today, but as a first-class flag so consumers stop string-matching the tag in their own code (forge has to read the tag back; wrappers emit it as bash). Promoting it to a flag lets pty own the contract — including the name. Future-proof: if the tag schema ever changes (strategy=permanent → strategy=managed), only pty changes.
--cleanup-on-clean-exit
When the leaf process exits with code 0, the supervisor itself removes the session's metadata (no client-side race). When the leaf exits non-zero, leaves the metadata alone — preserving the existing crash-→-restart-loop behavior.
Both flags can be used independently. The common case is both together: "I want this to come back on reboot, AND I want it to truly disappear when the user explicitly quits."
Why supervisor-side
Today the cleanup races the supervisor's scheduleRestart (1s backoff). Moving it into the supervisor itself — running atomically with the exit-detection path — removes the race window entirely.
It also lets pty centralize the "clean exit vs crash" distinction. Right now supervisor doesn't distinguish exit codes (supervisor.js:163-165); the semantic only lives in client code that races to remove metadata before backoff fires.
Implementation sketch
- New tags written alongside
strategy=permanent: strategy.cleanup-on-clean-exit=true (or fold into one tag value: strategy=permanent-ephemeral-on-clean-exit).
- Supervisor's
evaluateSession reads the new tag/value and, in the isExited branch, checks the recorded exit code:
- exit 0 + cleanup-on-clean-exit →
cleanupAll (skip scheduleRestart).
- non-zero → existing
scheduleRestart path.
pty run --permanent and pty run --cleanup-on-clean-exit set the corresponding tags. Internal Daemon already records exitedAt and the exit code via session_exit events, so no new wire data is needed.
Backwards compatibility
- Existing
--tag strategy=permanent users keep working (the flag is just sugar).
- Existing session metadata without the new tag retains today's "always restart on exit" semantic.
- No projection-layer change.
Net effect
Three independent reimplementations of "spawn permanent + clean up on clean exit" collapse to pty run --permanent --cleanup-on-clean-exit -- <leaf>. Supervisor owns the lifecycle instead of every spawner racing it.
Happy to send a PR if the design lands.
Posted on behalf of @schickling
| field |
value |
agent_name |
🌈 cl2-opal |
agent_session_id |
7df4e531-dcfd-4417-808c-06e7c504fe46 |
agent_tool |
Claude Code |
agent_tool_version |
2.1.139 |
agent_runtime |
Claude Code 2.1.139 |
agent_model |
claude-opus-4-7 |
worktree |
dotfiles/schickling-assistant/2026-05-22-ai-bundle-followup |
machine |
dev3 |
tooling_profile |
dotfiles@unknown-dirty |
Problem
Today, every consumer that wants a pty session to survive a host/supervisor restart re-derives the same contract by hand:
--tag strategy=permanentat spawn time, knowing the supervisor reads that tag (dist/supervisor.js—evaluateSession/loadState).pty rm <name>if the exit was clean — otherwise the supervisor's 1s-backoff restart loop fires (MAX_RESTARTS=5inRESTART_WINDOW_MS=60_000) and the session lands atFAILEDwith stale metadata.Both halves are load-bearing, but neither is discoverable from
pty run --help. Spawners learn the contract by reading supervisor source, and each consumer ends up reimplementing the same shell snippet:Concrete reimplementations I'm aware of:
cl1/co1/oc/pistyle agent launchers in schickling/dotfiles) — the snippet above, generated in nix.strategy=permanenttag plus a post-exitpty.rm()in its session-exit watcher.Each spawner racing the supervisor on cleanup is a known footgun: if the supervisor's fs-watcher fires
scheduleRestartbefore the wrapper checkspty list --json, one spurious respawn happens, then the wrapper removes the metadata anyway. Bounded, but messy.Proposed flags
Two small additions to
pty run:--permanentEquivalent to
--tag strategy=permanenttoday, but as a first-class flag so consumers stop string-matching the tag in their own code (forge has to read the tag back; wrappers emit it as bash). Promoting it to a flag lets pty own the contract — including the name. Future-proof: if the tag schema ever changes (strategy=permanent→strategy=managed), only pty changes.--cleanup-on-clean-exitWhen the leaf process exits with code 0, the supervisor itself removes the session's metadata (no client-side race). When the leaf exits non-zero, leaves the metadata alone — preserving the existing crash-→-restart-loop behavior.
Both flags can be used independently. The common case is both together: "I want this to come back on reboot, AND I want it to truly disappear when the user explicitly quits."
Why supervisor-side
Today the cleanup races the supervisor's
scheduleRestart(1s backoff). Moving it into the supervisor itself — running atomically with the exit-detection path — removes the race window entirely.It also lets pty centralize the "clean exit vs crash" distinction. Right now supervisor doesn't distinguish exit codes (
supervisor.js:163-165); the semantic only lives in client code that races to remove metadata before backoff fires.Implementation sketch
strategy=permanent:strategy.cleanup-on-clean-exit=true(or fold into one tag value:strategy=permanent-ephemeral-on-clean-exit).evaluateSessionreads the new tag/value and, in theisExitedbranch, checks the recorded exit code:cleanupAll(skipscheduleRestart).scheduleRestartpath.pty run --permanentandpty run --cleanup-on-clean-exitset the corresponding tags. Internal Daemon already recordsexitedAtand the exit code viasession_exitevents, so no new wire data is needed.Backwards compatibility
--tag strategy=permanentusers keep working (the flag is just sugar).Net effect
Three independent reimplementations of "spawn permanent + clean up on clean exit" collapse to
pty run --permanent --cleanup-on-clean-exit -- <leaf>. Supervisor owns the lifecycle instead of every spawner racing it.Happy to send a PR if the design lands.
Posted on behalf of @schickling
agent_nameagent_session_idagent_toolagent_tool_versionagent_runtimeagent_modelworktreemachinetooling_profile