A side pane with this session's subagents, background shells and the repo's worktrees, with alerts when they finish or fail.

A side pane with this session's subagents, background shells and the repo's worktrees.
FLEET · deployer
AGENTS 2 running · 1 done · 0 failed
▶ rust-reviewer 6m12s
review plan · Bash cargo clippy
✓ Explore 2m03s · 10:42
map snapshot callers
BACKGROUND
▶ cargo test --workspace 2m03s
✓ cargo build 41s · 10:38
WORKTREES
main clean
p31-snap ●3 changed ↑2
Read-only. Elapsed times tick live; a finished agent or shell also shows when it finished (with the date when not today).
claude plugin install fleet@vampik-plugins
/fleet toggles the pane any time. Once an agent or shell has run (or while the pane is open), a line above the prompt shows fleet 2▶ 1✓ 0✗ · bg 1 show: click show/hide, or focus the band (ctrl+x tab) and press f.paplay, freedesktop sounds) on a failure and on all done.idleMinutes, through telegram-notifier's bot (TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID).Named agents (agent teams) go idle between turns: they show as done without a toast.
agent.spawn and tool.call record agents and their last tool; $.agent.list() is polled every 2 s while any runs; a turn's end and task notifications report finishes at once; at each turn's end a background shell no longer in the engine's in-flight list is marked lost (?), its exit unknown; worktrees come from git worktree list every 15 s while the pane is shown.
| Option | Default | |
|---|---|---|
autoOpen | true | open the pane at 2+ running agents |
sound | true | chime on failure and all done |
telegram | true | Telegram summary when away |
idleMinutes | 5 | away after this long since your last prompt |
Needs function hooks (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1).
hooks/register.tsx 435 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import type { FleetAgent, FleetShell, FleetState } from "../types";
5import {
6 applyStatuses,
7 emptyFleet,
8 endShell,
9 endToast,
10 isFailed,
11 isLive,
12 liveCount,
13 loseShells,
14 mayNotify,
15 parseNotification,
16 parseStatus,
17 parseWorktrees,
18 statusLine,
19 summary,
20 toolLabel,
21 trim,
22} from "./core.ts";
23import { drawFleet, type FleetView } from "./view.tsx";
24
25const PANE = "fleet";
26const ENDED = new Set(["completed", "failed", "killed"]);
27const TOAST_GAP_MS = 2_100;
28
29const fleet = atom({ plugin: "fleet", key: "fleet" } as const, emptyFleet());
30const pane = atom({ plugin: "fleet", key: "pane" } as const, false);
31
32/** What the module keeps outside `$.state`; a reload starts it over. */
33type Tracker = {
34 autoOpen: boolean;
35 sound: boolean;
36 telegram: boolean;
37 idleMs: number;
38 repo: string;
39 root: string | undefined;
40 lastPrompt: number;
41 poll: { cancel: () => void } | undefined;
42};
43
44function chime($: EngineInterface, sound: "complete" | "dialog-error") {
45 // $.audio has no player on a Linux terminal; paplay does
46 void $.process
47 .run(["paplay", `/usr/share/sounds/freedesktop/stereo/${sound}.oga`])
48 .catch(() => undefined);
49}
50
51async function sendTelegram($: EngineInterface, text: string): Promise<void> {
52 const none = () => undefined;
53 const token = await $.env.get("TELEGRAM_BOT_TOKEN").catch(none);
54 const chat = await $.env.get("TELEGRAM_CHAT_ID").catch(none);
55 if (!token || !chat) return;
56 const res = await $.http
57 .fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
58 method: "POST",
59 headers: { "Content-Type": "application/json" },
60 body: JSON.stringify({ chat_id: chat, text }),
61 })
62 .catch(none);
63 if (!res?.ok)
64 $.ui.log(`fleet: Telegram send failed (${res?.status ?? "no response"})`, {
65 to: "debug",
66 });
67}
68
69/** Whether the engine draws the pane: `isShown` is false behind other tabs, so not asked. */
70async function isPaneDrawn($: EngineInterface): Promise<boolean> {
71 const panes = await $.ui.panes().catch(() => []);
72 return panes.some((p) => p.id === PANE && p.isPlaced);
73}
74
75async function refreshTrees($: EngineInterface, t: Tracker): Promise<void> {
76 if (!t.root) return;
77 const list = await $.process
78 .run(["git", "worktree", "list", "--porcelain"], { cwd: t.root })
79 .catch(() => undefined);
80 if (!list || list.exitCode !== 0) return;
81 const trees = await Promise.all(
82 parseWorktrees(list.stdout).map(async (w) => {
83 const st = await $.process
84 .run(["git", "status", "--porcelain=v1", "--branch"], { cwd: w.path })
85 .catch(() => undefined);
86 const counts =
87 st && st.exitCode === 0
88 ? parseStatus(st.stdout)
89 : { changed: 0, ahead: 0, behind: 0 };
90 return { ...w, ...counts };
91 }),
92 );
93 await update($, fleet, (f) => ({ ...f, trees }));
94}
95
96/** Toasts each item that just ended; a chime for a failure. */
97function announce(
98 $: EngineInterface,
99 t: Tracker,
100 agents: FleetAgent[],
101 shells: FleetShell[],
102) {
103 for (const a of agents) {
104 if (!ENDED.has(a.status)) continue; // a teammate gone idle is no finish
105 $.ui.toast(
106 endToast(a.type, a.description, a.status, a.endedAt! - a.startedAt),
107 );
108 if (isFailed(a.status) && t.sound) chime($, "dialog-error");
109 }
110 for (const s of shells) {
111 $.ui.toast(
112 endToast("shell", s.command, s.status, s.endedAt! - s.startedAt),
113 );
114 if (isFailed(s.status) && t.sound) chime($, "dialog-error");
115 }
116}
117
118/** When nothing is live any more: the all-done alert, and the batch closes. */
119async function settle($: EngineInterface, t: Tracker): Promise<void> {
120 const f = await read($, fleet);
121 if (liveCount(f) > 0) return;
122 t.poll?.cancel();
123 t.poll = undefined;
124 if (f.batchStart === undefined) return;
125
126 const batch = f.batchStart;
127 const size =
128 f.agents.filter((a) => a.startedAt >= batch).length +
129 f.shells.filter((s) => s.startedAt >= batch).length;
130 const now = Date.now();
131 const text = summary(f, t.repo, now);
132 await update($, fleet, ({ batchStart: _, ...g }) => ({
133 ...g,
134 autoOpened: false,
135 }));
136 // one item's own toast says it all; the all-done alert is for a batch
137 if (size >= 2) {
138 // the engine drops a toast within 2 s of the last, and the last item's just showed
139 const done = text.split("\n").slice(1, 3).join(" · ");
140 $.clock.after(TOAST_GAP_MS, () => $.ui.toast(done));
141 if (t.sound) chime($, "complete");
142 }
143 if (t.telegram && now - t.lastPrompt >= t.idleMs) await sendTelegram($, text);
144}
145
146/** One look at `$.agent.list()`: status changes, their alerts, maybe all done. */
147async function tick($: EngineInterface, t: Tracker): Promise<void> {
148 const list = await $.agent.list().catch(() => undefined);
149 if (!list) return;
150 const statuses = Object.fromEntries(list.map((a) => [a.id, a.status]));
151 let ended: FleetAgent[] = [];
152 await update($, fleet, (f) => {
153 const r = applyStatuses(f.agents, statuses, Date.now());
154 ended = r.ended;
155 return { ...f, agents: r.agents };
156 });
157 announce($, t, ended, []);
158 await settle($, t);
159}
160
161function ensurePoll($: EngineInterface, t: Tracker) {
162 if (!t.poll) t.poll = $.clock.every(2000, () => void tick($, t));
163}
164
165/** A task notification or a turn's end: one agent or shell reached `status`. */
166async function endTask(
167 $: EngineInterface,
168 t: Tracker,
169 id: string,
170 status: string,
171): Promise<void> {
172 let agents: FleetAgent[] = [];
173 let shell: FleetShell | undefined;
174 await update($, fleet, (f) => {
175 const now = Date.now();
176 const a = applyStatuses(f.agents, { [id]: status }, now);
177 const s = endShell(f.shells, id, status, now);
178 agents = a.ended;
179 shell = s.ended;
180 return { ...f, agents: a.agents, shells: s.shells };
181 });
182 announce($, t, agents, shell ? [shell] : []);
183 await settle($, t);
184}
185
186async function openPane($: EngineInterface, t: Tracker) {
187 const opened = await $.ui.open({ id: PANE, title: `Fleet · ${t.repo}` });
188 // an unasked open on a narrow terminal waits undrawn: still "show"
189 await update($, pane, () => opened.isPlaced);
190 void refreshTrees($, t);
191 return opened;
192}
193
194/** Closes the pane if the stored state says shown (undefined), opens it if not (how it opened). */
195async function togglePane($: EngineInterface, t: Tracker) {
196 if (await read($, pane)) {
197 await $.ui.close({ id: PANE });
198 await update($, pane, () => false);
199 return undefined;
200 }
201 return openPane($, t);
202}
203
204const textOf = (content: unknown): string =>
205 typeof content === "string"
206 ? content
207 : Array.isArray(content)
208 ? content
209 .filter((b) => b?.type === "text" && typeof b.text === "string")
210 .map((b) => b.text as string)
211 .join("\n")
212 : "";
213
214export const register: Register = (on, options) => {
215 const t: Tracker = {
216 autoOpen: options.autoOpen !== false,
217 sound: options.sound !== false,
218 telegram: options.telegram !== false,
219 idleMs:
220 (typeof options.idleMinutes === "number" && options.idleMinutes >= 0
221 ? options.idleMinutes
222 : 5) * 60_000,
223 repo: "",
224 root: undefined,
225 lastPrompt: Date.now(),
226 poll: undefined,
227 };
228
229 on("session.start", async ($, e, next) => {
230 const r = await next(e);
231 await $.command.register({
232 name: "fleet",
233 description:
234 "Show or hide the pane with this session's agents, background shells and worktrees",
235 });
236 const top = await $.process
237 .run(["git", "rev-parse", "--show-toplevel"])
238 .catch(() => undefined);
239 t.root = top?.exitCode === 0 ? top.stdout.trim() : undefined;
240 const cwd = await $.session.cwd().catch(() => "");
241 t.repo = (t.root ?? cwd).split("/").pop() || "session";
242 // a reload keeps an open pane
243 const drawn = await isPaneDrawn($);
244 await update($, pane, () => drawn);
245 $.clock.every(15_000, async () => {
246 let shown = await read($, pane);
247 // a waiting pane gets drawn once the terminal widens
248 if (!shown && (await isPaneDrawn($))) {
249 shown = true;
250 await update($, pane, () => true);
251 }
252 if (shown) await refreshTrees($, t);
253 });
254 // a reload while agents run: pick the poll up again
255 if ((await read($, fleet)).agents.some((a) => isLive(a.status)))
256 ensurePoll($, t);
257 return r;
258 });
259
260 on("session.end", async ($, e, next) => {
261 if (e.reason === "clear") {
262 t.poll?.cancel();
263 t.poll = undefined;
264 await update($, fleet, (f) => ({ ...emptyFleet(), trees: f.trees }));
265 }
266 return next(e);
267 });
268
269 on("command.run", { command: "fleet" }, async ($) => {
270 const opened = await togglePane($, t);
271 return {
272 text: !opened
273 ? "Fleet pane closed."
274 : opened.isPlaced
275 ? "Fleet pane opened."
276 : "Fleet pane is waiting for a wider terminal.",
277 };
278 });
279
280 on("prompt.submit", async ($, e, next) => {
281 if (e.origin.kind === "composer") t.lastPrompt = Date.now();
282 return next(e);
283 });
284
285 on("agent.spawn", async ($, e, next) => {
286 const r = await next(e);
287 if (r.deny !== undefined || !r.agentId) return r;
288 const agent: FleetAgent = {
289 id: r.agentId,
290 type: e.subagentType,
291 description: e.description,
292 model: r.model,
293 startedAt: Date.now(),
294 status: "running",
295 tools: 0,
296 ...(e.isTeammate ? { teammate: true } : {}),
297 };
298 let open = false;
299 await update($, fleet, (f) => {
300 const agents = trim([...f.agents, agent]);
301 const live = agents.filter((a) => isLive(a.status)).length;
302 open = t.autoOpen && !f.autoOpened && live >= 2;
303 return {
304 ...f,
305 agents,
306 batchStart: f.batchStart ?? agent.startedAt,
307 autoOpened: f.autoOpened || open,
308 };
309 });
310 ensurePoll($, t);
311 if (open && !(await read($, pane))) void openPane($, t);
312 return r;
313 });
314
315 on("tool.call", async ($, e, next) => {
316 if (e.agentId) {
317 const id = e.agentId;
318 const label = toolLabel(String(e.tool), e);
319 await update($, fleet, (f) =>
320 f.agents.some((a) => a.id === id)
321 ? {
322 ...f,
323 agents: f.agents.map((a) =>
324 a.id === id ? { ...a, lastTool: label, tools: a.tools + 1 } : a,
325 ),
326 }
327 : f,
328 );
329 }
330 const r = await next(e);
331 if (e.tool !== "Bash" || r.deny !== undefined) return r;
332 const taskId = (r.result as { backgroundTaskId?: string } | undefined)
333 ?.backgroundTaskId;
334 if (!taskId) return r;
335 const shell: FleetShell = {
336 id: taskId,
337 command: e.command,
338 startedAt: Date.now(),
339 status: "running",
340 };
341 await update($, fleet, (f) => ({
342 ...f,
343 shells: trim([...f.shells.filter((s) => s.id !== taskId), shell]),
344 batchStart: f.batchStart ?? shell.startedAt,
345 }));
346 return r;
347 });
348
349 // a background task's notification: a shell or an agent finished
350 on("session.append", async ($, e, next) => {
351 const r = await next(e);
352 if (!mayNotify(e.message)) return r;
353 const note = parseNotification(textOf(e.message.content));
354 if (note) await endTask($, t, note.id, note.status);
355 return r;
356 });
357
358 // a main turn's end lists the tasks still in flight: a live shell missing
359 // from it ended with no notification (a restart, a kill), so it is lost
360 on("classic.Stop", async ($, e, next) => {
361 const r = await next(e);
362 if (!e.background_tasks) return r;
363 const inFlight = new Set(e.background_tasks.map((b) => b.id));
364 await update($, fleet, (f) => ({
365 ...f,
366 shells: loseShells(f.shells, inFlight, Date.now()),
367 }));
368 await settle($, t);
369 return r;
370 });
371
372 on("turn.complete", async ($, e, next) => {
373 const r = await next(e);
374 if (!e.agentId) return r;
375 const f = await read($, fleet);
376 const a = f.agents.find((x) => x.id === e.agentId);
377 if (a && !a.teammate && isLive(a.status))
378 await endTask(
379 $,
380 t,
381 a.id,
382 e.isAborted ? "killed" : e.reason === "error" ? "failed" : "completed",
383 );
384 return r;
385 });
386
387 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
388 const f = await read($, fleet);
389 const view: FleetView = {
390 repo: t.repo,
391 width: e.props.bodyColumns,
392 agents: f.agents,
393 shells: f.shells,
394 trees: f.trees,
395 };
396 if (e.surface === "terminal" || e.surface === "desktop") {
397 const { Client } = $.ui.resolve(e);
398 return <Client key="fleet-body" module="./pane.tsx" props={view} />;
399 }
400 return drawFleet($.ui.resolve(e), view, Date.now());
401 });
402
403 // its own line under the other bands: the counts and a show/hide toggle
404 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
405 const f = await read($, fleet);
406 const open = await read($, pane);
407 const empty = f.agents.length === 0 && f.shells.length === 0;
408 if (e.props.hasSurvey || (empty && !open)) return next(e);
409 const below = await next(e);
410 const { Box, Button, Text } = $.ui.resolve(e);
411 return (
412 <Box flexDirection="column">
413 {below}
414 <Box flexDirection="row" columnGap={2} marginTop={1}>
415 <Text dimColor>{statusLine(f) ?? "fleet"}</Text>
416 <Button
417 key="fleet-toggle"
418 hotkey="f"
419 dimColor
420 onPress={() => togglePane($, t)}
421 >
422 {open ? "hide" : "show"}
423 </Button>
424 </Box>
425 </Box>
426 );
427 });
428
429 on("ui.close", { id: PANE }, async ($, e, next) => {
430 const r = await next(e);
431 await update($, pane, () => false);
432 return r;
433 });
434};
435hooks/core.ts 249 lines1/**
2 * core.ts — the pure half of fleet: status transitions, parsing git and task
3 * notifications, and the words of the toasts, status line and summary.
4 */
5
6import type { FleetAgent, FleetShell, FleetState, FleetTree } from "../types";
7
8/** Most agents and shells kept; the oldest ended ones fall off. */
9export const KEEP = 40;
10
11export const emptyFleet = (): FleetState => ({
12 agents: [],
13 shells: [],
14 trees: [],
15 autoOpened: false,
16});
17
18/** Still going: not started, running a turn, or held. */
19export const isLive = (status: string) =>
20 status === "pending" || status === "running" || status === "waiting";
21
22export const isFailed = (status: string) =>
23 status === "failed" || status === "killed";
24
25/** 45s, 6m12s, 1h04m */
26export function fmtElapsed(ms: number): string {
27 const total = Math.max(0, Math.floor(ms / 1000));
28 const h = Math.floor(total / 3600);
29 const m = Math.floor((total % 3600) / 60);
30 const s = total % 60;
31 if (h > 0) return `${h}h${String(m).padStart(2, "0")}m`;
32 return m > 0 ? `${m}m${String(s).padStart(2, "0")}s` : `${s}s`;
33}
34
35const MONTHS = [
36 "Jan",
37 "Feb",
38 "Mar",
39 "Apr",
40 "May",
41 "Jun",
42 "Jul",
43 "Aug",
44 "Sep",
45 "Oct",
46 "Nov",
47 "Dec",
48];
49
50const pad = (n: number) => String(n).padStart(2, "0");
51
52/** 16:42, with "Oct 4 " in front when it is not today. */
53export function fmtClock(at: number, now: number): string {
54 const d = new Date(at);
55 const clock = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
56 return d.toDateString() === new Date(now).toDateString()
57 ? clock
58 : `${MONTHS[d.getMonth()]} ${d.getDate()} ${clock}`;
59}
60
61/** Keeps every live item and the newest ended ones, `keep` in all. */
62export function trim<T extends { status: string; startedAt: number }>(
63 items: T[],
64 keep = KEEP,
65): T[] {
66 if (items.length <= keep) return items;
67 const ended = items.filter((i) => !isLive(i.status));
68 const drop = new Set(ended.slice(0, items.length - keep));
69 return items.filter((i) => !drop.has(i));
70}
71
72const firstWord = (v: unknown) =>
73 typeof v === "string" ? v.trim().split(/\s+/).slice(0, 3).join(" ") : "";
74const baseName = (v: unknown) =>
75 typeof v === "string" ? (v.split("/").pop() ?? "") : "";
76
77/** A tool call in a few words: "Bash cargo clippy", "Read apply.rs", "Grep snapshot_". */
78export function toolLabel(tool: string, input: unknown): string {
79 const i = (input ?? {}) as Record<string, unknown>;
80 let arg = "";
81 if (tool === "Bash") arg = firstWord(i.command);
82 else if (typeof i.file_path === "string") arg = baseName(i.file_path);
83 else if (typeof i.pattern === "string") arg = i.pattern;
84 else if (typeof i.description === "string") arg = i.description;
85 const label = arg ? `${tool} ${arg}` : tool;
86 return label.length > 40 ? `${label.slice(0, 39)}…` : label;
87}
88
89/**
90 * Whether a kept row may carry a task notification: a user row when the
91 * session is idle, a queued_command attachment when a running turn absorbs it.
92 */
93export const mayNotify = (m: { type: string; name?: string }) =>
94 m.type === "user" || (m.type === "attachment" && m.name === "queued_command");
95
96/** A task notification's id and status, from the row's text. */
97export function parseNotification(
98 text: string,
99): { id: string; status: string } | undefined {
100 if (!text.includes("<task-notification>")) return undefined;
101 const id = /<task-id>([^<]+)<\/task-id>/.exec(text)?.[1]?.trim();
102 const status = /<status>([^<]+)<\/status>/.exec(text)?.[1]?.trim();
103 return id && status ? { id, status } : undefined;
104}
105
106/**
107 * Applies statuses (from `$.agent.list()`, a notification, a turn's end) to
108 * the agents; answers the new list and the agents that just ended.
109 */
110export function applyStatuses(
111 agents: FleetAgent[],
112 statuses: Record<string, string>,
113 now: number,
114): { agents: FleetAgent[]; ended: FleetAgent[] } {
115 const ended: FleetAgent[] = [];
116 const next = agents.map((a) => {
117 const status = statuses[a.id];
118 if (status === undefined || status === a.status) return a;
119 // a resumed agent goes live again under the same id
120 if (isLive(status)) {
121 const { endedAt: _, ...rest } = a;
122 return { ...rest, status };
123 }
124 const changed = { ...a, status, endedAt: a.endedAt ?? now };
125 if (isLive(a.status)) ended.push(changed);
126 return changed;
127 });
128 return { agents: next, ended };
129}
130
131/** A shell the engine stopped tracking with no notification: its exit is unknown. */
132export const LOST = "lost";
133
134/**
135 * Marks a shell ended by its task id; answers it, or undefined if it is not a
136 * live or lost one. A lost one's late notification still sets its real status.
137 */
138export function endShell(
139 shells: FleetShell[],
140 id: string,
141 status: string,
142 now: number,
143): { shells: FleetShell[]; ended?: FleetShell } {
144 let ended: FleetShell | undefined;
145 const next = shells.map((s) => {
146 if (s.id !== id || !(isLive(s.status) || s.status === LOST)) return s;
147 ended = { ...s, status, endedAt: s.endedAt ?? now };
148 return ended;
149 });
150 return { shells: next, ended };
151}
152
153/** Marks lost each live shell the engine no longer has in flight. */
154export function loseShells(
155 shells: FleetShell[],
156 inFlight: ReadonlySet<string>,
157 now: number,
158): FleetShell[] {
159 return shells.map((s) =>
160 isLive(s.status) && !inFlight.has(s.id)
161 ? { ...s, status: LOST, endedAt: now }
162 : s,
163 );
164}
165
166export const liveCount = (f: FleetState) =>
167 f.agents.filter((a) => isLive(a.status)).length +
168 f.shells.filter((s) => isLive(s.status)).length;
169
170/** "✓ rust-reviewer · review apply · 6m12s" or "✗ Explore failed · map callers · 2m03s" */
171export function endToast(
172 name: string,
173 what: string,
174 status: string,
175 ms: number,
176): string {
177 const head = isFailed(status) ? `✗ ${name} ${status}` : `✓ ${name}`;
178 return [head, what, fmtElapsed(ms)].filter(Boolean).join(" · ");
179}
180
181/** The batch's counts on the band line above the prompt: "fleet 2▶ 1✓ 0✗ · bg 1" */
182export function statusLine(f: FleetState): string | undefined {
183 const batch = f.batchStart;
184 if (batch === undefined) return undefined;
185 const agents = f.agents.filter((a) => a.startedAt >= batch);
186 const run = agents.filter((a) => isLive(a.status)).length;
187 const fail = agents.filter((a) => isFailed(a.status)).length;
188 const done = agents.length - run - fail;
189 const bg = f.shells.filter((s) => isLive(s.status)).length;
190 return `fleet ${run}▶ ${done}✓ ${fail}✗${bg ? ` · bg ${bg}` : ""}`;
191}
192
193/** The all-done message: how many, how long, what failed, which shells. */
194export function summary(f: FleetState, repo: string, now: number): string {
195 const batch = f.batchStart ?? now;
196 const agents = f.agents.filter((a) => a.startedAt >= batch);
197 const shells = f.shells.filter((s) => s.startedAt >= batch);
198 const failed = agents.filter((a) => isFailed(a.status));
199 const lines = [`🤖 fleet · ${repo}`];
200 if (agents.length) {
201 lines.push(
202 `All ${agents.length} agent${agents.length === 1 ? "" : "s"} done in ${fmtElapsed(now - batch)}`,
203 );
204 const fails = failed.length
205 ? ` ✗ ${failed.length} failed (${failed.map((a) => `${a.type}: ${a.description}`).join("; ")})`
206 : "";
207 lines.push(`✓ ${agents.length - failed.length} completed${fails}`);
208 } else {
209 lines.push(`All done in ${fmtElapsed(now - batch)}`);
210 }
211 // no command text leaves the device: any word of it may hold a secret
212 if (shells.length) {
213 const bad = shells.filter((s) => isFailed(s.status)).length;
214 const lost = shells.filter((s) => s.status === LOST).length;
215 lines.push(
216 `Background shells: ✓ ${shells.length - bad - lost}${bad ? ` ✗ ${bad}` : ""}${lost ? ` ? ${lost}` : ""}`,
217 );
218 }
219 return lines.join("\n");
220}
221
222/** `git worktree list --porcelain` → each tree's path and branch (or short head). */
223export function parseWorktrees(
224 porcelain: string,
225): { path: string; name: string }[] {
226 const trees: { path: string; name: string }[] = [];
227 for (const block of porcelain.split(/\n\n+/)) {
228 const path = /^worktree (.+)$/m.exec(block)?.[1];
229 if (!path || /^bare$/m.test(block)) continue;
230 const branch = /^branch refs\/heads\/(.+)$/m.exec(block)?.[1];
231 const head = /^HEAD ([0-9a-f]{7})/m.exec(block)?.[1];
232 trees.push({ path, name: branch ?? `(${head ?? "detached"})` });
233 }
234 return trees;
235}
236
237/** `git status --porcelain=v1 --branch` → changed files and ahead/behind of upstream. */
238export function parseStatus(
239 porcelain: string,
240): Pick<FleetTree, "changed" | "ahead" | "behind"> {
241 const lines = porcelain.split("\n").filter(Boolean);
242 const head = lines[0]?.startsWith("## ") ? lines.shift()! : "";
243 return {
244 changed: lines.length,
245 ahead: Number(/ahead (\d+)/.exec(head)?.[1] ?? 0),
246 behind: Number(/behind (\d+)/.exec(head)?.[1] ?? 0),
247 };
248}
249hooks/view.tsx 115 lines1/**
2 * view.tsx — the pane's body, drawn from a snapshot and a time. The hook draws
3 * it once where a surface has no Client; the Client redraws it every second.
4 */
5
6import type { FleetAgent, FleetShell, FleetTree } from "../types";
7import { fmtClock, fmtElapsed, isFailed, isLive, LOST } from "./core.ts";
8
9export type FleetView = {
10 repo: string;
11 width: number;
12 agents: FleetAgent[];
13 shells: FleetShell[];
14 trees: FleetTree[];
15};
16
17/** The two elements the body uses, from `$.ui.resolve(e)` or `surface.elements`. */
18type Elements = { Box: any; Text: any };
19
20const fit = (s: string, n: number) =>
21 s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s.padEnd(n);
22
23/** "6m12s" while live; "6m12s · 16:42" once ended, with when it ended. */
24const timing = (i: { startedAt: number; endedAt?: number }, now: number) =>
25 i.endedAt === undefined
26 ? fmtElapsed(now - i.startedAt)
27 : `${fmtElapsed(i.endedAt - i.startedAt)} · ${fmtClock(i.endedAt, now)}`;
28
29/** Live ones first, then the most recently ended. */
30const order = <
31 T extends { status: string; startedAt: number; endedAt?: number },
32>(
33 items: T[],
34) =>
35 [...items].sort(
36 (a, b) =>
37 Number(isLive(b.status)) - Number(isLive(a.status)) ||
38 (b.endedAt ?? b.startedAt) - (a.endedAt ?? a.startedAt),
39 );
40
41function mark(Text: any, status: string) {
42 if (isLive(status)) return <Text color="cyan">▶</Text>;
43 if (status === LOST) return <Text dimColor>?</Text>;
44 return isFailed(status) ? (
45 <Text color="red">✗</Text>
46 ) : (
47 <Text color="green">✓</Text>
48 );
49}
50
51export function drawFleet(els: Elements, v: FleetView, now: number) {
52 const { Box, Text } = els;
53 const w = Math.max(24, v.width);
54 const name = Math.min(18, w - 10);
55 const run = v.agents.filter((a) => isLive(a.status)).length;
56 const fail = v.agents.filter((a) => isFailed(a.status)).length;
57 const done = v.agents.length - run - fail;
58
59 return (
60 <Box flexDirection="column">
61 <Text>
62 <Text bold>FLEET</Text>
63 <Text dimColor>{` · ${v.repo}`}</Text>
64 </Text>
65 <Text> </Text>
66 <Text
67 dimColor
68 >{`AGENTS ${run} running · ${done} done · ${fail} failed`}</Text>
69 {v.agents.length === 0 && <Text dimColor> none yet</Text>}
70 {order(v.agents).map((a) => (
71 <Box key={a.id} flexDirection="column">
72 <Text>
73 {mark(Text, a.status)}
74 {` ${fit(a.type, name)} ${timing(a, now)}`}
75 </Text>
76 <Text dimColor>
77 {" " +
78 fit(
79 isLive(a.status) && a.lastTool
80 ? `${a.description} · ${a.lastTool}`
81 : a.description,
82 w - 3,
83 ).trimEnd()}
84 </Text>
85 </Box>
86 ))}
87 {v.shells.length > 0 && <Text> </Text>}
88 {v.shells.length > 0 && <Text dimColor>BACKGROUND</Text>}
89 {order(v.shells).map((s) => {
90 const time = timing(s, now);
91 return (
92 <Text key={s.id}>
93 {mark(Text, s.status)}
94 {` ${fit(s.command, Math.max(8, w - 4 - time.length))} ${time}`}
95 </Text>
96 );
97 })}
98 {v.trees.length > 0 && <Text> </Text>}
99 {v.trees.length > 0 && <Text dimColor>WORKTREES</Text>}
100 {v.trees.map((t) => (
101 <Text key={t.path}>
102 {` ${fit(t.name, name)} `}
103 {t.changed ? (
104 <Text color="yellow">{`●${t.changed} changed`}</Text>
105 ) : (
106 <Text dimColor>clean</Text>
107 )}
108 {t.ahead ? ` ↑${t.ahead}` : ""}
109 {t.behind ? ` ↓${t.behind}` : ""}
110 </Text>
111 ))}
112 </Box>
113 );
114}
115hooks/pane.tsx 15 lines1import type { ClientModule } from "claude-code";
2
3import { drawFleet, type FleetView } from "./view.tsx";
4
5/** The pane's body, redrawn every second so elapsed times tick with no hook call. */
6const FleetPane: ClientModule<FleetView, number> = (props, surface) => {
7 if (surface.state === undefined) {
8 surface.every(1000, () => surface.setState(Date.now()));
9 surface.setState(Date.now());
10 }
11 return drawFleet(surface.elements, props, surface.state ?? Date.now());
12};
13
14export default FleetPane;
15types/index.d.ts 49 lines1export type FleetAgent = {
2 id: string;
3 type: string;
4 description: string;
5 model?: string;
6 startedAt: number;
7 endedAt?: number;
8 /** AgentStatus, as `$.agent.list()` or a task notification last said */
9 status: string;
10 /** The last tool it called, short: "Bash cargo clippy" */
11 lastTool?: string;
12 tools: number;
13 /** A teammate goes idle between turns, so its turn's end is no finish */
14 teammate?: boolean;
15};
16
17export type FleetShell = {
18 id: string;
19 command: string;
20 startedAt: number;
21 endedAt?: number;
22 status: string;
23};
24
25export type FleetTree = {
26 name: string;
27 path: string;
28 changed: number;
29 ahead: number;
30 behind: number;
31};
32
33export type FleetState = {
34 agents: FleetAgent[];
35 shells: FleetShell[];
36 trees: FleetTree[];
37 /** When the current batch's first item started; unset between batches */
38 batchStart?: number;
39 /** Whether this batch already opened the pane by itself */
40 autoOpened: boolean;
41};
42
43declare module "claude-code" {
44 interface PluginState {
45 /** pane: whether the fleet pane is open (shown or a tab) */
46 fleet: { fleet: FleetState; pane: boolean };
47 }
48}
49