SLOPSHOPPER

Flotilla

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

newpanebandguardcommandstatus
v1.3.2MITupdated 2026-10-10Aleqsd/flotilla
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · flotilla
│ ┃ flotilla ✕ › fix the failing auth test and add an audit log call │ ┃ ✓ Flotilla 0 active · 0 done │ ┃ ⏺ Read(src/auth.ts) │ ┃ No agents yet. They show up here as soon as ⎿ Read 6 lines │ ┃ one starts. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ 0 tokens · 0 tool calls ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ [ Timeline ] [ Above prompt ] [ Collapse ] [ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /flotilla │ ⎿ flotilla: Flotilla shown above the prompt. │ ⎿ flotilla: Nothing is running right now. Flotilla shows up as soo │ │ Flotilla is on · nothing running ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Flotilla is on · nothing running
Pane · flotilla
✓ Flotilla 0 active · 0 done No agents yet. They show up here as soon as one starts. 0 tokens · 0 tool calls [ Timeline ] [ Above prompt ] [ Collapse ] [ Hide done ] [ C
README

<img src="assets/icon.png" alt="Flotilla icon: three sailboats" width="96" align="right">

Flotilla

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.

Flotilla tracking four Haiku subagents, a nested subagent and a background shell command above the Claude Code prompt

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.

Features

  • Live list above the prompt (default): one row per agent with a spinner, type, model, current tool and target, an activity sparkline, and elapsed time. It disappears 15 s after the last agent finishes.
  • Side pane: a two-line-per-agent view docked next to the transcript (/flotilla side).
  • Compact mode: everything on one line (/flotilla compact).
  • Background tasks: shell commands run in the background (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.
  • Agent tree: agents spawned by other agents are nested under their parent, and so are the background tasks they start.
  • Stuck-agent alert: a running agent that hasn't called a tool in 60 s turns orange with ⚠. A long tool call in flight, such as a slow test run, isn't flagged; its row shows how long it's been running.
  • Results at a glance: finished agents show the first sentence of their final answer.
  • Model and cost: a haiku / sonnet / opus / fable badge per agent, plus an estimated cost per agent and in total.
  • Workflow groups: agents started by one workflow collapse into a single row with a progress bar (▰▰▰▱▱ 3/5). Click ▸ to expand.
  • Timeline: bars on a time axis show what ran in parallel and for how long (/flotilla timeline, or press t).
  • Status line: ● 3/5 agents · 1 task · ≈$0.43 shows whenever the session has agents, whichever view you use.
  • Loaded check: for a few seconds after a session starts, or after /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.

Install

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.

Usage

Flotilla shows up on its own as soon as an agent starts. To change the view:

CommandEffect
/flotillaDetailed list above the prompt (default)
/flotilla sideSide pane
/flotilla compactOne line above the prompt
/flotilla timeline / /flotilla listSwitch between timeline bars and rows
/flotilla clearRemove finished agents
/flotilla lang en / fr / autoSet the interface language

Buttons and their hotkeys work once the band or pane has focus:

KeyAction
tToggle timeline / list
pOpen the side pane
aMove back above the prompt (from the pane)
rCollapse to one line
eExpand from one line
hHide / show finished agents
cClear 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.

Language

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" } } } }

About the cost estimate

Costs are estimated from each agent's token usage and the public Anthropic API list prices (per million tokens):

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

How it works

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.
  • Background tasks are picked up from the tool result (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.

Development

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.

License

MIT

Source 9 files
hooks/register.tsx 578 lines
1import { 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}
578
hooks/cost.ts 48 lines
1import 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}
48
hooks/format.ts 61 lines
1import { 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}
61
hooks/rows.tsx 292 lines
1import 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}
292
hooks/state.ts 39 lines
1import 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))
39
hooks/status.ts 31 lines
1import 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}
31
hooks/strings.ts 73 lines
1export 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
73
hooks/tasks.ts 31 lines
1import 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}
31
types/index.d.ts 64 lines
1export 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