SLOPSHOPPER

ant-farm

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

newpanebandspinnerguardcommand
★ 1v0.3.5MITupdated 2026-10-13modmyclaude/claude-code-mods/mods/ant-farm
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ant-farm
│ ┃ Ant farm ✕ › fix the failing auth test and add an audit log call │ ┃ Standing by │ ┃ 0 running · 0 done · 0 failed ⏺ Read(src/auth.ts) │ ┃ Session ≈$0.42 (Claude Code) ⎿ Read 6 lines │ ┃ Subagents ≈$0.00 (price table) · 0 tokens · ⏺ Update(src/auth.ts) │ ┃ No budget set - add one in /config ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ██ z No subagents yet this session. E ⎿ 3 pass, 1 fail │ ┃ ▄██████▄ when it starts. │ ┃ ██▀▀██▀▀██ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ █▀▀████▀▀█ │ ┃ ▀██▀▀██▀ ✻ Worked for 42s · done 4:20 PM │ │ › /antfarm │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ ant-farm: ≈$0.42 this session (Claude Code's estimate)

Draws

Pane · Ant farm
Standing by 0 running · 0 done · 0 failed Session ≈$0.42 (Claude Code) Subagents ≈$0.00 (price table) · 0 tokens · 0s wall No budget set - add one in /config ██ z No subagents yet this session. Each one gets an ▄██████▄ when it starts. ██▀▀██▀▀██ █▀▀████▀▀█ ▀██▀▀██▀
README

<img src="../../brand/pixel-icons/svg/ant-farm.svg" width="64" height="64" alt="">

Ant Farm

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

Install

/plugin install ant-farm --marketplace modmyclaude/claude-code-mods

Use it

  • The band. While a turn runs, a slim line above the prompt shows the elapsed time, how many subagents are running and done, and the session's spend so far. It stays while background subagents still run. Other mods' band lines keep drawing, above it, so a long ant list never pushes their buttons out of view.
  • The pane is a mission control. /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.
  • The cards. One bordered card per running subagent, one to three across depending on the pane's width. Each shows its number and task, its ant, model and elapsed time, a status line (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.
  • The status is read, never guessed. 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.
  • The activity timeline. Under the cards, the last 80 things the subagents did, newest first on the mission clock: starts and handoffs (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.
  • The inspector. 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.
  • Keys. While the pane holds the keyboard (ctrl+x tab, or a click into it) a subagent's number 1 to 9 opens it, 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.
  • The ants. Every subagent gets a small pixel-art ant, one colour per model. It walks while its subagent works, stands still and dims when it is done, and lies on its back in the error colour if it failed.
  • ETA. Once a running subagent has made 3 model requests, its card shows an estimate of the time left ("~1m left"). It uses the subagent's own pace (time per request so far) and how many requests finished subagents of the same type needed this session. With nothing finished yet, it assumes the subagent is halfway.
  • Opens itself where it fits, once. When the first subagent starts on a surface that docks panes beside the transcript (the fullscreen terminal, the desktop app), the pane opens on its own, at most once a session and never after you closed it by hand. Under 144 columns the engine holds such a pane back, so the band lists the subagents instead (one line each, with a Hide button). /antfarm opens it any time, and asks the dock for room for two cards across.
  • Narrow panes. Under 40 columns the pane draws one line per subagent, no borders or art, and a shorter timeline, so nothing is lost.
  • Two estimates. The session's spend (the band, the pane's budget line, the footer mark, the status line and the alarm) is Claude Code's own estimate, the figure /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 ≈.
  • Budget alarm. Off until you set 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.

Settings

Every setting has a default, so the notice at install about options not yet set is expected; change them in /config.

SettingDefaultWhat it does
budgetUsd0 (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.

What it can touch

  • Files: none. Processes: none. Network: none.
  • Model calls: none. Everything is counted from the usage the engine reports for each model request; nothing is sent anywhere.
  • Engine calls: reads Claude Code's cost estimate for the session (after each model request and turn end), reads the session's agent list (to pick up a subagent that started before the mod loaded, to settle one that ended without saying so, and to see one the engine holds), opens and lists its own pane, sets its status line, shows toasts.
  • The two hooks that act: /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.
  • What it watches, without changing it: the chunks of each subagent's model requests (which kind streams: thinking, text, a tool named), each subagent's tool calls (the tool and what it names: a file's name, a command's first line, a pattern). The stream and the call pass through as the engine made them, and a permission decision is never read or touched by the mod; the main loop's own tool calls are not recorded. Anything shaped like a credential in those words (a token, a password= value, a key in a URL) is replaced with [REDACTED] before the pane keeps or shows it.
  • Memory: its counts live in session state and are gone when the session ends. Nothing is written to disk.

Limits

  • Per-subagent costs are estimates. They come from a small built-in table of Anthropic's published API list prices (cached 2026-10-06), marked with ≈. 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.
  • Claude Code's estimate is read after each model request and turn end, so the alarm can trip one request after the real crossing. A resumed session's estimate includes its earlier runs, as /cost does.
  • The fallback estimate counts only model requests of a turn. Side requests outside a turn (titles, safety checks) do not pass through turn.step, so when Claude Code gives no estimate the table's misses them.
  • The status line cannot be coloured. Claude Code's plugin status line takes text only, so the amber and red of the alarm are drawn on the budget mark beside the prompt footer, the band and the pane header; the status line itself says "over budget" or "past 2x the budget".
  • The ETA is a heuristic. Subagents do not announce how long they will take. Treat "~1m left" as a rough guide, most useful once a few subagents of the same type have finished.
  • The pane opens unasked only where it would be a sidebar. On the terminal's main screen (no fullscreen layout) use /antfarm.
  • The band and the footer mark draw on the terminal and the desktop app, the surfaces that have them. The pane draws wherever panes are placed.
  • A subagent that runs in a remote cloud session (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.
  • A background subagent stopped from outside may never report its end; the mod checks the engine's agent list about every 10 seconds and settles it from there. A running subagent that makes no request for 10 minutes (with no main turn running) stops the animation clock until it is heard from again.
  • An interrupted subagent shows as failed (red, on its back): Claude Code reports an interrupt as an aborted turn, not a success.
  • Caps: the pane lists 50 finished subagents one by one and counts the rest; past 200 subagents in a session the oldest finished ones leave the farm, and with them the subagents' header totals (Claude Code's session estimate is unaffected). The timeline keeps the last 80 rows; the long form shows 40.
  • The live status is what the engine said last. A subagent the mod only found mid-run reads 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.
  • Hotkeys act only while the pane holds the keyboard, as Claude Code keys every pane; from the prompt, click a card's inspect instead.
  • The mission map is a still picture, on the desktop app only. The terminal has no picture element, and an interactive drawing would be re-set on every tick and flash, so the map takes no clicks and redraws only when the mission changes; the cards and keys do the work.
  • The hover reveal draws where the surface tracks the pointer: the desktop app, and a terminal with mouse tracking. Elsewhere the cards are as before.
  • A click on the desktop app can be dropped while the pane is live. The app presses a button by the handle it was drawn with, and Claude Code hands out new handles on every redraw; a click that lands just after a redraw does nothing. The pane's own buttons are redrawn on every tick so this is rare (about one click in a minute of a busy run); click again, or use the keys. The terminal is not affected.
  • Task descriptions, agent types and model names are drawn with escape sequences removed and other control characters shown as U+FFFD.
  • Whether the pane may open on its own follows the surface that drew last; with a main-screen terminal and the desktop app both attached, that can be either.

Built from a prompt

This mod was built from the ant-farm prompt.

More mods: the full catalogue.

Source 11 files
hooks/register.tsx 661 lines
1// 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}
661
hooks/prices.ts 106 lines
1// 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}
106
hooks/cells.tsx 52 lines
1// 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
52
hooks/control.tsx 532 lines
1// 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}
532
hooks/redact.ts 63 lines
1// 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}
63
hooks/mission.ts 314 lines
1// 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
314
hooks/show.ts 281 lines
1// 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}
281
hooks/bolt-draw.tsx 86 lines
1// 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}
86
hooks/bolt.ts 109 lines
1// 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}
109
hooks/map.ts 121 lines
1// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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}
121
types/index.d.ts 113 lines
1// 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