SLOPSHOPPER

fleet

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

newpanebandguardcommandtoast
v0.17.0MITupdated 2026-10-05patrickkunzke/fleet/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · fleet
│ ┃ fleet ✕ › fix the failing auth test and add an audit log call │ ┃ Reading the board… │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /fleet │ ⎿ fleet: This session is not part of a fleet. `/fleet start` makes │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · fleet
Reading the board…
README

fleet

A chief of staff for Claude Code, as a herdr plugin.

You talk to one Claude Code session, the chief. It plans the work, breaks it into tasks on a shared board, and starts one Claude Code agent per repository, each in a herdr tab of its own, to do them. fleet shows you the crew: a live graph of who is working, who is waiting on you and who told whom what, with the board beside it.

It is for work that crosses repositories — a backend change, the service that consumes it, the UI on top — where one agent per repo keeps each one's context small, and something has to keep track of what waits on what.

The fleet tab in herdr: the chief on the left, the crew's graph and the board on the right

  • A chief that delegates. It plans and dispatches, and cannot change files, so it does not quietly do the work itself.
  • A go-ahead that holds. A worker cannot change files until its task has a go, from the chief with fleet board go or from you typing in its tab.
  • One agent per repository, each in a herdr tab named after it, briefed on its task, its repo, what it waits on and how to report.
  • A shared board of epics, tasks, dependencies and background processes, in SQLite, that every agent reads and writes through fleet board, with a /board skill that teaches it. Nothing to install: every agent starts with the skill loaded.
  • Messages that arrive. fleet board msg records a message on the board and puts it in front of the recipient, marked as coming from a peer. A Claude Code mod in each agent picks it up once the agent's turn has ended, whole; without mods (Claude Code before v2.1.287, or mods turned off) it is typed into the agent's pane instead.
  • A live graph of the crew: lines weighted by traffic, coloured by state, messages drawn in flight, and on each card the tool the agent is running and how full its context is. ↵ or a click goes to an agent's tab.
  • herdr's sidebar and notifications carry each agent's task, the board at a glance on the chief, and a notice when a task is blocked, in review or done.
  • Pick up where you left off. After a reboot or a herdr restart, fleet brings the whole crew back, each agent into its own conversation.

Requirements

  • Claude Code v2.1.287 or newer, for the mods that carry messages, hold the go-ahead and draw the fleet.
  • herdr 0.9.1 or newer, only to run fleet inside herdr, and its server too: an update leaves the old server running until herdr restarts, and herdr status shows both.
  • The native install of Claude Code (~/.local/bin/claude), or any claude named by FLEET_CLAUDE, which fleet starts its agents with.
  • A Rust toolchain (cargo) only on a machine without a prebuilt binary: macOS and Linux on arm64 and x86_64 have one.
  • macOS or Linux.

Without herdr

Outside herdr, start a fleet by running this in the workspace, the directory that holds your repositories:

fleet chief            # a new chief, and a new run
fleet chief --resume   # back into the last chief's conversation and its run

The terminal becomes the chief's Claude Code session: briefed, with the /board skill and fleet's mod, on the workspace's board. It refuses to start a second chief while the first is still running.

Or make a session you already have open the chief. In a Claude Code session with fleet's plugin loaded, in the workspace:

/fleet start

The session is put on the board as the chief of a new run, gets the board's tools and the fleet view, and receives the chief's brief as a turn. Claude Code keeps a plugin you install out of the system prompt, so the brief is part of the conversation; when the conversation is compacted, fleet puts the brief back at the head of what is kept. The plugin does nothing in a session until /fleet start.

fleet runs the agents the chief starts as Claude Code background sessions instead of herdr tabs. fleet spawn, fleet handoff, fleet board retire and resume work the same from any terminal: each agent is a claude --bg session in its repository, with the same brief, plugin and tools, and messages reach it through fleet's mod. claude agents lists the crew and says which one needs you; claude attach <id>, which fleet spawn prints, opens one to talk to.

The chief's own session draws the fleet, in place of the fleet tab:

  • /fleet opens a pane beside the conversation with the fleet view: the fleet tab in herdr, live and with its keys, drawn by fleet at the pane's size. Click into it, and ↑↓ ←→ move between agents, l switches to the log, n starts an agent, x retires one, r brings a crew back and q closes it; a click on a card selects it. ↵ on a background agent says how to open it (claude attach <id>), since there is no tab to switch to. A worker waiting for its go has a button beneath the view.
  • A band above the prompt keeps count, and names who needs something: fleet · 3 agents · 1 working · 4 open · billing needs a go · accounts at 84%.
  • Toasts say what herdr would have notified: a task blocked, in review or done, an agent waiting for a go or stopped at a permission prompt, one nearly out of context.

fleet tui runs in a plain terminal too, for the graph.

Two things to know:

  • Trust the workspace once. Claude Code refuses to start a background session in a folder it has not been trusted in. Trust carries down to the folders inside, so running claude once in the workspace and accepting the prompt covers every repository in it.
  • Messages need the mod. A background session has no prompt to type a message at, so an agent whose mod is not checking in (mods turned off, or Claude Code older than v2.1.287) only finds its messages on the board.

FLEET_HOST=herdr or FLEET_HOST=background picks one instead of going by where fleet runs. The setup check's running in row says which it will use.

Install

As a Claude Code plugin, in any terminal

claude plugin marketplace add patrickkunzke/fleet
claude plugin install fleet@fleet

Then start Claude Code in your workspace as you always do, and make that session the chief:

/fleet start

The plugin does nothing in a session until then. The first /fleet start downloads fleet's binary for your machine into the plugin's folder, checked against the release's SHA256SUMS; a fleet already on your PATH is used if that fails. See Without herdr for what the chief and its agents do from there.

In herdr

herdr plugin install patrickkunzke/fleet

herdr shows what it is about to run, clones the repository and runs its build step, scripts/install.sh. That downloads the release's prebuilt binary for your machine and checks it against the release's SHA256SUMS; it builds with cargo build --release --locked instead when there is no binary for your machine, or when the checkout is not a release commit. FLEET_BUILD=source always builds. Then, in ~/.config/herdr/config.toml:

[session]
# herdr would resume agents itself, as plain `claude --resume <id>`: no role,
# and a chief with its editing tools back. fleet resumes its crew instead.
resume_agents_on_restore = false

[ui.toast]
# herdr shows no notifications by default; fleet's go through it.
delivery = "system"   # or "herdr" for in-app toasts

[[keys.command]]
key = "prefix+f"
type = "plugin_action"
command = "fleet.open"
description = "fleet"

[[keys.command]]
key = "prefix+shift+f"
type = "plugin_action"
command = "fleet.new"
description = "new workspace in fleet mode"

To check the setup, run the fleet: check the setup action from herdr's action list (herdr plugin action invoke fleet.doctor), and read what it found with herdr plugin log list --plugin fleet. It checks herdr's server version, which claude fleet will start and whether it loads mods (v2.1.287 or newer, and disableAllHooks not set), and the two settings above, and says what to change.

The /board skill, and fleet on your PATH

Nothing to install for either. Every agent fleet starts has the /board skill loaded (as fleet:board, through Claude Code's --plugin-dir, for that session only) and fleet first on its PATH. Your own Claude Code sessions are left as they were: the board is for the crew.

To run fleet from your own shell as well:

ln -s "$(ls -d ~/.config/herdr/plugins/github/fleet-* | head -1)/target/release/fleet" ~/.local/bin/fleet

herdr keeps the plugin in the same directory across updates, so the link keeps working.

Updating

herdr has no update command of its own, so fleet has one:

fleet update --check   # the installed version and the newest release
fleet update           # install the newest release

It installs again through herdr at the newest release tag (vX.Y.Z), so herdr shows what it is about to run, as on the first install. herdr builds the new version beside the old one and swaps it in only when the build passes: a failed update leaves the fleet you had. For a checkout linked with herdr plugin link, it pulls main and builds instead, and stops if the checkout is on another branch or has uncommitted changes.

Without fleet on your PATH, the same thing by hand:

herdr plugin install patrickkunzke/fleet

After an update, a fleet view that is already open still runs the old version: press q in it, then prefix+f. The agents use the new fleet from their next command. The doctor action says when a newer release is out.

Quick start

  1. Open a herdr workspace at the directory that holds your repositories — ~/Code/acme, say, with service/billing-service and ui/storefront somewhere below it.
  2. Press prefix+f. The fleet tab opens with the chief on its left.
  3. Tell the chief what you want done. It writes the tasks to the board, starts an agent in each repository involved, and answers them when they ask to go ahead.

Every agent asks the chief before it starts changing code, and fleet holds it to that: its edits are refused until the chief gives its task a go with fleet board go, or you answer it in its own tab. The chief asks you about anything that is yours to decide. You can go to any agent's tab and talk to it directly; it is an ordinary Claude Code session.

prefix+shift+f opens a new workspace in fleet mode wherever the focused pane is. To have a directory's workspaces open in fleet mode by themselves, list it in ~/.claude-fleet/auto-open, one directory a line:

echo '~/Code/acme' >> ~/.claude-fleet/auto-open

It matches the workspace's own directory exactly, so a workspace opened in one repository inside it stays an ordinary one.

The fleet view

Key
← → ↑ ↓move between agents
↵, or a clickgo to that agent's tab
lthe log of every message and state change; l again for the graph
nstart an agent in a repository of your choosing
rbring back an earlier run
xtake an agent off the board (its tab is left alone)
qquit the view; the agents keep running

No key needs a prefix: none of them belongs to an agent.

The graph is the crew as it is shaped: the chief's card over its agents', a line from it into each. A line is heavy where the most has gone along it and dashed where nothing has, amber while the agent at its end is working and teal while it waits for you. A message is drawn travelling along its line as it is sent. An agent stopped at a permission or question dialog shows as ! needs you.

The board lists the open tasks by epic, with who holds each and what it waits on, and the background processes the agents have running. In a narrow pane it goes under the graph.

The sidebar. On each agent's second line, where herdr would say claude, fleet writes what it is on — ENG-2553-2 · waits on ENG-2553-1, ENG-2553-2 · review — and on the chief's, the board at a glance: 3 open · 1 blocked · 1 in review.

How it works

The chief and the workers

The chief is briefed as a chief of staff for the workspace: plan, write the tasks down, delegate, and answer the agents. It starts with --disallowed-tools Edit Write NotebookEdit, because asking was not enough — given a ticket touching a single repository, it did the work itself. fleet's mod (see Messages) also refuses the chief's Bash commands that write files: redirects, sed -i, tee, cp, mv, rm, git commit and the like, outside /tmp. Not airtight, since a pattern cannot know every way a command writes, but it closes the paths of least resistance.

A worker does not change files until its task has a go-ahead. It reads its task, sends the chief its plan, and its Edit, Write and file-writing Bash calls are refused, with a reason that says to wait, until either the chief answers with

fleet board go ENG-2553-2 "go — keep the old column until billing is on it"

which records the go and sends the message in one step, or you type a prompt in the worker's own tab: talking to it there is your go. A worker cannot run fleet board go itself. Anything short of a go, such as a question or "hold off", is a fleet board msg and leaves it waiting. A task approved before anyone claims it is approved for whoever does. Like message delivery, this needs the mod: without it, the go-ahead is only asked for, as it was.

A worker waiting for its go is easy to spot. Its card in the graph reads ◇ needs a go once it is idle (● planning while it is still reading and writing its plan), the header counts it, its herdr sidebar label says ENG-2553-2 · needs a go, and herdr notifies you once when it stops to wait. In the worker's own tab, the line under its prompt says waiting for a go on ENG-2553-2. A task that is blocked, or waits on another, is waiting on that instead, and is not shown as needing a go.

The mod also reports what its session is doing each time it checks in. A working agent's card names the tool it is running (● Bash, an MCP tool by its own name without the server's), and the top right gives how full its context window is beside its uptime, 41m · 63%, with the uptime dropped when a long name leaves no room. From 80% the figure turns to the accent colour and herdr notifies you once: an agent that compacts keeps a summary of its brief rather than the brief, and a fresh agent may be the better one to finish its task. An agent without the mod shows only ● working.

It delegates with fleet spawn, which opens a tab in the repository, briefs the agent, and claims the task on the board in the same step, so it cannot be dispatched twice:

fleet spawn billing-svc --repo ~/Code/acme/service/billing-service --task ENG-2553-2

An agent nearly out of context is handed off rather than left to compact:

fleet handoff billing-svc --note "the flaky test is known; skip it"

A fresh session starts in the same repository under the same name, on the same task, briefed on the task, its messages on the board, the old session's last reply and the note, and told to look at the branch and git status before anything else. A go-ahead the task had stands. The old session is retired and its tab closed; an agent mid-turn is refused unless --now is given. The chief is told when a worker passes 80% context, with the command to run, and so are you, through herdr.

A retired session that is still running, because its tab was left open, changes nothing more: fleet's mod refuses its edits, and tells it once, when it is next idle, to stop.

When an agent is finished with, the chief retires it with fleet board retire billing-svc, which takes it off the board and closes its tab. A working agent's tab is left open, and --keep-tab leaves it either way. x in the fleet view only takes an agent off the board.

Each agent is briefed in two halves. Who it is goes into the system prompt, with --append-system-prompt, where it outranks whatever a hook injects later and survives compaction. What to do now is its first turn: the task, its body, what it waits on, and how to report.

The board

One SQLite file per fleet, at ~/.claude-fleet/fleets/<name>/fleet.db, holding its epics, tasks and their dependencies, agents, runs, background processes, and every event. A fleet is one workspace directory, and its board is its own: agents are handed theirs in FLEET_DB, and a Claude Code session fleet did not start is refused (not part of a fleet) rather than written onto somebody else's.

fleet board epic ENG-2553 "shared settings flag"
fleet board add ENG-2553-1 ~/Code/acme/service/accounts-service "shared column" --epic ENG-2553
fleet board add ENG-2553-2 ~/Code/acme/service/billing-service "consume it" --epic ENG-2553 --dep ENG-2553-1
fleet board ready                  # only ENG-2553-1: the other one is waiting
fleet board start ENG-2553-1
fleet board done ENG-2553-1        # and says it unblocked ENG-2553-2
fleet board ls
fleet board log

fleet board --help lists the rest. FLEET_JSON=1 gives JSON from any read. fleet board sql takes a SELECT and refuses anything else: every write goes through a verb that records its event, and the log is only as good as that. From a shell, fleet fleets lists the fleets and --fleet <name> picks one.

Messages

$ fleet board msg accounts-svc chief "the column is in" --task ENG-2553-1
accounts-svc -> chief: the column is in
      queued for chief: it arrives when chief is next idle

The message is written to the board first, then delivered, marked so the agent does not take a peer for the person at the keyboard: [fleet · accounts-svc · ENG-2553-1] the column is in.

Every agent fleet starts carries a small Claude Code mod beside the /board skill. It checks the board every few seconds, and once its session's turn has ended it takes what is waiting and submits it as a prompt of its own: the whole body, its line breaks kept, several messages in one prompt. While a turn runs, the line under the prompt says how many are waiting. Nothing is typed into a prompt the agent is busy at.

An agent whose mod is not checking in — Claude Code older than v2.1.287, mods turned off with disableAllHooks or --safe-mode, or a session that is not running — gets the old delivery instead: the message typed at its prompt through herdr, on one line, cut at 1500 characters, and held at a permission dialog rather than typed into it. Neither delivery ever costs the record: a message to an agent that has gone is still on the board.

A message an agent sends some other way and never logs does not appear in the graph. The board is the record, by design.

Runs, and picking up where you left off

A reboot or a herdr restart takes the agents' processes, not their conversations. A run records which conversations belonged together: the chief and every agent it started, each with its session. Open fleet in a workspace with nothing running and a run to come back to, and it asks:

 pick up where you left off

 ▌ 28m ago · the chief + 2 agents
 ▌   ENG-2155-fixes, ENG-2155-review, ENG-2155, staging-fix
 ▌   chief, eng-2155, eng-2155-review
   start fresh — a new chief, nothing brought back

Choosing one starts each agent again with claude --resume, in its own repository and its own tab, with its role back in the system prompt and the chief's editing tools still withheld. An agent you took off with x stays off, and one whose conversation is gone from disk is named rather than resumed into an empty session.

Configuration

~/.claude-fleet/auto-opendirectories whose new workspaces open in fleet mode
FLEET_CLAUDEthe claude to start, by path. Otherwise ~/.local/bin/claude, then whatever is on PATH
FLEET_DBthe board a command uses. Set for every agent fleet starts
FLEET_JSON=1JSON from fleet board reads

fleet keeps everything under ~/.claude-fleet: each fleet's board, and the briefs each agent was started with (briefs/), which are what it was told, and the Claude Code plugin that carries the /board skill (claude-plugin/). It reads Claude Code's session registry (~/.claude/sessions) to see who is alive, and sends nothing anywhere.

Troubleshooting

↵ or a click does nothing for an agent in another tab. herdr's server is older than 0.9.1, whose agent focus moves herdr's focus but not your screen. herdr status shows the server's version; restart herdr to run the updated one.

An agent's tab opened and nothing happened. It is probably at Claude Code's folder-trust dialog, in a repository it has not seen. Answer it in the tab; the fleet view links the session once it has one.

The agent that started is an old Claude Code. A new pane's shell rebuilds PATH, and a Node version manager can put an old npm install of Claude Code first. fleet starts ~/.local/bin/claude by default for this reason; point FLEET_CLAUDE at the one you use.

Anything else: run the doctor action (see Install), and look at herdr plugin log list --plugin fleet.

Development

git clone https://github.com/patrickkunzke/fleet && cd fleet
cargo build --release
herdr plugin link .         # herdr runs your working tree
./install.sh                # fleet on your PATH
cargo test

herdr plugin link does not build; rebuild after a change, then reopen the view with q and prefix+f. The agents keep running across both.

Pull requests and commits

Every change reaches main through a pull request, merged with a merge commit; nothing is pushed to main directly. Each commit in a pull request is a conventional commit — feat: …, fix(board): …, docs: … — and CI checks that they are (sh scripts/check-commits.sh origin/main does the same locally).

Releases

Merging is releasing. When a pull request is merged, the release job reads the commits since the last vX.Y.Z tag and picks the version:

CommitsRelease
feat!: …, fix!: …, or a BREAKING CHANGE: footermajor (minor before 1.0)
feat: …minor
fix: …, perf: …patch
only docs, ci, chore, refactor, test, …none

It raises the version in Cargo.toml, Cargo.lock and herdr-plugin.toml, adds the release's section to CHANGELOG.md from the commits (git-cliff, cliff.toml), commits that as chore(release): vX.Y.Z, tags it, and publishes the GitHub Release. fleet update moves users to it.

Then binaries.yml builds fleet for macOS and Linux (static, musl), on arm64 and x86_64, and uploads the four binaries and their SHA256SUMS to the release. That takes a few minutes; an install in between builds from source. To give an existing release its binaries, run the workflow by hand: gh workflow run binaries.yml -f tag=v0.2.1.

For layout work there is a fixture fleet that needs neither herdr nor a real board:

./dev.sh                    # rebuild and redraw on every save
./dev.sh 140x40 log         # a size and a view: graph, log
fleet preview --plain       # one frame as text, for a diff

Credits

herdr's client code (src/herdr.rs) is adapted from herdr-projects, MIT; see NOTICE. The graph borrows its idea of liveness drawn on the structure itself from zoetrope.

License

MIT — see LICENSE.

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