SLOPSHOPPER

agent-fleet

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

newpanebandrowsguardcommand
★ 1v0.7.0NOASSERTIONupdated 2026-10-08mbelsis/Claude-Fleet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-fleet
│ ┃ Agent fleet ✕ › fix the failing auth test and add an audit log call │ ┃ Fleet [ ○ off ] this project [ ● on ] [ ? h │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭────────────────────────────────────────── ⎿ Read 6 lines │ ┃ │ Lead agents ⏺ Update(src/auth.ts) │ ┃ │ Planner [ ● on ] [ opus ▾ ] thinks firs ⎿ Added 2 lines, removed 1 line │ ┃ │ Reviewer [ ○ off ] [ opus ▾ ] checks the ⏺ Bash(bun test) │ ┃ │ Designer [ ○ off ] [ opus ▾ ] polishes i ⎿ 3 pass, 1 fail │ ┃ ╰────────────────────────────────────────── │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╭────────────────────────────────────────── │ ┃ │ Workers · 2 ✻ Worked for 42s · done 4:20 PM │ ┃ │ [ − ] [ + ] count [ always all ▾ ] │ ┃ │ › /fleet │ ┃ │ Agent 1 [ same as main ▾ ] Agent 2 [ sa ⎿ agent-fleet: Agent fleet pane opened. Type /fleet help for every │ ┃ ╰────────────────────────────────────────── │ ┃ │ ┃ ╭────────────────────────────────────────── │ ┃ │ Options │ ┃ │ Files [ ● on ] Worktrees [ ○ off ] Mess │ ┃ │ Budget none · notify all · set with / │ ┃ ╰────────────────────────────────────────── │ ┃ │ ┃ ╭────────────────────────────────────────── │ ┃ │ Progress [ ✕ clear finished ] [ ◷ history │ ┃ │ No subagents yet. Send Claude a task whil ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent fleet
Fleet [ ○ off ] this project [ ● on ] [ ? help ] [ ◐ navy ] ╭────────────────────────────────────────────────────────── │ Lead agents │ Planner [ ● on ] [ opus ▾ ] thinks first, plans jobs an │ Reviewer [ ○ off ] [ opus ▾ ] checks the combined result │ Designer [ ○ off ] [ opus ▾ ] polishes into files, uploa ╰────────────────────────────────────────────────────────── ╭─────────────────────────────────────────────────────────╮ │ Workers · 2 │ │ [ − ] [ + ] count [ always all ▾ ] │ │ │ │ Agent 1 [ same as main ▾ ] Agent 2 [ same as main ▾ ] │ ╰─────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────── │ Options │ Files [ ● on ] Worktrees [ ○ off ] Messages [ full ▾ ] │ Budget none · notify all · set with /fleet bu… ╰────────────────────────────────────────────────────────── ╭────────────────────────────────────────────────────╮ │ Progress [ ✕ clear finished ] [ ◷ history ] │ │ No subagents yet. Send Claude a task while the │ │ fleet is on. │ ╰────────────────────────────────────────────────────╯ Agent fleet 0.7.0 · © Belsis Meletis · press a ▾ butt…
README

Agent fleet for Claude Code

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.

How a request flows

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.

Screenshots

The fleet pane: the lead agents (planner, reviewer, designer) each with a model, the workers and their models, and the options, beside /fleet help:

The fleet pane and its commands

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 at work

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:

Two workers running

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:

The designer stage

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

The designer's PDF

Features

The fleet

  • Worker slots: how many subagents a task uses (1–10) and which model each runs on: /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.
  • Lead planner (on by default): thinks the task through, names the deliverable (report, slides, website, code or data) and writes one self-contained brief per worker. A job can depend on others — ## 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.
  • Automatic sizing (/fleet auto on): the planner chooses between 1 and N workers per task instead of always N.
  • Reviewer (off by default): checks the combined draft against the workers' "unverified or conflicting" notes and corrects it.
  • Designer (off by default): turns the reviewed result into finished files by deliverable type — an improved Markdown copy plus .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.
  • Run folders (on by default): each request gets ~/.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.

Watching and controlling it

  • The pane (/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.
  • Status band above the prompt: the current request's stage (Planning, Workers running, Combining, Reviewing, Designing, Paused, Done), an overall progress bar, time, tokens and cost.
  • Real progress: workers report their steps through a small progress tool, so each row shows a true percentage and the step it is on.
  • Peek: open any agent to see its latest output and tool call, refreshed while it runs.
  • Rerun: run a finished agent again with the same brief plus a note ("cut it to 6,000 words").
  • Pause and resume — see Pausing agents.
  • Stop: per agent, Stop all for a request, or /fleet stop.
  • Fewer messages — see Quieter transcript.
  • Finish notification: a request of 30 seconds or longer ends with a chime (rising when finished, falling when stopped) and, on macOS, a system banner. /fleet notify on | off | sound | banner.
  • History: finished requests are kept across sessions; /fleet history (this project) or /fleet history all, and a history section in the pane with an open button for each run folder.
  • Budget — see Budget.
  • Per project: /fleet use off switches the fleet off in one project only.
  • Colours: seven schemes for the pane (b or /fleet theme), navy by default.

Pausing agents

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.

  • Pause one agent with its pause button, a whole request with Pause all (x), or every running agent with /fleet pause. Resume the same ways (/fleet resume).
  • Nothing is left half-written: the refused call never ran, and the agent is told so, so it repeats it after resuming.
  • While paused an agent costs nothing. The request stays open, the band says Paused, and Claude is told not to relaunch the agent or finish without it.
  • Resume wakes the agent with a message and it continues with its full context. If it cannot be woken, the fleet starts it again with its original brief, how far it had got and its partial result file, and says so.
  • A pause lasts for the session: agents belong to the session that started them, so resume them before you close it, or they end stopped.
  • Stop on a paused agent ends it for good. The budget keeps counting after a resume.

Quieter transcript

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:

ModeWhat you see
full (default)everything, as Claude Code shows it
compacteach agent notice or report folded into one dim line — who, what happened, its first words — ▸ agent completed · 2m31s — … (ctrl+o for all)
quietas 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.

Budget

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

  • The cost is read every second from the same running total /cost shows, counted from the moment you send the request — the main conversation's share included.
  • It warns at 80% and at the limit, with a toast and a line that stays in the transcript. With stop, it stops the request's agents, refuses any further agent until your next message, and tells Claude why.
  • It is a tripwire, not a hard cap. Cost is counted only when a model response finishes, and responses already running when the stop happens still complete, so a request with several agents can end noticeably above the limit (in testing, $1.32 against $0.50). Set it below what you can tolerate.
  • On a subscription such as Claude Max the figure is what the usage would cost at API prices, not what you are billed.

Worktrees: how parallel code changes come back together

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:

  • Worktrees live outside the repository (~/.claude/fleet-worktrees/<repo>/), on branches fleet/<request>/job-N, all cut from the commit you were on when the request started.
  • A worker may only write inside its worktree (and the run folder). File edits under your main checkout, shell commands that name it, and git commands that move or publish branches (push, checkout, switch, rebase, reset --hard, stash, merge, branch -D …) are refused.
  • When a worker finishes, anything it left uncommitted is committed on its branch. A worktree with no changes is removed.
  • Nothing is merged automatically. /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.
  • A worktree is removed, and its branch deleted with the safe git branch -d, only after the branch is confirmed merged. Nothing is ever forced.
  • The list of fleet worktrees is kept across sessions. Each new session in the repository reminds you of branches that still hold unmerged work; /fleet worktrees lists them, and /fleet detach N removes a worktree folder while keeping its branch.

Install on any laptop

What you need

  • Claude Code 2.1.294 or later. The fleet is a plugin of function hooks (the plugin API Claude Code calls "mods"), which older versions do not load. Check with claude --version; update with claude update.
  • git, if you will use worktrees.
  • Nothing else: no Node, npm or build step. Claude Code compiles the TypeScript itself.

Option A — from GitHub

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.

Option B — from a copy of this folder

  1. Get the folder onto the laptop: git clone https://github.com/mbelsis/Claude-Fleet.git "claude fleet", or copy it.
  2. Register the folder as a plugin marketplace and install from it:
   claude plugin marketplace add "/path/to/claude fleet"
   claude plugin install agent-fleet@belsis-plugins --scope user
  1. Start a new Claude Code session and type /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.

First-time setup

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.

Check, update, remove

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

Troubleshooting

  • /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.
  • The pane text is hard to read: your Claude Code theme and terminal background disagree. Run /theme and pick the matching one, or change the pane with /fleet theme.
  • "could not create a worktree": the project is not a git repository, or git refused. Turn worktrees off with /fleet worktrees off, or fix the repository.
  • A paused agent says it was "stopped": that is how Claude Code lists a paused agent; /fleet resume wakes it.

Commands

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

Develop

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.

Contents

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

Licence and disclaimer

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.

Source 3 files
hooks/register.tsx 2186 lines
1import { 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 lines
1// 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]
736
types/index.d.ts 172 lines
1export 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