rota

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 Stop hook (rota hook stop),
  • a SessionStart hook (rota hook session-start),
  • a UserPromptSubmit hook (rota hook prompt): a one-line round digest on every message you send the orchestrator,
  • a PreToolUse hook on Bash|Edit|Write (rota hook guard): recalls path-scoped project context before edits in any session and refuses destructive git and gh pr merge for 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 as rotaWrapped.
  • The dump records the session state, then runs your command with the same input and output, so your bar looks the same.
  • rota hook uninstall removes 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.

  1. Every statusline refresh writes the session’s state (context percentage, rate limits) under the git common dir, as rota/session/<session_id>.json.
  2. When the orchestrator tries to stop and the state shows orchestrator.handoffThreshold percent (default 75) or more, the Stop hook blocks. It tells the orchestrator to write .rota/handoff/<base>.md and run /exit.
  3. If the hook blocked 2 more times after the first block and still found no handoff, it gives up and records handoffFailed in 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 start yet, so it holds no lease. A fresh handoff written by the Stop hook (first line <!-- rota-handoff: orchestrator -->) is injected anyway. Its first act is rota round start, then it reads the handoff.
  • resume and compact keep 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 install has 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.restartPrompt appended as the last argument. The first start never gets the prompt.
  • Without one: it stops. That is how /exit from 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 start and 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 start first.
  • A second run, or an orchestrator started by hand, is refused while it lives (rota round start exits 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 codex login in round.accounts, and the reset comes from try again at <time> in the text.
  • The message, as fallback: on herdr 0.9.x the loop subscribes to pane.output_matched with the phrases rota worker poll already uses for LIMITED. On tmux, or if herdr refuses the subscription, it captures the panes every --settle seconds. 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 3pm reads 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.

  • sleep waits for the reset.
  • switch applies 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 rule rota round assign uses. With such an account and an idle slot on it, the slot’s issue moves there with rota 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 (orchestrator or a slot) and the window,
  • whether the reset came from data or text, and when it resets,
  • the action, its status (waiting, resumed, switched or failed) 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 claude accounts with a dir in round.accounts.
  • The statusline dump on the account it moves to, which the statusline check 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 free meter (an unknown meter 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:

  • action switch (status switched): the account left is in account, the account switched to in note.
  • action restart (status resumed): no account was usable; note holds 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.

Edit this page on GitHub