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.
Fleet's Kanban board in Thurbox: six task columns, topic filters, and a card that opens its worker session
Alt+K opens the board; Enter on a running card opens its worker session.

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.

bash
curl -LsSf https://raw.githubusercontent.com/Thurbeen/fleet/main/install.sh | sh

On Windows, from PowerShell:

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:

  1. Open Thurbox and start the Mission Control session.
  2. 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.
  3. 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 #

the loop
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 gh and GitLab through glab , self-hosted included. A local-only fleet needs neither: it proves a pushed commit with git alone.
  • Merging is your explicit choice. shepherd merges, by squash only, just the repositories your own auto-merge.conf names. The tracked example names none, so a fresh clone merges nowhere.
  • The map is generated. uv run fleet sync-registry rebuilds 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 reviewed records 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 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 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:

the control-plane repo
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 checkout

Everything 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:

bash
# 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.

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

Borrowing 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.