SLOPSHOPPER

mission-control

A header with the workspace (folder, git branch), the main thread (model, context, cost, tokens, limits) and the subagents' totals, and a live panel of every…

paneguardcommandtoolprocessnetwork
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mission-control
│ ┃ mission-control ✕ › fix the failing auth test and add an audit log call │ ┃ Main thread │ ┃ Opus 5.5 ⏺ Read(src/auth.ts) │ ┃ Context 49% 97k / 200k · $0.00 · 0 tokens ·… ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Subagents · $0.00 · 0 tokens · 0:00 Collapse ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ No subagents yet ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /mission-control │ ⎿ mission-control: closed │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · mission-control
Main thread Opus 5.5 Context 49% 97k / 200k · $0.00 · 0 tokens · in 0 out 0 · 5h… Subagents · $0.00 · 0 tokens · 0:00 Collapse No subagents yet
README

mission-control

A Claude Code mod that shows your session and its subagents in a live side panel.

What it shows

  • Workspace: folder, parent path and git branch.
  • Main thread: model and effort, context used with a bar that turns from green to red, estimated cost, tokens sent and generated, and the 5-hour and 7-day limits.
  • Subagent totals: how many ran, how many are running or failed, tokens, cost and time.
  • One row per subagent, under "Running" or "Finished", indented under the agent that started it. Each row has a pixel crab, the title, model, effort, agent type, steps, cost, time, context and tokens.

Using the panel

  • The panel opens when a session starts. /mission-control opens and closes it.
  • Finished and stopped runs fold to one line. The ▸ arrow unfolds them.
  • The ■ button stops a running subagent once its current request or tool call ends. The agent that started it reads that it was stopped. The button exists only in the desktop app.
  • The terminal shows the same data as text.

Install

/plugin marketplace add davidbalivo/mission-control
/plugin install mission-control@mission-control

Then run /reload-plugins.

Cost is an estimate

Tokens from Claude Code × a price table. Price sources, first valid one wins:

  1. Official pricing page, fetched at session start: https://platform.claude.com/docs/en/about-claude/pricing.md.
  2. Last valid fetch, kept across sessions, if newer than the built-in table.
  3. Built-in table PRICES in hooks/register.tsx, copied from that page on 2026-10-17.

A fetch with any doubtful value: discarded whole. No credentials and no user data in the request.

Not priced: fast mode, 1-hour cache writes, US-only inference. No signal from Claude Code for them.

Step tool

Subagents report progress with the step tool, sending done and total. The panel draws it on the row as 3/5 and small blocks.

Credits

mission-control started as a fork of savvy-progress from johnnyvizz/claude-kit (MIT).

License

MIT. See LICENSE.

Source 2 files
hooks/register.tsx 1559 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, SessionUsage } from 'claude-code'
3
4import type { AgentRun, AgentStatus, Env, Limit, Main, Panel } from '../types'
5
6const agents = atom({ plugin: 'mission-control', key: 'agents' } as const, [])
7const panel = atom({ plugin: 'mission-control', key: 'panel' } as const, {
8  isCompact: false,
9  isDoneCollapsed: false,
10  expanded: [],
11})
12const now = atom({ plugin: 'mission-control', key: 'now' } as const, 0)
13// Where the session works (folder, its parent, git branch), for the header.
14const env = atom({ plugin: 'mission-control', key: 'env' } as const, { folder: '', path: '' } as Env)
15// The main thread's model, context and own usage, for its section.
16const main = atom({ plugin: 'mission-control', key: 'main' } as const, {
17  tokens: 0,
18  inputTokens: 0,
19  outputTokens: 0,
20  costUsd: 0,
21  limits: [],
22})
23
24const STEP_TOOL = 'mcp__mission-control__step'
25const PANE = 'mission-control'
26
27// ---------------------------------------------------------------------------
28// The panel's words.
29
30const S = {
31  pane: 'Mission Control',
32  repo: 'Repo',
33  mainThread: 'Main thread',
34  subagents: 'Subagents',
35  context: 'Context',
36  cost: 'Cost',
37  tokens: 'Tokens',
38  time: 'Time',
39  collapse: 'Collapse',
40  expand: 'Expand',
41  running: 'Running',
42  finished: 'Finished',
43  empty: 'No subagents yet',
44  round: 'round',
45  failed: 'error',
46  tokensWord: 'tokens',
47  isRunning: 'running',
48  isFinished: 'finished',
49  isFailed: 'failed',
50  isStopped: 'stopped',
51  stopping: 'stopping',
52  agent: 'agent',
53  agents: 'agents',
54  opened: 'opened',
55  closed: 'closed',
56  sub: 'sub',
57} as const
58
59const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
60
61const xml = (s: string): string =>
62  s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
63
64// Rough advance of system UI text, in em; good enough to size the title's slot.
65const charEm = (ch: string): number =>
66  /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Zmw@%]/.test(ch) ? 0.72 : 0.56
67
68const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
69
70// Cuts `s` to fit `maxW` pixels, with an ellipsis when it had to cut.
71const fitText = (s: string, size: number, maxW: number): string => {
72  if (textWidth(s, size) <= maxW) return s
73  let out = ''
74  for (const ch of s) {
75    if (textWidth(out + ch + '…', size) > maxW) break
76    out += ch
77  }
78  return out + '…'
79}
80
81// ---------------------------------------------------------------------------
82// Agents panel: every subagent of the session.
83
84// USD per million tokens: input, output, cache read, cache write (5-minute TTL).
85// The engine reports tokens, not money, so the panel's cost is an estimate.
86// Three sources, first valid one wins: the official pricing page, fetched at
87// session start; the last valid fetch, kept in `$.store`; the built-in PRICES.
88// PRICES: list prices from platform.claude.com/docs/en/about-claude/pricing,
89// checked 2026-10-17. First match wins, so specific versions go first.
90type Price = [number, number, number, number]
91const PRICES: [RegExp, Price][] = [
92  [/(fable|mythos)-5-1/, [10, 50, 0.25, 12.5]],
93  [/fable|mythos/, [10, 50, 1, 12.5]],
94  [/opus-5-5/, [4, 20, 0.2, 5]],
95  [/opus/, [5, 25, 0.5, 6.25]],
96  [/sonnet-5-5/, [2, 10, 0.1, 2.5]],
97  [/sonnet-5/, [2, 10, 0.2, 2.5]],
98  [/sonnet/, [3, 15, 0.3, 3.75]],
99  [/haiku-5-5/, [0.1, 0.5, 0.01, 0.125]],
100  [/haiku/, [1, 5, 0.1, 1.25]],
101]
102
103// Haiku 5.5 bills a request whose prompt (input + cache) passes 100k
104// tokens at these prices instead.
105const HAIKU_5_5_LONG: Price = [0.5, 2.5, 0.05, 0.625]
106const HAIKU_5_5_LIMIT = 100_000
107
108// --- prices from the official page. Its text goes through the parser and no
109// further: only numbers come out, never to the model, never into an SVG.
110
111const PRICE_URL = 'https://platform.claude.com/docs/en/about-claude/pricing.md'
112const PRICE_STORE_KEY = 'prices'
113// The page runs near 50 KB; anything far past that is not it.
114const PRICE_MAX_CHARS = 512_000
115// The page's table has far fewer rows; more is not it.
116const PRICE_MAX_ROWS = 200
117// Families the page must list for its table to count.
118const PRICE_FAMILIES = ['opus', 'sonnet', 'haiku'] as const
119// Models the page must list with their long-prompt tier, as PRICES knows them.
120const PRICE_TIERED = ['haiku-5-5'] as const
121// When PRICES was checked against the page (2026-10-17): a stored table
122// older than that loses to PRICES.
123const PRICES_CHECKED_AT = 1_792_195_200_000
124
125/** A prompt past `over` tokens bills at `price` instead (Haiku 5.5). */
126type LongPrice = { readonly over: number; readonly price: Price }
127/** One model's prices, keyed as `modelKey` names it: `opus-5-5`. */
128type PriceRow = { readonly key: string; readonly price: Price; readonly long?: LongPrice }
129type PriceTable = { readonly checkedAt: number; readonly rows: readonly PriceRow[] }
130
131// The table in use while the page or the store has given a valid one.
132let fetchedPrices: PriceTable | undefined
133
134const MODEL_ID = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i
135const KEY = /^(fable|mythos|opus|sonnet|haiku)-\d{1,2}(-\d{1,2})?$/
136
137// `claude-opus-5-5-20261001` and `claude-opus-5-5[1m]` → `opus-5-5`; the
138// alias `claude-opus-4-0` → `opus-4`, as the page names it.
139const modelKey = (model: string): string | undefined => {
140  const [, family, major, minor] = MODEL_ID.exec(model) ?? []
141  if (!family || !major) return undefined
142  return `${family.toLowerCase()}-${major}${minor && minor !== '0' ? `-${minor}` : ''}`
143}
144
145const isList = (v: unknown): v is readonly unknown[] => Array.isArray(v)
146const isFigure = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) && v >= 0.001 && v <= 1000
147
148// Each figure in range, and in the order every Claude price keeps:
149// cache read < input < output, cache write ≥ input.
150const isPrice = (p: unknown): p is Price => {
151  if (!isList(p) || p.length !== 4) return false
152  const [input, output, read, write] = p
153  return isFigure(input) && isFigure(output) && isFigure(read) && isFigure(write) && read < input && input < output && write >= input
154}
155
156// The whole table holds, or none of it is used.
157const isValidTable = (rows: readonly PriceRow[]): boolean => {
158  const keys = rows.map(r => r.key)
159  return (
160    new Set(keys).size === keys.length &&
161    PRICE_FAMILIES.every(f => keys.some(k => k.startsWith(`${f}-`))) &&
162    PRICE_TIERED.every(k => rows.some(r => r.key === k && r.long)) &&
163    rows.every(
164      r =>
165        KEY.test(r.key) &&
166        isPrice(r.price) &&
167        (r.long === undefined ||
168          (Number.isInteger(r.long.over) && r.long.over > 0 && isPrice(r.long.price) && r.long.price.every((v, i) => v >= (r.price[i] ?? 0)))),
169    )
170  )
171}
172
173// `$12.50 / MTok`, a footnote mark allowed: the number, or undefined.
174const moneyOf = (cell: string): number | undefined => {
175  const m = /^\$(\d{1,4}(?:\.\d{1,4})?) \/ MTok$/.exec(cell.replace(/<sup>\d{1,2}<\/sup>/g, '').trim())
176  return m?.[1] === undefined ? undefined : Number(m[1])
177}
178
179type PageRow = { readonly key: string; readonly price: Price; readonly tier?: { readonly side: 'up to' | 'over'; readonly at: number } }
180
181// `Claude Haiku 5.5 (for prompts over 100,000 tokens)` → its key and tier.
182// Rows of other shapes are skipped.
183const modelRowOf = (cell: string): Omit<PageRow, 'price'> | undefined => {
184  const text = cell.replace(/<sup>\d{1,2}<\/sup>/g, '').trim()
185  const m = /^Claude (Fable|Mythos|Opus|Sonnet|Haiku) (\d{1,2})(?:\.(\d{1,2}))?(?![\d.])(.*)$/.exec(text)
186  const [, family, major, minor, rest = ''] = m ?? []
187  if (!family || !major) return undefined
188  const key = `${family.toLowerCase()}-${major}${minor ? `-${minor}` : ''}`
189  const t = /\(for prompts (up to|over) (\d{1,3}(?:,\d{3})*) tokens\)/.exec(rest)
190  const side = t?.[1]
191  const at = Number((t?.[2] ?? '').replace(/,/g, ''))
192  if (side === 'up to' || side === 'over') return { key, tier: { side, at } }
193  return { key }
194}
195
196// The "Model pricing" table of the page as rows, by its header's names, not
197// its column order. Undefined when the table is missing or a price unreadable.
198const parsePricePage = (md: string): PriceRow[] | undefined => {
199  const lines = md.split('\n')
200  const start = lines.findIndex(l => l.trim() === '## Model pricing')
201  if (start < 0) return undefined
202  const after = lines.slice(start + 1)
203  const end = after.findIndex(l => l.startsWith('## '))
204  const section = end < 0 ? after : after.slice(0, end)
205  const first = section.findIndex(l => l.trim().startsWith('|'))
206  if (first < 0) return undefined
207  const rest = section.slice(first)
208  const stop = rest.findIndex(l => !l.trim().startsWith('|'))
209  const table = stop < 0 ? rest : rest.slice(0, stop)
210  const cells = (line: string): string[] => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim())
211
212  const [headLine, sepLine, ...body] = table
213  if (headLine === undefined || sepLine === undefined || !/^[\s|:-]+$/.test(sepLine)) return undefined
214  const head = cells(headLine)
215  const col = (re: RegExp): number => head.findIndex(h => re.test(h))
216  const cols = {
217    model: col(/^Model$/i),
218    input: col(/^Base input tokens$/i),
219    write: col(/^5m cache writes$/i),
220    read: col(/^Cache hits/i),
221    output: col(/^Output tokens$/i),
222  }
223  if (Object.values(cols).some(i => i < 0) || body.length > PRICE_MAX_ROWS) return undefined
224
225  const pageRows: PageRow[] = []
226  for (const line of body) {
227    const c = cells(line)
228    if (c.length !== head.length) return undefined
229    const model = modelRowOf(c[cols.model] ?? '')
230    if (!model) continue
231    const [input, output, read, write] = [cols.input, cols.output, cols.read, cols.write].map(i => moneyOf(c[i] ?? ''))
232    if (input === undefined || output === undefined || read === undefined || write === undefined) return undefined
233    pageRows.push({ ...model, price: [input, output, read, write] })
234  }
235
236  // A model in two tiers: the lower one is its price, the upper one its `long`.
237  const rows: PriceRow[] = []
238  for (const r of pageRows) {
239    if (!r.tier) {
240      rows.push({ key: r.key, price: r.price })
241      continue
242    }
243    if (r.tier.side === 'over') continue
244    const at = r.tier.at
245    const over = pageRows.find(o => o.key === r.key && o.tier?.side === 'over' && o.tier.at === at)
246    if (!over) return undefined
247    rows.push({ key: r.key, price: r.price, long: { over: at, price: over.price } })
248  }
249  // An upper tier with no lower one is a table it cannot read.
250  if (pageRows.some(o => o.tier?.side === 'over' && !rows.some(r => r.key === o.key && r.long))) return undefined
251  return rows
252}
253
254// The page's text, checked, as a table; undefined when anything is off.
255const tableOfPage = (text: string, checkedAt: number): PriceTable | undefined => {
256  if (text.length > PRICE_MAX_CHARS) return undefined
257  const rows = parsePricePage(text)
258  return rows && isValidTable(rows) ? { checkedAt, rows } : undefined
259}
260
261// What `$.store` gave back, checked again as the page's table is.
262const storedTable = (v: unknown): PriceTable | undefined => {
263  if (typeof v !== 'object' || v === null || !('checkedAt' in v) || !('rows' in v)) return undefined
264  const { checkedAt, rows } = v
265  if (typeof checkedAt !== 'number' || !Array.isArray(rows)) return undefined
266  const out: PriceRow[] = []
267  for (const r of rows) {
268    if (typeof r !== 'object' || r === null || !('key' in r) || !('price' in r)) return undefined
269    const { key, price } = r
270    if (typeof key !== 'string' || !isPrice(price)) return undefined
271    const long = 'long' in r ? r.long : undefined
272    if (long === undefined) {
273      out.push({ key, price })
274      continue
275    }
276    if (typeof long !== 'object' || long === null || !('over' in long) || !('price' in long)) return undefined
277    const { over, price: longPrice } = long
278    if (typeof over !== 'number' || !isPrice(longPrice)) return undefined
279    out.push({ key, price, long: { over, price: longPrice } })
280  }
281  return checkedAt >= PRICES_CHECKED_AT && isValidTable(out) ? { checkedAt, rows: out } : undefined
282}
283
284// The last valid fetch from the store, then the page itself. A store or a
285// fetch that fails, or does not check out, changes nothing. Never awaited by the panel.
286async function loadPrices($: EngineInterface): Promise<void> {
287  const stored = storedTable(await $.store.get(PRICE_STORE_KEY).catch(() => undefined))
288  if (stored) fetchedPrices = stored
289  const r = await $.http.fetch(PRICE_URL, { headers: { accept: 'text/markdown' } })
290  if (!r.ok || !(r.headers['content-type'] ?? '').startsWith('text/')) return
291  const table = tableOfPage(r.text, await $.clock.now())
292  if (!table) return
293  fetchedPrices = table
294  await $.store.set(PRICE_STORE_KEY, table)
295}
296
297type Usage = {
298  input_tokens: number
299  output_tokens: number
300  cache_read_input_tokens: number
301  cache_creation_input_tokens: number
302}
303
304// Everything the request sent, cached or not.
305const inputOf = (u: Usage): number =>
306  (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
307
308// The fetched table first; a model it does not list takes PRICES.
309const priceOf = (model: string, prompt = 0): Price => {
310  const key = modelKey(model)
311  const row = key === undefined ? undefined : fetchedPrices?.rows.find(r => r.key === key)
312  if (row) return row.long && prompt > row.long.over ? row.long.price : row.price
313  const id = model.toLowerCase()
314  if (/haiku-5-5/.test(id) && prompt > HAIKU_5_5_LIMIT) return HAIKU_5_5_LONG
315  return PRICES.find(([re]) => re.test(id))?.[1] ?? [4, 20, 0.2, 5]
316}
317
318const costOf = (model: string, u: Usage): number => {
319  const [i, o, r, w] = priceOf(model, inputOf(u))
320  return (
321    ((u.input_tokens || 0) * i +
322      (u.output_tokens || 0) * o +
323      (u.cache_read_input_tokens || 0) * r +
324      (u.cache_creation_input_tokens || 0) * w) /
325    1e6
326  )
327}
328
329// Haiku 5.5 has the 1M window; older Haikus keep 200k.
330const windowOf = (model: string): number => (/haiku/i.test(model) && !/haiku-5-5/i.test(model) ? 200_000 : 1_000_000)
331
332const modelName = (id: string): string => {
333  const m = MODEL_ID.exec(id)
334  const [, family = '', major = '', minor] = m ?? []
335  if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
336  return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
337}
338
339const norm = (s: string): string => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
340
341const fmtTokens = (n: number): string =>
342  n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
343
344const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
345
346const fmtTime = (ms: number): string => {
347  const s = Math.max(0, Math.round(ms / 1000))
348  const h = Math.floor(s / 3600)
349  const m = Math.floor((s % 3600) / 60)
350  const ss = String(s % 60).padStart(2, '0')
351  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
352}
353
354const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
355
356const totals = (list: AgentRun[], at: number) => {
357  const cost = list.reduce((s, a) => s + a.costUsd, 0)
358  const tokens = list.reduce((s, a) => s + a.tokens, 0)
359  const start = Math.min(...list.map(a => a.startedAt))
360  const end = Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt)))
361  return { cost, tokens, time: list.length ? end - start : 0 }
362}
363
364// ---------------------------------------------------------------------------
365// The tree of spawns. `agent.spawn` records the loop each run was
366// spawned from; a run whose parent is not in the list (main thread, trimmed)
367// is a root. A family is a root and all its descendants.
368
369const MAX_DEPTH = 3
370const INDENT = 18
371const RAIL_OFFSET = 10
372
373type TreeRow = {
374  a: AgentRun
375  depth: number
376  /** rails[level]: the ancestor at that depth has later siblings, so its line runs on. */
377  rails: boolean[]
378  isLast: boolean
379  isGroupEnd: boolean
380  /** Every descendant, not just direct children. */
381  kids: number
382}
383
384type Family = { rows: TreeRow[]; isRunning: boolean }
385
386const familiesOf = (list: AgentRun[]): Family[] => {
387  const byAgentId = new Map(list.filter(a => a.agentId).map(a => [a.agentId as string, a]))
388  const parentOf = (a: AgentRun): AgentRun | undefined => {
389    const p = a.parentAgentId ? byAgentId.get(a.parentAgentId) : undefined
390    return p && p.id !== a.id ? p : undefined
391  }
392  const children = new Map<string, AgentRun[]>()
393  const roots: AgentRun[] = []
394  for (const a of list) {
395    const p = parentOf(a)
396    if (!p) roots.push(a)
397    else children.set(p.id, [...(children.get(p.id) ?? []), a])
398  }
399  const seen = new Set<string>()
400  const walk = (a: AgentRun, depth: number, rails: boolean[], isLast: boolean, out: TreeRow[]): void => {
401    if (seen.has(a.id)) return
402    seen.add(a.id)
403    const at = out.length
404    out.push({ a, depth, rails, isLast, isGroupEnd: false, kids: 0 })
405    const kids = children.get(a.id) ?? []
406    // Below this run, its own rail runs on while it has later siblings.
407    const next = rails.slice()
408    next[depth] = depth > 0 && !isLast
409    kids.forEach((c, i) => walk(c, depth + 1, next, i === kids.length - 1, out))
410    const row = out[at]
411    if (row) row.kids = out.length - at - 1
412  }
413  // A parent loop that never reaches a root (it should not happen) still shows: its runs become roots.
414  const families = (rs: AgentRun[]): Family[] =>
415    rs.flatMap(r => {
416      if (seen.has(r.id)) return []
417      const rows: TreeRow[] = []
418      walk(r, 0, [], true, rows)
419      const last = rows[rows.length - 1]
420      if (last) last.isGroupEnd = true
421      return [{ rows, isRunning: rows.some(x => x.a.status === 'running') }]
422    })
423  const out = families(roots)
424  return [...out, ...families(list.filter(a => !seen.has(a.id)))]
425}
426
427// --- desktop drawings: each row is one SVG.
428
429// Palette from the redesign research (Few, Carbon, Okabe-Ito, WCAG):
430// grey text in three steps, one hue per state, every color with a dark variant.
431// Text passes 4.5:1 and marks 3:1 on #FFFFFF / #1F1F1E. `.cR` running, `.cF`
432// failed, `.cG` `.cW` `.cO` `.cF` the header's context by level (green, amber,
433// orange, red), `.bf` an agent's body (`--b`), `.card` the usage card's soft fill.
434// Metrics: `.mvC` cost in a muted teal, the only colored figure on a row; time,
435// context and tokens in the grey `.ml`; the words `ctx`, `in`, `out` and the
436// agent type lighter still, `.mk`
437// (about 2.6:1, below 4.5:1 on purpose: they name columns the values explain).
438const BODY_COLORS: [string, string][] = [
439  ['#C96442', '#D97757'],
440  ['#0072B2', '#56B4E9'],
441  ['#009E73', '#2BBE8E'],
442  ['#B8609A', '#CC79A7'],
443  ['#B97A00', '#E69F00'],
444  ['#2A86BD', '#7CC4EE'],
445  ['#7F7A00', '#C9C24A'],
446  ['#66717D', '#9AA4AF'],
447]
448const bodyCss = (i: 0 | 1): string => BODY_COLORS.map((c, n) => `.b${n}{--b:${c[i]}}`).join('')
449const PANE_CSS = `<style>
450.t{fill:#1F1F1E}.s{fill:#55544F}.m{fill:#73726D}.k{fill:#E6E5E1}.ln{stroke:#ECEBE7}.tile{fill:#F4F3F0}.card{fill:#F5F4F1}.tr{stroke:#D3D2CD;stroke-width:1.2;fill:none;stroke-linecap:round}
451.mvC{fill:#2B6A62}.ml{fill:#73726D}.mk{fill:#A3A29C}
452.cR{fill:#B5532F}.cF{fill:#C42B2B}.cW{fill:#9A5B00}.cG{fill:#2E7D52}.cO{fill:#B34E00}.sF{stroke:#C42B2B}.sM{stroke:#73726D}.bf{fill:var(--b)}${bodyCss(0)}
453@media (prefers-color-scheme: dark){.t{fill:#ECEBE8}.s{fill:#BDBCB6}.m{fill:#97968F}.k{fill:#3A3A37}.ln{stroke:#2E2E2C}.tile{fill:#262625}.card{fill:#2A2A28}.tr{stroke:#4A4A47}
454.mvC{fill:#8FC8BF}.ml{fill:#97968F}.mk{fill:#6C6B66}
455.cR{fill:#E08A6B}.cF{fill:#F2726B}.cW{fill:#E8A33D}.cG{fill:#5DBE8A}.cO{fill:#F08A4B}.sF{stroke:#F2726B}.sM{stroke:#97968F}${bodyCss(1)}}
456</style>`
457
458// Pixel Clawd from DockCrab (Clawdy): a 24×18 crab on a 30×28 grid. Explore
459// runs wear a pirate costume; every other run is the bare crab.
460const CLAY = '#D97757'
461// Each agent run gets its own body color, in launch order, cycling.
462// The terminal takes the light variant; an SVG takes `BODY` and the row's `.bN` class.
463const bodyIndexOf = (list: AgentRun[], a: AgentRun): number => {
464  const i = list.findIndex(x => x.id === a.id)
465  return ((i % BODY_COLORS.length) + BODY_COLORS.length) % BODY_COLORS.length
466}
467const bodyOf = (list: AgentRun[], a: AgentRun): string => BODY_COLORS[bodyIndexOf(list, a)]?.[0] ?? CLAY
468const BODY = 'var(--b)'
469const INK = '#1F1E1D'
470
471// `cls` puts a pixel in a named group: `bd` (the default) is the body and its
472// costume, `la`/`lb` the leg pairs, anything else a prop with its own motion.
473type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
474type Costume = 'explore' | 'other'
475
476const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
477  rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
478
479// `armCls` lets a raised claw travel with the prop it holds.
480const crabBody = (f: Fill, body: string, armFront = 0, armCls?: string): void => {
481  f(7, 10, 16, 12, body)
482  f(3, 14, 4, 4, body)
483  f(23, 14 + armFront, 4, 4, body, armCls)
484  f(9, 12, 2, 2, INK)
485  f(19, 12, 2, 2, INK)
486  f(7, 22, 2, 4, body, 'la')
487  f(17, 22, 2, 4, body, 'la')
488  f(11, 22, 2, 4, body, 'lb')
489  f(21, 22, 2, 4, body, 'lb')
490}
491
492// Pure CSS, run by the compositor: no redraws. Periods divide one second, so the
493// once-a-second redraw of a running row restarts them in phase. Every crab walks;
494// a costume adds its prop's own motion on top.
495const CRAB_CSS = `<style>
496.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
497.run .bd{animation:bob .5s steps(1) infinite -.125s}
498.run g{transform-box:fill-box}
499@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
500.c-explore.run .it{transform-origin:50% 100%;animation:fence .5s ease-in-out infinite}
501@keyframes fence{50%{transform:rotate(25deg)}}
502@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}
503</style>`
504
505const COSTUMES: Record<Costume, (f: Fill) => void> = {
506  // Explore: pirate scouting the code; the cutlass fences.
507  explore: f => {
508    crabBody(f, BODY)
509    stamp(f, 5, 3, ['.kk..............kk.', '.kkk....kkkk....kkk.', '..kkkkkkkwwkkkkkkk..', '..kkkkkkkkkkkkkkkk..', '.gggggggggggggggggg.'], { k: '#55514C', w: '#F8F6F1', g: '#F5C542' })
510    f(7, 11, 11, 1, INK); f(18, 11, 4, 3, INK)
511    f(27, 6, 1, 9, '#C9CCD2', 'it'); f(26, 15, 3, 1, '#7A4A26', 'it')
512  },
513  other: f => crabBody(f, BODY),
514}
515
516const costumeOf = (type: string): Costume => (type === 'Explore' ? 'explore' : 'other')
517
518// Body and props nest inside `bd` so a prop rides the bob and adds its own motion;
519// legs stay outside it and step on their own.
520const crab = (x: number, y: number, costume: Costume, isWalking: boolean, scale: number): string => {
521  const groups = new Map<string, string[]>([['bd', []]])
522  const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
523    if (!groups.has(cls)) groups.set(cls, [])
524    const paint = c === BODY ? 'class="bf"' : `fill="${c}"`
525    groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" ${paint}/>`)
526  }
527  COSTUMES[costume](f)
528  const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
529  const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
530  const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
531  return `<g transform="translate(${x},${y}) scale(${scale})" shape-rendering="crispEdges"><g class="c-${costume}${isWalking ? ' run' : ''}">${body}${group('la')}${group('lb')}</g></g>`
532}
533
534// A run still working shows its stop button in the slot, a finished or
535// stopped one its fold arrow; a failed one draws a red cross there.
536const failedMark = (x: number, y: number): string =>
537  `<path class="sF" d="M${x - 4} ${y - 4}l8 8M${x + 4} ${y - 4}l-8 8" stroke-width="1.8" stroke-linecap="round"/>`
538
539// Runs that fold to one line: those that ended well, and those stopped from the panel.
540const isFoldable = (a: AgentRun): boolean => a.status === 'done' || a.status === 'stopped'
541
542// Runs whose slot holds a Button beside the drawing: the stop, or the fold arrow.
543const hasSlotButton = (a: AgentRun): boolean => a.status === 'running' || isFoldable(a)
544
545// What a run says it is, for a screen reader.
546const stateOf = (a: AgentRun): string =>
547  a.status === 'running' ? (a.isStopRequested ? S.stopping : S.isRunning) : a.status === 'stopped' ? S.isStopped : a.status === 'failed' ? S.isFailed : S.isFinished
548
549const svg = (W: number, H: number, body: string): string =>
550  `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PANE_CSS}${CRAB_CSS}${body}</svg>`
551
552// The pane's own title says "Mission Control". The header has two bands. Above, the
553// folder, its parent path and the git branch in a pill; below, context, tokens,
554// cost and time, each value under its label, the last three on the agent rows'
555// metric columns. Only the cost and the context carry color and weight.
556const MONO = "ui-monospace,'SF Mono',SFMono-Regular,Menlo,monospace"
557const monoWidth = (s: string, size: number): number => [...s].length * 0.6 * size
558
559// Cuts the middle out of `s` to fit `maxW`, keeping its start and its end.
560const fitMiddle = (s: string, size: number, maxW: number): string => {
561  if (textWidth(s, size) <= maxW) return s
562  const chars = [...s]
563  let head = Math.ceil(chars.length / 2)
564  let tail = Math.floor(chars.length / 2)
565  while (head + tail > 1 && textWidth(`${chars.slice(0, head).join('')}…${chars.slice(chars.length - tail).join('')}`, size) > maxW) {
566    if (head > tail) head--
567    else tail--
568  }
569  return `${chars.slice(0, head).join('')}…${chars.slice(chars.length - tail).join('')}`
570}
571
572const BRANCH_ICON = (x: number, y: number): string =>
573  `<g transform="translate(${x},${y})" class="sM" fill="none" stroke-width="1.2" stroke-linecap="round"><circle cx="2" cy="2" r="1.5"/><circle cx="2" cy="10" r="1.5"/><circle cx="8" cy="4" r="1.5"/><path d="M2 3.5v5M8 5.5c0 2.5-6 2-6 3"/></g>`
574
575// The main thread's context figure and bar change color as it fills up:
576// green below 40%, amber below 50%, orange below 60%, red from there.
577const ctxLevel = (pct: number): string => (pct < 40 ? 'cG' : pct < 50 ? 'cW' : pct < 60 ? 'cO' : 'cF')
578
579// A rate-limit window grey until 80%, orange until 95%, red from there.
580const limitLevel = (pct: number): string => (pct < 80 ? 'ml' : pct < 95 ? 'cO' : 'cF')
581
582const LIMIT_LABEL: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
583
584// The windows the section shows, in a fixed order, at most two.
585const limitsOf = (list: readonly Limit[]): { label: string; pct: number }[] =>
586  Object.entries(LIMIT_LABEL)
587    .flatMap(([kind, label]) => {
588      const l = list.find(x => x.kind === kind)
589      return l ? [{ label, pct: Math.round(l.pct) }] : []
590    })
591    .slice(0, 2)
592
593// A window size reads `1M`, not `1.0M`.
594const fmtWindow = (n: number): string => (n >= 1e6 && n % 1e6 === 0 ? `${n / 1e6}M` : fmtTokens(n))
595
596const svgText = (x: number, y: number, size: number, cls: string, body: string, extra = ''): string =>
597  `<text class="${cls}" x="${x}" y="${y}" font-family="${FONT}" font-size="${size}" font-variant-numeric="tabular-nums"${extra}>${body}</text>`
598const END = ' text-anchor="end"'
599const BOLD = ' font-weight="600"'
600// Bold runs ~6% wider than the estimate.
601const boldWidth = (s: string, size: number): number => textWidth(s, size) * HEADER_BOLD
602
603// A section's title: 10 px capitals, tracked, bold, in the `.m` grey, so it
604// reads apart from the 11 px column labels.
605const TITLE_TRACKING = 0.8
606const sectionTitle = (x: number, y: number, title: string): string =>
607  svgText(x, y, 10, 'm', xml(title.toUpperCase()), `${BOLD} letter-spacing="${TITLE_TRACKING}"`)
608const titleWidth = (title: string): number => boldWidth(title.toUpperCase(), 10) + TITLE_TRACKING * title.length
609
610// One figure of a row: its width and its markup, drawn at a given x.
611type Figure = { w: number; body: string }
612
613// A grey figure with its word in the lighter `ctx` grey, before or after it.
614const labelled = (value: string, word: string, side: 'before' | 'after' = 'before', size = SIZE): Figure => ({
615  w: textWidth(`${word} ${value}`, size),
616  body:
617    side === 'before'
618      ? `<tspan class="mk">${xml(word)}</tspan> <tspan class="ml">${xml(value)}</tspan>`
619      : `<tspan class="ml">${xml(value)}</tspan> <tspan class="mk">${xml(word)}</tspan>`,
620})
621
622// Figures in a row from x = 0, FIGURE_GAP apart. A group that does not fit in
623// `maxW` is left out whole, and so are the ones after it.
624const FIGURE_GAP = 20
625const spaced = (groups: Figure[][], maxW: number, y: number, size = SIZE): string => {
626  const out: string[] = []
627  let x = 0
628  for (const group of groups) {
629    const gaps = out.length ? group.length : group.length - 1
630    const w = group.reduce((sum, f) => sum + f.w, 0) + FIGURE_GAP * gaps
631    if (x + w > maxW) break
632    for (const f of group) {
633      if (out.length) x += FIGURE_GAP
634      out.push(svgText(Math.round(x), y, size, 'ml', f.body))
635      x += f.w
636    }
637  }
638  return out.join('')
639}
640
641// The header: the repo on top, the session's usage in one card below.
642// Top line: the folder, its parent path and the git branch in a pill, with no
643// title. Below it a soft card holds the usage in two sections, Main thread and
644// Subagents, each under its title; the card starts in this SVG and ends in the
645// subagents' one, stacked right under it. Right of the time column the rows
646// keep room for their status mark and fold arrow (ARROW_W + ROW_GAP); the card
647// takes that room as its right margin outside plus PAD inside, and mirrors both
648// on the left, so the right side keeps the agent rows' columns.
649// Main thread: two rows mirrored around the context bar. On the left the model
650// over the cost and tokens; on the right two columns that share the agent rows'
651// cost and time edges: the context percentage over one limit, the context
652// tokens over the other. Everything ends on the time column.
653const PAD = 12
654// A function, not a constant: ARROW_W and ROW_GAP are declared further down.
655const cardInset = (): number => ARROW_W + ROW_GAP - PAD
656const CARD_R = 8
657const TITLE_GAP = 25
658const SECTION_GAP = 44
659const Y_ID = 22
660const CARD_TOP = 40
661// Air inside the card above its first title and below its last figures,
662// and below the card before the agent list.
663const CARD_PAD_TOP = 32
664const CARD_PAD_BOTTOM = 24
665const CARD_GAP_BELOW = 16
666const Y_TITLE = CARD_TOP + CARD_PAD_TOP
667const Y_ROW1 = Y_TITLE + TITLE_GAP
668const BAR_Y = Y_ROW1 + 9
669const BAR_H = 4
670const Y_ROW2 = BAR_Y + BAR_H + 9 + 9
671const HEADER_H = Y_ROW2 + 12
672const SIZE = 12
673const SIZE_KEY = 13
674
675const headerSvg = (W: number, where: Env, m: Main): string => {
676  const [, c2, c3] = rowEdges(W)
677  const cx = cardInset()
678  // The card's right edge, cx from the panel's, as on the left.
679  const cardW = W - cx
680  const x0 = cx + PAD
681
682  // Repo: folder, then its parent path, then the branch pill ending on the time column.
683  // The folder starts at x0, on the card's titles.
684  let pill = ''
685  let pillX = c3
686  if (where.branch) {
687    const maxText = Math.max(30, W * 0.4 - 26)
688    let branch = where.branch
689    while (branch.length > 2 && monoWidth(branch, 11) > maxText) branch = branch.slice(0, -2) + '…'
690    const pw = Math.round(26 + monoWidth(branch, 11))
691    pillX = c3 - pw
692    pill =
693      `<rect class="tile" x="${pillX}" y="${Y_ID - 11}" width="${pw}" height="18" rx="6"/>${BRANCH_ICON(pillX + 6, Y_ID - 8)}` +
694      `<text class="s" x="${pillX + 20}" y="${Y_ID + 2}" font-family="${MONO}" font-size="11">${xml(branch)}</text>`
695  }
696  const folder = fitText(where.folder || '—', 14, Math.max(40, pillX - x0 - 12 - 60))
697  const pathX = Math.round(x0 + textWidth(folder, 14) * 1.04 + 6)
698  const pathW = pillX - 12 - pathX
699  const path = where.path && pathW > 30 ? svgText(pathX, Y_ID, 11, 'm', xml(fitMiddle(where.path, 11, pathW))) : ''
700
701  // Main thread. Both left lines stop at the same edge, clear of the cost column's figures.
702  const leftW = Math.max(0, c2 - x0 - Math.max(boldWidth('100%', SIZE_KEY), textWidth('spend 100%', SIZE)) - 16)
703  // The model in the row's grey, its effort in the lighter one; the effort goes first when room runs out.
704  const name = m.model ? modelName(m.model) : '—'
705  const effort = m.model && m.effort ? ` · ${m.effort}` : ''
706  const model =
707    effort && textWidth(name + effort, SIZE) <= leftW
708      ? `${xml(name)}<tspan class="mk">${xml(effort)}</tspan>`
709      : xml(fitText(name, SIZE, leftW))
710  const pct = m.ctxPct ?? 0
711  const pctClass = ctxLevel(pct)
712  const ctxTokens = m.ctxWindow
713    ? `<tspan class="ml">${xml(fmtTokens(m.ctxTokens ?? 0))}</tspan><tspan class="mk"> / ${xml(fmtWindow(m.ctxWindow))}</tspan>`
714    : ''
715  const barW = c3 - x0
716  const fill = Math.round((barW * Math.min(100, pct)) / 100)
717
718  // Row 2, left: the cost, then the tokens, then sent and generated; a group
719  // that does not fit is left out whole, the last first.
720  const cost = fmtCost(m.costUsd)
721  const figures = spaced(
722    [
723      [{ w: boldWidth(cost, SIZE_KEY), body: `<tspan class="mvC" font-size="${SIZE_KEY}" font-weight="600">${xml(cost)}</tspan>` }],
724      [labelled(fmtTokens(m.tokens), S.tokensWord, 'after')],
725      [labelled(fmtTokens(m.inputTokens), 'in'), labelled(fmtTokens(m.outputTokens), 'out')],
726    ],
727    leftW,
728    Y_ROW2,
729  )
730
731  // Row 2, right: the limits on the time column, then the cost column.
732  const limits = limitsOf(m.limits)
733    .reverse()
734    .map((l, i) =>
735      svgText(i === 0 ? c3 : c2, Y_ROW2, SIZE, 'ml', `<tspan class="mk">${l.label}</tspan> <tspan class="${limitLevel(l.pct)}">${l.pct}%</tspan>`, END),
736    )
737    .join('')
738
739  return svg(
740    W,
741    HEADER_H,
742    `${svgText(x0, Y_ID, 14, 'ml', xml(folder), BOLD)}${path}${pill}
743<path class="card" d="M${cx} ${CARD_TOP + CARD_R}a${CARD_R} ${CARD_R} 0 0 1 ${CARD_R} -${CARD_R}H${cardW - CARD_R}a${CARD_R} ${CARD_R} 0 0 1 ${CARD_R} ${CARD_R}V${HEADER_H}H${cx}Z"/>
744${sectionTitle(x0, Y_TITLE, S.mainThread)}
745${svgText(x0, Y_ROW1, SIZE, 's', model)}
746${svgText(c2, Y_ROW1, SIZE_KEY, pctClass, `${pct}%`, END + BOLD)}${ctxTokens ? svgText(c3, Y_ROW1, SIZE, 'ml', ctxTokens, END) : ''}
747<rect class="k" x="${x0}" y="${BAR_Y}" width="${barW}" height="${BAR_H}" rx="${BAR_H / 2}"/><rect class="${pctClass}" x="${x0}" y="${BAR_Y}" width="${fill}" height="${BAR_H}" rx="${BAR_H / 2}"/>
748<g transform="translate(${x0},0)">${figures}</g>
749${limits}`,
750  )
751}
752
753// The subagents' sums, the card's second section: the title SECTION_GAP below
754// the main thread's last row, with the column labels on its line while 24 px
755// stay between them, else LABELS_DROP lower; the values TITLE_GAP below the
756// labels, on the agent rows' columns; then the card's rounded bottom. Left of
757// the values, how many runs there are, then how many still work and how many
758// failed. Figures take the main thread's sizes: SIZE, and SIZE_KEY for the cost.
759const SUB_TITLE = Y_ROW2 + SECTION_GAP - HEADER_H
760const LABELS_DROP = 19
761const labelsDrop = (W: number): number => {
762  const [c1] = rowEdges(W)
763  return cardInset() + PAD + titleWidth(S.subagents) + 24 <= c1 - textWidth(S.tokens, 11) ? 0 : LABELS_DROP
764}
765const cardBottom = (W: number): number => SUB_TITLE + labelsDrop(W) + TITLE_GAP + CARD_PAD_BOTTOM
766const subagentsHeight = (W: number): number => cardBottom(W) + CARD_GAP_BELOW
767const subagentsSvg = (W: number, t: ReturnType<typeof totals>, list: readonly AgentRun[]): string => {
768  const [c1, c2, c3] = rowEdges(W)
769  const H = subagentsHeight(W)
770  const bottom = cardBottom(W)
771  const yLab = SUB_TITLE + labelsDrop(W)
772  const yVal = yLab + TITLE_GAP
773  const cx = cardInset()
774  // The card's right edge, cx from the panel's, as on the left.
775  const cardW = W - cx
776  const x0 = cx + PAD
777  const running = list.filter(a => a.status === 'running').length
778  const failed = list.filter(a => a.status === 'failed').length
779  const tokens = fmtTokens(t.tokens)
780  // A failed count keeps the red of the failed mark.
781  const failedFigure: Figure = {
782    w: textWidth(`${failed} ${S.isFailed}`, SIZE),
783    body: `<tspan class="cF">${failed}</tspan> <tspan class="mk">${S.isFailed}</tspan>`,
784  }
785  const counts = spaced(
786    [
787      [labelled(String(list.length), list.length === 1 ? S.agent : S.agents, 'after')],
788      running ? [labelled(String(running), S.isRunning, 'after')] : [],
789      failed ? [failedFigure] : [],
790    ].filter(g => g.length),
791    c1 - x0 - textWidth(tokens, SIZE) - 16,
792    yVal,
793  )
794  return svg(
795    W,
796    H,
797    `<path class="card" d="M${cx} 0H${cardW}V${bottom - CARD_R}a${CARD_R} ${CARD_R} 0 0 1 -${CARD_R} ${CARD_R}H${cx + CARD_R}a${CARD_R} ${CARD_R} 0 0 1 -${CARD_R} -${CARD_R}Z"/>
798${sectionTitle(x0, SUB_TITLE, S.subagents)}
799${svgText(c1, yLab, 11, 'mk', S.tokens, END)}${svgText(c2, yLab, 11, 'mk', S.cost, END)}${svgText(c3, yLab, 11, 'mk', S.time, END)}
800<g transform="translate(${x0},0)">${counts}</g>${svgText(c1, yVal, SIZE, 'ml', xml(tokens), END)}${svgText(c2, yVal, SIZE_KEY, 'mvC', xml(fmtCost(t.cost)), END + BOLD)}${svgText(c3, yVal, SIZE, 'ml', xml(fmtTime(t.time)), END)}`,
801  )
802}
803
804// A line of the list in 11 px and the `.mk` grey of `ctx`, `in` and `out`:
805// "No subagents yet", and the "Running · n" and "Finished · n" labels. Drawn,
806// not a dim Text, so the grey is ours and not the surface's.
807const NOTE_H = 18
808const noteSvg = (W: number, text: string): string =>
809  svg(W, NOTE_H, `<text class="mk" x="0" y="13" font-family="${FONT}" font-size="11">${xml(text)}</text>`)
810
811// The task's own progress when the worker reports steps; a finished run is full.
812const progressOf = (a: AgentRun): number | null => {
813  if (a.status === 'done') return 1
814  if (a.stepTotal) return Math.min(1, (a.stepDone ?? 0) / a.stepTotal)
815  return null
816}
817
818const ctxOf = (a: AgentRun): number => (a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0)
819
820// Rows are taller and airier. A run that ended well or was stopped folds to
821// one line on its own; its arrow unfolds it. Failed and running runs always
822// show in full.
823const isFolded = (a: AgentRun, p: Panel): boolean => isFoldable(a) && !(p.expanded ?? []).includes(a.id)
824
825// Height, gap between text lines, crab scale and where the text starts. Text
826// blocks and the crab sit centred in the row.
827type RowGeom = { H: number; gap: number; scale: number; textX: number }
828const FULL_ROOT: RowGeom = { H: 76, gap: 19, scale: 0.9, textX: 38 }
829const FULL_SUB: RowGeom = { H: 64, gap: 17, scale: 0.7, textX: 30 }
830const FOLD_ROOT: RowGeom = { H: 38, gap: 0, scale: 0.6, textX: 26 }
831const FOLD_SUB: RowGeom = { H: 32, gap: 0, scale: 0.5, textX: 22 }
832const geomOf = (r: TreeRow, isFold: boolean): RowGeom =>
833  r.depth > 0 ? (isFold ? FOLD_SUB : FULL_SUB) : isFold ? FOLD_ROOT : FULL_ROOT
834
835const rowHeight = (r: TreeRow, isFold = false): number => geomOf(r, isFold).H
836
837// Baselines of `n` lines `gap` apart, centred on the row (+4: half a cap height).
838const baselines = (H: number, n: number, gap: number): number[] =>
839  Array.from({ length: n }, (_, k) => Math.round(H / 2 + 4 - ((n - 1) * gap) / 2 + k * gap))
840
841// The crab's body spans rows 10 to 26 of its 28-row grid: centre that.
842const crabTop = (H: number, sc: number): number => Math.round(H / 2 - 18 * sc)
843
844// The metrics form a borderless table of three right-aligned columns, the same
845// widths on every row, so cost, time and tokens line up down the whole list.
846const COL_GAP = 20
847// The slot right of each desktop row's time column: a finished run's fold
848// arrow, or the status mark of a run still working or failed. ROW_GAP keeps it
849// clear of the time column.
850const ARROW_W = 16
851const ROW_GAP = 4
852// The metric columns of the rows, and of the header above them, from the pane's width.
853const rowEdges = (W: number): [number, number, number] => metricEdges(W - ARROW_W - ROW_GAP)
854// A foldable row lights while hovered. A translucent grey reads on
855// both themes; no theme key paints a background.
856const ROW_HOVER = 'rgba(128,128,128,0.08)'
857// The slot Buttons keep their resting look under the pointer: dim, no
858// background. The desktop builds the pane's page nodes anew on every drawing
859// (once a second while runs work), so a Button under the pointer lost its
860// hover look and faded back in: a flash. With the same look either way, a new
861// drawing has nothing to fade. Needs the row's keyed Box.
862const SLOT_HOVER = { dimColor: true, backgroundColor: 'rgba(0,0,0,0)' } as const
863// The stop's slot lights while hovered, on the keyed Box around its Button. A
864// Box draws as a page node with no fade, so its hover comes back at once on a
865// new drawing; the Button's eases in, hence SLOT_HOVER. Darker than ROW_HOVER.
866const STOP_HOVER = 'rgba(128,128,128,0.2)'
867// Widths also fit the header's 14 px bold totals (bold runs ~6% wider).
868const HEADER_BOLD = 1.06
869const METRIC_COLS: [number, number, number] = [
870  Math.max(textWidth('888k', 11), textWidth('8.8M', 14) * HEADER_BOLD),
871  Math.max(textWidth('in 8.8M', 11), textWidth('$88.88', 14) * HEADER_BOLD),
872  Math.max(textWidth('out 888k', 11), textWidth('8:88:88', 14) * HEADER_BOLD),
873]
874// Right edge of each column, from the row's right edge.
875const metricEdges = (RX: number): [number, number, number] => {
876  const [, w2, w3] = METRIC_COLS
877  const c3 = RX
878  const c2 = c3 - w3 - COL_GAP
879  return [c2 - w2 - COL_GAP, c2, c3]
880}
881
882const altOf = (r: TreeRow, state: string): string =>
883  `${r.depth > 0 ? '↳ ' : ''}${r.a.description || r.a.type}: ${modelName(r.a.model)}, ${state}${r.kids > 0 ? `, ${r.kids} ${S.sub}` : ''}` +
884  // A folded row still says its cost and tokens to a screen reader.
885  `, ${fmtCost(r.a.costUsd)}, ${fmtTokens(r.a.tokens)} ${S.tokensWord}`
886
887// Steps as small blocks after "3/5" (a thin bar past 8 steps). Returns the markup
888// and the width it took.
889const MAX_STEP_BLOCKS = 8
890const stepBlocks = (x: number, base: number, done: number, total: number): [string, number] => {
891  if (total > MAX_STEP_BLOCKS) {
892    const w = 48
893    const fill = Math.round((w * Math.min(done, total)) / total)
894    return [`<rect class="k" x="${x}" y="${base - 6}" width="${w}" height="3" rx="1.5"/><rect class="cR" x="${x}" y="${base - 6}" width="${fill}" height="3" rx="1.5"/>`, w]
895  }
896  const blocks = Array.from({ length: total }, (_, k) => `<rect class="${k < done ? 'cR' : 'k'}" x="${x + k * 8}" y="${base - 7}" width="6" height="4" rx="1"/>`)
897  return [blocks.join(''), total * 8 - 2]
898}
899
900// A row knows its place in the tree of spawns. Subagents draw smaller,
901// indented under the run that spawned them, joined to it by tree lines.
902// The "two densities" layout. In full, the left block (title; type,
903// model and effort; steps) faces the metrics table (cost, time; context;
904// total, sent and generated tokens). Folded, the title faces cost and time.
905const agentSvg = (W: number, row: TreeRow, at: number, bodyIdx: number, isFold = false): string => {
906  const { a } = row
907  const d = Math.min(row.depth, MAX_DEPTH)
908  const isSub = d > 0
909  const ind = d * INDENT
910  const g = geomOf(row, isFold)
911  const { H, scale: sc } = g
912  const crabY = crabTop(H, sc)
913  const x0 = ind + g.textX
914  const [c1, c2, c3] = rowEdges(W)
915  // A run with a Button in the slot leaves it to the Button, beside the drawing.
916  const svgW = hasSlotButton(a) ? W - ARROW_W : W
917  const crabBottom = crabY + Math.round(28 * sc)
918
919  // Tree lines: ancestors with later siblings keep their rail; this row's own
920  // elbow; a stub down from the crab when children follow.
921  const railX = (level: number): number => (level - 1) * INDENT + RAIL_OFFSET
922  const midY = crabY + Math.round(14 * sc)
923  const lines: string[] = []
924  for (let level = 1; level < d; level++) {
925    if (row.rails[level]) lines.push(`<line class="tr" x1="${railX(level)}" y1="0" x2="${railX(level)}" y2="${H}"/>`)
926  }
927  if (isSub) {
928    const x = railX(d)
929    lines.push(`<path class="tr" d="M${x} 0V${row.isLast ? midY - 4 : H}"/>`)
930    lines.push(`<path class="tr" d="M${x} ${midY - 4}q0 4 4 4H${ind - 2}"/>`)
931  }
932  if (row.kids > 0 && row.depth < MAX_DEPTH) {
933    const x = railX(d + 1)
934    lines.push(`<line class="tr" x1="${x}" y1="${crabBottom + 2}" x2="${x}" y2="${H}"/>`)
935  }
936
937  const text = (x: number, y: number, size: number, cls: string, body: string, extra = ''): string =>
938    `<text class="${cls}" x="${x}" y="${y}" font-family="${FONT}" font-size="${size}" font-variant-numeric="tabular-nums"${extra}>${body}</text>`
939  const end = ' text-anchor="end"'
940  const cell = (x: number, y: number, size: number, cls: string, body: string): string => (body ? text(x, y, size, cls, body, end) : '')
941  const cost = fmtCost(a.costUsd)
942  const time = fmtTime(elapsed(a, at))
943  const crabSvg = `<g class="b${bodyIdx}">${crab(ind, crabY, costumeOf(a.type), a.status === 'running', sc)}</g>`
944  const groupEnd = row.isGroupEnd ? `<line class="ln" x1="0" y1="${H - 0.5}" x2="${W - ARROW_W}" y2="${H - 0.5}"/>` : ''
945  const mark = a.status === 'failed' ? failedMark(W - ARROW_W / 2, Math.round(H / 2)) : ''
946
947  if (isFold) {
948    const [y] = baselines(H, 1, 0)
949    const size = isSub ? 11 : 12
950    // A stopped run keeps saying so after its title, bold in the light `.mk` grey.
951    const stopped = a.status === 'stopped' ? ` · ${S.isStopped}` : ''
952    const titleW = Math.max(0, c2 - METRIC_COLS[1] - x0 - 12 - textWidth(stopped, 11))
953    const title = xml(fitText(a.description || a.type, size, titleW)) + (stopped ? `<tspan class="mk" font-size="11" font-weight="600">${xml(stopped)}</tspan>` : '')
954    return svg(
955      svgW,
956      H,
957      `${lines.join('')}${crabSvg}
958${text(x0, y ?? 0, size, 's', title, ' font-weight="500"')}
959${text(c2, y ?? 0, 11, 'mvC', xml(cost), `${end} font-weight="600"`)}${cell(c3, y ?? 0, 11, 'ml', xml(time))}
960${mark}${groupEnd}`,
961    )
962  }
963
964  const ctx = ctxOf(a)
965  const fmtSplit = (n: number | undefined): string => (n === undefined ? '—' : fmtTokens(n))
966  const [titleSize, s1] = isSub ? [11, 11] : [12, 12]
967  const [r1 = 0, r2 = 0, r3 = 0] = baselines(H, 3, g.gap)
968  // Tokens: the total bare, then sent and generated, each after its label. Only
969  // cost, context percentage and time stand out; the rest share the labels' grey.
970  const labelled = (label: string, v: string): string => `<tspan class="mk">${label}</tspan> ${xml(v)}`
971  const metrics = [
972    text(c2, r1, s1, 'mvC', xml(cost), `${end} font-weight="600"`),
973    cell(c3, r1, s1, 'ml', xml(time)),
974    cell(c1, r2, 11, 'mk', 'ctx'),
975    text(c2, r2, 11, 'ml', `${ctx}%`, `${end} font-weight="600"`),
976    cell(c3, r2, 11, 'ml', xml(fmtTokens(a.contextTokens))),
977    cell(c1, r3, 11, 'ml', xml(fmtTokens(a.tokens))),
978    cell(c2, r3, 11, 'ml', labelled('in', fmtSplit(a.inputTokens))),
979    cell(c3, r3, 11, 'ml', labelled('out', fmtSplit(a.outputTokens))),
980  ].join('')
981
982  // How far each left line may run before it meets the metrics beside it: row 1
983  // leaves column 1 empty, row 2 holds only "ctx" there, row 3 is full width.
984  const roomTo = (edge: number): number => Math.max(0, edge - x0 - 12)
985  const titleW = roomTo(c2 - METRIC_COLS[1])
986  const metaW = roomTo(c1 - textWidth('ctx', 11))
987  const leftW = roomTo(c1 - METRIC_COLS[0])
988
989  // Three lines, centred. Line 2 is the model and its effort (plus
990  // round, subagents and failure); line 3 the agent type, then the steps.
991  const [l1 = 0, l2 = 0, l3 = 0] = baselines(H, 3, g.gap)
992  // The model in the row's grey; its effort and the round in the lighter `.mk`
993  // grey, as on the main thread's line.
994  const meta: [string, string][] = [[modelName(a.model), 's']]
995  if (a.effort) meta.push([` · ${a.effort}`, 'mk'])
996  if (a.round > 1) meta.push([` · ${S.round} ${a.round}`, 'mk'])
997  if (row.kids > 0) meta.push([` · ↳ ${row.kids} ${S.sub}`, 's'])
998  // The run's end, when it is not a plain finish, bold: a failure in red, a stop
999  // in the light `.mk` grey.
1000  const [tail, tailCls] =
1001    a.status === 'failed'
1002      ? [` · ${S.failed}`, 'cF']
1003      : a.status === 'stopped'
1004        ? [` · ${S.isStopped}`, 'mk']
1005        : a.isStopRequested
1006          ? [` · ${S.stopping}…`, 'mk']
1007          : ['', '']
1008  // Each part in its grey while the line fits; past that, the whole cut in the row's grey.
1009  const metaPlain = meta.map(([t]) => t).join('')
1010  const metaRoom = Math.max(0, metaW - textWidth(tail, 11))
1011  const metaRich =
1012    (textWidth(metaPlain, 11) <= metaRoom
1013      ? meta.map(([t, cls]) => `<tspan class="${cls}">${xml(t)}</tspan>`).join('')
1014      : xml(fitText(metaPlain, 11, metaRoom))) + (tail ? `<tspan class="${tailCls}" font-weight="600">${xml(tail)}</tspan>` : '')
1015
1016  let line3 = ''
1017  if (a.stepTotal) {
1018    const total = a.stepTotal
1019    const done = a.stepDone ?? 0
1020    const count = `${done}/${total}`
1021    const [, bw] = stepBlocks(0, 0, done, total)
1022    // The steps come first in priority: the type gets what they leave.
1023    const stepsW = textWidth(count, 11) + 6 + bw
1024    const typeRoom = leftW - stepsW - 10
1025    const typeText = typeRoom >= 30 ? fitText(a.type, 11, typeRoom) : ''
1026    let x = x0 + (typeText ? textWidth(typeText, 11) + 10 : 0)
1027    if (typeText) line3 += text(x0, l3, 11, 'mk', xml(typeText))
1028    line3 += text(x, l3, 11, 't', count, ' font-weight="600"')
1029    x += textWidth(count, 11) + 6
1030    // The count and its blocks alone: the step's note made the line too long.
1031    if (x + bw <= x0 + leftW) line3 += stepBlocks(x, l3, done, total)[0]
1032  } else {
1033    line3 = text(x0, l3, 11, 'mk', xml(fitText(a.type, 11, leftW)))
1034  }
1035
1036  return svg(
1037    svgW,
1038    H,
1039    `${lines.join('')}${crabSvg}
1040${text(x0, l1, titleSize, 't', xml(fitText(a.description || a.type, titleSize, titleW)), ' font-weight="600"')}
1041${text(x0, l2, 11, 's', metaRich)}
1042${line3}
1043${metrics}
1044${mark}${groupEnd}`,
1045  )
1046}
1047
1048// --- terminal drawing: the same rows in text.
1049
1050const ctxBar = (pct: number, width: number): string => {
1051  const filled = Math.round((width * pct) / 100)
1052  return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
1053}
1054
1055const STATUS_GLYPH: Record<AgentStatus, string> = { running: '●', done: '✓', failed: '✗', stopped: '■' }
1056
1057// Opens the agents pane, or closes it when it is up; true when it ends up open.
1058async function togglePane($: EngineInterface): Promise<boolean> {
1059  const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
1060  if (isOpen) {
1061    await $.ui.close({ id: PANE })
1062    return false
1063  }
1064  const at = await $.clock.now()
1065  await update($, now, () => at)
1066  await $.ui.open({ id: PANE, title: S.pane })
1067  return true
1068}
1069
1070// Folder, parent path and branch, read from the session and git. Runs
1071// at start, after each Bash call (a checkout moves the branch) and when the main
1072// thread ends a turn; never while drawing, which happens once a second.
1073async function refreshEnv($: EngineInterface): Promise<void> {
1074  const root = (await $.session.root()).replace(/\/+$/, '')
1075  const home = ((await $.env.get('HOME')) ?? '').replace(/\/+$/, '')
1076  const cut = root.lastIndexOf('/')
1077  const folder = root.slice(cut + 1) || root
1078  const parent = cut > 0 ? root.slice(0, cut) : '/'
1079  const path = home && (parent === home || parent.startsWith(`${home}/`)) ? `~${parent.slice(home.length)}` : parent
1080  let branch: string | undefined
1081  try {
1082    const git = (args: string[]) => $.process.run(['git', ...args], { cwd: root, timeoutMs: 2000 })
1083    const r = await git(['branch', '--show-current'])
1084    if (r.exitCode === 0) branch = r.stdout.trim()
1085    if (r.exitCode === 0 && !branch) {
1086      const h = await git(['rev-parse', '--short', 'HEAD'])
1087      branch = h.exitCode === 0 ? `@${h.stdout.trim()}` : undefined
1088    }
1089  } catch {
1090    branch = undefined
1091  }
1092  await update($, env, prev => (prev.folder === folder && prev.path === path && prev.branch === branch ? prev : { ...prev, folder, path, branch }))
1093}
1094
1095const mainModel = (m: Main): string => (m.model ? (m.effort ? `${modelName(m.model)} · ${m.effort}` : modelName(m.model)) : '—')
1096
1097// The main thread's section in words: for a screen reader and the terminal.
1098const mainSummary = (m: Main): string[] => [
1099  `${S.context} ${m.ctxPct ?? 0}%${m.ctxWindow ? ` ${fmtTokens(m.ctxTokens ?? 0)} / ${fmtWindow(m.ctxWindow)}` : ''}`,
1100  `${fmtCost(m.costUsd)} · ${fmtTokens(m.tokens)} ${S.tokensWord} · in ${fmtTokens(m.inputTokens)} out ${fmtTokens(m.outputTokens)}`,
1101  ...limitsOf(m.limits).map(l => `${l.label} ${l.pct}%`),
1102]
1103
1104// The main thread's context and the account's limits, as the engine measured them.
1105const measured = (prev: Main, u: Pick<SessionUsage, 'context' | 'rateLimits'>): Main => ({
1106  ...prev,
1107  ctxTokens: u.context.tokens,
1108  ctxWindow: u.context.window,
1109  ctxPct: u.context.percent,
1110  limits: u.rateLimits.map(r => ({ kind: r.kind, pct: r.percentUsed })),
1111})
1112
1113// What a run stopped from the panel answers to the agent that spawned it.
1114const STOP_ANSWER = 'Stopped by the person from the Mission Control panel before finishing.'
1115
1116const isStopRequested = async ($: EngineInterface, agentId: string): Promise<boolean> =>
1117  (await read($, agents)).some(a => a.agentId === agentId && a.isStopRequested === true)
1118
1119// A run's stop Button is keyed STOP_KEY + the run's id, its fold arrow FOLD_KEY + the id,
1120// and the Box that holds either SLOT_KEY + the id.
1121const STOP_KEY = 'stop-'
1122const FOLD_KEY = 'fold-'
1123const SLOT_KEY = 'slot-'
1124
1125// Marks a running run to stop; a run that already ended is left alone.
1126const requestStop = ($: EngineInterface, id: string) =>
1127  update($, agents, rows => rows.map(x => (x.id === id && x.status === 'running' ? { ...x, isStopRequested: true } : x)))
1128
1129// Unfolds a finished run, or folds it back.
1130const toggleRun = ($: EngineInterface, id: string) =>
1131  update($, panel, prev => {
1132    const open = prev.expanded ?? []
1133    return { ...prev, expanded: open.includes(id) ? open.filter(x => x !== id) : [...open, id].slice(-200) }
1134  })
1135
1136// The desktop's row Buttons, kept from one drawing to the next. The host
1137// gives a Button a new handle for every new element, and drops the old handle
1138// as soon as the next drawing is made. The page sends a click with the handle
1139// it drew, so a click that lands after a redraw names a handle the host no
1140// longer holds: the engine drops it before any `ui.press` hook runs. While
1141// runs work the pane redraws several times a second, so most clicks on the
1142// stop were lost. The same element, drawn again, keeps its handle.
1143// Keyed by the Button's key; a new label (the fold arrow's ▸ / ▾) makes a new one.
1144const keptButtons = new Map<string, { label: string; element: RenderElement }>()
1145
1146export const register: Register = on => {
1147  on('session.start', async ($, e, next) => {
1148    const started = await next(e)
1149    // Prices load beside the rest; until they do, the built-in PRICES count.
1150    void loadPrices($).catch(() => undefined)
1151    await refreshEnv($).catch(() => undefined)
1152    const usage = await $.session.usage()
1153    // The model before the first response; each request names it from then on.
1154    const model = await $.session.model()
1155    await update($, main, prev => ({ ...measured(prev, usage), model }))
1156    await $.tool.register({
1157      name: 'step',
1158      description:
1159        'For subagents: report progress on your own task to the Mission Control panel. ' +
1160        'Right after reading the brief, call it with `total` (your plan in 3-8 steps) and `done: 0`; ' +
1161        'call it again as each step finishes. Cheap and silent: it only draws a bar.',
1162      inputSchema: {
1163        type: 'object',
1164        properties: {
1165          done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
1166          total: { type: 'integer', minimum: 1, description: 'Steps planned; may change if the plan changes.' },
1167          note: { type: 'string', description: 'The step in progress, a few words.' },
1168        },
1169        required: ['done'],
1170      },
1171    })
1172    await $.command.register({
1173      name: 'mission-control',
1174      description: 'Show or hide Mission Control: the session and its subagents, with model, context, cost and time',
1175    })
1176
1177    // Ticks the running agents' clocks; quiet when nothing runs.
1178    $.clock.every(1000, () => {
1179      void (async () => {
1180        const list = await read($, agents)
1181        if (!list.some(a => a.status === 'running')) return
1182        const at = await $.clock.now()
1183        await update($, now, () => at)
1184      })()
1185    })
1186
1187    // Open the agents panel by default on every session, right away.
1188    const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
1189    if (!isOpen) void $.ui.open({ id: PANE, title: S.pane })
1190    return started
1191  })
1192
1193  on('command.run', { command: 'mission-control' }, async $ => {
1194    const isOpen = await togglePane($)
1195    return { text: isOpen ? S.opened : S.closed }
1196  })
1197
1198  // A press on a run's stop or fold arrow, taken here by its key: a kept
1199  // Button's own handler holds the `$` of the drawing that first made it.
1200  on('ui.press', { plugin: 'mission-control' }, async ($, e, next) => {
types/index.d.ts 90 lines
1/** `stopped`: the person stopped it from the panel. */
2export type AgentStatus = 'running' | 'done' | 'failed' | 'stopped'
3
4export type AgentRun = {
5  id: string
6  agentId?: string
7  /** AgentId of the loop that spawned this run; absent when the main thread did. */
8  parentAgentId?: string
9  type: string
10  description: string
11  model: string
12  effort?: string
13  status: AgentStatus
14  startedAt: number
15  endedAt?: number
16  contextTokens: number
17  contextMax: number
18  tokens: number
19  /** Tokens sent, cache reads and writes included. Absent on runs recorded before this field existed. */
20  inputTokens?: number
21  /** Tokens generated. Absent on runs recorded before this field existed. */
22  outputTokens?: number
23  costUsd: number
24  steps: number
25  round: number
26  /** Self-reported by the worker through the `step` tool. */
27  stepDone?: number
28  stepTotal?: number
29  stepNote?: string
30  /** Set by the panel's stop: the run asks for nothing more and ends at its next model request. */
31  isStopRequested?: boolean
32}
33
34export type Panel = {
35  isCompact: boolean
36  isDoneCollapsed: boolean
37  /** Ids of runs that ended well, unfolded by their arrow. Absent in state saved before this field existed. */
38  expanded?: string[]
39}
40
41/** Where the session works, for the header. */
42export type Env = {
43  /** Last segment of the session's root. */
44  folder: string
45  /** The root's parent, with the home directory as `~`. */
46  path: string
47  /** Current git branch, `@abc1234` on a detached HEAD; absent outside git. */
48  branch?: string
49}
50
51/** One rate-limit window of the account, as the engine reports it. */
52export type Limit = {
53  /** `five_hour`, `seven_day` or `spend_limit`. */
54  kind: string
55  /** How much of the window is used, 0 to 100 (past 100 on an exceeded spend limit). */
56  pct: number
57}
58
59/** The main thread: its model, its context and what its own requests used. */
60export type Main = {
61  model?: string
62  effort?: string
63  /** Input tokens of the last response; absent before the first response. */
64  ctxTokens?: number
65  /** The model's context window, in tokens. */
66  ctxWindow?: number
67  /** `ctxTokens` over `ctxWindow`, 0 to 100; absent before the first response. */
68  ctxPct?: number
69  /** Tokens sent (cache reads and writes included) plus tokens generated. */
70  tokens: number
71  inputTokens: number
72  outputTokens: number
73  /** Estimated from `PRICES`, as for the subagents. */
74  costUsd: number
75  /** Empty off a subscription or before the first reading. */
76  limits: Limit[]
77}
78
79declare module 'claude-code' {
80  interface PluginState {
81    'mission-control': {
82      agents: AgentRun[]
83      panel: Panel
84      now: number
85      env: Env
86      main: Main
87    }
88  }
89}
90