SLOPSHOPPER

subagent-monitor

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

newpanebandguardcommandtoast
v0.2.0MITupdated 2026-10-25thibbdesigns/subagent-monitor
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · subagent-monitor
│ ┃ Subagents ✕ › fix the failing auth test and add an audit log call │ ┃ z │ ┃ (-_-) ⏺ Read(src/auth.ts) │ ┃ /| |\ ⎿ Read 6 lines │ ┃ No subagents yet ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /subagents │ ⎿ subagent-monitor: Subagent monitor opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Subagents
z (-_-) /| |\ No subagents yet
README

Subagent Monitor

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.

What it shows

  • A summary at the top: estimated cost, total agent time and total tokens across all agents.
  • One card per agent:
  • the task name and an estimated percent done
  • what it is doing right now (for example Grep · login)
  • a progress bar in that agent's own color
  • its type, model, time and tokens
  • Animated crabs: a working agent's crab bobs, snaps its claws and blinks. When the agent ends, its crab and bar turn grey and go still: smiling if it finished, eyes closed if it failed or was stopped.

What you can do

  • View: reads the agent's conversation inside the sidebar. Your own messages show as chat bubbles marked Sent, then Seen. The view follows new messages unless you scroll up, and ↓ Jump to latest brings you back.
  • Model & message:
  • switch a running agent's model (Opus 5.5, Sonnet 5.5, Haiku 5.5, Fable 5.1) from its next request onward
  • type it a message, which it reads at its next step. Enter sends; Shift+Enter starts a new line, and the box grows as you type.
  • Reopening: close the pane and a 🦀 row above the prompt brings it back. /subagents opens it at any time.

On the mobile app, cards offer View only: the app does not draw pickers or text fields yet.

Install

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

Notes

  • Progress is an estimate. Agents don't report how far along they are. The bar compares the steps an agent has taken with how many steps agents of the same type have needed before. It stays under 95% until the agent actually finishes.
  • Cost is an estimate. It multiplies each model request's tokens by that model's Anthropic API list price, with cache reads and writes priced separately. Your real billing may differ. The prices are in hooks/pricing.ts.
  • Picking an unavailable model will fail the agent. If you choose a model your account can't use, that agent's next request fails.

Developing

  • hooks/register.tsx: the hooks, the pane and the controls
  • hooks/art.ts: the crab and the progress bars
  • hooks/pricing.ts: the price table
  • types/index.d.ts: the state the pane keeps

Run the checks:

claude plugin validate .
claude plugin test .

License

MIT

Source 4 files
hooks/register.tsx 702 lines
1import { 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}
702
hooks/art.ts 125 lines
1import 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}
125
hooks/pricing.ts 72 lines
1import 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}
72
types/index.d.ts 60 lines
1export 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