Mission control for your subagents: what each one is doing, its activity and its cost, live.

<img src="../../brand/pixel-icons/svg/ant-farm.svg" width="64" height="64" alt="">
Mission control for your subagents: what each one is doing, its activity and its cost, live.
/plugin install ant-farm --marketplace modmyclaude/claude-code-mods
/antfarm opens it (mid-turn too, and it adds nothing to the conversation). At the top: Bolt, the mission's state in one line (Working · 3 running, Waiting · 1 held, All done · 4 agents, 2 failed · 1 done), the mission clock (T+12:04), the counts, the subagents' total (price-table estimate), the session's spend against your budget (Claude Code's estimate where it keeps one) and a budget bar. With no subagents yet, Bolt naps here. From 84 columns a heat strip sits beside the hero line: one column per subagent in mission order (twelve at most, the running ones always among them), one row per ten seconds of the last minute, each cell shaded by how much that subagent did in that window, in its colour, with the mission numbers underneath. It steps once every ten seconds. On the desktop app the pane also holds a mission map: a still picture of who spawned whom, each node named by its mission number and task, a halo on the running ones, done and failed in their colours, the full task names in its alt text; m hides or shows it.WORKING · Bash npm test · 4s, THINKING · 12s, WAITING · held by the engine), a sparkline of tokens per request, requests and tokens, a progress bar, its estimated cost and ETA, and on a wide card how many tools it called, files it edited and calls that failed. The border takes the model's colour, green for a moment when it finishes, red when it fails, then rests dim. The pointer lights a card, and resting on it grows the card by a few dim rows: the task in full where the title had to cut it, and the subagent's last three rows. Nothing is redrawn for it; the surface shows and hides the rows by itself.THINKING while a thinking block streams in its own model request, WORKING while it writes or runs a tool (the status line names the tool and what it touches: Bash npm test, Edit auth.ts), WAITING while Claude Code's agent list says it is held, DONE and FAILED at its end. A held subagent counts as waiting, not as running too.from #1), tool calls, edits, endings with their duration, tools that failed or were refused, responses that hit a limit, the budget alarm. 12 rows by default, +N more shows 40, hide folds it.open on a card (or its number) opens a panel with the subagent's task, type, model, start, where it came from, its live state, its numbers, a longer sparkline and its own last rows; previous, next and close move between subagents.n and p move, x closes the inspector, f expands or collapses the finished list, t folds the timeline; a Keys line shows then. Esc hands the keyboard back./antfarm opens it any time, and asks the dock for room for two cards across./cost shows, labelled "Claude Code's estimate". It is summed at list prices, so it is not your bill (a subscription does not pay it). Where Claude Code gives none, the price-table estimate stands in. Each subagent's cost is always the price-table estimate: Claude Code does not total cost per subagent. Every figure is marked ≈.budgetUsd in /config. Then the mod's status line under the prompt shows the session's spend against it. When it reaches the budget you get a toast and the budget mark beside the prompt footer turns amber; at twice the budget, another toast and red. Each crossing toasts once, and lands on the timeline./clear (or another session taking this one's place) starts the farm over, as it does /cost.Every setting has a default, so the notice at install about options not yet set is expected; change them in /config.
| Setting | Default | What it does |
|---|---|---|
budgetUsd | 0 (off) | Session budget in US dollars for the alarm, compared against Claude Code's own cost estimate (else the price-table estimate). Off until you set one; the pane says so. It only warns, it never stops Claude. |
/antfarm opens the pane, or lists the subagents in the band above the prompt where a pane cannot be placed, and does nothing else. The hook on a subagent's spawn runs after the engine has spawned it and records the number, task, type and model that came back; it never refuses, delays or changes a spawn. Every other hook only watches.password= value, a key in a URL) is replaced with [REDACTED] before the pane keeps or shows it.≈. The table cannot see fast mode, batch pricing, partner clouds (Bedrock, Vertex) or negotiated rates, and it prices every cache write at the 5-minute rate. A model missing from the table shows its tokens and a cost of ?. The subagents' sum can therefore differ from Claude Code's estimate./cost does.turn.step, so when Claude Code gives no estimate the table's misses them./antfarm.isolation: 'remote', internal builds only) has no local loop: ant-farm sees it start but none of its requests, and cannot tell exactly when it ends.WORKING until its next request says more; after a code reload every running one does. While Claude Code asks you about a subagent's tool, the card keeps its last phase: the mod reads no permission decision.inspect instead.This mod was built from the ant-farm prompt.
More mods: the full catalogue.
hooks/register.tsx 661 lines1// ant-farm: watch your subagents work, and what they cost, live.
2//
3// Every model request (`turn.step`) is counted against the loop it ran in:
4// a subagent (started at `agent.spawn`, finished at its `turn.complete`), the
5// main loop, or another loop the engine runs. Per-subagent costs come from a
6// small price table and are always estimates; the session's total and the
7// budget alarm use Claude Code's own cost estimate (what /cost shows), with the
8// table's estimate as the fallback where the engine gives none. While
9// anything runs, one clock ticks to walk the ants and the timers; when
10// everything is idle it stops.
11//
12// The pane is a mission control: what each subagent is doing right now comes
13// from the chunks of its model requests (thinking, text, a tool named), its
14// tool calls; the activity timeline keeps
15// the last eighty of those. Nothing shown is inferred.
16
17import { atom, read, update } from 'claude-code'
18import type { AgentStatus, EngineInterface, Register, Timer, ToolCallResult, TurnStepResult } from 'claude-code'
19
20import type { AntFarmAnt, AntFarmEvent, AntFarmLoops, AntFarmStatus, AntFarmUsage } from '../types'
21import { addRequest, budgetLevel, NO_USAGE, sumUsage } from './prices'
22import { glyphRow } from './cells'
23import { drawControl } from './control'
24import { redact } from './redact'
25import {
26 FRESH_MS, moveSelection, nextN, ordered, pushEvent, pushHistory, SETTLE_MS, stopWarning, toolSummary,
27} from './mission'
28import type { AntFarmPhase, LivePhase } from './mission'
29import {
30 alarmText, clean, fmtCost, fmtDuration, fmtEta, fmtTokens, miniAnt, modelColors, nextAlarm, phaseOf,
31 progressOf, spendLabel, spendOf, statusText, trimAnts, viewAfterClose,
32} from './show'
33import type { Spend } from './show'
34
35const PANE = 'ant-farm'
36const TITLE = 'Ant farm'
37/** The body columns /antfarm asks the dock for: two cards side by side. */
38const PANE_COLUMNS = 84
39/**
40 * A moment after a pane opens, ask for a draw again. On the desktop app the
41 * first draw of a pane opened by a command did not land once (the pane said
42 * nothing had been drawn until a resize asked again); the second ask costs one
43 * render and makes the pane whole either way.
44 */
45const OPEN_NUDGE_MS = 300
46/** One frame of the ants, and of every elapsed time shown. */
47const TICK_MS = 500
48/** Every this many ticks, running ants are checked against the engine's agent list. */
49const RECONCILE_TICKS = 20
50const MAX_ANTS = 200
51/** A running ant silent this long (no request) no longer keeps the clock ticking on its own. */
52const STALL_MS = 10 * 60_000
53
54const ants = atom({ plugin: 'ant-farm', key: 'ants' } as const, [])
55const loops = atom({ plugin: 'ant-farm', key: 'loops' } as const, { main: NO_USAGE, other: NO_USAGE })
56const turn = atom({ plugin: 'ant-farm', key: 'turn' } as const, { isRunning: false, startedAt: 0 })
57const alarm = atom({ plugin: 'ant-farm', key: 'alarm' } as const, { level: 0, budgetUsd: -1 })
58const engineUsd = atom({ plugin: 'ant-farm', key: 'engineUsd' } as const, null)
59const events = atom({ plugin: 'ant-farm', key: 'events' } as const, [])
60const view = atom({ plugin: 'ant-farm', key: 'view' } as const, {
61 isListInBand: false,
62 isFinishedExpanded: false,
63 isClosedByHand: false,
64 hasOpenedUnasked: false,
65})
66
67const isRunning = (a: AntFarmAnt) => a.status === 'running'
68// The untrusted texts of an ant, cleaned for drawing.
69const taskOf = (a: AntFarmAnt) => clean(a.task)
70const modelOf = (a: AntFarmAnt) => clean(shortModelOf(a))
71const shortModelOf = (a: AntFarmAnt) => a.model.replace(/^.*?claude-/, '') || a.type
72
73function sessionTotal(list: readonly AntFarmAnt[], l: AntFarmLoops): AntFarmUsage {
74 return sumUsage([...list.map(a => a.usage), l.main, l.other])
75}
76
77function endStatus(s: AgentStatus | undefined): AntFarmStatus | undefined {
78 if (s === undefined || s === 'completed' || s === 'idle') return 'done'
79 if (s === 'failed' || s === 'killed') return 'failed'
80 return undefined
81}
82
83// Module variables hold only what a reload may lose: the options, the timer
84// (a reload cancels it anyway), the last status text, a cache of loop ids and
85// the live phase of each running subagent (seconds old at most; a reload
86// starts it over at "working"). Helpers that take `$` live here at the top of
87// the file, where the engine can follow `$` into them.
88let budgetUsd = 0
89let ticker: Timer | undefined
90let ticks = 0
91let docksPanes: boolean | undefined
92let lastStatus: string | undefined
93const notAgents = new Set<string>()
94const live = new Map<string, LivePhase>()
95
96/** Notes what a subagent was last seen doing; the clock stamps when, off the hook's path. */
97function setPhase($: EngineInterface, id: string, phase: AntFarmPhase, detail?: string) {
98 const was = live.get(id)
99 if (was !== undefined && was.phase === phase && was.detail === detail) return
100 const next: LivePhase = detail !== undefined ? { phase, detail } : { phase }
101 live.set(id, next)
102 $.clock.now().then(now => {
103 if (live.get(id) === next) next.since = now
104 }).catch(() => undefined)
105}
106
107function stopTicker() {
108 ticker?.cancel()
109 ticker = undefined
110}
111
112function startTicker($: EngineInterface) {
113 if (ticker === undefined) ticker = $.clock.every(TICK_MS, () => void tick($))
114}
115
116async function tick($: EngineInterface) {
117 ticks += 1
118 try {
119 if (ticks % RECONCILE_TICKS === 0) await reconcile($)
120 const now = await $.clock.now()
121 const list = await read($, ants)
122 const newest = (await read($, events)).at(-1)
123 // Busy while anything runs, and for the moment a finished card keeps its
124 // colour and the newest row its weight, so those transitions complete.
125 const busy =
126 (await read($, turn)).isRunning ||
127 list.some(a => isRunning(a) && now - (a.lastActiveAt ?? a.startedAt) < STALL_MS) ||
128 list.some(a => a.endedAt !== undefined && now - a.endedAt < SETTLE_MS) ||
129 (newest !== undefined && now - newest.at < FRESH_MS)
130 if (!busy) stopTicker()
131 $.ui.invalidate('ui.render')
132 } catch {
133 // A tick that fails only skips a frame.
134 }
135}
136
137// A background subagent stopped from outside may never raise its
138// turn.complete: the engine's own list settles it, so no ant runs forever.
139// The same list says which running agents the engine holds (waiting).
140async function reconcile($: EngineInterface) {
141 const now = await $.clock.now()
142 const suspects = (await read($, ants)).filter(a => isRunning(a) && !a.isWorkflow && now - a.startedAt > 5000)
143 if (suspects.length === 0) return
144 const listed = new Map((await $.agent.list()).map(info => [info.id, info.status] as const))
145 const ended = new Map<string, AntFarmStatus>()
146 for (const a of suspects) {
147 const status = endStatus(listed.get(a.id))
148 if (status !== undefined) ended.set(a.id, status)
149 else if (listed.get(a.id) === 'waiting') setPhase($, a.id, 'waiting', 'held by the engine')
150 else if (live.get(a.id)?.detail === 'held by the engine') setPhase($, a.id, 'working')
151 }
152 if (ended.size === 0) return
153 await update($, ants, list =>
154 list.map(a => (isRunning(a) && ended.has(a.id) ? { ...a, status: ended.get(a.id) ?? 'done', endedAt: now } : a)),
155 )
156 const settled = suspects.filter(a => ended.has(a.id))
157 for (const a of settled) live.delete(a.id)
158 await update($, events, list =>
159 settled.reduce(
160 (acc, a) => pushEvent(acc, { at: now, kind: ended.get(a.id) === 'failed' ? 'failed' : 'done', agentId: a.id, text: a.task, detail: 'settled from the agent list' }),
161 list,
162 ),
163 )
164}
165
166// Claude Code's own cost estimate for the session, or null where the host keeps
167// no ledger or the call fails: the table's estimate stands in then.
168async function engineTotal($: EngineInterface): Promise<number | null> {
169 try {
170 const usd = (await $.session.usage()).cost?.usd
171 return typeof usd === 'number' && Number.isFinite(usd) ? usd : null
172 } catch {
173 return null
174 }
175}
176
177/** The session's spend as drawn: the engine total last read, else the estimate. */
178async function sessionSpend($: EngineInterface): Promise<Spend> {
179 return spendOf(await read($, engineUsd), sessionTotal(await read($, ants), await read($, loops)))
180}
181
182// The status line and the alarm, after every counted request and turn end.
183async function announce($: EngineInterface) {
184 const usd = await engineTotal($)
185 // A failed read keeps the last good total, so one hiccup cannot drop the
186 // figure to the estimate and re-arm the alarm. Compared by value: state
187 // crosses as JSON.
188 if (usd !== null && usd !== (await read($, engineUsd))) await update($, engineUsd, () => usd)
189 const spend = await sessionSpend($)
190 const level = budgetLevel(spend.usd, budgetUsd)
191 const text = statusText(spend, budgetUsd, level)
192 if (text !== lastStatus) {
193 lastStatus = text
194 $.ui.status(text === undefined ? undefined : clean(text))
195 }
196 // nextAlarm alone decides; the read first only skips a write that would change nothing.
197 const stored = await read($, alarm)
198 const preview = nextAlarm(stored, level, budgetUsd)
199 if (preview.toast === 0 && preview.alarm.level === stored.level && preview.alarm.budgetUsd === stored.budgetUsd) return
200 let toast = 0 as 0 | 1 | 2
201 await update($, alarm, a => {
202 const next = nextAlarm(a, level, budgetUsd)
203 toast = next.toast
204 return next.alarm
205 })
206 if (toast !== 0) {
207 const text = alarmText(spend, budgetUsd, toast)
208 $.ui.toast(clean(text), { timeoutMs: 8000 })
209 const now = await $.clock.now()
210 await update($, events, list => pushEvent(list, { at: now, kind: 'warning', text }))
211 startTicker($) // the row is bold for a moment, even with nothing running
212 }
213}
214
215// Is this loop one of the session's agents? One the mod did not see start
216// (it loaded mid-run, a forked skill) is taken from the engine's list; a
217// loop the list does not name (compaction, memory) counts as "other".
218async function knowAnt($: EngineInterface, id: string): Promise<boolean> {
219 if ((await read($, ants)).some(a => a.id === id)) return true
220 if (notAgents.has(id)) return false
221 const info = (await $.agent.list()).find(a => a.id === id)
222 if (info === undefined) {
223 notAgents.add(id)
224 return false
225 }
226 const now = await $.clock.now()
227 await update($, ants, list => {
228 if (list.some(a => a.id === id)) return list
229 const ant: AntFarmAnt = {
230 id, task: info.description || info.type, type: info.type, model: '', status: 'running',
231 startedAt: now, lastActiveAt: now, usage: NO_USAGE, n: nextN(list),
232 ...(info.parentId !== undefined ? { parentId: info.parentId } : {}),
233 }
234 return trimAnts([...list, ant], MAX_ANTS)
235 })
236 return true
237}
238
239// Counts one finished request. It runs just after the response is handed
240// back, so it may land after that loop's turn.complete: `at` (when the
241// response ended) tells a late count of a finished run, which leaves the ant
242// finished, from a teammate's next turn, which wakes it.
243async function recordStep(
244 $: EngineInterface, agentId: string | undefined, requested: string, r: TurnStepResult, at: number,
245) {
246 const hasResponse = !(r.usage === null && r.stopReason === null)
247 if (!hasResponse && agentId === undefined) return // no response arrived
248 const model = r.usage?.model ?? requested
249 if (agentId === undefined) {
250 await update($, loops, l => ({ ...l, main: addRequest(l.main, model, r.usage) }))
251 } else if (await knowAnt($, agentId)) {
252 const warning = stopWarning(r.stopReason, hasResponse)
253 if (hasResponse) {
254 await update($, ants, list =>
255 list.map(a => {
256 if (a.id !== agentId) return a
257 const usage = addRequest(a.usage, model, r.usage)
258 const history = pushHistory(a.history, usage.tokens - a.usage.tokens)
259 const lastActiveAt = Math.max(a.lastActiveAt ?? a.startedAt, at)
260 if (a.endedAt !== undefined && at <= a.endedAt) return { ...a, model, lastActiveAt, usage, history }
261 const { endedAt: _, ...rest } = a
262 return { ...rest, model, status: 'running' as const, lastActiveAt, usage, history }
263 }),
264 )
265 if ((await read($, ants)).some(a => a.id === agentId && isRunning(a))) startTicker($)
266 }
267 if (warning !== undefined) await update($, events, list => pushEvent(list, { at, kind: 'warning', agentId, text: warning }))
268 } else if (hasResponse) {
269 await update($, loops, l => ({ ...l, other: addRequest(l.other, model, r.usage) }))
270 }
271 if (hasResponse) await announce($)
272}
273
274// Notes one finished tool call of a subagent on its ant and the timeline:
275// after the result went back, off the tool's path.
276async function recordTool($: EngineInterface, agentId: string, tool: string, summary: { kind: 'tool' | 'file'; text: string }, result: ToolCallResult) {
277 if (!(await knowAnt($, agentId))) return
278 const now = await $.clock.now()
279 const deny = typeof result.deny === 'string' ? result.deny : undefined
280 const isError = deny === undefined && result.isError === true
281 const failed = deny !== undefined || isError
282 await update($, ants, list =>
283 list.map(a =>
284 a.id !== agentId ? a : {
285 ...a,
286 tools: (a.tools ?? 0) + 1,
287 ...(summary.kind === 'file' ? { edits: (a.edits ?? 0) + 1 } : {}),
288 ...(failed ? { errors: (a.errors ?? 0) + 1 } : {}),
289 },
290 ),
291 )
292 // Redacted whole, then cut: a cut never leaves half a credential behind.
293 const detail = deny !== undefined ? `refused: ${firstWords(redact(deny))}` : isError ? firstWords(redact(typeof result.text === 'string' ? result.text : 'failed')) : undefined
294 await update($, events, list =>
295 pushEvent(list, {
296 at: now, kind: failed ? 'error' : summary.kind, agentId, text: summary.text, ...(detail !== undefined ? { detail } : {}),
297 }),
298 )
299}
300
301/** The first line of a text, at most 60 characters. */
302function firstWords(text: string): string {
303 const line = text.split(/\r?\n/, 1)[0] ?? ''
304 return line.length > 60 ? `${line.slice(0, 59)}…` : line
305}
306
307// Unasked, the pane opens at most once a session, only where it would be a
308// sidebar (a surface that docks panes), and not after the person closed it
309// by hand. Under 144 columns the engine keeps it waiting: the band lists the
310// ants instead.
311async function openUnasked($: EngineInterface) {
312 if (docksPanes !== true) return
313 if ((await $.ui.panes()).some(p => p.id === PANE)) return
314 // Claim the one unasked open in the write itself, so two spawns at once open it once.
315 let isClaimed = false
316 await update($, view, v => {
317 isClaimed = !v.hasOpenedUnasked && !v.isClosedByHand
318 return isClaimed ? { ...v, hasOpenedUnasked: true } : v
319 })
320 if (!isClaimed) return
321 const opened = await $.ui.open({ id: PANE, title: TITLE })
322 if (!opened.isPlaced) await update($, view, v => ({ ...v, isListInBand: true }))
323 else nudgeAfterOpen($)
324}
325
326/** One more draw shortly after an open, so a first draw that did not land is drawn after all. */
327function nudgeAfterOpen($: EngineInterface) {
328 $.clock.after(OPEN_NUDGE_MS, () => $.ui.invalidate('ui.render'))
329}
330
331async function isPanePlaced($: EngineInterface): Promise<boolean> {
332 try {
333 return (await $.ui.panes()).some(p => p.id === PANE && p.isPlaced)
334 } catch {
335 return false
336 }
337}
338
339export const register: Register = (on, options) => {
340 budgetUsd = typeof options.budgetUsd === 'number' ? options.budgetUsd : 0
341
342 // ------------------------------------------------------------ events
343
344 on('session.start', async ($, e, next) => {
345 await $.command.register({
346 name: 'antfarm',
347 description: 'Open the ant farm: mission control for every subagent this session, its activity and estimated cost',
348 immediate: true,
349 })
350 // A reload of the code (session.start fires again) loses the module's
351 // clock while the kept state may still say something runs: start it again,
352 // so the pane keeps moving instead of waiting for the next event.
353 const kept = await read($, ants)
354 if ((await read($, turn)).isRunning || kept.some(isRunning)) startTicker($)
355 return next(e)
356 })
357
358 // A /clear starts the session over, as /cost does; so does another session
359 // taking this one's place.
360 on('session.end', async ($, e, next) => {
361 if (e.reason === 'clear' || e.reason === 'resume') {
362 stopTicker()
363 notAgents.clear()
364 live.clear()
365 await update($, ants, () => [])
366 await update($, loops, () => ({ main: NO_USAGE, other: NO_USAGE }))
367 await update($, turn, () => ({ isRunning: false, startedAt: 0 }))
368 await update($, alarm, () => ({ level: 0, budgetUsd: -1 }))
369 await update($, engineUsd, () => null)
370 await update($, events, () => [])
371 await update($, view, v => {
372 const { selectedId: _, ...rest } = v
373 return rest
374 })
375 lastStatus = undefined
376 $.ui.status(undefined)
377 }
378 return next(e)
379 })
380
381 // No transcript text: the model would read it. The pane is the answer; a
382 // pane that cannot be placed is explained in a toast.
383 on('command.run', { command: 'antfarm' }, async $ => {
384 const opened = await $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS })
385 if (opened.isPlaced) {
386 await update($, view, v => ({ ...v, isListInBand: false, isClosedByHand: false }))
387 nudgeAfterOpen($)
388 return {}
389 }
390 await update($, view, v => ({ ...v, isListInBand: true }))
391 $.ui.toast(clean(`The pane cannot be placed here (${opened.reason}). The band above the prompt lists the subagents instead.`))
392 return {}
393 })
394
395 // An observer, never a gate: it lets the spawn run first, then records the
396 // started subagent. The handler only makes that explicit: whatever this hook
397 // does, the spawn's own result stands.
398 on('agent.spawn', async ($, e, next) => {
399 const result = await next(e)
400 if (result.deny !== undefined || result.agentId === undefined) return result
401 const agentId = result.agentId
402 const now = await $.clock.now()
403 let parentN: number | undefined
404 await update($, ants, list => {
405 parentN = e.parentAgentId !== undefined ? list.find(a => a.id === e.parentAgentId)?.n : undefined
406 const ant: AntFarmAnt = {
407 id: agentId,
408 task: e.description || e.subagentType,
409 type: e.subagentType,
410 model: result.model,
411 status: 'running',
412 startedAt: now,
413 lastActiveAt: now,
414 usage: NO_USAGE,
415 n: nextN(list.filter(a => a.id !== agentId)),
416 ...(e.parentAgentId !== undefined ? { parentId: e.parentAgentId } : {}),
417 ...(e.workflow !== undefined ? { isWorkflow: true } : {}),
418 }
419 return trimAnts([...list.filter(a => a.id !== agentId), ant], MAX_ANTS)
420 })
421 setPhase($, agentId, 'starting')
422 await update($, events, list =>
423 pushEvent(list, {
424 at: now, kind: e.parentAgentId !== undefined ? 'handoff' : 'spawn', agentId, text: e.description || e.subagentType,
425 ...(e.parentAgentId !== undefined ? { detail: parentN !== undefined ? `from #${parentN}` : 'from another agent' } : {}),
426 }),
427 )
428 startTicker($)
429 await openUnasked($)
430 return result
431 }).catch(($, e, next) => next(e))
432
433 on('turn.start', async ($, e, next) => {
434 const started = await next(e)
435 const now = await $.clock.now()
436 await update($, turn, () => ({ isRunning: true, startedAt: now }))
437 startTicker($)
438 return started
439 })
440
441 // The stream passes through untouched, each chunk yielded as it came; a
442 // subagent's chunks say what it is doing (thinking, writing, naming a tool).
443 // The response goes back at once; its usage is counted just after, on the
444 // clock, so counting (a dozen host calls) never delays a model request, and
445 // a count that fails is only missing from the estimate.
446 on('turn.step', async function* ($, e, next) {
447 const stream = next(e)
448 let item = await stream.next()
449 while (item.done !== true) {
450 const chunk = item.value
451 if (e.agentId !== undefined) {
452 if (chunk.kind === 'thinking') setPhase($, e.agentId, 'thinking')
453 else if (chunk.kind === 'text') setPhase($, e.agentId, 'writing')
454 else if (chunk.kind === 'tool') setPhase($, e.agentId, 'tool', clean(chunk.name))
455 }
456 yield chunk
457 item = await stream.next()
458 }
459 const response = item.value
460 const at = await $.clock.now()
461 $.clock.after(0, () => void recordStep($, e.agentId, e.model, response, at).catch(() => undefined))
462 return response
463 })
464
465 // A subagent's tool call: what it runs now, then what came of it. The
466 // call itself is untouched: its result goes back as the engine made it.
467 on('tool.call', async ($, e, next) => {
468 if (e.agentId === undefined) return next(e)
469 const agentId = e.agentId
470 const summary = toolSummary(e.tool, e as unknown as Record<string, unknown>) // secret shapes already replaced
471 setPhase($, agentId, 'tool', clean(summary.text))
472 const result = await next(e)
473 setPhase($, agentId, 'working')
474 $.clock.after(0, () => void recordTool($, agentId, e.tool, summary, result).catch(() => undefined))
475 return result
476 }).catch(($, e, next) => next(e))
477
478 on('turn.complete', async ($, e, next) => {
479 const result = await next(e)
480 if (e.agentId === undefined) {
481 await update($, turn, t => ({ ...t, isRunning: false }))
482 } else {
483 const agentId = e.agentId
484 const now = await $.clock.now()
485 const status: AntFarmStatus = e.reason === 'answer' ? 'done' : 'failed'
486 live.delete(agentId)
487 let task: string | undefined
488 let startedAt: number | undefined
489 await update($, ants, list =>
490 list.map(a => {
491 if (a.id !== agentId) return a
492 task = a.task
493 startedAt = a.startedAt
494 return { ...a, status, endedAt: now }
495 }),
496 )
497 if (task !== undefined) {
498 const detail = status === 'done' ? fmtDuration(now - (startedAt ?? now)) : e.reason
499 await update($, events, list => pushEvent(list, { at: now, kind: status, agentId, text: task ?? '', detail }))
500 }
501 }
502 // The engine's total may have moved since the last request was counted.
503 await announce($)
504 return result
505 })
506
507 // The person closing the pane by hand also ends the band's list, and stops
508 // the pane from opening again unasked this session.
509 on('ui.close', { id: PANE }, async ($, e, next) => {
510 const result = await next(e)
511 await update($, view, v => viewAfterClose(v, e.origin.kind))
512 return result
513 }).catch(($, e, next) => next(e))
514
515 // ------------------------------------------------------------ drawing
516
517 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
518 if (e.viewport?.isFullscreen !== undefined) docksPanes = e.viewport.isFullscreen
519 if (e.props.hasSurvey) return next(e)
520
521 const list = await read($, ants)
522 const v = await read($, view)
523 const { running, finished } = ordered(list)
524 const showList = v.isListInBand && list.length > 0 && !(await isPanePlaced($))
525 if (!e.props.isWorking && running.length === 0 && !showList) return next(e)
526
527 const { Box, Text, Button } = $.ui.resolve(e)
528 const now = await $.clock.now()
529 const t = await read($, turn)
530 const spend = await sessionSpend($)
531 const level = budgetLevel(spend.usd, budgetUsd)
532 const tone = level === 2 ? { color: 'error' } : level === 1 ? { color: 'warning' } : { dimColor: true }
533 const failed = finished.filter(a => a.status === 'failed').length
534 const since =
535 e.props.isWorking && t.isRunning ? t.startedAt
536 : running.length > 0 ? Math.min(...running.map(a => a.startedAt))
537 : undefined
538 const colors = modelColors(list)
539 const frame = Math.floor(now / TICK_MS)
540
541 const summary = [
542 ...(since !== undefined ? [fmtDuration(now - since)] : []),
543 `${running.length} running / ${finished.length - failed} done`,
544 ...(failed > 0 ? [`${failed} failed`] : []),
545 spendLabel(spend),
546 ].join(' · ')
547
548 const items = [...running, ...finished]
549 const room = Math.max(1, e.props.maxRows - 2)
550 const shown = showList ? items.slice(0, room) : []
551 const hidden = showList ? items.length - shown.length : 0
552 // Other mods' bands are never erased by this one, and they draw first: an
553 // overflowing ant list can then never push another mod's buttons out of view.
554 // What is beneath may be the engine's own node, which the engine refuses
555 // under a Box that sizes, hides or moves it (width, height, display,
556 // overflow, position): only our own rows carry the band's width.
557 const below = await next(e)
558
559 return (
560 <Box flexDirection="column">
561 {below}
562 <Box flexDirection="column" key="ant-farm" width={e.props.bodyColumns}>
563 <Box flexDirection="row" key="band">
564 <Box key="summary" flexDirection="row" flexGrow={1}>
565 {glyphRow($.ui.resolve(e), e.surface, miniAnt(running.length > 0 ? 'running' : 'done', frame), tone)}
566 <Text wrap="truncate-end" {...tone}>
567 {' '}{summary}
568 </Text>
569 </Box>
570 {showList ? (
571 <Button key="hide-list" label="Hide" onPress={() => update($, view, x => ({ ...x, isListInBand: false }))} />
572 ) : null}
573 </Box>
574 {shown.map(a => {
575 const p = progressOf(a, list, now)
576 const held = live.get(a.id)?.phase === 'waiting'
577 const tail = isRunning(a) ? (held ? 'waiting' : p.etaMs !== null ? fmtEta(p.etaMs) : 'running') : a.status
578 return (
579 <Box flexDirection="row" key={`row-${a.id}`}>
580 {glyphRow($.ui.resolve(e), e.surface, miniAnt(a.status, frame + phaseOf(a.id)), { color: colors.get(a.model) ?? 'subtle' })}
581 <Text wrap="truncate-end" dimColor={!isRunning(a)}>
582 {' '}{taskOf(a)} · {modelOf(a)} · {fmtDuration((a.endedAt ?? now) - a.startedAt)} · {fmtTokens(a.usage.tokens)} tok · {fmtCost(a.usage)} · {tail}
583 </Text>
584 </Box>
585 )
586 })}
587 {hidden > 0 ? <Text dimColor>+{hidden} more - /antfarm shows them all</Text> : null}
588 </Box>
589 </Box>
590 )
591 })
592
593 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
594 if (e.viewport?.isFullscreen !== undefined) docksPanes = e.viewport.isFullscreen
595 const list = await read($, ants)
596 const v = await read($, view)
597 const now = await $.clock.now()
598 const spend = await sessionSpend($)
599 const select = (id: string | undefined) =>
600 void update($, view, x => {
601 const { selectedId: _, ...rest } = x
602 return id === undefined ? rest : { ...rest, selectedId: id }
603 })
604 return drawControl({
605 ui: $.ui.resolve(e),
606 surface: e.surface,
607 columns: e.props.bodyColumns,
608 isFocused: e.props.isFocused,
609 now,
610 frame: Math.floor(now / TICK_MS),
611 list,
612 events: await read($, events),
613 view: v,
614 live,
615 colors: modelColors(list),
616 spend,
617 budgetUsd,
618 level: budgetLevel(spend.usd, budgetUsd),
619 subagents: sumUsage(list.map(a => a.usage)),
620 on: {
621 select,
622 move: by => {
623 const { running, finished } = ordered(list)
624 const ids = [...running, ...finished].map(a => a.id)
625 void update($, view, x => {
626 const next = moveSelection(ids, x.selectedId, by)
627 const { selectedId: _, ...rest } = x
628 return next === undefined ? rest : { ...rest, selectedId: next }
629 })
630 },
631 toggleFinished: () => void update($, view, x => ({ ...x, isFinishedExpanded: !x.isFinishedExpanded })),
632 toggleTimeline: () => void update($, view, x => ({ ...x, isTimelineHidden: x.isTimelineHidden !== true })),
633 growTimeline: () => void update($, view, x => ({ ...x, isTimelineExpanded: true })),
634 toggleMap: () => void update($, view, x => ({ ...x, isMapHidden: x.isMapHidden !== true })),
635 },
636 })
637 })
638
639 // $.ui.status takes text alone, so the colour of the budget alarm is drawn
640 // here: a mark beside the prompt footer's mode labels, amber past the
641 // budget, red past twice it. Under the budget the engine draws its own.
642 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
643 const spend = await sessionSpend($)
644 const level = budgetLevel(spend.usd, budgetUsd)
645 if (level === 0) return next(e)
646 const { Box, Text } = $.ui.resolve(e)
647 const engine = await next(e)
648 return (
649 <Box flexDirection="row">
650 {engine}
651 <Box key="budget-mark">
652 <Text color={level === 2 ? 'error' : 'warning'}>
653 {e.props.modes.length > 0 ? ' · ' : ''}
654 {spend.isEngine ? `Claude Code's estimate ${spend.text}` : spend.text} {level === 2 ? 'past 2x budget' : 'over budget'}
655 </Text>
656 </Box>
657 </Box>
658 )
659 })
660}
661hooks/prices.ts 106 lines1// The price table and the cost math. Pure: no `$`, so the tests drive it directly.
2
3import type { ModelUsage } from 'claude-code'
4
5import type { AntFarmUsage } from '../types'
6
7type Rate = { input: number; output: number; cacheWrite: number; cacheRead: number }
8type PriceRow = Rate & {
9 id: string
10 /** A higher rate for requests whose prompt is longer than `promptTokens`. */
11 above?: Rate & { promptTokens: number }
12}
13
14/**
15 * ESTIMATES ONLY. US dollars per million tokens: Anthropic's published
16 * first-party API list prices, as cached on 2026-10-06. Cache writes are priced
17 * at the 5-minute rate (1.25x input); the usage a request reports does not say
18 * which cache lifetime was used. Partner clouds (Bedrock, Vertex), fast mode,
19 * batch and negotiated rates differ. A model not listed here costs "?".
20 */
21export const PRICES_USD_PER_MTOK: readonly PriceRow[] = [
22 { id: 'claude-fable-5-1', input: 10, output: 50, cacheWrite: 12.5, cacheRead: 0.25 },
23 { id: 'claude-mythos-5-1', input: 10, output: 50, cacheWrite: 12.5, cacheRead: 0.25 },
24 { id: 'claude-fable-5', input: 10, output: 50, cacheWrite: 12.5, cacheRead: 1 },
25 { id: 'claude-mythos-5', input: 10, output: 50, cacheWrite: 12.5, cacheRead: 1 },
26 { id: 'claude-opus-5-5', input: 4, output: 20, cacheWrite: 5, cacheRead: 0.2 },
27 { id: 'claude-opus-5', input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 },
28 { id: 'claude-opus-4-8', input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 },
29 { id: 'claude-opus-4-7', input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 },
30 { id: 'claude-opus-4-6', input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 },
31 { id: 'claude-sonnet-5-5', input: 2, output: 10, cacheWrite: 2.5, cacheRead: 0.2 },
32 { id: 'claude-sonnet-5', input: 2, output: 10, cacheWrite: 2.5, cacheRead: 0.2 },
33 { id: 'claude-sonnet-4-6', input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 },
34 {
35 id: 'claude-haiku-5-5', input: 0.1, output: 0.5, cacheWrite: 0.125, cacheRead: 0.01,
36 above: { promptTokens: 100_000, input: 0.5, output: 2.5, cacheWrite: 0.625, cacheRead: 0.05 },
37 },
38 { id: 'claude-haiku-4-5', input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1 },
39]
40
41// Longest id first, and an id never matches a longer version of itself:
42// `claude-opus-5` must not price `claude-opus-5-5` or a future `claude-opus-5-6`,
43// while dated (`-20251101`), regional (`us.anthropic.`), `-v1:0` and `[1m]`
44// spellings of a listed id still match.
45const MATCHERS = [...PRICES_USD_PER_MTOK]
46 .sort((a, b) => b.id.length - a.id.length)
47 .map(row => ({ row, re: new RegExp(`(?:^|[^a-z0-9])${row.id}(?!\\d)(?!-\\d{1,2}(?!\\d))`) }))
48
49/** The price row for a model id as the engine or the API spells it, or undefined. */
50export function priceFor(model: string): PriceRow | undefined {
51 const id = model.toLowerCase()
52 return MATCHERS.find(m => m.re.test(id))?.row
53}
54
55export function tokensOf(u: ModelUsage): number {
56 return u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
57}
58
59/** Estimated dollars for one request's usage, or undefined for a model not in the table. */
60export function costOf(model: string, u: ModelUsage): number | undefined {
61 const row = priceFor(model)
62 if (row === undefined) return undefined
63 const prompt = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
64 const rate: Rate = row.above !== undefined && prompt > row.above.promptTokens ? row.above : row
65 return (
66 u.input_tokens * rate.input +
67 u.output_tokens * rate.output +
68 u.cache_creation_input_tokens * rate.cacheWrite +
69 u.cache_read_input_tokens * rate.cacheRead
70 ) / 1_000_000
71}
72
73export const NO_USAGE: AntFarmUsage = { requests: 0, tokens: 0, costUsd: 0, unpricedTokens: 0 }
74
75/** One more request on the tally; `usage` null when the response carried none. */
76export function addRequest(acc: AntFarmUsage, model: string, usage: ModelUsage | null): AntFarmUsage {
77 if (usage === null) return { ...acc, requests: acc.requests + 1 }
78 const tokens = tokensOf(usage)
79 const cost = costOf(model, usage)
80 return {
81 requests: acc.requests + 1,
82 tokens: acc.tokens + tokens,
83 costUsd: acc.costUsd + (cost ?? 0),
84 unpricedTokens: acc.unpricedTokens + (cost === undefined ? tokens : 0),
85 }
86}
87
88export function sumUsage(list: readonly AntFarmUsage[]): AntFarmUsage {
89 return list.reduce(
90 (a, u) => ({
91 requests: a.requests + u.requests,
92 tokens: a.tokens + u.tokens,
93 costUsd: a.costUsd + u.costUsd,
94 unpricedTokens: a.unpricedTokens + u.unpricedTokens,
95 }),
96 NO_USAGE,
97 )
98}
99
100/** The budget level: 0 under, 1 at or past the budget, 2 at or past twice it. 0 when off. */
101export function budgetLevel(spendUsd: number, budgetUsd: number): 0 | 1 | 2 {
102 if (!(budgetUsd > 0)) return 0
103 if (spendUsd >= budgetUsd * 2) return 2
104 return spendUsd >= budgetUsd ? 1 : 0
105}
106hooks/cells.tsx 52 lines1// Glyph art (ants, progress bars) on the desktop app. Its Text is proportional, and a
2// Text that holds block characters is drawn in the code font, so words beside the art
3// change font and overflow, and rows of art drift against each other.
4// There the art is drawn one code-font cell per character, apart from the words.
5// On every surface the row never shrinks or wraps: a row too tight for it cuts it.
6
7import type { BoxProps, ElementConstructor, RenderElement, TextProps } from 'claude-code'
8
9type Elements = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps> }
10
11/** One row of glyph art: a plain Text on the terminal, a row of one-cell Boxes on the desktop. */
12export function glyphRow(elements: Elements, surface: string, text: string, paint: TextProps = {}): RenderElement {
13 const { Box, Text } = elements
14 if (surface !== 'desktop') {
15 return (
16 <Box flexShrink={0}>
17 <Text {...paint} wrap="truncate-end">{text}</Text>
18 </Box>
19 )
20 }
21 const cells: RenderElement[] = []
22 for (const run of text.match(/ +|[^ ]/gu) ?? []) {
23 cells.push(run.startsWith(' ') ? <Box width={run.length} /> : <Box width={1}><Text {...paint}>{run}</Text></Box>)
24 }
25 return (
26 <Box flexDirection="row" flexWrap="nowrap" flexShrink={0}>
27 {cells}
28 </Box>
29 )
30}
31
32/** A cell of glyph art with its own paint. */
33export type PaintedCell = { glyph: string; paint: TextProps }
34
35/** One row of cells painted one by one: inline Texts on the terminal, one-cell Boxes on the desktop. */
36export function paintedRow(elements: Elements, surface: string, cells: readonly PaintedCell[]): RenderElement {
37 const { Box, Text } = elements
38 if (surface !== 'desktop') {
39 return (
40 <Box flexDirection="row" flexShrink={0}>
41 {cells.map(cell => <Text {...cell.paint}>{cell.glyph}</Text>)}
42 </Box>
43 )
44 }
45 return (
46 <Box flexDirection="row" flexWrap="nowrap" flexShrink={0}>
47 {cells.map(cell => <Box width={1}><Text {...cell.paint}>{cell.glyph}</Text></Box>)}
48 </Box>
49 )
50}
51
52hooks/control.tsx 532 lines1// The mission control pane: the overview header, the command grid of cards,
2// the inspector of the selected subagent, the finished list and the activity
3// timeline. Every value drawn comes from the farm's state and what the loop
4// events said; nothing is guessed. Words are drawn in Text, glyph art in
5// glyph rows (one code-font cell per character on the desktop). Every row is
6// sized in cells so nothing wraps: a part too long for its room is cut at
7// its end, never folded onto a line of its own.
8
9import type { BoxProps, ButtonProps, ElementConstructor, RasterProps, RenderElement, SvgProps, TextProps } from 'claude-code'
10
11import type { AntFarmAnt, AntFarmEvent, AntFarmUsage, AntFarmView } from '../types'
12import { boltSprite } from './bolt-draw'
13import type { BoltSmallPose } from './bolt'
14import { glyphRow, paintedRow } from './cells'
15import { mapNodes, missionMap } from './map'
16import {
17 activityText, COMPACT_BELOW, eventLook, FRESH_MS, gridOf, heatGlyph, heatGrid, hotkeyOf, met, ordered, pulse, rowsOf, SETTLE_MS,
18 sparkline, spinner, statusLook, TIMELINE_ROWS, TIMELINE_ROWS_EXPANDED,
19} from './mission'
20import type { LivePhase } from './mission'
21import {
22 ANT_WIDTH, antRows, bar, clean, fmtCost, fmtDuration, fmtEta, fmtTokens, fmtUsd, miniAnt, MINI_WIDTH, phaseOf, progressOf, shortModel,
23} from './show'
24import type { Spend } from './show'
25
26export type Elements = {
27 Box: ElementConstructor<BoxProps>
28 Text: ElementConstructor<TextProps>
29 Button: ElementConstructor<ButtonProps>
30 Raster?: ElementConstructor<RasterProps>
31 Svg?: ElementConstructor<SvgProps>
32}
33
34/** Everything the pane is drawn from, read by the hook and handed here. */
35export type ControlInput = {
36 ui: Elements
37 surface: string
38 /** The body's cells across, inside the frame. */
39 columns: number
40 /** The pane holds the keyboard: hotkeys work and a key hint shows. */
41 isFocused: boolean
42 now: number
43 /** The animation frame: one every TICK_MS. */
44 frame: number
45 list: readonly AntFarmAnt[]
46 events: readonly AntFarmEvent[]
47 view: AntFarmView
48 live: ReadonlyMap<string, LivePhase>
49 /** One colour per model. */
50 colors: ReadonlyMap<string, string>
51 spend: Spend
52 budgetUsd: number
53 level: 0 | 1 | 2
54 /** The subagents' own tally (price table). */
55 subagents: AntFarmUsage
56 on: {
57 select: (id: string | undefined) => void
58 move: (by: 1 | -1) => void
59 toggleFinished: () => void
60 toggleTimeline: () => void
61 growTimeline: () => void
62 toggleMap: () => void
63 }
64}
65
66/** Narrower than this, Bolt steps out; from BOLT_SMALL_COLUMNS the small sprite keeps the header. */
67export const BOLT_MIN_COLUMNS = 40
68export const BOLT_SMALL_COLUMNS = 56
69/** The finished subagents the pane lists one by one; the rest are counted. */
70export const MAX_FINISHED_SHOWN = 50
71/** A card this wide inside its border gives the ant three cells of walk and the sparkline twelve. */
72export const WIDE_CARD = 44
73/** A card this wide inside its border also counts its tools, edits and errors on the bottom row. */
74export const COUNTS_CARD = 50
75const SPARK_CELLS = 12
76const SPARK_MIN = 6
77const BAR_CELLS = 12
78const BUDGET_BAR_CELLS = 20
79/** Cells the `open` button and its gap take at the end of a row (`9: open`). */
80const OPEN_CELLS = 8
81/** A finished line names its model from this pane width; narrower, the ant's colour says it. */
82/** From this many columns the header holds the heat strip beside the hero column. */
83const HEAT_MIN_COLUMNS = 84
84export const LINE_MODEL_COLUMNS = 100
85
86const isRunning = (a: AntFarmAnt) => a.status === 'running'
87const taskOf = (a: AntFarmAnt) => clean(a.task)
88const modelOf = (a: AntFarmAnt) => clean(shortModel(a.model) || a.type)
89const numberOf = (a: AntFarmAnt | undefined) => (a?.n !== undefined ? `#${a.n}` : '·')
90
91/** The state the header speaks for: its words, colour and Bolt's pose. */
92export function headline(list: readonly AntFarmAnt[], live: ReadonlyMap<string, LivePhase>): { text: string; color: string; pose: BoltSmallPose } {
93 const { running, finished } = ordered(list)
94 const waiting = running.filter(a => live.get(a.id)?.phase === 'waiting').length
95 const failed = finished.filter(a => a.status === 'failed').length
96 if (list.length === 0) return { text: 'Standing by', color: 'subtle', pose: 'sleep' }
97 if (waiting > 0) return { text: `Waiting · ${waiting} held`, color: 'warning', pose: 'think' }
98 if (running.length > 0) return { text: `Working · ${running.length} running`, color: 'success', pose: 'think' }
99 if (failed > 0) return { text: `${failed} failed · ${finished.length - failed} done`, color: 'error', pose: 'sweat' }
100 return { text: `All done · ${finished.length} ${finished.length === 1 ? 'agent' : 'agents'}`, color: 'success', pose: 'cheer' }
101}
102
103/**
104 * The status line of a card or the inspector: the mark, the word and what
105 * follows it (`◐ WORKING · Bash npm test · 4s`, `◇ WAITING · held by the
106 * engine`, `✓ DONE · 1m 20s`). The mark turns while the subagent works.
107 */
108export function stateLine(a: AntFarmAnt, live: LivePhase | undefined, now: number, frame: number): { text: string; color: string } {
109 const look = statusLook(a.status, live?.phase)
110 if (!isRunning(a)) return { text: `${look.glyph} ${look.word} · ${fmtDuration((a.endedAt ?? now) - a.startedAt)}`, color: look.color }
111 const glyph = look.word === 'WAITING' ? look.glyph : spinner(frame)
112 const rest = clean(activityText(live, now))
113 return { text: `${glyph} ${look.word}${rest !== '' ? ` · ${rest}` : ''}`, color: look.color }
114}
115
116export function drawControl(c: ControlInput): RenderElement {
117 const { Box, Text, Button } = c.ui
118 const { running, finished } = ordered(c.list)
119 const failed = finished.filter(a => a.status === 'failed').length
120 const waiting = running.filter(a => c.live.get(a.id)?.phase === 'waiting').length
121 const compact = c.columns < COMPACT_BELOW
122 // The desktop draws words in a proportional font and clips a row at the pane's
123 // edge instead of cutting it with an ellipsis, so a narrow desktop pane wraps
124 // its lines; the terminal, where cells are exact, cuts them at the end.
125 const cut: TextProps['wrap'] = c.surface === 'desktop' && compact ? 'wrap' : 'truncate-end'
126 // Every Button is plain: the terminal draws `1: open` with the key hint in the accent
127 // colour, and a desktop draws its native button either way.
128 const plain = { plain: true as const }
129 // The desktop app keeps the press handle a Button was mounted with, and the engine hands
130 // out new handles on every drawing: a Button that never remounts there is dead after the
131 // first redraw. A wrapper keyed by the frame remounts it on every drawing.
132 const remount = (name: string) => (c.surface === 'desktop' ? { key: `press-${name}-${c.frame}` } : {})
133 const firstStart = c.list.length > 0 ? Math.min(...c.list.map(a => a.startedAt)) : c.now
134 const lastEnd = running.length > 0 ? c.now : Math.max(firstStart, ...finished.map(a => a.endedAt ?? a.startedAt))
135 const selected = c.view.selectedId !== undefined ? c.list.find(a => a.id === c.view.selectedId) : undefined
136 const { cols, cardWidth } = gridOf(c.columns)
137 const byId = new Map(c.list.map(a => [a.id, a] as const))
138
139 const antPaint = (a: AntFarmAnt): TextProps => {
140 if (a.status === 'failed') return { color: 'error' }
141 if (isRunning(a)) return { color: c.colors.get(a.model) ?? 'subtle' }
142 if (a.endedAt !== undefined && c.now - a.endedAt < SETTLE_MS) return { color: 'success' }
143 return { color: 'subtle', dimColor: true }
144 }
145
146 // The button that opens a subagent in the inspector, keyed by its mission number.
147 const openButton = (a: AntFarmAnt, isSelected: boolean) => {
148 const hotkey = hotkeyOf(a.n)
149 return (
150 <Button
151 key={`inspect-${a.id}`}
152 {...plain}
153 {...(hotkey !== undefined ? { hotkey } : {})}
154 dimColor={!isRunning(a) && !isSelected}
155 label={isSelected ? 'close' : 'open'}
156 onPress={() => c.on.select(isSelected ? undefined : a.id)}
157 />
158 )
159 }
160
161 // ---------------------------------------------------------------- a card
162
163 const card = (a: AntFarmAnt): RenderElement => {
164 const live = c.live.get(a.id)
165 const isSelected = selected?.id === a.id
166 const justFinished = !isRunning(a) && a.endedAt !== undefined && c.now - a.endedAt < SETTLE_MS
167 const border = isSelected ? 'suggestion' : a.status === 'failed' ? 'error' : justFinished ? 'success' : isRunning(a) ? c.colors.get(a.model) ?? 'subtle' : 'subtle'
168 const rested = !isRunning(a) && !justFinished && !isSelected
169 const tone: TextProps = a.status === 'failed' ? { color: 'error' } : { dimColor: !isRunning(a) }
170 const p = progressOf(a, c.list, c.now)
171 // Held on the person, a subagent makes no progress: no time left to show.
172 const etaMs = live?.phase === 'waiting' ? null : p.etaMs
173 // The card's rows in cells: the art lane, one gap, the words beside it.
174 const inner = cardWidth - 4
175 const lane = ANT_WIDTH + (inner >= WIDE_CARD ? 3 : 2)
176 const meta = Math.max(1, inner - lane - 1)
177 const spark = Math.min(SPARK_CELLS, Math.max(SPARK_MIN, meta - 16))
178 const rows = antRows(a.status, c.frame + phaseOf(a.id), lane)
179 const state = stateLine(a, live, c.now, c.frame)
180 // On hover the card grows by a few rows in its own style: the task in full where the
181 // title had to cut it, then the subagent's last three rows. Drawn hidden in the flow
182 // inside the card, so the card's own hover reveals it; no offsets, no fill, nothing
183 // drawn over anything, and only this card grows.
184 const titleCut = [...`${numberOf(a)} ${taskOf(a)}`].length > inner - OPEN_CELLS - 1
185 const recent = c.events.filter(ev => ev.agentId === a.id).slice(-3).reverse()
186 const revealRow = (ev: AntFarmEvent, i: number): RenderElement => {
187 const look = eventLook(ev.kind)
188 return (
189 <Box flexDirection="row" key={`recent-${i}`}>
190 <Box width={8} flexShrink={0}><Text dimColor>{met(ev.at - firstStart)}</Text></Box>
191 <Box width={2} flexShrink={0}><Text {...(look.color !== undefined ? { color: look.color } : { dimColor: true })}>{look.glyph}</Text></Box>
192 <Box flexGrow={1} flexShrink={1} minWidth={0}>
193 <Text dimColor wrap="truncate-end">{clean(ev.text)}{ev.detail !== undefined ? ` · ${clean(ev.detail)}` : ''}</Text>
194 </Box>
195 </Box>
196 )
197 }
198 const reveal = (
199 <Box display="none" hover={{ display: 'flex' }} flexDirection="column" marginTop={1}>
200 {titleCut ? <Text dimColor wrap="wrap">{taskOf(a)}</Text> : null}
201 {recent.length > 0 ? recent.map(revealRow) : <Text dimColor>No activity recorded yet</Text>}
202 </Box>
203 )
204 const money = [
205 fmtCost(a.usage),
206 ...(etaMs !== null ? [fmtEta(etaMs)] : []),
207 ...(inner >= COUNTS_CARD && (a.tools ?? 0) > 0 ? [`${a.tools} ${a.tools === 1 ? 'tool' : 'tools'}`] : []),
208 ...(inner >= COUNTS_CARD && (a.edits ?? 0) > 0 ? [`${a.edits} ${a.edits === 1 ? 'edit' : 'edits'}`] : []),
209 ...(inner >= COUNTS_CARD && (a.errors ?? 0) > 0 ? [`${a.errors} ${a.errors === 1 ? 'error' : 'errors'}`] : []),
210 ].join(' · ')
211 return (
212 <Box
213 key={`ant-${a.id}`}
214 flexDirection="column"
215 width={cardWidth}
216 flexShrink={0}
217 minWidth={0}
218 borderStyle="round"
219 borderColor={border}
220 borderDimColor={rested}
221 paddingX={1}
222 hover={{ borderColor: 'suggestion' }}
223 >
224 <Box flexDirection="row">
225 <Box flexGrow={1} flexShrink={1} minWidth={0}>
226 <Text bold={isRunning(a) || isSelected} dimColor={rested} wrap="truncate-end">{numberOf(a)} {taskOf(a)}</Text>
227 </Box>
228 <Box flexShrink={0} marginLeft={1}>{openButton(a, isSelected)}</Box>
229 </Box>
230 <Box flexDirection="row">
231 <Box key={`art-${a.id}`} flexDirection="column" width={lane} flexShrink={0}>
232 {rows.map(row => glyphRow(c.ui, c.surface, row, antPaint(a)))}
233 </Box>
234 <Box flexDirection="column" width={meta} flexShrink={0} minWidth={0} marginLeft={1}>
235 <Text dimColor wrap="truncate-end">{modelOf(a)} · {fmtDuration((a.endedAt ?? c.now) - a.startedAt)}</Text>
236 <Text bold wrap="truncate-end" color={state.color} dimColor={rested}>{state.text}</Text>
237 <Box flexDirection="row">
238 {glyphRow(c.ui, c.surface, sparkline(a.history ?? [], spark), antPaint(a))}
239 <Box marginLeft={1} flexShrink={1} minWidth={0}>
240 <Text dimColor wrap="truncate-end">{a.usage.requests} req · {fmtTokens(a.usage.tokens)} tok</Text>
241 </Box>
242 </Box>
243 </Box>
244 </Box>
245 <Box flexDirection="row">
246 {glyphRow(c.ui, c.surface, bar(p.fraction, BAR_CELLS), tone)}
247 <Box marginLeft={1} flexGrow={1} flexShrink={1} minWidth={0}>
248 <Text wrap="truncate-end" {...tone}>{money}</Text>
249 </Box>
250 </Box>
251 {reveal}
252 </Box>
253 )
254 }
255
256 // ---------------------------------------------------------------- a line
257
258 const line = (a: AntFarmAnt): RenderElement => {
259 const look = statusLook(a.status, c.live.get(a.id)?.phase)
260 const isSelected = selected?.id === a.id
261 const paint = antPaint(a)
262 // The model name goes first on a narrow pane (the ant carries its colour); the rest stays whole
263 // wherever the pane has room for the mini ant, a few cells of task, the numbers, the mark and the button.
264 const numbers = `${c.columns >= LINE_MODEL_COLUMNS ? `${modelOf(a)} · ` : ''}${fmtDuration((a.endedAt ?? c.now) - a.startedAt)} · ${fmtTokens(a.usage.tokens)} tok · ${fmtCost(a.usage)}`
265 const numbersFit = c.columns >= MINI_WIDTH + 1 + 8 + 3 + [...numbers].length + 3 + OPEN_CELLS
266 if (compact) {
267 return (
268 <Box flexDirection="row" key={`line-${a.id}`}>
269 {glyphRow(c.ui, c.surface, miniAnt(a.status, isRunning(a) ? c.frame + phaseOf(a.id) : 0), paint)}
270 <Box marginLeft={1} flexGrow={1} flexShrink={1} minWidth={0}>
271 <Text dimColor={!isRunning(a)} wrap={cut}>{numberOf(a)} {a.status} · {taskOf(a)} · {numbers}</Text>
272 </Box>
273 </Box>
274 )
275 }
276 // The task gives way first (it is the longer part); the numbers give way only when
277 // the pane is too narrow for them, so no row is ever wider than the pane.
278 return (
279 <Box flexDirection="row" key={`line-${a.id}`}>
280 {glyphRow(c.ui, c.surface, miniAnt(a.status, isRunning(a) ? c.frame + phaseOf(a.id) : 0), paint)}
281 <Box marginLeft={1} flexGrow={1} flexShrink={1} minWidth={0}>
282 <Text dimColor={!isRunning(a)} wrap="truncate-end">{numberOf(a)} {a.status} · {taskOf(a)}</Text>
283 </Box>
284 <Box flexShrink={numbersFit ? 0 : 1} minWidth={0}>
285 <Text dimColor={!isRunning(a)} wrap="truncate-end"> · {numbers}</Text>
286 </Box>
287 <Box flexShrink={0} marginLeft={1}><Text color={look.color}>{look.glyph}</Text></Box>
288 <Box flexShrink={0} marginLeft={1}>{openButton(a, isSelected)}</Box>
289 </Box>
290 )
291 }
292
293 // ---------------------------------------------------------------- the header
294
295 const head = headline(c.list, c.live)
296 const clock = running.length > 0 ? met(c.now - firstStart) : c.list.length > 0 ? `${fmtDuration(lastEnd - firstStart)} wall` : ''
297 const source = c.spend.isEngine ? ' (Claude Code)' : ' (price table)'
298 const budgetLine =
299 c.budgetUsd > 0
300 ? `Session ${c.spend.text} of ${fmtUsd(c.budgetUsd)} budget${source}${c.level === 2 ? ' - past 2x' : c.level === 1 ? ' - over' : ''}`
301 : `Session ${c.spend.text}${source}`
302 const budgetTone: TextProps = c.level === 2 ? { color: 'error' } : c.level === 1 ? { color: 'warning' } : { dimColor: true }
303 const budgetFraction = c.budgetUsd > 0 ? Math.min(1, c.spend.usd / c.budgetUsd) : null
304 const boltSize = compact || c.columns < BOLT_MIN_COLUMNS ? undefined : c.columns >= BOLT_SMALL_COLUMNS ? 'small' : 'tiny'
305
306 const headColumn = (
307 <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
308 <Box key="hero" flexDirection="row">
309 <Box flexGrow={1} flexShrink={1} minWidth={0}>
310 <Text bold color={head.color} wrap={cut}>{head.text}</Text>
311 </Box>
312 {clock !== '' ? <Box flexShrink={0} marginLeft={1}><Text dimColor>{clock}</Text></Box> : null}
313 </Box>
314 <Box key="counts" flexDirection="row" {...(compact ? { flexWrap: 'wrap' as const } : {})}>
315 <Text bold>{running.length - waiting}</Text><Text dimColor> running · </Text>
316 <Text bold>{finished.length - failed}</Text><Text dimColor> done · </Text>
317 <Text bold {...(failed > 0 ? { color: 'error' } : {})}>{failed}</Text><Text dimColor> failed</Text>
318 {waiting > 0 ? <Text color="warning"> · {waiting} waiting</Text> : null}
319 </Box>
320 <Box key="budget" minWidth={0}>
321 <Text wrap={cut} {...budgetTone}>{budgetLine}</Text>
322 </Box>
323 <Box key="totals" minWidth={0}>
324 <Text dimColor wrap={cut}>
325 Subagents {fmtCost(c.subagents)} (price table) · {fmtTokens(c.subagents.tokens)} tokens · {fmtDuration(lastEnd - firstStart)} wall
326 </Text>
327 </Box>
328 {budgetFraction !== null ? (
329 <Box key="budget-bar" flexDirection="row">
330 {glyphRow(c.ui, c.surface, bar(budgetFraction, BUDGET_BAR_CELLS), budgetTone)}
331 <Box marginLeft={1} minWidth={0}><Text wrap={cut} {...budgetTone}>{Math.round(budgetFraction * 100)}%</Text></Box>
332 </Box>
333 ) : (
334 <Box key="no-budget" minWidth={0}>
335 <Text dimColor wrap={cut}>No budget set - add one in /config</Text>
336 </Box>
337 )}
338 </Box>
339 )
340 // The heat strip: one column per subagent (running ones first), one row per ten seconds of
341 // the last minute (oldest on top, the windows anchored to the clock), each cell shaded by how
342 // many rows the subagent put on the timeline in that window, in its own colour; the mission
343 // numbers underneath. On a pane wide enough to hold it beside the hero column.
344 const heat = !compact && c.columns >= HEAT_MIN_COLUMNS && c.list.length > 0 ? heatGrid(c.list, c.events, c.now) : undefined
345 const heatBlock =
346 heat !== undefined && heat.ids.length > 0 ? (
347 <Box key="heat" flexDirection="column" flexShrink={0} marginLeft={1}>
348 <Text dimColor>last 60 s</Text>
349 {heat.grid.map(row =>
350 paintedRow(c.ui, c.surface, row.map((count, i) => ({ glyph: heatGlyph(count), paint: count === 0 ? { dimColor: true } : antPaint(byId.get(heat.ids[i]!)!) }))),
351 )}
352 {paintedRow(c.ui, c.surface, heat.ids.map(id => ({ glyph: hotkeyOf(byId.get(id)?.n) ?? '·', paint: { dimColor: true } })))}
353 </Box>
354 ) : null
355 const header =
356 boltSize !== undefined && c.list.length > 0 ? (
357 <Box key="head" flexDirection="row" gap={1} marginBottom={1}>
358 {boltSize === 'small' ? boltSprite(c.ui, c.surface, { size: 'small', pose: head.pose }) : boltSprite(c.ui, c.surface, { size: 'tiny', pose: 'idle' })}
359 {headColumn}
360 {heatBlock}
361 </Box>
362 ) : (
363 <Box key="head" flexDirection="row" marginBottom={1}>{headColumn}{heatBlock}</Box>
364 )
365
366 // ---------------------------------------------------------------- the inspector
367
368 const eventRow = (ev: AntFarmEvent, i: number, withTime: boolean): RenderElement => {
369 const look = eventLook(ev.kind)
370 const fresh = c.now - ev.at < FRESH_MS && i === 0
371 const who = numberOf(ev.agentId !== undefined ? byId.get(ev.agentId) : undefined)
372 const words = `${clean(ev.text)}${ev.detail !== undefined ? ` · ${clean(ev.detail)}` : ''}`
373 return (
374 <Box flexDirection="row" key={`event-${i}`}>
375 {withTime ? <Box width={9} flexShrink={0}><Text dimColor>{met(ev.at - firstStart)}</Text></Box> : null}
376 <Box width={4} flexShrink={0}><Text dimColor>{who}</Text></Box>
377 <Box width={2} flexShrink={0}><Text {...(look.color !== undefined ? { color: look.color } : {})}>{look.glyph}</Text></Box>
378 <Box width={8} flexShrink={0}><Text {...(look.color !== undefined ? { color: look.color } : { dimColor: true })}>{look.word}</Text></Box>
379 <Box flexGrow={1} flexShrink={1} minWidth={0}>
380 <Text wrap={cut} bold={fresh} dimColor={ev.kind === 'tool' && !fresh}>{words}</Text>
381 </Box>
382 </Box>
383 )
384 }
385
386 const inspector = (a: AntFarmAnt): RenderElement => {
387 const live = c.live.get(a.id)
388 const look = statusLook(a.status, live?.phase)
389 const p = progressOf(a, c.list, c.now)
390 const etaMs = live?.phase === 'waiting' ? null : p.etaMs
391 const elapsed = fmtDuration((a.endedAt ?? c.now) - a.startedAt)
392 const parent = a.parentId !== undefined ? byId.get(a.parentId) : undefined
393 const from = a.parentId === undefined ? 'from the main loop' : `from ${parent !== undefined ? `${numberOf(parent)} ${taskOf(parent)}` : 'another agent'}`
394 const state = stateLine(a, live, c.now, c.frame)
395 const own = c.events.filter(ev => ev.agentId === a.id).slice(-6).reverse()
396 const tally = [
397 `${a.usage.requests} ${a.usage.requests === 1 ? 'request' : 'requests'}`,
398 `${fmtTokens(a.usage.tokens)} tokens`,
399 fmtCost(a.usage),
400 `${a.tools ?? 0} tools`,
401 `${a.edits ?? 0} edits`,
402 `${a.errors ?? 0} errors`,
403 ...(etaMs !== null ? [fmtEta(etaMs)] : []),
404 ].join(' · ')
405 return (
406 <Box key="inspector" flexDirection="column" borderStyle="round" borderColor="suggestion" paddingX={1} marginBottom={1} minWidth={0}>
407 <Box flexDirection="row">
408 <Box flexGrow={1} flexShrink={1} minWidth={0}>
409 <Text bold wrap={cut}>{numberOf(a)} {taskOf(a)}</Text>
410 </Box>
411 <Box flexShrink={0} marginLeft={1}>
412 <Text bold color={look.color}>{look.glyph} {look.word}</Text>
413 </Box>
414 </Box>
415 <Text dimColor wrap={cut}>{clean(a.type)} · {modelOf(a)} · started {met(a.startedAt - firstStart)} · {elapsed} · {from}</Text>
416 <Text bold wrap={cut} color={state.color}>{state.text}</Text>
417 <Text dimColor wrap={cut}>{tally}</Text>
418 <Box flexDirection="row">
419 {glyphRow(c.ui, c.surface, sparkline(a.history ?? [], Math.min(24, Math.max(SPARK_CELLS, c.columns - 24))), antPaint(a))}
420 <Box marginLeft={1} minWidth={0}><Text dimColor wrap={cut}>tokens per request</Text></Box>
421 </Box>
422 {own.length > 0 ? own.map((ev, i) => eventRow(ev, i + 1, !compact)) : <Text dimColor>No activity recorded yet</Text>}
423 <Box flexDirection="row" gap={2} marginTop={1}>
424 <Box {...remount('prev')}><Button key="inspector-prev" {...plain} hotkey="p" label="previous" onPress={() => c.on.move(-1)} /></Box>
425 <Box {...remount('next')}><Button key="inspector-next" {...plain} hotkey="n" label="next" onPress={() => c.on.move(1)} /></Box>
426 <Box {...remount('close')}><Button key="inspector-close" {...plain} hotkey="x" label="close" onPress={() => c.on.select(undefined)} /></Box>
427 <Box flexGrow={1} />
428 <Box flexShrink={1} minWidth={0}><Text dimColor wrap={cut}>id {clean(a.id)}</Text></Box>
429 </Box>
430 </Box>
431 )
432 }
433
434 // ---------------------------------------------------------------- the timeline
435
436 const timeline = (): RenderElement | null => {
437 if (c.list.length === 0 && c.events.length === 0) return null
438 const hidden = c.view.isTimelineHidden === true
439 const room = c.view.isTimelineExpanded === true ? TIMELINE_ROWS_EXPANDED : compact ? 6 : TIMELINE_ROWS
440 const newest = [...c.events].reverse()
441 const shown = hidden ? [] : newest.slice(0, room)
442 const left = newest.length - shown.length
443 return (
444 <Box key="timeline" flexDirection="column" marginTop={1}>
445 <Box key="timeline-head" flexDirection="row">
446 <Text bold>Activity</Text>
447 <Text {...pulse(c.frame)} color={running.length > 0 ? 'success' : 'subtle'}> ●</Text>
448 <Text dimColor> {c.events.length} {c.events.length === 1 ? 'row' : 'rows'}</Text>
449 <Box flexGrow={1} />
450 {!hidden && left > 0 && c.view.isTimelineExpanded !== true ? (
451 <Box marginRight={2} {...remount('more')}><Button key="more-events" {...plain} label={`+${left} more`} onPress={() => c.on.growTimeline()} /></Box>
452 ) : null}
453 <Box {...remount('timeline')}><Button key="toggle-timeline" {...plain} hotkey="t" label={hidden ? 'show' : 'hide'} onPress={() => c.on.toggleTimeline()} /></Box>
454 </Box>
455 {shown.map((ev, i) => eventRow(ev, i, !compact))}
456 {!hidden && shown.length === 0 ? <Text dimColor>Nothing yet. Tool calls, edits, handoffs and endings land here.</Text> : null}
457 </Box>
458 )
459 }
460
461 // ---------------------------------------------------------------- assembly
462
463 const runningBlock = compact ? running.map(line) : rowsOf(running, cols).map(row => (
464 <Box flexDirection="row" gap={1} alignItems="flex-start">{row.map(card)}</Box>
465 ))
466 const finishedShown = finished.slice(0, MAX_FINISHED_SHOWN)
467 const finishedBlock =
468 c.view.isFinishedExpanded && !compact ? rowsOf(finishedShown, cols).map(row => <Box flexDirection="row" gap={1} alignItems="flex-start">{row.map(card)}</Box>) : finishedShown.map(line)
469
470 // The mission map, on the surfaces that draw vectors: the main loop and every subagent as
471 // nodes, handoffs as edges, a still picture (the desktop re-sets an interactive frame on
472 // every render pass of a hook that ticks, so no pulse and no tooltips). Its source changes
473 // only when the mission's shape does.
474 const Svg = c.ui.Svg
475 const mapBlock = (() => {
476 if (Svg === undefined || c.surface === 'terminal' || compact || c.list.length === 0) return null
477 const hidden = c.view.isMapHidden === true
478 const shape = mapNodes(c.list, clean)
479 const map = hidden ? undefined : missionMap(shape.nodes, shape.hidden)
480 return (
481 <Box key="map" flexDirection="column" marginBottom={1}>
482 <Box key="map-head" flexDirection="row">
483 <Text bold>Mission map</Text>
484 <Box flexGrow={1} />
485 <Box {...remount('map')}><Button key="toggle-map" {...plain} hotkey="m" label={hidden ? 'show' : 'hide'} onPress={() => c.on.toggleMap()} /></Box>
486 </Box>
487 {map !== undefined ? <Svg key="mission-map" source={map.source} alt={map.alt} width={Math.min(map.width, c.columns * 7)} /> : null}
488 </Box>
489 )
490 })()
491 return (
492 <Box flexDirection="column" width={c.columns}>
493 {header}
494 {c.list.length === 0 ? (
495 <Box key="empty" flexDirection="row" gap={2} alignItems="center">
496 {boltSize !== undefined ? boltSprite(c.ui, c.surface, { size: 'small', pose: 'sleep' }) : null}
497 <Text dimColor>No subagents yet this session. Each one gets an ant here when it starts.</Text>
498 </Box>
499 ) : null}
500 {mapBlock}
501 {selected !== undefined ? inspector(selected) : null}
502 {runningBlock}
503 {finished.length > 0 ? (
504 <Box flexDirection="row" key="finished-head" marginTop={running.length > 0 ? 1 : 0}>
505 <Text dimColor>Finished · {finished.length} </Text>
506 <Box {...remount('finished')}>
507 <Button
508 key="toggle-finished"
509 {...plain}
510 hotkey="f"
511 label={c.view.isFinishedExpanded ? 'collapse' : 'expand'}
512 onPress={() => c.on.toggleFinished()}
513 />
514 </Box>
515 </Box>
516 ) : null}
517 {finishedBlock}
518 {finished.length > MAX_FINISHED_SHOWN ? (
519 <Box key="more-finished">
520 <Text dimColor>+{finished.length - MAX_FINISHED_SHOWN} more finished</Text>
521 </Box>
522 ) : null}
523 {timeline()}
524 {c.isFocused ? (
525 <Box key="keys" marginTop={1} minWidth={0}>
526 <Text dimColor wrap={cut}>Keys · 1-9 open · n next · p previous · x close · f finished · t activity{c.surface !== 'terminal' ? ' · m map' : ''}</Text>
527 </Box>
528 ) : null}
529 </Box>
530 )
531}
532hooks/redact.ts 63 lines1// Secret redaction for what the pane keeps about a subagent's tool calls: a
2// command's first line, a path, a URL, the first words of an error. The pane
3// stays open through screen shares and recordings, so anything shaped like a
4// credential is replaced before it is stored. The same rules as crossroads
5// (mods install on their own, so they cannot share code); keep the two alike.
6
7// ---------------------------------------------------------------- secret redaction
8
9export const REDACTED = '[REDACTED]'
10
11// Token shapes with a fixed, recognisable prefix. Deliberately no "long hex"
12// or "long base64" rule: git SHAs, UUIDs and hashes would all match it.
13const TOKEN_SHAPES: readonly RegExp[] = [
14 /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----|$)/g,
15 /\b(?:AKIA|ASIA|ABIA|ACCA|AGPA|AIDA|AIPA|ANPA|ANVA|AROA|APKA)[A-Z0-9]{16}\b/g,
16 /\bgh[pousr]_[A-Za-z0-9]{30,255}\b/g,
17 /\bgithub_pat_[A-Za-z0-9_]{20,255}\b/g,
18 /\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}/g,
19 /\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{10,}\b/g,
20 /\bwhsec_[A-Za-z0-9]{16,}\b/g,
21 /\bxox[abposr]-[A-Za-z0-9-]{10,}/g,
22 /\bAIza[0-9A-Za-z_-]{35}\b/g,
23 /\beyJ[A-Za-z0-9_-]{5,}\.eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}/g,
24 /\bglpat-[A-Za-z0-9_-]{20,}/g,
25 /\bhf_[A-Za-z0-9]{30,}\b/g,
26 /\bnpm_[A-Za-z0-9]{36}\b/g,
27 /\bSK[0-9a-f]{32}\b/g,
28 // A Slack or Discord webhook URL is itself the credential.
29 /\bhttps:\/\/(?:hooks\.slack\.com\/services|discord(?:app)?\.com\/api\/webhooks)\/\S+/g,
30]
31
32// `password=...`, `API_KEY: "..."`, `"client_secret": "..."`: the name must END
33// in the keyword, so `max_tokens=5` and `author: Alex` are left alone.
34const ASSIGNMENT =
35 /\b([A-Za-z0-9_.-]*?(?:password|passwd|pwd|pass|passphrase|secret|secret_key_base|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|credentials?))(["']?\s*[:=]\s*)("[^"\n]*"|'[^'\n]*'|[^\s"',;&]+)/gi
36const AUTH_HEADER = /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{12,}/g
37// GitHub's `Authorization: token ...`: only after the header name, so "token authentication" stays.
38const TOKEN_HEADER = /(\bAuthorization\s*:\s*token\s+)[A-Za-z0-9._~+/=-]{12,}/gi
39const URL_PASSWORD = /\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)[^\s@/]+@/gi
40// `--password hunter2`, `--token=abc`: a secret-named flag and its value.
41const FLAG_SECRET =
42 /(\s--?(?:password|passwd|pass|pwd|token|secret|api[-_]?key|access[-_]?key|auth[-_]?token)[=\s]+)("[^"]*"|'[^']*'|\S+)/gi
43// `mysql -phunter2` (the value glued to -p), and `curl -u user:pass`.
44const MYSQL_P = /(\b(?:mysql|mysqldump|mariadb)\b[^\n]*?\s-p)(\S+)/g
45const USER_PASS = /((?:\s-u\s+|\s--user[=\s]+)[^\s:]+:)(\S+)/g
46// "my password is hunter2"
47const SAID_PASSWORD = /\b(password|passphrase|passwd)(\s+(?:is|was)\s+)(\S+)/gi
48
49/** Replaces anything that looks like a secret with a placeholder. */
50export function redact(text: string): string {
51 let out = text
52 for (const shape of TOKEN_SHAPES) out = out.replace(shape, REDACTED)
53 out = out.replace(ASSIGNMENT, (whole, name: string, sep: string, value: string) =>
54 value.includes(REDACTED) ? whole : `${name}${sep}${REDACTED}`,
55 )
56 out = out.replace(AUTH_HEADER, `$1 ${REDACTED}`).replace(TOKEN_HEADER, `$1${REDACTED}`)
57 out = out.replace(FLAG_SECRET, (whole, flag: string, value: string) => (value.includes(REDACTED) ? whole : `${flag}${REDACTED}`))
58 out = out.replace(MYSQL_P, `$1${REDACTED}`).replace(USER_PASS, `$1${REDACTED}`)
59 out = out.replace(SAID_PASSWORD, `$1$2${REDACTED}`)
60
61 return out.replace(URL_PASSWORD, `$1${REDACTED}@`)
62}
63hooks/mission.ts 314 lines1// Mission control's words and arithmetic: the status of a subagent from what
2// was seen of it, the activity timeline's rows, the sparkline, the grid and
3// the mission clock. Pure: no `$`, so the tests drive it directly.
4
5import type { AntFarmAnt, AntFarmEvent, AntFarmEventKind } from '../types'
6import { redact } from './redact'
7
8// ---------------------------------------------------------------- the live phase
9
10/**
11 * What a running subagent was last seen doing, from the events the engine
12 * raises in its loop: nothing is inferred. `starting` until its first model
13 * request; `thinking` while a thinking block streams; `writing` while text
14 * streams; `tool` from the model naming a tool until the call answered;
15 * `waiting` while the engine's list says the agent is held; `working`
16 * between a tool's answer and the next
17 * request, and for an agent the mod only found mid-run.
18 */
19export type AntFarmPhase = 'starting' | 'thinking' | 'writing' | 'tool' | 'waiting' | 'working'
20
21export type LivePhase = {
22 phase: AntFarmPhase
23 /** The tool and what it touches, or what is waited for. Untrusted: cleaned when drawn. */
24 detail?: string
25 /** `$.clock.now()` when the phase began; absent until the clock answered. */
26 since?: number
27}
28
29/** The status word of a card, its colour and its mark: one of the five the dashboard names. */
30export type StatusLook = { word: string; color: string; glyph: string }
31
32export function statusLook(status: AntFarmAnt['status'], phase: AntFarmPhase | undefined): StatusLook {
33 if (status === 'failed') return { word: 'FAILED', color: 'error', glyph: '✗' }
34 if (status === 'done') return { word: 'DONE', color: 'success', glyph: '✓' }
35 if (phase === 'waiting') return { word: 'WAITING', color: 'warning', glyph: '◇' }
36 if (phase === 'thinking') return { word: 'THINKING', color: 'suggestion', glyph: '◌' }
37 return { word: 'WORKING', color: 'success', glyph: '●' }
38}
39
40/**
41 * What follows the status word on a running card: the tool and what it
42 * touches, what is waited for, or the step ("starting", "writing"), then for
43 * how long; empty when there is nothing to add to the word.
44 */
45export function activityText(live: LivePhase | undefined, now: number): string {
46 if (live === undefined) return ''
47 const forMs = live.since !== undefined ? Math.max(0, now - live.since) : undefined
48 const parts: string[] = []
49 switch (live.phase) {
50 case 'starting': parts.push('starting'); break
51 case 'writing': parts.push('writing'); break
52 case 'tool': parts.push(live.detail ?? 'a tool'); break
53 case 'waiting': parts.push(live.detail ?? 'held'); break
54 default: break
55 }
56 if (forMs !== undefined && forMs >= 1000) parts.push(fmtShort(forMs))
57 return parts.join(' · ')
58}
59
60/** "4s", "1m 20s", "2h 05m": the short form used in activity lines and event rows. */
61export function fmtShort(ms: number): string {
62 const s = Math.max(0, Math.floor(ms / 1000))
63 if (s < 60) return `${s}s`
64 const m = Math.floor(s / 60)
65 if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
66 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
67}
68
69// ---------------------------------------------------------------- the mission clock
70
71/** Mission elapsed time, as a launch clock reads: "T+00:00", "T+12:04", "T+1:02:33". */
72export function met(ms: number): string {
73 const s = Math.max(0, Math.floor(ms / 1000))
74 const h = Math.floor(s / 3600)
75 const m = Math.floor((s % 3600) / 60)
76 const mm = String(m).padStart(2, '0')
77 const ss = String(s % 60).padStart(2, '0')
78 return h > 0 ? `T+${h}:${mm}:${ss}` : `T+${mm}:${ss}`
79}
80
81// ---------------------------------------------------------------- tools
82
83/** The file tools: a call of one is a file modification on the timeline. */
84const FILE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
85
86export type ToolSummary = { kind: 'tool' | 'file'; text: string }
87
88/** The last path segment, so a row names the file and not its folder. */
89export function basename(path: string): string {
90 const trimmed = path.replace(/[\\/]+$/, '')
91 const at = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
92 return at === -1 ? trimmed : trimmed.slice(at + 1)
93}
94
95function firstLine(text: string, max: number): string {
96 const line = text.split(/\r?\n/, 1)[0] ?? ''
97 return line.length > max ? `${line.slice(0, max - 1)}…` : line
98}
99
100// Every argument read is redacted whole before it is cut, so a cut can never
101// leave half a credential that the rules no longer recognise.
102const str = (input: Record<string, unknown>, key: string): string | undefined => {
103 const v = input[key]
104 return typeof v === 'string' && v.length > 0 ? redact(v) : undefined
105}
106
107/**
108 * A tool call in a few words for the timeline: the tool, then what it
109 * touched (a file's name, a command's first line, a pattern). The words come
110 * from the model's own arguments: secret shapes are replaced here, and the
111 * rest is cleaned where drawn.
112 */
113export function toolSummary(tool: string, input: Record<string, unknown>): ToolSummary {
114 if (FILE_TOOLS.has(tool)) {
115 const path = str(input, 'file_path') ?? str(input, 'notebook_path')
116 return { kind: 'file', text: `${tool} ${path !== undefined ? basename(path) : ''}`.trim() }
117 }
118 const mcp = /^mcp__([^_].*?)__(.+)$/.exec(tool)
119 if (mcp !== null) return { kind: 'tool', text: `${mcp[1]}: ${mcp[2]}` }
120 const detail =
121 tool === 'Bash' ? firstLine(str(input, 'command') ?? '', 48)
122 : tool === 'Read' ? basename(str(input, 'file_path') ?? '')
123 : tool === 'Grep' || tool === 'Glob' ? firstLine(str(input, 'pattern') ?? '', 40)
124 : tool === 'Agent' ? firstLine(str(input, 'description') ?? '', 40)
125 : tool === 'WebFetch' ? firstLine(str(input, 'url') ?? '', 48)
126 : tool === 'WebSearch' ? firstLine(str(input, 'query') ?? '', 40)
127 : tool === 'Skill' ? firstLine(str(input, 'skill') ?? '', 40)
128 : ''
129 return { kind: 'tool', text: `${tool}${detail !== '' ? ` ${detail}` : ''}` }
130}
131
132/** A model response's stop reason worth a warning row, or undefined. */
133export function stopWarning(stopReason: string | null, hasResponse: boolean): string | undefined {
134 if (!hasResponse) return 'no response from the model'
135 if (stopReason === 'max_tokens') return 'hit the output limit'
136 if (stopReason === 'model_context_window_exceeded') return 'context window exceeded'
137 if (stopReason === 'refusal') return 'the model refused'
138 return undefined
139}
140
141// ---------------------------------------------------------------- the timeline
142
143export const EVENTS_MAX = 80
144export const TIMELINE_ROWS = 12
145export const TIMELINE_ROWS_EXPANDED = 40
146
147/** Appends one row, dropping the oldest past `max`. */
148export function pushEvent(list: readonly AntFarmEvent[], event: AntFarmEvent, max = EVENTS_MAX): AntFarmEvent[] {
149 const next = [...list, event]
150 return next.length > max ? next.slice(next.length - max) : next
151}
152
153export type EventLook = { glyph: string; word: string; color?: string }
154
155/** How a kind of row reads: its mark, its word and its colour (none: the surface's text colour). */
156export function eventLook(kind: AntFarmEventKind): EventLook {
157 switch (kind) {
158 case 'spawn': return { glyph: '⇢', word: 'start', color: 'suggestion' }
159 case 'handoff': return { glyph: '⇢', word: 'handoff', color: 'suggestion' }
160 case 'file': return { glyph: '✎', word: 'edit', color: 'suggestion' }
161 case 'done': return { glyph: '✓', word: 'done', color: 'success' }
162 case 'failed': return { glyph: '✗', word: 'failed', color: 'error' }
163 case 'error': return { glyph: '✗', word: 'error', color: 'error' }
164 case 'warning': return { glyph: '△', word: 'warning', color: 'warning' }
165 default: return { glyph: '▸', word: 'tool' }
166 }
167}
168
169/** The next mission number: one past the highest so far, so a trimmed list never reuses one. */
170export function nextN(list: readonly AntFarmAnt[]): number {
171 return list.reduce((top, a) => Math.max(top, a.n ?? 0), 0) + 1
172}
173
174// ---------------------------------------------------------------- the sparkline
175
176export const SPARK_GLYPHS = '▁▂▃▄▅▆▇█'
177export const HISTORY_MAX = 24
178
179/** Appends one request's tokens, keeping the last HISTORY_MAX. */
180export function pushHistory(history: readonly number[] | undefined, tokens: number): number[] {
181 const next = [...(history ?? []), tokens]
182 return next.length > HISTORY_MAX ? next.slice(next.length - HISTORY_MAX) : next
183}
184
185/**
186 * The last `width` values as a row of eight-level bars, scaled to their
187 * largest; a value of 0 is the lowest bar. Dots where there is nothing yet.
188 */
189export function sparkline(values: readonly number[], width: number): string {
190 const tail = values.slice(-width)
191 if (tail.length === 0) return '·'.repeat(width)
192 const top = Math.max(1, ...tail)
193 const glyphs = tail.map(v => SPARK_GLYPHS[Math.min(7, Math.max(0, Math.round((Math.max(0, v) / top) * 7)))] ?? '▁').join('')
194 return ' '.repeat(width - tail.length) + glyphs
195}
196
197// ---------------------------------------------------------------- the grid
198
199/** Narrower than this, a card has no room for art and the pane draws compact rows. */
200export const COMPACT_BELOW = 40
201/** A card needs this many columns: the lane for the ant, the words beside it, a border. */
202export const CARD_MIN = 38
203export const GRID_MAX_COLUMNS = 3
204
205/** How many cards fit side by side, and how wide each is, for a body width. */
206export function gridOf(bodyColumns: number): { cols: number; cardWidth: number } {
207 const cols = Math.max(1, Math.min(GRID_MAX_COLUMNS, Math.floor((bodyColumns + 1) / (CARD_MIN + 1))))
208 const cardWidth = Math.max(CARD_MIN, Math.floor((bodyColumns - (cols - 1)) / cols))
209 return { cols, cardWidth }
210}
211
212/** `items` in rows of `n`, the last row shorter. */
213export function rowsOf<T>(items: readonly T[], n: number): T[][] {
214 const rows: T[][] = []
215 for (let i = 0; i < items.length; i += Math.max(1, n)) rows.push(items.slice(i, i + Math.max(1, n)))
216 return rows
217}
218
219// ---------------------------------------------------------------- motion
220
221/** The spinner beside a working card, one glyph a frame. */
222export const SPIN = ['◐', '◓', '◑', '◒'] as const
223
224export function spinner(frame: number): string {
225 return SPIN[((frame % SPIN.length) + SPIN.length) % SPIN.length] ?? '◐'
226}
227
228/** The heartbeat's weight this frame: bold, plain, dim, plain, so the dot breathes. */
229export function pulse(frame: number): { bold?: true; dimColor?: true } {
230 const beat = ((frame % 4) + 4) % 4
231 return beat === 0 ? { bold: true } : beat === 2 ? { dimColor: true } : {}
232}
233
234/** A finished subagent keeps its full colour this long before it rests dim: the eye follows the change. */
235export const SETTLE_MS = 2500
236/** The newest timeline row is drawn bold this long. */
237export const FRESH_MS = 1500
238
239// ---------------------------------------------------------------- selection
240
241/** The id after (`1`) or before (`-1`) the current one among `ids`, wrapping; the first with none current. */
242export function moveSelection(ids: readonly string[], current: string | undefined, by: 1 | -1): string | undefined {
243 if (ids.length === 0) return undefined
244 const at = current === undefined ? -1 : ids.indexOf(current)
245 if (at === -1) return by === 1 ? ids[0] : ids[ids.length - 1]
246 return ids[(at + by + ids.length) % ids.length]
247}
248
249/** A card's hotkey is its mission number while that is one digit; past nine, none. */
250export function hotkeyOf(n: number | undefined): string | undefined {
251 return n !== undefined && Number.isInteger(n) && n >= 1 && n <= 9 ? String(n) : undefined
252}
253
254// ---------------------------------------------------------------- order
255
256/** Running first (oldest on top), then finished (latest on top). */
257export function ordered(list: readonly AntFarmAnt[]): { running: AntFarmAnt[]; finished: AntFarmAnt[] } {
258 const running = list.filter(a => a.status === 'running').sort((a, b) => a.startedAt - b.startedAt)
259 const finished = list
260 .filter(a => a.status !== 'running')
261 .sort((a, b) => (b.endedAt ?? b.startedAt) - (a.endedAt ?? a.startedAt))
262 return { running, finished }
263}
264
265// ---------------------------------------------------------------- the heat strip
266
267/** Rows of the heat strip: the last minute in ten-second windows, oldest on top. */
268export const HEAT_ROWS = 6
269export const HEAT_WINDOW_MS = 10_000
270/** Columns at most: one per subagent in mission order; the rest are not shown. */
271export const HEAT_COLUMNS_MAX = 12
272/** The shade for 0, 1, 2, 3 and 4 or more rows a subagent put on the timeline in a window. */
273export const HEAT_GLYPHS = '·░▒▓█'
274
275export function heatGlyph(count: number): string {
276 return HEAT_GLYPHS[Math.min(HEAT_GLYPHS.length - 1, Math.max(0, Math.floor(count)))] ?? '·'
277}
278
279/**
280 * The heat strip's cells: one column per subagent in mission order (when
281 * more than `maxColumns`, the running ones and then the latest finished are
282 * the ones kept, still laid out in mission order, so no column jumps when one
283 * finishes), one row per window with the oldest on top, each cell how many
284 * timeline rows that subagent produced in that window.
285 *
286 * The windows are anchored to the clock (each starts on a multiple of the
287 * window), so the strip steps once per window instead of sliding with every
288 * tick; the newest window is the one `now` falls in.
289 */
290export function heatGrid(
291 list: readonly AntFarmAnt[],
292 events: readonly AntFarmEvent[],
293 now: number,
294 rows = HEAT_ROWS,
295 maxColumns = HEAT_COLUMNS_MAX,
296): { ids: string[]; grid: number[][] } {
297 const byMission = (a: AntFarmAnt, b: AntFarmAnt) => (a.n ?? Number.MAX_SAFE_INTEGER) - (b.n ?? Number.MAX_SAFE_INTEGER) || a.startedAt - b.startedAt
298 const running = list.filter(a => a.status === 'running').sort(byMission)
299 const finished = list.filter(a => a.status !== 'running').sort((a, b) => (b.endedAt ?? b.startedAt) - (a.endedAt ?? a.startedAt) || byMission(a, b))
300 const ids = [...running, ...finished].slice(0, maxColumns).sort(byMission).map(a => a.id)
301 const column = new Map(ids.map((id, i) => [id, i] as const))
302 const grid = Array.from({ length: rows }, () => ids.map(() => 0))
303 const end = Math.floor(now / HEAT_WINDOW_MS) * HEAT_WINDOW_MS + HEAT_WINDOW_MS
304 const start = end - rows * HEAT_WINDOW_MS
305 for (const ev of events) {
306 const col = ev.agentId !== undefined ? column.get(ev.agentId) : undefined
307 if (col === undefined || ev.at < start || ev.at > now) continue
308 const row = Math.min(rows - 1, Math.floor((ev.at - start) / HEAT_WINDOW_MS))
309 grid[row]![col] = (grid[row]![col] ?? 0) + 1
310 }
311 return { ids, grid }
312}
313
314hooks/show.ts 281 lines1// What the band and the pane say: formatting, the ETA, model colours and the
2// pixel ants. Pure: no `$`, so the tests drive it directly.
3
4import type { AntFarmAlarm, AntFarmAnt, AntFarmUsage, AntFarmView } from '../types'
5
6// ---------------------------------------------------------------- untrusted text
7
8// Task descriptions, agent types and model names come from the model and
9// other plugins. The engine refuses a drawn tree holding a control character
10// (and draws its own in its place, hiding every band beneath), so every such
11// text is cleaned where it is drawn: escape sequences removed, any other C0,
12// DEL or C1 character shown as U+FFFD.
13const ESCAPES = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?|\x1b[@-Z\\-_]|\x9b[0-?]*[ -/]*[@-~]/g
14const CONTROLS = /[\x00-\x1f\x7f-\x9f]/g
15
16export function clean(text: string): string {
17 return text.replace(ESCAPES, '').replace(CONTROLS, '\uFFFD')
18}
19
20// ---------------------------------------------------------------- numbers
21
22export function fmtTokens(n: number): string {
23 if (n < 1000) return String(n)
24 if (n < 1_000_000) return `${(n / 1000).toFixed(n < 100_000 ? 1 : 0)}k`.replace('.0k', 'k')
25 return `${(n / 1_000_000).toFixed(2)}M`
26}
27
28/** Dollars with the precision small amounts need: "$1.23", "$0.042", "$0.0021". */
29export function fmtDollars(c: number): string {
30 return `$${c === 0 || c >= 0.1 ? c.toFixed(2) : c >= 0.01 ? c.toFixed(3) : c.toFixed(4)}`
31}
32
33/** Every table cost is an estimate: "≈$0.042"; a model the table lacks makes it "?" or "≈$0.04+?". */
34export function fmtCost(u: AntFarmUsage): string {
35 if (u.costUsd === 0 && u.unpricedTokens > 0) return '?'
36 return `≈${fmtDollars(u.costUsd)}${u.unpricedTokens > 0 ? '+?' : ''}`
37}
38
39/**
40 * The session's spend: Claude Code's own estimate where the engine gives one
41 * (its cost ledger, summed at list prices: not a bill), else the table's.
42 * Either way an estimate, drawn with ≈.
43 */
44export type Spend = { usd: number; text: string; isEngine: boolean; isAny: boolean }
45
46export function spendOf(engineUsd: number | null, estimate: AntFarmUsage): Spend {
47 if (engineUsd !== null) {
48 return { usd: engineUsd, text: `≈${fmtDollars(engineUsd)}`, isEngine: true, isAny: engineUsd > 0 || estimate.tokens > 0 }
49 }
50 return { usd: estimate.costUsd, text: fmtCost(estimate), isEngine: false, isAny: estimate.tokens > 0 }
51}
52
53export function fmtUsd(usd: number): string {
54 return `$${usd.toFixed(2)}`
55}
56
57/** The plugin's status line: the session's spend against the budget, once anything was spent. */
58export function statusText(spend: Spend, budgetUsd: number, level: 0 | 1 | 2): string | undefined {
59 if (!spend.isAny) return undefined
60 const source = spend.isEngine ? " (Claude Code's estimate)" : ''
61 // Claude Code already labels a plugin's status line with its name.
62 if (!(budgetUsd > 0)) return `${spend.text} this session${source}`
63 const over = level === 2 ? ' - past 2x the budget' : level === 1 ? ' - over budget' : ''
64 return `${spend.text} of ${fmtUsd(budgetUsd)} budget${source}${over}`
65}
66
67/**
68 * The alarm after a new reading: a level once announced is not announced
69 * again, even if a later reading comes in lower (a stale or failed read), until
70 * the budget itself changes. `toast` is the level to announce now, or 0.
71 */
72export function nextAlarm(stored: AntFarmAlarm, level: 0 | 1 | 2, budgetUsd: number): { alarm: AntFarmAlarm; toast: 0 | 1 | 2 } {
73 const before = stored.budgetUsd === budgetUsd ? stored.level : 0
74 return {
75 alarm: { level: Math.max(before, level), budgetUsd },
76 toast: level > before ? level : 0,
77 }
78}
79
80/** The session figure with its source: "Claude Code's estimate ≈$2.50" or "≈$2.50 session". */
81export function spendLabel(spend: Spend): string {
82 return spend.isEngine ? `Claude Code's estimate ${spend.text}` : `${spend.text} session`
83}
84
85/** The toast when the spend crosses the budget (level 1) or twice it (level 2). */
86export function alarmText(spend: Spend, budgetUsd: number, level: 1 | 2): string {
87 const what = `${spend.isEngine ? "Claude Code's estimate" : 'Session estimate'} ${spend.text}`
88 return level === 2
89 ? `${what} is past 2x your ${fmtUsd(budgetUsd)} budget.`
90 : `${what} reached your ${fmtUsd(budgetUsd)} budget.`
91}
92
93export function fmtDuration(ms: number): string {
94 const s = Math.max(0, Math.floor(ms / 1000))
95 if (s < 60) return `${s}s`
96 const m = Math.floor(s / 60)
97 if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
98 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
99}
100
101export function fmtEta(ms: number): string {
102 const s = Math.round(ms / 1000)
103 if (s < 60) return `~${Math.max(5, Math.round(s / 5) * 5)}s left`
104 const m = Math.round(s / 60)
105 if (m < 60) return `~${m}m left`
106 return `~${Math.floor(m / 60)}h ${m % 60}m left`
107}
108
109/** `claude-opus-5-5` -> `opus-5-5`; regional and dated spellings kept readable. */
110export function shortModel(model: string): string {
111 return model.replace(/^.*?claude-/, '') || model
112}
113
114/** At most `max` ants, the oldest finished ones dropped before any running one. */
115export function trimAnts(list: readonly AntFarmAnt[], max: number): AntFarmAnt[] {
116 let excess = list.length - max
117 if (excess <= 0) return [...list]
118 const kept = list.filter(a => {
119 if (excess > 0 && a.status !== 'running') {
120 excess -= 1
121 return false
122 }
123 return true
124 })
125 return kept.slice(-max)
126}
127
128/**
129 * The view after the pane closed: the person's own close also ends the band's
130 * list and keeps the pane from opening again unasked; a plugin's or the
131 * engine's close changes nothing.
132 */
133export function viewAfterClose(v: AntFarmView, by: 'plugin' | 'person' | 'unload'): AntFarmView {
134 return by === 'person' ? { ...v, isClosedByHand: true, isListInBand: false } : v
135}
136
137// ---------------------------------------------------------------- ETA
138
139/** The minimum requests before a pace is worth extrapolating. */
140export const ETA_MIN_REQUESTS = 3
141
142function median(values: readonly number[]): number | undefined {
143 if (values.length === 0) return undefined
144 const sorted = [...values].sort((a, b) => a - b)
145 const mid = Math.floor(sorted.length / 2)
146 return sorted.length % 2 === 1 ? sorted[mid] : ((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2
147}
148
149export type Progress = {
150 /** 0..1, or null while there is no basis for one yet. */
151 fraction: number | null
152 /** Milliseconds left, or null. */
153 etaMs: number | null
154}
155
156/**
157 * How far along a running subagent is, from its own pace (time per request so
158 * far) and how many requests finished subagents needed: the median of those of
159 * its type this session, else of all of them. With no finished subagent yet it
160 * assumes it is halfway (as many requests to go as made). Nothing before
161 * ETA_MIN_REQUESTS requests.
162 */
163export function progressOf(ant: AntFarmAnt, all: readonly AntFarmAnt[], now: number): Progress {
164 if (ant.status === 'done') return { fraction: 1, etaMs: null }
165 const steps = ant.usage.requests
166 if (ant.status !== 'running' || steps < ETA_MIN_REQUESTS) return { fraction: null, etaMs: null }
167
168 const done = all.filter(a => a.status === 'done' && a.id !== ant.id && a.usage.requests > 0)
169 const sameType = done.filter(a => a.type === ant.type)
170 const expected = median((sameType.length > 0 ? sameType : done).map(a => a.usage.requests))
171 // Past what finished subagents needed there is no basis for a time left: the
172 // bar stays nearly full and the ETA goes, rather than reading "5s left" for minutes.
173 if (expected !== undefined && steps >= Math.round(expected)) return { fraction: steps / (steps + 1), etaMs: null }
174 const remaining = expected === undefined ? steps : Math.max(1, Math.round(expected - steps))
175 const pace = Math.max(0, now - ant.startedAt) / steps
176 return { fraction: steps / (steps + remaining), etaMs: remaining * pace }
177}
178
179export function bar(fraction: number | null, width: number): string {
180 if (fraction === null) return '·'.repeat(width)
181 const full = Math.round(Math.min(1, Math.max(0, fraction)) * width)
182 return '█'.repeat(full) + '░'.repeat(width - full)
183}
184
185// ---------------------------------------------------------------- colours
186
187// Theme keys, so the ants follow the person's light or dark theme. The status
188// colours (success, warning, error) are kept for status and the budget.
189// Never the `claude` key: that is the terracotta of another brand (brand/BRAND.md).
190const PALETTE = ['remember', 'autoAccept', 'suggestion', 'planMode', 'permission', 'ide', 'merged'] as const
191const FAMILY: readonly [RegExp, (typeof PALETTE)[number]][] = [
192 [/fable|mythos/, 'remember'],
193 [/opus/, 'autoAccept'],
194 [/sonnet/, 'suggestion'],
195 [/haiku/, 'planMode'],
196]
197
198/** One colour per model, in order of first appearance; a family keeps its colour while it is free. */
199export function modelColors(ants: readonly AntFarmAnt[]): Map<string, string> {
200 const colors = new Map<string, string>()
201 const taken = new Set<string>()
202 for (const { model } of [...ants].sort((a, b) => a.startedAt - b.startedAt)) {
203 if (colors.has(model)) continue
204 const preferred = FAMILY.find(([re]) => re.test(model.toLowerCase()))?.[1]
205 const free = PALETTE.find(c => !taken.has(c))
206 const color = preferred !== undefined && !taken.has(preferred) ? preferred : free ?? PALETTE[colors.size % PALETTE.length]!
207 colors.set(model, color)
208 taken.add(color)
209 }
210 return colors
211}
212
213// ---------------------------------------------------------------- pixel ants
214
215// Each ant is drawn from pixel rows ('#' on, '.' off), two pixel rows to a
216// text row with half blocks, so it is plain text on every surface. Facing
217// right: abdomen, waist, thorax, head and an antenna; the legs alternate.
218// Side on, facing right, 10 x 6 pixels (3 text rows, one per line of a card):
219// abdomen, a thin waist, thorax, head with its antenna, and legs that swap as it walks.
220const WALK: readonly (readonly string[])[] = [
221 ['.........#', '........#.', '.##..##.##', '##########', '.##.#.#.#.', '...#..#..#'],
222 ['.........#', '........#.', '.##..##.##', '##########', '.##..#.#..', '....#.#.#.'],
223]
224const REST: readonly string[] = ['.........#', '........#.', '.##..##.##', '##########', '.##.#.#.#.', '...#.#.#..']
225// A failed ant lies on its back, legs up.
226const FLIPPED = [...REST].reverse()
227
228const MINI_WALK: readonly (readonly string[])[] = [
229 ['###.#.##', '.#.#.#..'],
230 ['###.#.##', '#.#.#.#.'],
231]
232
233export const ANT_WIDTH = 10
234export const MINI_WIDTH = 8
235
236function halfBlocks(pixels: readonly string[]): string[] {
237 const rows: string[] = []
238 for (let y = 0; y < pixels.length; y += 2) {
239 const top = pixels[y] ?? ''
240 const bottom = pixels[y + 1] ?? ''
241 rows.push(
242 [...top]
243 .map((t, x) => {
244 const a = t === '#'
245 const b = bottom[x] === '#'
246 return a && b ? '█' : a ? '▀' : b ? '▄' : ' '
247 })
248 .join(''),
249 )
250 }
251 return rows
252}
253
254/** A per-ant offset so a colony does not march in step. */
255export function phaseOf(id: string): number {
256 let h = 0
257 for (const c of id) h = (h * 31 + c.charCodeAt(0)) >>> 0
258 return h % 997
259}
260
261/**
262 * The three text rows of a full-size ant in a lane `lane` cells wide: a running
263 * ant walks right across it (one cell and one leg swap a frame), a finished
264 * one stands at the far end, a failed one lies on its back.
265 */
266export function antRows(status: AntFarmAnt['status'], frame: number, lane: number): string[] {
267 const room = Math.max(0, lane - ANT_WIDTH)
268 if (status !== 'running') {
269 return halfBlocks(status === 'failed' ? FLIPPED : REST).map(r => ' '.repeat(room) + r)
270 }
271 const x = frame % (room + 1)
272 return halfBlocks(WALK[frame % 2]!).map(r => ' '.repeat(x) + r + ' '.repeat(room - x))
273}
274
275/** One text row: the small ant the band's list draws. */
276export function miniAnt(status: AntFarmAnt['status'], frame: number): string {
277 const pixels = status === 'running' ? MINI_WALK[frame % 2]! : MINI_WALK[0]!
278 const row = halfBlocks(status === 'failed' ? [...pixels].reverse() : pixels)[0] ?? ''
279 return row
280}
281hooks/bolt-draw.tsx 86 lines1// Draws Bolt, the modmyclaude mascot (brand/bolt-sprites), on the surface a hook draws for.
2// Same file in every mod that shows Bolt: mods install on their own, so they cannot share code.
3// Terminal: one Raster, a cell grid. Desktop: one code-font cell per character, since its Text is
4// proportional and a row of half-blocks would drift. Elsewhere: coloured Text runs.
5// Bolt is static here: no timer, no redraw of its own. Never draw it over a safety hold or in a toast.
6
7import type { BoxProps, ElementConstructor, RasterProps, RenderElement, TextProps } from 'claude-code'
8
9import { boltCells, boltRuns } from './bolt'
10import type { BoltCell, BoltSmallPose, BoltTinyPose } from './bolt'
11
12type Elements = {
13 Box: ElementConstructor<BoxProps>
14 Text: ElementConstructor<TextProps>
15 Raster?: ElementConstructor<RasterProps>
16}
17
18export type BoltPick = { size: 'small'; pose: BoltSmallPose } | { size: 'tiny'; pose: BoltTinyPose }
19
20/** Columns a pose takes: small is 10, tiny is 3. */
21export const BOLT_COLUMNS = { small: 10, tiny: 3 } as const
22
23/** The cells of a pose: half-blocks, two pixel rows per cell. */
24export function boltOf(pick: BoltPick): BoltCell[][] {
25 return pick.size === 'small' ? boltCells('small', pick.pose) : boltCells('tiny', pick.pose)
26}
27
28/** Bolt as one element under `key`, drawn the way `surface` lays out cells. */
29export function boltSprite(elements: Elements, surface: string, pick: BoltPick, key = 'bolt'): RenderElement {
30 const { Box, Text, Raster } = elements
31 const cells = boltOf(pick)
32 const columns = cells[0]?.length ?? 0
33 if (surface === 'terminal' && Raster !== undefined) {
34 return <Raster key={key} columns={columns} rows={cells.length} cells={rasterCells(cells)} />
35 }
36 const paint = (cell: BoltCell): TextProps => ({
37 ...(cell.fg !== undefined ? { color: cell.fg } : {}),
38 ...(cell.bg !== undefined ? { backgroundColor: cell.bg } : {}),
39 })
40 if (surface === 'desktop') {
41 return (
42 <Box key={key} flexDirection="column" flexShrink={0}>
43 {cells.map(line => (
44 <Box flexDirection="row" flexWrap="nowrap">
45 {line.map(cell =>
46 cell.fg === undefined && cell.bg === undefined ? <Box width={1} /> : <Box width={1}><Text {...paint(cell)}>{cell.ch}</Text></Box>,
47 )}
48 </Box>
49 ))}
50 </Box>
51 )
52 }
53 return (
54 <Box key={key} flexDirection="column" flexShrink={0}>
55 {cells.map(line => (
56 <Text>
57 {boltRuns(line).map(run => {
58 const text = run.ch.replace(/ /g, ' ')
59 return run.fg === undefined && run.bg === undefined ? text : <Text {...paint(run)}>{text}</Text>
60 })}
61 </Text>
62 ))}
63 </Box>
64 )
65}
66
67/** RasterProps.cells: standard base64 of little-endian u32 [codePoint, fg, bg] per cell, row-major. */
68export function rasterCells(cells: readonly (readonly BoltCell[])[]): string {
69 const DEFAULT = 0x01000000
70 const count = cells.reduce((sum, line) => sum + line.length, 0)
71 const view = new DataView(new ArrayBuffer(count * 12))
72 let at = 0
73 for (const line of cells) {
74 for (const cell of line) {
75 view.setUint32(at, cell.ch.codePointAt(0) ?? 32, true)
76 view.setUint32(at + 4, cell.fg === undefined ? DEFAULT : parseInt(cell.fg.slice(1), 16), true)
77 view.setUint32(at + 8, cell.bg === undefined ? DEFAULT : parseInt(cell.bg.slice(1), 16), true)
78 at += 12
79 }
80 }
81 let binary = ''
82 const bytes = new Uint8Array(view.buffer)
83 for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i] ?? 0)
84 return btoa(binary)
85}
86hooks/bolt.ts 109 lines1// Bolt sprites for mods, v1.0.0. GENERATED by brand/bolt-sprites/tools/build.py: edit design.py, not this file.
2// Copy this file into your mod's hooks/ folder (mods install on their own, so they cannot import from brand/).
3// Rules (brand/BRAND.md): pink is the antenna light only, blush the cheeks only. Never recolour, rotate or stretch.
4// Never draw Bolt over a safety hold, and never in a toast (toasts are uncoloured text).
5
6export const BOLT_PALETTE = {
7 B: '#C8F53C',
8 S: '#9BC21A',
9 O: '#FF5C8A',
10 K: '#FF8FB1',
11 E: '#101114',
12 W: '#FBFBF8',
13 G: '#8E95A3',
14} as const
15
16export type BoltSize = 'small' | 'tiny'
17export type BoltSmallPose = 'idle' | 'run1' | 'run2' | 'jump' | 'crash' | 'cheer' | 'sleep' | 'sweat' | 'think' | 'egg' | 'egg-wobble-l' | 'egg-wobble-r' | 'egg-crack-1' | 'egg-crack-2' | 'egg-crack-3' | 'egg-hatch'
18export type BoltTinyPose = 'idle' | 'run1' | 'run2' | 'jump' | 'crash' | 'cheer' | 'sleep'
19export type BoltPose = BoltSmallPose | BoltTinyPose
20
21/** One text cell: a character and its colours (absent = the surface's own). */
22export type BoltCell = { ch: string; fg?: string; bg?: string }
23
24/** Pixel rows: one palette letter per pixel, '.' empty. Heights are even (two pixel rows per cell). */
25export const BOLT_SPRITES = {
26 small: {
27 'idle': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '..SS..SS..'],
28 'run1': ['...OO.....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBWEBBWEB', 'BBBEEBBEEB', 'BKKBBBBBKB', 'BBBBBEEBBB', '.SSSSSSSS.', 'SS......SS'],
29 'run2': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBWEBBWEB', 'BBBEEBBEEB', 'BKKBBBBBKB', 'BBBBBEEBBB', '.SSSSSSSS.', '...SSSS...'],
30 'jump': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '...S..S...'],
31 'crash': ['......OO..', '.....S....', '..BBBBBB..', '.BBBBBBBB.', 'BEBEBBEBEB', 'BBEBBBBEBB', 'BEBEBBEBEB', 'BBBBEEBBBB', '.SSSSSSSS.', 'SS......SS'],
32 'cheer': ['S...OO...S', 'S...SS...S', 'SBBBBBBBBS', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBEEEEBBB', '.SSSSSSSS.', '..SS..SS..'],
33 'sleep': ['....SS....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBBBBBBBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBBBBBBB', '.SSSSSSSS.', '..SS..SS..'],
34 'sweat': ['....OO....', '....SS...G', '..BBBBBB.G', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEEBBB', '.SSSSSSSS.', '..SS..SS..'],
35 'think': ['....OO....', '....SS....', '..BBBBBB..', '.BBWEBWEB.', 'BBBEEBEEBB', 'BBBBBBBBBB', 'BKKBBBBKKB', 'BBBBBEBBBB', '.SSSSSSSS.', '..SS..SS..'],
36 'egg': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
37 'egg-wobble-l': ['...OO.....', '...SS.....', '..BWBB....', '.BWBBBB...', '.BBBBBB...', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
38 'egg-wobble-r': ['.....OO...', '.....SS...', '....BWBB..', '...BWBBBB.', '...BBBBBB.', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
39 'egg-crack-1': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBBBBBB.', '.BBEBBBBS.', '..BBBBBS..', '...SSSS...'],
40 'egg-crack-2': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBEBBBB.', '.BBEBEBBS.', '..BBBBBS..', '...SSSS...'],
41 'egg-crack-3': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBEBEBB.', '.BBEBEBES.', '..BBBBBS..', '...SSSS...'],
42 'egg-hatch': ['......OO..', '......SS..', '....BWBBBB', '....B.B.B.', '..........', '..........', '.B.B.B.BB.', '.BSSSSSSB.', '..BBBBBS..', '...SSSS...'],
43 },
44 tiny: {
45 'idle': ['.O.', 'BEB', 'BBB', 'S.S'],
46 'run1': ['.O.', 'BEB', 'BBB', 'S.S'],
47 'run2': ['.O.', 'BEB', 'BBB', '.S.'],
48 'jump': ['.O.', 'BEB', 'BBB', '.S.'],
49 'crash': ['...', '.SO', 'BEB', 'SSS'],
50 'cheer': ['SOS', 'BEB', 'BBB', 'S.S'],
51 'sleep': ['.S.', 'BSB', 'BBB', 'S.S'],
52 },
53} as const
54
55/** Letters drawn over a whole cell (cell coordinates). */
56const GLYPHS: Record<string, readonly { row: number; col: number; ch: string; color: keyof typeof BOLT_PALETTE }[]> = {
57 'small/sleep': [{ row: 0, col: 8, ch: 'z', color: 'G' }],
58}
59
60/** The two run frames, in order. Swap them about every 120 ms while running. */
61export const BOLT_RUN = ['run1', 'run2'] as const
62
63function colour(key: string | undefined): string | undefined {
64 if (key === undefined || key === '.') return undefined
65 return (BOLT_PALETTE as Record<string, string>)[key]
66}
67
68/**
69 * Packs a pose into half-block text cells: rows / 2 lines of `cols` cells.
70 * Terminal: draw with Raster (see hatchling's rasterCells). Desktop: one fixed-width cell per character.
71 */
72export function boltCells(size: 'small', pose: BoltSmallPose): BoltCell[][]
73export function boltCells(size: 'tiny', pose: BoltTinyPose): BoltCell[][]
74export function boltCells(size: BoltSize, pose: BoltPose): BoltCell[][] {
75 const rows: readonly string[] = (BOLT_SPRITES[size] as Record<string, readonly string[]>)[pose] ?? []
76 const out: BoltCell[][] = []
77 for (let r = 0; r + 1 < rows.length; r += 2) {
78 const top = rows[r] ?? ''
79 const bottom = rows[r + 1] ?? ''
80 const line: BoltCell[] = []
81 for (let c = 0; c < top.length; c += 1) {
82 const t = colour(top[c])
83 const b = colour(bottom[c])
84 if (t === undefined && b === undefined) line.push({ ch: ' ' })
85 else if (b === undefined) line.push({ ch: '\u2580', fg: t })
86 else if (t === undefined) line.push({ ch: '\u2584', fg: b })
87 else if (t === b) line.push({ ch: '\u2588', fg: t })
88 else line.push({ ch: '\u2580', fg: t, bg: b })
89 }
90 out.push(line)
91 }
92 for (const glyph of GLYPHS[`${size}/${pose}`] ?? []) {
93 const line = out[glyph.row]
94 if (line !== undefined && glyph.col < line.length) line[glyph.col] = { ch: glyph.ch, fg: BOLT_PALETTE[glyph.color] }
95 }
96 return out
97}
98
99/** Merges a row of cells into runs of one style, for coloured Text spans. */
100export function boltRuns(line: readonly BoltCell[]): BoltCell[] {
101 const runs: BoltCell[] = []
102 for (const cell of line) {
103 const last = runs[runs.length - 1]
104 if (last !== undefined && last.fg === cell.fg && last.bg === cell.bg) last.ch += cell.ch
105 else runs.push({ ...cell })
106 }
107 return runs
108}
109hooks/map.ts 121 lines1// The mission map: the main loop and every subagent as nodes, handoffs as
2// edges, drawn as one SVG for the surfaces that draw vectors. It is a still
3// picture: the desktop re-sets an interactive frame on every render pass of a
4// hook that ticks, so there is no pulse and no tooltip; the full tasks go in
5// the alt text. Its source depends on the mission's shape alone, never on the
6// clock, so a tick redraws the same bytes.
7
8import type { AntFarmAnt } from '../types'
9
10export type MapNode = {
11 id: string
12 n?: number
13 status: AntFarmAnt['status']
14 parentId?: string
15 /** The task, shortened for the label. Already cleaned. */
16 label: string
17 /** The task in full, for the tooltip. Already cleaned. */
18 title: string
19}
20
21/** Nodes at most; the rest are counted. */
22export const MAP_NODES_MAX = 10
23const LABEL_MAX = 24
24/** About one label character at the map's face, in CSS pixels. */
25const CHAR_PX = 7
26/** The map's own colours: it draws in a frame of its own, where theme keys do not reach; these read on light and dark. */
27export const MAP_COLORS = { text: '#8b949e', running: '#58a6ff', done: '#3fb950', failed: '#f85149' } as const
28const COLUMN = 200
29const ROW = 22
30const LEFT = 20
31const TOP = 30
32const LABEL_DX = 14
33
34export function esc(s: string): string {
35 return s.replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch] ?? ch)
36}
37
38function short(s: string): string {
39 const chars = [...s]
40 return chars.length > LABEL_MAX ? `${chars.slice(0, LABEL_MAX - 1).join('')}…` : s
41}
42
43/** The nodes in mission order, at most `max`, a parent kept only when it is among them. */
44export function mapNodes(list: readonly AntFarmAnt[], clean: (s: string) => string, max = MAP_NODES_MAX): { nodes: MapNode[]; hidden: number } {
45 const sorted = [...list].sort((a, b) => (a.n ?? Number.MAX_SAFE_INTEGER) - (b.n ?? Number.MAX_SAFE_INTEGER) || a.startedAt - b.startedAt)
46 const shown = sorted.slice(0, max)
47 const ids = new Set(shown.map(a => a.id))
48 const nodes = shown.map(a => {
49 const task = clean(a.task)
50 return {
51 id: a.id,
52 ...(a.n !== undefined ? { n: a.n } : {}),
53 status: a.status,
54 ...(a.parentId !== undefined && ids.has(a.parentId) ? { parentId: a.parentId } : {}),
55 label: short(task),
56 title: task,
57 }
58 })
59 return { nodes, hidden: sorted.length - shown.length }
60}
61
62/** The SVG: a tree from the main loop, one column per depth, one row per node; running nodes pulse. */
63export function missionMap(nodes: readonly MapNode[], hidden = 0): { source: string; alt: string; width: number; height: number } {
64 const children = new Map<string | undefined, MapNode[]>()
65 for (const node of nodes) children.set(node.parentId, [...(children.get(node.parentId) ?? []), node])
66 const placed: { node: MapNode; depth: number; row: number }[] = []
67 let row = 1
68 const visit = (parent: string | undefined, depth: number) => {
69 for (const node of children.get(parent) ?? []) {
70 placed.push({ node, depth, row })
71 row += 1
72 visit(node.id, depth + 1)
73 }
74 }
75 visit(undefined, 1)
76 const maxDepth = Math.max(1, ...placed.map(p => p.depth))
77 const labelOf = (node: MapNode) => `${node.n !== undefined ? `#${node.n} ` : ''}${node.label}`
78 // Wide enough for the longest label in the last column, so nothing is cut at the right edge.
79 const rightEdge = Math.max(LEFT + LABEL_DX + 9 * CHAR_PX, ...placed.map(p => LEFT + p.depth * COLUMN + LABEL_DX + [...labelOf(p.node)].length * CHAR_PX))
80 const width = rightEdge + 16
81 const height = TOP + (row + (hidden > 0 ? 1 : 0)) * ROW
82 const x = (depth: number) => LEFT + depth * COLUMN
83 const y = (r: number) => TOP + r * ROW
84 const at = new Map(placed.map(p => [p.node.id, p] as const))
85 const parts: string[] = []
86 parts.push(
87 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11">`,
88 )
89 // Edges leave a parent from its bottom and arrive at a child from its left, so none runs through a label.
90 parts.push(`<g stroke="${MAP_COLORS.text}" stroke-width="1.5" fill="none" stroke-opacity="0.6">`)
91 for (const p of placed) {
92 const from = p.node.parentId !== undefined ? at.get(p.node.parentId) : undefined
93 const x1 = from !== undefined ? x(from.depth) : x(0)
94 const y1 = (from !== undefined ? y(from.row) : y(0)) + 8
95 const x2 = x(p.depth) - 8
96 const y2 = y(p.row)
97 parts.push(`<path d="M${x1} ${y1} C${x1} ${y1 + 14}, ${x2 - 24} ${y2}, ${x2} ${y2}"/>`)
98 }
99 parts.push('</g>')
100 // The main loop's label sits above its node, out of the edges' way.
101 parts.push(`<circle cx="${x(0)}" cy="${y(0)}" r="8" fill="none" stroke="${MAP_COLORS.text}" stroke-width="2"/>`)
102 parts.push(`<text x="${x(0) - 8}" y="${y(0) - 13}" fill="${MAP_COLORS.text}">main loop</text>`)
103 for (const p of placed) {
104 const color = p.node.status === 'running' ? MAP_COLORS.running : p.node.status === 'failed' ? MAP_COLORS.failed : MAP_COLORS.done
105 // A running node wears a halo in place of a pulse: a still picture cannot move.
106 const halo = p.node.status === 'running' ? `<circle cx="${x(p.depth)}" cy="${y(p.row)}" r="11" fill="none" stroke="${color}" stroke-opacity="0.45" stroke-width="2"/>` : ''
107 parts.push(
108 `<g>${halo}<circle cx="${x(p.depth)}" cy="${y(p.row)}" r="7" fill="${color}"/>` +
109 `<text x="${x(p.depth) + LABEL_DX}" y="${y(p.row) + 4}" fill="${MAP_COLORS.text}">${esc(labelOf(p.node))}</text></g>`,
110 )
111 }
112 if (hidden > 0) parts.push(`<text x="${x(1)}" y="${y(row) + 4}" fill="${MAP_COLORS.text}">+${hidden} more</text>`)
113 parts.push('</svg>')
114 const running = nodes.filter(n => n.status === 'running').length
115 const failed = nodes.filter(n => n.status === 'failed').length
116 const done = nodes.length - running - failed
117 const tasks = placed.map(p => `#${p.node.n ?? '?'} ${p.node.title}${p.node.parentId !== undefined ? ` (from #${at.get(p.node.parentId)?.node.n ?? '?'})` : ''}: ${p.node.status}`).join('; ')
118 const alt = `Mission map: the main loop and ${nodes.length + hidden} subagents, ${running} running, ${done} done, ${failed} failed. ${tasks}`.slice(0, 900)
119 return { source: parts.join(''), alt, width, height }
120}
121types/index.d.ts 113 lines1// The values ant-farm keeps in $.state for the session. Plain JSON data:
2// the host holds them across a hot reload, and every drawing that reads one
3// is drawn again when it changes. Fields added after 0.1.1 are optional: a
4// value kept by an older version lacks them, and the code treats absent as empty.
5
6/** Tokens and estimated spend of one model loop (a subagent, the main loop, the rest). */
7export type AntFarmUsage = {
8 /** Model requests that came back with a response. */
9 requests: number
10 /** Every token the requests reported: input, output, cache reads and cache writes. */
11 tokens: number
12 /** Estimated US dollars for the tokens whose model is in the price table. */
13 costUsd: number
14 /** Tokens from models the price table does not know (their cost shows as "?"). */
15 unpricedTokens: number
16}
17
18export type AntFarmStatus = 'running' | 'done' | 'failed'
19
20/** One subagent of this session: one ant. */
21export type AntFarmAnt = {
22 /** The agent id `agent.spawn` answered; every loop event carries it. */
23 id: string
24 /** The Agent call's short description of the task. */
25 task: string
26 /** The agent type (`Explore`, `general-purpose`, a plugin's agent). */
27 type: string
28 /** The model the agent runs on, as the API last reported it. */
29 model: string
30 status: AntFarmStatus
31 /** `$.clock.now()` milliseconds. */
32 startedAt: number
33 endedAt?: number
34 usage: AntFarmUsage
35 /** When it last started or answered a request: an ant silent for long stops the clock. */
36 lastActiveAt?: number
37 /** Started by a workflow script: never listed by `$.agent.list()`. */
38 isWorkflow?: boolean
39 /** Its number in the mission, from 1 in order of start; the cards and the timeline cite it. */
40 n?: number
41 /** The id of the agent whose loop started it; absent when the main loop did. */
42 parentId?: string
43 /** Tokens of its last model requests, oldest first, at most 24: the card's sparkline. */
44 history?: number[]
45 /** Tool calls it made (every tool). */
46 tools?: number
47 /** Of those, calls that wrote a file (Edit, Write, MultiEdit, NotebookEdit). */
48 edits?: number
49 /** Tool calls that failed or were refused. */
50 errors?: number
51}
52
53/** Usage outside the subagents: the main loop, and loops no agent list names (engine forks). */
54export type AntFarmLoops = { main: AntFarmUsage; other: AntFarmUsage }
55
56/** The budget level last announced and the budget it was measured against. */
57export type AntFarmAlarm = { level: number; budgetUsd: number }
58
59/** The main loop's current turn. */
60export type AntFarmTurn = { isRunning: boolean; startedAt: number }
61
62export type AntFarmView = {
63 /** Draw the compact list inside the band: the pane was wanted but could not be placed. */
64 isListInBand: boolean
65 /** Finished subagents drawn as full cards instead of one line each. */
66 isFinishedExpanded: boolean
67 /** The person closed the pane by hand: do not open it again unasked this session. */
68 isClosedByHand: boolean
69 /** The pane already opened once unasked: it never does again this session. */
70 hasOpenedUnasked: boolean
71 /** The subagent whose inspector is open, by id; absent for none. */
72 selectedId?: string
73 /** The activity timeline shows its long form (40 rows) instead of 12. */
74 isTimelineExpanded?: boolean
75 /** The activity timeline is folded away. */
76 isTimelineHidden?: boolean
77 /** The mission map is folded away. */
78 isMapHidden?: boolean
79}
80
81/** What one row of the activity timeline records. */
82export type AntFarmEventKind = 'spawn' | 'handoff' | 'tool' | 'file' | 'done' | 'failed' | 'error' | 'warning'
83
84/** One row of the activity timeline: something a subagent did, or something that happened to it. */
85export type AntFarmEvent = {
86 /** `$.clock.now()` milliseconds. */
87 at: number
88 kind: AntFarmEventKind
89 /** The subagent it concerns; absent for a session-wide note (the budget alarm). */
90 agentId?: string
91 /** The row's words: a tool and what it touched, a task, a reason. Untrusted: cleaned when drawn. */
92 text: string
93 /** A second part drawn after the text: an error's first words, a refusal's reason. Untrusted. */
94 detail?: string
95}
96
97declare module 'claude-code' {
98 interface PluginState {
99 'ant-farm': {
100 ants: AntFarmAnt[]
101 loops: AntFarmLoops
102 turn: AntFarmTurn
103 /** The budget level announced (0 under, 1 past the budget, 2 past twice it); it only rises until the budget changes or /clear. */
104 alarm: AntFarmAlarm
105 /** Claude Code's own cost estimate for the session (what /cost shows, at list prices), or null where unavailable. */
106 engineUsd: number | null
107 view: AntFarmView
108 /** The activity timeline, oldest first, at most 80 rows; older rows leave. */
109 events: AntFarmEvent[]
110 }
111 }
112}
113