Orchestration

Running agents across many repos raises a question that one repo never does: where does the plan live? This page describes the control plane — the shape that falls out of Thurbox's primitives once you use them at more than one repo — and points at a template you can start from.

Start by just asking #

Before any of the structure below: you do not have to write the orchestration yourself. thurbox-cli is installed beside thurbox , so it is already on every session agent’s PATH — from inside any session you can ask for a fleet in English.

  • Using thurbox-cli , start refactor sessions for this repo — one per crate, each on its own worktree branch off main , and send each one a prompt to refactor its crate. Then show me thurbox-cli session list .
  • Spawn a reviewer session on this repo with no worktree, and send it standing instructions to review every file I change.
  • Create a nightly automation that sends “triage new issues” to the triage session on weekdays at 09:00.

The agent discovers the surface with thurbox-cli --help ; the commands it reaches for are session create (with --worktree-branch , --agent , --host , --add-repo ), session send , session list and automation create . Everything it does shows up in your running TUI within a tick, because the TUI and the CLI share one SQLite database. The recipes page has the same thing written as bash, if you would rather commit a script.

That covers a handful of sessions on one goal. The rest of this page is about what changes when you are doing it across many repos, repeatedly.

The problem #

One repo, one agent session, one task: nothing to orchestrate. The repo holds the code, the session holds the context, and the pull request is the record of what happened.

At five repos this breaks in a specific place. The work still lands in the repos — that part is fine. But the plan has nowhere to live. A goal that spans three repos is not a file in any of them. Neither is the context that makes the goal legible: what each project is for, which ones depend on each other, which are dormant. Neither is the log of what you launched last Tuesday and what came back.

So it ends up in a chat transcript — not durable, not diffable, and not readable by the next session.

The control plane #

Give the plan its own repo, holding two things and nothing else:

  • The map. An always-current index of your repos, generated from the GitHub API so it cannot drift, plus hand-written context files holding the judgement a generated index can't: what a project is for , how it relates to the others, what its current goals are.
  • The orchestration. Reusable recipes (playbooks) and one log per run.

The defining rule is what the control plane does not hold:

The control plane holds the plan and the log . It never holds the workers' branches.

Each unit of work becomes one Thurbox worker session, targeting a real repo in its own git worktree , driven by a single self-contained prompt. Workers share no context with the control plane and none with each other, so every prompt restates the goal, the constraints, and what "done" means, from scratch.

Why split generated from hand-written? They have different failure modes. An index of repo names and default branches goes stale the moment you rename something, so it must be regenerated and never hand-edited. Why a project exists cannot be generated at all, and changes on a human timescale. One file for both guarantees that refreshing the half that must be fresh destroys the half that must be preserved.

Why a separate repo? The plan outlives every branch it spawns. A run log inside a worker repo would collide with the branches it describes, and would need a new home the moment a run touched a second repo. The split also makes the rule enforceable rather than aspirational: a control plane with no worktrees cannot accidentally become a place where work happens.

The layout #

the control-plane repo
registry/
  owners.txt                 GitHub owners to index, one per line
  repos.generated.yaml       generated; never hand-edited
  context/_TEMPLATE.md       copy this to add a project
orchestration/
  playbooks/_TEMPLATE.md     copy this to add a recipe
  runs/_TEMPLATE.md          copy this per run
scripts/
  sync-registry.sh           regenerate the index via `gh`
  sync-checkout.sh           fast-forward main when that is safe
  install-extension.sh       render extension.toml, then install
extension.toml.in            manifest template (rendered at install time)
FLEET.md                     standing context for the long-lived session
CLAUDE.md                    how an agent works inside the control plane

The run loop #

  1. Clarify the goal. Pick a playbook, or write one from the template.
  2. Open a run log, named <YYYY-MM-DD>-<slug>.md .
  3. For each unit of work, launch a worker session with one self-contained prompt, in its own worktree on the target repo.
  4. Record every session — name, repo, prompt intent, outcome, PR — in the run log as it happens . A log written afterwards is a summary; one written during is the source of truth, and it survives the lead session dying.
  5. Drain results from the mailbox as workers report.
  6. Review the PRs. Delete each session as it closes out.

Steps 3 and 5, concretely — the lead spawns, then reads its mail:

bash
# The lead spawns a worker: its own worktree, parented to the lead.
thurbox-cli session create \
  --name ship-auth-fix \
  --repo-path /abs/path/to/repo \
  --worktree-branch fix/auth --base-branch main \
  --parent "$THURBOX_SESSION" \
  --json

# The worker, when it is done, mails the lead. `send` wakes the recipient.
thurbox-cli message send --to "$LEAD" --kind result --body 'https://github.com/owner/repo/pull/42'

# The lead drains its inbox exactly once. No polling.
thurbox-cli message inbox --for "$LEAD" --claim --json

# Afterwards, enumerate the run's workers — including the silent ones.
thurbox-cli session list --parent "$LEAD" --json

Why it is a Thurbox pattern #

Nothing above is invented. Each piece is a Thurbox primitive doing load-bearing work.

Worktree-per-session

A worker creates a git worktree on its own branch. Two workers on the same repo cannot collide, and an abandoned worker costs you a directory, not a dirty checkout on a branch someone else needs. This is what makes "one unit of work, one session" safe to say.

The lead is a real session

The control plane installs itself as an extension with one long-lived [[sessions]] entry, which Thurbox self-heals : delete the session and it comes back at the next startup or automation tick. A control plane that evaporates when someone tidies their session list is not a control plane.

And because it is a real session rather than an ad-hoc terminal, it can be addressed: workers can mail it, it has a UUID to hand out as --parent , and it gets THURBOX_SESSION in its environment like any other session.

The mailbox, not polling

Workers report through inter-session messages . send wakes the recipient, so the lead never polls. Thurbox injects THURBOX_SESSION into every session's environment, and both --from and inbox --for default to it — a worker needs no ids to mail home.

Why not poll gh pr list ? It is exact (a message comes from a worker that knows it finished, not from a side effect), immediate (a poll adds latency proportional to its interval), and it can report "not applicable" . A worker that correctly concludes there was nothing to do says so in one message. A PR poll cannot distinguish "no PR because nothing needed fixing" from "no PR because the worker is still thinking" — and those demand opposite responses from the lead.

Deliberately no automations

Thurbox has automations , and this pattern does not use them. The one scheduled candidate — the registry sync — commits and pushes, so a human runs it and reads the diff. Nothing else is periodic: a run starts because someone has a goal. Restraint is part of the pattern. A control plane that fires unattended writes is a second actor in a system whose whole point is that there is one.

Three constraints #

Each costs real time to rediscover.

The status field is not a completion signal

session get / list --json do carry the working / blocked / done / idle state that agent hooks report via session signal , in hook_state . What they cannot carry is any guarantee that it is current : the value is latched — whatever was written last, by an agent that may since have crashed, been interrupted, or never have been wired to report at all. Polling it for completion is how a lead waits forever on a worker that finished an hour ago.

So headless completion detection is still the mailbox, or a printed sentinel the lead greps out of session capture . What the state fields are for is supervision — noticing that a worker is stuck, blocked or gone — and each arrives with what it takes to judge it: hook_state_age_secs (how old the report is; there is deliberately no built-in staleness timeout, since a turn may legitimately run for an hour), hook_reported (whether anything ever reported — silence is not idle ), hook_coverage (what this agent can report at all), and hook_corroboration / hook_state_contradicted (what actually holds the pane, and whether it agrees). thurbox-cli session doctor turns the same picture into a verdict and an exit code.

And a session whose agent Thurbox did not launch is no longer invisible: its pane is read, so a known agent binary in the foreground reports state: "running" with state_source: "process" . Better still, have your harness call session signal itself — THURBOX_SESSION is already in the pane's environment, so the call needs no arguments. To get the real hooks rather than the coarse pane reading, ask for the wiring: thurbox-cli agent launch-args <name> prints the command , args and env Thurbox itself would launch with — the status hooks are arguments, so an agent started any other way has none.

A session parked with session stop is equally readable: stopped: true and state: "stopped" on session get and list alike, so a liveness check is the poll you were already doing. Parking also clears the latched state and refuses a new one — a parked session has no process, so a heartbeat poll or a mirror pass writing a turn onto it would report something that did not happen.

watch, for everything that is not a report

The mailbox is how a worker says it finished. thurbox-cli watch --json --initial is how a driver learns everything the worker was never going to mail: that it went blocked on a permission, that its pane died, that somebody deleted it.

It streams an append-only log , not a sampled diff. Every writer that changes what a watcher reports appends its event in the same transaction as the change, so two transitions in the same instant are two events — which matters because working → blocked → working is exactly what an auto-answered permission looks like, and a sampler reading the row every 250 ms sees neither edge. Each line carries a monotonic seq ( --since <seq> resumes exactly where a restarted driver left off), a reason saying which kind of event it was — soft_deleted can be restored, force_deleted cannot — the from_stateto_state of the transition, and the same gating fields session get publishes, so acting on a blocked needs no second call. The stream exits the moment its reader closes the pipe.

Read the exit status before parsing

Errors are structured documents on stdout , not lines on stderr — an agent reads one stream. The exit code carries the verdict ( 0 ok, 1 ran and failed, 2 bad invocation, 3 a session reference matched more than one session), and the trap that follows is worth naming: thurbox-cli … --json | jq -r '.field' exits 0 with empty output when the command failed, because jq parsed the error object and the pipeline carries jq 's status. set -o pipefail does not help. Capture first, branch on the status, then parse.

bash
out=$(thurbox-cli session get "$ref" --json) || {
  printf 'thurbox: %s\n' "$(jq -r .error <<<"$out")" >&2
  exit 1
}
id=$(jq -r .id <<<"$out")

Fast-forward the base branch first

A worktree inherits whatever the local base branch points at, not what the remote does. A stale local main yields a worker that does perfectly correct work against a month-old tree and opens a conflicting PR — a failure that looks like a bad agent and is really a bad base.

bash
git -C "$REPO" fetch origin
git -C "$REPO" merge --ff-only origin/main
git -C "$REPO" rev-list --count main..origin/main   # expect 0

The reference implementation #

Everything above, as a public GitHub template you fork with "Use this template" : Thurbeen/fleet . Edit registry/owners.txt — your GitHub username plus any orgs — then run:

bash
./scripts/sync-registry.sh      # regenerate the repo index via `gh`
./scripts/install-extension.sh  # render the manifest, then install it

That leaves you with a working control-plane session, and a manifest that registers exactly two things: a fleet agent in agents.toml , and one long-lived fleet session opened on the checkout.

It is a template, not a Thurbox feature: nothing in Thurbox knows it exists, and a fork is yours to diverge from immediately. The part worth copying is the arrangement, not the files. Two of its choices are that arrangement rather than its own taste.

The lead's job description is a payload file, not a prompt. The manifest's [[files]] lays down FLEET.md in the extension home, and three [[symlinks]] surface it as CLAUDE.md , AGENTS.md and GEMINI.md , so the lead reads what it is for whichever CLI is behind it. That text is deliberately not the repo's own CLAUDE.md : one says what the session is for — hold the plan and the log, never the branches — and the other says how to work inside the checkout. A lead whose invariant lives only in the conversation that stated it keeps that invariant exactly as long as the conversation.

The manifest ships as extension.toml.in because no token spells "my clone". [[sessions]] repo_path has to name the checkout — that is where registry/ and orchestration/ are — and {home} is substituted, but it resolves to the extension home. A template cannot hardcode a path that exists on one machine, so it ships a __REPO_PATH__ placeholder that install-extension.sh renders from git rev-parse --show-toplevel before installing. A leading ~ is expanded there, but only since 0.174.2 — so a manifest declaring an older min_thurbox_version , as this one does, cannot lean on it either.