Animated sidebar of your subagents: progress, time, tokens and cost, with model switching and messages

A Claude Code mod that shows every subagent you deploy in a sidebar, each with its own animated pixel crab. At a glance you see what each agent is doing, how far along it is, and what it is costing, and you can steer a running agent without leaving the sidebar.
Grep · login)/subagents opens it at any time.On the mobile app, cards offer View only: the app does not draw pickers or text fields yet.
At the prompt of a terminal session:
/plugin install subagent-monitor --marketplace thibbdesigns/subagent-monitor
From a shell:
claude plugin install subagent-monitor --marketplace thibbdesigns/subagent-monitor
In the desktop app: click + next to the prompt box, then Plugins, Add marketplace with thibbdesigns/subagent-monitor, then Add plugin.
Source: https://github.com/thibbdesigns/subagent-monitor
hooks/pricing.ts.hooks/register.tsx: the hooks, the pane and the controlshooks/art.ts: the crab and the progress barshooks/pricing.ts: the price tabletypes/index.d.ts: the state the pane keepsRun the checks:
claude plugin validate .
claude plugin test .
MIT
hooks/register.tsx 702 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { AgentRow, AgentRunStatus, SentNote } from '../types'
5import { PALETTE, barSvg, barText, colorOf, crabSvg, crabText } from './art'
6import { MODEL_CHOICES, costOf, costOfUsage, formatCost, modelName } from './pricing'
7
8const PANE = 'subagent-monitor'
9const TITLE = 'Subagents'
10const HISTORY = 'history'
11const DEFAULT_STEPS = 15
12
13const agents = atom({ plugin: 'subagent-monitor', key: 'agents' } as const, [])
14const now = atom({ plugin: 'subagent-monitor', key: 'now' } as const, 0)
15const isPaneOpen = atom({ plugin: 'subagent-monitor', key: 'isPaneOpen' } as const, false)
16// Set once the person closes the pane, so new agents stop popping it back open.
17const isPaneDismissed = atom({ plugin: 'subagent-monitor', key: 'isPaneDismissed' } as const, false)
18const expandedId = atom({ plugin: 'subagent-monitor', key: 'expandedId' } as const, '')
19const viewingId = atom({ plugin: 'subagent-monitor', key: 'viewingId' } as const, '')
20const drafts = atom({ plugin: 'subagent-monitor', key: 'drafts' } as const, {})
21const isFollowing = atom({ plugin: 'subagent-monitor', key: 'isFollowing' } as const, true)
22const MAX_BOX_ROWS = 6
23
24// Per agent type: how many runs finished and how many model steps they took in all.
25type History = Record<string, { runs: number; steps: number }>
26
27let ticker: Timer | undefined
28// Only ever counts up, so a cleared agent's color is not handed straight back out.
29let nextColor = 0
30
31async function expectedSteps($: EngineInterface, type: string): Promise<number> {
32 const history = ((await $.store.get(HISTORY)) ?? {}) as History
33 const seen = history[type]
34 return seen && seen.runs > 0 ? Math.max(3, seen.steps / seen.runs) : DEFAULT_STEPS
35}
36
37async function recordRun($: EngineInterface, type: string, steps: number) {
38 const history = ((await $.store.get(HISTORY)) ?? {}) as History
39 const seen = history[type] ?? { runs: 0, steps: 0 }
40 // Keep the average recent: older runs weigh less once there are 20.
41 const runs = Math.min(seen.runs, 19)
42 const avg = seen.runs > 0 ? seen.steps / seen.runs : steps
43 history[type] = { runs: runs + 1, steps: avg * runs + steps }
44 await $.store.set(HISTORY, history)
45}
46
47function blankRow(id: string, at: number, colorIndex: number): AgentRow {
48 return {
49 id,
50 description: 'Subagent',
51 type: 'agent',
52 model: '',
53 startedAt: at,
54 status: 'running',
55 steps: 0,
56 toolCalls: 0,
57 inputTokens: 0,
58 cacheReadTokens: 0,
59 cacheWriteTokens: 0,
60 outputTokens: 0,
61 activity: 'Getting started',
62 expectedSteps: DEFAULT_STEPS,
63 colorIndex,
64 cost: 0,
65 messagesSent: 0,
66 }
67}
68
69// Changes one agent's row, adding it first if an event for it beat its spawn.
70async function patch($: EngineInterface, id: string, fn: (row: AgentRow) => AgentRow) {
71 const at = await $.clock.now()
72 await update($, agents, rows => {
73 if (rows.some(row => row.id === id)) return rows.map(row => (row.id === id ? fn(row) : row))
74 // After a reload the counter starts over: never go below the colors on show.
75 const colorIndex = Math.max(nextColor, ...rows.map(row => (row.colorIndex ?? -1) + 1))
76 nextColor = colorIndex + 1
77 return [...rows, fn(blankRow(id, at, colorIndex))].slice(-50)
78 })
79}
80
81// While View follows, bring the pane to its end once the new content is drawn.
82async function followView($: EngineInterface, agentId: string) {
83 if ((await read($, viewingId)) !== agentId || !(await read($, isFollowing))) return
84 $.clock.after(120, () => {
85 void $.ui.scroll({ to: 'end', in: PANE }).catch(() => undefined)
86 })
87}
88
89async function syncTicker($: EngineInterface) {
90 const list = await read($, agents)
91 const isAnyRunning = list.some(row => row.status === 'running')
92 if (isAnyRunning && !ticker) {
93 const t = await $.clock.now()
94 await update($, now, () => t)
95 ticker = $.clock.every(1000, () => {
96 void $.clock.now().then(t => update($, now, () => t))
97 })
98 } else if (!isAnyRunning && ticker) {
99 ticker.cancel()
100 ticker = undefined
101 }
102}
103
104async function openPane($: EngineInterface) {
105 const opened = await $.ui.open({ id: PANE, title: TITLE })
106 // A pane the surface could not place yet counts as closed: the row above the prompt stays.
107 await update($, isPaneOpen, () => opened.isPlaced)
108 return opened
109}
110
111async function pickModel($: EngineInterface, row: AgentRow, model: string) {
112 await patch($, row.id, one => ({ ...one, modelOverride: model, model }))
113 $.ui.toast(`${clip(row.description, 30)} switches to ${modelName(model)} from its next step`)
114}
115
116const NOTE_PREFIX = 'Message from the person, sent from the Subagents sidebar:\n\n'
117
118// The person's note joins the agent's conversation as a user message, read at
119// its next step. (SendMessage is the model's tool: under auto mode a send with
120// no model request behind it gets no verdict and is refused.)
121async function sendNote($: EngineInterface, row: AgentRow, text: string) {
122 if (!text.trim()) return
123 const before = await $.session.messages({ agentId: row.id }).catch(() => undefined)
124 const afterCount = Array.isArray(before) ? before.length : Number.MAX_SAFE_INTEGER
125 const added = await $.session
126 .append({
127 agentId: row.id,
128 message: {
129 type: 'user',
130 content: [{ type: 'text', text: `${NOTE_PREFIX}${text}` }],
131 },
132 })
133 .catch((error: unknown) => ({ deny: error instanceof Error ? error.message : String(error) }))
134 if (added.deny !== undefined) {
135 $.ui.toast(`Couldn't message ${clip(row.description, 30)}: ${added.deny}`)
136 return
137 }
138 await update($, drafts, all => {
139 const { [row.id]: _sent, ...rest } = all
140 return rest
141 })
142 const note: SentNote = { text, sentAt: await $.clock.now(), afterCount, isSeen: false }
143 await patch($, row.id, one => ({
144 ...one,
145 messagesSent: (one.messagesSent ?? 0) + 1,
146 notes: [...(one.notes ?? []), note],
147 }))
148 await followView($, row.id)
149 $.ui.toast(`Sent to ${clip(row.description, 30)}`)
150}
151
152function clip(text: string, max: number): string {
153 const one = text.replace(/\s+/g, ' ').trim()
154 return one.length > max ? one.slice(0, max - 1) + '…' : one
155}
156
157function describeTool(e: Record<string, unknown>): string {
158 const tool = String(e.tool)
159 const pick = (key: string) => (typeof e[key] === 'string' ? (e[key] as string) : undefined)
160 const path = pick('file_path') ?? pick('path') ?? pick('notebook_path')
161 const detail =
162 pick('description') ??
163 (path ? path.split('/').pop() : undefined) ??
164 pick('pattern') ??
165 pick('query') ??
166 pick('command') ??
167 pick('url')
168 return detail ? `${tool} · ${clip(detail, 40)}` : tool
169}
170
171function formatTokens(n: number): string {
172 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
173 if (n >= 1000) return `${Math.round(n / 1000)}k`
174 return String(n)
175}
176
177function formatTime(ms: number): string {
178 const s = Math.max(0, Math.floor(ms / 1000))
179 if (s < 60) return `${s}s`
180 const m = Math.floor(s / 60)
181 if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
182 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
183}
184
185function tokensOf(row: AgentRow): number {
186 return (
187 (row.inputTokens ?? 0) +
188 (row.cacheReadTokens ?? 0) +
189 (row.cacheWriteTokens ?? 0) +
190 (row.outputTokens ?? 0)
191 )
192}
193
194function elapsedOf(row: AgentRow, tick: number): number {
195 return (row.endedAt ?? Math.max(tick, row.startedAt)) - row.startedAt
196}
197
198// No agent says how far along it is, so this estimates from model steps taken
199// against how many steps agents of this type have needed before. It eases
200// toward 95% and only reaches 100% when the agent actually finishes.
201function progressOf(row: AgentRow): number {
202 if (row.status === 'done') return 1
203 const ratio = row.steps / row.expectedSteps
204 return Math.min(0.95, 1 - Math.exp(-1.6 * ratio))
205}
206
207const STATUS_WORD: Record<AgentRunStatus, string> = {
208 running: '',
209 done: 'Done',
210 failed: 'Failed',
211 stopped: 'Stopped',
212}
213
214export const register: Register = on => {
215 on('session.start', async ($, e, next) => {
216 await $.command.register({
217 name: 'subagents',
218 description: 'Show the subagent monitor pane',
219 })
220 const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
221 await update($, isPaneOpen, () => isOpen)
222 await syncTicker($)
223
224 return next(e)
225 })
226
227 on('command.run', { command: 'subagents' }, async $ => {
228 await update($, isPaneDismissed, () => false)
229 await openPane($)
230
231 return { text: 'Subagent monitor opened.' }
232 })
233
234 on('ui.close', { id: PANE }, async ($, e, next) => {
235 await next(e)
236 await update($, isPaneOpen, () => false)
237 if (e.origin.kind === 'person') await update($, isPaneDismissed, () => true)
238 })
239
240 // The person scrolling up in View stops the following; back at the end, it resumes.
241 on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
242 const result = await next(e)
243 if (e.origin.kind === 'person' && (await read($, viewingId))) {
244 const isAtEnd = e.offset >= e.contentRows - e.bodyRows - 1
245 if (isAtEnd !== (await read($, isFollowing))) await update($, isFollowing, () => isAtEnd)
246 }
247 return result
248 })
249
250 on('agent.spawn', async ($, e, next) => {
251 const result = await next(e)
252 if (result.deny !== undefined || !result.agentId) return result
253
254 const id = result.agentId
255 const expected = await expectedSteps($, e.subagentType)
256 const at = await $.clock.now()
257 await patch($, id, row => ({
258 ...row,
259 description: e.description || row.description,
260 type: e.subagentType,
261 model: result.model,
262 expectedSteps: expected,
263 status: 'running',
264 // A resumed run keeps its counts but restarts its clock.
265 startedAt: e.resume ? at : row.startedAt,
266 endedAt: undefined,
267 }))
268 if (!(await read($, isPaneDismissed))) void openPane($)
269 await syncTicker($)
270
271 return result
272 }).catch(($, e, next) => next(e))
273
274 on('turn.step', async function* ($, e, next) {
275 // A model picked in the pane takes over from the agent's next request.
276 const id = e.agentId
277 const picked = id === undefined ? undefined : (await read($, agents)).find(row => row.id === id)?.modelOverride
278 const asked = picked && picked !== e.model ? { ...e, model: picked } : e
279 // This request carries every note sent so far: they count as seen.
280 if (id !== undefined) {
281 const rows = await read($, agents)
282 if (rows.find(row => row.id === id)?.notes?.some(note => !note.isSeen)) {
283 await patch($, id, row => ({ ...row, notes: row.notes?.map(note => ({ ...note, isSeen: true })) }))
284 }
285 }
286 const result = yield* next(asked)
287 if (id !== undefined) {
288 const usage = result.usage
289 const answeredBy = usage?.model ?? asked.model
290 await patch($, id, row => ({
291 ...row,
292 steps: row.steps + 1,
293 model: answeredBy,
294 cost: costOf(row) + (usage ? costOfUsage(answeredBy, usage) : 0),
295 inputTokens: (row.inputTokens ?? 0) + (usage?.input_tokens ?? 0),
296 cacheReadTokens: (row.cacheReadTokens ?? 0) + (usage?.cache_read_input_tokens ?? 0),
297 cacheWriteTokens: (row.cacheWriteTokens ?? 0) + (usage?.cache_creation_input_tokens ?? 0),
298 outputTokens: (row.outputTokens ?? 0) + (usage?.output_tokens ?? 0),
299 activity: result.toolUses.length === 0 && result.answer ? 'Writing report' : row.activity,
300 }))
301 await followView($, id)
302 }
303
304 return result
305 })
306
307 on('tool.call', async ($, e, next) => {
308 if (e.agentId === undefined) return next(e)
309
310 await patch($, e.agentId, row => ({
311 ...row,
312 toolCalls: row.toolCalls + 1,
313 activity: describeTool(e as unknown as Record<string, unknown>),
314 }))
315 await followView($, e.agentId)
316
317 return next(e)
318 }).catch(($, e, next) => next(e))
319
320 on('turn.complete', async ($, e, next) => {
321 if (e.agentId === undefined) return next(e)
322
323 const at = await $.clock.now()
324 const status: AgentRunStatus =
325 e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'stopped' : 'failed'
326 let finished: AgentRow | undefined
327 await patch($, e.agentId, row => {
328 finished = { ...row, status, endedAt: at, activity: STATUS_WORD[status] }
329 return finished
330 })
331 await followView($, e.agentId)
332 if (finished && status === 'done' && finished.steps > 0) {
333 await recordRun($, finished.type, finished.steps)
334 }
335 await syncTicker($)
336
337 return next(e)
338 })
339
340 // The way back: while the pane is closed or behind another tab, a slim crab
341 // row above the prompt offers to open it again.
342 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
343 const list = await read($, agents)
344 const isOpen = await read($, isPaneOpen)
345 if (e.props.hasSurvey || list.length === 0) return next(e)
346 const pane = (await $.ui.panes()).find(one => one.id === PANE)
347 if (isOpen && pane?.isShown && pane.isPlaced) return next(e)
348
349 const { Box, Text, Button } = $.ui.resolve(e)
350 const running = list.filter(row => row.status === 'running').length
351 const label =
352 running > 0
353 ? `${running} ${running === 1 ? 'agent' : 'agents'} working`
354 : `${list.length} ${list.length === 1 ? 'agent' : 'agents'} done`
355
356 return (
357 <Box flexDirection="row" gap="sm" alignItems="center">
358 <Text color={running > 0 ? 'claude' : 'success'}>🦀</Text>
359 <Text dimColor>{label}</Text>
360 <Button
361 key="open-subagents"
362 label="Open"
363 onPress={async () => {
364 await update($, isPaneDismissed, () => false)
365 await openPane($)
366 }}
367 />
368 </Box>
369 )
370 })
371
372 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
373 const table = $.ui.resolve(e)
374 const { Box, Text, Button } = table
375 // The terminal has no Svg: there the crab and bar are drawn in text.
376 const Svg = e.surface !== 'terminal' && 'Svg' in table ? table.Svg : undefined
377 // The mobile app draws no picker or text field yet: there a card offers View alone.
378 const Select = e.surface !== 'mobile' && 'Select' in table ? table.Select : undefined
379 const Input = e.surface !== 'mobile' && 'Input' in table ? table.Input : undefined
380 const list = await read($, agents)
381 const tick = await read($, now)
382 const expanded = await read($, expandedId)
383 const viewingAt = await read($, viewingId)
384 const viewing = list.find(row => row.id === viewingAt)
385 const draftOf = await read($, drafts)
386 const following = await read($, isFollowing)
387
388 // The message box: grows a line at a time with what's typed, up to
389 // MAX_BOX_ROWS, then scrolls inside. Enter sends, Shift+Enter breaks the line,
390 // and the draft is kept in state so a redraw or a view change never loses it.
391 const composer = (row: AgentRow, where: 'card' | 'view', indent: number) => {
392 if (!Input) return null
393 const draft = draftOf[row.id] ?? ''
394 const perLine = Math.max(16, (e.props.bodyColumns ?? 40) - indent)
395 const wrapped = draft
396 .split('\n')
397 .reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / perLine)), 0)
398 const rows = Math.min(MAX_BOX_ROWS, Math.max(1, wrapped))
399 const n = row.messagesSent ?? 0
400 return (
401 <Box flexDirection="column" gap="xs">
402 <Input
403 key={where === 'card' ? `msg-${row.id}-${n}` : `view-msg-${row.id}-${n}`}
404 placeholder="Message this agent"
405 submitLabel="send"
406 rows={rows}
407 value={draft}
408 onInput={text => void update($, drafts, all => ({ ...all, [row.id]: text }))}
409 onSubmit={text => void sendNote($, row, text)}
410 />
411 <Text size="caption" dimColor>
412 Enter to send · Shift+Enter for a new line
413 </Text>
414 </Box>
415 )
416 }
417
418 if (viewing) {
419 const color = colorOf(viewing.colorIndex ?? 0, viewing.status)
420 const found = await $.session.messages({ agentId: viewing.id })
421 const back = (
422 <Button key="back" label="← All agents" dimColor onPress={() => update($, viewingId, () => '')} />
423 )
424 if ('deny' in found) {
425 return (
426 <Box flexDirection="column" gap="sm" padding={Svg ? 'sm' : 0}>
427 {back}
428 <Text dimColor>Can't read this agent's conversation: {found.deny}</Text>
429 </Box>
430 )
431 }
432 // The newest 40 messages; tool results are left out, the calls say enough.
433 const firstUser = found.find(message => message.role === 'user')
434 const start = Math.max(0, found.length - 40)
435 const notes = viewing.notes ?? []
436 // The person's notes that came after the message at `count`, in order.
437 const notesAt = (count: number) =>
438 notes
439 .map((note, n) => ({ note, n }))
440 .filter(({ note }) =>
441 count === found.length ? note.afterCount >= count : note.afterCount === count,
442 )
443 .map(({ note, n }) => ({ key: `note-${n}`, who: 'You', text: note.text, note }))
444 type Line = { key: string; who: string; text: string; note?: SentNote }
445 const lines: Line[] = found.slice(start).flatMap((message, offset) => {
446 const at = start + offset
447 const out: Line[] = []
448 const text = message.text.trim()
449 if (text && message.role === 'assistant') {
450 out.push({ key: `${at}-t`, who: 'Agent', text: clip(text, 400) })
451 } else if (text && message === firstUser) {
452 out.push({ key: `${at}-t`, who: 'Task', text: clip(text, 400) })
453 }
454 message.toolUses.forEach((use, n) =>
455 out.push({
456 key: `${at}-u${n}`,
457 who: '→',
458 text: describeTool({ tool: use.tool, ...use.input }),
459 }),
460 )
461 return [...out, ...notesAt(at + 1)]
462 })
463 if (start === 0) lines.unshift(...notesAt(0))
464 const bubbleColor = PALETTE[(viewing.colorIndex ?? 0) % PALETTE.length]
465 const noteStatus = (note: SentNote) =>
466 note.isSeen ? 'Seen' : viewing.status === 'running' ? 'Sent' : 'Not seen · agent finished'
467 return (
468 <Box flexDirection="column" gap="sm" padding={Svg ? 'sm' : 0}>
469 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
470 {back}
471 <Text color={color} weight="semibold" bold>
472 {clip(viewing.description, 32)}
473 </Text>
474 </Box>
475 {!following && (
476 <Button
477 key="jump-latest"
478 label="↓ Jump to latest"
479 onPress={async () => {
480 await update($, isFollowing, () => true)
481 await $.ui.scroll({ to: 'end', in: PANE })
482 }}
483 />
484 )}
485 {lines.length === 0 && <Text dimColor>Nothing yet.</Text>}
486 {lines.map(line =>
487 line.note ? (
488 // The person's own message: a bubble on the right in the agent's color.
489 <Box key={line.key} flexDirection="column" alignItems="flex-end" marginY={Svg ? 'xs' : 0}>
490 <Box
491 flexDirection="column"
492 paddingX={1}
493 backgroundColor={Svg ? bubbleColor : undefined}
494 borderStyle={Svg ? undefined : 'round'}
495 borderColor={Svg ? undefined : bubbleColor}
496 >
497 <Text color={Svg ? '#1B1B1F' : undefined}>{line.text}</Text>
498 </Box>
499 <Text size="caption" dimColor>
500 You · {noteStatus(line.note)}
501 </Text>
502 </Box>
503 ) : (
504 <Box key={line.key} flexDirection="row" gap="sm">
505 <Text color={line.who === '→' ? color : undefined} dimColor={line.who !== 'Agent'} bold={line.who !== '→'}>
506 {line.who}
507 </Text>
508 <Text dimColor={line.who === '→'} size={line.who === '→' ? 'footnote' : 'body'}>
509 {line.text}
510 </Text>
511 </Box>
512 ),
513 )}
514 {viewing.status === 'running' ? (
515 composer(viewing, 'view', 4)
516 ) : (
517 <Text size="caption" dimColor>
518 This agent has finished, so it can't get new messages.
519 </Text>
520 )}
521 </Box>
522 )
523 }
524 const isTerminal = Svg === undefined
525 const frame = Math.floor(tick / 1000)
526
527 if (list.length === 0) {
528 return (
529 <Box flexDirection="column" alignItems="center" padding="md" gap="sm">
530 {Svg === undefined ? (
531 crabText('stopped', 0).map(line => <Text dimColor>{line}</Text>)
532 ) : (
533 <Svg source={crabSvg('#9A9A9A', 'stopped', 0)} alt="A sleeping crab" width={48} />
534 )}
535 <Text dimColor>No subagents yet</Text>
536 </Box>
537 )
538 }
539
540 const running = list.filter(row => row.status === 'running').length
541 const totalCost = list.reduce((sum, row) => sum + costOf(row), 0)
542 const totalTime = list.reduce((sum, row) => sum + elapsedOf(row, tick), 0)
543 const totalTokens = list.reduce((sum, row) => sum + tokensOf(row), 0)
544 const hasFinished = list.some(row => row.status !== 'running')
545
546 // Running agents first, newest first within each group.
547 const ordered = [...list].reverse().sort((a, b) => {
548 const ar = a.status === 'running' ? 0 : 1
549 const br = b.status === 'running' ? 0 : 1
550 return ar - br
551 })
552
553 const stat = (key: string, value: string, label: string) => (
554 <Box key={key} flexDirection="column" flexGrow={1}>
555 <Text size="heading" weight="bold" tabular>
556 {value}
557 </Text>
558 <Text size="caption" dimColor>
559 {label}
560 </Text>
561 </Box>
562 )
563
564 const summary = isTerminal ? (
565 <Text tabular>
566 <Text bold>~{formatCost(totalCost)}</Text>
567 <Text dimColor> · </Text>
568 <Text bold>{formatTime(totalTime)}</Text>
569 <Text dimColor> · </Text>
570 <Text bold>{formatTokens(totalTokens)}</Text>
571 <Text dimColor> tokens</Text>
572 </Text>
573 ) : (
574 <Box flexDirection="row" gap="md">
575 {stat('cost', `~${formatCost(totalCost)}`, 'cost')}
576 {stat('time', formatTime(totalTime), 'agent time')}
577 {stat('tokens', formatTokens(totalTokens), 'tokens')}
578 </Box>
579 )
580
581 return (
582 <Box flexDirection="column" gap="md" padding={isTerminal ? 0 : 'sm'}>
583 {summary}
584 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
585 <Text size="caption" dimColor>
586 {running > 0 ? `${running} working · ` : ''}
587 {list.length - running} done
588 </Text>
589 {hasFinished && (
590 <Button
591 key="clear"
592 label="Clear done"
593 onPress={() => update($, agents, rows => rows.filter(row => row.status === 'running'))}
594 />
595 )}
596 </Box>
597 {ordered.map(row => {
598 const color = colorOf(row.colorIndex ?? 0, row.status)
599 const fraction = progressOf(row)
600 const isRunning = row.status === 'running'
601 const meta = [row.type, modelName(row.model), formatTime(elapsedOf(row, tick)), `${formatTokens(tokensOf(row))} tok`]
602 .filter(Boolean)
603 .join(' · ')
604 const percent = isRunning ? `${Math.round(fraction * 100)}%` : STATUS_WORD[row.status]
605
606 return (
607 <Box key={row.id} flexDirection="row" gap="sm" alignItems="center">
608 <Box flexDirection="column" flexShrink={0}>
609 {Svg === undefined ? (
610 crabText(row.status, frame + (row.colorIndex ?? 0)).map(line => (
611 <Text color={color}>{line}</Text>
612 ))
613 ) : (
614 <Svg
615 source={crabSvg(color, row.status, row.colorIndex ?? 0)}
616 alt={`Crab for ${row.description}`}
617 width={48}
618 isInteractive
619 />
620 )}
621 </Box>
622 <Box flexDirection="column" flexGrow={1} gap={isTerminal ? 0 : 'xs'}>
623 <Box flexDirection="row" justifyContent="space-between">
624 <Text weight="semibold" bold>
625 {clip(row.description, 32)}
626 </Text>
627 <Text color={color} tabular>
628 {percent}
629 </Text>
630 </Box>
631 {isRunning && (
632 <Text size="footnote" dimColor italic>
633 {clip(row.activity, 44)}
634 </Text>
635 )}
636 {Svg === undefined ? (
637 <Text color={color}>{barText(fraction)}</Text>
638 ) : (
639 <Svg
640 source={barSvg(color, fraction, isRunning)}
641 alt={`${Math.round(fraction * 100)} percent`}
642 width={200}
643 height={8}
644 isInteractive={isRunning}
645 />
646 )}
647 <Text size="caption" dimColor tabular>
648 {meta}
649 </Text>
650 <Box flexDirection="row" gap="sm">
651 <Button
652 key={`view-${row.id}`}
653 label="View"
654 dimColor
655 onPress={async () => {
656 // Where the main view can't switch to an agent, show it in the pane.
657 const shown = await $.ui.view({ agentId: row.id })
658 if (shown.deny) {
659 await update($, viewingId, () => row.id)
660 await update($, isFollowing, () => true)
661 await followView($, row.id)
662 }
663 }}
664 />
665 {isRunning && Select && Input && (
666 <Button
667 key={`manage-${row.id}`}
668 label={expanded === row.id ? 'Hide controls' : 'Model & message'}
669 dimColor
670 onPress={() => update($, expandedId, id => (id === row.id ? '' : row.id))}
671 />
672 )}
673 </Box>
674 {isRunning && expanded === row.id && Select && Input && (
675 <Box flexDirection="column" gap="xs">
676 <Select
677 key={`model-${row.id}`}
678 label="Model"
679 options={
680 MODEL_CHOICES.some(choice => row.model.includes(choice.value)) || !row.model
681 ? MODEL_CHOICES
682 : [{ value: row.model, label: modelName(row.model) }, ...MODEL_CHOICES]
683 }
684 value={
685 row.modelOverride ??
686 MODEL_CHOICES.find(choice => row.model.includes(choice.value))?.value ??
687 (row.model || undefined)
688 }
689 onSelect={value => void pickModel($, row, value)}
690 />
691 {composer(row, 'card', 16)}
692 </Box>
693 )}
694 </Box>
695 </Box>
696 )
697 })}
698 </Box>
699 )
700 })
701}
702hooks/art.ts 125 lines1import type { AgentRunStatus } from '../types'
2
3// One color per agent, picked in turn, so neighbouring agents never match.
4export const PALETTE = [
5 '#E8765A', // coral
6 '#4FA3E0', // sky
7 '#5CC48A', // mint
8 '#B07CE8', // violet
9 '#F2B53B', // amber
10 '#E85C9C', // pink
11 '#3FC1C9', // teal
12 '#8C9EFF', // periwinkle
13]
14
15const RESTING = '#9A9A9A'
16
17// Only working agents wear their color: a finished one turns grey, whatever its end.
18export function colorOf(colorIndex: number, status: AgentRunStatus): string {
19 if (status !== 'running') return RESTING
20 return PALETTE[colorIndex % PALETTE.length]
21}
22
23// A pixel crab on a 16x12 grid. It animates itself (SMIL), so the surface
24// needs no redraws to keep it moving: bobbing and snapping while it works,
25// still once it ends, smiling if it finished and eyes closed if it failed or stopped.
26export function crabSvg(color: string, status: AgentRunStatus, seed: number): string {
27 const isRunning = status === 'running'
28 const isDone = status === 'done'
29 const isResting = !isRunning && !isDone
30 // Each crab moves a little out of step with the others.
31 const lag = `${((seed * 0.37) % 1).toFixed(2)}s`
32 const eye = '#1B1B1F'
33
34 const claw = (side: 'left' | 'right') => {
35 const x = side === 'left' ? 0 : 13
36 const pivotX = side === 'left' ? 1.5 : 14.5
37 const swing = side === 'left' ? -18 : 18
38 const motion = isRunning
39 ? `<animateTransform attributeName="transform" type="rotate" values="0 ${pivotX} 5;${swing} ${pivotX} 5;0 ${pivotX} 5" dur="0.45s" begin="${lag}" repeatCount="indefinite"/>`
40 : ''
41 return (
42 `<g>${motion}` +
43 `<rect x="${x}" y="0" width="1" height="2"/><rect x="${x + 2}" y="0" width="1" height="2"/>` +
44 `<rect x="${x}" y="2" width="3" height="1"/><rect x="${x + 1}" y="3" width="1" height="2"/>` +
45 `</g>`
46 )
47 }
48
49 const legs = (xs: number[], phase: string) =>
50 `<g>${
51 isRunning
52 ? `<animateTransform attributeName="transform" type="translate" values="0 0;0 -0.7;0 0" dur="0.3s" begin="${phase}" repeatCount="indefinite"/>`
53 : ''
54 }${xs.map(x => `<rect x="${x}" y="10" width="1" height="2"/>`).join('')}</g>`
55
56 const eyes = isResting
57 ? // Closed eyes: flat lines.
58 `<rect x="4.5" y="7" width="2" height="0.6" fill="${eye}"/><rect x="9.5" y="7" width="2" height="0.6" fill="${eye}"/>`
59 : `<g fill="${eye}"><rect x="5" y="6" width="1" height="2">` +
60 `<animate attributeName="height" values="2;2;0.3;2" keyTimes="0;0.92;0.96;1" dur="3.2s" begin="${lag}" repeatCount="indefinite"/></rect>` +
61 `<rect x="10" y="6" width="1" height="2">` +
62 `<animate attributeName="height" values="2;2;0.3;2" keyTimes="0;0.92;0.96;1" dur="3.2s" begin="${lag}" repeatCount="indefinite"/></rect></g>`
63
64 const smile = isDone ? `<rect x="7" y="8" width="2" height="0.6" fill="${eye}"/>` : ''
65
66 const bob = isRunning
67 ? `<animateTransform attributeName="transform" type="translate" values="0 0;0 -0.5;0 0" dur="0.6s" begin="${lag}" repeatCount="indefinite"/>`
68 : ''
69
70 return (
71 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -1.5 16 13.5" shape-rendering="crispEdges">` +
72 `<g fill="${color}">${bob}` +
73 claw('left') +
74 claw('right') +
75 `<rect x="3" y="4" width="10" height="1"/><rect x="2" y="5" width="12" height="4"/><rect x="3" y="9" width="10" height="1"/>` +
76 legs([3, 10], '0s') +
77 legs([5, 12], '0.15s') +
78 eyes +
79 smile +
80 `</g></svg>`
81 )
82}
83
84// A rounded bar in the agent's color; while the agent works the fill gently pulses.
85export function barSvg(color: string, fraction: number, isRunning: boolean): string {
86 const width = 200
87 const fill = Math.max(fraction > 0 ? 8 : 0, Math.round(fraction * width))
88 const pulse = isRunning
89 ? `<animate attributeName="opacity" values="1;0.7;1" dur="1.4s" repeatCount="indefinite"/>`
90 : ''
91 return (
92 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} 8">` +
93 `<rect x="0" y="0" width="${width}" height="8" rx="4" fill="${color}" fill-opacity="0.18"/>` +
94 `<rect x="0" y="0" width="${fill}" height="8" rx="4" fill="${color}">${pulse}</rect>` +
95 `</svg>`
96 )
97}
98
99// The terminal's crab: three lines of text, stepped one frame per redraw.
100const WALK = [
101 ['(\\/) (\\/)', ' (°▽°) ', ' /|/ \\|\\ '],
102 ['(\\|) (|/)', ' (°▽°) ', ' \\|\\ /|/ '],
103 ['(\\/) (\\/)', ' (-▽-) ', ' /|/ \\|\\ '],
104 ['(|/) (\\|)', ' (°▽°) ', ' \\|\\ /|/ '],
105]
106const SCUTTLE = [0, 1, 2, 1]
107
108const STILL: Record<Exclude<AgentRunStatus, 'running'>, string[]> = {
109 done: [' (\\/)(\\/)', ' (^▽^) ', ' /| |\\ '],
110 failed: [' _ _ ', ' (;_;) ', ' /| |\\ '],
111 stopped: [' z ', ' (-_-) ', ' /| |\\ '],
112}
113
114export function crabText(status: AgentRunStatus, frame: number): string[] {
115 if (status !== 'running') return STILL[status].map(line => ` ${line} `)
116 const step = frame % WALK.length
117 const shift = ' '.repeat(SCUTTLE[step])
118 return WALK[step].map(line => (shift + line).padEnd(11))
119}
120
121export function barText(fraction: number, width = 18): string {
122 const filled = Math.round(fraction * width)
123 return '━'.repeat(filled) + '╌'.repeat(width - filled)
124}
125hooks/pricing.ts 72 lines1import type { AgentRow } from '../types'
2
3// Anthropic API list prices in dollars per million tokens (as of 2026-10).
4// Cache writes are priced at 1.25x input (the 5-minute cache). Matched by
5// substring of the model id, most specific first.
6const PRICES: { match: string; input: number; output: number; cacheRead: number }[] = [
7 { match: 'fable-5-1', input: 10, output: 50, cacheRead: 0.25 },
8 { match: 'mythos-5-1', input: 10, output: 50, cacheRead: 0.25 },
9 { match: 'fable-5', input: 10, output: 50, cacheRead: 1 },
10 { match: 'opus-5-5', input: 4, output: 20, cacheRead: 0.2 },
11 { match: 'opus-5', input: 5, output: 25, cacheRead: 0.5 },
12 { match: 'opus-4', input: 5, output: 25, cacheRead: 0.5 },
13 { match: 'sonnet-5', input: 2, output: 10, cacheRead: 0.2 },
14 { match: 'sonnet-4', input: 3, output: 15, cacheRead: 0.3 },
15 { match: 'haiku-5-5', input: 0.1, output: 0.5, cacheRead: 0.01 },
16 { match: 'haiku-4', input: 1, output: 5, cacheRead: 0.1 },
17]
18// An unknown model is priced as the default model, Opus 5.5.
19const FALLBACK = PRICES[3]
20
21type Usage = {
22 input_tokens: number
23 output_tokens: number
24 cache_read_input_tokens: number
25 cache_creation_input_tokens: number
26}
27
28// What one model request cost, at the model that answered it.
29export function costOfUsage(model: string, usage: Usage): number {
30 const price = PRICES.find(p => model.includes(p.match)) ?? FALLBACK
31 return (
32 (usage.input_tokens * price.input +
33 usage.cache_creation_input_tokens * price.input * 1.25 +
34 usage.cache_read_input_tokens * price.cacheRead +
35 usage.output_tokens * price.output) /
36 1_000_000
37 )
38}
39
40export function costOf(row: AgentRow): number {
41 if (row.cost !== undefined) return row.cost
42 // Rows recorded before per-request costs: price them all at their last model.
43 return costOfUsage(row.model, {
44 input_tokens: row.inputTokens ?? 0,
45 output_tokens: row.outputTokens ?? 0,
46 cache_read_input_tokens: row.cacheReadTokens ?? 0,
47 cache_creation_input_tokens: row.cacheWriteTokens ?? 0,
48 })
49}
50
51// What the pane's model picker offers.
52export const MODEL_CHOICES = [
53 { value: 'claude-opus-5-5', label: 'Opus 5.5' },
54 { value: 'claude-sonnet-5-5', label: 'Sonnet 5.5' },
55 { value: 'claude-haiku-5-5', label: 'Haiku 5.5' },
56 { value: 'claude-fable-5-1', label: 'Fable 5.1' },
57]
58
59export function formatCost(dollars: number): string {
60 if (dollars === 0) return '$0'
61 if (dollars < 0.01) return '<$0.01'
62 return `$${dollars < 10 ? dollars.toFixed(2) : dollars.toFixed(1)}`
63}
64
65// "claude-haiku-5-5" -> "Haiku 5.5"
66export function modelName(model: string): string {
67 const found = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2}))?(?!\d)/.exec(model)
68 if (!found) return model
69 const family = found[1][0].toUpperCase() + found[1].slice(1)
70 return found[3] ? `${family} ${found[2]}.${found[3]}` : `${family} ${found[2]}`
71}
72types/index.d.ts 60 lines1export type AgentRunStatus = 'running' | 'done' | 'failed' | 'stopped'
2
3// A message the person sent from the pane. The engine keeps it as a hidden row
4// that reading the conversation back leaves out, so the pane keeps its own copy.
5export type SentNote = {
6 text: string
7 sentAt: number
8 // How many conversation messages came before it: where View draws it.
9 afterCount: number
10 // True once a model request of the agent's has carried it.
11 isSeen: boolean
12}
13
14export type AgentRow = {
15 id: string
16 description: string
17 type: string
18 model: string
19 startedAt: number
20 endedAt?: number
21 status: AgentRunStatus
22 steps: number
23 toolCalls: number
24 // Uncached input, cache reads and cache writes are kept apart: each is priced differently.
25 inputTokens: number
26 cacheReadTokens: number
27 cacheWriteTokens: number
28 outputTokens: number
29 activity: string
30 expectedSteps: number
31 // Which palette color this agent's crab and bar wear.
32 colorIndex: number
33 // Dollars so far, added up request by request at each request's own model.
34 cost: number
35 // The model the person picked in the pane; its next requests go there.
36 modelOverride?: string
37 // Counts the person's messages, so the message box starts empty after each.
38 messagesSent: number
39 notes?: SentNote[]
40}
41
42declare module 'claude-code' {
43 interface PluginState {
44 'subagent-monitor': {
45 agents: AgentRow[]
46 now: number
47 isPaneOpen: boolean
48 isPaneDismissed: boolean
49 // The card whose controls are showing, if any.
50 expandedId: string
51 // The agent whose conversation the pane shows in place of the list, if any.
52 viewingId: string
53 // Unsent message text per agent, kept across redraws and view changes.
54 drafts: Record<string, string>
55 // True while View stays pinned to the newest messages; false once the person scrolls up.
56 isFollowing: boolean
57 }
58 }
59}
60