A chief of staff for Claude Code: /fleet start makes a session the chief, which plans, keeps a task board and delegates to one agent per repository.

A chief of staff for Claude Code, as a herdr plugin.
You talk to one Claude Code session, the chief. It plans the work, breaks it into tasks on a shared board, and starts one Claude Code agent per repository, each in a herdr tab of its own, to do them. fleet shows you the crew: a live graph of who is working, who is waiting on you and who told whom what, with the board beside it.
It is for work that crosses repositories — a backend change, the service that consumes it, the UI on top — where one agent per repo keeps each one's context small, and something has to keep track of what waits on what.

fleet board go or from you typing in its tab.fleet board, with a /board skill that teaches it. Nothing to install: every agent starts with the skill loaded.fleet board msg records a message on the board and puts it in front of the recipient, marked as coming from a peer. A Claude Code mod in each agent picks it up once the agent's turn has ended, whole; without mods (Claude Code before v2.1.287, or mods turned off) it is typed into the agent's pane instead.↵ or a click goes to an agent's tab.herdr status shows both.~/.local/bin/claude), or any claude named by FLEET_CLAUDE, which fleet starts its agents with.cargo) only on a machine without a prebuilt binary: macOS and Linux on arm64 and x86_64 have one.Outside herdr, start a fleet by running this in the workspace, the directory that holds your repositories:
fleet chief # a new chief, and a new run
fleet chief --resume # back into the last chief's conversation and its run
The terminal becomes the chief's Claude Code session: briefed, with the /board skill and fleet's mod, on the workspace's board. It refuses to start a second chief while the first is still running.
Or make a session you already have open the chief. In a Claude Code session with fleet's plugin loaded, in the workspace:
/fleet start
The session is put on the board as the chief of a new run, gets the board's tools and the fleet view, and receives the chief's brief as a turn. Claude Code keeps a plugin you install out of the system prompt, so the brief is part of the conversation; when the conversation is compacted, fleet puts the brief back at the head of what is kept. The plugin does nothing in a session until /fleet start.
fleet runs the agents the chief starts as Claude Code background sessions instead of herdr tabs. fleet spawn, fleet handoff, fleet board retire and resume work the same from any terminal: each agent is a claude --bg session in its repository, with the same brief, plugin and tools, and messages reach it through fleet's mod. claude agents lists the crew and says which one needs you; claude attach <id>, which fleet spawn prints, opens one to talk to.
The chief's own session draws the fleet, in place of the fleet tab:
/fleet opens a pane beside the conversation with the fleet view: the fleet tab in herdr, live and with its keys, drawn by fleet at the pane's size. Click into it, and ↑↓ ←→ move between agents, l switches to the log, n starts an agent, x retires one, r brings a crew back and q closes it; a click on a card selects it. ↵ on a background agent says how to open it (claude attach <id>), since there is no tab to switch to. A worker waiting for its go has a button beneath the view.fleet · 3 agents · 1 working · 4 open · billing needs a go · accounts at 84%.fleet tui runs in a plain terminal too, for the graph.
Two things to know:
claude once in the workspace and accepting the prompt covers every repository in it.FLEET_HOST=herdr or FLEET_HOST=background picks one instead of going by where fleet runs. The setup check's running in row says which it will use.
claude plugin marketplace add patrickkunzke/fleet
claude plugin install fleet@fleet
Then start Claude Code in your workspace as you always do, and make that session the chief:
/fleet start
The plugin does nothing in a session until then. The first /fleet start downloads fleet's binary for your machine into the plugin's folder, checked against the release's SHA256SUMS; a fleet already on your PATH is used if that fails. See Without herdr for what the chief and its agents do from there.
herdr plugin install patrickkunzke/fleet
herdr shows what it is about to run, clones the repository and runs its build step, scripts/install.sh. That downloads the release's prebuilt binary for your machine and checks it against the release's SHA256SUMS; it builds with cargo build --release --locked instead when there is no binary for your machine, or when the checkout is not a release commit. FLEET_BUILD=source always builds. Then, in ~/.config/herdr/config.toml:
[session]
# herdr would resume agents itself, as plain `claude --resume <id>`: no role,
# and a chief with its editing tools back. fleet resumes its crew instead.
resume_agents_on_restore = false
[ui.toast]
# herdr shows no notifications by default; fleet's go through it.
delivery = "system" # or "herdr" for in-app toasts
[[keys.command]]
key = "prefix+f"
type = "plugin_action"
command = "fleet.open"
description = "fleet"
[[keys.command]]
key = "prefix+shift+f"
type = "plugin_action"
command = "fleet.new"
description = "new workspace in fleet mode"
To check the setup, run the fleet: check the setup action from herdr's action list (herdr plugin action invoke fleet.doctor), and read what it found with herdr plugin log list --plugin fleet. It checks herdr's server version, which claude fleet will start and whether it loads mods (v2.1.287 or newer, and disableAllHooks not set), and the two settings above, and says what to change.
/board skill, and fleet on your PATHNothing to install for either. Every agent fleet starts has the /board skill loaded (as fleet:board, through Claude Code's --plugin-dir, for that session only) and fleet first on its PATH. Your own Claude Code sessions are left as they were: the board is for the crew.
To run fleet from your own shell as well:
ln -s "$(ls -d ~/.config/herdr/plugins/github/fleet-* | head -1)/target/release/fleet" ~/.local/bin/fleet
herdr keeps the plugin in the same directory across updates, so the link keeps working.
herdr has no update command of its own, so fleet has one:
fleet update --check # the installed version and the newest release
fleet update # install the newest release
It installs again through herdr at the newest release tag (vX.Y.Z), so herdr shows what it is about to run, as on the first install. herdr builds the new version beside the old one and swaps it in only when the build passes: a failed update leaves the fleet you had. For a checkout linked with herdr plugin link, it pulls main and builds instead, and stops if the checkout is on another branch or has uncommitted changes.
Without fleet on your PATH, the same thing by hand:
herdr plugin install patrickkunzke/fleet
After an update, a fleet view that is already open still runs the old version: press q in it, then prefix+f. The agents use the new fleet from their next command. The doctor action says when a newer release is out.
~/Code/acme, say, with service/billing-service and ui/storefront somewhere below it.prefix+f. The fleet tab opens with the chief on its left.Every agent asks the chief before it starts changing code, and fleet holds it to that: its edits are refused until the chief gives its task a go with fleet board go, or you answer it in its own tab. The chief asks you about anything that is yours to decide. You can go to any agent's tab and talk to it directly; it is an ordinary Claude Code session.
prefix+shift+f opens a new workspace in fleet mode wherever the focused pane is. To have a directory's workspaces open in fleet mode by themselves, list it in ~/.claude-fleet/auto-open, one directory a line:
echo '~/Code/acme' >> ~/.claude-fleet/auto-open
It matches the workspace's own directory exactly, so a workspace opened in one repository inside it stays an ordinary one.
| Key | |
|---|---|
← → ↑ ↓ | move between agents |
↵, or a click | go to that agent's tab |
l | the log of every message and state change; l again for the graph |
n | start an agent in a repository of your choosing |
r | bring back an earlier run |
x | take an agent off the board (its tab is left alone) |
q | quit the view; the agents keep running |
No key needs a prefix: none of them belongs to an agent.
The graph is the crew as it is shaped: the chief's card over its agents', a line from it into each. A line is heavy where the most has gone along it and dashed where nothing has, amber while the agent at its end is working and teal while it waits for you. A message is drawn travelling along its line as it is sent. An agent stopped at a permission or question dialog shows as ! needs you.
The board lists the open tasks by epic, with who holds each and what it waits on, and the background processes the agents have running. In a narrow pane it goes under the graph.
The sidebar. On each agent's second line, where herdr would say claude, fleet writes what it is on — ENG-2553-2 · waits on ENG-2553-1, ENG-2553-2 · review — and on the chief's, the board at a glance: 3 open · 1 blocked · 1 in review.
The chief is briefed as a chief of staff for the workspace: plan, write the tasks down, delegate, and answer the agents. It starts with --disallowed-tools Edit Write NotebookEdit, because asking was not enough — given a ticket touching a single repository, it did the work itself. fleet's mod (see Messages) also refuses the chief's Bash commands that write files: redirects, sed -i, tee, cp, mv, rm, git commit and the like, outside /tmp. Not airtight, since a pattern cannot know every way a command writes, but it closes the paths of least resistance.
A worker does not change files until its task has a go-ahead. It reads its task, sends the chief its plan, and its Edit, Write and file-writing Bash calls are refused, with a reason that says to wait, until either the chief answers with
fleet board go ENG-2553-2 "go — keep the old column until billing is on it"
which records the go and sends the message in one step, or you type a prompt in the worker's own tab: talking to it there is your go. A worker cannot run fleet board go itself. Anything short of a go, such as a question or "hold off", is a fleet board msg and leaves it waiting. A task approved before anyone claims it is approved for whoever does. Like message delivery, this needs the mod: without it, the go-ahead is only asked for, as it was.
A worker waiting for its go is easy to spot. Its card in the graph reads ◇ needs a go once it is idle (● planning while it is still reading and writing its plan), the header counts it, its herdr sidebar label says ENG-2553-2 · needs a go, and herdr notifies you once when it stops to wait. In the worker's own tab, the line under its prompt says waiting for a go on ENG-2553-2. A task that is blocked, or waits on another, is waiting on that instead, and is not shown as needing a go.
The mod also reports what its session is doing each time it checks in. A working agent's card names the tool it is running (● Bash, an MCP tool by its own name without the server's), and the top right gives how full its context window is beside its uptime, 41m · 63%, with the uptime dropped when a long name leaves no room. From 80% the figure turns to the accent colour and herdr notifies you once: an agent that compacts keeps a summary of its brief rather than the brief, and a fresh agent may be the better one to finish its task. An agent without the mod shows only ● working.
It delegates with fleet spawn, which opens a tab in the repository, briefs the agent, and claims the task on the board in the same step, so it cannot be dispatched twice:
fleet spawn billing-svc --repo ~/Code/acme/service/billing-service --task ENG-2553-2
An agent nearly out of context is handed off rather than left to compact:
fleet handoff billing-svc --note "the flaky test is known; skip it"
A fresh session starts in the same repository under the same name, on the same task, briefed on the task, its messages on the board, the old session's last reply and the note, and told to look at the branch and git status before anything else. A go-ahead the task had stands. The old session is retired and its tab closed; an agent mid-turn is refused unless --now is given. The chief is told when a worker passes 80% context, with the command to run, and so are you, through herdr.
A retired session that is still running, because its tab was left open, changes nothing more: fleet's mod refuses its edits, and tells it once, when it is next idle, to stop.
When an agent is finished with, the chief retires it with fleet board retire billing-svc, which takes it off the board and closes its tab. A working agent's tab is left open, and --keep-tab leaves it either way. x in the fleet view only takes an agent off the board.
Each agent is briefed in two halves. Who it is goes into the system prompt, with --append-system-prompt, where it outranks whatever a hook injects later and survives compaction. What to do now is its first turn: the task, its body, what it waits on, and how to report.
One SQLite file per fleet, at ~/.claude-fleet/fleets/<name>/fleet.db, holding its epics, tasks and their dependencies, agents, runs, background processes, and every event. A fleet is one workspace directory, and its board is its own: agents are handed theirs in FLEET_DB, and a Claude Code session fleet did not start is refused (not part of a fleet) rather than written onto somebody else's.
fleet board epic ENG-2553 "shared settings flag"
fleet board add ENG-2553-1 ~/Code/acme/service/accounts-service "shared column" --epic ENG-2553
fleet board add ENG-2553-2 ~/Code/acme/service/billing-service "consume it" --epic ENG-2553 --dep ENG-2553-1
fleet board ready # only ENG-2553-1: the other one is waiting
fleet board start ENG-2553-1
fleet board done ENG-2553-1 # and says it unblocked ENG-2553-2
fleet board ls
fleet board log
fleet board --help lists the rest. FLEET_JSON=1 gives JSON from any read. fleet board sql takes a SELECT and refuses anything else: every write goes through a verb that records its event, and the log is only as good as that. From a shell, fleet fleets lists the fleets and --fleet <name> picks one.
$ fleet board msg accounts-svc chief "the column is in" --task ENG-2553-1
accounts-svc -> chief: the column is in
queued for chief: it arrives when chief is next idle
The message is written to the board first, then delivered, marked so the agent does not take a peer for the person at the keyboard: [fleet · accounts-svc · ENG-2553-1] the column is in.
Every agent fleet starts carries a small Claude Code mod beside the /board skill. It checks the board every few seconds, and once its session's turn has ended it takes what is waiting and submits it as a prompt of its own: the whole body, its line breaks kept, several messages in one prompt. While a turn runs, the line under the prompt says how many are waiting. Nothing is typed into a prompt the agent is busy at.
An agent whose mod is not checking in — Claude Code older than v2.1.287, mods turned off with disableAllHooks or --safe-mode, or a session that is not running — gets the old delivery instead: the message typed at its prompt through herdr, on one line, cut at 1500 characters, and held at a permission dialog rather than typed into it. Neither delivery ever costs the record: a message to an agent that has gone is still on the board.
A message an agent sends some other way and never logs does not appear in the graph. The board is the record, by design.
A reboot or a herdr restart takes the agents' processes, not their conversations. A run records which conversations belonged together: the chief and every agent it started, each with its session. Open fleet in a workspace with nothing running and a run to come back to, and it asks:
pick up where you left off
▌ 28m ago · the chief + 2 agents
▌ ENG-2155-fixes, ENG-2155-review, ENG-2155, staging-fix
▌ chief, eng-2155, eng-2155-review
start fresh — a new chief, nothing brought back
Choosing one starts each agent again with claude --resume, in its own repository and its own tab, with its role back in the system prompt and the chief's editing tools still withheld. An agent you took off with x stays off, and one whose conversation is gone from disk is named rather than resumed into an empty session.
~/.claude-fleet/auto-open | directories whose new workspaces open in fleet mode |
FLEET_CLAUDE | the claude to start, by path. Otherwise ~/.local/bin/claude, then whatever is on PATH |
FLEET_DB | the board a command uses. Set for every agent fleet starts |
FLEET_JSON=1 | JSON from fleet board reads |
fleet keeps everything under ~/.claude-fleet: each fleet's board, and the briefs each agent was started with (briefs/), which are what it was told, and the Claude Code plugin that carries the /board skill (claude-plugin/). It reads Claude Code's session registry (~/.claude/sessions) to see who is alive, and sends nothing anywhere.
↵ or a click does nothing for an agent in another tab. herdr's server is older than 0.9.1, whose agent focus moves herdr's focus but not your screen. herdr status shows the server's version; restart herdr to run the updated one.
An agent's tab opened and nothing happened. It is probably at Claude Code's folder-trust dialog, in a repository it has not seen. Answer it in the tab; the fleet view links the session once it has one.
The agent that started is an old Claude Code. A new pane's shell rebuilds PATH, and a Node version manager can put an old npm install of Claude Code first. fleet starts ~/.local/bin/claude by default for this reason; point FLEET_CLAUDE at the one you use.
Anything else: run the doctor action (see Install), and look at herdr plugin log list --plugin fleet.
git clone https://github.com/patrickkunzke/fleet && cd fleet
cargo build --release
herdr plugin link . # herdr runs your working tree
./install.sh # fleet on your PATH
cargo test
herdr plugin link does not build; rebuild after a change, then reopen the view with q and prefix+f. The agents keep running across both.
Every change reaches main through a pull request, merged with a merge commit; nothing is pushed to main directly. Each commit in a pull request is a conventional commit — feat: …, fix(board): …, docs: … — and CI checks that they are (sh scripts/check-commits.sh origin/main does the same locally).
Merging is releasing. When a pull request is merged, the release job reads the commits since the last vX.Y.Z tag and picks the version:
| Commits | Release |
|---|---|
feat!: …, fix!: …, or a BREAKING CHANGE: footer | major (minor before 1.0) |
feat: … | minor |
fix: …, perf: … | patch |
only docs, ci, chore, refactor, test, … | none |
It raises the version in Cargo.toml, Cargo.lock and herdr-plugin.toml, adds the release's section to CHANGELOG.md from the commits (git-cliff, cliff.toml), commits that as chore(release): vX.Y.Z, tags it, and publishes the GitHub Release. fleet update moves users to it.
Then binaries.yml builds fleet for macOS and Linux (static, musl), on arm64 and x86_64, and uploads the four binaries and their SHA256SUMS to the release. That takes a few minutes; an install in between builds from source. To give an existing release its binaries, run the workflow by hand: gh workflow run binaries.yml -f tag=v0.2.1.
For layout work there is a fixture fleet that needs neither herdr nor a real board:
./dev.sh # rebuild and redraw on every save
./dev.sh 140x40 log # a size and a view: graph, log
fleet preview --plain # one frame as text, for a diff
herdr's client code (src/herdr.rs) is adapted from herdr-projects, MIT; see NOTICE. The graph borrows its idea of liveness drawn on the structure itself from zoetrope.
MIT — see LICENSE.
hooks/register.tsx 846 lines1// fleet's mod: messages from the board, delivered by the session itself.
2//
3// Without it, `fleet board msg` types a message into the recipient's herdr
4// pane: one line, cut at 1500 characters, and typed whether or not the agent
5// is in the middle of a turn. With it, this session checks its mailbox on the
6// board every few seconds and, once it is idle, takes what is waiting and
7// submits it as a prompt of its own, whole.
8//
9// The check is also how the board knows the mod is here: a mailbox that
10// stops checking in goes quiet after fifteen seconds, and fleet goes back to
11// typing. So a session without the mod, or one whose mod has died, still
12// gets its messages.
13//
14// It also holds the go-ahead. A worker does not edit until its task has one:
15// `fleet board go` from the chief, or a prompt the person types in the
16// worker's own pane. The chief does not edit at all, Bash included. Both are
17// a guard against an agent drifting into work, not a sandbox: a determined
18// one can write a file in ways no pattern here knows. When the board cannot
19// be asked, the edit goes ahead, as it would without the mod.
20//
21// It gives the session the board as tools: `mcp__fleet__board_start`,
22// `..._msg`, `..._go` and the rest, each running the `fleet board` command of
23// the same name. A tool has a schema to fill in where a shell line has
24// quoting to get wrong, and nothing to mistake for a skill.
25//
26// And in the chief's session it draws the fleet. In herdr the fleet tab sits
27// beside the chief; outside herdr there is no tab, so `/fleet` opens a pane
28// with the crew, the open tasks and the latest messages, a band above the
29// prompt says who needs something, and toasts say what herdr would have
30// notified. It reads `fleet board snapshot` every few seconds; a worker's
31// session stops reading once the board says it is one.
32
33import { atom, read, update } from 'claude-code'
34import type { EngineInterface, Register } from 'claude-code'
35
36import type { GraphSpan, Snapshot } from '../types'
37
38/** How often the mailbox is checked. Well inside the board's fifteen. */
39const EVERY = 3_000
40
41/** What `fleet board inbox` prints. */
42type Inbox = {
43 agent: string | null
44 text: string | null
45 waiting: number
46 /** The task this worker cannot change files for until it has a go. */
47 awaiting_go?: string | null
48}
49
50/** What `fleet board gate` prints. */
51type Gate = {
52 agent: string | null
53 role: string | null
54 tasks: string[]
55 approved: boolean
56}
57
58/** The tools that change files. */
59const EDITS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
60
61/** Where writing is fine even for an agent that may not edit. */
62const SCRATCH = String.raw`(?:/dev/|/tmp/|/private/tmp/|\$TMPDIR|"\$TMPDIR)`
63
64/** A command at the start of a pipeline, a list or a substitution. */
65const AT = String.raw`(?:^|[;&|(]\s*|\$\(\s*|\bxargs\s+)`
66
67/**
68 * Whether a shell command writes files outside scratch space, as far as a
69 * pattern can tell. Quoted text is blanked first, so a message that says
70 * "a -> b" is not a redirect.
71 */
72export function writes(command: string): boolean {
73 const bare = command.replace(/'[^']*'/g, "''").replace(/"(?:[^"\\]|\\.)*"/g, '""')
74 const rules = [
75 // > and >> into a file, not 2>&1 and not into /dev/null or /tmp
76 new RegExp(String.raw`(?<![0-9&])>>?\s*(?!&|\s*${SCRATCH})[^\s&|;)]`),
77 new RegExp(String.raw`[0-9]>>?\s*(?!&|\s*${SCRATCH})[^\s&|;)]`),
78 new RegExp(String.raw`${AT}(?:sed|gsed)\s+(?:[^|;&]*\s)?(?:-[a-zA-Z]*i|--in-place)`),
79 new RegExp(String.raw`${AT}perl\s+(?:-[a-zA-Z]*\s+)*-[a-zA-Z]*i`),
80 new RegExp(String.raw`${AT}tee\s+(?!(?:-a\s+)?${SCRATCH})`),
81 new RegExp(String.raw`${AT}(?:cp|mv|rm|touch|mkdir|rmdir|ln|install|truncate|patch|dd)\b(?![^;&|]*\s${SCRATCH}\S*\s*$)`),
82 new RegExp(String.raw`${AT}git\s+(?:-C\s+\S+\s+)?(?:apply|am|checkout|switch|restore|reset|stash|commit|merge|rebase|cherry-pick|revert|mv|rm|clean|pull)\b`),
83 ]
84 return rules.some(rule => rule.test(bare))
85}
86
87/** One of the board's commands, as a tool the model calls. */
88type BoardTool = {
89 name: string
90 description: string
91 properties: Record<string, unknown>
92 required: string[]
93 /** Only the chief plans, dispatches and gives the go. */
94 isChiefs?: true
95 /** The `fleet` command line, from the tool's input and who calls it. */
96 argv: (input: Record<string, unknown>, me: string) => string[]
97}
98
99const str = (description: string) => ({ type: 'string', description })
100const TASK = str('The task key, such as ENG-2553-1')
101
102/** `--flag value` when the value was given. */
103function opt(flag: string, value: unknown): string[] {
104 return typeof value === 'string' && value !== '' ? [flag, value] : []
105}
106
107const BOARD_TOOLS: BoardTool[] = [
108 {
109 name: 'board_ls',
110 description: 'Every task on the board, with its state, its agent and what it waits on.',
111 properties: { state: str('Only tasks in this state: queued, running, blocked, review, done, dropped'), epic: str('Only this epic'), repo: str('Only tasks whose repository path contains this') },
112 required: [],
113 argv: i => ['board', 'ls', ...opt('--state', i.state), ...opt('--epic', i.epic), ...opt('--repo', i.repo)],
114 },
115 {
116 name: 'board_show',
117 description: 'One task: its brief, and everything that happened to it.',
118 properties: { task: TASK },
119 required: ['task'],
120 argv: i => ['board', 'show', String(i.task)],
121 },
122 {
123 name: 'board_ready',
124 description: 'Queued tasks whose dependencies are all done: what can be dispatched now.',
125 properties: {},
126 required: [],
127 argv: () => ['board', 'ready'],
128 },
129 {
130 name: 'board_start',
131 description: 'Mark a task running: you have picked it up.',
132 properties: { task: TASK },
133 required: ['task'],
134 argv: i => ['board', 'start', String(i.task)],
135 },
136 {
137 name: 'board_block',
138 description: 'Mark a task blocked, with why. Tell the chief as well.',
139 properties: { task: TASK, reason: str('What it is waiting for') },
140 required: ['task', 'reason'],
141 argv: i => ['board', 'block', String(i.task), String(i.reason)],
142 },
143 {
144 name: 'board_unblock',
145 description: 'Carry on with a blocked task.',
146 properties: { task: TASK },
147 required: ['task'],
148 argv: i => ['board', 'unblock', String(i.task)],
149 },
150 {
151 name: 'board_review',
152 description: 'Open a task for review rather than finished.',
153 properties: { task: TASK, mr: str('The merge or pull request URL') },
154 required: ['task'],
155 argv: i => ['board', 'review', String(i.task), ...opt('--mr', i.mr)],
156 },
157 {
158 name: 'board_done',
159 description: 'Finish a task. Says which tasks that freed.',
160 properties: { task: TASK, mr: str('The merge or pull request URL') },
161 required: ['task'],
162 argv: i => ['board', 'done', String(i.task), ...opt('--mr', i.mr)],
163 },
164 {
165 name: 'board_msg',
166 description: 'Send another agent a message, recorded on the board and delivered to them. You are the sender.',
167 properties: { to: str('The agent, by its board name: chief, or a worker'), summary: str('One line'), body: str('The rest, if there is more'), task: TASK },
168 required: ['to', 'summary'],
169 argv: (i, me) => ['board', 'msg', me, String(i.to), String(i.summary), ...opt('--body', i.body), ...opt('--task', i.task)],
170 },
171 {
172 name: 'board_note',
173 description: 'Record something on the board that is not a message to anyone.',
174 properties: { summary: str('One line'), body: str('The rest'), task: TASK },
175 required: ['summary'],
176 argv: i => ['board', 'note', String(i.summary), ...opt('--body', i.body), ...opt('--task', i.task)],
177 },
178 {
179 name: 'board_add',
180 description: 'Queue a task for a repository.',
181 properties: {
182 task: TASK,
183 repo: str('The repository, as an absolute path'),
184 title: str('A short title'),
185 body: str('What a fresh agent in that repository needs in order to start'),
186 epic: str('The epic it belongs to'),
187 deps: { type: 'array', items: { type: 'string' }, description: 'Tasks it waits on' },
188 },
189 required: ['task', 'repo', 'title'],
190 isChiefs: true,
191 argv: i => [
192 'board', 'add', String(i.task), String(i.repo), String(i.title),
193 ...opt('--epic', i.epic), ...opt('--body', i.body),
194 ...(Array.isArray(i.deps) ? i.deps.flatMap(d => ['--dep', String(d)]) : []),
195 ],
196 },
197 {
198 name: 'board_dep',
199 description: 'Record that one task waits on another.',
200 properties: { task: TASK, depends_on: str('The task it waits on') },
201 required: ['task', 'depends_on'],
202 isChiefs: true,
203 argv: i => ['board', 'dep', String(i.task), String(i.depends_on)],
204 },
205 {
206 name: 'board_epic',
207 description: 'Create or retitle an epic.',
208 properties: { key: str('The epic key, such as ENG-2553'), title: str('Its title') },
209 required: ['key', 'title'],
210 isChiefs: true,
211 argv: i => ['board', 'epic', String(i.key), String(i.title)],
212 },
213 {
214 name: 'board_claim',
215 description: 'Assign a task to an agent.',
216 properties: { task: TASK, agent: str('The agent, by its board name') },
217 required: ['task', 'agent'],
218 isChiefs: true,
219 argv: i => ['board', 'claim', String(i.task), String(i.agent)],
220 },
221 {
222 name: 'board_go',
223 description: "Give a worker the go-ahead on its task, and tell it. Until a task has one, its worker cannot change files. Anything short of a go is board_msg.",
224 properties: { task: TASK, message: str('What to send with it; "go" when not given'), body: str('More, if there is more') },
225 required: ['task'],
226 isChiefs: true,
227 argv: i => ['board', 'go', String(i.task), ...(typeof i.message === 'string' && i.message ? [i.message] : []), ...opt('--body', i.body)],
228 },
229 {
230 name: 'board_drop',
231 description: 'Abandon a task.',
232 properties: { task: TASK, reason: str('Why') },
233 required: ['task'],
234 isChiefs: true,
235 argv: i => ['board', 'drop', String(i.task), ...(typeof i.reason === 'string' && i.reason ? [i.reason] : [])],
236 },
237 {
238 name: 'spawn',
239 description: 'Start an agent in a repository, briefed on its task, and claim the task for it.',
240 properties: { name: str('What to call it, usually after the repository'), repo: str('The repository, as an absolute path'), task: TASK },
241 required: ['name', 'repo'],
242 isChiefs: true,
243 argv: i => ['spawn', String(i.name), '--repo', String(i.repo), ...opt('--task', i.task)],
244 },
245 {
246 name: 'handoff',
247 description: "Hand an agent's task to a fresh session of it, for one nearly out of context.",
248 properties: { agent: str('The agent'), note: str('Anything the new session should know that the board does not say'), now: { type: 'boolean', description: 'Close the old session even mid-turn' } },
249 required: ['agent'],
250 isChiefs: true,
251 argv: i => ['handoff', String(i.agent), ...opt('--note', i.note), ...(i.now === true ? ['--now'] : [])],
252 },
253 {
254 name: 'retire',
255 description: "Take an agent off the board once its work is done, and close its tab or background session.",
256 properties: { name: str('The agent') },
257 required: ['name'],
258 isChiefs: true,
259 argv: i => ['board', 'retire', String(i.name)],
260 },
261]
262
263/** What Claude Code calls a tool of this plugin's. */
264const TOOL_PREFIX = 'mcp__fleet__'
265
266/** Run a board tool as the agent `me`, and answer with what fleet said. */
267async function runBoardTool($: EngineInterface, tool: BoardTool, input: Record<string, unknown>, me: string) {
268 try {
269 // spawn and handoff wait for the new session to report.
270 const out = await $.process.run(['fleet', ...tool.argv(input, me)], { timeoutMs: 120_000 })
271 const said = (out.stdout + out.stderr).trim() || 'done'
272 return out.exitCode === 0 ? { result: said } : { isError: true as const, result: said }
273 } catch (err) {
274 return { isError: true as const, result: `fleet could not be run: ${String(err)}` }
275 }
276}
277
278/** Register the board's tools in this session. */
279async function registerTools($: EngineInterface) {
280 for (const t of BOARD_TOOLS) {
281 try {
282 await $.tool.register({ name: t.name, description: t.description, inputSchema: { type: 'object', properties: t.properties, required: t.required } })
283 } catch {
284 // A name already taken: the `fleet board` command still is.
285 }
286 }
287}
288
289/**
290 * Where the `fleet` to start with is, or why there is none. The one that
291 * matches this plugin first, so the mod and the board agree on what they
292 * say to each other: FLEET_BIN when set, for working on fleet itself; then
293 * the copy in this plugin's folder, fetched now if it is not there yet; and
294 * only then whatever fleet is on PATH.
295 */
296async function findFleet($: EngineInterface): Promise<{ path: string } | { problem: string }> {
297 const given = await $.env.get('FLEET_BIN')
298 if (given) return { path: given }
299 const own = `${$.plugin.root}/bin/fleet`
300 if ((await $.process.run(['test', '-x', own])).exitCode === 0) return { path: own }
301 const fetched = await $.process.run(['sh', `${$.plugin.root}/scripts/fetch-fleet.sh`], { timeoutMs: 120_000 })
302 if (fetched.exitCode === 0) return { path: own }
303 const onPath = await $.process.run(['sh', '-c', 'command -v fleet'])
304 if (onPath.exitCode === 0 && onPath.stdout.trim()) return { path: onPath.stdout.trim() }
305 return { problem: (fetched.stderr || fetched.stdout).trim() || 'fleet could not be downloaded' }
306}
307
308/** What `fleet chief --adopt` prints. */
309type Adopted = { board: string; run: number; bin: string | null; brief: string }
310
311/**
312 * A plain `fleet board`, `fleet spawn` or `fleet handoff` command: what a
313 * fleet session runs without being asked. Anything that chains, pipes,
314 * redirects or substitutes is left to the usual prompt.
315 */
316const FLEET_COMMAND = /^\s*fleet\s+(?:board|spawn|handoff)\b[^;&|`$<>\n]*$/
317
318/** A worker reaching for the go-ahead it is waiting for. */
319const SELF_APPROVAL = /\bfleet\s+board\s+(?:go|gate)\b/
320
321/**
322 * Ask the board whether `session` may edit, and with `approve`, give its
323 * task the user's go first. Undefined when the board cannot be asked.
324 */
325async function gate($: EngineInterface, session: string, approve = false): Promise<Gate | undefined> {
326 const argv = ['fleet', 'board', 'gate', '--session', session]
327 if (approve) argv.push('--user-approves')
328 try {
329 const out = await $.process.run(argv, { timeoutMs: 10_000 })
330 return out.exitCode === 0 ? (JSON.parse(out.stdout) as Gate) : undefined
331 } catch {
332 return undefined
333 }
334}
335
336const PANE = 'fleet'
337/** From here an agent is close to compacting: the board's CONTEXT_HIGH. */
338const HIGH = 80
339
340const snapshot = atom({ plugin: 'fleet', key: 'snapshot' } as const, null)
341const brief = atom({ plugin: 'fleet', key: 'brief' } as const, null)
342
343/** The board as `session` sees it; undefined when it cannot be read. */
344async function look($: EngineInterface, session: string): Promise<Snapshot | undefined> {
345 try {
346 const out = await $.process.run(['fleet', 'board', 'snapshot', '--session', session], { timeoutMs: 10_000 })
347 return out.exitCode === 0 ? (JSON.parse(out.stdout) as Snapshot) : undefined
348 } catch {
349 return undefined
350 }
351}
352
353/** A key as `fleet board view-send` names it, with its modifiers. */
354function keyWords(e: { key: string; ctrl: boolean; shift: boolean; meta: boolean }): string[] | undefined {
355 const name = e.key === ' ' ? 'space' : e.key
356 if (!name || /\s/.test(name)) return undefined
357 return ['key', name, ...(e.ctrl ? ['ctrl'] : []), ...(e.shift ? ['shift'] : []), ...(e.meta ? ['alt'] : [])]
358}
359
360/** Send the followed view one message: a key, a click or its size. */
361async function sendView($: EngineInterface, control: string, words: string[]) {
362 try {
363 await $.process.run(['fleet', 'board', 'view-send', '--control', control, ...words], { timeoutMs: 5_000 })
364 } catch {
365 // The view has ended: the next tick starts another.
366 }
367}
368
369/** Give a waiting agent its go, as the chief would with `fleet board go`. */
370async function give($: EngineInterface, task: string) {
371 try {
372 const out = await $.process.run(['fleet', 'board', 'go', task], { timeoutMs: 10_000 })
373 $.ui.toast(out.exitCode === 0 ? `${task}: go given` : `${task}: ${out.stderr.trim() || 'the go did not go through'}`)
374 } catch {
375 $.ui.toast(`${task}: fleet could not be run`)
376 }
377}
378
379/** What needs the user, for the band and its count. */
380function needs(s: Snapshot): string[] {
381 const out: string[] = []
382 for (const a of s.agents) {
383 if (a.waiting_for) out.push(`${a.name} at a ${a.waiting_for}`)
384 else if (a.awaiting_go && a.presence === 'waiting') out.push(`${a.name} needs a go`)
385 if (a.context !== null && a.context >= HIGH) out.push(`${a.name} at ${a.context}%`)
386 }
387 return out
388}
389
390/**
391 * Toast what is new since `seen`, and return what holds now. `seen` is
392 * undefined until the first read, which marks everything already there as
393 * seen: opening the chief must not replay yesterday's blockers.
394 */
395function notify($: EngineInterface, s: Snapshot, seen: Set<string> | undefined): Set<string> {
396 const now = new Set<string>()
397 const said: string[] = []
398 for (const e of s.events) {
399 if (!e.notice) continue
400 now.add(`event:${e.key}`)
401 if (seen && !seen.has(`event:${e.key}`)) said.push(`${e.notice.title} — ${e.notice.body}`)
402 }
403 for (const a of s.agents) {
404 const marks: [string, string][] = []
405 if (a.waiting_for) marks.push([`ask:${a.name}:${a.waiting_for}`, `fleet · ${a.name} is waiting at a ${a.waiting_for}`])
406 if (a.awaiting_go && a.presence === 'waiting') {
407 marks.push([`go:${a.name}:${a.awaiting_go}`, `fleet · ${a.name} needs a go on ${a.awaiting_go}`])
408 }
409 if (a.context !== null && a.context >= HIGH) {
410 marks.push([`full:${a.name}`, `fleet · ${a.name} is at ${a.context}% context: \`fleet handoff ${a.name}\``])
411 }
412 for (const [mark, text] of marks) {
413 now.add(mark)
414 if (seen && !seen.has(mark)) said.push(text)
415 }
416 }
417 for (const text of said) $.ui.toast(text)
418 // Only what still holds, so a later wait or climb is news again.
419 return now
420}
421
422export const register: Register = on => {
423 // The session's id, once it has started.
424 let session: string | undefined
425 // Whether it is part of a fleet: started by one (FLEET_DB is set), or made
426 // its chief with /fleet start. Until then the plugin does nothing at all.
427 let active = false
428
429 // The view's own: see the header.
430 // Set once the board says this session is a worker: it draws nothing.
431 let isWorker = false
432 let looking = false
433 // The fleet view behind the pane: `fleet board view --follow`, started
434 // when the pane first asks for a frame and stopped when it closes. Its
435 // newest frame, numbered, and the number the pane last got.
436 let view: { control: string; size: { w: number; h: number } } | undefined
437 let frame: GraphSpan[][] = []
438 let frameNumber = 0
439 let shownNumber = -1
440 // What has been toasted already: see notify.
441 let seen: Set<string> | undefined
442
443
444 // A turn is running. Messages wait on the board until it ends, rather than
445 // queueing up as prompts behind it.
446 let busy = false
447 // A prompt was submitted and its turn has not started yet. Taking more now
448 // would stack a second prompt behind the first.
449 let pending = false
450 // A check is still running. The timer does not wait for one to finish.
451 let checking = false
452 // The main loop's tools running now, newest last: what the fleet view
453 // shows the agent doing. Several run at once when Claude calls them in
454 // parallel.
455 const running: string[] = []
456
457 on('session.start', async ($, e, next) => {
458 if (!e.isInteractive) return next(e)
459 const id = await $.session.id()
460 session = id
461 // /fleet, in every session with the plugin: in one fleet started it
462 // shows the fleet, in any other `/fleet start` makes it the chief.
463 try {
464 await $.command.register({ name: 'fleet', description: 'Show the fleet, or `/fleet start` to make this session its chief', argumentHint: '[start]' })
465 } catch {
466 // A command of that name already: the band and toasts still work.
467 }
468
469 // A session fleet started has a board from the start. Any other waits
470 // for /fleet start, and the timers below do nothing until then.
471 active = Boolean(await $.env.get('FLEET_DB'))
472 // The board as tools. Which ones the chief alone may use is settled when
473 // they are called: a session is put on the board, and so has a role,
474 // only after it starts.
475 if (active) await registerTools($)
476
477 const check = async () => {
478 if (!active || checking) return
479 checking = true
480 try {
481 const take = !busy && !pending
482 const argv = ['fleet', 'board', 'inbox', '--session', id]
483 if (take) argv.push('--take')
484 const tool = running.at(-1)
485 if (tool) argv.push('--tool', tool)
486 try {
487 const { context } = await $.session.usage()
488 if (context.percent !== undefined) argv.push('--context', String(Math.round(context.percent)))
489 } catch {
490 // No reading yet, before the first answer: nothing to say.
491 }
492 const out = await $.process.run(argv, { timeoutMs: 10_000 })
493 if (out.exitCode !== 0) return
494 const inbox = JSON.parse(out.stdout) as Inbox
495 if (inbox.text) {
496 pending = true
497 // Not awaited: it resolves when the turn starts, and the mailbox
498 // has to keep checking in meanwhile or the board stops trusting it.
499 $.prompt.submit({ text: inbox.text }).then(
500 () => { pending = false },
501 () => { pending = false },
502 )
503 }
504 const parts: string[] = []
505 if (inbox.awaiting_go) parts.push(`waiting for a go on ${inbox.awaiting_go}`)
506 if (inbox.waiting > 0) {
507 parts.push(`${inbox.waiting} ${inbox.waiting === 1 ? 'message' : 'messages'} waiting for this turn to end`)
508 }
509 $.ui.status(parts.length ? parts.join(' · ') : undefined)
510 } catch {
511 // fleet not on PATH, a board locked past the timeout, output that is
512 // not JSON: the next check tries again, and while none succeeds the
513 // board falls back to typing.
514 } finally {
515 checking = false
516 }
517 }
518
519 $.clock.every(EVERY, () => { void check() })
520 void check()
521
522 // The chief's view, in every fleet session, since which one is the chief
523 // is only known once the board has linked it.
524 const watch = $.clock.every(EVERY, async () => {
525 if (!active || isWorker || looking) return
526 looking = true
527 try {
528 const s = await look($, id)
529 if (!s) return
530 if (s.me?.role === 'worker') {
531 isWorker = true
532 watch.cancel()
533 return
534 }
535 // Not linked yet: a session is put on the board a few seconds after
536 // it starts.
537 if (s.me?.role !== 'chief') return
538 await update($, snapshot, () => s)
539 seen = notify($, s, seen)
540
541 } finally {
542 looking = false
543 }
544 })
545 return next(e)
546 })
547
548 // The person typing in this pane is the user's own go-ahead. Three things
549 // arrive the same way and are not: the brief fleet starts an agent with,
550 // given on the command line, which is always a new session's first prompt
551 // (a resumed one has turns already); a message fleet typed here because
552 // the mod was not checking in; and a slash command.
553 on('prompt.submit', async ($, e, next) => {
554 const typed = e.origin?.kind === 'composer' || e.origin?.kind === 'bridge'
555 const text = e.text.trimStart()
556 if (
557 active && session && typed && !text.startsWith('[fleet ·') && !text.startsWith('/') &&
558 (await $.session.turns()) > 0
559 ) {
560 await gate($, session, true)
561 }
562 return next(e)
563 })
564
565 on('tool.call', async ($, e, next) => {
566 // The main loop's tool, not a subagent's: those run under the main
567 // loop's Agent call, which is what the fleet view says it is doing.
568 const main = session !== undefined && e.agentId === undefined
569 if (main) running.push(e.tool)
570 try {
571 const boardTool = active && session && e.tool.startsWith(TOOL_PREFIX)
572 ? BOARD_TOOLS.find(t => TOOL_PREFIX + t.name === e.tool)
573 : undefined
574 if (boardTool && session) {
575 const g = await gate($, session)
576 if (!g?.agent) return { isError: true as const, result: 'fleet: this session is not on the board yet; try again in a moment' }
577 if (g.role === 'retired') return { deny: `fleet: this session of ${g.agent} was retired, and changes nothing more.` }
578 if (boardTool.isChiefs && g.role !== 'chief') {
579 return { deny: `fleet: ${boardTool.name} is the chief's. Ask the chief with board_msg.` }
580 }
581 return await runBoardTool($, boardTool, e as unknown as Record<string, unknown>, g.agent)
582 }
583 const edit = EDITS.has(e.tool)
584 const command = e.tool === 'Bash' && typeof e.command === 'string' ? e.command : undefined
585 if (!active || !session || (!edit && command === undefined)) return await next(e)
586 if (command !== undefined && !writes(command) && !SELF_APPROVAL.test(command)) return await next(e)
587
588 const g = await gate($, session)
589 if (!g?.agent) return await next(e)
590
591 // Retired, or handed off to a fresh session that carries on: what
592 // this one changes now, the other does not know about.
593 if (g.role === 'retired') {
594 return {
595 deny:
596 `fleet: this session of ${g.agent} was retired, and changes nothing more. ` +
597 'If its task was handed off, a fresh session is carrying on with it.',
598 }
599 }
600
601 if (g.role === 'chief') {
602 if (edit || (command !== undefined && writes(command))) {
603 return {
604 deny:
605 'fleet: the chief of staff does not change files, through Bash either. ' +
606 'Put the work on the board and give it to the agent for that repository.',
607 }
608 }
609 return await next(e)
610 }
611
612 if (command !== undefined && SELF_APPROVAL.test(command)) {
613 return { deny: `fleet: a go-ahead comes from the chief or the user, not from ${g.agent}.` }
614 }
615 if (g.approved) return await next(e)
616 const task = g.tasks.join(', ')
617 return {
618 deny:
619 `fleet: ${task} has no go-ahead yet, so ${g.agent} does not change files. ` +
620 `Send the chief your plan with \`fleet board msg ${g.agent} chief '...'\` and wait for its go, ` +
621 'or for the user to answer you here.',
622 }
623 } finally {
624 if (main) running.splice(running.lastIndexOf(e.tool), 1)
625 }
626 })
627
628 // A chief made with /fleet start has its brief as a turn, and compaction
629 // summarises turns: put the brief back at the head of what is kept. A
630 // chief fleet started has it in its system prompt, which compaction
631 // leaves alone, so it has nothing here to put back.
632 on('session.compact', async ($, e, next) => {
633 const compacted = await next(e)
634 const kept = await read($, brief)
635 if (!active || !kept || e.agentId !== undefined || !('messages' in compacted) || !compacted.messages) {
636 return compacted
637 }
638 const reminder = {
639 role: 'user' as const,
640 text: `[fleet] You are this workspace's chief of staff. Your brief, kept through compaction:\n\n${kept}`,
641 toolUses: [],
642 }
643 return { ...compacted, messages: [reminder, ...compacted.messages] }
644 })
645
646 // fleet's own tools and plain fleet commands need no prompt in a fleet
647 // session: an agent started by fleet has them allowed on its command
648 // line, and a session made the chief with /fleet start has this.
649 on('tool.check', ($, e, next) => {
650 if (!active) return next(e)
651 if (e.tool.startsWith(TOOL_PREFIX)) return { decision: 'allow' as const }
652 const command = (e.input as { command?: unknown } | null)?.command
653 if (e.tool === 'Bash' && typeof command === 'string' && FLEET_COMMAND.test(command)) {
654 return { decision: 'allow' as const }
655 }
656 return next(e)
657 })
658
659 on('turn.start', ($, e, next) => {
660 busy = true
661 pending = false
662 return next(e)
663 })
664
665 on('turn.complete', async ($, e, next) => {
666 const done = await next(e)
667 // A subagent's run ends with a turn.complete of its own, in the middle
668 // of the main loop's turn; only the main loop's says the session is idle.
669 if (e.agentId === undefined) busy = false
670 return done
671 })
672
673 on('command.run', { command: 'fleet' }, async ($, e) => {
674 if (e.args.trim() === 'start') {
675 if (active) return { text: 'This session is part of a fleet already. /fleet shows it.' }
676 if (!session) return { text: 'This session has not started yet.' }
677 const root = await $.session.root()
678 let adopted: Adopted
679 try {
680 const found = await findFleet($)
681 if ('problem' in found) return { text: `${found.problem}\nInstall fleet another way (see its README), then run /fleet start again.` }
682 const out = await $.process.run([found.path, 'chief', '--adopt', session, '--root', root], { timeoutMs: 20_000 })
683 if (out.exitCode !== 0) return { text: `fleet: ${(out.stderr || out.stdout).trim()}` }
684 adopted = JSON.parse(out.stdout) as Adopted
685 } catch (err) {
686 return { text: `fleet could not be started: ${String(err)}` }
687 }
688 // What a session fleet starts is given on its command line: the board,
689 // the run, and fleet itself on PATH, for this session's processes and
690 // the agents it starts.
691 await $.env.set('FLEET_DB', adopted.board)
692 await $.env.set('FLEET_RUN', String(adopted.run))
693 if (adopted.bin) await $.env.set('PATH', `${adopted.bin}:${(await $.env.get('PATH')) ?? ''}`)
694 await registerTools($)
695 active = true
696 // The chief's brief, as a turn of its own: Claude Code keeps a mod
697 // installed by a user out of the system prompt. From a timer, since a
698 // command cannot wait on a turn while it holds this one.
699 $.clock.after(1, () => { void $.prompt.submit({ text: adopted.brief }) })
700 // Kept for compaction, which summarises a turn like any other.
701 await update($, brief, () => adopted.brief)
702 return { text: `This session is the chief of ${root} now. Its brief follows; /fleet shows the crew.` }
703 }
704 if (!active) return { text: 'This session is not part of a fleet. `/fleet start` makes it the chief of this workspace.' }
705 if (isWorker) return { text: "The fleet is drawn in the chief's session." }
706 const opened = await $.ui.open({ id: PANE, title: 'fleet' })
707 return opened.isPlaced ? {} : { text: 'Widen the terminal to see the fleet pane.' }
708 })
709
710 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
711 const { Box, Button, Client, Text } = $.ui.resolve(e)
712 const s = await read($, snapshot)
713 if (!s) return <Text dimColor>Reading the board…</Text>
714 const waiting = s.agents.filter(a => a.awaiting_go && a.presence === 'waiting')
715 // The fleet tab, live: fleet draws it, the Client shows it and takes its
716 // keys. Beneath it, while a worker waits, the go the tab gives by keys.
717 // The rows the pane shows, not the terminal's: the prompt and the
718 // status take the bottom of the screen.
719 const shown = e.props.scroll?.bodyRows ?? (e.viewport?.rows ?? 30) - 8
720 const rows = Math.max(10, shown - (waiting.length > 0 ? 1 : 0))
721 return (
722 <Box flexDirection="column" width={Math.max(40, e.props.bodyColumns ?? 80)}>
723 <Client key="fleet-view" module="./view.tsx" props={{ lines: frame }} width="100%" height={rows} />
724 {waiting.length > 0 && (
725 <Box flexDirection="row" gap={1}>
726 <Text> Waiting for a go:</Text>
727 {waiting.map(a => (
728 <Button key={`go-${a.name}`} label={`go ${a.awaiting_go}`} onPress={() => give($, a.awaiting_go ?? '')} />
729 ))}
730 </Box>
731 )}
732 </Box>
733 )
734 })
735
736 // What the pane's view posts: a tick asking for a newer frame, with its
737 // size; a key; a click.
738 on('ui.message', async ($, e, next) => {
739 const data = e.data as { t?: string; w?: number; h?: number; key?: string; ctrl?: boolean; shift?: boolean; meta?: boolean; x?: number; y?: number }
740 if (!active || !session) return next(e)
741 if (data.t === 'tick' && typeof data.w === 'number' && typeof data.h === 'number' && data.w > 0 && data.h > 0) {
742 const size = { w: data.w, h: data.h }
743 if (!view) {
744 const home = (await $.env.get('HOME')) ?? '/tmp'
745 // Short: a socket's path has a hundred characters or so to fit in.
746 const control = `${home}/.claude-fleet/views/${session.slice(0, 8)}.sock`
747 const root = await $.session.root()
748 view = { control, size }
749 const argv = ['fleet', 'board', 'view', '--root', root, '--width', String(size.w), '--height', String(size.h), '--follow', '--control', control]
750 void (async () => {
751 let pending = ''
752 try {
753 for await (const piece of $.process.spawn({ argv })) {
754 if (piece.stream !== 'stdout') continue
755 pending += piece.text
756 let end = pending.indexOf('\n')
757 while (end >= 0) {
758 const line = pending.slice(0, end)
759 pending = pending.slice(end + 1)
760 try {
761 frame = (JSON.parse(line) as { lines: GraphSpan[][] }).lines
762 frameNumber += 1
763 } catch {
764 // Not a frame: nothing to draw.
765 }
766 end = pending.indexOf('\n')
767 }
768 }
769 // It ended by itself: the view was quit, as `q` quits the fleet
770 // tab. The pane goes with it.
771 view = undefined
772 await $.ui.close({ id: PANE })
773 } catch {
774 // It could not start, or it died: the next tick starts another.
775 } finally {
776 view = undefined
777 }
778 })()
779 } else if (view.size.w !== size.w || view.size.h !== size.h) {
780 view.size = size
781 await sendView($, view.control, ['resize', String(size.w), String(size.h)])
782 }
783 if (frameNumber !== shownNumber) {
784 shownNumber = frameNumber
785 return { props: { lines: frame } }
786 }
787 return {}
788 }
789 if (!view) return {}
790 if (data.t === 'key' && typeof data.key === 'string') {
791 const words = keyWords({ key: data.key, ctrl: data.ctrl === true, shift: data.shift === true, meta: data.meta === true })
792 if (words) await sendView($, view.control, words)
793 return {}
794 }
795 if (data.t === 'click' && typeof data.x === 'number' && typeof data.y === 'number') {
796 await sendView($, view.control, ['click', String(data.x), String(data.y)])
797 return {}
798 }
799 return next(e)
800 })
801
802 // The pane closed: the view behind it has no one to draw for.
803 on('ui.close', async ($, e, next) => {
804 const closed = await next(e)
805 if (view && (e as { id?: string }).id === PANE) {
806 await sendView($, view.control, ['quit'])
807 frame = []
808 shownNumber = -1
809 }
810 return closed
811 })
812
813 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
814 if (isWorker || e.props.hasSurvey) return next(e)
815 const s = await read($, snapshot)
816 const crew = s?.agents.filter(a => a.role === 'worker') ?? []
817 if (!s || crew.length === 0) return next(e)
818 const { Box, Text } = $.ui.resolve(e)
819 const want = needs(s)
820 const working = crew.filter(a => a.presence === 'working').length
821 const idle = crew.filter(a => a.presence === 'waiting').length
822 const open = s.tasks.length
823
824 // A card: the fleet's numbers, and under them, only when there is any,
825 // what needs the user. The border turns yellow with it. As wide as the
826 // band, not its text.
827 return (
828 <Box flexDirection="column" borderStyle="round" borderColor={want.length > 0 ? 'yellow' : 'gray'} paddingX={1} width={e.props.bodyColumns}>
829 <Box flexDirection="row" columnGap={3}>
830 <Text bold>fleet</Text>
831 <Text>{`${crew.length} agent${crew.length === 1 ? '' : 's'}`}</Text>
832 <Text><Text color="green">●</Text>{` ${working} working`}</Text>
833 <Text><Text dimColor>○</Text>{` ${idle} idle`}</Text>
834 <Text>{`${open} open`}</Text>
835 <Text dimColor>/fleet</Text>
836 </Box>
837 {want.length > 0 && (
838 <Box flexDirection="row" flexWrap="wrap" columnGap={3}>
839 {want.map(w => <Text color="yellow">{`▲ ${w}`}</Text>)}
840 </Box>
841 )}
842 </Box>
843 )
844 })
845}
846hooks/view.tsx 45 lines1// The fleet view in the chief's /fleet pane: the frames fleet draws, and the
2// keys and clicks that go back to it.
3//
4// A surface module: it runs where the pane is drawn, with no `$`. It draws
5// the newest frame its props carry, asks the hooks module for a newer one on
6// its frame clock, and posts every key pressed while it has the focus and
7// every click, with its own size, so the view behind it is drawn to fit.
8
9import type { ClientModule } from 'claude-code'
10
11import type { GraphSpan } from '../types'
12
13type Props = { lines: GraphSpan[][] }
14type State = { isStarted: true }
15
16/** How often to ask for a newer frame: the view's own animation rate. */
17const TICK_MS = 100
18
19const FleetView: ClientModule<Props, State> = (props, surface) => {
20 const { Box, Text } = surface.elements
21 if (surface.state === undefined) {
22 surface.every(TICK_MS, () => surface.post({ t: 'tick', w: surface.columns, h: surface.rows }))
23 surface.onKey(e => surface.post({ t: 'key', key: e.key, ctrl: e.ctrl === true, shift: e.shift === true, meta: e.meta === true }))
24 surface.onPointer(e => {
25 if (e.type === 'down' && e.button === 'left') surface.post({ t: 'click', x: e.x, y: e.y })
26 })
27 surface.setState({ isStarted: true })
28 }
29 const lines = props.lines ?? []
30 if (lines.length === 0) return <Text dimColor>Drawing the fleet…</Text>
31 return (
32 <Box flexDirection="column">
33 {lines.map((line, i) => (
34 <Text key={`l${i}`} wrap="truncate-end">
35 {line.length === 0 ? ' ' : line.map(span => (
36 <Text color={span.fg} bold={span.bold} dimColor={span.dim}>{span.t}</Text>
37 ))}
38 </Text>
39 ))}
40 </Box>
41 )
42}
43
44export default FleetView
45types/index.d.ts 53 lines1// What `fleet board snapshot` prints: the fleet as the chief's session draws it.
2
3export type SnapAgent = {
4 name: string
5 role: 'chief' | 'worker'
6 presence: 'working' | 'waiting' | 'gone' | 'unlinked'
7 waiting_for: string | null
8 tool: string | null
9 context: number | null
10 task: string | null
11 awaiting_go: string | null
12 target: string | null
13}
14
15export type SnapTask = {
16 key: string
17 title: string
18 state: string
19 agent: string | null
20 note: string | null
21}
22
23export type SnapEvent = {
24 key: string
25 kind: string
26 from: string | null
27 to: string | null
28 task: string | null
29 summary: string
30 notice: { title: string; body: string } | null
31}
32
33export type Snapshot = {
34 me: { name: string; role: string } | null
35 agents: SnapAgent[]
36 tasks: SnapTask[]
37 events: SnapEvent[]
38}
39
40/** One run of cells in one style, as `fleet board graph` draws them. */
41export type GraphSpan = { t: string; fg?: string; bold?: boolean; dim?: boolean }
42
43declare module 'claude-code' {
44 interface PluginState {
45 fleet: {
46 snapshot: Snapshot | null
47 /** The brief a session made the chief with /fleet start received as a
48 * turn, kept to put back after compaction. */
49 brief: string | null
50 }
51 }
52}
53