Orchestration
Running agents across many repos raises a question that one repo never does: where does the plan live? The ready-made answer is Fleet , the maintained control plane for Thurbox. This page starts there, then walks through the Thurbox primitives Fleet is built from, for when you want to drive them yourself.
Fleet: orchestration, ready to run #
Fleet maps your repositories and runs a task queue. You give its lead session, Mission Control, a goal in plain words. It splits the goal into tasks, runs one worker session per task in the real repository, and hands you back pull or merge requests to review.
- Durable topics and tasks. A prompt is kept verbatim as a topic, and every task has its own brief, record and result file on disk, so the plan outlives the lead session.
- Isolated workers. Each task runs in its own Thurbox session on its own git worktree, one per repository it spans.
- Mission Control. A long-lived lead session that Thurbox keeps alive. Ctrl+X returns to it from any session, a worker's included.
- A Kanban board. Alt+K opens the whole queue full-screen, and Enter on a card focuses its live worker.
- Supervised follow-through. A reconciler collects results, shepherds change requests, restarts workers that ran out of quota, and releases sessions once their work lands.
Install
Fleet needs Thurbox with tmux (psmux on Windows), git, and an agent CLI that Thurbox runs, such
as Claude Code or Codex.
gh
or
glab
is optional. The installer fetches uv itself.
curl -LsSf https://raw.githubusercontent.com/Thurbeen/fleet/main/install.sh | shOn Windows, from PowerShell:
irm https://raw.githubusercontent.com/Thurbeen/fleet/main/install.ps1 | iex
Pick a clone location you will keep, since the extension records its path. Running the installer
again is safe, and
uv run fleet preflight
lists anything still missing. Then:
- Open Thurbox and start the Mission Control session.
-
Run
/fleet-onboarding. It builds the repository map if you work on a forge, installs the Kanban board and starts the reconciler, and asks wherever the choice is yours. - Type a goal into Mission Control.
Fleet's
README
covers the board,
AGENTS.md
is its operating guide, and
its skills
hold one job each. Fleet installs from its
main
branch; this page describes what is there, not proposals that have not landed.
How Fleet runs a goal #
prompt ─▶ topic ─▶ tasks + briefs ─▶ plan / dispatch ─▶ one worker per task
│ writes result.md
reap ◀── landed ◀── shepherd / refuel ◀── watch / collect ◀─┘
Every stage is a
uv run fleet queue
command. The lead runs the first three; the reconciler keeps the rest moving.
| Stage | Command | What it does |
|---|---|---|
| Topic | queue topic add |
Keeps the prompt verbatim as the topic's PROMPT.md |
| Brief | queue add |
One task per branch, with a BRIEF.md that states the goal, the constraints and
what done means; dispatch refuses a placeholder
|
| Plan, dispatch | queue plan · queue dispatch |
Shows what is ready and why the rest waits, then launches every ready task at once;
queue block records a real reason to wait
|
| Watch, collect | queue watch · queue collect |
Folds Thurbox's event stream into each record; collect reads the worker's
result.md and checks its artifact, and only that closes a task
|
| Shepherd, refuel | queue shepherd · queue refuel |
Follows every open change request and merges only where you allowed it; restarts workers that stopped on a spent quota window |
| Land, reap | queue reap |
Once the forge says every artifact merged, the task is landed and its session is released |
The reconciler
is
uv run fleet reconcile
, a supervised loop you control with
ensure
,
start
,
stop
and
status
. It runs
watch
,
collect
,
shepherd
,
refuel
and the read-only
plan
on their own intervals, and tells the lead when new work is ready or a worker stalls. It never
dispatches and writes no record of its own.
stop
leaves a down flag that
ensure
honours until
start
clears it.
What Fleet keeps, and what it needs
-
A forge is optional.
GitHub through
ghand GitLab throughglab, self-hosted included. A local-only fleet needs neither: it proves a pushed commit with git alone. -
Merging is your explicit choice.
shepherdmerges, by squash only, just the repositories your ownauto-merge.confnames. The tracked example names none, so a fresh clone merges nowhere. -
The map is generated.
uv run fleet sync-registryrebuilds the repository index from your owners list. Nobody hand-edits it, and both files stay local, so there is nothing to commit or push. -
Facts are written once.
A worker records what it learned about a project in its result; each fact is its own file and
is never edited, only replaced by a newer one.
uv run fleet context repo <repo>hands them to the next worker on that project. - Your state stays on your machine. The queue, run logs, map, facts and settings are gitignored in the public repo, which does not back them up. Back up your clone yourself.
-
A served document waits for its reader.
A task that serves a page for review stays open, its session kept to answer questions, until
uv run fleet queue reviewedrecords that the reader is done.
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 your forge accounts 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. A task queue, 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 #
Fleet's checkout, trimmed to the parts this page talks about:
fleet/, scripts/lib/ the `uv run fleet` command and its modules
registry/
owners.txt owners to index, one per line
repos.generated.yaml generated by `uv run fleet sync-registry`; never hand-edited
facts/<repo>/<id>.md one learned fact per file, never edited
context/<repo>.md optional narrative about a project
orchestration/
queue/<topic>/ PROMPT.md, then one directory per task:
<task>/ task.yaml, BRIEF.md, progress.jsonl, result.md
runs/ one log per topic
playbooks/ reusable recipes
reconcile/, shepherd/ the loop's runtime state
*.example.conf tracked settings; your copies are gitignored
interface/ the Kanban board and the Ctrl+X key
extension.toml.in manifest source, rendered at install time
FLEET.md standing instructions for Mission Control
AGENTS.md how an agent works inside the checkoutEverything a running fleet writes (the queue, the logs, the map and its facts) is gitignored. The public repo tracks the machinery and none of your content.
The run loop, in Thurbox calls #
Fleet is built on the same
thurbox-cli
you have. If you build a lead of your own, these are the calls to reach for:
# Spawn 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
# Follow every transition as it happens. No sampling, no polling loop.
thurbox-cli watch --json --initial
# Enumerate the run's workers — including the silent ones.
thurbox-cli session list --parent "$LEAD" --json
# A replacement lead adopts a worker.
thurbox-cli session reparent "$WORKER" "$NEW_LEAD"A Fleet worker finishes by writing a result file in the queue: an outcome, the artifact it produced and what it learned. The lead reads that file, and only that file closes the task.
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.
A result file, not a poll
A Fleet worker reports by writing
result.md
, which
uv run fleet queue collect
reads and checks against the forge, or against git for a pushed commit. A turn ending is not a
task finishing, so nothing else closes a task.
Why not poll
gh pr list
?
A file the worker writes on purpose is
exact
(it comes from a worker that knows it finished, not from a side effect), it is
durable
(it survives the lead session restarting), and it can say
"not applicable"
. A worker that correctly concludes there was nothing to do says so in its result. 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.
Thurbox's
inter-session messages
are still there for a lead you build yourself, and Fleet's reconciler uses them with
--no-wake
to leave Mission Control a note when it cannot type into its terminal.
A supervised loop, not automations
Fleet's periodic work is not a Thurbox automation . It is the reconciler: one supervised process holding a lock and a heartbeat, brought back by a user service or by Mission Control's session start, and stopped only by you. It runs the queue's own commands on a timer, because a worker that died on a token limit fires no hook to say so.
Restraint is still part of the pattern. The reconciler never dispatches, cancels or reorders,
and every change it makes goes through
uv run fleet queue
. Rebuilding the map is a command you run, and it writes only local files.
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 something the worker writes on purpose — a result file,
as Fleet uses, a mailbox message, 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
A result file 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, and
lost
is a pane that died with nobody asking, such as a multiplexer crash — 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 0Borrowing Fleet's arrangement #
Fleet is an extension , not a Thurbox feature: nothing in Thurbox knows it exists. If you build your own lead, 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.rendered.md
, rendered from the checkout's
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
AGENTS.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 committed file cannot hardcode a path
that exists on one machine, so it ships a
__REPO_PATH__
placeholder that
uv run fleet install-extension
renders with the clone's absolute path before installing.