Part of forgectl — see the command roster.
forgectl resume # pick from recent sessions across every repo, then resume in place
forgectl resume forgectl # filter by repo, name, cwd, or id; one hit resumes it, several list the candidates
forgectl resume --fork # branch a new session off the transcript — the only way into a still-running one
forgectl resume --dry-run forge # resolve and print the cwd + claude argv, exec nothing (never prompts)
forgectl resume ls # list without acting (every subcommand returns; only bare `resume` execs)
forgectl resume ls --json # machine-readable JSON (safe to pipe; counts go to stderr; see `resume ls --help` for the field table)
forgectl resume snapshot # capture what a live session's exit would destroy
forgectl resume snapshot --quiet # same, silent — the form a Stop hook uses
forgectl resume outdated # list live sessions running an older claude than the one installed (read-only)
forgectl resume outdated --json # stable JSON array for scripts; see `resume outdated --help` for the field table
forgectl resume restart --outdated # stop + resume those sessions in their herdr panes once each is idle (--dry-run: plan only)
forgectl resume hooks run # fire the [[resume.on_update]] hooks if claude changed version (--dry-run: show what would fire)
forgectl resume hooks install # install the launchd watcher that runs the hooks when claude updates (macOS)
forgectl resume hooks status # watcher installed/loaded, recorded versions, last hook runs (--json)A terminal restart costs three steps otherwise: find the folder, run claude --resume, then recognize the session in a picker that shows neither repo nor branch. forgectl resume collapses that to one command from a cold terminal — it lists recent sessions across every repo with name, repo, branch, and last activity, and lands you back inside the one you pick, in the right directory, with its task list restored.
Like launch, it execs claude in place (via syscall.Exec) and never returns; the resumed session is interactive, so there is no -p/--print form. From a script or an agent tool call, reach for resume --dry-run (prints the resolved cwd and argv, execs nothing) or resume ls --json. Every subcommand (ls, snapshot, outdated, restart, hooks) returns; only bare resume execs.
Against launch: launch starts or resumes a session in the current directory. resume is the cross-repo one — it finds a session anywhere on the machine and moves you to it.
Claude Code stays the source of truth for identity. Its prompt history and per-project transcripts already carry the session id, cwd, branch, and title indefinitely — so there is no daemon and no poller here, just a reader over artifacts that already exist. Two things genuinely do not survive a session's exit, and resume snapshot is what captures them:
- the
/renamename, which lives only in the live-process registry; - the task bodies, which Claude Code deletes when a session ends.
The snapshot record it writes does also carry recovery metadata — session id, cwd, version, timestamps, and the task-directory association — but that is a cache with a known authority above it, not a second source of truth: everything except the name, the tasks, and the task-directory pairing is re-derivable from Claude Code's own files.
Wire the capture to a Stop hook so every turn refreshes it. It is cheap, idempotent, and always exits 0 — a failed snapshot must never become a failed turn.
Merge this into the hooks object of ~/.claude/settings.json — user scope, since the whole point is cross-repo. Do not replace that file; it holds unrelated settings, and the fragment below is a fragment, not a document:
"Stop": [
{ "hooks": [{ "type": "command", "command": "forgectl resume snapshot --quiet" }] }
]forgectl must be on the hook's PATH — hooks do not inherit an interactive shell's environment, so use an absolute path if yours is not in the system PATH.
Because snapshot always exits 0, a hook that is missing, misspelled, or wired into the wrong settings file looks exactly like one that works — until a session exits and its tasks are gone. forgectl doctor's resume tasks check is the detector: it warns when live sessions exist and the snapshot store is empty.
Snapshots live one JSON file per session in forgectl's config directory — ~/Library/Application Support/forgectl/resume-sessions/ on macOS, ~/.config/forgectl/resume-sessions/ on Linux — alongside every other forgectl store. The store self-prunes at most once a day, retiring a record when Claude Code's transcript for that session is gone — transcript existence, not a fixed age, because a snapshot is worth keeping exactly as long as claude --resume can still open the session, and transcript retention is operator-configurable. A record whose transcript survives is kept however old it is; the 180-day ceiling applies only to records whose transcript is already gone, so that a machine whose transcript lookup is broken still retires dead records eventually. A pass that would retire most of the store refuses and reports why, since that is the signature of a broken lookup rather than of that many dead sessions — and FORGECTL_RESUME_NO_PRUNE (any non-empty value) disables pruning entirely.
Notes:
- An ambiguous filter lists the candidates instead of prompting — with exit 1, the same code as "no session matched", because both recover the same way: change the filter. When more than one session matches and nobody can answer the picker —
--dry-run, or any run without a terminal —resumeprints one candidate row per line on stdout and exits 1 rather than opening a selector no one can see. That list is also the answer to "is this filter unique?", which a caller otherwise has no way to know before running the command: candidates on stdout means ambiguous, empty stdout means no match, exit 0 means it resumed. - A running session is refused, not continued — with exit 2, distinct from the exit 1 used for "no session matched" or an ambiguous filter, because a refusal names something the caller can fix rather than a filter to retry. Two Claude Code processes on one transcript corrupts it, so
resumeerrors out and names the live pid.--forkis the way in anyway: it branches a new session off the transcript, which only reads it. Pre-check withresume ls --json, which exposesliveandpid. - A bad
[proxy] launch_profileis the other exit-2 refusal. When that key names a profile the config does not define, or one that sets no values,resumerefuses before restoring any task — no filter change can fix it, which is why it does not share exit 1. It is resolved ahead of--dry-run's early return too, so the dry run's exit code is the one the real run would give. See proxy. --forkalways forks, including on a session that is not running. It is not a no-op safety flag: a fork starts a new session rather than continuing the old one, and a new session reads its own empty task list, so snapshotted tasks are reported rather than restored (its task directory is named after a session id that does not exist until after the exec). Pass it in response to the live-session error, not defensively.resume outdatedis read-only and compares numerically. It lists live sessions whose registryversionis older than the installedclaude(the~/.local/bin/claudesymlink target when it points into aversionsdirectory, otherwiseclaude --version; the binary is found the waylaunchfinds it), so2.1.100is newer than2.1.99. A registry file whose pid is dead is skipped. A session whose version cannot be parsed is listed and markedversion_unparseablerather than guessed at. Thepane(table columnHERDR_PANE_ID) is the process's ownHERDR_PANE_IDenvironment value, not a verified pane: aclaudelaunched inside another session inherits its parent's id. It is read in-process (thekern.procargs2sysctl on macOS,/proc/<pid>/environon Linux) and is empty when unreadable. Any status other thanidle(busy,waiting,shell, or an unknown value) reads asbusy. Exits 0 whether or not anything is outdated, non-zero only when the installed version cannot be determined; a missing or unreadable session registry lists as empty.--jsonemits[]when nothing is.resume restart --outdatedstops a session only when it is provably safe, and resumes it in the same herdr pane. It acts on the setresume outdatedlists (--session <id>, repeatable, narrows it). It finds each session's pane by session id: oneherdr pane listper run, before planning, and the pane whose herdr label isclaudewith that session id is the one it checks and relaunches into. The process'sHERDR_PANE_IDis only the fallback, used when no pane carries the session or the list fails or is rejected (a run-levelnoteline then says so). That matters after a herdr server restart, which renumbers every pane: a long-running session keeps its old id, and herdr answerspane_not_foundfor it. When the found pane differs from the environment's, the dry-run and progress lines showpane <found> (found by session; env said <old>). Two panes labelled with one session (a nestedclaude's hooks can relabel its parent's pane) is refused withforgectl resume <id>to run by hand. When herdr answerspane_not_foundduring a real run, the list is read once more for that session; if another pane now holds it, the checks continue there, and if not, the session endspane-gone, which counts as incomplete (exit 1, and a watcher restart is retried) because a renumbered pane can come back.--dry-rundoes not re-read. The label alone is never trusted: the pane checks below still run against the found pane. Immediately before the signal it re-checks four things: the pid is still theclaudethat wrote the registry file (same session id andprocStart, aclaudeexecutable, a kernel start time matchingprocStart, so a reused pid is never signalled); the status is exactlyidle;herdr pane getnames the same session and the pid is the pane's foreground process (a nestedclaudeinherits its parent'sHERDR_PANE_ID); and the pane's input line is empty, read off the screen as the❯line between Claude Code's two horizontal rules, becauseidlesays nothing about an unsent draft and stopping loses one. An unrecognized screen counts as a draft. A busy session or a draft waits and is re-checked every few seconds up to--timeout(default30m), and so does a herdr error other thanpane_not_found; a failed identity or pane check is reported and never signalled; a session with no pane, an unparseable version, or one this run is inside (its pid is an ancestor of this process, as when an agent's shell tool runs the command) is reported withforgectl resume <id>to run by hand. Before the stop it runs the same capture asresume snapshotand renders the relaunch command, so nothing that could fail the relaunch is left for after the signal. The stop isSIGTERM; the relaunch waits for the pid to exit, its registry file to go, and the same shell that owned the pane before the stop to hold the foreground. It refuses if the session is already running again, sends Ctrl-U to clear anything typed at the shell prompt in the gap, re-checks the foreground, then typesforgectl resume <id>(this binary's absolute path; theforgectlonPATHundergo run) into the pane and confirms the session registers again. Nothing is relaunched unless the old process is gone. Keystrokes landing in the few milliseconds between the last screen read and the signal are lost with the process. A run holds an exclusive lock (restart.lockbeside forgectl's snapshot store), so a concurrent run exits at once. Ctrl-C,SIGTERMandSIGHUPstop the waiting but never a restart already signalled, andSIGPIPEis ignored for the run. Each herdr call runs in a process group of its own, so neither a closed terminal's hangup nor Ctrl-C reaches a call in flight; only the progress lines are lost with the terminal. Each call is bounded at 10 seconds instead, and one that runs past it is killed, so a wedged herdr after the stop is reported as a failure with the command to resume it by hand. The exception is the relaunch itself: apane runkilled at the bound may already have typed the line, so the run still waits for the session to register, and reports it resumed if it does or reports delivery as unknown (check the pane before resuming by hand) if it does not.--dry-runprints each session's action and what the checks say now, using reads only. Exits 0 when every session was resumed or skipped with a reason, 1 when a stop or relaunch failed, the timeout or Ctrl-C left sessions waiting, or a session endedpane-gone, 2 on bad usage.- The task store never shrinks to follow Claude Code. Snapshots merge by task id and retain what they have already captured, so a later pass seeing fewer tasks never discards the earlier ones — dropping to the live set would throw away exactly what the feature exists to rescue. This is a property of repeated snapshots taken while the session is alive:
snapshotwalks the live-process registry, so it cannot discover a session that has already exited. Without a prior snapshot, running it after a crash recovers nothing — which is why theStophook, not manual invocation, is the intended wiring. - Restore never overwrites. A task file the live session owns always wins, and
.highwatermarkis raised but never lowered, so a resumed session is never handed an id already on disk. Running it repeatedly is a no-op. - The resumed session gets its own project's posture.
[launch]profile resolution is a pure function of the config and a directory, so resuming into another repo picks up that repo's model, effort, permission mode, and--add-dirset for free. With stdout not a terminal, the resume withholds--allow-dangerously-skip-permissionsand keeps the rest, aslaunchdoes, so--dry-runpiped into another command shows the argv without that flag. forgectl doctorcarries aresume taskscheck. Task rescue depends on Claude Code naming per-session task directories after the session id — verified behavior, not a guarantee — and the check warns if that ever stops being true, so rescue cannot silently degrade to writing where nothing reads.
resume hooks runs the [[resume.on_update]] entries in config.toml when the installed claude changes version, so an update can restart outdated sessions (or run any command) with nothing typed. forgectl init adds a commented example.
[[resume.on_update]]
harness = "claude"
action = "restart" # built-in: the same run as `resume restart --outdated`
[[resume.on_update]]
harness = "claude"
command = ["/usr/bin/say", "claude updated"] # an argv array, run with no shell
timeout_seconds = 60 # optional; default 300 (restart: 1800)Each entry names a harness and exactly one of action = "restart" or command. Only harness = "claude" is supported; codex and pi are refused with "not supported yet". An unknown key (a misspelled comand, say) is a config error naming the key, and so is a top-level [[on_update]] or [on_update] table (the spelling in the original design), which names [[resume.on_update]] instead of being silently ignored. launch doctor's config check reports both. The timeout key is timeout_seconds, in the same style as [net] ttl_seconds. For a command it bounds the whole run: at the deadline the hook's process group is killed, helpers it forked included. For the restart action it bounds only the waiting for sessions to go idle; a session already stopped is still relaunched and confirmed after it passes.
Detection. resume hooks run reads the installed version the way resume outdated does and compares it with the state it recorded (resume-hooks/state.json in forgectl's config directory). The first run records a baseline and fires nothing — an install is not an update. A change fires only once the version has settled: two reads a few seconds apart must agree (up to five re-reads), so a symlink that moves twice, or reverts, during one update fires nothing for the transient value. After acting, a run reads the version again, and an update that landed while its hooks ran (a restart can wait up to 30 minutes) is handled in the same run, up to three passes. Runs take a lock and wait for each other; a manual run behind the watcher's prints that it is waiting. A restart also takes resume restart's own lock, so a manual restart and the watcher's never act on the same session.
When the version is recorded. Only after every hook for it has run. A run stopped by SIGTERM (launchd sends it when the agent is unloaded, including by a re-install), SIGINT, or SIGHUP ends the restart's waiting, starts no further hook, records nothing, and exits 1, so the next run fires the whole set again. A run killed outright (SIGKILL, power loss) also records nothing. So a hook can run twice for one update; write hooks that are safe to repeat (restart is: a session already restarted is no longer outdated).
Retrying an incomplete restart. A restart hook that ends incomplete (a session still busy at the timeout, a failed relaunch, a session whose herdr pane could not be found, another restart holding the lock) is recorded as pending for that version. Later runs while the version is unchanged — the next watch event, the next login, or the watcher's 30-minute interval — re-run only that restart, up to 3 attempts in all; then it gives up until the next version, and status and launch doctor say so. Command hooks fire once per version and are never retried.
Command hooks receive FORGECTL_HARNESS, FORGECTL_OLD_VERSION, and FORGECTL_NEW_VERSION in their environment. They are never put in the argv, which runs exactly as written, with no shell. Hooks run in config order, and one failing (or timing out) does not stop the next.
Audit trail. Each hook run appends one JSON line to resume-hooks/runs.jsonl: time (UTC), harness, old and new version, which hook (its position and kind, and for a command only the program's base name — never its arguments, which can carry a token or a webhook URL), outcome (ok, failed, timeout, or incomplete for a restart that left sessions failed, waiting, or with no herdr pane), exit status, duration, and trigger (launchd for the watcher, manual otherwise). Installing and removing the watcher append a line too. Of a command's output, only a failed command's stderr tail is kept: 240 characters, with the hook's own argument values scrubbed, lines that could hold a URL credential or auth header withheld, and control characters escaped. That redaction works line by line, so a bare token a failing program prints on stderr can still survive in the tail and in the watcher log; both files are owner-only (0600 in a 0700 directory). Stdout and a successful command's output are never stored. The file rotates to runs.jsonl.1 at 1 MiB. resume hooks status shows the last five records and any pending restart (--json for scripts).
The watcher is a launchd agent, local.forgectl.resume-hooks, which resume hooks install writes to ~/Library/LaunchAgents/ and loads (macOS only; elsewhere, run resume hooks run from your own scheduler). launchd runs forgectl resume hooks run when the claude binary's path or its directory changes, once at load, and every 30 minutes (StartInterval). The load-time run records the baseline right away and catches an update that landed while the agent was not loaded; the interval retries a pending restart and covers a missed watch event, and costs a symlink read when there is nothing to do. It watches the directory as well as the link because launchd watches a path through the file it opens, and opening a symlink opens its target, so an update that replaces the link may never touch it. Install warns loudly when the claude it would watch is not a symlink into a versions directory (a wrapper script, say): an update never touches such a file, so only the load-time and interval runs would notice.
launchd starts the job with no shell environment and a minimal PATH, so install bakes absolute paths into the plist:
- this
forgectlas the program, refusing ago runbuild (it is deleted when it exits); FORGECTL_CLAUDE_BINset to the claude it watches, so the watcher reads the version of the same file whatever else is onPATH;FORGECTL_HERDR_BINset to the absoluteherdr, which the restart action runs instead of lookingherdrup;PATHset to the directories ofclaude,herdr, andforgectlahead of/usr/bin:/bin:/usr/sbin:/sbin, for a command hook's bare program name (prefer absolute paths incommand);HERDR_SOCKET_PATHcopied when set, so the restart talks to the same herdr server as the terminal install ran in;- the working directory and the log (
resume-hooks/watcher.log, moved towatcher.log.1once it passes 1 MiB) under forgectl's config directory.
Install warns when another user could change any baked binary or directory (it is group- or other-writable, as Homebrew's admin-group /opt/homebrew/bin is, or someone else owns it), since the watcher runs what is there unattended. Re-run install after moving any of those binaries or changing herdr servers. install and uninstall are safe to repeat; install --dry-run prints the plist without writing or loading it. When the job is loaded, install unloads it before writing the new plist, so a failed unload leaves the old file and the next install tries again rather than reporting "already installed"; a refused load right after the unload is retried a few times.
forgectl launch doctor carries an update_hooks row. It warns when hooks are configured and the watcher is not installed or loaded; when the newest audit record for the recorded version did not end ok; when a restart is pending or gave up; and when launchd says the job is running and its run has held the hooks lock longer than the longest hook timeout plus 10 minutes (stuck on a permission prompt or a hung call). It does not rely on launchd's last exit code, which the next run with nothing to do resets to 0.
A restart run by the watcher has no terminal: its progress lines go to the log and its outcome to the audit trail. Every safety check of resume restart --outdated still applies — a busy session waits up to the hook's timeout, a session the run is inside is never stopped — and a restart that left sessions failed or waiting records incomplete and exits 1. A session the watcher could not restart (a stop or relaunch that failed, which can leave it stopped) also posts a macOS notification naming the command that resumes it, since nobody is reading the log when it happens.