Your running subagents and background tasks, live: above the prompt, in a side pane or on one line, with timeline, workflow groups, cost and stuck-agent alerts

<img src="assets/icon.png" alt="Flotilla icon: three sailboats" width="96" align="right">
Your Claude Code subagents and background tasks, live. Flotilla is a Claude Code mod that shows every running agent and background task (what it is, what it's doing, how much it costs, and whether it's stuck) right above your prompt, in a side pane, or on a single line.

A real session, sped up 2×: four subagents, one of them spawning its own, plus a background sleep. Each row shows its tool, tokens, cost and the first sentence of its answer.
/flotilla side)./flotilla compact).Bash/PowerShell, including the ones you push there with Ctrl+B) and Monitor watches show up next to the agents. Each one shows its command and how long it's been running, and is marked done, failed or stopped when it ends.⚠. A long tool call in flight, such as a slow test run, isn't flagged; its row shows how long it's been running.haiku / sonnet / opus / fable badge per agent, plus an estimated cost per agent and in total.▰▰▰▱▱ 3/5). Click ▸ to expand./flotilla timeline, or press t).● 3/5 agents · 1 task · ≈$0.43 shows whenever the session has agents, whichever view you use./flotilla, the band reads "Flotilla is on · nothing running" when nothing runs.Works in the terminal and in the Claude Code tab of the desktop app, in English or French.
In a Claude Code terminal session:
/plugin install flotilla --marketplace aleqsd/flotilla
Answer y to add the marketplace, then pick the user scope so it loads in every session.
Or from your shell:
claude plugin marketplace add aleqsd/flotilla
claude plugin install flotilla@aleqs-mods --scope user
Sessions that were already open pick it up after /reload-plugins, or after a restart.
Flotilla shows up on its own as soon as an agent starts. To change the view:
| Command | Effect |
|---|---|
/flotilla | Detailed list above the prompt (default) |
/flotilla side | Side pane |
/flotilla compact | One line above the prompt |
/flotilla timeline / /flotilla list | Switch between timeline bars and rows |
/flotilla clear | Remove finished agents |
/flotilla lang en / fr / auto | Set the interface language |
Buttons and their hotkeys work once the band or pane has focus:
| Key | Action |
|---|---|
t | Toggle timeline / list |
p | Open the side pane |
a | Move back above the prompt (from the pane) |
r | Collapse to one line |
e | Expand from one line |
h | Hide / show finished agents |
c | Clear finished agents (pane) |
Closing the side pane by hand collapses Flotilla to its one-line view, so you don't lose track of your agents.
Flotilla speaks English and French. Its language option takes one of these values:
auto (default): follows Claude Code's own language setting, then your locale (LC_ALL, LC_MESSAGES, LANG). It falls back to English.en or fr: forces that language.Set it with /flotilla lang fr, from the /config menu, or in settings.json:
{ "pluginConfigs": { "flotilla@aleqs-mods": { "options": { "language": "fr" } } } }
Costs are estimated from each agent's token usage and the public Anthropic API list prices (per million tokens):
| Model | Input | Output | Cache read |
|---|---|---|---|
| Fable 5.1 | $10 | $50 | $0.25 |
| Opus 5.5 | $4 | $20 | $0.20 |
| Sonnet 5.5 | $2 | $10 | $0.20 |
| Haiku 5.5 | $0.10 | $0.50 | $0.01 |
Cache writes are counted at 1.25× the input price. Older models use their own rates. This is an estimate at API prices: it isn't what a Claude subscription bills you, and it ignores long-context and regional pricing. A + after a total means at least one agent's model is unknown and wasn't counted.
Flotilla is a plugin of function hooks (hooks/register.tsx):
agent.spawn records each agent: type, description, parent, model, workflow run.tool.call tracks the current tool, call counts, activity and tools still running.turn.complete marks an agent done and records its answer and token usage.$.agent.list() is polled every 2 s to sync statuses and pick up agents started before the mod loaded.backgroundTaskId, a Monitor's taskId). They end on a TaskStop, on their task notification, or when they no longer appear in the background work the engine lists at the end of each turn (classic.Stop).ui.render draws the AbovePrompt band and the Pane, and $.ui.status drives the status line.Everything lives in session state. Nothing is written to disk or sent anywhere.
git clone https://github.com/aleqsd/flotilla
claude --plugin-dir ./flotilla
With --plugin-dir, saving a file hot-reloads the mod. To check it:
claude plugin validate ./flotilla
claude plugin test ./flotilla
The function-hook plugin API is in early access and may change between Claude Code releases. Flotilla was built against Claude Code 2.1.293.
hooks/register.tsx 578 lines1import { atom, read, update } from 'claude-code'
2import type { AgentSpawnInput, AgentStatus, EngineInterface, Register } from 'claude-code'
3
4import type { FleetAgent, FleetKind, FleetView } from '../types'
5import { totals } from './cost'
6import { elapsed, kilo, oneLine, summarize, targetOf } from './format'
7import { headline, itemsOf, listRows, timelineRows } from './rows'
8import {
9 IDLE_HINT_MS, LINGER_MS, MAX_ROWS, PANE, patch, SPARK, SPINNER, TASK_TOOLS, TICK_MS, TITLE, ZERO,
10} from './state'
11import type { Ctx } from './state'
12import { icon, isBusy, isLive, isStuck, isTask } from './status'
13import { asLang, setLang, t } from './strings'
14import type { Lang } from './strings'
15import { notified } from './tasks'
16import type { Ended, InFlight } from './tasks'
17
18// The state the drawings read. The validator lists every `$.state` key a module
19// touches, so the atoms are declared here, in the module's own file.
20const fleet = atom({ plugin: 'flotilla', key: 'fleet' } as const, [])
21const view = atom({ plugin: 'flotilla', key: 'view' } as const, 'above')
22const layout = atom({ plugin: 'flotilla', key: 'layout' } as const, 'list')
23const paneShown = atom({ plugin: 'flotilla', key: 'paneShown' } as const, false)
24const hideDone = atom({ plugin: 'flotilla', key: 'hideDone' } as const, false)
25const clock = atom({ plugin: 'flotilla', key: 'clock' } as const, { frame: 0, now: 0 })
26const workflowNames = atom({ plugin: 'flotilla', key: 'workflowNames' } as const, [])
27const openRuns = atom({ plugin: 'flotilla', key: 'openRuns' } as const, [])
28const idleHintUntil = atom({ plugin: 'flotilla', key: 'idleHintUntil' } as const, 0)
29
30// Every function that takes `$` lives in this file too: the validator follows `$`
31// only through functions declared here. The other modules are pure.
32
33// ---- Language -----------------------------------------------------------
34
35// `auto`: Claude Code's own `language` setting, then the locale variables.
36async function detectLang($: EngineInterface): Promise<Lang> {
37 const settings = (await $.settings.read().catch(() => ({}))) as Record<string, unknown>
38 const fromSettings = asLang(settings.language)
39 if (fromSettings !== undefined) return fromSettings
40 const locale = (await $.env.get('LC_ALL').catch(() => undefined))
41 ?? (await $.env.get('LC_MESSAGES').catch(() => undefined))
42 ?? (await $.env.get('LANG').catch(() => undefined))
43
44 return asLang(locale) ?? 'en'
45}
46
47async function setLanguage($: EngineInterface, value: string) {
48 return $.config.set({ key: 'flotilla.language', value })
49}
50
51// Said after a view change while nothing is running, so an empty band isn't a mystery;
52// the band shows its "nothing running" line for a moment too.
53async function quietNote($: EngineInterface) {
54 if ((await read($, fleet)).some(a => isLive(a.status))) return ''
55 await showIdleHint($)
56
57 return `\n${t.noneRunning}`
58}
59
60async function showIdleHint($: EngineInterface) {
61 const until = (await $.clock.now()) + IDLE_HINT_MS
62 await update($, idleHintUntil, () => until)
63}
64
65// ---- Views --------------------------------------------------------------
66
67async function openPane($: EngineInterface) {
68 const opened = await $.ui.open({ id: PANE, title: TITLE })
69 await update($, paneShown, () => opened.isPlaced)
70}
71
72async function showIn($: EngineInterface, where: FleetView) {
73 await update($, view, () => where)
74 if (where === 'side') await openPane($)
75 else await $.ui.close({ id: PANE })
76}
77
78async function toggleRun($: EngineInterface, runId: string) {
79 await update($, openRuns, runs => (runs.includes(runId) ? runs.filter(r => r !== runId) : [...runs, runId]))
80}
81
82async function toggleLayout($: EngineInterface) {
83 await update($, layout, l => (l === 'list' ? 'timeline' : 'list'))
84}
85
86async function clearDone($: EngineInterface) {
87 await update($, fleet, list => list.filter(a => isLive(a.status)))
88}
89
90// ---- Agents -------------------------------------------------------------
91
92// Brings statuses in line with the engine's roster and picks up agents
93// started before this module loaded.
94async function reconcile($: EngineInterface, now: number) {
95 const roster = await $.agent.list()
96 await update($, fleet, list => {
97 let next = list
98 for (const info of roster) {
99 const known = next.find(a => a.id === info.id)
100 if (known === undefined) {
101 next = [...next, {
102 id: info.id,
103 type: info.type,
104 description: info.description || info.name || info.type,
105 parentId: info.parentId,
106 startedAt: now,
107 endedAt: isLive(info.status) ? undefined : now,
108 lastActivityAt: now,
109 inFlight: 0,
110 status: info.status,
111 tools: 0,
112 activity: [],
113 usage: ZERO,
114 }]
115 } else if (known.status !== info.status) {
116 next = patch(next, info.id, a => ({
117 ...a,
118 status: info.status,
119 endedAt: isLive(info.status) ? undefined : (a.endedAt ?? now),
120 }))
121 }
122 }
123
124 return next.slice(-MAX_ROWS)
125 })
126}
127
128async function track($: EngineInterface, e: AgentSpawnInput, agentId: string, model: string) {
129 const now = await $.clock.now()
130 const row: FleetAgent = {
131 id: agentId,
132 type: e.subagentType,
133 description: e.description || e.name || e.subagentType,
134 parentId: e.parentAgentId,
135 model,
136 workflowRun: e.workflow?.runId || undefined,
137 workflowCall: e.workflow ? e.tool_use_id : undefined,
138 startedAt: now,
139 lastActivityAt: now,
140 inFlight: 0,
141 status: 'running',
142 tools: 0,
143 activity: [],
144 usage: ZERO,
145 }
146 await update($, fleet, list => [...list.filter(a => a.id !== agentId), row].slice(-MAX_ROWS))
147 await update($, clock, c => ({ frame: c.frame, now }))
148 if ((await read($, view)) === 'side' && !(await read($, paneShown))) void openPane($).catch(() => undefined)
149}
150
151async function toolStarted($: EngineInterface, agentId: string, tool: string, target: string | undefined) {
152 const now = await $.clock.now()
153 await update($, fleet, list =>
154 patch(list, agentId, a => ({
155 ...a,
156 status: 'running',
157 endedAt: undefined,
158 lastActivityAt: now,
159 inFlight: a.inFlight + 1,
160 tools: a.tools + 1,
161 tool,
162 target,
163 activity: [...a.activity.slice(0, -1), (a.activity.at(-1) ?? 0) + 1],
164 })),
165 )
166}
167
168async function toolEnded($: EngineInterface, agentId: string) {
169 const now = await $.clock.now()
170 await update($, fleet, list =>
171 patch(list, agentId, a => ({ ...a, lastActivityAt: now, inFlight: Math.max(0, a.inFlight - 1) })),
172 )
173}
174
175async function noteWorkflow($: EngineInterface, toolUseId: string, input: unknown) {
176 const fields = input as Record<string, unknown>
177 const script = typeof fields.script === 'string' ? fields.script : ''
178 const name = typeof fields.name === 'string' && fields.name !== ''
179 ? fields.name
180 : /name:\s*['"]([^'"]+)['"]/.exec(script)?.[1]
181 if (name === undefined) return
182 await update($, workflowNames, names => [...names.filter(w => w.toolUseId !== toolUseId), { toolUseId, name }].slice(-20))
183}
184
185type UsageInput = {
186 model: string
187 input_tokens: number
188 output_tokens: number
189 cache_read_input_tokens: number
190 cache_creation_input_tokens: number
191}
192
193async function finished($: EngineInterface, agentId: string, e: { answer: string; isAborted: boolean; reason: string; usage?: UsageInput }) {
194 const now = await $.clock.now()
195 const u = e.usage
196 const status: AgentStatus = e.isAborted ? 'killed' : e.reason === 'error' ? 'failed' : 'completed'
197 const summary = summarize(e.answer)
198 await update($, fleet, list =>
199 patch(list, agentId, a => ({
200 ...a,
201 status,
202 endedAt: now,
203 inFlight: 0,
204 tool: undefined,
205 target: undefined,
206 model: a.model ?? u?.model,
207 summary: summary ?? a.summary,
208 usage: u === undefined ? a.usage : {
209 input: a.usage.input + u.input_tokens,
210 output: a.usage.output + u.output_tokens,
211 cacheRead: a.usage.cacheRead + u.cache_read_input_tokens,
212 cacheWrite: a.usage.cacheWrite + u.cache_creation_input_tokens,
213 },
214 })),
215 )
216}
217
218// ---- Background tasks ---------------------------------------------------
219
220async function taskStarted($: EngineInterface, kind: Exclude<FleetKind, 'agent'>, input: unknown, output: unknown, parentId: string | undefined) {
221 const out = (output ?? {}) as Record<string, unknown>
222 const id = typeof out.backgroundTaskId === 'string' ? out.backgroundTaskId
223 : kind === 'monitor' && typeof out.taskId === 'string' ? out.taskId
224 : undefined
225 if (id === undefined) return
226 const fields = input as Record<string, unknown>
227 const command = typeof fields.command === 'string' && fields.command !== '' ? oneLine(fields.command) : undefined
228 const description = typeof fields.description === 'string' && fields.description !== '' ? fields.description : (command ?? kind)
229 const now = await $.clock.now()
230 const row: FleetAgent = {
231 id, kind, type: kind, description, parentId, target: command,
232 startedAt: now, lastActivityAt: now, inFlight: 0, status: 'running',
233 tools: 0, activity: [], usage: ZERO,
234 }
235 await update($, fleet, list => [...list.filter(a => a.id !== id), row].slice(-MAX_ROWS))
236 await update($, clock, c => ({ frame: c.frame, now }))
237}
238
239async function tasksEnded($: EngineInterface, ended: Ended[]) {
240 if (ended.length === 0) return
241 const now = await $.clock.now()
242 await update($, fleet, list => list.map(a => {
243 const end = ended.find(x => x.id === a.id)
244
245 return end !== undefined && isTask(a) && isLive(a.status) ? { ...a, status: end.status, endedAt: now } : a
246 }))
247}
248
249// At each turn's end the engine lists the background work still in flight:
250// tracked tasks missing from it have ended; shells and monitors never seen starting are added.
251async function syncTasks($: EngineInterface, inFlight: readonly InFlight[]) {
252 const now = await $.clock.now()
253 const ids = new Set(inFlight.map(x => x.id))
254 await update($, fleet, list => {
255 const known = new Set(list.map(a => a.id))
256 const ended = list.map(a => (isTask(a) && isLive(a.status) && !ids.has(a.id) ? { ...a, status: 'completed' as const, endedAt: now } : a))
257 const added: FleetAgent[] = inFlight
258 .filter(x => (x.type === 'shell' || x.type === 'monitor') && !known.has(x.id))
259 .map(x => ({
260 id: x.id,
261 kind: x.type === 'shell' ? 'shell' : 'monitor',
262 type: x.type,
263 description: x.description || x.command || x.type,
264 target: x.command === undefined ? undefined : oneLine(x.command),
265 startedAt: now, lastActivityAt: now, inFlight: 0, status: 'running',
266 tools: 0, activity: [], usage: ZERO,
267 }))
268
269 return [...ended, ...added].slice(-MAX_ROWS)
270 })
271}
272
273// ---- Clock --------------------------------------------------------------
274
275let ticks = 0
276let lastStatus: string | undefined
277
278// Four frames a second while something is busy (the spinners), one a second
279// while rows only wait or linger, none when nothing is left to draw. The
280// status line carries a still dot, so it changes only when the counts do.
281async function tick($: EngineInterface) {
282 ticks += 1
283 const list = await read($, fleet)
284 const isAnyLive = list.some(a => isLive(a.status))
285 const isAnyBusy = list.some(a => isBusy(a.status))
286 if (ticks % (isAnyLive ? 8 : 40) === 1) await reconcile($, await $.clock.now())
287
288 const hintUntil = await read($, idleHintUntil)
289 if (hintUntil !== 0 && (await $.clock.now()) >= hintUntil) await update($, idleHintUntil, () => 0)
290
291 const { cost } = totals(list)
292 const status = list.length === 0 ? undefined : `${headline(list, undefined, true)}${cost ? ` · ${cost}` : ''}`
293 if (status !== lastStatus) {
294 lastStatus = status
295 $.ui.status(status)
296 }
297
298 const now = await $.clock.now()
299 const isLingering = list.some(a => a.endedAt !== undefined && now - a.endedAt < LINGER_MS + 1000)
300 if (!isAnyLive && !isLingering) return
301 const isSecond = ticks % 4 === 0
302 if (!isAnyBusy && !isSecond) return
303
304 if (isSecond && isAnyBusy) {
305 await update($, fleet, all =>
306 all.map(a => (isLive(a.status) && !isTask(a) ? { ...a, activity: [...a.activity, 0].slice(-SPARK) } : a)),
307 )
308 }
309 await update($, clock, c => ({ frame: c.frame + 1, now }))
310}
311
312// ---- Hooks --------------------------------------------------------------
313
314export const register: Register = (on, options) => {
315 const chosen = asLang(options.language)
316 setLang(chosen ?? 'en')
317
318 on('session.start', async ($, e, next) => {
319 if (chosen === undefined) {
320 setLang(await detectLang($))
321 $.ui.invalidate('ui.render')
322 }
323 await $.command.register({
324 name: 'flotilla',
325 description: 'Show running agents above the prompt, in the side pane (`side`) or on one line (`compact`)',
326 argumentHint: '[above | side | compact | timeline | list | clear | lang en|fr|auto]',
327 })
328 $.clock.every(TICK_MS, () => void tick($).catch(() => undefined))
329 void showIdleHint($).catch(() => undefined)
330 if ((await read($, view)) !== 'side') void $.ui.close({ id: PANE }).catch(() => undefined)
331
332 return next(e)
333 })
334
335 on('command.run', { command: 'flotilla' }, async ($, e) => {
336 const arg = e.args.trim().toLowerCase()
337 const lang = /^lang(?:uage)?\s+(en|fr|auto)$/.exec(arg)?.[1]
338 if (lang !== undefined) {
339 const { deny } = await setLanguage($, lang)
340
341 return { text: deny === undefined ? `Flotilla language set to ${lang}.` : `Couldn't set the language: ${deny}` }
342 }
343 if (arg === 'clear') {
344 await clearDone($)
345
346 return { text: 'Finished agents cleared from the flotilla.' }
347 }
348 if (arg === 'timeline' || arg === 'list') {
349 await update($, layout, () => (arg === 'timeline' ? 'timeline' : 'list'))
350
351 return { text: `Flotilla drawn as a ${arg}.` }
352 }
353 if (arg === 'side' || arg === 'pane' || arg === 'panneau') {
354 await showIn($, 'side')
355
356 return { text: 'Flotilla shown in the side pane.' }
357 }
358 if (arg === 'compact' || arg === 'band') {
359 await showIn($, 'compact')
360
361 return { text: `Flotilla shown on one line above the prompt.${await quietNote($)}` }
362 }
363 await showIn($, 'above')
364
365 return { text: `Flotilla shown above the prompt.${await quietNote($)}` }
366 })
367
368 on('agent.spawn', async ($, e, next) => {
369 const result = await next(e)
370 const started = result as { agentId?: string; model?: string }
371 if (started.agentId === undefined) return result
372
373 await track($, e, started.agentId, started.model ?? e.model ?? e.parentModel).catch(() => undefined)
374
375 return result
376 }).catch(($, e, next) => next(e))
377
378 on('tool.call', async ($, e, next) => {
379 const tool = e.tool as string
380 if (tool === 'Workflow') await noteWorkflow($, e.tool_use_id, e).catch(() => undefined)
381 const agentId = e.agentId
382 const taskKind = TASK_TOOLS[tool]
383 if (agentId === undefined && taskKind === undefined && tool !== 'TaskStop') return next(e)
384
385 if (agentId !== undefined) await toolStarted($, agentId, e.tool, targetOf(e)).catch(() => undefined)
386 try {
387 const ran = await next(e)
388 const output = (ran as { result?: unknown }).result
389 if (taskKind !== undefined) await taskStarted($, taskKind, e, output, agentId).catch(() => undefined)
390 if (tool === 'TaskStop') {
391 const stopped = (output as { task_id?: unknown } | undefined)?.task_id
392 if (typeof stopped === 'string') await tasksEnded($, [{ id: stopped, status: 'killed' }]).catch(() => undefined)
393 }
394
395 return ran
396 } finally {
397 if (agentId !== undefined) void toolEnded($, agentId).catch(() => undefined)
398 }
399 }).catch(($, e, next) => next(e))
400
401 on('prompt.submit', ($, e, next) => {
402 if (e.origin.kind === 'task-notification') void tasksEnded($, notified(e.text)).catch(() => undefined)
403
404 return next(e)
405 }).catch(($, e, next) => next(e))
406
407 on('classic.Stop', async ($, e, next) => {
408 await syncTasks($, e.background_tasks ?? []).catch(() => undefined)
409
410 return next(e)
411 }).catch(($, e, next) => next(e))
412
413 on('turn.complete', async ($, e, next) => {
414 if (e.agentId !== undefined) await finished($, e.agentId, e).catch(() => undefined)
415
416 return next(e)
417 })
418
419 on('ui.close', async ($, e, next) => {
420 if (e.id === PANE) {
421 // Closing the pane by hand collapses to one line rather than hiding the flotilla.
422 const isByHand = e.origin.kind === 'person'
423 await update($, paneShown, () => false)
424 .then(async () => (isByHand && (await read($, view)) === 'side' ? update($, view, () => 'compact') : undefined))
425 .catch(() => undefined)
426 }
427
428 return next(e)
429 }).catch(($, e, next) => next(e))
430
431 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
432 const el = $.ui.resolve(e)
433 const { Box, Text, Button } = el
434 const all = await read($, fleet)
435 const isHidingDone = await read($, hideDone)
436 const isTimeline = (await read($, layout)) === 'timeline'
437 const { frame } = await read($, clock)
438 const now = await $.clock.now()
439 const ctx: Ctx = { frame, now, cols: e.props.bodyColumns }
440 const shown = isHidingDone ? all.filter(a => isLive(a.status)) : all
441 const items = itemsOf(shown, await read($, workflowNames), await read($, openRuns))
442 const onToggle = (runId: string) => void toggleRun($, runId)
443 const busy = all.filter(a => isBusy(a.status)).length
444 const done = all.filter(a => !isLive(a.status)).length
445 const tools = all.reduce((sum, a) => sum + a.tools, 0)
446 const { tokens, cost } = totals(all)
447
448 return (
449 <Box flexDirection="column">
450 <Box flexDirection="row" justifyContent="space-between">
451 <Box flexDirection="row" gap={1}>
452 <Text color={busy > 0 ? 'claude' : 'success'}>{busy > 0 ? SPINNER[frame % SPINNER.length] : '✓'}</Text>
453 <Text bold>{TITLE}</Text>
454 </Box>
455 <Text dimColor>
456 {t.active(busy)} · {t.done(done)}
457 </Text>
458 </Box>
459 <Box flexDirection="column" marginY={1}>
460 {items.length === 0 && (
461 <Text dimColor>{t.empty}</Text>
462 )}
463 {items.length > 0 && (isTimeline ? timelineRows(el, items, ctx, onToggle) : listRows(el, items, ctx, false, onToggle))}
464 </Box>
465 <Text dimColor>
466 {kilo(tokens)} tokens{cost ? ` · ${cost}` : ''} · {t.toolCalls(tools)}
467 </Text>
468 <Box flexDirection="row" gap={1} marginTop={1} flexWrap="wrap">
469 <Button key="layout" label={isTimeline ? t.list : t.timeline} hotkey="t" onPress={() => toggleLayout($)} />
470 <Button key="above" label={t.above} hotkey="a" onPress={() => showIn($, 'above')} />
471 <Button key="collapse" label={t.collapse} hotkey="r" onPress={() => showIn($, 'compact')} />
472 <Button
473 key="hide-done"
474 label={isHidingDone ? t.showDone : t.hideDone}
475 hotkey="h"
476 onPress={() => update($, hideDone, v => !v)}
477 />
478 <Button key="clear" label={t.clear} hotkey="c" onPress={() => clearDone($)} />
479 </Box>
480 </Box>
481 )
482 })
483
484 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
485 if (e.props.hasSurvey) return next(e)
486 const where = await read($, view)
487 if (where === 'side' && (await read($, paneShown))) return next(e)
488
489 const { frame } = await read($, clock)
490 const now = await $.clock.now()
491 const isHidingDone = await read($, hideDone)
492 const shown = (await read($, fleet)).filter(
493 a => isLive(a.status) || (!isHidingDone && a.endedAt !== undefined && now - a.endedAt < LINGER_MS),
494 )
495 if (shown.length === 0) {
496 if (now >= (await read($, idleHintUntil))) return next(e)
497 const { Text } = $.ui.resolve(e)
498
499 return <Text dimColor>{t.idleLine}</Text>
500 }
501
502 const el = $.ui.resolve(e)
503 const { Box, Text, Button } = el
504 const busy = shown.filter(a => isBusy(a.status)).length
505 const head = headline(shown, frame, false)
506 const onToggle = (runId: string) => void toggleRun($, runId)
507
508 const isTimeline = (await read($, layout)) === 'timeline'
509 const items = itemsOf(shown, await read($, workflowNames), await read($, openRuns))
510 const room = Math.max(1, e.props.maxRows - (isTimeline ? 2 : 1))
511 // A band too short to list the rows falls back to the one-line view.
512 const isCramped = items.length > room && room < 3
513
514 if (where === 'above' && !isCramped) {
515 const ctx: Ctx = { frame, now, cols: e.props.bodyColumns }
516 const visible = items.length > room ? items.slice(0, room - 1) : items
517 const hidden = items.length - visible.length
518 const done = shown.filter(a => !isLive(a.status)).length
519 const { tokens, cost } = totals(shown)
520
521 return (
522 <Box flexDirection="column">
523 <Box flexDirection="row" justifyContent="space-between">
524 <Box flexDirection="row" gap={2}>
525 <Text color={busy > 0 ? 'claude' : 'success'} bold>{head}</Text>
526 <Text dimColor>
527 {t.done(done)}{tokens > 0 ? ` · ${kilo(tokens)} tokens` : ''}{cost ? ` · ${cost}` : ''}
528 </Text>
529 </Box>
530 <Box flexDirection="row" gap={1}>
531 <Button key="layout" label={isTimeline ? t.list : t.timeline} hotkey="t" onPress={() => toggleLayout($)} />
532 <Button key="side" label={t.pane} hotkey="p" onPress={() => showIn($, 'side')} />
533 <Button key="collapse" label={t.collapse} hotkey="r" onPress={() => showIn($, 'compact')} />
534 <Button
535 key="hide-done"
536 label={isHidingDone ? t.showDone : t.hideDone}
537 hotkey="h"
538 onPress={() => update($, hideDone, v => !v)}
539 />
540 </Box>
541 </Box>
542 {isTimeline ? timelineRows(el, visible, ctx, onToggle) : listRows(el, visible, ctx, true, onToggle)}
543 {hidden > 0 && <Text dimColor> {t.more(hidden)}</Text>}
544 </Box>
545 )
546 }
547
548 let width = e.props.bodyColumns - head.length - 14
549 const chips: { agent: FleetAgent; text: string }[] = []
550 for (const a of [...shown].sort((x, y) => Number(isLive(y.status)) - Number(isLive(x.status)))) {
551 const words = a.description.split(/\s+/).slice(0, 3).join(' ')
552 const text = `${a.type} ${words} ${elapsed((a.endedAt ?? now) - a.startedAt)}`
553 if (text.length + 4 > width) break
554 width -= text.length + 4
555 chips.push({ agent: a, text })
556 }
557 const hidden = shown.length - chips.length
558
559 return (
560 <Box flexDirection="row" gap={2}>
561 <Text color={busy > 0 ? 'claude' : 'success'} bold>{head}</Text>
562 {chips.map(({ agent: a, text }) => {
563 const mark = icon(a, frame, now)
564
565 return (
566 <Box key={a.id} flexDirection="row" gap={1}>
567 <Text color={mark.color}>{isBusy(a.status) && !isStuck(a, now) ? '●' : mark.glyph}</Text>
568 <Text dimColor={!isLive(a.status)}>{text}</Text>
569 </Box>
570 )
571 })}
572 {hidden > 0 && <Text dimColor>+{hidden}</Text>}
573 <Button key="expand" label={t.expand} hotkey="e" onPress={() => showIn($, 'above')} />
574 </Box>
575 )
576 })
577}
578hooks/cost.ts 48 lines1import type { FleetAgent, FleetUsage } from '../types'
2import { isTask } from './status'
3
4// $ per million tokens; cache writes at the 5-minute rate (1.25x input).
5type Price = { input: number; output: number; cacheRead: number; cacheWrite: number }
6const price = (input: number, output: number, cacheRead: number): Price =>
7 ({ input, output, cacheRead, cacheWrite: input * 1.25 })
8const PRICES: [RegExp, Price][] = [
9 [/fable|mythos/, price(10, 50, 0.25)],
10 [/opus-5-5|^opus$/, price(4, 20, 0.2)],
11 [/opus/, price(5, 25, 0.5)],
12 [/sonnet-5|^sonnet$/, price(2, 10, 0.2)],
13 [/sonnet/, price(3, 15, 0.3)],
14 [/haiku-5|^haiku$/, price(0.1, 0.5, 0.01)],
15 [/haiku/, price(1, 5, 0.1)],
16]
17
18export function costOf(a: FleetAgent): number | undefined {
19 const model = a.model?.toLowerCase()
20 const p = model === undefined ? undefined : PRICES.find(([re]) => re.test(model))?.[1]
21 if (p === undefined) return undefined
22 const u = a.usage
23
24 return (u.input * p.input + u.output * p.output + u.cacheRead * p.cacheRead + u.cacheWrite * p.cacheWrite) / 1e6
25}
26
27export const tokensOf = (u: FleetUsage) => u.input + u.output + u.cacheRead + u.cacheWrite
28export const family = (model?: string) => /fable|mythos|opus|sonnet|haiku/.exec(model?.toLowerCase() ?? '')?.[0]
29
30export function dollars(n: number | undefined): string {
31 if (n === undefined) return ''
32 if (n === 0) return '$0'
33 if (n < 0.01) return '<$0.01'
34
35 return `$${n.toFixed(2)}`
36}
37
38// Tokens and estimated cost of the agents in a list; background tasks cost nothing.
39export function totals(all: FleetAgent[]): { tokens: number; cost: string } {
40 const list = all.filter(a => !isTask(a))
41 const costs = list.map(costOf)
42 const known = costs.filter((c): c is number => c !== undefined)
43 const sum = known.reduce((s, c) => s + c, 0)
44 const cost = known.length === 0 ? '' : `≈${dollars(sum)}${known.length < costs.length ? '+' : ''}`
45
46 return { tokens: list.reduce((s, a) => s + tokensOf(a.usage), 0), cost }
47}
48hooks/format.ts 61 lines1import { SPARK } from './state'
2
3export function elapsed(ms: number): string {
4 const s = Math.max(0, Math.floor(ms / 1000))
5 if (s < 60) return `${s}s`
6 if (s < 3600) return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}`
7
8 return `${Math.floor(s / 3600)}h${String(Math.floor(s / 60) % 60).padStart(2, '0')}`
9}
10
11export const kilo = (n: number) => (n < 1000 ? String(n) : n < 1e6 ? `${(n / 1000).toFixed(1)}k` : `${(n / 1e6).toFixed(2)}M`)
12
13export function spark(activity: number[]): string {
14 const bars = '▁▃▅▆█'
15 const padded = [...Array(Math.max(0, SPARK - activity.length)).fill(0), ...activity.slice(-SPARK)]
16
17 return padded.map(v => bars[Math.min(bars.length - 1, v)]).join('')
18}
19
20export function progress(done: number, total: number, width = 8): string {
21 const filled = total === 0 ? 0 : Math.round((done / total) * width)
22
23 return '▰'.repeat(filled) + '▱'.repeat(width - filled)
24}
25
26export const oneLine = (text: string) => {
27 const flat = text.replace(/\s+/g, ' ').trim()
28
29 return flat.length > 80 ? `${flat.slice(0, 79)}…` : flat
30}
31
32// What a tool call works on, short enough for one row.
33export function targetOf(input: unknown): string | undefined {
34 const fields = input as Record<string, unknown>
35 for (const key of ['file_path', 'path', 'pattern', 'command', 'url', 'query', 'description', 'skill']) {
36 const value = fields[key]
37 if (typeof value !== 'string' || value === '') continue
38 const flat = value.replace(/\s+/g, ' ').trim()
39 const isPath = /[\\/]/.test(flat) && !flat.includes(' ')
40 const short = isPath ? flat.split(/[\\/]/).slice(-2).join('/') : flat
41
42 return short.length > 60 ? `${short.slice(0, 59)}…` : short
43 }
44
45 return undefined
46}
47
48// The first sentence of a final answer, markdown stripped.
49export function summarize(answer: string): string | undefined {
50 const flat = answer
51 .replace(/```[\s\S]*?```/g, ' ')
52 .replace(/^\s*(?:[-*>]|\d+\.)\s+/gm, '')
53 .replace(/[#*`_|]/g, '')
54 .replace(/\s+/g, ' ')
55 .trim()
56 if (flat === '') return undefined
57 const sentence = /^(.{20,}?[.!?])(?:\s|$)/.exec(flat)?.[1] ?? flat
58
59 return sentence.length > 140 ? `${sentence.slice(0, 139)}…` : sentence
60}
61hooks/rows.tsx 292 lines1import type { FleetAgent, FleetWorkflowName } from '../types'
2import { costOf, dollars, family, tokensOf, totals } from './cost'
3import { elapsed, kilo, progress, spark } from './format'
4import { SPARK, SPINNER, TYPE_COLORS } from './state'
5import type { Ctx, El, Group, Item } from './state'
6import { groupIcon, icon, isBusy, isLive, isStuck, isTask } from './status'
7import { t } from './strings'
8
9type Toggle = (runId: string) => void
10
11// What a row says under its name: the tool at work, the command, or what it found.
12export function detail(a: FleetAgent, now: number): string {
13 if (isTask(a)) {
14 const what = a.target ?? a.description
15
16 return isLive(a.status) ? `$ ${what}` : `${t.ended(a.status)} · $ ${what}`
17 }
18 if (isStuck(a, now)) return t.idleFor(elapsed(now - a.lastActivityAt))
19 // Its turn is over but it holds background work (a monitor, a subagent): say so.
20 if ((a.status === 'waiting' || a.status === 'idle') && a.inFlight === 0) return t.waitingOn
21 if (isLive(a.status)) {
22 const running = a.inFlight > 0 && now - a.lastActivityAt > 10_000 ? ` (${elapsed(now - a.lastActivityAt)})` : ''
23
24 // Between tools (or after a turn, before the next) an agent that already worked is thinking.
25 if (a.tool === undefined) return `↳ ${a.tools === 0 ? t.starting : t.thinking}`
26
27 return `↳ ${a.tool}${a.target ? ` ${a.target}` : ''}${running}`
28 }
29 const cost = costOf(a)
30 const counts = `${t.tools(a.tools)} · ${kilo(tokensOf(a.usage))} tokens${cost === undefined ? '' : ` · ${dollars(cost)}`}`
31
32 return a.summary ? `${counts} — ${a.summary}` : counts
33}
34
35// `⠋ 2 agents · 1 waiting · 1 task`: running agents, agents waiting on their
36// own background work, then running tasks; each only when there are any.
37// `frame` is the spinner frame to draw, or undefined for a still dot.
38export function headline(list: FleetAgent[], frame: number | undefined, withTotal: boolean): string {
39 const agents = list.filter(a => !isTask(a))
40 const busyAgents = agents.filter(a => isBusy(a.status)).length
41 const waitingAgents = agents.filter(a => isLive(a.status) && !isBusy(a.status)).length
42 const busyTasks = list.filter(a => isTask(a) && isBusy(a.status)).length
43 const isBusyNow = busyAgents + waitingAgents + busyTasks > 0
44 const glyph = !isBusyNow ? '✓' : frame === undefined ? '●' : SPINNER[frame % SPINNER.length]
45 const agentPart = withTotal ? t.agents(agents.length).replace(/^\d+/, `${busyAgents}/${agents.length}`) : t.agents(busyAgents)
46 const parts = [
47 ...(busyAgents > 0 || (withTotal && agents.length > 0) ? [agentPart] : []),
48 ...(waitingAgents > 0 ? [t.waiting(waitingAgents)] : []),
49 ...(busyTasks > 0 ? [t.tasks(busyTasks)] : []),
50 ]
51
52 const tasks = list.filter(a => isTask(a)).length
53 const idle = agents.length === 0 && tasks > 0 ? t.tasks(tasks) : t.agents(agents.length)
54
55 return `${glyph} ${parts.length > 0 ? parts.join(' · ') : idle}`
56}
57
58// Roots and workflow groups by start time, each followed by its children.
59export function itemsOf(list: FleetAgent[], names: FleetWorkflowName[], open: string[]): Item[] {
60 const ids = new Set(list.map(a => a.id))
61 const byStart = [...list].sort((x, y) => x.startedAt - y.startedAt)
62 const isRoot = (a: FleetAgent) => a.parentId === undefined || !ids.has(a.parentId)
63 const runs = new Map<string, FleetAgent[]>()
64 const tops: { start: number; item: FleetAgent | string }[] = []
65 for (const a of byStart) {
66 if (!isRoot(a)) continue
67 if (a.workflowRun === undefined) {
68 tops.push({ start: a.startedAt, item: a })
69 } else if (runs.has(a.workflowRun)) {
70 runs.get(a.workflowRun)?.push(a)
71 } else {
72 runs.set(a.workflowRun, [a])
73 tops.push({ start: a.startedAt, item: a.workflowRun })
74 }
75 }
76
77 const out: Item[] = []
78 const walk = (a: FleetAgent, depth: number) => {
79 out.push({ kind: 'agent', agent: a, depth })
80 for (const child of byStart) if (child.parentId === a.id) walk(child, depth + 1)
81 }
82 for (const { item } of tops) {
83 if (typeof item !== 'string') {
84 walk(item, 0)
85 continue
86 }
87 const members = runs.get(item) ?? []
88 const call = members[0]?.workflowCall
89 const name = names.find(w => w.toolUseId === call)?.name ?? `workflow ${item.replace(/^wf_/, '').slice(0, 8)}`
90 const isOpen = open.includes(item)
91 out.push({ kind: 'group', runId: item, name, members, isOpen })
92 if (isOpen) for (const m of members) walk(m, 1)
93 }
94
95 return out
96}
97
98function groupFacts(g: Group, now: number) {
99 const done = g.members.filter(a => !isLive(a.status)).length
100 const start = Math.min(...g.members.map(a => a.startedAt))
101 const isAnyLive = g.members.some(a => isLive(a.status))
102 const end = isAnyLive ? now : Math.max(...g.members.map(a => a.endedAt ?? now))
103
104 return { done, total: g.members.length, start, end, ...totals(g.members) }
105}
106
107function groupRow(el: El, g: Group, ctx: Ctx, onToggle: Toggle) {
108 const { Box, Text, Button } = el
109 const mark = groupIcon(g.members, ctx.frame, ctx.now)
110 const f = groupFacts(g, ctx.now)
111
112 // Fixed parts never shrink: only the name gives way to a narrow column.
113 return (
114 <Box key={`wf-${g.runId}`} flexDirection="row" gap={1}>
115 <Box flexShrink={0} flexDirection="row" gap={1}>
116 <Button key={`wf-${g.runId}`} label={g.isOpen ? '▾' : '▸'} plain onPress={() => onToggle(g.runId)} />
117 <Text color={mark.color}>{mark.glyph}</Text>
118 <Text color="merged" bold>workflow</Text>
119 </Box>
120 <Box flexGrow={1} flexShrink={1}>
121 <Text wrap="truncate-end">{g.name}</Text>
122 </Box>
123 <Box flexShrink={0} flexDirection="row" gap={1}>
124 <Text color={f.done === f.total ? 'success' : 'claude'}>{progress(f.done, f.total)}</Text>
125 <Text dimColor>
126 {f.done}/{f.total}{f.cost ? ` · ${f.cost}` : ''}
127 </Text>
128 <Box width={6} justifyContent="flex-end">
129 <Text dimColor>{elapsed(f.end - f.start)}</Text>
130 </Box>
131 </Box>
132 </Box>
133 )
134}
135
136const activityOf = (a: FleetAgent) => (isTask(a) ? ' '.repeat(SPARK) : spark(a.activity))
137
138// Two lines an agent: what it is, then what it does or what it found.
139function paneRow(el: El, a: FleetAgent, depth: number, ctx: Ctx) {
140 const { Box, Text } = el
141 const mark = icon(a, ctx.frame, ctx.now)
142 const live = isLive(a.status)
143 const indent = depth > 0 ? `${' '.repeat(depth - 1)}└─ ` : ''
144 const model = family(a.model)
145
146 return (
147 <Box key={a.id} flexDirection="row" gap={1}>
148 <Box flexShrink={0}>
149 <Text color={mark.color}>{mark.glyph}</Text>
150 </Box>
151 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
152 <Box flexDirection="row" gap={1}>
153 <Box flexShrink={0} flexDirection="row" gap={1}>
154 {indent !== '' && <Text dimColor>{indent.trimEnd()}</Text>}
155 <Text color={TYPE_COLORS[a.type] ?? 'remember'} bold>{a.type}</Text>
156 {model !== undefined && <Text dimColor>{model}</Text>}
157 </Box>
158 <Box flexShrink={1}>
159 <Text dimColor={!live} wrap="truncate-end">{a.description}</Text>
160 </Box>
161 </Box>
162 <Text color={isStuck(a, ctx.now) ? 'warning' : undefined} dimColor={!isStuck(a, ctx.now)} wrap="truncate-end">
163 {' '.repeat(indent.length)}{detail(a, ctx.now)}
164 </Text>
165 </Box>
166 <Box flexShrink={0} flexDirection="row" gap={1}>
167 {ctx.cols >= 52 && <Text color={isBusy(a.status) ? 'claude' : 'inactive'}>{activityOf(a)}</Text>}
168 <Box width={6} justifyContent="flex-end">
169 <Text dimColor>{elapsed((a.endedAt ?? ctx.now) - a.startedAt)}</Text>
170 </Box>
171 </Box>
172 </Box>
173 )
174}
175
176// One line an agent, for the band above the prompt.
177function lineRow(el: El, a: FleetAgent, depth: number, ctx: Ctx) {
178 const { Box, Text } = el
179 const mark = icon(a, ctx.frame, ctx.now)
180 const live = isLive(a.status)
181 const model = family(a.model)
182
183 return (
184 <Box key={a.id} flexDirection="row" gap={1}>
185 <Box flexShrink={0} flexDirection="row" gap={1}>
186 <Text color={mark.color}>{mark.glyph}</Text>
187 {depth > 0 && <Text dimColor>{`${' '.repeat(depth - 1)}└─`}</Text>}
188 <Text color={TYPE_COLORS[a.type] ?? 'remember'} bold>{a.type}</Text>
189 {model !== undefined && <Text dimColor>{model}</Text>}
190 </Box>
191 <Box width={Math.max(12, Math.floor(ctx.cols * 0.28))} flexShrink={1}>
192 <Text dimColor={!live} wrap="truncate-end">{a.description}</Text>
193 </Box>
194 <Box flexGrow={1} flexShrink={1}>
195 <Text color={isStuck(a, ctx.now) ? 'warning' : undefined} dimColor={!isStuck(a, ctx.now)} wrap="truncate-end">
196 {detail(a, ctx.now)}
197 </Text>
198 </Box>
199 <Box flexShrink={0} flexDirection="row" gap={1}>
200 {ctx.cols >= 90 && <Text color={isBusy(a.status) ? 'claude' : 'inactive'}>{activityOf(a)}</Text>}
201 <Box width={6} justifyContent="flex-end">
202 <Text dimColor>{elapsed((a.endedAt ?? ctx.now) - a.startedAt)}</Text>
203 </Box>
204 </Box>
205 </Box>
206 )
207}
208
209// A bar on the time axis for each agent, task or closed workflow.
210export function timelineRows(el: El, items: Item[], ctx: Ctx, onToggle: Toggle) {
211 const { Box, Text, Button } = el
212 const spans = items.map(item =>
213 item.kind === 'agent'
214 ? { start: item.agent.startedAt, end: item.agent.endedAt ?? ctx.now }
215 : groupFacts(item, ctx.now),
216 )
217 const t0 = spans.length === 0 ? ctx.now : Math.min(...spans.map(s => s.start))
218 const t1 = Math.max(t0 + 1000, ...spans.map(s => s.end))
219 const labelWidth = Math.max(14, Math.min(30, Math.floor(ctx.cols * 0.35)))
220 const barWidth = Math.max(8, ctx.cols - labelWidth - 10)
221 const place = (start: number, end: number) => {
222 const from = Math.min(barWidth - 1, Math.floor(((start - t0) / (t1 - t0)) * barWidth))
223 const to = Math.min(barWidth, Math.max(from + 1, Math.ceil(((end - t0) / (t1 - t0)) * barWidth)))
224
225 return { pad: ' '.repeat(from), bar: to - from, rest: ' '.repeat(barWidth - to) }
226 }
227
228 const rows = items.map((item, i) => {
229 const span = spans[i] ?? { start: t0, end: t1 }
230 const { pad, bar, rest } = place(span.start, span.end)
231 if (item.kind === 'group') {
232 const mark = groupIcon(item.members, ctx.frame, ctx.now)
233
234 return (
235 <Box key={`tl-wf-${item.runId}`} flexDirection="row" gap={1}>
236 <Box width={labelWidth} flexDirection="row" gap={1}>
237 <Button key={`wf-${item.runId}`} label={item.isOpen ? '▾' : '▸'} plain onPress={() => onToggle(item.runId)} />
238 <Text color={mark.color}>{mark.glyph}</Text>
239 <Text color="merged" wrap="truncate-end">{item.name}</Text>
240 </Box>
241 <Text>{pad}</Text>
242 <Text color={mark.color}>{'▓'.repeat(bar)}</Text>
243 <Text>{rest}</Text>
244 <Box width={6} justifyContent="flex-end">
245 <Text dimColor>{elapsed(span.end - span.start)}</Text>
246 </Box>
247 </Box>
248 )
249 }
250 const a = item.agent
251 const mark = icon(a, ctx.frame, ctx.now)
252 const indent = item.depth > 0 ? `${' '.repeat(item.depth - 1)}└` : ''
253
254 return (
255 <Box key={`tl-${a.id}`} flexDirection="row" gap={1}>
256 <Box width={labelWidth} flexDirection="row" gap={1}>
257 <Text color={mark.color}>{indent}{mark.glyph}</Text>
258 <Text color={TYPE_COLORS[a.type] ?? 'remember'} bold>{a.type}</Text>
259 <Text dimColor={!isLive(a.status)} wrap="truncate-end">{a.description}</Text>
260 </Box>
261 <Text>{pad}</Text>
262 <Text color={mark.color}>{(isLive(a.status) ? '█' : '━').repeat(bar)}</Text>
263 <Text>{rest}</Text>
264 <Box width={6} justifyContent="flex-end">
265 <Text dimColor>{elapsed(span.end - span.start)}</Text>
266 </Box>
267 </Box>
268 )
269 })
270 const startLabel = t.ago(elapsed(ctx.now - t0))
271 const axis = (
272 <Box key="tl-axis" flexDirection="row" gap={1}>
273 <Box width={labelWidth}>
274 <Text> </Text>
275 </Box>
276 <Text dimColor>{startLabel.padEnd(Math.max(startLabel.length + 1, barWidth - t.now.length))}{t.now}</Text>
277 </Box>
278 )
279
280 return [...rows, axis]
281}
282
283export function listRows(el: El, items: Item[], ctx: Ctx, isOneLine: boolean, onToggle: Toggle) {
284 return items.map(item =>
285 item.kind === 'group'
286 ? groupRow(el, item, ctx, onToggle)
287 : isOneLine
288 ? lineRow(el, item.agent, item.depth, ctx)
289 : paneRow(el, item.agent, item.depth, ctx),
290 )
291}
292hooks/state.ts 39 lines1import type { Color, ElementTable } from 'claude-code'
2
3import type { FleetAgent, FleetKind, FleetUsage } from '../types'
4
5export const PANE = 'flotilla'
6export const TITLE = 'Flotilla'
7export const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
8export const TICK_MS = 250
9export const SPARK = 12
10export const LINGER_MS = 15_000
11export const STUCK_MS = 60_000
12export const MAX_ROWS = 80
13
14/** How long the "nothing running" line shows after the session starts or `/flotilla`. */
15export const IDLE_HINT_MS = 8_000
16
17export type El = Pick<ElementTable, 'Box' | 'Text' | 'Button'>
18export type Ctx = { frame: number; now: number; cols: number }
19export type Group = { runId: string; name: string; members: FleetAgent[]; isOpen: boolean }
20export type Item = { kind: 'agent'; agent: FleetAgent; depth: number } | ({ kind: 'group' } & Group)
21
22export const ZERO: FleetUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
23
24export const TYPE_COLORS: Record<string, Color> = {
25 Explore: 'suggestion',
26 Plan: 'planMode',
27 'general-purpose': 'claude',
28 fork: 'merged',
29 teammate: 'autoAccept',
30 shell: 'bashBorder',
31 monitor: 'permission',
32}
33
34// Tools whose result names a background task, and the kind of task it is.
35export const TASK_TOOLS: Record<string, Exclude<FleetKind, 'agent'>> = { Bash: 'shell', PowerShell: 'shell', Monitor: 'monitor' }
36
37export const patch = (list: FleetAgent[], id: string, fn: (a: FleetAgent) => FleetAgent) =>
38 list.map(a => (a.id === id ? fn(a) : a))
39hooks/status.ts 31 lines1import type { AgentStatus, Color } from 'claude-code'
2
3import type { FleetAgent } from '../types'
4import { SPINNER, STUCK_MS } from './state'
5
6export const isLive = (s: AgentStatus) =>
7 s === 'pending' || s === 'running' || s === 'waiting' || s === 'idle'
8export const isBusy = (s: AgentStatus) => s === 'pending' || s === 'running'
9export const isTask = (a: FleetAgent) => a.kind !== undefined && a.kind !== 'agent'
10export const isStuck = (a: FleetAgent, now: number) =>
11 !isTask(a) && a.status === 'running' && a.inFlight === 0 && now - a.lastActivityAt > STUCK_MS
12
13export function icon(a: FleetAgent, frame: number, now: number): { glyph: string; color: Color } {
14 if (isStuck(a, now)) return { glyph: '⚠', color: 'warning' }
15 if (isBusy(a.status)) return { glyph: SPINNER[(frame + a.tools) % SPINNER.length] ?? '•', color: 'claude' }
16 if (a.status === 'waiting' || a.status === 'idle') return { glyph: '◌', color: 'inactive' }
17 if (a.status === 'completed') return { glyph: '✓', color: 'success' }
18 if (a.status === 'failed') return { glyph: '✗', color: 'error' }
19
20 return { glyph: '■', color: 'warning' }
21}
22
23export function groupIcon(members: FleetAgent[], frame: number, now: number): { glyph: string; color: Color } {
24 if (members.some(a => isStuck(a, now))) return { glyph: '⚠', color: 'warning' }
25 if (members.some(a => isBusy(a.status))) return { glyph: SPINNER[frame % SPINNER.length] ?? '•', color: 'claude' }
26 if (members.some(a => a.status === 'failed')) return { glyph: '✗', color: 'error' }
27 if (members.some(a => isLive(a.status))) return { glyph: '◌', color: 'inactive' }
28
29 return { glyph: '✓', color: 'success' }
30}
31hooks/strings.ts 73 lines1export type Lang = 'en' | 'fr'
2
3const STRINGS = {
4 en: {
5 active: (n: number) => `${n} active`,
6 done: (n: number) => `${n} done`,
7 tools: (n: number) => `${n} tool${n === 1 ? '' : 's'}`,
8 toolCalls: (n: number) => `${n} tool call${n === 1 ? '' : 's'}`,
9 more: (n: number) => `+${n} more · /flotilla side to see all`,
10 idleFor: (d: string) => `⚠ no activity for ${d}`,
11 starting: 'starting…',
12 thinking: 'thinking…',
13 waitingOn: '◌ waiting on its background work',
14 agents: (n: number) => `${n} agent${n === 1 ? '' : 's'}`,
15 waiting: (n: number) => `${n} waiting`,
16 empty: 'No agents yet. They show up here as soon as one starts.',
17 ago: (d: string) => `${d} ago`,
18 now: 'now',
19 list: 'List',
20 timeline: 'Timeline',
21 pane: 'Side pane',
22 above: 'Above prompt',
23 collapse: 'Collapse',
24 expand: 'Expand',
25 hideDone: 'Hide done',
26 showDone: 'Show done',
27 clear: 'Clear',
28 noneRunning: 'Nothing is running right now. Flotilla shows up as soon as an agent or a background task starts.',
29 tasks: (n: number) => `${n} task${n === 1 ? '' : 's'}`,
30 idleLine: 'Flotilla is on · nothing running',
31 ended: (status: string) => ({ completed: 'done', failed: 'failed', killed: 'stopped' })[status] ?? status,
32 },
33 fr: {
34 active: (n: number) => `${n} actif${n > 1 ? 's' : ''}`,
35 done: (n: number) => `${n} terminé${n > 1 ? 's' : ''}`,
36 tools: (n: number) => `${n} outil${n > 1 ? 's' : ''}`,
37 toolCalls: (n: number) => `${n} appel${n > 1 ? 's' : ''} d'outils`,
38 more: (n: number) => `+${n} autre${n > 1 ? 's' : ''} · /flotilla side pour tout voir`,
39 idleFor: (d: string) => `⚠ aucune activité depuis ${d}`,
40 starting: 'démarrage…',
41 thinking: 'réfléchit…',
42 waitingOn: '◌ attend son travail en arrière-plan',
43 agents: (n: number) => `${n} agent${n > 1 ? 's' : ''}`,
44 waiting: (n: number) => `${n} en attente`,
45 empty: "Aucun agent en cours. Ils apparaissent ici dès qu'un agent démarre.",
46 ago: (d: string) => `il y a ${d}`,
47 now: 'maintenant',
48 list: 'Liste',
49 timeline: 'Chronologie',
50 pane: 'Panneau',
51 above: 'Au-dessus du prompt',
52 collapse: 'Réduire',
53 expand: 'Agrandir',
54 hideDone: 'Masquer terminés',
55 showDone: 'Afficher terminés',
56 clear: 'Vider',
57 noneRunning: "Rien ne tourne pour l'instant. Flotilla apparaît dès qu'un agent ou une tâche en arrière-plan démarre.",
58 tasks: (n: number) => `${n} tâche${n > 1 ? 's' : ''}`,
59 idleLine: 'Flotilla est actif · rien ne tourne',
60 ended: (status: string) => ({ completed: 'terminée', failed: 'échouée', killed: 'arrêtée' })[status] ?? status,
61 },
62}
63
64/** The words every drawing uses; set from the `language` option, or detected. */
65export let t: (typeof STRINGS)[Lang] = STRINGS.en
66
67export function setLang(lang: Lang) {
68 t = STRINGS[lang]
69}
70
71export const asLang = (value: unknown): Lang | undefined =>
72 typeof value !== 'string' ? undefined : /^(fr|french|fran[cç]ais)/i.test(value) ? 'fr' : /^(en|english)/i.test(value) ? 'en' : undefined
73hooks/tasks.ts 31 lines1import type { AgentStatus } from 'claude-code'
2
3export type Ended = { id: string; status: AgentStatus }
4
5/** One entry of the background work the engine lists at a turn's end (`classic.Stop`). */
6export type InFlight = { id: string; type: string; status: string; description: string; command?: string }
7
8// A task notification names its task and how it ended, as Claude Code writes it:
9// <task-notification>
10// <task-id>bcduz1ph2</task-id>
11// <tool-use-id>…</tool-use-id>
12// <output-file>…</output-file>
13// <status>failed</status>
14// <summary>Background command "…" failed with exit code 3</summary>
15// </task-notification>
16export function notified(text: string): Ended[] {
17 const out: Ended[] = []
18 for (const block of text.split(/<\/task-notification>/)) {
19 const id = /<task-id>\s*([^<\s]+)\s*<\/task-id>/.exec(block)?.[1]
20 if (id === undefined) continue
21 const word = /<status>\s*([a-z_]+)\s*<\/status>/i.exec(block)?.[1]?.toLowerCase() ?? 'completed'
22 const exitCode = Number(/exit code (-?\d+)/i.exec(block)?.[1] ?? 0)
23 const status: AgentStatus = word === 'failed' || word === 'error' || exitCode !== 0 ? 'failed'
24 : word === 'killed' || word === 'stopped' ? 'killed'
25 : 'completed'
26 out.push({ id, status })
27 }
28
29 return out
30}
31types/index.d.ts 64 lines1export type FleetStatus = 'pending' | 'running' | 'waiting' | 'idle' | 'completed' | 'failed' | 'killed'
2
3/** Token counts as the API reports them, summed over the agent's turns. */
4export type FleetUsage = { input: number; output: number; cacheRead: number; cacheWrite: number }
5
6/** What a row tracks: a subagent, or a background task (shell command, monitor). */
7export type FleetKind = 'agent' | 'shell' | 'monitor'
8
9export type FleetAgent = {
10 id: string
11 /** Absent on rows written before tasks were tracked: an agent. */
12 kind?: FleetKind
13 type: string
14 description: string
15 parentId?: string
16 /** The model id the agent runs on, when known. */
17 model?: string
18 /** The workflow run that started it, and the Workflow tool call of that run. */
19 workflowRun?: string
20 workflowCall?: string
21 startedAt: number
22 endedAt?: number
23 /** Last tool call started or finished, or the spawn. */
24 lastActivityAt: number
25 /** Tool calls running right now. */
26 inFlight: number
27 status: FleetStatus
28 tools: number
29 tool?: string
30 target?: string
31 /** Tool calls per second, newest last. */
32 activity: number[]
33 usage: FleetUsage
34 /** First sentence of the agent's final answer. */
35 summary?: string
36}
37
38/** Where the fleet shows: a detailed list above the prompt, the side pane, or one compact line. */
39export type FleetView = 'above' | 'side' | 'compact'
40
41/** How the agents are drawn: rows with details, or bars on a time axis. */
42export type FleetLayout = 'list' | 'timeline'
43
44export type FleetWorkflowName = { toolUseId: string; name: string }
45
46export type FleetClock = { frame: number; now: number }
47
48declare module 'claude-code' {
49 interface PluginState {
50 'flotilla': {
51 fleet: FleetAgent[]
52 view: FleetView
53 layout: FleetLayout
54 paneShown: boolean
55 hideDone: boolean
56 clock: FleetClock
57 workflowNames: FleetWorkflowName[]
58 openRuns: string[]
59 /** Until when the band shows the "nothing running" line (ms since epoch; 0 for never). */
60 idleHintUntil: number
61 }
62 }
63}
64