SLOPSHOPPER

subagents-monitor

Live side pane listing the session's subagents: status, elapsed and working time, model, effort and estimated cost.

newpaneguardcommandstatustimer
★ 1v0.2.0MITupdated 2026-10-05Fazzani/claude-mods/plugins/subagents-monitor
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · subagents-monitor
│ ┃ Subagents ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Subagents │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ No subagents yet. ⏺ Update(src/auth.ts) │ ┃ They show up here the moment one starts. ⎿ 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 │ ⎿ subagents-monitor: Subagents pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Subagents
◆ Subagents ● 0 ✓ 0 ──────────────────────────────────────────────────────── No subagents yet. They show up here the moment one starts.
README

subagents-monitor

A side pane, docked to the right of the transcript, showing every subagent of the current Claude Code session as a live card:

◆ Subagents                         ◐ 2 ✓ 3
──────────────────────────────────────────
╭────────────────────────────────────────╮
│ ◐ Explore auth flow           WORKING  │
│ Explore · sonnet 5.5 · medium          │
│ ▰▰▰▰▰▰▱▱▱▱            ⏱ 01:42 ⚡ 00:58 │
│ 312k tok                        ≈$0.08 │
╰────────────────────────────────────────╯
FieldSource
Statusturn.complete of the agent's loop (Done / Aborted / Failed), reconciled with $.agent.list() (Failed / Killed)
⏱ ElapsedWall-clock time from spawn to end, live while the agent is working
⚡ WorkingTime spent in model requests and tool runs, with overlapping work counted once
Model, effortEach turn.step of the agent's loop
CostToken usage of each step multiplied by list prices (hooks/pricing.ts). This is an estimate; the footer shows the session's actual total.

The status line shows ⚙ 2 working · 3 done · ≈$0.41 / $1.20.

Commands

  • /subagents: open the pane
  • /subagents clear: remove finished subagents from the pane

The pane docks beside the transcript in the terminal's fullscreen layout (110+ columns) and in the desktop app; on a narrower terminal it opens above the prompt.

Source 6 files
hooks/register.tsx 450 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRow, AgentStatus, AgentTokens } from '../types'
5import { LOCALES, pickLocale } from './i18n'
6import type { Locale } from './i18n'
7import { costOf, shortModel } from './pricing'
8
9const PANE = 'subagents'
10const PANE_COLUMNS = 54
11
12const agents = atom({ plugin: 'subagents-monitor', key: 'agents' } as const, [])
13const now = atom({ plugin: 'subagents-monitor', key: 'now' } as const, 0)
14const sessionUsd = atom({ plugin: 'subagents-monitor', key: 'sessionUsd' } as const, null)
15
16const COLORS: Record<AgentStatus, string> = {
17  working: '#58A6FF',
18  done: '#3FB950',
19  aborted: '#D29922',
20  failed: '#F85149',
21  killed: '#F85149',
22}
23const GLYPHS: Record<AgentStatus, string> = {
24  working: '●',
25  done: '✓',
26  aborted: '◌',
27  failed: '✗',
28  killed: '✗',
29}
30const ACCENT = '#D97757'
31const SPINNER = ['◐', '◓', '◑', '◒']
32
33const ZERO: AgentTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
34
35function blankRow(id: string, at: number): AgentRow {
36  return {
37    id,
38    description: 'subagent',
39    type: 'agent',
40    parentId: null,
41    isBackground: false,
42    status: 'working',
43    model: null,
44    effort: null,
45    startedAt: at,
46    endedAt: null,
47    workingMs: 0,
48    inFlight: 0,
49    activeSince: null,
50    tokens: ZERO,
51    costUsd: 0,
52  }
53}
54
55async function patch(
56  $: EngineInterface,
57  id: string,
58  fn: (row: AgentRow, at: number) => AgentRow,
59): Promise<void> {
60  const at = await $.clock.now()
61  await update($, agents, list => {
62    const rows = list ?? []
63    const known = rows.some(row => row.id === id)
64    const next = known ? rows : [...rows, blankRow(id, at)]
65
66    return next.map(row => (row.id === id ? fn(row, at) : row)).slice(-100)
67  })
68}
69
70/** A model request or tool run of the agent starts: it is working again. */
71function begin(row: AgentRow, at: number): AgentRow {
72  return {
73    ...row,
74    status: 'working',
75    endedAt: null,
76    inFlight: row.inFlight + 1,
77    activeSince: row.inFlight === 0 ? at : row.activeSince,
78  }
79}
80
81function end(row: AgentRow, at: number): AgentRow {
82  const inFlight = Math.max(0, row.inFlight - 1)
83  if (inFlight > 0 || row.activeSince === null) {
84    return { ...row, inFlight }
85  }
86
87  return { ...row, inFlight, activeSince: null, workingMs: row.workingMs + (at - row.activeSince) }
88}
89
90function finish(row: AgentRow, at: number, status: AgentStatus): AgentRow {
91  const working = row.activeSince === null ? row.workingMs : row.workingMs + (at - row.activeSince)
92
93  return { ...row, status, endedAt: row.endedAt ?? at, inFlight: 0, activeSince: null, workingMs: working }
94}
95
96function elapsedOf(row: AgentRow, at: number): number {
97  return Math.max(0, (row.endedAt ?? at) - row.startedAt)
98}
99
100function workingOf(row: AgentRow, at: number): number {
101  return row.workingMs + (row.activeSince === null ? 0 : Math.max(0, at - row.activeSince))
102}
103
104function clock(ms: number): string {
105  const total = Math.floor(ms / 1000)
106  const h = Math.floor(total / 3600)
107  const m = Math.floor((total % 3600) / 60)
108  const s = total % 60
109  const mm = String(m).padStart(2, '0')
110  const ss = String(s).padStart(2, '0')
111
112  return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`
113}
114
115function usd(value: number): string {
116  if (value === 0) {
117    return '$0'
118  }
119
120  return value < 0.01 ? '<$0.01' : `$${value.toFixed(2)}`
121}
122
123function tokens(t: AgentTokens): string {
124  const total = t.input + t.output + t.cacheRead + t.cacheWrite
125
126  return total >= 1_000_000
127    ? `${(total / 1_000_000).toFixed(1)}M`
128    : total >= 1000
129      ? `${Math.round(total / 1000)}k`
130      : String(total)
131}
132
133function bar(ratio: number, cells: number): [string, string] {
134  const filled = Math.round(Math.min(1, Math.max(0, ratio)) * cells)
135
136  return ['▰'.repeat(filled), '▱'.repeat(cells - filled)]
137}
138
139function depthOf(row: AgentRow, byId: Map<string, AgentRow>): number {
140  let depth = 0
141  let parent = row.parentId
142  while (parent && byId.has(parent) && depth < 4) {
143    depth += 1
144    parent = byId.get(parent)?.parentId ?? null
145  }
146
147  return depth
148}
149
150function summary(list: AgentRow[], total: number | null, locale: Locale): string | undefined {
151  if (list.length === 0) {
152    return undefined
153  }
154  const working = list.filter(row => row.status === 'working').length
155  const done = list.length - working
156  const cost = list.reduce((sum, row) => sum + row.costUsd, 0)
157
158  return `${LOCALES[locale].summary(working, done)} · ≈${usd(cost)}${total === null ? '' : ` / ${usd(total)}`}`
159}
160
161const STATUSES: Record<string, AgentStatus> = {
162  completed: 'done',
163  failed: 'failed',
164  killed: 'killed',
165  stopped: 'killed',
166  cancelled: 'killed',
167}
168
169/** Once a second: advance live timers, reconcile with the engine's list, refresh the status line. */
170async function tick($: EngineInterface, locale: Locale): Promise<void> {
171  const at = await $.clock.now()
172  const list = (await read($, agents)) ?? []
173  const isAnyWorking = list.some(row => row.status === 'working')
174
175  if (isAnyWorking) {
176    await update($, now, () => at)
177    const engine = await $.agent.list()
178    const byId = new Map(engine.map(info => [info.id, info]))
179    await update($, agents, rows =>
180      (rows ?? []).map(row => {
181        const info = byId.get(row.id)
182        if (!info) {
183          return row
184        }
185        const named = row.description === 'subagent' ? { ...row, description: info.description, type: info.type } : row
186        const status = STATUSES[info.status]
187
188        return row.status === 'working' && status ? finish(named, at, status) : named
189      }),
190    )
191  }
192
193  const usage = await $.session.usage()
194  const total = usage.cost?.usd ?? null
195  await update($, sessionUsd, () => total)
196  $.ui.status(summary((await read($, agents)) ?? [], total, locale))
197}
198
199/** The plugin's option, Claude Code's `language` setting, then the process locale. */
200async function resolveLocale($: EngineInterface, option: unknown): Promise<Locale> {
201  const settings = await $.settings.read()
202
203  return pickLocale(
204    option,
205    settings.language,
206    await $.env.get('LC_ALL'),
207    await $.env.get('LC_MESSAGES'),
208    await $.env.get('LANG'),
209  )
210}
211
212export const register: Register = (on, options) => {
213  let locale: Locale = 'en'
214  const t = () => LOCALES[locale]
215
216  on('session.start', async ($, e, next) => {
217    locale = await resolveLocale($, options.language)
218    await $.command.register({
219      name: 'subagents',
220      description: t().commandDescription,
221    })
222    await update($, now, () => Date.now())
223    $.clock.every(1000, () => void tick($, locale))
224    void $.ui.open({ id: PANE, title: t().title, columns: PANE_COLUMNS })
225
226    return next(e)
227  })
228
229  on('command.run', { command: 'subagents' }, async ($, e) => {
230    if (e.args.trim() === 'clear') {
231      await update($, agents, list => (list ?? []).filter(row => row.status === 'working'))
232
233      return { text: t().cleared }
234    }
235    await $.ui.open({ id: PANE, title: t().title, columns: PANE_COLUMNS })
236
237    return { text: t().opened }
238  })
239
240  on('agent.spawn', async ($, e, next) => {
241    const started = await $.clock.now()
242    const result = await next(e)
243    if (result.agentId) {
244      const model = e.fork ? e.parentModel : result.model
245      await patch($, result.agentId, row => ({
246        ...row,
247        description: e.description || row.description,
248        type: e.fork ? 'fork' : e.subagentType,
249        parentId: e.parentAgentId ?? null,
250        isBackground: e.background,
251        model: row.model ?? model,
252        startedAt: Math.min(row.startedAt, started),
253      }))
254      await update($, now, () => started)
255    }
256
257    return result
258  })
259
260  on('turn.step', async function* ($, e, next) {
261    const id = e.agentId
262    if (!id) {
263      return yield* next(e)
264    }
265    await patch($, id, (row, at) => ({
266      ...begin(row, at),
267      model: e.model,
268      effort: e.effort === undefined ? row.effort : String(e.effort),
269    }))
270    try {
271      const result = yield* next(e)
272      const used = result.usage
273      if (used) {
274        const step: AgentTokens = {
275          input: used.input_tokens,
276          output: used.output_tokens,
277          cacheRead: used.cache_read_input_tokens,
278          cacheWrite: used.cache_creation_input_tokens,
279        }
280        await patch($, id, row => ({
281          ...row,
282          model: used.model,
283          tokens: {
284            input: row.tokens.input + step.input,
285            output: row.tokens.output + step.output,
286            cacheRead: row.tokens.cacheRead + step.cacheRead,
287            cacheWrite: row.tokens.cacheWrite + step.cacheWrite,
288          },
289          costUsd: row.costUsd + costOf(used.model, step),
290        }))
291      }
292
293      return result
294    } finally {
295      await patch($, id, end)
296    }
297  })
298
299  on('tool.call', async ($, e, next) => {
300    const id = e.agentId
301    if (!id) {
302      return next(e)
303    }
304    await patch($, id, begin)
305    try {
306      return await next(e)
307    } finally {
308      await patch($, id, end)
309    }
310  })
311
312  on('turn.complete', async ($, e, next) => {
313    const id = e.agentId
314    if (id) {
315      const status: AgentStatus =
316        e.reason === 'aborted' ? 'aborted' : e.reason === 'error' ? 'failed' : 'done'
317      await patch($, id, (row, at) => finish(row, at, status))
318      await tick($, locale)
319    }
320
321    return next(e)
322  })
323
324  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
325    const { Box, Text } = $.ui.resolve(e)
326    const m = t()
327    const list = (await read($, agents)) ?? []
328    const at = Math.max(await read($, now), ...list.map(row => row.endedAt ?? row.startedAt))
329    const total = await read($, sessionUsd)
330    const width = Math.max(28, e.props.bodyColumns)
331    const isNarrow = width < 44
332
333    const working = list.filter(row => row.status === 'working').length
334    const done = list.filter(row => row.status === 'done').length
335    const failed = list.length - working - done
336    const estimated = list.reduce((sum, row) => sum + row.costUsd, 0)
337
338    const byId = new Map(list.map(row => [row.id, row]))
339    const ordered = [...list].sort((a, b) => {
340      const live = Number(b.status === 'working') - Number(a.status === 'working')
341
342      return live !== 0 ? live : b.startedAt - a.startedAt
343    })
344    const spin = SPINNER[Math.floor(at / 1000) % SPINNER.length]
345
346    return (
347      <Box flexDirection="column" width={width}>
348        <Box flexDirection="row" justifyContent="space-between">
349          <Text bold color={ACCENT}>
350            {m.heading}
351          </Text>
352          <Box flexDirection="row" gap={1}>
353            <Text color={COLORS.working}>
354              {working > 0 ? spin : '●'} {working}
355            </Text>
356            <Text color={COLORS.done}>✓ {done}</Text>
357            {failed > 0 && <Text color={COLORS.failed}>✗ {failed}</Text>}
358          </Box>
359        </Box>
360        <Text dimColor>{'─'.repeat(width)}</Text>
361
362        {list.length === 0 && (
363          <Box flexDirection="column" paddingY={1}>
364            <Text dimColor>{m.emptyTitle}</Text>
365            <Text dimColor>{m.emptyHint}</Text>
366          </Box>
367        )}
368
369        {ordered.map(row => {
370          const color = COLORS[row.status]
371          const isLive = row.status === 'working'
372          const elapsed = elapsedOf(row, at)
373          const work = workingOf(row, at)
374          const [full, empty] = bar(elapsed === 0 ? 0 : work / elapsed, isNarrow ? 6 : 10)
375          const indent = depthOf(row, byId) * 2
376
377          return (
378            <Box
379              key={`agent-${row.id}`}
380              flexDirection="column"
381              borderStyle="round"
382              borderColor={color}
383              borderDimColor={!isLive}
384              paddingX={1}
385              marginLeft={indent}
386            >
387              <Box flexDirection="row" justifyContent="space-between">
388                <Box flexShrink={1}>
389                  <Text color={color} bold>
390                    {isLive ? spin : GLYPHS[row.status]}{' '}
391                  </Text>
392                  <Text bold={isLive} wrap="truncate-end">
393                    {row.description}
394                  </Text>
395                </Box>
396                <Text color={color} bold>
397                  {' '}
398                  {m.status[row.status]}
399                </Text>
400              </Box>
401
402              <Box flexDirection="row" gap={1}>
403                <Text color={ACCENT}>{row.type}</Text>
404                <Text dimColor>·</Text>
405                <Text>{shortModel(row.model)}</Text>
406                {row.effort && (
407                  <>
408                    <Text dimColor>·</Text>
409                    <Text italic>{row.effort}</Text>
410                  </>
411                )}
412                {row.isBackground && <Text dimColor>· {m.background}</Text>}
413              </Box>
414
415              <Box flexDirection="row" justifyContent="space-between">
416                <Box flexDirection="row">
417                  <Text color={color}>{full}</Text>
418                  <Text dimColor>{empty}</Text>
419                </Box>
420                <Box flexDirection="row" gap={1}>
421                  <Text dimColor>⏱</Text>
422                  <Text>{clock(elapsed)}</Text>
423                  <Text dimColor>⚡</Text>
424                  <Text>{clock(work)}</Text>
425                </Box>
426              </Box>
427
428              <Box flexDirection="row" justifyContent="space-between">
429                <Text dimColor>{tokens(row.tokens)} {m.tokens}</Text>
430                <Text color={COLORS.done}>≈{usd(row.costUsd)}</Text>
431              </Box>
432            </Box>
433          )
434        })}
435
436        {list.length > 0 && (
437          <>
438            <Text dimColor>{'─'.repeat(width)}</Text>
439            <Box flexDirection="row" justifyContent="space-between">
440              <Text dimColor>{m.footerSubagents} ≈{usd(estimated)}</Text>
441              {total !== null && <Text dimColor>{m.footerSession} {usd(total)}</Text>}
442            </Box>
443            <Text dimColor>{m.legend}</Text>
444          </>
445        )}
446      </Box>
447    )
448  })
449}
450
hooks/i18n.ts 40 lines
1import { en } from '../locales/en'
2import { fr } from '../locales/fr'
3
4export type { Messages } from '../locales/en'
5
6export const LOCALES = { en, fr }
7
8export type Locale = keyof typeof LOCALES
9
10function match(value: unknown): Locale | null {
11  if (typeof value !== 'string') {
12    return null
13  }
14  const tag = value.trim().toLowerCase()
15  if (tag.startsWith('fr')) {
16    return 'fr'
17  }
18  if (tag.startsWith('en')) {
19    return 'en'
20  }
21
22  return null
23}
24
25/**
26 * The first candidate naming a supported locale, English otherwise. Callers pass,
27 * in order: the plugin's `language` option, Claude Code's `language` setting,
28 * then LC_ALL, LC_MESSAGES and LANG.
29 */
30export function pickLocale(...candidates: unknown[]): Locale {
31  for (const candidate of candidates) {
32    const locale = match(candidate)
33    if (locale) {
34      return locale
35    }
36  }
37
38  return 'en'
39}
40
hooks/pricing.ts 47 lines
1import type { AgentTokens } from '../types'
2
3/** USD per million tokens: input, output, cache read. Cache writes bill at 1.25x input. */
4type Price = { input: number; output: number; cacheRead: number }
5
6// Anthropic first-party list prices (cached 2026-09-25). First match wins.
7const PRICES: [RegExp, Price][] = [
8  [/fable-5|mythos-5/, { input: 10, output: 50, cacheRead: 0.25 }],
9  [/opus-5-5/, { input: 4, output: 20, cacheRead: 0.2 }],
10  [/opus-(5|4-[5-8])/, { input: 5, output: 25, cacheRead: 0.5 }],
11  [/opus/, { input: 15, output: 75, cacheRead: 1.5 }],
12  [/sonnet-5/, { input: 2, output: 10, cacheRead: 0.2 }],
13  [/sonnet/, { input: 3, output: 15, cacheRead: 0.3 }],
14  [/haiku-4/, { input: 1, output: 5, cacheRead: 0.1 }],
15  [/haiku/, { input: 0.8, output: 4, cacheRead: 0.08 }],
16]
17
18const FALLBACK: Price = { input: 4, output: 20, cacheRead: 0.2 }
19
20export function priceOf(model: string | null): Price {
21  const id = (model ?? '').toLowerCase()
22
23  return PRICES.find(([pattern]) => pattern.test(id))?.[1] ?? FALLBACK
24}
25
26export function costOf(model: string | null, tokens: AgentTokens): number {
27  const price = priceOf(model)
28
29  return (
30    (tokens.input * price.input +
31      tokens.output * price.output +
32      tokens.cacheRead * price.cacheRead +
33      tokens.cacheWrite * price.input * 1.25) /
34    1_000_000
35  )
36}
37
38/** `claude-sonnet-5-5` → `sonnet 5.5`; anything else is shown as given. */
39export function shortModel(model: string | null): string {
40  if (!model) {
41    return '—'
42  }
43  const match = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d))?/.exec(model)
44
45  return match ? `${match[1]} ${match[2]}${match[3] ? `.${match[3]}` : ''}` : model
46}
47
locales/en.ts 25 lines
1export const en = {
2  commandDescription: 'Show the subagents pane (`/subagents clear` drops finished ones)',
3  cleared: 'Finished subagents cleared.',
4  opened: 'Subagents pane opened.',
5  title: 'Subagents',
6  heading: '◆ Subagents',
7  emptyTitle: 'No subagents yet.',
8  emptyHint: 'They show up here the moment one starts.',
9  status: {
10    working: 'WORKING',
11    done: 'DONE',
12    aborted: 'ABORTED',
13    failed: 'FAILED',
14    killed: 'KILLED',
15  },
16  tokens: 'tok',
17  background: 'bg',
18  footerSubagents: 'Subagents',
19  footerSession: 'Session',
20  legend: '⏱ elapsed · ⚡ working · /subagents clear',
21  summary: (working: number, done: number) => `⚙ ${working} working · ${done} done`,
22}
23
24export type Messages = typeof en
25
locales/fr.ts 25 lines
1import type { Messages } from './en'
2
3export const fr: Messages = {
4  commandDescription: 'Affiche le panneau des sous-agents (`/subagents clear` retire les terminés)',
5  cleared: 'Sous-agents terminés retirés.',
6  opened: 'Panneau des sous-agents ouvert.',
7  title: 'Sous-agents',
8  heading: '◆ Sous-agents',
9  emptyTitle: 'Aucun sous-agent pour le moment.',
10  emptyHint: 'Ils apparaissent ici dès que l’un démarre.',
11  status: {
12    working: 'EN COURS',
13    done: 'TERMINÉ',
14    aborted: 'INTERROMPU',
15    failed: 'ÉCHEC',
16    killed: 'ARRÊTÉ',
17  },
18  tokens: 'tok',
19  background: 'arr.-plan',
20  footerSubagents: 'Sous-agents',
21  footerSession: 'Session',
22  legend: '⏱ écoulé · ⚡ travail · /subagents clear',
23  summary: (working: number, done: number) => `⚙ ${working} en cours · ${done} terminé${done > 1 ? 's' : ''}`,
24}
25
types/index.d.ts 45 lines
1export type AgentStatus = 'working' | 'done' | 'aborted' | 'failed' | 'killed'
2
3export type AgentTokens = {
4  input: number
5  output: number
6  cacheRead: number
7  cacheWrite: number
8}
9
10export type AgentRow = {
11  /** The id `$.agent.list()` and every event of its loop carry. */
12  id: string
13  description: string
14  type: string
15  parentId: string | null
16  isBackground: boolean
17  status: AgentStatus
18  /** Last model that answered (or was asked), short form. */
19  model: string | null
20  effort: string | null
21  startedAt: number
22  endedAt: number | null
23  /** Time spent in model requests and tool runs, overlaps counted once. */
24  workingMs: number
25  /** Model requests and tool runs in flight right now. */
26  inFlight: number
27  /** When `inFlight` last went from 0 to 1; null while idle. */
28  activeSince: number | null
29  tokens: AgentTokens
30  /** Estimated from `tokens` and list prices. */
31  costUsd: number
32}
33
34declare module 'claude-code' {
35  interface PluginState {
36    'subagents-monitor': {
37      agents: AgentRow[]
38      /** Wall clock the pane last ticked at, so live timers redraw. */
39      now: number
40      /** Real session total from the cost ledger. */
41      sessionUsd: number | null
42    }
43  }
44}
45