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

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 │
╰────────────────────────────────────────╯
| Field | Source |
|---|---|
| Status | turn.complete of the agent's loop (Done / Aborted / Failed), reconciled with $.agent.list() (Failed / Killed) |
| ⏱ Elapsed | Wall-clock time from spawn to end, live while the agent is working |
| ⚡ Working | Time spent in model requests and tool runs, with overlapping work counted once |
| Model, effort | Each turn.step of the agent's loop |
| Cost | Token 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.
/subagents: open the pane/subagents clear: remove finished subagents from the paneThe 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.
hooks/register.tsx 450 lines1import { 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}
450hooks/i18n.ts 40 lines1import { 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}
40hooks/pricing.ts 47 lines1import 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}
47locales/en.ts 25 lines1export 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
25locales/fr.ts 25 lines1import 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}
25types/index.d.ts 45 lines1export 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