respawn-sessions
Respawn Bryan's long-running Claude Code sessions in detached tmux sessions. Four modes — `missing` (default, fill gaps), `plugin` (kill+respawn anyone lacking the fleet plugin), `all` (kill+respawn e
Files3
View on GitHubSKILL.md.claude/skills/respawn-sessions/SKILL.md
GitHubRespawn Sessions
Re-open Bryan's long-running Claude Code sessions as detached tmux sessions. One command for any of these scenarios:
- After a Mac reboot — fill gaps in the running set.
- After a plugin/marketplace update — push every session onto the new plugin set.
- "Respawn everything" — full fleet reset.
Attach to any session with tmux a -t <session> when you want a pane visible.
Modes
| Mode | What it does |
|---|---|
missing (default) |
For each respawn: true project: spawn a fresh session ONLY if no claude is currently running with that project's path as cwd. Skips everything that's already up. Safe. |
plugin |
Kill+respawn any running session whose argv includes neither install key of the canonical fleet plugin (plugin:claude-workspaces@claude-workspaces, or the pre-rename plugin:live-feedback@claude-live-feedback). Use after enabling/upgrading a plugin globally. Team Lead (self) is never killed; if it lacks the plugin, the script flags it for manual restart. |
all |
Kill+respawn every respawn: true session. Use as a full fleet reset. Team Lead (self) is never killed. |
running |
Kill+respawn every session that is actually running, at its own cwd, ignoring the registry. This is the account-switch mode — use it after Bryan runs /login. Team Lead (self) is never killed. |
After a /login, the mode is running — not all
--mode all respawns what the registry says should be up, at the registry's canonical path. After an account switch you need the opposite: every session that is actually up has to cycle, or it keeps the old account's MCP handshakes. all gets this wrong in two specific ways, and both bit on 2026-09-01:
- It skips
respawn: falsesessions. Workspaces is markedrespawn: falseand was 51% of that day's burn — the single most important session to move, silently excluded. - It relocates a worktree session to the registry path. The ClientOrg Project Beta peer came back at
~/dev/project-alphaon an unrelated branch, resuming a different conversation, and looked completely healthy doing it.
The failure mode to watch for in yourself is patching those two symptoms by hand — reaching for a raw tmux new-session for the session the mode skipped, and another for the one it misplaced. That is how both peers came up with no CW_AGENT_NAME and mute on the board. One --mode running --execute does the whole job.
Every mode takes --only <substr>, repeatable. It restricts the mode to targets whose display name or path matches, case-insensitively — so a subset respawn is --mode missing --only workspaces --only <second-project> --execute, not a reason to write your own loop. A mode is not all-or-nothing, and on 2026-08-30 assuming it was cost the fleet two extra restarts. Full flag list: respawn.py --help.
Per-peer DISCORD_STATE_DIR scoping (automatic)
Each spawned tmux session gets DISCORD_STATE_DIR set explicitly via tmux new-session -e DISCORD_STATE_DIR=…, so the peer doesn't inherit the team-lead's discord state. Two destinations:
- Peer has its own
.claude/discord/access.json(e.g., octoturtle with a family-bot) → that peer's local state dir is used. - Peer doesn't → a shared
~/.config/team-lead/no-discord/state is used. The dir contains an empty allowlist and no.env, so the discord plugin loads but doesn't connect (no token) and the peer isn't subscribed to any channel.
Why this matters: without the per-session override, every peer inherits the team-lead's DISCORD_STATE_DIR from the team-lead's shell env (set by direnv). All peers then share the team-lead's bot + access.json + channel subscriptions. A single Discord post to the team-lead's channel fans out to every peer. The -e flag is critical — new-session ignores client env if the tmux server is already running, so just exporting the var in the parent shell doesn't help.
Encoded in respawn.py's discord_state_dir_for(path) + spawn_session_tmux.
Every session needs a name, and a nameless one fails silently
CW_AGENT_NAME — and FEEDBACK_AGENT_NAME, which carries the same value for the pre-rename spelling — is what gives a session an identity on a claude-workspaces board. It comes from session_name in registry.yaml, and spawn_session_tmux passes both via tmux new-session -e.
Without it, a session reads the board perfectly and writes nothing. It connects, it attaches, it receives every comment event as it arrives. Every write — a comment, a status note, a filed task — is refused with author-required, because an unnamed session collapses into the shared "agent" category and a category is not an author.
That asymmetry is the whole hazard. There is no crash, no red banner, no missing tool. From the inside the session looks healthy and stays busy; from the outside it is a peer that hears everything the user says and answers none of it. On 2026-08-30 all eight tmux peers ran that way, and it surfaced only because the user asked one of them directly whether it was still connected.
Check it before trusting a cycled fleet
for s in $(tmux ls -F '#{session_name}'); do
printf '%-22s ' "$s"; tmux show-environment -t "$s" CW_AGENT_NAME 2>&1 | head -1
done
Every line should print CW_AGENT_NAME=<the registry session_name>. A line reading unknown variable is a mute peer. It cannot be repaired in place — the value is read at startup, so the fix is always a relaunch: --mode missing --only <name> --execute.
Ask a suspected session to post something, not to read something. Reading was never the broken half, so "I can see the board" is not evidence either way.
Hand-rolling a spawn drops it
This is how it happened. An account cycle was run with a hand-written tmux new-session loop instead of this script, because of a wrong belief that the script could not do a subset. The loop carried DISCORD_STATE_DIR across and dropped both name variables — a third spawn path with none of the script's accumulated knowledge in it. All eight sessions came up mute.
Use respawn.py for any spawn, including a single session and including an account switch. A spawn line has more load-bearing detail than it looks: the two name variables, DISCORD_STATE_DIR scoped per peer, --continue only where a transcript exists and never where fresh_start: true, and home-path resolution so a worktree session resumes its own transcript. A loop written from memory reproduces whatever the docs discuss at length and silently drops whatever lives in a docstring — which is not the same ranking as what matters.
If you must hand-roll one anyway, copy the -e flags out of spawn_session_tmux verbatim rather than writing them from memory, then run the check above before calling the cycle done.
There are TWO spawn paths, and only one of them gets updated
respawn.py spawns every peer. self-respawn.sh spawns the team-lead, because the team-lead runs as a
bare claude --continue and cannot be killed by the modes above. They carry their launch env
independently, and a variable added to one does not appear in the other.
Measured 2026-09-17. claude-workspaces 0.1.182 moved the end-of-turn note route under the board and made
the Stop hook read that board from the launch env — readWorkspaceId consults CW_WORKSPACE_ID then
FEEDBACK_WORKSPACE_ID and has no third fallback. respawn.py was updated and passes it for every peer.
self-respawn.sh was not, so the team-lead came back carrying CW_AGENT_NAME and no board, and its hook
exited 0 posting nothing on every turn for weeks. One record in 24 hours across a night of continuous work.
- The failure is silent on both sides. The hook exits 0 by design so it can never block a turn, and a session posting nothing looks exactly like a session having quiet turns. Nothing distinguishes them from outside; reading the session's own env is the only check that answers it.
- The session it misses is the one coordinating the others. A structural fix that covers everything spawned one way cannot reach the thing spawned the other way, and here that is the team-lead.
- When you add anything to
spawn_session_tmux, add it toself-respawn.shin the same change. Read the value fromregistry.yamlrather than hardcoding it, so the two paths cannot drift to different answers.TL_WORKSPACEis the worked example. - Verify from inside a real session, not from the script.
printenv CW_WORKSPACE_IDin the running team-lead is the check; the script reading correctly proves only what the next spawn will do.
Expect this to recur. The trend is toward more per-session configuration, and each new variable breaks in exactly this place — the path nobody remembered was a second path.
Misplaced session home
A session's home is the directory whose transcript --continue resumes. Normally
that is the registry path. When a peer is started in a git worktree, its transcript
lives under the worktree's encoding instead, and the two disagree.
[misplaced] in the dry-run output means exactly that: the session is alive somewhere
other than its registry path, and the script is respawning it where it is, because
that is the only cwd whose --continue restores the work it is holding. Respawning at
the registry path instead would resume a different, older conversation and the peer
would come back looking healthy while holding none of its context.
- This is correct behaviour, not an error. Do not "fix" it by pointing the respawn at the registry path.
- It reproduces on every respawn until the transcript is migrated from the worktree encoding to the registry-path encoding. Until then the peer's home is the worktree.
- A worktree is temporary; the registry path is the agent's home. Migrate when the branch work is finished, then the warning stops.
--at <path> is the deliberate form of the same thing — see below.
Reviving a session at an explicit path: --at
python3 respawn.py --at ~/dev/some-project/worktrees/feature-x --execute
Repeatable. Takes an explicit cwd, spawns there whether or not anything is currently running at that path, and whether or not the registry knows the path at all. Anything alive there is cycled first.
The gap it fills: --mode running can only cycle a session that is alive, and
--mode missing spawns at the registry path. So when a worktree session dies —
a reboot, a crash — neither mode brings it back where it lived, and missing mode
quietly resumes the wrong conversation. Added 2026-09-04 after a reboot killed a
worktree session mid-benchmark and the only way back looked like a hand-rolled tmux
spawn, which is the thing this script exists to prevent.
It goes through the full spawn path, so the identity env, the scoped Discord state dir, dialog dismissal and the orphan-MCP sweep all still apply.
Spawning on a second account: --account-dir
python3 respawn.py --mode running --only <name> --account-dir <folder> --execute
Sets CLAUDE_CONFIG_DIR=<folder> for each spawn, so the session runs on the
subscription account logged in to that folder. Combine it with any mode, --only or
--at.
- Build the folder with
scripts/account_dir.py <folder> --execute. It links the shared parts of~/.claude(settings, plugins, transcripts and memory, skills) into the folder and seeds the folder's own.claude.jsonwithout the main login's account record. - Someone runs
/loginin the folder once, asCLAUDE_CONFIG_DIR=<folder> claude. The script refuses a folder with no login recorded, since a session spawned there would sit at a login prompt. - Spell the folder the same way every time. The Keychain entry holding the login
is keyed to the exact path string. The script canonicalizes to the realpath, and
account_dir.pyprints the command to log in with that spelling. - A spawn without the flag uses the default login. So
--mode runningwithout--account-dirmoves a session on a spare account back to the main one.
What --execute actually does
Beyond spawning the tmux sessions, when something gets spawned the script also:
- Polls each new tmux pane for known startup dialogs and sends Enter to dismiss them — covers the dev-channel approval dialog (
--dangerously-load-development-channels), the MCP-server approval dialog, and the resume-from-summary auto-compact dialog. Polls every 3s for up to ~75s; multiple Enters per pane are sent if dialogs chain. - Sweeps orphan
claude-hive-mcp/server.tsprocesses (re-parented to PID 1 / launchd) left behind by killed claudes. Without this, new claude sessions can't register with claude-hive cleanly — the hive registry would still show the old PIDs.
Pass --no-auto-accept to skip both post-spawn steps and walk through the panes by hand.
Safety: dry-run by default
The script is dry-run by default. A bare invocation prints what it would do (which projects are already running, which would be killed, which would be spawned) and exits without touching anything. To actually execute you must pass --execute.
# Safe preview:
python3 respawn.py
python3 respawn.py --mode plugin
python3 respawn.py --mode all
# Actually do it:
python3 respawn.py --execute # missing (default)
python3 respawn.py --mode plugin --execute # upgrade plugin-less sessions
python3 respawn.py --mode all --execute # full fleet reset
# Skip post-spawn dialog acceptance + cleanup (rare — usually you want it):
python3 respawn.py --execute --no-auto-accept
# Help:
python3 respawn.py --help
How to invoke
Manually (from a team-lead session)
python3 /Users/bryanchan/dev/ai-team-lead/.claude/skills/respawn-sessions/respawn.py --mode all --execute
Via the skill
If you have this skill loaded, say "respawn my sessions" or "respawn all agents." Team Lead will pick the right mode based on intent — all for "respawn all," plugin for "the plugin isn't loaded everywhere," missing for default fill-gap.
Attaching to a session
tmux a -t <session-name> # e.g. tmux a -t my-project
tmux ls # list all live tmux sessions
Session names are derived from the registry's session_name field, lowercased with spaces replaced by hyphens (e.g., "My Project" → my-project).
Team Lead self-protection
The script walks the parent process chain to identify the team-lead's own claude PID and never kills it, in any mode. If --mode plugin finds the team-lead lacks the plugin, it prints a flag — but you don't need a human to restart the team-lead: use self-respawn.sh (next section) to cycle your own session autonomously.
If the parent-walk fails to find a claude PID (get_self_pid() returns None), --mode all and --mode plugin abort with a non-zero exit code rather than risk killing the team-lead.
Respawning yourself (the team-lead)
Because the modes above never kill self, the team-lead can't ride an --mode all/plugin respawn onto a new binary, plugin, or ~/.claude/settings.json change. Use self-respawn.sh — it cycles your own session autonomously, no human step:
# Discover your own claude PID first: the top-level …/bin/claude ancestor
# (NOT the hive-mcp child), via the parent-process walk — same logic as
# respawn.py's get_self_pid. Then:
bash .claude/skills/respawn-sessions/self-respawn.sh <team-lead-claude-pid>
How it works: the team-lead runs as a bare claude --continue process and can't revive itself directly (a dying process can't respawn itself, and a detached spawn needs a pty). The script schedules a detached respawn via setsid — so it survives the team-lead's own death — then kills self. The detached job waits for the old process to exit, then spawns a fresh team-lead into tmux session team-lead on the current binary + plugin set, resuming this very conversation via --continue, and auto-accepts the startup dialogs (dev-channel / MCP / resume-from-summary → this is what compacts you).
- ~10–20s gap with no team-lead, then back in
tmux:team-lead, same conversation (compacted), named "Team Lead". Reconnect viatmux a -t team-leador Remote Control — it re-registers on the hive. - Do this LAST — it ends the current session. Deliver any report and finish any in-flight handoff first.
- No
-e DISCORD_STATE_DIR— the team-lead keeps its own shell/direnv discord state (unlike peers, which get an explicit override to prevent channel fan-out). - Tell a live human first if one's mid-conversation — the
--continuepreserves context, but the ~15s gap + reconnect is visible. For a fully-autonomous/off-hours run, just fire it. - Use it to pick up: a new Claude Code binary (
/self-updateStep 6), the fleet plugin, or any user-scope settings change that only applies on restart.
Hard-won invariants — do not "simplify" these away
Learned the expensive way (2026-07-11): the first version used setsid, which does not exist on macOS. The detach failed with setsid: command not found, but the script fell through and killed self anyway — the team-lead went down for ~16 hours with no revival path, until a human noticed and a peer hand-restarted it.
- Detach via
python3 -c 'os.setsid()', neversetsid(1). macOS has nosetsidbinary.nohup … & disownalone is not sufficient — it only ignores SIGHUP, it does not escape the dying process's group. - Never kill self until the detached job proves it armed. The job's first act is to
toucha sentinel; the script blocks on that sentinel and ABORTs (leaving the team-lead alive) if it never appears. A failed detach must cost nothing.
If you see ABORT: respawn job never armed, that is the guard working — the team-lead is still up and nothing was lost. Read /tmp/tl-selfrespawn.log and fix the cause; do not bypass the guard.
The general rule: any script that kills its own caller must prove the revival path is live before pulling the trigger — and you must test it against a dummy victim (with side-effects stubbed) before ever aiming it at yourself.
How to add or remove a project from the respawn list
Edit registry.yaml and add (or remove) two fields on the project's entry:
projects:
example-project:
type: personal
path: ~/dev/example-project
repo: fryanpan/example-project
respawn: true # ← include in respawn
session_name: "Example" # ← tmux session name (optional; defaults to humanized key)
docs:
...
respawn: true is required. session_name is optional — if missing, the script uses a Title-Cased version of the registry key. repo is optional: omit it for a local-only folder (no git repo — e.g. a synced Google Drive subfolder). respawn.py never runs git regardless, so a local-only project respawns exactly like a repo-backed one; only path + respawn: true are needed.
No need to redeploy the script — the next respawn pass picks up the new registry contents automatically.
How it parses registry.yaml
Minimal regex parser (~30 lines), no PyYAML dependency. Handles the registry's specific structure (2-space project keys, 4-space scalar fields). Ignores nested blocks like docs:, linear:, notion:.
Why zsh -ic (and not bare claude)
Bryan's claude is a zsh function (defined in ~/.zshrc), not an alias. The function injects the canonical channel + dev-plugin flags before forwarding to /Users/bryanchan/.local/bin/claude. Bare claude from a non-interactive shell skips the function entirely.
The script invokes tmux new-session -d -s <name> -c <path> /bin/zsh -ic "claude --continue" so the interactive zsh sources ~/.zshrc and the function applies. Don't try to inline the channel flags in claude_invocation — the function will append its own copy and you'll see duplicates in argv.
Bootstrapping requirements
For each project to actually have a session to resume:
- The project's
pathmust exist as a directory. - The project must have at least one prior Claude Code conversation in that directory (so
claude --continuefinds something to resume). For brand-new projects, the script falls back to plainclaude(perfeedback_first_time_spawn.md).
The script logs [skip] for any registry entry whose path doesn't exist on disk and continues.
Failure modes and how to debug
claudecommand not found in pane: the inlinezsh -icshould source~/.zshrc. If yourclaudeshell function isn't there, or~/.local/binisn't onPATH, fix the rc file. Verify withtmux capture-pane -t <session> -pafter spawn.- Dialog wasn't dismissed: the auto-accept polls the pane via
tmux capture-pane -pand sendsEnterwhen it sees one ofDIALOG_PATTERNS(Resume from summary,I am using this for local development,Use this and all future MCP servers,Enter to confirm). If a new dialog wording doesn't match, add it to the pattern list. As a workaround:tmux send-keys -t <session> Entermanually. - Wrong session resumed:
claude --continueresumes the most recent conversation in the cwd. If you accidentally started a fresh session in that cwd at any point, it's now the "most recent." Fix: attach withtmux a -t <name>, run/resumein-session, pick the right one, exit, and the next respawn will grab it. - tmux session name collision:
spawn_session_tmuxcallstmux kill-session -t <name>before creating, so the spawn is idempotent. If you see "session not found" warnings on first run, that's normal — the kill is best-effort.
Common scenarios
"I just enabled a new plugin globally and want every agent to pick it up"
python3 respawn.py --mode plugin --execute
If team-lead itself lacks the plugin, the script flags it. Restart team-lead with /exit then claude --continue.
"I rebooted my Mac and want my fleet back"
python3 respawn.py --execute
(missing mode — only spawns gaps, doesn't disturb anything that's already up.)
"Something's weird — full reset"
python3 respawn.py --mode all --execute
Kills + respawns everything. Team Lead is preserved; restart manually if needed.