Split each task across a fleet of subagents you configure (planner, workers in waves, reviewer, designer), with progress, budgets, run folders, history and…

A Claude Code plugin that splits each task you give Claude across a fleet of subagents you configure — a lead planner, workers on the models you choose, a reviewer and a designer — and shows you what every one of them is doing.
Version 0.7.0 · © 2026 Belsis Meletis · free to use, see Licence and disclaimer. This is a test plugin: try it on work you can afford to redo.
you type a task
│
▼
Planner ──── thinks it through, names the deliverable, writes one brief per worker,
│ marks which jobs need others first ("## Job 3 … (after 1, 2)")
▼
Workers ──── run in parallel, wave by wave, each on its slot's model,
│ each writing its result to the run folder
▼
Claude ───── combines the results
▼
Reviewer ─── checks facts and consistency, corrects the draft (optional)
▼
Designer ─── turns it into finished files: .docx/.pdf, .pptx, a better (optional)
web page, charts — on your laptop only
Every stage is optional except the workers, and each lead agent (planner, reviewer, designer) has its own model.
The fleet pane: the lead agents (planner, reviewer, designer) each with a model, the workers and their models, and the options, beside /fleet help:

A request starts: the planner (violet) is splitting "a 2-page PDF on ISO 27001 change management" into jobs. The band above the prompt shows the stage, time and cost against the $8 budget:

The planner has finished and two workers run in parallel — the planner chose two jobs for this task — each on its own slot's model (sonnet and haiku), with its current step and the job it was given:

Both workers are done, Claude has combined their drafts, and the designer (pink) is laying the result out as a PDF; here in the slate colour scheme:

What the designer produced — a two-page practitioner guide, built on the laptop and saved in the run folder's design/, nothing uploaded:

/fleet 4 sonnet sonnet opus inherit. Agents launched together always get distinct slots, so slot N's model is really used for one of them.## Job 3: UI (after 1, 2) — and the fleet runs such jobs in waves, refusing to start one before the jobs it needs have finished and handing it their result files./fleet auto on): the planner chooses between 1 and N workers per task instead of always N..docx and .pdf for a report; a .pptx for slides (one message per slide, charts for numbers, speaker notes); browser-checked fixes to layout, spacing, contrast and accessibility for a web page; charts for data. It writes into the run folder's design/ with a CHANGES.md, never adds facts, never overwrites the original, and uploads nothing: tools that would publish or send work off the laptop are refused for it. It is also the most thorough stage, so it can be the most expensive; /fleet designer sonnet keeps it cheaper.~/.claude/fleet-runs/<project>/<date>-<title>/ with the request, the plan, each worker's result (job-N.md), the combined result, the designer's design/ and a summary.md./fleet): the plan and its settings, then each request with its agents — role, model, completion, time, tool calls, tokens, current step and the task each was given. The planner (violet), reviewer (blue) and designer (pink) are coloured apart from the workers./fleet stop./fleet notify on | off | sound | banner./fleet history (this project) or /fleet history all, and a history section in the pane with an open button for each run folder./fleet use off switches the fleet off in one project only.b or /fleet theme), navy by default.A pause takes effect at the agent's next tool call — not immediately. Claude Code has no way to freeze an agent in the middle of its thinking, so the fleet waits for the next point where nothing is half-done: when the agent next tries to read a file, search, run a command or write, that call is refused and the agent is stopped there. An agent that is only thinking or writing a long answer keeps going until it reaches that point — usually seconds, sometimes longer. Meanwhile its row says pausing.
x), or every running agent with /fleet pause. Resume the same ways (/fleet resume).Agents fill the transcript with completion notices and reports. /fleet messages (or the pane's messages button, v) chooses how much of that you see:
| Mode | What you see |
|---|---|
full (default) | everything, as Claude Code shows it |
compact | each agent notice or report folded into one dim line — who, what happened, its first words — ▸ agent completed · 2m31s — … (ctrl+o for all) |
quiet | as compact, and Claude keeps its own progress updates to one short line while agents run; the routine "finished" line is dropped (the chime and banner still come) |
Press ctrl+o to read any folded row in full. Only what you see changes: Claude still reads every report in full, so the quality of the work is unaffected.
/fleet budget 5 sets a spending limit per request in US dollars; /fleet budget warn (default) or /fleet budget stop chooses what happens at the limit; /fleet budget off removes it.
/cost shows, counted from the moment you send the request — the main conversation's share included.With /fleet worktrees on, every worker edits its own git worktree on its own branch, so parallel workers never overwrite each other. The rules are built to never lose or overwrite code:
~/.claude/fleet-worktrees/<repo>/), on branches fleet/<request>/job-N, all cut from the commit you were on when the request started./fleet merge (or Merge finished in the pane) merges the finished branches into the base branch one at a time, in job order, with --no-ff. It refuses to start if your checkout has uncommitted changes, a merge is in progress, a worker is still running, or a different branch is checked out. On the first conflict it runs git merge --abort, so your checkout is exactly as before that branch, names the conflicting files, and stops. Resolve with git merge <branch> yourself, then run /fleet merge again to continue.git branch -d, only after the branch is confirmed merged. Nothing is ever forced./fleet worktrees lists them, and /fleet detach N removes a worktree folder while keeping its branch.claude --version; update with claude update.Inside any Claude Code session:
/plugin install agent-fleet --marketplace mbelsis/Claude-Fleet
Answer y to add the marketplace, then choose user scope so it loads in every project. It is active straight away.
The repository is private, so the laptop must be able to clone it: signed in to GitHub with access to mbelsis/Claude-Fleet (for example through gh auth login, a credential manager, or an SSH key). If the install says the marketplace cannot be fetched, check access with git clone https://github.com/mbelsis/Claude-Fleet.git first.
git clone https://github.com/mbelsis/Claude-Fleet.git "claude fleet", or copy it. claude plugin marketplace add "/path/to/claude fleet"
claude plugin install agent-fleet@belsis-plugins --scope user
/fleet.Claude Code reads the plugin straight from that folder: after you edit or git pull it, run /reload-plugins in a session. No reinstall is needed.
The plan starts off. A sensible start:
/fleet 3 sonnet sonnet opus # three workers and their models (also turns the plan on)
/fleet planner opus # lead planner on the newest Opus
/fleet reviewer on # check facts before you see the result
/fleet budget 5 # warn when a request passes $5
/fleet messages compact # fold the agents' messages
Settings are saved per user and follow you to every project. /fleet use off turns the fleet off in one project; /fleet off everywhere.
claude plugin list # shows agent-fleet and the folder it is read from
claude plugin update agent-fleet@belsis-plugins # for a GitHub install; a local folder just needs /reload-plugins
claude plugin uninstall agent-fleet@belsis-plugins
claude plugin marketplace remove belsis-plugins
Uninstalling leaves your run folders (~/.claude/fleet-runs/) and any fleet worktrees (~/.claude/fleet-worktrees/) in place. Merge or detach worktrees first (/fleet worktrees, /fleet merge).
/fleet is not recognised: the plugin is not loaded. Run claude plugin list; if it is missing, repeat the install; if it is listed, start a new session. claude --debug shows why a plugin did not load./theme and pick the matching one, or change the pane with /fleet theme./fleet worktrees off, or fix the repository./fleet resume wakes it./fleet opens the pane; /fleet help lists:
/fleet N model… N worker slots and their models, e.g. /fleet 4 sonnet sonnet opus
/fleet on | off apply the plan to your requests, or not (everywhere)
/fleet use on | off switch the fleet on or off for this project only
/fleet planner on|off|M lead planner, and its model (opus, fable, sonnet, inherit)
/fleet reviewer on|off|M reviewer that checks the combined result last
/fleet designer on|off|M designer that polishes the result into files (uploads nothing)
/fleet auto on|off let the planner choose 1–N workers per task
/fleet files on|off workers write results to files in a run folder
/fleet budget N | off spending limit per request, in US dollars
/fleet budget warn|stop what happens at the limit
/fleet worktrees on|off each worker edits its own git worktree and branch
/fleet worktrees list fleet worktrees and what is still unmerged
/fleet merge merge finished worktrees into the base branch, stop on conflict
/fleet detach N remove job N's worktree but keep its branch
/fleet notify on|off|sound|banner announce finished requests (30 s or longer)
/fleet messages full|compact|quiet how much of the agents' messages the transcript shows
/fleet history [all] past requests here (or everywhere) and their run folders
/fleet theme NAME pane colours: default, dark, navy, slate, forest, light, paper
/fleet pause | resume pause running agents at their next tool call; resume them
/fleet stop stop every running fleet agent
/fleet clear remove finished agents and requests
/fleet help show this list
Pane keys: t plan · u this project · p/o planner · r/e reviewer · d/n designer · f/m fewer/more workers · a count · 1–9 agent model · l files · w worktrees · v messages · b colours · x pause or resume all · s stop all · c clear · y history · g merge · h help.
claude plugin validate . # what the module hooks and calls, and anything the engine would refuse
claude plugin test . # runs tests/*.test.ts against the engine
How it is built, how each feature works inside, and how to extend it: docs/TECHNICAL-GUIDE.md.
hooks/, types/, tests/, fx/, .claude-plugin/ — the plugin (see the technical guide).docs/TECHNICAL-GUIDE.md — architecture, design decisions and how to extend the fleet.docs/GRC-Tool-Specification.md — a product specification for a GRC tool produced by a fleet run (planner, four workers, reviewer): 428 requirements, every Must with acceptance criteria.Screenshots/ — the images above.Copyright © 2026 Belsis Meletis.
You may use this plugin free of charge, for any purpose, and copy, change or customise it as you want.
This is a test plugin. It is provided as is, with no warranty of any kind. The creator accepts no responsibility for any action, loss or damage that results from installing, using or customising it, including lost or overwritten code, model usage charges, or anything an agent does while it runs. Use it at your own risk.
The full terms are in LICENSE.
hooks/register.tsx 2186 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type {
5 FleetHistoryEntry,
6 FleetLead,
7 FleetPeek,
8 FleetPlan,
9 FleetRequest,
10 FleetRole,
11 FleetRun,
12 FleetStatus,
13 FleetWorktree,
14} from '../types'
15import {
16 bar,
17 budgetLevel,
18 elapsedText,
19 fileNote,
20 fit,
21 HELP_LINES,
22 isFileHandoffOn,
23 isInside,
24 isModel,
25 isReplaced,
26 isWorktreesOn,
27 jobOfPrompt,
28 jobsOf,
29 LEAD_MODELS,
30 MAX_AGENTS,
31 modelLabel,
32 nextLeadModel,
33 nextModel,
34 NO_STEPS,
35 parsePlan,
36 percentOf,
37 phaseOf,
38 PLANNER_PROMPT,
39 PLANNER_TYPE,
40 plannerOf,
41 planText,
42 PROGRESS_NOTE,
43 PROGRESS_SPEC,
44 PROGRESS_TOOL,
45 progressFrom,
46 promptReminder,
47 requestPercent,
48 REVIEWER_PROMPT,
49 REVIEWER_TYPE,
50 reviewerOf,
51 shortModel,
52 slotLines,
53 slugOf,
54 stepsAfter,
55 taskOf,
56 THEME_KEYS,
57 DEFAULT_THEME,
58 AGENT_ORIGINS,
59 compactLine,
60 COPYRIGHT,
61 QUIET_SECTION,
62 VERSION,
63 ROLE_COLOR,
64 titleOf,
65 DESIGNER_PROMPT,
66 DESIGNER_TYPE,
67 deliverableOf,
68 designerGuard,
69 designerOf,
70 THEMES,
71 themeOf,
72 tokensText,
73 usdText,
74 wavesOf,
75 worktreeGuard,
76 worktreeNote,
77} from './lib'
78import type { Phase } from './lib'
79
80// The pure logic lives in ./lib; tests and other plugins import it from here too.
81export * from './lib'
82
83const PANE = 'agent-fleet'
84/** A space the renderer keeps at the edge of a Text, where a plain one is dropped. */
85const NB = '\u00a0'
86const KEPT_RUNS = 80
87const KEPT_REQUESTS = 6
88/** How long the status band stays up after a request finished. */
89const BAND_LINGER_MS = 2 * 60 * 1000
90
91const plan = atom({ plugin: 'agent-fleet', key: 'plan' } as const, {
92 isEnabled: false,
93 models: ['inherit', 'inherit'],
94})
95const runs = atom({ plugin: 'agent-fleet', key: 'runs' } as const, [])
96const now = atom({ plugin: 'agent-fleet', key: 'now' } as const, 0)
97const requests = atom({ plugin: 'agent-fleet', key: 'requests' } as const, [])
98const isBandHidden = atom({ plugin: 'agent-fleet', key: 'isBandHidden' } as const, false)
99const isHelpOpen = atom({ plugin: 'agent-fleet', key: 'isHelpOpen' } as const, false)
100const isProjectOff = atom({ plugin: 'agent-fleet', key: 'isProjectOff' } as const, false)
101const worktrees = atom({ plugin: 'agent-fleet', key: 'worktrees' } as const, [])
102const peek = atom({ plugin: 'agent-fleet', key: 'peek' } as const, null)
103const history = atom({ plugin: 'agent-fleet', key: 'history' } as const, [])
104const isHistoryOpen = atom({ plugin: 'agent-fleet', key: 'isHistoryOpen' } as const, false)
105/** Finished requests kept across sessions. */
106const KEPT_HISTORY = 200
107/** Requests shorter than this finish without a sound or banner. */
108const NOTIFY_AFTER_MS = 30_000
109
110const GLYPH = { running: '●', paused: '⏸', done: '✓', failed: '✗', stopped: '■' } as const
111// Theme keys, so status colours follow the person's light or dark theme.
112const STATUS_COLOR = {
113 running: 'claude',
114 paused: 'warning',
115 done: 'success',
116 failed: 'error',
117 stopped: 'warning',
118} as const
119const PHASE_LABEL: Record<Phase, string> = {
120 planning: 'Planning',
121 working: 'Workers running',
122 paused: 'Paused',
123 combining: 'Combining results',
124 reviewing: 'Reviewing',
125 designing: 'Designing',
126 done: 'Done',
127 stopped: 'Stopped',
128}
129const WORKTREE_LABEL: Record<FleetWorktree['status'], string> = {
130 active: 'in use',
131 ready: 'ready to merge',
132 empty: 'no changes',
133 merged: 'merged',
134 conflict: 'CONFLICT',
135 removed: 'merged, removed',
136 detached: 'branch kept',
137}
138/** Worktree states whose work is not yet on the base branch. */
139const UNMERGED: FleetWorktree['status'][] = ['active', 'ready', 'conflict', 'detached']
140
141const roleLabel = (run: FleetRun): string =>
142 run.role === 'planner'
143 ? 'planner'
144 : run.role === 'reviewer'
145 ? 'reviewer'
146 : run.role === 'designer'
147 ? 'designer'
148 : run.job != null
149 ? `job ${run.job}`
150 : run.slot !== null
151 ? `agent ${run.slot + 1}`
152 : 'agent'
153
154async function refreshStatus($: EngineInterface): Promise<void> {
155 const list = await read($, runs)
156 const running = list.filter(run => run.status === 'running').length
157 const done = list.filter(run => run.status !== 'running').length
158 $.ui.status(list.length === 0 ? undefined : `fleet: ${running} running · ${done} finished`)
159}
160
161async function savePlan($: EngineInterface, change: (current: FleetPlan) => FleetPlan) {
162 await update($, plan, change)
163 await $.store.set('plan', await read($, plan))
164}
165
166/** The project the session works in: its git repository's root, or its working folder. */
167async function projectRoot($: EngineInterface): Promise<string> {
168 // A host without a repository or session root (a test, a remote surface) gets no project.
169 try {
170 const repo = await $.session.repo()
171 return repo?.root ?? (await $.session.root())
172 } catch {
173 return ''
174 }
175}
176
177async function homeDir($: EngineInterface): Promise<string> {
178 return (await $.env.get('HOME')) ?? '/tmp'
179}
180
181async function git($: EngineInterface, args: string[], cwd: string) {
182 return $.process.run(['git', ...args], { cwd, timeoutMs: 120_000 })
183}
184
185/** The request still in progress, if any: the latest one with no end. */
186async function openRequest($: EngineInterface): Promise<FleetRequest | undefined> {
187 return (await read($, requests)).findLast(request => request.endedAt === null)
188}
189
190async function setRequest(
191 $: EngineInterface,
192 id: string,
193 change: (request: FleetRequest) => FleetRequest,
194): Promise<void> {
195 await update($, requests, items => items.map(one => (one.id === id ? change(one) : one)))
196}
197
198async function isFleetActive($: EngineInterface): Promise<boolean> {
199 return (await read($, plan)).isEnabled && !(await read($, isProjectOff))
200}
201
202/** Saves the worktree ledger, which outlives the session so no branch is forgotten. */
203async function saveWorktrees(
204 $: EngineInterface,
205 change: (list: FleetWorktree[]) => FleetWorktree[],
206) {
207 await update($, worktrees, change)
208 await $.store.set('worktrees', await read($, worktrees))
209}
210
211async function setWorktree($: EngineInterface, id: string, change: Partial<FleetWorktree>) {
212 await saveWorktrees($, list => list.map(one => (one.id === id ? { ...one, ...change } : one)))
213}
214
215/** Stops one running agent through the TaskStop tool, as the person's own action. */
216async function stopRun($: EngineInterface, run: FleetRun): Promise<void> {
217 if (run.status === 'paused') {
218 // Already stopped at a safe point: it only stops being resumable.
219 const at = await $.clock.now()
220 await update($, runs, list =>
221 list.map(one =>
222 one.id === run.id ? { ...one, status: 'stopped' as const, endedAt: at } : one,
223 ),
224 )
225 await refreshStatus($)
226 return
227 }
228 const answer = await $.tool.call({
229 tool: 'TaskStop',
230 task_id: run.id,
231 consent: `The user pressed "Stop" for the fleet agent "${run.description}".`,
232 })
233 if (answer.deny !== undefined) {
234 $.ui.toast(`Could not stop "${run.description}": ${answer.deny}`)
235 return
236 }
237 const at = await $.clock.now()
238 await update($, runs, list =>
239 list.map(one =>
240 one.id === run.id && one.status === 'running'
241 ? { ...one, status: 'stopped' as const, endedAt: at }
242 : one,
243 ),
244 )
245 await refreshStatus($)
246}
247
248/** Announces a finished request: a transcript line always, then a sound and a banner. */
249async function announce($: EngineInterface, entry: FleetHistoryEntry): Promise<void> {
250 const mode = (await read($, plan)).notify ?? 'all'
251 const line =
252 `Agent fleet: "${entry.title}" ${entry.outcome === 'done' ? 'finished' : 'stopped'} · ` +
253 `${elapsedText(entry.startedAt, entry.endedAt)} · ${entry.agents} agents` +
254 (entry.cost ? ` · ${usdText(entry.cost)}` : '') +
255 (entry.runDir ? ` · ${entry.runDir}` : '')
256 if ((await read($, plan)).messages !== 'quiet') $.ui.log(line)
257 if (mode === 'off' || entry.endedAt - entry.startedAt < NOTIFY_AFTER_MS) return
258 if (mode === 'all' || mode === 'sound') {
259 const asset = entry.outcome === 'done' ? 'fx/done.wav' : 'fx/alert.wav'
260 await $.audio.play({ asset }).catch(() => undefined)
261 }
262 if (mode === 'all' || mode === 'banner') {
263 // A system banner reaches the person when the terminal is in the background (macOS only;
264 // elsewhere the command is missing and nothing happens).
265 const quote = (text: string) => text.replace(/[\\"]/g, '').slice(0, 180)
266 const title = entry.outcome === 'done' ? 'Agent fleet: finished' : 'Agent fleet: stopped'
267 const script = `display notification "${quote(line.replace(/^Agent fleet: /, ''))}" with title "${title}"`
268 await $.process.run(['osascript', '-e', script], { timeoutMs: 5_000 }).catch(() => undefined)
269 }
270}
271
272/** Records a request that has ended in the history, and announces it once. */
273async function finishRequest($: EngineInterface, requestId: string): Promise<void> {
274 const request = (await read($, requests)).find(one => one.id === requestId)
275 if (request === undefined || request.endedAt === null) return
276 if ((await read($, history)).some(one => one.id === request.id)) return
277 const mine = (await read($, runs)).filter(run => run.requestId === request.id)
278 const entry: FleetHistoryEntry = {
279 id: request.id,
280 title: request.title,
281 project: await projectRoot($),
282 startedAt: request.startedAt,
283 endedAt: request.endedAt,
284 outcome: request.outcome === 'stopped' ? 'stopped' : 'done',
285 agents: mine.length,
286 tokens: mine.reduce((sum, run) => sum + (run.tokens ?? 0), 0),
287 cost: request.cost ?? null,
288 runDir: request.runDir ?? null,
289 }
290 await update($, history, list => [...list, entry].slice(-KEPT_HISTORY))
291 await $.store.set('history', await read($, history))
292 await announce($, entry)
293}
294
295/** Opens a run folder in the system's file browser. */
296async function openFolder($: EngineInterface, path: string): Promise<void> {
297 const opened = await $.process.run(['open', path]).catch(() => null)
298 if (opened === null || opened.exitCode !== 0) {
299 const other = await $.process.run(['xdg-open', path]).catch(() => null)
300 if (other === null || other.exitCode !== 0) $.ui.toast(`Agent fleet: run folder ${path}`)
301 }
302}
303
304function historyText(list: FleetHistoryEntry[], project: string | null): string {
305 const shown = list
306 .filter(one => project === null || one.project === project)
307 .slice(-15)
308 .reverse()
309 if (shown.length === 0)
310 return project
311 ? 'No finished fleet requests in this project yet.'
312 : 'No finished fleet requests yet.'
313 return shown
314 .map(
315 one =>
316 `${one.outcome === 'done' ? '✓' : '■'} ${new Date(one.endedAt).toISOString().slice(0, 16).replace('T', ' ')} ` +
317 `${one.title} · ${elapsedText(one.startedAt, one.endedAt)} · ${one.agents} agents` +
318 (one.cost ? ` · ${usdText(one.cost)}` : '') +
319 (project === null ? `\n ${one.project}` : '') +
320 (one.runDir ? `\n ${one.runDir}` : ''),
321 )
322 .join('\n')
323}
324
325/** Tells the main conversation what the person did to its agents, so it waits or carries on. */
326async function tellMain($: EngineInterface, text: string): Promise<void> {
327 await $.session
328 .append({
329 message: { type: 'user', content: [{ type: 'text', text: `[Agent fleet] ${text}` }] },
330 })
331 .catch(() => undefined)
332}
333
334/**
335 * Asks a running agent to pause. Nothing is cut off mid-step: the fleet waits for the agent's
336 * next tool call, refuses that call, and stops the agent there (see the tool.call hook).
337 */
338async function pauseRun($: EngineInterface, run: FleetRun): Promise<void> {
339 if (run.status !== 'running') return
340 await update($, runs, list =>
341 list.map(one => (one.id === run.id ? { ...one, pauseRequested: true } : one)),
342 )
343 $.ui.toast(`Agent fleet: "${run.description}" will pause at its next tool call.`)
344}
345
346/** Wakes a paused agent with a message; it carries on with everything it had done. */
347async function resumeRun(
348 $: EngineInterface,
349 run: FleetRun,
350 queue: Map<string, FleetRun>,
351): Promise<void> {
352 if (run.status !== 'paused') return
353 const answer = await $.tool
354 .call({
355 tool: 'SendMessage',
356 to: run.id,
357 summary: 'Resume a paused fleet agent',
358 message:
359 'Resume your task from where you stopped. The user paused you just before a tool call; ' +
360 'that call did not run, so make it again if you still need it.',
361 consent: `The user pressed "Resume" for the fleet agent "${run.description}".`,
362 })
363 .catch((error: unknown) => ({ deny: String(error) }))
364 const failed = answer.deny !== undefined || (answer as { isError?: boolean }).isError === true
365 if (failed) {
366 // The agent could not be woken: start it again with its brief and what it had done.
367 const done = run.progress
368 ? ` It had finished ${run.progress.done} of ${run.progress.total} steps (next: ${run.progress.step}).`
369 : ''
370 const partial = run.outputPath
371 ? ` Any partial result is in ${run.outputPath}; build on it.`
372 : ''
373 await update($, runs, list =>
374 list.map(one => (one.id === run.id ? { ...one, status: 'stopped' as const } : one)),
375 )
376 await rerunRun($, run, `This continues an earlier run the user paused.${done}${partial}`, queue)
377 $.ui.toast(`Agent fleet: "${run.description}" could not be woken, so it was started again.`)
378 return
379 }
380 await update($, runs, list =>
381 list.map(one =>
382 one.id === run.id ? { ...one, status: 'running' as const, endedAt: null } : one,
383 ),
384 )
385 await tellMain($, `The user resumed "${run.description}". Wait for its result as before.`)
386 await refreshStatus($)
387}
388
389async function stopRequest($: EngineInterface, request: FleetRequest): Promise<void> {
390 const list = await read($, runs)
391 const running = list.filter(
392 run => run.requestId === request.id && (run.status === 'running' || run.status === 'paused'),
393 )
394 for (const run of running) await stopRun($, run)
395 const at = await $.clock.now()
396 await setRequest($, request.id, one =>
397 one.endedAt === null ? { ...one, endedAt: at, outcome: 'stopped' } : one,
398 )
399 await finishRequest($, request.id)
400}
401
402/** Creates the worktree and branch one worker edits in, cut from the request's base commit. */
403async function createWorktree(
404 $: EngineInterface,
405 request: FleetRequest,
406 job: number,
407 label: string,
408 name = `job-${job}`,
409): Promise<FleetWorktree | string> {
410 const repo = await $.session.repo()
411 if (repo === null) return 'this project is not a git repository'
412 let baseCommit = request.baseCommit ?? null
413 let baseBranch = request.baseBranch ?? null
414 if (baseCommit === null) {
415 const head = await git($, ['rev-parse', 'HEAD'], repo.root)
416 const branch = await git($, ['rev-parse', '--abbrev-ref', 'HEAD'], repo.root)
417 if (head.exitCode !== 0) return `cannot read HEAD: ${head.stderr.trim()}`
418 baseCommit = head.stdout.trim()
419 baseBranch = branch.stdout.trim()
420 await setRequest($, request.id, one => ({ ...one, baseCommit, baseBranch }))
421 const dirty = await git($, ['status', '--porcelain', '--untracked-files=no'], repo.root)
422 if (dirty.stdout.trim() !== '') {
423 $.ui.toast(
424 'Agent fleet: your checkout has uncommitted changes; worktrees start from the last ' +
425 'commit and will not contain them.',
426 )
427 }
428 }
429 const existing = (await read($, worktrees)).find(
430 one =>
431 one.requestId === request.id && one.id === `${request.id}-${name}` && one.status === 'active',
432 )
433 if (existing) return existing
434 const home = await homeDir($)
435 const repoName = slugOf(repo.root.split('/').pop() ?? 'repo')
436 const branch = `fleet/${request.id}/${name}`
437 const path = `${home}/.claude/fleet-worktrees/${repoName}/${request.id}-${name}`
438 const added = await git($, ['worktree', 'add', '-b', branch, path, baseCommit], repo.root)
439 if (added.exitCode !== 0) return added.stderr.trim() || 'git worktree add failed'
440 const worktree: FleetWorktree = {
441 id: `${request.id}-${name}`,
442 repoRoot: repo.root,
443 requestId: request.id,
444 requestTitle: request.title,
445 job,
446 label,
447 path,
448 branch,
449 baseCommit,
450 baseBranch: baseBranch ?? 'HEAD',
451 createdAt: await $.clock.now(),
452 status: 'active',
453 commits: 0,
454 note: '',
455 }
456 await saveWorktrees($, list => [...list, worktree])
457 return worktree
458}
459
460/**
461 * Makes sure nothing is left uncommitted in a worktree: anything the agent did not commit is
462 * committed on its branch. Returns null when the worktree is clean afterwards, or why not.
463 */
464async function saveWorktreeWork($: EngineInterface, wt: FleetWorktree): Promise<string | null> {
465 const status = await git($, ['status', '--porcelain'], wt.path)
466 if (status.exitCode !== 0) return status.stderr.trim() || 'cannot read the worktree status'
467 if (status.stdout.trim() === '') return null
468 const added = await git($, ['add', '-A'], wt.path)
469 if (added.exitCode !== 0) return added.stderr.trim()
470 const committed = await git(
471 $,
472 ['commit', '-m', `fleet: save uncommitted work of ${wt.label}`],
473 wt.path,
474 )
475 return committed.exitCode === 0 ? null : committed.stderr.trim() || committed.stdout.trim()
476}
477
478/** After a worker ends: save its work, count its commits, and drop a worktree that made none. */
479async function finishWorktree($: EngineInterface, wt: FleetWorktree): Promise<void> {
480 const unsaved = await saveWorktreeWork($, wt)
481 if (unsaved !== null) {
482 await setWorktree($, wt.id, { note: `uncommitted changes could not be saved: ${unsaved}` })
483 $.ui.toast(
484 `Agent fleet: ${wt.label} left changes that could not be committed — see /fleet worktrees`,
485 )
486 return
487 }
488 const count = await git($, ['rev-list', '--count', `${wt.baseCommit}..${wt.branch}`], wt.repoRoot)
489 const commits = Number(count.stdout.trim()) || 0
490 if (commits > 0) {
491 await setWorktree($, wt.id, { status: 'ready', commits, note: '' })
492 return
493 }
494 // No commits and a clean tree: nothing to keep. Neither removal is ever forced.
495 const removed = await git($, ['worktree', 'remove', wt.path], wt.repoRoot)
496 const deleted =
497 removed.exitCode === 0 ? await git($, ['branch', '-d', wt.branch], wt.repoRoot) : removed
498 await setWorktree($, wt.id, {
499 status: removed.exitCode === 0 ? 'empty' : 'ready',
500 commits: 0,
501 note: deleted.exitCode === 0 ? '' : `kept: ${deleted.stderr.trim()}`,
502 })
503}
504
505/**
506 * Merges the finished worktrees of this repository into their base branch, one branch at a
507 * time in job order. Stops at the first conflict and aborts that merge, so the checkout is
508 * left as it was before it; never stashes, never resolves, never forces. Worktrees are
509 * removed only after their branch is confirmed merged.
510 */
511async function mergeWorktrees($: EngineInterface): Promise<string> {
512 const repo = await $.session.repo()
513 if (repo === null) return 'This project is not a git repository.'
514 const list = (await read($, worktrees)).filter(
515 one => one.repoRoot === repo.root && (one.status === 'ready' || one.status === 'conflict'),
516 )
517 if (list.length === 0) return 'No finished fleet worktrees are waiting to be merged here.'
518 const active = (await read($, worktrees)).filter(
519 one => one.repoRoot === repo.root && one.status === 'active',
520 )
521 const running = (await read($, runs)).filter(
522 run =>
523 run.status === 'running' && run.worktreeId && active.some(one => one.id === run.worktreeId),
524 )
525 if (running.length > 0) {
526 return `Wait for the running workers first: ${running.map(run => run.description).join(', ')}.`
527 }
528 const inMerge = await git($, ['rev-parse', '-q', '--verify', 'MERGE_HEAD'], repo.root)
529 if (inMerge.exitCode === 0) {
530 return 'A merge is already in progress in your checkout. Finish or abort it, then run /fleet merge again.'
531 }
532 const dirty = await git($, ['status', '--porcelain', '--untracked-files=no'], repo.root)
533 if (dirty.stdout.trim() !== '') {
534 return (
535 'Your checkout has uncommitted changes. Commit them (or set them aside yourself) and run ' +
536 '/fleet merge again; the fleet never stashes or overwrites your work.'
537 )
538 }
539 const branchNow = (await git($, ['rev-parse', '--abbrev-ref', 'HEAD'], repo.root)).stdout.trim()
540 const report: string[] = []
541 const ordered = [...list].sort((a, b) => a.createdAt - b.createdAt || (a.job ?? 0) - (b.job ?? 0))
542 for (const wt of ordered) {
543 if (wt.baseBranch !== branchNow) {
544 report.push(
545 `✗ ${wt.label}: made from ${wt.baseBranch}, but ${branchNow} is checked out. Switch to ` +
546 `${wt.baseBranch} and run /fleet merge again.`,
547 )
548 break
549 }
550 const unsaved = await saveWorktreeWork($, wt)
551 if (unsaved !== null) {
552 report.push(`✗ ${wt.label}: uncommitted changes could not be saved (${unsaved}). Stopped.`)
553 break
554 }
555 const isMerged = await git($, ['merge-base', '--is-ancestor', wt.branch, 'HEAD'], repo.root)
556 if (isMerged.exitCode !== 0) {
557 const merged = await git(
558 $,
559 ['merge', '--no-ff', '--no-edit', '-m', `fleet: merge ${wt.label}`, wt.branch],
560 repo.root,
561 )
562 if (merged.exitCode !== 0) {
563 const files = await git($, ['diff', '--name-only', '--diff-filter=U'], repo.root)
564 await git($, ['merge', '--abort'], repo.root)
565 const conflicted = files.stdout.trim().split('\n').filter(Boolean)
566 await setWorktree($, wt.id, {
567 status: 'conflict',
568 note: conflicted.length ? `conflicts in ${conflicted.join(', ')}` : merged.stderr.trim(),
569 })
570 report.push(
571 `✗ ${wt.label} conflicts with what is already merged` +
572 (conflicted.length ? ` (${conflicted.join(', ')})` : '') +
573 `. The merge was aborted, so your checkout is unchanged. Resolve it with ` +
574 `"git merge ${wt.branch}" and commit, then run /fleet merge again to continue.`,
575 )
576 break
577 }
578 }
579 await setWorktree($, wt.id, { status: 'merged', note: '' })
580 const removed = await git($, ['worktree', 'remove', wt.path], repo.root)
581 const deleted =
582 removed.exitCode === 0 ? await git($, ['branch', '-d', wt.branch], repo.root) : removed
583 if (removed.exitCode === 0 && deleted.exitCode === 0) {
584 await setWorktree($, wt.id, { status: 'removed' })
585 report.push(`✓ ${wt.label}: merged (${wt.commits} commits); worktree and branch removed.`)
586 } else {
587 await setWorktree($, wt.id, {
588 note: `merged; cleanup left for you: ${deleted.stderr.trim()}`,
589 })
590 report.push(`✓ ${wt.label}: merged; ${wt.path} was kept (${deleted.stderr.trim()}).`)
591 }
592 }
593 const left = (await read($, worktrees)).filter(
594 one => one.repoRoot === repo.root && UNMERGED.includes(one.status),
595 )
596 if (left.length) report.push(`${left.length} fleet worktree(s) still hold unmerged work.`)
597 return report.join('\n')
598}
599
600/** Removes one worktree folder but keeps its branch, so its commits stay reachable. */
601async function detachWorktree($: EngineInterface, job: number): Promise<string> {
602 const repo = await $.session.repo()
603 const wt = (await read($, worktrees))
604 .filter(one => one.repoRoot === repo?.root && one.job === job && UNMERGED.includes(one.status))
605 .at(-1)
606 if (wt === undefined) return `No unmerged fleet worktree for job ${job} here.`
607 const running = (await read($, runs)).some(
608 run => run.worktreeId === wt.id && run.status === 'running',
609 )
610 if (running) return `Job ${job}'s worker is still running.`
611 const unsaved = await saveWorktreeWork($, wt)
612 if (unsaved !== null) return `Job ${job}: uncommitted changes could not be saved (${unsaved}).`
613 const removed = await git($, ['worktree', 'remove', wt.path], wt.repoRoot)
614 if (removed.exitCode !== 0) return `Could not remove ${wt.path}: ${removed.stderr.trim()}`
615 await setWorktree($, wt.id, { status: 'detached', note: 'folder removed; branch kept' })
616 return `Removed ${wt.path}. Branch ${wt.branch} is kept with its ${wt.commits} commit(s).`
617}
618
619async function worktreeReport($: EngineInterface): Promise<string> {
620 const root = await projectRoot($)
621 const list = (await read($, worktrees)).filter(one => one.repoRoot === root)
622 if (list.length === 0) return 'No fleet worktrees in this project.'
623 return list
624 .map(
625 one =>
626 `${UNMERGED.includes(one.status) ? '•' : '✓'} ${one.label} — ${WORKTREE_LABEL[one.status]}` +
627 ` · ${one.commits} commit(s) · ${one.branch}\n ${one.path}${one.note ? `\n ${one.note}` : ''}`,
628 )
629 .join('\n')
630}
631
632/**
633 * Switches the fleet on or off for the current project. Shared by `/fleet use` and the pane's
634 * button: a plugin cannot answer a command it runs itself, so the button calls this directly.
635 */
636async function setProjectUse($: EngineInterface, isOn: boolean): Promise<string> {
637 const root = await projectRoot($)
638 const offList = ((await $.store.get('projects-off')) as string[] | undefined) ?? []
639 const nextList = isOn ? offList.filter(one => one !== root) : [...new Set([...offList, root])]
640 await $.store.set('projects-off', nextList)
641 await update($, isProjectOff, () => !isOn)
642 return isOn
643 ? `Agent fleet is on again for ${root}.`
644 : `Agent fleet is off for ${root}. Other projects keep their setting; /fleet use on turns it back on here.`
645}
646
647/** Turns worktrees on or off; shared by `/fleet worktrees on|off` and the pane's button. */
648async function setWorktreesMode($: EngineInterface, isOn: boolean): Promise<string> {
649 if (isOn && (await $.session.repo()) === null) {
650 return 'This project is not a git repository, so worktrees cannot be used here.'
651 }
652 await savePlan($, current => ({ ...current, isWorktrees: isOn }))
653 return isOn
654 ? 'Each worker now edits its own git worktree and branch. Merge them with /fleet merge.'
655 : 'Workers edit the project directly. Existing fleet worktrees are kept; see /fleet worktrees.'
656}
657
658/** Reads what an agent is doing: its latest text and its latest tool call. */
659async function peekRun($: EngineInterface, run: FleetRun): Promise<FleetPeek> {
660 const at = await $.clock.now()
661 const messages = await $.session.messages({ agentId: run.id })
662 if (!Array.isArray(messages)) return { runId: run.id, text: messages.deny, tool: '', at }
663 const assistant = messages.filter(message => message.role === 'assistant')
664 const lastText = [...assistant].reverse().find(message => message.text.trim() !== '')
665 const lastTool = [...assistant].reverse().flatMap(message => [...message.toolUses].reverse())[0]
666 const toolText = lastTool
667 ? `${lastTool.tool} ${String(
668 lastTool.input.file_path ??
669 lastTool.input.command ??
670 lastTool.input.query ??
671 lastTool.input.pattern ??
672 lastTool.input.url ??
673 lastTool.input.step ??
674 '',
675 ).slice(0, 120)}`
676 : ''
677 const text = (lastText?.text ?? '(no text yet)').trim().split('\n').slice(-6).join('\n')
678 return { runId: run.id, text: text.slice(0, 900), tool: toolText, at }
679}
680
681/** Starts a finished agent again with the same brief and the person's note. */
682async function rerunRun(
683 $: EngineInterface,
684 run: FleetRun,
685 note: string,
686 queue: Map<string, FleetRun>,
687): Promise<void> {
688 if (!run.prompt) {
689 $.ui.toast(
690 'Agent fleet: this agent started before reruns were possible; its brief was not kept.',
691 )
692 return
693 }
694 const description = `${run.description.replace(/ \(rerun\)$/, '')} (rerun)`
695 queue.set(description, run)
696 const wt = run.worktreeId
697 ? (await read($, worktrees)).find(one => one.id === run.worktreeId)
698 : undefined
699 const extra = note.trim() ? `\n\nNote from the user for this rerun: ${note.trim()}` : ''
700 const started = await $.agent.spawn({
701 prompt: run.prompt + extra,
702 description,
703 subagentType: run.type,
704 model: run.model,
705 ...(wt && UNMERGED.includes(wt.status) ? { cwd: wt.path } : {}),
706 })
707 if (started.deny !== undefined) {
708 queue.delete(description)
709 $.ui.toast(`Agent fleet: rerun refused: ${started.deny}`)
710 }
711}
712
713/** Keeps a request's live cost and acts on its budget. */
714async function tickBudget($: EngineInterface): Promise<void> {
715 // The latest request, and for a minute after it ends too: responses still in flight when it
716 // stopped land afterwards, and the figure shown should include them.
717 const request = (await read($, requests)).at(-1)
718 if (request === undefined || request.costAtStart == null) return
719 const at = await $.clock.now()
720 if (request.endedAt !== null && at - request.endedAt > 60_000) return
721 const usd = (await $.session.usage()).cost?.usd
722 if (usd === undefined) return
723 const cost = Math.max(0, usd - request.costAtStart)
724 const fleet = await read($, plan)
725 const level = budgetLevel(cost, fleet.budgetUsd)
726 const before = request.budgetLevel ?? 0
727 if (Math.abs(cost - (request.cost ?? 0)) >= 0.01 || level > before) {
728 await setRequest($, request.id, one => ({
729 ...one,
730 cost,
731 budgetLevel: Math.max(level, one.budgetLevel ?? 0),
732 }))
733 }
734 if (level <= before || !fleet.budgetUsd || request.endedAt !== null) return
735 const budget = usdText(fleet.budgetUsd)
736 if (level === 1) {
737 const line = `Agent fleet: "${request.title}" has used ${usdText(cost)} of its ${budget} budget.`
738 $.ui.toast(line)
739 $.ui.log(line)
740 return
741 }
742 const isStop = fleet.budgetAction === 'stop'
743 const line =
744 `Agent fleet: "${request.title}" reached its ${budget} budget (${usdText(cost)} used)` +
745 (isStop ? ' and was stopped. No further agents start for it.' : '.')
746 // A toast passes quickly; the transcript line stays, and Claude is told why.
747 $.ui.toast(line)
748 $.ui.log(line)
749 await $.session.append({
750 message: {
751 type: 'user',
752 content: [
753 {
754 type: 'text',
755 text:
756 `[Agent fleet] This request reached the user's ${budget} budget (${usdText(cost)} used).` +
757 (isStop
758 ? ' The fleet stopped its agents and will refuse new ones. Do not relaunch them; tell ' +
759 'the user the budget was reached and offer to continue if they raise it (/fleet budget).'
760 : ' The user chose to be warned, not stopped; finish promptly and mention the cost.'),
761 },
762 ],
763 },
764 })
765 if (isStop) await stopRequest($, request)
766}
767
768export const register: Register = on => {
769 // Slots the main loop has handed out since the person's last prompt, when no job is named.
770 let slotCursor = 0
771 // Slots handed out but not yet recorded, per request. Agents launched together reach the
772 // spawn hook at the same moment, before any of them is in the state; without this they all
773 // took slot 1 (and its model).
774 const reserved = new Map<string, Set<number>>()
775 // Reruns waiting for their agent.spawn, by description.
776 const rerunQueue = new Map<string, FleetRun>()
777 let ticks = 0
778
779 on('session.start', async ($, e, next) => {
780 const saved = (await $.store.get('plan')) as FleetPlan | undefined
781 if (saved !== undefined && Array.isArray(saved.models)) {
782 await update($, plan, () => saved)
783 }
784 const root = await projectRoot($)
785 const offList = ((await $.store.get('projects-off')) as string[] | undefined) ?? []
786 await update($, isProjectOff, () => offList.includes(root))
787 const past = ((await $.store.get('history')) as FleetHistoryEntry[] | undefined) ?? []
788 await update($, history, () => past)
789 const ledger = ((await $.store.get('worktrees')) as FleetWorktree[] | undefined) ?? []
790 await update($, worktrees, () => ledger)
791 const pending = ledger.filter(one => one.repoRoot === root && UNMERGED.includes(one.status))
792 if (pending.length > 0) {
793 $.ui.toast(
794 `Agent fleet: ${pending.length} worktree branch(es) here hold unmerged work — /fleet worktrees`,
795 )
796 }
797
798 await $.command.register({
799 name: 'fleet',
800 description: 'Split tasks across subagents you configure, and follow their progress',
801 argumentHint: '[N model…] | on | off | use on|off | merge | worktrees | budget … | help',
802 })
803 await $.agent.register({
804 name: 'planner',
805 description:
806 'Lead planner of the agent fleet: thinks a task through and returns one self-contained ' +
807 'job brief per worker slot. Launch it first when the fleet plan has a planner.',
808 prompt: PLANNER_PROMPT,
809 tools: ['Read', 'Bash', 'WebSearch', 'WebFetch'],
810 effort: 'high',
811 })
812 await $.agent.register({
813 name: 'reviewer',
814 description:
815 "Reviewer of the agent fleet: checks and corrects the combined draft of the fleet's " +
816 'workers. Launch it last when the fleet plan has a reviewer.',
817 prompt: REVIEWER_PROMPT,
818 tools: ['Read', 'Bash', 'WebSearch', 'WebFetch'],
819 effort: 'high',
820 })
821 await $.agent.register({
822 name: 'designer',
823 description:
824 "Designer of the agent fleet: polishes the fleet's final result into documents, decks or " +
825 'improved web pages as local files. Launch it last when the fleet plan has a designer.',
826 prompt: DESIGNER_PROMPT,
827 effort: 'high',
828 })
829 await $.tool.register(PROGRESS_SPEC)
830 $.clock.every(1000, async () => {
831 ticks += 1
832 const list = await read($, runs)
833 const open = await openRequest($)
834 if (open !== undefined || list.some(run => run.status === 'running')) {
835 const at = await $.clock.now()
836 await update($, now, () => at)
837 }
838 // The budget is checked every second: cost arrives in large steps as responses finish.
839 if ((await read($, plan)).budgetUsd) await tickBudget($)
840 // A peek at a running agent refreshes every five seconds.
841 const peeked = await read($, peek)
842 if (peeked && ticks % 5 === 0) {
843 const run = list.find(one => one.id === peeked.runId)
844 if (run?.status === 'running') {
845 const fresh = await peekRun($, run)
846 await update($, peek, () => fresh)
847 }
848 }
849 })
850
851 return next(e)
852 })
853
854 on('command.run', { command: 'fleet' }, async ($, e) => {
855 const arg = e.args.trim().toLowerCase()
856 const [head = '', word = ''] = arg.split(/\s+/)
857
858 if (arg === 'on' || arg === 'off') {
859 await savePlan($, current => ({ ...current, isEnabled: arg === 'on' }))
860 return { text: `Agent fleet ${arg === 'on' ? 'enabled' : 'disabled'} everywhere.` }
861 }
862 if (head === 'use') {
863 if (word !== 'on' && word !== 'off') return { text: 'Use /fleet use on | off' }
864 return { text: await setProjectUse($, word === 'on') }
865 }
866 if (arg === 'clear') {
867 await update($, runs, list => list.filter(run => run.status === 'running'))
868 await update($, requests, items => items.filter(request => request.endedAt === null))
869 await update($, peek, () => null)
870 await refreshStatus($)
871 return { text: 'Cleared finished agents and requests.' }
872 }
873 if (arg === 'stop') {
874 const open = await openRequest($)
875 const list = await read($, runs)
876 if (open !== undefined) await stopRequest($, open)
877 else for (const run of list.filter(one => one.status === 'running')) await stopRun($, run)
878 return { text: 'Stopped the running fleet agents.' }
879 }
880 if (head === 'planner' || head === 'reviewer' || head === 'designer') {
881 const current = await read($, plan)
882 const lead =
883 head === 'planner'
884 ? plannerOf(current)
885 : head === 'reviewer'
886 ? reviewerOf(current)
887 : designerOf(current)
888 let chosen: FleetLead | null = null
889 if (word === 'on' || word === 'off') chosen = { ...lead, isEnabled: word === 'on' }
890 else if (isModel(word)) chosen = { isEnabled: true, model: word }
891 if (chosen === null)
892 return { text: `Use /fleet ${head} on | off | ${LEAD_MODELS.join(' | ')}` }
893 const value = chosen
894 await savePlan($, plan0 => ({ ...plan0, [head]: value }))
895 const name =
896 head === 'planner' ? 'Lead planner' : head === 'reviewer' ? 'Reviewer' : 'Designer'
897 return {
898 text: value.isEnabled
899 ? `${name} on, running on ${modelLabel(value.model)}.`
900 : `${name} off.`,
901 }
902 }
903 if (head === 'auto' || head === 'files') {
904 if (word !== 'on' && word !== 'off') return { text: `Use /fleet ${head} on | off` }
905 const key = head === 'auto' ? 'isAutoSize' : 'isFileHandoff'
906 await savePlan($, current => ({ ...current, [key]: word === 'on' }))
907 return {
908 text:
909 head === 'auto'
910 ? word === 'on'
911 ? 'Worker count is chosen per task, up to the number of slots.'
912 : 'Every task uses all worker slots.'
913 : word === 'on'
914 ? 'Workers write their results to files in a run folder per request.'
915 : 'Workers reply with their results directly.',
916 }
917 }
918 if (head === 'budget') {
919 if (word === 'off') {
920 await savePlan($, current => ({ ...current, budgetUsd: null }))
921 return { text: 'No budget per request.' }
922 }
923 if (word === 'warn' || word === 'stop') {
924 await savePlan($, current => ({ ...current, budgetAction: word }))
925 return {
926 text:
927 word === 'stop'
928 ? 'At the budget the fleet stops the request.'
929 : 'At the budget the fleet warns you.',
930 }
931 }
932 const usd = Number(word.replace(/^\$/, ''))
933 if (!Number.isFinite(usd) || usd <= 0)
934 return { text: 'Use /fleet budget 5 (US dollars) | off | warn | stop' }
935 await savePlan($, current => ({ ...current, budgetUsd: usd }))
936 const action = (await read($, plan)).budgetAction ?? 'warn'
937 return { text: `Budget ${usdText(usd)} per request; at the limit the fleet will ${action}.` }
938 }
939 if (head === 'worktrees') {
940 if (word === 'on' || word === 'off') {
941 return { text: await setWorktreesMode($, word === 'on') }
942 }
943 return { text: await worktreeReport($) }
944 }
945 if (arg === 'merge') return { text: await mergeWorktrees($) }
946 if (arg === 'pause' || arg === 'resume') {
947 const list = (await read($, runs)).filter(run =>
948 arg === 'pause' ? run.status === 'running' : run.status === 'paused',
949 )
950 if (list.length === 0) {
951 return {
952 text: arg === 'pause' ? 'No fleet agent is running.' : 'No fleet agent is paused.',
953 }
954 }
955 for (const run of list) {
956 if (arg === 'pause') await pauseRun($, run)
957 else await resumeRun($, run, rerunQueue)
958 }
959 return {
960 text:
961 arg === 'pause'
962 ? `${list.length} agent(s) will pause at their next tool call.`
963 : `Resumed ${list.length} agent(s).`,
964 }
965 }
966 if (head === 'notify') {
967 const modes = { on: 'all', off: 'off', sound: 'sound', banner: 'banner', all: 'all' } as const
968 const mode = modes[word as keyof typeof modes]
969 if (mode === undefined) return { text: 'Use /fleet notify on | off | sound | banner' }
970 await savePlan($, current => ({ ...current, notify: mode }))
971 return {
972 text:
973 mode === 'off'
974 ? 'Finished requests are only noted in the transcript.'
975 : `Requests of 30 seconds or longer announce themselves with ${mode === 'all' ? 'a sound and a banner' : `a ${mode}`}.`,
976 }
977 }
978 if (head === 'history') {
979 return {
980 text: historyText(await read($, history), word === 'all' ? null : await projectRoot($)),
981 }
982 }
983 if (head === 'detach') {
984 const job = Number(word)
985 if (!Number.isInteger(job) || job < 1) return { text: 'Use /fleet detach N (the job number)' }
986 return { text: await detachWorktree($, job) }
987 }
988 if (head === 'theme') {
989 if (!THEME_KEYS.includes(word)) return { text: `Use /fleet theme ${THEME_KEYS.join(' | ')}` }
990 await savePlan($, current => ({ ...current, theme: word }))
991 return { text: `Fleet pane colours: ${word}.` }
992 }
993 if (arg === 'help') {
994 return { text: [`Agent fleet ${VERSION} ${COPYRIGHT} — commands:`, ...HELP_LINES].join('\n') }
995 }
996 if (head === 'messages') {
997 if (word !== 'full' && word !== 'compact' && word !== 'quiet') {
998 return { text: 'Use /fleet messages full | compact | quiet' }
999 }
1000 await savePlan($, current => ({ ...current, messages: word }))
1001 return {
1002 text:
1003 word === 'full'
1004 ? 'Agent messages are shown in full.'
1005 : word === 'compact'
1006 ? 'Agent messages are folded to one line each; press ctrl+o to read one in full.'
1007 : 'Agent messages are folded, and Claude keeps its own progress updates to one line.',
1008 }
1009 }
1010 if (arg !== '') {
1011 const parsed = parsePlan(arg, await read($, plan))
1012 if (typeof parsed === 'string') return { text: `${parsed}. /fleet help lists every command.` }
1013 await savePlan($, () => parsed)
1014 return { text: `Agent fleet: ${parsed.models.map((m, i) => `${i + 1}=${m}`).join(', ')}.` }
1015 }
1016
1017 await $.ui.open({ id: PANE, title: 'Agent fleet', focus: true })
1018 return { text: 'Agent fleet pane opened. Type /fleet help for every command.' }
1019 })
1020
1021 on('prompt.submit', async ($, e, next) => {
1022 // Only the person's own messages start a new request; agent hand-backs do not.
1023 if (e.origin.kind !== 'composer') return next(e)
1024 if (!(await isFleetActive($)) || e.text.trimStart().startsWith('/')) return next(e)
1025 const fleet = await read($, plan)
1026
1027 slotCursor = 0
1028 const at = await $.clock.now()
1029 const title = titleOf(e.text)
1030 const id = `r${at}`
1031 let runDir: string | null = null
1032 if (isFileHandoffOn(fleet)) {
1033 const home = await homeDir($)
1034 const project = slugOf((await projectRoot($)).split('/').pop() ?? 'project')
1035 const stamp = new Date(at).toISOString().slice(0, 16).replace(/[:T]/g, '-')
1036 runDir = `${home}/.claude/fleet-runs/${project}/${stamp}-${slugOf(title)}`
1037 await $.fs.write(`${runDir}/request.md`, `# ${title}\n\n${e.text}\n`)
1038 }
1039 const usd = (await $.session.usage()).cost?.usd ?? null
1040 const request: FleetRequest = {
1041 id,
1042 title,
1043 startedAt: at,
1044 endedAt: null,
1045 expectedWorkers: fleet.isAutoSize ? null : fleet.models.length,
1046 outcome: 'open',
1047 runDir,
1048 costAtStart: usd,
1049 cost: 0,
1050 budgetLevel: 0,
1051 jobs: [],
1052 baseCommit: null,
1053 baseBranch: null,
1054 }
1055 // A request still open from before is closed as it stood: the person moved on.
1056 await update($, requests, items =>
1057 [
1058 ...items.map(one =>
1059 one.endedAt === null ? { ...one, endedAt: at, outcome: 'done' as const } : one,
1060 ),
1061 request,
1062 ].slice(-KEPT_REQUESTS),
1063 )
1064 await update($, isBandHidden, () => false)
1065
1066 return next({ ...e, context: [...(e.context ?? []), promptReminder(fleet, runDir)] })
1067 })
1068
1069 on('prompt.compose', async ($, e, next) => {
1070 const composed = await next(e)
1071 if (!(await isFleetActive($))) return composed
1072 const fleet = await read($, plan)
1073 const quiet =
1074 fleet.messages === 'quiet'
1075 ? [{ id: 'agent-fleet:quiet', text: QUIET_SECTION, scope: 'session' as const }]
1076 : []
1077
1078 return {
1079 sections: [
1080 ...composed.sections,
1081 { id: 'agent-fleet:plan', text: planText(fleet), scope: 'session' as const },
1082 ...quiet,
1083 ],
1084 }
1085 })
1086
1087 on('agent.spawn', async ($, e, next) => {
1088 const fleet = await read($, plan)
1089 const active = await isFleetActive($)
1090 // Once the latest request has spent its budget under "stop", nothing new starts until the
1091 // person sends another message: not a worker, a planner, a reviewer or a nested agent.
1092 if (fleet.budgetUsd && fleet.budgetAction === 'stop') {
1093 await tickBudget($)
1094 const latest = (await read($, requests)).at(-1)
1095 if (latest && (latest.budgetLevel ?? 0) >= 2) {
1096 return {
1097 deny:
1098 `The agent fleet budget of ${usdText(fleet.budgetUsd)} for this request is spent ` +
1099 `(${usdText(latest.cost ?? 0)} used), so no further agents start. Stop here and tell ` +
1100 'the user; they can raise it with /fleet budget, or send a new message.',
1101 }
1102 }
1103 }
1104 const isMain = e.parentAgentId === undefined && !e.workflow && !e.isTeammate
1105 const rerunOf = rerunQueue.get(e.description)
1106 if (rerunOf) rerunQueue.delete(e.description)
1107 const role: FleetRole = rerunOf
1108 ? (rerunOf.role ?? 'worker')
1109 : e.subagentType === PLANNER_TYPE
1110 ? 'planner'
1111 : e.subagentType === REVIEWER_TYPE
1112 ? 'reviewer'
1113 : e.subagentType === DESIGNER_TYPE
1114 ? 'designer'
1115 : active && isMain
1116 ? 'worker'
1117 : 'other'
1118 const open = isMain ? await openRequest($) : undefined
1119 const requestId = rerunOf?.requestId ?? open?.id ?? null
1120 const request = requestId
1121 ? (await read($, requests)).find(one => one.id === requestId)
1122 : undefined
1123 const list = await read($, runs)
1124 let slot: number | null = rerunOf?.slot ?? null
1125 let job: number | null = rerunOf?.job ?? null
1126 let input = e
1127 let outputPath: string | null = rerunOf?.outputPath ?? null
1128 let worktreeId: string | null = rerunOf?.worktreeId ?? null
1129
1130 if (role === 'planner') {
1131 // The planner takes no worker slot: it runs on its own model and is told the slots.
1132 const planner = plannerOf(fleet)
1133 const howMany = fleet.isAutoSize
1134 ? `You choose how many jobs to write: between 1 and ${fleet.models.length}, as many as ` +
1135 'the task really divides into. Job N goes to Agent N.'
1136 : `Write exactly ${fleet.models.length} jobs. Job N goes to Agent N.`
1137 input = {
1138 ...e,
1139 prompt: `${e.prompt}\n\nWorker slots (${fleet.models.length}):\n${slotLines(fleet).join('\n')}\n${howMany}`,
1140 }
1141 if (planner.model !== 'inherit') input = { ...input, model: planner.model }
1142 } else if (role === 'reviewer') {
1143 const reviewer = reviewerOf(fleet)
1144 const files = request?.runDir
1145 ? `\n\nThe run folder ${request.runDir} holds the plan (plan.md), each worker's result ` +
1146 '(job-N.md) and the combined result (combined.md) when it is long; read them there.'
1147 : ''
1148 input = { ...e, prompt: e.prompt + files }
1149 if (reviewer.model !== 'inherit') input = { ...input, model: reviewer.model }
1150 } else if (role === 'designer' && !rerunOf) {
1151 const designer = designerOf(fleet)
1152 const kind = request?.deliverable ?? 'unknown'
1153 const home = await homeDir($)
1154 const folder = request?.runDir
1155 ? `${request.runDir}/design`
1156 : `${home}/.claude/fleet-runs/design-${slugOf(e.description || 'result')}`
1157 outputPath = `${folder}/CHANGES.md`
1158 let note =
1159 `\n\nYour folder: ${folder} — write everything you produce there.` +
1160 (request?.runDir
1161 ? ` The run folder ${request.runDir} holds the plan, the workers' results and the combined result.`
1162 : '') +
1163 `\nDeliverable type named by the planner: ${kind}.`
1164 let cwd: string | undefined
1165 if (isWorktreesOn(fleet) && request && (kind === 'website' || kind === 'code')) {
1166 const made = await createWorktree($, request, 0, 'designer', 'design')
1167 if (typeof made === 'string') {
1168 return { deny: `Agent fleet could not create a worktree for the designer: ${made}.` }
1169 }
1170 worktreeId = made.id
1171 cwd = made.path
1172 note += worktreeNote(made.path, made.branch, made.repoRoot)
1173 }
1174 input = { ...e, prompt: e.prompt + note, ...(cwd ? { cwd } : {}) }
1175 if (designer.model !== 'inherit') input = { ...input, model: designer.model }
1176 } else if (role === 'worker' && !rerunOf) {
1177 const mine = list.filter(
1178 run => run.requestId === requestId && run.role === 'worker' && !isReplaced(run, list),
1179 )
1180 job = jobOfPrompt(e.prompt)
1181 const jobs = request?.jobs ?? []
1182 if (job !== null && mine.some(run => run.job === job && run.status !== 'failed')) {
1183 return { deny: `Job ${job} has already been launched for this request.` }
1184 }
1185 // A job waits for the jobs its heading names: it runs in a later wave.
1186 const planned = job !== null ? jobs.find(one => one.n === job) : undefined
1187 if (planned && planned.after.length > 0) {
1188 const waiting = planned.after.filter(
1189 dep => !mine.some(run => run.job === dep && run.status === 'done'),
1190 )
1191 if (waiting.length > 0) {
1192 return {
1193 deny:
1194 `Job ${job} runs after job ${waiting.join(', ')}. Launch it once ` +
1195 `${waiting.length > 1 ? 'they have' : 'that job has'} returned.`,
1196 }
1197 }
1198 }
1199 const pending = (reserved.get(requestId ?? '') ?? new Set<number>()).size
1200 if (Math.max(mine.length, pending) >= fleet.models.length) {hooks/lib.ts 736 lines1// The fleet's pure logic: texts, parsing and arithmetic. Nothing here touches `$`.
2import type {
3 FleetJob,
4 FleetLead,
5 FleetModel,
6 FleetPlan,
7 FleetProgress,
8 FleetRequest,
9 FleetRole,
10 FleetRun,
11 FleetSteps,
12} from '../types'
13
14/** Shown in the pane's footer; kept in step with .claude-plugin/plugin.json. */
15export const VERSION = '0.7.0'
16export const COPYRIGHT = '© Belsis Meletis'
17
18export const MODELS: FleetModel[] = ['inherit', 'opus', 'sonnet', 'haiku', 'fable']
19export const LEAD_MODELS: FleetModel[] = ['opus', 'fable', 'sonnet', 'inherit']
20export const MAX_AGENTS = 10
21
22/** The lead agents' types as the Agent tool names them: `<plugin>:<name>`. */
23export const PLANNER_TYPE = 'agent-fleet:planner'
24export const REVIEWER_TYPE = 'agent-fleet:reviewer'
25export const DESIGNER_TYPE = 'agent-fleet:designer'
26/** The progress tool as a subagent calls it: `mcp__<plugin>__<name>`. */
27export const PROGRESS_TOOL = 'mcp__agent-fleet__progress'
28
29// `opus` is an alias, so a lead always runs on the newest Opus this build knows.
30const DEFAULT_PLANNER: FleetLead = { isEnabled: true, model: 'opus' }
31const DEFAULT_REVIEWER: FleetLead = { isEnabled: false, model: 'opus' }
32const DEFAULT_DESIGNER: FleetLead = { isEnabled: false, model: 'opus' }
33
34export const plannerOf = (fleet: FleetPlan): FleetLead => fleet.planner ?? DEFAULT_PLANNER
35export const reviewerOf = (fleet: FleetPlan): FleetLead => fleet.reviewer ?? DEFAULT_REVIEWER
36export const designerOf = (fleet: FleetPlan): FleetLead => fleet.designer ?? DEFAULT_DESIGNER
37export const isFileHandoffOn = (fleet: FleetPlan): boolean => fleet.isFileHandoff !== false
38export const isWorktreesOn = (fleet: FleetPlan): boolean => fleet.isWorktrees === true
39
40/** Background and text colours of the pane; `default` leaves both to the theme. */
41export const THEMES: Record<string, { label: string; bg?: string; text: string; line: string }> = {
42 default: { label: 'theme', text: 'text', line: 'subtle' },
43 dark: { label: 'dark', bg: '#1e1f22', text: '#e6e6e6', line: '#4a4d55' },
44 navy: { label: 'navy', bg: '#0f1b2d', text: '#e6edf7', line: '#34507a' },
45 slate: { label: 'slate', bg: '#2b303b', text: '#eceff4', line: '#5a6478' },
46 forest: { label: 'forest', bg: '#14251c', text: '#e3efe6', line: '#3d6650' },
47 light: { label: 'light', bg: '#f6f6f3', text: '#1f2328', line: '#c9c9c0' },
48 paper: { label: 'paper', bg: '#fdf6e3', text: '#3b3a36', line: '#d6c9a2' },
49}
50export const THEME_KEYS = Object.keys(THEMES)
51/** Navy unless the person picked another scheme. */
52export const DEFAULT_THEME = 'navy'
53export const themeOf = (fleet: FleetPlan) =>
54 THEMES[fleet.theme ?? DEFAULT_THEME] ?? THEMES[DEFAULT_THEME]!
55
56export const isModel = (word: string): word is FleetModel => (MODELS as string[]).includes(word)
57
58/** `/fleet 3 opus sonnet haiku`: a count, then one model per agent (or one for all). */
59export function parsePlan(args: string, current: FleetPlan): FleetPlan | string {
60 const words = args.trim().toLowerCase().split(/\s+/).filter(Boolean)
61 const count = Number(words[0])
62 if (!Number.isInteger(count) || count < 1 || count > MAX_AGENTS) {
63 return `Give a number of agents from 1 to ${MAX_AGENTS}, e.g. /fleet 3 opus sonnet haiku`
64 }
65 const given = words.slice(1)
66 const unknown = given.find(word => !isModel(word))
67 if (unknown !== undefined) {
68 return `Unknown model "${unknown}". Use one of: ${MODELS.join(', ')}`
69 }
70 const models = Array.from({ length: count }, (_, i): FleetModel => {
71 const word = given.length === 1 ? given[0] : given[i]
72 return word !== undefined && isModel(word) ? word : (current.models[i] ?? 'inherit')
73 })
74 return { ...current, isEnabled: true, models }
75}
76
77export const modelLabel = (model: FleetModel): string =>
78 model === 'inherit' ? 'same as main' : model
79
80const slotLines = (fleet: FleetPlan): string[] =>
81 fleet.models.map(
82 (model, i) => `- Agent ${i + 1}: ${model === 'inherit' ? 'same model as you' : model}`,
83 )
84export { slotLines }
85
86/** "exactly 4" or "between 1 and 4": how many workers a request gets. */
87const workerCount = (fleet: FleetPlan): string =>
88 fleet.isAutoSize && fleet.models.length > 1
89 ? `between 1 and ${fleet.models.length}`
90 : `exactly ${fleet.models.length}`
91
92const reviewStep = (fleet: FleetPlan): string[] =>
93 reviewerOf(fleet).isEnabled
94 ? [
95 `Last step: launch ONE Agent call with subagent_type "${REVIEWER_TYPE}" whose prompt ` +
96 "is your combined draft (or its file path) followed by every worker's notes on " +
97 'unverified or conflicting points. Give the user its corrected version and its list ' +
98 'of changes.',
99 ]
100 : []
101
102const designStep = (fleet: FleetPlan): string[] =>
103 designerOf(fleet).isEnabled
104 ? [
105 `Design step (after any review): launch ONE Agent call with subagent_type "${DESIGNER_TYPE}" ` +
106 'whose prompt names the final result (its file path, or the text itself), the ' +
107 'deliverable type (report, slides, website, code or data) and who it is for. Give the ' +
108 'user the file paths it reports and its list of changes. It writes local files only.',
109 ]
110 : []
111
112const fileStep = (runDir: string | null | undefined): string[] =>
113 runDir
114 ? [
115 `Workers write their results to files in ${runDir}; read them from there to combine. ` +
116 `Write a long combined result to ${runDir}/combined.md and give the user its path.`,
117 ]
118 : []
119
120const plannerSteps = (fleet: FleetPlan): string[] => {
121 if (!plannerOf(fleet).isEnabled) return []
122 const jobs = fleet.isAutoSize
123 ? `between 1 and ${fleet.models.length} job briefs (it decides how many)`
124 : `exactly ${fleet.models.length} job briefs`
125 return [
126 `Step 1: launch ONE Agent call with subagent_type "${PLANNER_TYPE}" whose prompt is the ` +
127 "user's request in full, with any context from this conversation it needs. It plans and " +
128 `returns ${jobs} ("## Job 1" …). Wait for it.`,
129 'Step 2: launch the workers wave by wave. Jobs whose heading has no "(after …)" form the ' +
130 'first wave: launch them together in one message. When every job a later job waits for ' +
131 "has returned, launch that job. Each worker gets its job's brief word for word, starting " +
132 'with its "## Job N: …" heading line, which tells the fleet which job it is. Then combine ' +
133 'their results as the plan\'s "How to combine" says.',
134 ]
135}
136
137export function planText(fleet: FleetPlan): string {
138 const count = workerCount(fleet)
139 const planner = plannerOf(fleet)
140 return [
141 '# Agent fleet (set by the user)',
142 'This section applies to the main conversation only. If you are a subagent, ignore it.',
143 `The user has asked that every task they give you is broken into ${count} ` +
144 `subtasks and handed to ${count} worker subagents with the Agent tool. Models are ` +
145 'assigned automatically (Job N goes to Agent N), so do not set the `model` parameter:',
146 ...slotLines(fleet),
147 ...(planner.isEnabled
148 ? [
149 `A lead planner (${modelLabel(planner.model)}) does the thinking first and does not ` +
150 'take a worker slot.',
151 ...plannerSteps(fleet),
152 ]
153 : [
154 'Split along real seams (separate parts, files, questions or sections) so the ' +
155 'subagents do not repeat each other, give each one a self-contained prompt, launch ' +
156 'them together, and put the hardest part first if slot 1 has the strongest model. ' +
157 'Then combine their results for the user.',
158 ]),
159 ...reviewStep(fleet),
160 ...designStep(fleet),
161 ...(isWorktreesOn(fleet)
162 ? [
163 'Worktrees are on: each worker edits its own git worktree and branch. Do not merge ' +
164 'them yourself; the user merges with /fleet merge, which stops on any conflict.',
165 ]
166 : []),
167 'Launching more workers than this per request is refused. Exempt: a question you can ' +
168 'answer directly with no work, and requests to change the agent fleet itself.',
169 ].join('\n')
170}
171
172/** Attached beside each prompt the user types, where it is hardest to overlook. */
173export function promptReminder(fleet: FleetPlan, runDir?: string | null): string {
174 const count = fleet.models.length
175 const planner = plannerOf(fleet)
176 const head = planner.isEnabled
177 ? `Agent fleet is ON with a lead planner: this request is planned first, then split into ` +
178 `${workerCount(fleet)} worker subtasks. Workers (no \`model\` parameter; assigned in order):`
179 : fleet.isAutoSize && count > 1
180 ? `Agent fleet is ON: break this request into between 1 and ${count} subtasks (as many as ` +
181 'it really divides into) and launch them together in your next message (no `model` ' +
182 'parameter; the models are assigned in order):'
183 : `Agent fleet is ON: break this request into exactly ${count} subtasks and launch ` +
184 `${count} Agent calls together in your next message (no \`model\` parameter; the ` +
185 'models are assigned in order):'
186 return [
187 head,
188 ...slotLines(fleet),
189 ...(planner.isEnabled ? plannerSteps(fleet) : ['Then combine their results.']),
190 ...fileStep(isFileHandoffOn(fleet) ? runDir : null),
191 ...reviewStep(fleet),
192 ...designStep(fleet),
193 'Skip this only if the message needs no work, or asks to change the agent fleet itself.',
194 ].join('\n')
195}
196
197export const PLANNER_PROMPT = [
198 'You are the lead planner of an agent fleet. You do the thinking; workers do the doing.',
199 'You receive a task and the list of worker slots, each with its model. Think the task ',
200 'through: what the result must contain, how it divides into parts, and which part is ',
201 'hardest. If the task concerns files, a codebase or facts you need to check, look them up ',
202 'first (read-only: never create, edit or delete files, and run only commands that change ',
203 'nothing).',
204 '',
205 'Then answer with one job per worker, in slot order, in this format:',
206 '',
207 '## Plan',
208 'Two or three sentences: the approach and how the parts fit together.',
209 '',
210 '## Deliverable: <report | slides | website | code | data>',
211 'One line: what the user should end up with, and for whom.',
212 '',
213 '## Job 1: <short title>',
214 'A self-contained brief the worker can act on without seeing anything else: the goal, the ',
215 'inputs and file paths, constraints, what not to do, and exactly what to return (format and ',
216 'length). Workers cannot see the task, your plan or each other. Ask each worker to end with ',
217 'a short "Unverified or conflicting" list.',
218 '',
219 '## Job 2: <short title> (after 1)',
220 'A job that needs another job\'s result names it in its heading: "(after 1)" or "(after 1, 3)". ',
221 "It then runs in a later wave and receives that job's result file. Use this only where a job ",
222 "genuinely cannot start without another's output; independent jobs run in parallel and are ",
223 'faster.',
224 '',
225 '(…one "## Job N" section per worker…)',
226 '',
227 '## How to combine',
228 "How the main session should merge the workers' results into the final answer.",
229 '',
230 'Rules: jobs must not overlap; give the hardest job to the strongest model in the slot list. ',
231 'The slot message says how many jobs to write: exactly the number of slots, or, when it lets ',
232 'you choose, as many as the task really divides into (one is fine for a small task). Return ',
233 'only the plan.',
234].join('\n')
235
236export const REVIEWER_PROMPT = [
237 'You are the reviewer of an agent fleet. You receive a combined draft that several workers ',
238 'wrote in parallel (inline, or as files in the run folder named in your prompt), followed by ',
239 'their notes on points they could not verify or found conflicting. Your job is to make the ',
240 'draft correct and consistent before the user sees it.',
241 '',
242 'Check every factual claim the notes flag, and any other claim that looks doubtful, against ',
243 'primary sources where you can (web search, the files named). Fix contradictions between ',
244 "sections, remove repetition, and keep the authors' structure and tone. Never invent a ",
245 'source. Where a point stays unverified, soften it ("reportedly") or remove it. You are ',
246 'read-only: never create, edit or delete files.',
247 '',
248 'Answer with:',
249 '## Corrected version',
250 '(the full corrected text; for a draft too long to return, a numbered list of exact edits,',
251 'each as FIND: <verbatim existing text> / REPLACE: <new text>)',
252 '## Changes',
253 '(a short list: what you changed and why, with the source you relied on)',
254 '## Still unverified',
255 '(anything you could not settle)',
256].join('\n')
257
258export const DESIGNER_PROMPT = [
259 'You are the designer of an agent fleet. The content is already written and reviewed; your ',
260 'job is to make it look and read as well as it can, without changing what it says.',
261 '',
262 'Your prompt names the result (a file path or the text), the deliverable type and the ',
263 'audience, and the fleet adds the folder you write into. Work by type:',
264 '- report or document: restructure for the reader (an executive summary first, clear ',
265 ' headings, short paragraphs, tables where they help). Write an improved Markdown copy, and ',
266 ' a .docx and a .pdf built with the docx and pdf skills (load them with the Skill tool).',
267 '- slides: build a .pptx with the pptx skill: one message per slide, at most six short ',
268 ' bullets, a chart or table wherever there are numbers, speaker notes with the detail.',
269 '- website or code with a user interface: open the pages in the browser (the claude-in-chrome ',
270 ' tools) at desktop and phone widths, take screenshots, then fix layout, spacing, contrast, ',
271 ' typography and accessibility in the code, and take screenshots again. Keep them in your folder.',
272 '- data: add the clearest charts and a summary table; keep the raw figures unchanged.',
273 '',
274 'Rules: never add facts, figures or claims, and never drop content the reviewer kept. Never ',
275 'overwrite the original files; write into your folder. Never upload, publish or share ',
276 'anything: no artifacts, no claude.ai documents, no external services. Those tools are ',
277 'refused for you. Finish by writing CHANGES.md in your folder (what you changed and why), ',
278 'then reply with the list of files you produced and a three-line summary.',
279].join('\n')
280
281/** Tools that would send work off the laptop; the designer may not use them. */
282export function designerGuard(tool: string): string | null {
283 const isUpload =
284 tool.startsWith('Artifact') ||
285 tool.startsWith('mcp__claude_ai_') ||
286 [
287 'DesignSync',
288 'ClaudeDesign',
289 'SendUserFile',
290 'SendFile',
291 'PublishPlugin',
292 'RemoteTrigger',
293 ].includes(tool)
294 return isUpload
295 ? `Agent fleet: the designer writes local files only; ${tool} would upload or publish, so it is refused.`
296 : null
297}
298
299/** The deliverable type a planner's answer names in its "## Deliverable:" line. */
300export function deliverableOf(answer: string): string | null {
301 const match = /^#{0,4}\s*Deliverable\s*[:\-–—]\s*\**\s*([A-Za-z]+)/im.exec(answer)
302 return match ? match[1]!.toLowerCase() : null
303}
304
305/** What a worker is asked to do so its progress can be measured. */
306export const PROGRESS_NOTE =
307 `\n\nProgress reporting: the user watches your progress. Once you know your steps (3–6), ` +
308 `call the ${PROGRESS_TOOL} tool with done 0, the total and the first step; after each step ` +
309 'call it again with the steps done so far and a few words naming the next step. If that ' +
310 'tool is not available, carry on without it and do not mention it.'
311
312export function fileNote(outputPath: string, inputs: { path: string; label: string }[]): string {
313 const given = inputs.length
314 ? '\n\nInputs from earlier jobs (read them before you start):\n' +
315 inputs.map(input => `- ${input.path} — ${input.label}`).join('\n')
316 : ''
317 return (
318 given +
319 `\n\nDelivery: write your complete result to ${outputPath} with the Write tool (for code ` +
320 'changes, a summary of what you changed, where and why). Then reply with only the file ' +
321 'path, one line on what it contains, and any notes your brief asks for, such as unverified ' +
322 'points. Do not paste the full result into your reply.'
323 )
324}
325
326export function worktreeNote(path: string, branch: string, repoRoot: string): string {
327 return (
328 `\n\nWorktree: you work in your own git worktree at ${path} on branch ${branch}. Every ` +
329 `path in your brief that points into ${repoRoot} means the same relative path inside ` +
330 `${path}. Edit files only inside ${path}; edits under ${repoRoot} are refused. Commit your ` +
331 'changes to your branch with clear messages before you finish. Do not push, switch or ' +
332 'create branches, rebase, reset, stash, or touch other worktrees; the user merges.'
333 )
334}
335
336export const PROGRESS_SPEC = {
337 name: 'progress',
338 description:
339 'Report your progress on the task you were given, so the user can follow it: call once ' +
340 'your steps are planned (done 0) and again after each step. For subagents of the agent ' +
341 'fleet; the main session never needs it.',
342 inputSchema: {
343 type: 'object',
344 properties: {
345 done: { type: 'integer', minimum: 0, description: 'Steps finished so far' },
346 total: { type: 'integer', minimum: 1, description: 'Steps in your plan' },
347 step: { type: 'string', description: 'A few words naming the step you are on now' },
348 },
349 required: ['done', 'total'],
350 },
351 isDeferred: false,
352} as const
353
354export function nextModel(model: FleetModel): FleetModel {
355 return MODELS[(MODELS.indexOf(model) + 1) % MODELS.length] ?? 'inherit'
356}
357
358export const nextLeadModel = (model: FleetModel): FleetModel =>
359 LEAD_MODELS[(LEAD_MODELS.indexOf(model) + 1) % LEAD_MODELS.length] ?? 'opus'
360
361/** `claude-sonnet-5-5` → `sonnet 5.5`; an alias or an unknown id is shown as given. */
362export function shortModel(model: string): string {
363 const match = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-|$)/.exec(model)
364 if (match === null) return model
365 return match[3] === undefined ? `${match[1]} ${match[2]}` : `${match[1]} ${match[2]}.${match[3]}`
366}
367
368export const NO_STEPS: FleetSteps = {
369 todoDone: 0,
370 todoTotal: 0,
371 created: 0,
372 completedIds: [],
373 deletedIds: [],
374}
375
376/** Folds one tool call of an agent into its step counts; other tools leave them as they are. */
377export function stepsAfter(
378 steps: FleetSteps,
379 tool: string,
380 input: Record<string, unknown>,
381): FleetSteps {
382 if (tool === 'TodoWrite' && Array.isArray(input.todos)) {
383 const todos = input.todos as { status?: string }[]
384 return {
385 ...steps,
386 todoDone: todos.filter(todo => todo.status === 'completed').length,
387 todoTotal: todos.length,
388 }
389 }
390 if (tool === 'TaskCreate') return { ...steps, created: steps.created + 1 }
391 if (tool === 'TaskUpdate' && typeof input.taskId === 'string') {
392 const id = input.taskId
393 if (input.status === 'completed' && !steps.completedIds.includes(id)) {
394 return { ...steps, completedIds: [...steps.completedIds, id] }
395 }
396 if (input.status === 'deleted' && !steps.deletedIds.includes(id)) {
397 return {
398 ...steps,
399 completedIds: steps.completedIds.filter(done => done !== id),
400 deletedIds: [...steps.deletedIds, id],
401 }
402 }
403 }
404 return steps
405}
406
407/** Reads the progress tool's input; anything malformed is ignored. */
408export function progressFrom(input: Record<string, unknown>): FleetProgress | null {
409 const total = Number(input.total)
410 const done = Number(input.done)
411 if (!Number.isFinite(total) || !Number.isFinite(done) || total < 1) return null
412 const step = typeof input.step === 'string' ? input.step.slice(0, 80) : ''
413 return { done: Math.max(0, Math.min(done, total)), total, step }
414}
415
416/** Completion as a whole percentage, or null while the agent has nothing to measure. */
417export function percentOf(run: Pick<FleetRun, 'status' | 'steps' | 'progress'>): number | null {
418 if (run.status === 'done') return 100
419 if (run.progress) return Math.round((run.progress.done / run.progress.total) * 100)
420 const steps = run.steps ?? NO_STEPS
421 const total = steps.todoTotal + Math.max(0, steps.created - steps.deletedIds.length)
422 if (total === 0) return null
423 const done = steps.todoDone + steps.completedIds.length
424 return Math.min(100, Math.round((done / total) * 100))
425}
426
427const JOB_HEADING = /^#{1,4}\s*Job\s+(\d+)\b\s*[:.\-–—]?\s*(.*)$/i
428
429/** The jobs of a planner's answer, each with the jobs it waits for. */
430export function jobsOf(answer: string): FleetJob[] {
431 const jobs: FleetJob[] = []
432 for (const line of answer.split('\n')) {
433 const match = JOB_HEADING.exec(line.trim())
434 if (match === null) continue
435 const n = Number(match[1])
436 let title = (match[2] ?? '').trim()
437 const after: number[] = []
438 const deps = /\((?:after|depends on|needs)\s+([^)]*)\)\s*$/i.exec(title)
439 if (deps) {
440 for (const found of deps[1]!.matchAll(/\d+/g)) after.push(Number(found[0]))
441 title = title.slice(0, deps.index).trim()
442 }
443 if (!jobs.some(job => job.n === n)) jobs.push({ n, title, after })
444 }
445 // Dependencies on unknown jobs or on themselves are dropped.
446 const known = new Set(jobs.map(job => job.n))
447 return jobs.map(job => ({ ...job, after: job.after.filter(d => d !== job.n && known.has(d)) }))
448}
449
450/** Counts the "## Job N" sections of a planner's answer. */
451export const jobCountOf = (answer: string): number => jobsOf(answer).length
452
453/** Wave number (1 first) of every job; a dependency cycle puts the rest in one last wave. */
454export function wavesOf(jobs: FleetJob[]): Map<number, number> {
455 const wave = new Map<number, number>()
456 let current = 1
457 while (wave.size < jobs.length) {
458 const ready = jobs.filter(
459 job => !wave.has(job.n) && job.after.every(d => wave.has(d) && wave.get(d)! < current),
460 )
461 const placed = ready.length ? ready : jobs.filter(job => !wave.has(job.n))
462 for (const job of placed) wave.set(job.n, current)
463 current += 1
464 }
465 return wave
466}
467
468/** The job number a worker's prompt names in its first "## Job N" heading. */
469export function jobOfPrompt(prompt: string): number | null {
470 for (const line of prompt.split('\n').slice(0, 8)) {
471 const match = JOB_HEADING.exec(line.trim())
472 if (match) return Number(match[1])
473 }
474 return null
475}
476
477/**
478 * The job an agent was given, in a line or two: a planner brief's title and goal, or the
479 * prompt's first sentence. Markdown marks are dropped.
480 */
481export function taskOf(prompt: string): string {
482 const plain = (text: string) =>
483 text
484 .replace(/\*\*|__|`/g, '')
485 .replace(/^#+\s*/, '')
486 .replace(/\s+/g, ' ')
487 .trim()
488 const lines = prompt
489 .split('\n')
490 .map(line => line.trim())
491 .filter(Boolean)
492 const heading = lines.find(line => /^#{1,4}\s*Job\s+\d+/i.test(line))
493 const title = heading
494 ? plain(heading)
495 .replace(/^Job\s+\d+\s*[:.\-–—]\s*/i, '')
496 .replace(/\s*\((?:after|depends on|needs)[^)]*\)\s*$/i, '')
497 : ''
498 const goalLine = lines.find(line => /^\*{0,2}Goal\b/i.test(line))
499 const body = plain(goalLine ?? lines.find(line => !line.startsWith('#')) ?? '').replace(
500 /^Goal\.?\s*:?\s*/i,
501 '',
502 )
503 const sentence = (body.match(/^.+?[.!?](\s|$)/)?.[0] ?? body).trim()
504 const text = title ? `${title}: ${sentence}` : sentence
505 return text.length > 220 ? `${text.slice(0, 219)}…` : text
506}
507
508export type Phase =
509 'planning' | 'working' | 'paused' | 'combining' | 'reviewing' | 'designing' | 'done' | 'stopped'
510
511/** Where a request stands, read from its agents. */
512export function phaseOf(request: FleetRequest, list: FleetRun[]): Phase {
513 if (request.outcome === 'stopped') return 'stopped'
514 if (request.endedAt !== null) return 'done'
515 const mine = list.filter(run => run.requestId === request.id)
516 const isRunning = (role: FleetRole) => mine.some(r => r.role === role && r.status === 'running')
517 if (!mine.some(r => r.status === 'running') && mine.some(r => r.status === 'paused'))
518 return 'paused'
519 if (isRunning('designer')) return 'designing'
520 if (isRunning('reviewer')) return 'reviewing'
521 if (isRunning('worker') || isRunning('other')) return 'working'
522 if (isRunning('planner')) return 'planning'
523 if (mine.some(run => run.role === 'worker')) return 'combining'
524 return mine.some(run => run.role === 'planner') ? 'working' : 'planning'
525}
526
527/**
528 * Overall completion of a request: the planner, each expected worker, the reviewer and the
529 * final combining step are one share each; a running agent counts by its own progress.
530 */
531export function requestPercent(request: FleetRequest, list: FleetRun[], fleet: FleetPlan): number {
532 if (request.endedAt !== null) return 100
533 const mine = list.filter(run => run.requestId === request.id && !isReplaced(run, list))
534 const share = (run: FleetRun | undefined): number =>
535 run === undefined
536 ? 0
537 : run.status === 'running' || run.status === 'paused'
538 ? (percentOf(run) ?? 0) / 100
539 : 1
540 const planners = mine.filter(run => run.role === 'planner')
541 const workers = mine.filter(run => run.role === 'worker' || run.role === 'other')
542 const reviewers = mine.filter(run => run.role === 'reviewer')
543 const designers = mine.filter(run => run.role === 'designer')
544 const hasPlanner = plannerOf(fleet).isEnabled || planners.length > 0
545 const hasReviewer = reviewerOf(fleet).isEnabled || reviewers.length > 0
546 const hasDesigner = designerOf(fleet).isEnabled || designers.length > 0
547 const expected = Math.max(workers.length, request.expectedWorkers ?? fleet.models.length, 1)
548 const total = (hasPlanner ? 1 : 0) + expected + (hasReviewer ? 1 : 0) + (hasDesigner ? 1 : 0) + 1
549 const done =
550 (hasPlanner ? share(planners[planners.length - 1]) : 0) +
551 workers.reduce((sum, run) => sum + share(run), 0) +
552 (hasReviewer ? share(reviewers[reviewers.length - 1]) : 0) +
553 (hasDesigner ? share(designers[designers.length - 1]) : 0)
554 return Math.min(99, Math.round((done / total) * 100))
555}
556
557/** A run a later rerun replaces no longer counts towards progress. */
558export const isReplaced = (run: FleetRun, list: FleetRun[]): boolean =>
559 list.some(other => other.rerunOf === run.id)
560
561export function elapsedText(from: number, to: number): string {
562 const seconds = Math.max(0, Math.round((to - from) / 1000))
563 return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${seconds % 60}s`
564}
565
566export const tokensText = (tokens: number): string =>
567 tokens >= 1_000_000
568 ? `${(tokens / 1_000_000).toFixed(1)}M tok`
569 : `${Math.round(tokens / 1000)}k tok`
570
571export const usdText = (usd: number): string =>
572 usd < 10 ? `$${usd.toFixed(2)}` : `$${usd.toFixed(1)}`
573
574export function fit(text: string, width: number): string {
575 return text.length <= width ? text.padEnd(width) : `${text.slice(0, Math.max(0, width - 1))}…`
576}
577
578export function bar(percent: number, width: number): string {
579 const filled = Math.round((Math.max(0, Math.min(100, percent)) / 100) * width)
580 return `${'█'.repeat(filled)}${'░'.repeat(width - filled)}`
581}
582
583/** 0 under budget, 1 from 80% of it, 2 at or over it. */
584export function budgetLevel(cost: number, budgetUsd: number | null | undefined): number {
585 if (!budgetUsd || budgetUsd <= 0) return 0
586 if (cost >= budgetUsd) return 2
587 return cost >= budgetUsd * 0.8 ? 1 : 0
588}
589
590/** A folder-safe name: lower case, letters, digits and dashes, at most 40 characters. */
591export const slugOf = (text: string): string =>
592 text
593 .toLowerCase()
594 .replace(/[^a-z0-9]+/g, '-')
595 .replace(/^-+|-+$/g, '')
596 .slice(0, 40) || 'request'
597
598/** Whether `path` lies inside `root` (both absolute); `..` segments are refused outright. */
599export function isInside(path: string, root: string): boolean {
600 if (/(^|\/)\.\.(\/|$)/.test(path)) return false
601 const base = root.endsWith('/') ? root : `${root}/`
602 return path === root || path.startsWith(base)
603}
604
605/** Git subcommands a worktree agent may not run: they move, rewrite or publish branches. */
606const FORBIDDEN_GIT =
607 /\bgit\b[^|;&]*\b(push|checkout|switch|rebase|reset\s+--hard|stash|worktree|branch\s+-[dDmM]|merge|cherry-pick|filter-branch|clean\s+-[a-zA-Z]*f)\b/
608
609/**
610 * Why a worktree agent's tool call is refused, or null when it may run. File tools must
611 * write inside the agent's worktree or the run folder; shell commands may not name the
612 * main checkout or run git commands that move or publish branches.
613 */
614export function worktreeGuard(
615 tool: string,
616 input: Record<string, unknown>,
617 worktreePath: string,
618 repoRoot: string,
619 runDir: string | null,
620): string | null {
621 const allowed = (path: string) =>
622 isInside(path, worktreePath) || (runDir !== null && isInside(path, runDir))
623 if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit' || tool === 'MultiEdit') {
624 const target = String(input.file_path ?? input.notebook_path ?? '')
625 if (target.startsWith('/') && !allowed(target)) {
626 return `Agent fleet: this agent works in its worktree ${worktreePath}; it may not write ${target}.`
627 }
628 }
629 if (tool === 'Bash') {
630 const command = String(input.command ?? '')
631 const namesRoot = command.split(repoRoot).length > 1 && !command.includes(worktreePath)
632 if (namesRoot) {
633 return `Agent fleet: this agent works in ${worktreePath}; commands may not touch ${repoRoot}.`
634 }
635 const git = FORBIDDEN_GIT.exec(command)
636 if (git) {
637 return `Agent fleet: "git ${git[1]}" is not allowed in a fleet worktree; the user merges.`
638 }
639 }
640 return null
641}
642
643/**
644 * A request's title: the first line of what the person typed, with pasted-content markers
645 * removed, cut to 60 characters.
646 */
647export function titleOf(text: string): string {
648 const line =
649 text
650 .replace(/<\/?pasted_content[^>]*>/gi, '\n')
651 .split('\n')
652 .map(one => one.trim())
653 .find(one => one !== '') ?? ''
654 return line.length > 60 ? `${line.slice(0, 59)}…` : line || 'Request'
655}
656
657/**
658 * Colours that tell the lead agents apart from the workers in the pane. Mid-tone, so they read
659 * on the dark schemes and on the light ones alike.
660 */
661export const ROLE_COLOR: Partial<Record<FleetRole, string>> = {
662 planner: '#a371f7',
663 reviewer: '#2f9fd8',
664 designer: '#e0569b',
665}
666
667/** Transcript rows that agents create: their completion notices and the reports they send. */
668export const AGENT_ORIGINS: readonly string[] = [
669 'task-notification',
670 'peer',
671 'peer-send-message',
672 'coordinator',
673]
674
675/** One line standing for a folded agent message: who, what happened, and its first words. */
676export function compactLine(
677 text: string,
678 origin: string,
679 from: string | undefined,
680 task: { status?: string; durationMs?: number } | undefined,
681 width: number,
682): string {
683 const who = from ? `@${from}` : origin === 'task-notification' ? 'agent' : 'message'
684 const status = task?.status ? ` ${task.status}` : ''
685 const time = task?.durationMs ? ` · ${elapsedText(0, task.durationMs)}` : ''
686 const first =
687 text
688 .replace(/<[^>]+>/g, ' ')
689 .split('\n')
690 .map(one => one.trim())
691 .find(one => one !== '' && !/^\[?subagent hand-back\]?/i.test(one)) ?? ''
692 const head = `▸ ${who}${status}${time} — `
693 const tail = ' (ctrl+o for all)'
694 const room = Math.max(10, width - head.length - tail.length)
695 return head + (first.length > room ? `${first.slice(0, room - 1)}…` : first) + tail
696}
697
698/** Added to the system prompt in quiet mode, so Claude's own updates are short too. */
699export const QUIET_SECTION = [
700 '# Agent fleet: quiet progress (set by the user)',
701 'While fleet agents are running, keep each update to the user to one short line (what started, ',
702 'what finished, what is next). Do not restate what the agents reported; put the substance in ',
703 'the final answer. This applies to the main conversation only.',
704].join('\n')
705
706/** Every /fleet parameter, as the command's own help. */
707export const HELP_LINES: readonly string[] = [
708 '/fleet open the pane',
709 '/fleet N model… N worker slots and their models, e.g. /fleet 4 sonnet sonnet opus',
710 '/fleet on | off apply the plan to your requests, or not (everywhere)',
711 '/fleet use on | off switch the fleet on or off for this project only',
712 '/fleet planner on|off|M lead planner, and its model (opus, fable, sonnet, inherit)',
713 '/fleet reviewer on|off|M reviewer that checks the combined result last',
714 '/fleet designer on|off|M designer that polishes the result into files (uploads nothing)',
715 '/fleet auto on|off let the planner choose 1–N workers per task',
716 '/fleet files on|off workers write results to files in a run folder',
717 '/fleet budget N | off spending limit per request, in US dollars',
718 '/fleet budget warn|stop what happens at the limit',
719 '/fleet worktrees on|off each worker edits its own git worktree and branch',
720 '/fleet worktrees list fleet worktrees and what is still unmerged',
721 '/fleet merge merge finished worktrees into the base branch, stop on conflict',
722 "/fleet detach N remove job N's worktree but keep its branch",
723 '/fleet notify on|off|sound|banner announce finished requests (30 s or longer)',
724 "/fleet messages full|compact|quiet how much of the agents' messages the transcript shows",
725 '/fleet history [all] past requests here (or everywhere) and their run folders',
726 '/fleet theme NAME pane colours: ' + Object.keys(THEMES).join(', '),
727 '/fleet pause | resume pause running agents at their next tool call; resume them',
728 '/fleet stop stop every running fleet agent',
729 '/fleet clear remove finished agents and requests',
730 '/fleet help show this list',
731 'Pane keys: t plan · p/o planner · r/e reviewer · d/n designer · f/m fewer/more · a count',
732 ' 1–9 agent model',
733 ' b colours · s stop all · x pause or resume all · c clear · y history · v messages',
734 ' h help',
735]
736types/index.d.ts 172 lines1export type FleetModel = 'inherit' | 'opus' | 'sonnet' | 'haiku' | 'fable'
2
3/** A lead agent outside the worker slots: the planner, the reviewer. */
4export type FleetLead = { isEnabled: boolean; model: FleetModel }
5/** Kept for plans saved before the reviewer existed. */
6export type FleetPlanner = FleetLead
7
8export type FleetPlan = {
9 isEnabled: boolean
10 models: FleetModel[]
11 planner?: FleetLead
12 reviewer?: FleetLead
13 /** Polishes the final result into files (documents, decks, web pages); uploads nothing. */
14 designer?: FleetLead
15 /** The planner (or Claude) picks between 1 and `models.length` workers. */
16 isAutoSize?: boolean
17 /** A key of the pane's colour schemes. */
18 theme?: string
19 /** Workers write their results to files in the request's run folder. On unless turned off. */
20 isFileHandoff?: boolean
21 /** Each worker gets its own git worktree and branch. Off unless turned on. */
22 isWorktrees?: boolean
23 /** Spending limit per request in US dollars; null or absent for none. */
24 budgetUsd?: number | null
25 /** What happens when a request reaches its budget. */
26 budgetAction?: 'warn' | 'stop'
27 /** How a finished request is announced: sound and banner (default), one of them, or nothing. */
28 notify?: 'all' | 'sound' | 'banner' | 'off'
29 /** How much of the agents' messages the transcript shows. Full unless changed. */
30 messages?: 'full' | 'compact' | 'quiet'
31}
32
33export type FleetStatus = 'running' | 'paused' | 'done' | 'failed' | 'stopped'
34
35export type FleetRole = 'planner' | 'worker' | 'reviewer' | 'designer' | 'other'
36
37/** `todo`: the agent's latest TodoWrite list. `tasks`: TaskCreate/TaskUpdate ids by status. */
38export type FleetSteps = {
39 todoDone: number
40 todoTotal: number
41 created: number
42 completedIds: string[]
43 deletedIds: string[]
44}
45
46/** What an agent last reported through the fleet's progress tool. */
47export type FleetProgress = { done: number; total: number; step: string }
48
49export type FleetRun = {
50 id: string
51 description: string
52 type: string
53 model: string
54 slot: number | null
55 status: FleetStatus
56 startedAt: number
57 endedAt: number | null
58 tools: number
59 lastTool: string | null
60 tokens: number | null
61 /** The agent's own task list: steps it has completed and steps it has planned. */
62 steps: FleetSteps
63 role?: FleetRole
64 requestId?: string | null
65 progress?: FleetProgress | null
66 /** One or two sentences naming the job the agent was given, read from its prompt. */
67 task?: string
68 /** The prompt as the agent's caller wrote it, before the fleet's notes: what a rerun repeats. */
69 prompt?: string
70 /** The planner's job number this agent works on, when its brief names one. */
71 job?: number | null
72 /** Where the agent was asked to write its result. */
73 outputPath?: string | null
74 /** The worktree the agent works in, when worktrees are on. */
75 worktreeId?: string | null
76 /** The run this one repeats, for a rerun. */
77 rerunOf?: string | null
78 /** The person asked to pause it: it stops at its next tool call. */
79 pauseRequested?: boolean
80}
81
82/** One job of a planner's plan, and the jobs it waits for. */
83export type FleetJob = { n: number; title: string; after: number[] }
84
85/** One message the person typed while the fleet was on, and everything it started. */
86export type FleetRequest = {
87 id: string
88 title: string
89 startedAt: number
90 endedAt: number | null
91 /** Workers the plan expects: the slot count, or the planner's job count when it chooses. */
92 expectedWorkers: number | null
93 outcome: 'open' | 'done' | 'stopped'
94 /** Folder holding the plan, each worker's result and the combined result. */
95 runDir?: string | null
96 /** The session's cost when the request started, and what the request has cost since. */
97 costAtStart?: number | null
98 cost?: number | null
99 /** 0: under budget; 1: warned at 80%; 2: reached. */
100 budgetLevel?: number
101 /** What the person should end up with, as the planner named it: report, slides, website, code, data. */
102 deliverable?: string | null
103 /** The planner's jobs, with their waves read from the dependencies. */
104 jobs?: FleetJob[]
105 /** The commit and branch worktrees are cut from, when worktrees are on. */
106 baseCommit?: string | null
107 baseBranch?: string | null
108}
109
110/**
111 * A worktree the fleet created. Kept across sessions until its branch is merged and the
112 * worktree removed, so no work is left behind.
113 */
114export type FleetWorktree = {
115 id: string
116 repoRoot: string
117 requestId: string
118 requestTitle: string
119 job: number | null
120 label: string
121 path: string
122 branch: string
123 baseCommit: string
124 baseBranch: string
125 createdAt: number
126 status: 'active' | 'ready' | 'empty' | 'merged' | 'conflict' | 'removed' | 'detached'
127 /** Commits on the branch that the base does not have. */
128 commits: number
129 note: string
130}
131
132/** One finished request, kept across sessions so a run can be found again. */
133export type FleetHistoryEntry = {
134 id: string
135 title: string
136 project: string
137 startedAt: number
138 endedAt: number
139 outcome: 'done' | 'stopped'
140 agents: number
141 tokens: number
142 cost: number | null
143 runDir: string | null
144}
145
146/** What the pane shows for one agent opened with "peek". */
147export type FleetPeek = {
148 runId: string
149 text: string
150 tool: string
151 at: number
152}
153
154declare module 'claude-code' {
155 interface PluginState {
156 'agent-fleet': {
157 plan: FleetPlan
158 runs: FleetRun[]
159 now: number
160 requests: FleetRequest[]
161 isBandHidden: boolean
162 isHelpOpen: boolean
163 /** The fleet is switched off for the current project with /fleet use off. */
164 isProjectOff: boolean
165 worktrees: FleetWorktree[]
166 peek: FleetPeek | null
167 history: FleetHistoryEntry[]
168 isHistoryOpen: boolean
169 }
170 }
171}
172