A live pane of the session's subagents: what each is doing, how long it has run, and how much context it holds

Mods and other artifacts for Claude Code. Each mod lives in plugins/<name>/; the repository is a plugin marketplace listing them in .claude-plugin/marketplace.json.
| Mod | What it does |
|---|---|
usage-meter | A band above the prompt: a context-window progress bar, plus the 5h and weekly usage limits as percentages with the time until each resets. |
subagent-monitor | A pane (/subagents, or opens by itself when a subagent starts) listing each subagent: its status, what it is doing now, how long it has run, and how much context it holds. |
At the prompt of a Claude Code terminal session:
/plugin install usage-meter --marketplace Ztubben/claude-code-mods
/plugin install subagent-monitor --marketplace Ztubben/claude-code-mods
Answer y to add the marketplace, then pick a scope (user scope loads it in every session).
From a shell:
claude plugin install usage-meter --marketplace Ztubben/claude-code-mods
claude plugin install subagent-monitor --marketplace Ztubben/claude-code-mods
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with Ztubben/claude-code-mods, then Add plugin.
hooks/register.tsx 235 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ThemeKey, TurnUsage } from 'claude-code'
3
4import type { AgentRow } from '../types'
5
6const PANE = 'subagents'
7const TITLE = 'Subagents'
8const KEEP = 50
9
10const agents = atom({ plugin: 'subagent-monitor', key: 'agents' } as const, [])
11const now = atom({ plugin: 'subagent-monitor', key: 'now' } as const, 0)
12const contextWindow = atom({ plugin: 'subagent-monitor', key: 'window' } as const, 0)
13
14const ENDED: ReadonlySet<AgentRow['status']> = new Set(['completed', 'failed', 'killed'])
15const isActive = (row: AgentRow) => !ENDED.has(row.status)
16
17/** The tool argument that best says what a call is about, by the built-in tools' names for it. */
18const ARG_KEYS = ['command', 'file_path', 'pattern', 'path', 'url', 'query', 'description', 'prompt', 'skill']
19
20export function summarizeCall(tool: string, input: Readonly<Record<string, unknown>>): string {
21 for (const key of ARG_KEYS) {
22 const value = input[key]
23 if (typeof value === 'string' && value.trim() !== '') {
24 return `${tool} ${value.replace(/\s+/g, ' ').trim()}`
25 }
26 }
27 return tool
28}
29
30/** What the agent's next request re-sends: the last response's whole input side plus what it wrote. */
31export function contextOf(usage: TurnUsage): number {
32 return (
33 usage.input_tokens +
34 usage.cache_read_input_tokens +
35 usage.cache_creation_input_tokens +
36 usage.output_tokens
37 )
38}
39
40export function formatElapsed(ms: number): string {
41 const s = Math.max(0, Math.floor(ms / 1000))
42 if (s < 60) return `${s}s`
43 const m = Math.floor(s / 60)
44 if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
45 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
46}
47
48export function formatTokens(n: number): string {
49 if (n < 1000) return `${n}`
50 if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
51 return `${(n / 1_000_000).toFixed(2)}M`
52}
53
54const patch = ($: EngineInterface, id: string, fn: (row: AgentRow) => AgentRow) =>
55 update($, agents, list => list.map(row => (row.id === id ? fn(row) : row)))
56
57/** Adds a row for an agent first seen by id alone (spawned before a reload, or by a path no spawn hook saw). */
58async function ensure($: EngineInterface, id: string): Promise<void> {
59 const list = await read($, agents)
60 if (list.some(row => row.id === id)) return
61 const info = (await $.agent.list()).find(one => one.id === id)
62 const row: AgentRow = {
63 id,
64 description: info?.description ?? id,
65 type: info?.type ?? 'agent',
66 status: info?.status ?? 'running',
67 startedAt: await $.clock.now(),
68 contextTokens: 0,
69 steps: 0,
70 toolCalls: 0,
71 activity: 'starting',
72 }
73 await update($, agents, rows => [...rows, row].slice(-KEEP))
74}
75
76/** Each second while any agent is active: advance the clock the pane reads, and take statuses from the engine. */
77async function tick($: EngineInterface): Promise<void> {
78 const list = await read($, agents)
79 if (!list.some(isActive)) return
80 const at = await $.clock.now()
81 const infos = new Map((await $.agent.list()).map(info => [info.id, info]))
82 await update($, agents, rows =>
83 rows.map(row => {
84 const status = infos.get(row.id)?.status ?? row.status
85 if (status === row.status) return row
86 return { ...row, status, endedAt: ENDED.has(status) ? (row.endedAt ?? at) : undefined }
87 }),
88 )
89 await update($, now, () => at)
90 const { context } = await $.session.usage()
91 await update($, contextWindow, () => context.window)
92}
93
94const STATUS_LOOK: Record<AgentRow['status'], { glyph: string; color: ThemeKey }> = {
95 pending: { glyph: '○', color: 'subtle' },
96 running: { glyph: '●', color: 'success' },
97 waiting: { glyph: '◐', color: 'warning' },
98 idle: { glyph: '◌', color: 'suggestion' },
99 completed: { glyph: '✓', color: 'subtle' },
100 failed: { glyph: '✗', color: 'error' },
101 killed: { glyph: '■', color: 'error' },
102}
103
104export const register: Register = on => {
105 on('session.start', async ($, e, next) => {
106 $.clock.every(1000, () => void tick($))
107 await $.command.register({
108 name: 'subagents',
109 description: 'Show what the subagents are doing, how long they have run and their context use',
110 })
111
112 return next(e)
113 })
114
115 on('command.run', { command: 'subagents' }, async $ => {
116 await $.ui.open({ id: PANE, title: TITLE })
117
118 return { text: 'Subagents pane opened.' }
119 })
120
121 on('agent.spawn', async ($, e, next) => {
122 const started = await next(e)
123 if (started.agentId === undefined) return started
124
125 const at = await $.clock.now()
126 const row: AgentRow = {
127 id: started.agentId,
128 description: e.description,
129 type: e.subagentType,
130 model: started.model,
131 status: 'running',
132 startedAt: at,
133 contextTokens: 0,
134 steps: 0,
135 toolCalls: 0,
136 activity: 'starting',
137 }
138 await update($, agents, list => [...list.filter(one => one.id !== row.id), row].slice(-KEEP))
139 await update($, now, () => at)
140 void $.ui.open({ id: PANE, title: TITLE })
141
142 return started
143 }).catch(($, e, next) => next(e))
144
145 on('turn.start', async ($, e, next) => {
146 if (e.agentId === undefined) return next(e)
147 await ensure($, e.agentId)
148 await patch($, e.agentId, row => ({ ...row, status: 'running', endedAt: undefined }))
149
150 return next(e)
151 })
152
153 on('tool.call', async ($, e, next) => {
154 if (e.agentId === undefined) return next(e)
155 const id = e.agentId
156 await ensure($, id)
157 const activity = summarizeCall(e.tool, e as unknown as Record<string, unknown>)
158 await patch($, id, row => ({ ...row, toolCalls: row.toolCalls + 1, activity }))
159
160 return next(e)
161 }).catch(($, e, next) => next(e))
162
163 on('turn.step', async function* ($, e, next) {
164 if (e.agentId === undefined) return yield* next(e)
165 const id = e.agentId
166 const response = yield* next(e)
167 await ensure($, id)
168 await patch($, id, row => ({
169 ...row,
170 steps: row.steps + 1,
171 model: response.usage?.model ?? row.model ?? e.model,
172 contextTokens: response.usage ? contextOf(response.usage) : row.contextTokens,
173 activity: response.toolUses.length === 0 && response.answer !== '' ? 'writing its answer' : row.activity,
174 }))
175
176 return response
177 })
178
179 on('turn.complete', async ($, e, next) => {
180 if (e.agentId === undefined) return next(e)
181 const id = e.agentId
182 const at = await $.clock.now()
183 await patch($, id, row => ({
184 ...row,
185 status: e.reason === 'answer' ? 'completed' : e.reason === 'aborted' ? 'killed' : 'failed',
186 endedAt: at,
187 activity: e.reason === 'answer' ? 'done' : e.reason,
188 }))
189 await update($, now, () => at)
190
191 return next(e)
192 })
193
194 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
195 const { Box, Text } = $.ui.resolve(e)
196 const list = await read($, agents)
197 const at = await read($, now)
198 const win = await read($, contextWindow)
199 const active = list.filter(isActive)
200 const ended = list.filter(row => !isActive(row)).reverse()
201 const rows = Math.max(1, (e.viewport?.rows ?? 24) - 3)
202 const shown = [...active, ...ended].slice(0, Math.max(1, Math.floor(rows / 3)))
203
204 return (
205 <Box flexDirection="column">
206 <Text bold>
207 {active.length} active · {ended.length} finished
208 </Text>
209 {list.length === 0 && <Text dimColor>No subagents yet this session.</Text>}
210 {shown.map(row => {
211 const look = STATUS_LOOK[row.status]
212 const elapsed = formatElapsed((row.endedAt ?? Math.max(at, row.startedAt)) - row.startedAt)
213 const percent = win > 0 && row.contextTokens > 0 ? ` (${Math.round((row.contextTokens / win) * 100)}%)` : ''
214 return (
215 <Box key={row.id} flexDirection="column">
216 <Text wrap="truncate-end">
217 <Text color={look.color}>{look.glyph}</Text> <Text bold>{row.type}</Text> {row.description}
218 </Text>
219 <Text dimColor wrap="truncate-end">
220 {' '}
221 {elapsed} · ctx {formatTokens(row.contextTokens)}
222 {percent} · {row.toolCalls} tools · {row.steps} steps{row.model ? ` · ${row.model}` : ''}
223 </Text>
224 <Text dimColor={!isActive(row)} wrap="truncate-end">
225 {' ↳ '}
226 {row.activity}
227 </Text>
228 </Box>
229 )
230 })}
231 </Box>
232 )
233 })
234}
235types/index.d.ts 22 lines1export type AgentRow = {
2 id: string
3 description: string
4 type: string
5 model?: string
6 status: 'pending' | 'running' | 'waiting' | 'idle' | 'completed' | 'failed' | 'killed'
7 startedAt: number
8 endedAt?: number
9 /** Tokens the agent's last model request was answered over, plus its output: what its next request re-sends. */
10 contextTokens: number
11 steps: number
12 toolCalls: number
13 /** What it is doing now: its latest tool call, summarized. */
14 activity: string
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 'subagent-monitor': { agents: AgentRow[]; now: number; window: number }
20 }
21}
22