A boxed band above the prompt listing the main agent and each active subagent with its model and context usage.

hooks/register.tsx 257 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { LiveAgent, LiveShell } from '../types'
5
6const MAX_SUBAGENT_ROWS = 4
7const KEEP_AGENTS = 50
8const KEEP_SHELLS = 50
9
10const agents = atom({ plugin: 'agent-monitor', key: 'agents' } as const, {})
11const shells = atom(
12 { plugin: 'agent-monitor', key: 'shells' } as const,
13 {} as Record<string, LiveShell>,
14)
15
16const elapsed = (since: number): string => {
17 const s = Math.max(0, Math.round((Date.now() - since) / 1000))
18 if (s >= 3600) {
19 return `${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60)}m`
20 }
21 if (s >= 60) {
22 return `${Math.floor(s / 60)}m${s % 60}s`
23 }
24
25 return `${s}s`
26}
27const rev = atom({ plugin: 'agent-monitor', key: 'rev' } as const, 0)
28
29const ENDED = ['completed', 'failed', 'killed']
30
31const compact = (n: number | null | undefined): string => {
32 if (n === null || n === undefined) {
33 return '-'
34 }
35 if (n >= 1_000_000) {
36 return `${(n / 1_000_000).toFixed(1)}M`
37 }
38 if (n >= 1_000) {
39 return `${(n / 1_000).toFixed(1)}k`
40 }
41
42 return String(n)
43}
44
45const bar = (percent: number | null): string => {
46 const filled =
47 percent === null ? 0 : Math.round(Math.min(100, Math.max(0, percent)) / 10)
48
49 return '█'.repeat(filled) + '░'.repeat(10 - filled)
50}
51
52const percentColor = (percent: number | null): string => {
53 if (percent !== null && percent >= 85) {
54 return 'error'
55 }
56 if (percent !== null && percent >= 70) {
57 return 'warning'
58 }
59
60 return 'success'
61}
62
63async function addShell(
64 $: EngineInterface,
65 id: string,
66 kind: string,
67 command: string,
68) {
69 await update($, shells, all =>
70 Object.fromEntries(
71 Object.entries({
72 ...all,
73 [id]: { kind, command, startedAt: Date.now(), ended: false },
74 }).slice(-KEEP_SHELLS),
75 ),
76 )
77}
78
79async function endShell($: EngineInterface, id: string | undefined) {
80 if (id === undefined) {
81 return
82 }
83 await update($, shells, all =>
84 all[id] ? { ...all, [id]: { ...all[id], ended: true } } : all,
85 )
86}
87
88export const register: Register = on => {
89 on('session.measure', async ($, e, next) => {
90 await update($, rev, n => n + 1)
91
92 return next(e)
93 })
94
95 on('agent.spawn', async ($, e, next) => {
96 const spawned = await next(e)
97 await update($, rev, n => n + 1)
98
99 return spawned
100 })
101
102 on('turn.step', async function* ($, e, next) {
103 const result = yield* next(e)
104 const id = e.agentId
105
106 if (id !== undefined) {
107 const usage = result.usage
108 const tokens = usage
109 ? usage.input_tokens +
110 usage.cache_read_input_tokens +
111 usage.cache_creation_input_tokens
112 : null
113
114 await update($, agents, all => {
115 const known: LiveAgent | undefined = all[id]
116 const merged: Record<string, LiveAgent> = {
117 ...all,
118 [id]: {
119 model: usage?.model ?? e.model,
120 tokens: tokens ?? known?.tokens ?? null,
121 },
122 }
123
124 return Object.fromEntries(Object.entries(merged).slice(-KEEP_AGENTS))
125 })
126 }
127
128 return result
129 })
130
131 // Background shells (Bash/PowerShell) and monitors: registered from the
132 // tool result, which carries the task id however the backgrounding happened
133 // (run_in_background, Ctrl+B, or an auto-background on timeout).
134 on('tool.call', async ($, e, next) => {
135 const out = await next(e)
136 const input = e as unknown as Record<string, unknown>
137 const result = (out as { result?: Record<string, unknown> }).result
138
139 if (e.tool === 'Bash' || e.tool === 'PowerShell') {
140 const id = result?.backgroundTaskId
141 if (typeof id === 'string') {
142 await addShell($, id, e.tool, String(input.command ?? ''))
143 }
144 } else if (e.tool === 'Monitor') {
145 const id = result?.taskId
146 if (typeof id === 'string') {
147 await addShell(
148 $,
149 id,
150 'Monitor',
151 String(input.command ?? input.description ?? 'monitor'),
152 )
153 }
154 } else if (e.tool === 'TaskStop') {
155 const id = input.task_id ?? input.shell_id
156 await endShell($, typeof id === 'string' ? id : undefined)
157 }
158
159 return out
160 })
161
162 // A finished background task arrives as a task-notification prompt naming its id.
163 on('prompt.submit', async ($, e, next) => {
164 if (e.origin.kind === 'task-notification') {
165 const id = /<task-id>\s*([^<\s]+)\s*<\/task-id>/.exec(e.text)?.[1]
166 await endShell($, id)
167 }
168
169 return next(e)
170 })
171
172 on('turn.complete', async ($, e, next) => {
173 await update($, rev, n => n + 1)
174
175 return next(e)
176 })
177
178 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
179 if (e.props.hasSurvey || e.props.maxRows < 3) {
180 return next(e)
181 }
182
183 await read($, rev)
184 const live = await read($, agents)
185 const shellMap = await read($, shells)
186 const [model, usage, roster] = await Promise.all([
187 $.session.model(),
188 $.session.usage(),
189 $.agent.list(),
190 ])
191 const { Box, Text } = $.ui.resolve(e)
192
193 const context = usage.context
194 const percent = context.percent ?? null
195 const running = roster.filter(agent => !ENDED.includes(agent.status))
196 const activeShells = Object.entries(shellMap).filter(([, s]) => !s.ended)
197 const items = [
198 ...running.map(agent => ({ agent, shell: undefined })),
199 ...activeShells.map(([id, shell]) => ({ agent: undefined, id, shell })),
200 ]
201 const room = Math.max(0, Math.min(MAX_SUBAGENT_ROWS, e.props.maxRows - 3))
202 const overflow = items.length > room
203 const shown = items.slice(0, overflow ? Math.max(0, room - 1) : room)
204
205 return (
206 <Box
207 flexDirection="column"
208 borderStyle="round"
209 borderDimColor
210 paddingX={1}
211 >
212 <Box gap={2} height={1} overflow="hidden">
213 <Text bold color="claude">
214 main
215 </Text>
216 <Text>{model}</Text>
217 <Text color={percentColor(percent)}>
218 {bar(percent)} {percent === null ? '-' : `${Math.round(percent)}%`}
219 </Text>
220 <Text dimColor>
221 {compact(context.tokens)} / {compact(context.window)}
222 </Text>
223 </Box>
224 {shown.map(item =>
225 item.agent ? (
226 <Box gap={2} height={1} overflow="hidden">
227 <Text dimColor>└</Text>
228 <Text bold>{item.agent.type}</Text>
229 <Text>{live[item.agent.id]?.model ?? '(starting)'}</Text>
230 <Text dimColor>{compact(live[item.agent.id]?.tokens)} tok</Text>
231 <Text dimColor wrap="truncate-end">
232 {item.agent.status} · {item.agent.description}
233 </Text>
234 </Box>
235 ) : (
236 <Box gap={2} height={1} overflow="hidden">
237 <Text dimColor>└</Text>
238 <Text bold color="warning">
239 {item.shell.kind === 'Monitor' ? 'monitor' : 'shell'}
240 </Text>
241 <Text dimColor>{elapsed(item.shell.startedAt)}</Text>
242 <Text dimColor wrap="truncate-end">
243 {item.shell.command}
244 </Text>
245 </Box>
246 ),
247 )}
248 {overflow && (
249 <Text dimColor>
250 └ +{items.length - shown.length} more
251 </Text>
252 )}
253 </Box>
254 )
255 })
256}
257types/index.d.ts 22 lines1export type LiveAgent = {
2 model: string
3 tokens: number | null
4}
5
6export type LiveShell = {
7 kind: string
8 command: string
9 startedAt: number
10 ended: boolean
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'agent-monitor': {
16 agents: Record<string, LiveAgent>
17 shells: Record<string, LiveShell>
18 rev: number
19 }
20 }
21}
22