Unattended rounds
A round can run for hours with no one at the keyboard. Four pieces keep it going: hooks that make the orchestrator hand off before its context fills, a supervisor that restarts it, a watcher that waits out usage limits, and an opt-in switch that moves the orchestrator to another account before a limit hits. All of it is opt-in and acts only on the orchestrator, the session that holds the round lease. Workers are not touched.
What you need first is a working round: parallel rounds. Without any of this,
/rota-pause and /rota-work (no argument) are the manual route; see pausing and resuming.
| Piece | Verb | Stops | Config |
|---|---|---|---|
| Handoff hooks | rota hook install |
an orchestrator whose context is at handoffThreshold |
orchestrator.handoff* |
| Keepalive | rota keepalive run |
nothing: it restarts the orchestrator after a handoff exit | orchestrator.keepalive* |
| Usage limits | rota limit watch |
a session stalled on a 5-hour or weekly limit | limits.* |
| Account switch | orchestrator.switchOnUsage |
an orchestrator close to a limit, before it hits | orchestrator.usageThreshold |
A typical unattended start: rota hook install, then in the orchestrator’s pane
rota keepalive run -- claude --model opus. rota orchestrate already starts the orchestrator under rota keepalive run, so after rota hook install you only start it. The supervisor runs the limit watcher beside the
command.
Orchestrator handoff
An orchestrator’s context fills over a long round. Two hooks make it hand off before that happens, with no one at the keyboard.
Install the hooks
Once per project:
rota hook install # .claude/settings.local.json, per developer, not committed
rota hook install --wrap-statusline # when you already have a statusLine
install merges into the settings file and never replaces anything it did not write. It adds:
- a
Stophook (rota hook stop), - a
SessionStarthook (rota hook session-start), - a
UserPromptSubmithook (rota hook prompt): a one-line round digest on every message you send the orchestrator, - a
PreToolUsehook onBash|Edit|Write(rota hook guard): recalls path-scoped project context before edits in any session and refuses destructive git andgh pr mergefor worker Bash calls. See the worker guard, - a statusline (
rota statusline dump).
Hook entries end in # rota-hook, so a second run updates them instead of stacking copies.
--scope project writes .claude/settings.json (committed); --scope user writes your Claude config dir.
Your own statusline keeps working
With a statusline already set, plain install refuses (exit 4). --wrap-statusline handles it:
- It rewrites the statusline to
rota statusline dump --then '<your command>'and keeps the original beside it asrotaWrapped. - The dump records the session state, then runs your command with the same input and output, so your bar looks the same.
rota hook uninstallremoves the hooks and puts your command back.- The file is rewritten as two-space JSON. If it already is, the round trip is byte for byte.
What happens at the threshold
This path is the orchestrator’s. Every other session gets the lighter work.contextGuard: a one-line /rota-pause recommendation (warn, the default) or a capped Stop block asking for it (auto), at the same threshold. See pausing and resuming.
- Every statusline refresh writes the session’s state (context percentage, rate limits) under the git
common dir, as
rota/session/<session_id>.json. - When the orchestrator tries to stop and the state shows
orchestrator.handoffThresholdpercent (default 75) or more, the Stop hook blocks. It tells the orchestrator to write.rota/handoff/<base>.mdand run/exit. - If the hook blocked 2 more times after the first block and still
found no handoff, it gives up and records
handoffFailedin the state, so a session that cannot write a handoff is not held forever.
The percentage comes from context_window.used_percentage, else from current_usage over
context_window_size.
The next session
The SessionStart hook fires on startup and clear.
- When the new session holds the lease and the handoff exists, the hook injects the file as context and
moves it to
<base>.md.consumed. - A restarted orchestrator has not run
rota round startyet, so it holds no lease. A fresh handoff written by the Stop hook (first line<!-- rota-handoff: orchestrator -->) is injected anyway. Its first act isrota round start, then it reads the handoff. resumeandcompactkeep the file.
Checking it
rota doctor reports whether the statusline runs the dump and the hooks are in place (statusline,
stop-hook). The hooks are opt-in:
- Until
rota hook installhas written something, both checks skip. - After that they fail on a partial or broken install: one hook missing, a statusline without the dump, or a hook command that no longer resolves.
💓 Keepalive
The hooks end an orchestrator session cleanly. rota keepalive run starts the next one. Start the
orchestrator under it, in the pane it will own:
rota keepalive run -- claude --model opus # everything after -- is the command
rota keepalive run is the pane’s foreground process and claude is its child, so it learns the exit from
the child’s own status and needs neither herdr nor tmux to notice it. It supervises the process and does not watch the pane, so an orchestrator not started under run is not restarted.
When it restarts
On every exit it looks for a fresh handoff, .rota/handoff/<base>.md no older than
900 seconds.
- With one: it waits 5 seconds and starts the command again with
orchestrator.restartPromptappended as the last argument. The first start never gets the prompt. - Without one: it stops. That is how
/exitfrom you ends the loop.
The lease
run takes the round lease, holds it across restarts, and tells its child through
ROTA_ROUND_HOLDER_PID. So:
rota round startand the hooks inside the orchestrator see the supervisor as the holder.- The round number survives a restart.
- The SessionStart hook injects the handoff at once, without
rota round startfirst. - A second
run, or an orchestrator started by hand, is refused while it lives (rota round startexits 4).
rota keepalive status shows the supervisor and the lease. A supervisor killed with SIGKILL leaves a stale
lease, which the next run reclaims.
The breaker
A restarted orchestrator that dies before it reads the handoff (bad auth, a hook error) leaves the same
file behind. run counts a restart as progress only when the handoff changed: a new write with different
content.
- After 3 restarts in a row without progress, it stops.
- After 10 restarts in total, it stops regardless.
Both stops post an escalation comment on issue orchestrator.escalateIssue through
rota round escalate send and raise the herdr notification. With escalateIssue unset, which is the
default, you get the notification and a warning only. The handoff is kept; fix the cause and run it again.
How it stops
The supervisor stops:
- when the orchestrator leaves no fresh handoff (
no-handoff), - on the breaker or the restart limit (exit 1),
- when you interrupt it: SIGINT and SIGTERM go to the child, the supervisor waits for it and does not
restart (
interrupted).
It then releases the lease and records status: stopped in <git-common-dir>/rota/keepalive.json. It
never deletes the handoff; only the SessionStart hook consumes it.
Flags
--max-restarts, --breaker, --backoff and --prompt override the config for one run. --config-dir sets the CLAUDE_CONFIG_DIR the command starts under (default: the one the supervisor inherited), so a supervised orchestrator can run on a chosen account. --first-prompt appends a prompt to the first start only (rota orchestrate uses it to start /rota-orchestrate).
--no-limits leaves out the usage-limit watcher the supervisor otherwise runs beside the command (see
usage limits). --json prints one envelope when the loop ends, not before.
⏳ Usage limits
A 5-hour or weekly usage limit stops a session until the window resets. rota limit watch notices the
limit, waits for the reset, and types a resume prompt into the pane.
Under rota keepalive run the same loop runs inside the supervisor. For
an orchestrator not started under run, start the verb in the background or in a pane of its own:
rota limit watch # blocks for the life of the round
rota limit status # the log, and whether anything is watching
watch needs the round lease, because moving work between accounts is the orchestrator’s act. It
refuses to start (exit 4) without it, under a live rota keepalive run (which already watches), or beside
another watcher.
How it notices
- The orchestrator: it reads the rate limits the statusline dump stores. A window at 100 percent with its reset still ahead is a limit; the later reset wins if both are.
- A worker slot: it reads the account meter (
rota worker account list), but only after the slot’s pane shows a limit message, never on a timer. - A Codex slot: there is no meter, so the pane’s limit message is the evidence. The limit counts
against the slot’s
codexlogin inround.accounts, and the reset comes fromtry again at <time>in the text. - The message, as fallback: on herdr 0.9.x the loop subscribes to
pane.output_matchedwith the phrasesrota worker pollalready uses for LIMITED. On tmux, or if herdr refuses the subscription, it captures the panes every--settleseconds. A match counts only in the harness’s own dialog region of the pane, the block above the input box. A limit phrase quoted in a reply, a diff or a tool listing starts nothing. - No data behind a message: the reset time comes from the text (
resets at 3pmreads as the next 3pm in your time zone, within 8 days). Otherwise it sleeps 30 minutes.
“Approaching your usage limit” is a warning and starts nothing.
Sleep or switch
limits.mode is switch (default) or sleep.
sleepwaits for the reset.switchapplies to a worker slot only. It keeps the slot’s account unless it is cooling, otherwise takes the account with the most headroom (rota worker account pick --exclude <account>), the rulerota round assignuses. With such an account and an idle slot on it, the slot’s issue moves there withrota round transfer, so the work continues from its pushed branch and a handoff comment. With no usable account, or no idle slot on it, the limit sleeps instead and the entry says why.- The orchestrator only sleeps. A limited session cannot write a handoff, and a restarted one with no handoff has nothing to continue from. Moving it to another account is opt-in and happens before the limit: see switching the orchestrator’s account.
The slot cap
When every account a worker could run
under is cooling down, rota round tick (and rota round watch --autopilot) mints and assigns nothing and
prints capped with the reason, and rota round assign refuses with blockedBy quota. rota round
status shows cap <effective>/<roster> and the reason while the cap is below the roster size. The slots
already working keep going. Slots fill again by themselves once an account’s window resets.
A Claude account is cooling when its meter shows a spent window with a reset ahead (round.accounts). A Codex
login is cooling while the limit log holds a waiting entry for it (a codex entry in round.accounts). An unconfigured pool
never counts as cooling, an account whose usage cannot be read counts as free, and the Claude pool is left out
when the first entry of round.harnesses is codex.
Resuming
At the reset plus 60 seconds the loop types limits.resumePrompt into the pane and
keeps watching it. A pane still limited after the prompt starts another cycle, up to 3 times
for one limit. Past that the entry is failed and the loop posts an escalation on issue
orchestrator.escalateIssue, or raises a host notification when that is unset.
Stop the watcher with Ctrl-C or SIGTERM. The waiting entries stay waiting, and the next watcher resumes any whose reset has already passed.
The log
The limits list in .rota/workers.json, beside slots and escalations. Each entry (l1, l2, …)
records:
- the session (
orchestratoror a slot) and the window, - whether the reset came from data or text, and when it resets,
- the action, its status (
waiting,resumed,switchedorfailed) and a note.
rota limit status reads it back. rota round status and rota round reconcile list the entries still waiting.
Nothing prunes resolved ones.
Config
Two keys under limits, both silent defaults: mode (switch) and resumePrompt (The usage limit has reset. Continue
where you left off.). The resume margin (60 s), fallback sleep (1800 s) and resume cap (3) are fixed. See configuration.
Switching the orchestrator’s account
Off by default. With orchestrator.switchOnUsage set to true, an orchestrator close to its usage
limit hands off and restarts under another account instead of running into the limit and sleeping. The
usage-limit sleep still applies when the key is off, and to anything the switch does not cover.
rota config set orchestrator.switchOnUsage true
rota config set orchestrator.usageThreshold 90 # percent, the default
What it needs
- The Stop hook installed (
rota hook install). - The orchestrator started under
rota keepalive run. Without a supervisor nothing would restart it, so the hook does not ask. - At least two
claudeaccounts with adirinround.accounts. - The statusline dump on the account it moves to, which the
statuslinecheck covers.
rota doctor has a switch check, but it covers only the Stop hook and two accounts with a dir. It
cannot tell whether the orchestrator runs under rota keepalive run.
At the threshold
When the larger of the 5-hour and weekly used_percentage reaches orchestrator.usageThreshold, the Stop
hook blocks the way it does for context. It asks for the handoff and /exit, and records which window
tripped. A session with no rate-limit reading (API billing) is never asked.
The restart
When the orchestrator exits with that handoff, the supervisor picks the account. It never keeps the current one. The target is the account that:
- has the most headroom and a
freemeter (anunknownmeter is not taken), - has a
dir, - has headroom above
100 - usageThreshold.
The restarted claude runs with CLAUDE_CONFIG_DIR set to that account, and every later restart of the same
run keeps it. It reads its handoff through the SessionStart hook as after any restart. The switch counts as a
restart against the cap of 10 restarts.
With no usable account
The supervisor restarts at once on the same account and holds the switch until the window’s reset
(switchHold in keepalive.json; 30 minutes when there is no reset time).
- While the hold lasts, the hook passes on usage, so the session works on and the in-session limit sleep handles the real limit at 100 percent.
- A context handoff is not held.
- While usage stays at or above the threshold, handoffs repeat after each hold until the cap of 10 restarts stops the supervisor.
The figure is not a limit
The threshold reads the session file’s used_percentage, which carries no extra_usage information. A
weekly window at the threshold counts even when extra usage is enabled and would keep the account going.
Set usageThreshold to 100 to wait for the window to fill.
The log
Each decision is an entry in the limits list:
actionswitch(statusswitched): the account left is inaccount, the account switched to innote.actionrestart(statusresumed): no account was usable;noteholds why and the hold’s end.
rota limit status shows them. keepalive.json gains account, switches and switchHold.
The herdr agent integration is per account (herdr integration install claude with that
CLAUDE_CONFIG_DIR); rota doctor’s hook check covers it. The project-local hooks apply to every account.