Skip to content

Add pty run --permanent (and --cleanup-on-clean-exit) so spawners stop reimplementing the supervisor contract #40

Description

@schickling-assistant

Problem

Today, every consumer that wants a pty session to survive a host/supervisor restart re-derives the same contract by hand:

  1. Pass --tag strategy=permanent at spawn time, knowing the supervisor reads that tag (dist/supervisor.jsevaluateSession / loadState).
  2. 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=permanentstrategy=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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions