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 offmain, and send each one a prompt to refactor its crate. Then show methurbox-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
triagesession 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 #
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 planeThe run loop #
- Clarify the goal. Pick a playbook, or write one from the template.
-
Open a run log, named
<YYYY-MM-DD>-<slug>.md. - For each unit of work, launch a worker session with one self-contained prompt, in its own worktree on the target repo.
- 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.
- Drain results from the mailbox as workers report.
- Review the PRs. Delete each session as it closes out.
Steps 3 and 5, concretely — the lead spawns, then reads its mail:
# 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" --jsonWhy 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_state
→
to_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.
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.
git -C "$REPO" fetch origin
git -C "$REPO" merge --ff-only origin/main
git -C "$REPO" rev-list --count main..origin/main # expect 0The 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:
./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.