A progress bar above the prompt that fills as Claude works through a task, and a panel with the plan and every subagent's model, context use, estimated cost…

Claude Code shows a spinner while it works, but nothing that tells you how far along a longer task is. Agent Monitor adds a progress bar above the prompt that fills as Claude finishes each step, and a panel that shows the whole plan and every subagent in the session.
When you ask for something that takes a few steps, Claude lays out its plan and the bar appears:
● temps.py module + tests ██████████████░░░░░░░░░░ 2/4 Write README.md · 0:41 Panel ✕
You see the task, how many steps are done, what Claude is working on right now and how long it has been going. If Claude starts subagents along the way, the bar adds how many are running. When the last step is done the bar turns green and shows the total time. It clears itself once you send your next message.
Quick questions and one-step requests don't get a bar, because there is nothing to measure progress against. If Claude only runs subagents without a plan, the bar counts those instead (Agents 1/3 · 2 running).
Press ✕ to hide the bar, or Panel to open the full view. You never need a command for the bar; it shows up on its own.
Type /agent-monitor, or press Panel on the bar, to open the side panel. Run the command again to close it. It shows the current plan as a checklist, followed by every subagent in the session:
temps.py module + tests · 2/4
✓ Write temps.py
✓ Write test_temps.py
▸ Write README.md
○ Run tests
Subagents · 1 running · 1 finished · ≈$0.13
● Scan the auth module
Explore · Opus 5.5 · high · 14 steps
ctx 48k (5%) · ≈$0.09 · 1:12
▾ Finished · 1 Clear finished
✓ Read the API docs
general-purpose · Sonnet 5.5 · medium · 9 steps
ctx 31k (3%) · ≈$0.04 · 0:48
In the Claude Code desktop app, the bar and the subagent rows are drawn as graphics, with a small crab that walks while work is going on.
Type this at the Claude Code prompt:
/plugin install agent-monitor --marketplace GarvitNangru/claude-code-mods
It asks whether to add this repo as a marketplace. Say y and pick a scope; the user scope loads it in every session. It starts working right away, no restart needed.
To update it later, run claude plugin update agent-monitor@garvit-mods, then /reload-plugins in an open session. To remove it, run /plugin uninstall agent-monitor@garvit-mods. To run it from a clone instead, see Trying one from a clone.
Claude Code doesn't keep a record of how much of a task is done, so the mod gives Claude a small tool called progress. Claude calls it once with its plan, then again each time it finishes a step. The mod also adds two sentences to Claude's instructions explaining when to use it.
▸ Plan · temps.py module + tests · 4 steps, instead of a full tool block. The step updates after it add nothing there, since the bar already shows them.USD_PER_MTOK in hooks/register.tsx.A finished agent is marked ✓, one you interrupted ■, and one that failed ✗. Everything the mod shows is kept for the current session only.
claude plugin validate mods/agent-monitor
claude plugin test mods/agent-monitor
Once Claude Code has loaded the mod from its folder, tsc -p mods/agent-monitor type-checks it against the API of the installed version.
The idea comes from savvy-progress by JohnnyVizz, which pairs a similar bar and panel with his savvy-flow skill. Agent Monitor is a smaller, standalone take that works without any other plugin.
hooks/register.tsx 634 lines1import { atom, read, update } from 'claude-code'
2import type { AgentStatus, EngineInterface, ModelUsage, Register } from 'claude-code'
3
4import type { AgentRun, Plan, RunStatus } from '../types'
5
6const runs = atom({ plugin: 'agent-monitor', key: 'runs' } as const, [])
7const wave = atom({ plugin: 'agent-monitor', key: 'wave' } as const, { ids: [], isHidden: true })
8const plan = atom({ plugin: 'agent-monitor', key: 'plan' } as const, null)
9const isDoneCollapsed = atom({ plugin: 'agent-monitor', key: 'isDoneCollapsed' } as const, false)
10const now = atom({ plugin: 'agent-monitor', key: 'now' } as const, 0)
11
12const PANE = 'agent-monitor'
13const PROGRESS_TOOL = 'mcp__agent-monitor__progress'
14const MAX_RUNS = 100
15const CLAY = '#D97757'
16const DONE = '#3B9C5F'
17const FAIL = '#D0453F'
18const INK = '#1F1E1D'
19const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif"
20
21const STATUS_COLOR: Record<RunStatus, string> = { running: CLAY, done: DONE, stopped: '#8A8A86', failed: FAIL }
22
23const STATUS_GLYPH: Record<RunStatus, string> = { running: '●', done: '✓', stopped: '■', failed: '✗' }
24
25const PROGRESS_DESCRIPTION =
26 'Shows the user a progress bar above their prompt for the task you are working on. ' +
27 'For any task that takes three or more steps, call it once you have a plan, with a short `title` and the `steps` in order. ' +
28 'Then call it with `done` (how many steps are finished) each time you finish one, and with the full `steps` again if the plan changes. ' +
29 'Send `done` equal to the number of steps when the task is complete. Skip it for questions and one-step tasks. It only draws the bar.'
30
31const PROGRESS_GUIDANCE =
32 'The user sees a progress bar above their prompt that only moves when you report to it. ' +
33 'When a request will take three or more steps (several files to create or edit, a change plus its tests, a multi-part request), ' +
34 `first call ${PROGRESS_TOOL} with a short title and the steps, then call it with \`done\` after you finish each step. ` +
35 'Skip it for questions and one-step requests.'
36
37const PROGRESS_SCHEMA = {
38 type: 'object',
39 properties: {
40 title: { type: 'string', description: 'The task in a few words, e.g. "Add login page".' },
41 steps: {
42 type: 'array',
43 items: { type: 'string' },
44 description: 'The plan in order, each step a few words. Sending it replaces the plan.',
45 },
46 done: { type: 'integer', minimum: 0, description: 'How many steps are finished.' },
47 },
48}
49
50type ProgressInput = { title?: unknown; steps?: unknown; done?: unknown }
51
52type Price = [input: number, output: number, cacheRead: number, cacheWrite1h: number]
53
54const USD_PER_MTOK: [RegExp, Price][] = [
55 [/fable|mythos/, [10, 50, 0.25, 20]],
56 [/opus-5-5/, [4, 20, 0.2, 8]],
57 [/opus/, [5, 25, 0.5, 10]],
58 [/sonnet-4/, [3, 15, 0.3, 6]],
59 [/sonnet/, [2, 10, 0.2, 4]],
60 [/haiku/, [1, 5, 0.1, 2]],
61]
62
63const priceOf = (model: string): Price =>
64 USD_PER_MTOK.find(([re]) => re.test(model.toLowerCase()))?.[1] ?? [4, 20, 0.2, 8]
65
66const costOf = (model: string, u: ModelUsage): number => {
67 const [i, o, r, w] = priceOf(model)
68 return (
69 ((u.input_tokens || 0) * i +
70 (u.output_tokens || 0) * o +
71 (u.cache_read_input_tokens || 0) * r +
72 (u.cache_creation_input_tokens || 0) * w) /
73 1e6
74 )
75}
76
77const contextOf = (u: ModelUsage): number =>
78 (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
79
80const windowOf = (model: string): number => (/haiku/i.test(model) ? 200_000 : 1_000_000)
81
82const modelName = (id: string): string => {
83 const [, family, major, minor] = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id) ?? []
84 if (!family) return id.replace(/^claude-/, '') || '—'
85 return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? `.${minor}` : ''}`
86}
87
88const fmtTokens = (n: number): string =>
89 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${n}`
90
91const fmtCost = (usd: number): string => `≈$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
92
93const fmtTime = (ms: number): string => {
94 const s = Math.max(0, Math.round(ms / 1000))
95 const h = Math.floor(s / 3600)
96 const m = Math.floor((s % 3600) / 60)
97 const ss = String(s % 60).padStart(2, '0')
98 return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
99}
100
101const clip = (s: string, max: number): string => (s.length > max ? `${s.slice(0, Math.max(1, max - 1))}…` : s)
102
103const isLive = (r: AgentRun): boolean => r.status === 'running'
104
105const endOf = (r: AgentRun, at: number): number => r.endedAt ?? Math.max(at, r.startedAt)
106
107const ctxPct = (r: AgentRun): number => Math.min(100, Math.round((r.contextTokens / windowOf(r.model)) * 100))
108
109const summarize = (list: AgentRun[], at: number) => {
110 const running = list.filter(isLive).length
111 return {
112 total: list.length,
113 running,
114 finished: list.length - running,
115 failed: list.filter(r => r.status === 'failed').length,
116 cost: list.reduce((sum, r) => sum + r.costUsd, 0),
117 time: list.length ? Math.max(...list.map(r => endOf(r, at))) - Math.min(...list.map(r => r.startedAt)) : 0,
118 }
119}
120
121type Summary = ReturnType<typeof summarize>
122
123const isComplete = (p: Plan): boolean => p.done >= p.steps.length
124
125const nextPlan = (prev: Plan | null, input: ProgressInput, at: number): Plan | null => {
126 const title = typeof input.title === 'string' ? input.title.trim() : ''
127 const steps = Array.isArray(input.steps) ? input.steps.map(s => String(s).trim()).filter(Boolean) : []
128 const done = typeof input.done === 'number' && Number.isFinite(input.done) ? Math.max(0, Math.round(input.done)) : undefined
129
130 if (!prev || (steps.length && (isComplete(prev) || (title && title !== prev.title)))) {
131 if (!steps.length) return prev
132 const d = Math.min(steps.length, done ?? 0)
133 return {
134 title: title || 'Working',
135 steps,
136 done: d,
137 startedAt: at,
138 endedAt: d >= steps.length ? at : undefined,
139 isStale: false,
140 isHidden: false,
141 }
142 }
143
144 const nextSteps = steps.length ? steps : prev.steps
145 const d = Math.min(nextSteps.length, done ?? prev.done)
146 return {
147 ...prev,
148 title: title || prev.title,
149 steps: nextSteps,
150 done: d,
151 endedAt: d >= nextSteps.length ? (prev.endedAt ?? at) : undefined,
152 isStale: false,
153 }
154}
155
156type Band = {
157 title: string
158 ratio: number
159 label: string
160 detail: string
161 color: string
162 glyph: string
163 isLive: boolean
164}
165
166const agentsRunning = (n: number): string => (n ? `${n} agent${n > 1 ? 's' : ''} running` : '')
167
168const planBand = (p: Plan, agents: Summary | null, at: number, isWorking: boolean): Band => {
169 const total = p.steps.length
170 const complete = isComplete(p)
171 const detail = [
172 complete ? `done in ${fmtTime((p.endedAt ?? at) - p.startedAt)}` : (p.steps[p.done] ?? ''),
173 agentsRunning(agents?.running ?? 0),
174 !complete && isWorking ? fmtTime(at - p.startedAt) : '',
175 ]
176 return {
177 title: p.title,
178 ratio: total ? p.done / total : 0,
179 label: `${p.done}/${total}`,
180 detail: detail.filter(Boolean).join(' · '),
181 color: complete ? DONE : CLAY,
182 glyph: complete ? '✓' : '●',
183 isLive: !complete,
184 }
185}
186
187const waveBand = (s: Summary): Band => ({
188 title: 'Agents',
189 ratio: s.total ? s.finished / s.total : 0,
190 label: s.running ? `${s.finished}/${s.total}` : s.failed ? `${s.failed} of ${s.total} failed` : `${s.total} done`,
191 detail: [s.running ? `${s.running} running` : '', fmtCost(s.cost), fmtTime(s.time)].filter(Boolean).join(' · '),
192 color: s.running ? CLAY : s.failed ? FAIL : DONE,
193 glyph: s.running ? '●' : s.failed ? '✗' : '✓',
194 isLive: s.running > 0,
195})
196
197const runMeta = (r: AgentRun): string =>
198 [r.type, modelName(r.model), r.effort, `${r.steps} steps`].filter(Boolean).join(' · ')
199
200const runStats = (r: AgentRun, at: number): string =>
201 `ctx ${fmtTokens(r.contextTokens)} (${ctxPct(r)}%) · ${fmtCost(r.costUsd)} · ${fmtTime(endOf(r, at) - r.startedAt)}`
202
203const planRow = (input: unknown): string | null => {
204 const { title, steps } = (input ?? {}) as ProgressInput
205 if (!Array.isArray(steps) || !steps.length) return null
206 return `Plan · ${typeof title === 'string' && title ? `${title} · ` : ''}${steps.length} steps`
207}
208
209const textBar = (ratio: number, width: number): string => {
210 const filled = Math.round(width * Math.min(1, Math.max(0, ratio)))
211 return '█'.repeat(filled) + '░'.repeat(width - filled)
212}
213
214const xml = (s: string): string => s.replace(/[&<>"]/g, c => `&#${c.charCodeAt(0)};`)
215
216const fit = (s: string, maxPx: number, size: number): string => clip(s, Math.max(1, Math.floor(maxPx / (size * 0.58))))
217
218const SVG_CSS = `<style>
219.t{fill:#1f1f1f}.s{fill:#6b6b68}.k{fill:#e7e6e2}.ln{stroke:#e4e4e1}
220@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a3a39f}.k{fill:#2e2e2c}.ln{stroke:#333331}}
221.walk .la{animation:st .5s steps(1) infinite}.walk .lb{animation:st .5s steps(1) infinite -.25s}
222.walk .bd{animation:bob .5s steps(1) infinite -.125s}
223.flow{animation:flow .8s linear infinite}.live{animation:pulse 1.6s ease-in-out infinite}
224@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
225@keyframes flow{to{transform:translateX(12px)}}@keyframes pulse{50%{opacity:.3}}
226@media (prefers-reduced-motion: reduce){.walk g,.flow,.live{animation:none}}
227</style>`
228
229const svg = (W: number, H: number, body: string): string =>
230 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${SVG_CSS}${body}</svg>`
231
232const crab = (x: number, y: number, isWalking: boolean, opacity = 1): string => {
233 const px = (cx: number, cy: number, w: number, h: number, c = CLAY) =>
234 `<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`
235 return (
236 `<g transform="translate(${x},${y}) scale(2)" opacity="${opacity}" shape-rendering="crispEdges">` +
237 `<g class="${isWalking ? 'walk' : ''}">` +
238 `<g class="bd">${px(1, 0, 10, 6)}${px(0, 2, 1, 2)}${px(11, 2, 1, 2)}${px(3, 1, 1, 2, INK)}${px(8, 1, 1, 2, INK)}</g>` +
239 `<g class="la">${px(2, 6, 1, 2)}${px(7, 6, 1, 2)}</g>` +
240 `<g class="lb">${px(4, 6, 1, 2)}${px(9, 6, 1, 2)}</g>` +
241 `</g></g>`
242 )
243}
244
245const statusMark = (x: number, y: number, status: RunStatus): string => {
246 const c = STATUS_COLOR[status]
247 if (status === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${c}"/>`
248 if (status === 'done')
249 return `<path d="M${x - 5} ${y}l3.5 3.5 6.5-7" fill="none" stroke="${c}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`
250 if (status === 'failed')
251 return `<path d="M${x - 4} ${y - 4}l8 8M${x + 4} ${y - 4}l-8 8" stroke="${c}" stroke-width="1.8" stroke-linecap="round"/>`
252 return `<rect x="${x - 3.5}" y="${y - 3.5}" width="7" height="7" rx="1.5" fill="${c}"/>`
253}
254
255const BAND_H = 22
256const BAR_H = 14
257
258const bandSvg = (b: Band, W: number): string => {
259 const title = fit(b.title, Math.max(60, W * 0.3), 13)
260 const barX = Math.round(30 + title.length * 13 * 0.58 + 12)
261 const tail = fit(b.detail ? `${b.label} · ${b.detail}` : b.label, Math.max(60, W - barX - 90), 12)
262 const barW = Math.max(80, W - barX - Math.round(tail.length * 12 * 0.55) - 10)
263 const fillW = Math.round(barW * Math.min(1, b.ratio))
264 const y0 = (BAND_H - BAR_H) / 2
265 const stripes: string[] = []
266 for (let x = -BAR_H - 12; x < fillW; x += 12) stripes.push(`<path d="M${x} ${BAR_H}l${BAR_H} -${BAR_H}h5l-${BAR_H} ${BAR_H}z"/>`)
267 return svg(
268 W,
269 BAND_H,
270 `${crab(0, 3, b.isLive)}
271<text class="t" x="30" y="${BAND_H / 2 + 4.5}" font-family="${FONT}" font-size="13" font-weight="600">${xml(title)}</text>
272<defs><clipPath id="f"><rect width="${fillW}" height="${BAR_H}" rx="${BAR_H / 2}"/></clipPath></defs>
273<g transform="translate(${barX},${y0})">
274<rect class="k" width="${barW}" height="${BAR_H}" rx="${BAR_H / 2}"/>
275<rect width="${fillW}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${b.color}"/>
276${b.isLive ? `<g clip-path="url(#f)"><g class="flow" fill="#fff" opacity=".22">${stripes.join('')}</g></g>` : ''}
277</g>
278<text class="s" x="${barX + barW + 10}" y="${BAND_H / 2 + 4.5}" font-family="${FONT}" font-size="12" font-variant-numeric="tabular-nums">${xml(tail)}</text>`,
279 )
280}
281
282const ROW_H = 50
283
284const runSvg = (r: AgentRun, W: number, at: number): string => {
285 const stats = runStats(r, at)
286 const statsW = Math.round(stats.length * 6)
287 const textX = 34
288 const barW = W - textX - 2
289 return svg(
290 W,
291 ROW_H,
292 `${crab(0, 9, isLive(r), isLive(r) ? 1 : 0.55)}
293<text class="t" x="${textX}" y="15" font-family="${FONT}" font-size="13" font-weight="600">${xml(fit(r.description || r.type, W - textX - 20, 13))}</text>
294<text class="s" x="${textX}" y="31" font-family="${FONT}" font-size="11">${xml(fit(runMeta(r), barW - statsW - 12, 11))}</text>
295<text class="s" x="${W - 2}" y="31" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${xml(stats)}</text>
296<rect class="k" x="${textX}" y="38" width="${barW}" height="3" rx="1.5"/>
297<rect x="${textX}" y="38" width="${Math.round((barW * ctxPct(r)) / 100)}" height="3" rx="1.5" fill="${STATUS_COLOR[r.status]}"/>
298${statusMark(W - 8, 11, r.status)}
299<line class="ln" x1="0" y1="${ROW_H - 0.5}" x2="${W}" y2="${ROW_H - 0.5}"/>`,
300 )
301}
302
303async function togglePane($: EngineInterface): Promise<boolean> {
304 if ((await $.ui.panes()).some(p => p.id === PANE)) {
305 await $.ui.close({ id: PANE })
306 return false
307 }
308 const at = await $.clock.now()
309 await update($, now, () => at)
310 await $.ui.open({ id: PANE, title: 'Agent Monitor' })
311 return true
312}
313
314async function dismissBand($: EngineInterface): Promise<void> {
315 await update($, plan, p => (p ? { ...p, isHidden: true } : p))
316 await update($, wave, w => ({ ...w, isHidden: true }))
317}
318
319const ENDED: Partial<Record<AgentStatus, RunStatus>> = { completed: 'done', failed: 'failed', killed: 'stopped' }
320
321async function settleEndedAgents($: EngineInterface, list: AgentRun[], at: number): Promise<void> {
322 const ended = new Map<string, RunStatus>()
323 for (const a of await $.agent.list()) {
324 const status = ENDED[a.status]
325 if (status) ended.set(a.id, status)
326 }
327 if (!list.some(r => isLive(r) && ended.has(r.id))) return
328 await update($, runs, prev =>
329 prev.map(r => (isLive(r) && ended.has(r.id) ? { ...r, status: ended.get(r.id) ?? 'done', endedAt: at } : r)),
330 )
331}
332
333let isTurnRunning = false
334
335async function tick($: EngineInterface): Promise<void> {
336 const list = await read($, runs)
337 const hasLiveAgents = list.some(isLive)
338 if (!hasLiveAgents) {
339 const p = await read($, plan)
340 if (!isTurnRunning || !p || p.isHidden || isComplete(p)) return
341 }
342 const at = await $.clock.now()
343 if (hasLiveAgents) await settleEndedAgents($, list, at)
344 await update($, now, () => at)
345}
346
347export const register: Register = on => {
348 on('session.start', async ($, e, next) => {
349 const started = await next(e)
350 await $.command.register({
351 name: 'agent-monitor',
352 description: "Show or hide the Agent Monitor panel: Claude's current plan and every subagent with its model, context, estimated cost and time",
353 })
354 await $.tool.register({ name: 'progress', description: PROGRESS_DESCRIPTION, inputSchema: PROGRESS_SCHEMA })
355 $.clock.every(1000, () => void tick($))
356 return started
357 })
358
359 on('command.run', { command: 'agent-monitor' }, async $ => ({
360 text: (await togglePane($)) ? 'Agent Monitor panel opened.' : 'Agent Monitor panel closed.',
361 }))
362
363 on('tool.describe', { tool: PROGRESS_TOOL }, async ($, e, next) => ({ ...(await next(e)), isDeferred: false }))
364
365 on('tool.check', { tool: PROGRESS_TOOL }, async ($, e, next) => {
366 const verdict = await next(e)
367 return verdict.decision === 'deny' ? verdict : { decision: 'allow', reason: 'It only draws the progress bar.' }
368 })
369
370 on('prompt.compose', async ($, e, next) => {
371 const composed = await next(e)
372 if (!e.tools.includes(PROGRESS_TOOL)) return composed
373 return { sections: [...composed.sections, { id: 'agent-monitor:progress', text: PROGRESS_GUIDANCE, scope: 'session' }] }
374 })
375
376 on('tool.call', { tool: PROGRESS_TOOL }, async ($, e) => {
377 if (e.agentId) return { result: 'Ignored: the progress bar follows the main task only.' }
378 const at = await $.clock.now()
379 const p = await update($, plan, prev => nextPlan(prev, e as unknown as ProgressInput, at))
380 await update($, now, () => at)
381 if (!p) return { result: 'No plan yet: call it with a title and steps first.' }
382 return { result: `Shown: ${p.done} of ${p.steps.length} steps done.` }
383 })
384
385 on('turn.start', ($, e, next) => {
386 isTurnRunning = true
387 return next(e)
388 })
389
390 on('agent.spawn', async ($, e, next) => {
391 const started = await next(e)
392 if (started.deny !== undefined || !started.agentId) return started
393
394 const at = await $.clock.now()
395 const run: AgentRun = {
396 id: started.agentId,
397 type: e.subagentType,
398 description: e.description,
399 model: started.model,
400 status: 'running',
401 startedAt: at,
402 contextTokens: 0,
403 costUsd: 0,
404 steps: 0,
405 }
406 const list = await update($, runs, prev => [...prev.filter(r => r.id !== run.id), run].slice(-MAX_RUNS))
407 const liveIds = new Set(list.filter(isLive).map(r => r.id))
408 await update($, wave, w =>
409 w.ids.some(id => liveIds.has(id)) ? { ...w, ids: [...w.ids, run.id] } : { ids: [run.id], isHidden: false },
410 )
411 await update($, now, () => at)
412 return started
413 })
414
415 on('turn.step', async function* ($, e, next) {
416 const result = yield* next(e)
417 const usage = result.usage
418 const agentId = e.agentId
419 if (!agentId || !usage) return result
420
421 const model = usage.model || e.model
422 await update($, runs, list =>
423 list.map(r =>
424 r.id !== agentId
425 ? r
426 : {
427 ...r,
428 model,
429 effort: typeof e.effort === 'string' ? e.effort : r.effort,
430 status: 'running',
431 endedAt: undefined,
432 contextTokens: contextOf(usage),
433 costUsd: r.costUsd + costOf(model, usage),
434 steps: r.steps + 1,
435 },
436 ),
437 )
438 return result
439 })
440
441 on('turn.complete', async ($, e, next) => {
442 const agentId = e.agentId
443 if (!agentId) {
444 isTurnRunning = false
445 return next(e)
446 }
447 const at = await $.clock.now()
448 const status: RunStatus = e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'stopped' : 'failed'
449 await update($, runs, list =>
450 list.map(r => {
451 if (r.id !== agentId) return r
452 const usage = r.steps === 0 ? e.usage : undefined
453 return {
454 ...r,
455 status,
456 endedAt: at,
457 ...(usage
458 ? { model: usage.model || r.model, costUsd: costOf(usage.model || r.model, usage), contextTokens: contextOf(usage) }
459 : {}),
460 }
461 }),
462 )
463 await update($, now, () => at)
464 return next(e)
465 })
466
467 on('prompt.submit', async ($, e, next) => {
468 const p = await read($, plan)
469 if (p) await update($, plan, prev => (!prev || isComplete(prev) || prev.isStale ? null : { ...prev, isStale: true }))
470 const w = await read($, wave)
471 if (w.ids.length) {
472 const list = await read($, runs)
473 if (!list.some(r => isLive(r) && w.ids.includes(r.id))) await update($, wave, () => ({ ids: [], isHidden: true }))
474 }
475 return next(e)
476 })
477
478 on('ui.render', { component: 'ToolUse', props: { tool: PROGRESS_TOOL } }, ($, e) => {
479 const { Box, Text } = $.ui.resolve(e)
480 const row = planRow(e.props.input)
481 if (!row) return <Box />
482 return (
483 <Box paddingLeft={2}>
484 <Text dimColor wrap="truncate-end">
485 ▸ {row}
486 </Text>
487 </Box>
488 )
489 })
490
491 on('ui.render', { component: 'ToolResult', props: { tool: PROGRESS_TOOL } }, ($, e) => {
492 const { Box } = $.ui.resolve(e)
493 return <Box />
494 })
495
496 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
497 if (e.props.hasSurvey) return next(e)
498 const p = await read($, plan)
499 const w = await read($, wave)
500 const waveRuns = w.isHidden ? [] : (await read($, runs)).filter(r => w.ids.includes(r.id))
501 const at = await read($, now)
502 const agents = waveRuns.length ? summarize(waveRuns, at) : null
503 const band = p && !p.isHidden ? planBand(p, agents, at, e.props.isWorking) : agents ? waveBand(agents) : null
504 if (!band) return next(e)
505
506 const ui = $.ui.resolve(e)
507 const { Box, Text, Button } = ui
508 const open = <Button key="open" label="Panel" plain onPress={() => void togglePane($)} />
509 const dismiss = <Button key="dismiss" label="✕" plain role="dismiss" onPress={() => dismissBand($)} />
510
511 if (e.surface === 'desktop' && 'Svg' in ui) {
512 const { Svg } = ui
513 const width = Math.max(180, Math.min(1400, (e.props.bodyColumns || 100) * 8 - 120))
514 return (
515 <Box flexDirection="row" alignItems="center" gap={1}>
516 <Svg source={bandSvg(band, width)} alt={`${band.title}: ${band.label}, ${band.detail}`} width={width} height={BAND_H} />
517 {open}
518 {dismiss}
519 </Box>
520 )
521 }
522
523 const cols = e.props.bodyColumns
524 const title = clip(band.title, Math.max(12, Math.floor(cols / 4)))
525 const barW = Math.max(6, Math.min(24, cols - title.length - band.label.length - Math.min(band.detail.length, 30) - 24))
526 return (
527 <Box flexDirection="row" gap={1}>
528 <Text color={band.color}>{band.glyph}</Text>
529 <Text bold>{title}</Text>
530 <Text color={band.color}>{textBar(band.ratio, barW)}</Text>
531 <Text>{band.label}</Text>
532 {band.detail ? (
533 <Text dimColor wrap="truncate-end">
534 {band.detail}
535 </Text>
536 ) : null}
537 {open}
538 {dismiss}
539 </Box>
540 )
541 })
542
543 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
544 const ui = $.ui.resolve(e)
545 const { Box, Text, Button } = ui
546 const p = await read($, plan)
547 const list = await read($, runs)
548 const at = await read($, now)
549 const isCollapsed = await read($, isDoneCollapsed)
550 const running = list.filter(isLive).reverse()
551 const finished = list.filter(r => !isLive(r)).reverse()
552 const s = summarize(list, at)
553
554 const planView = p && (
555 <Box key="plan" flexDirection="column" marginBottom={1}>
556 <Text bold wrap="truncate-end">
557 {p.title} · {p.done}/{p.steps.length}
558 </Text>
559 {p.steps.map((step, i) => (
560 <Text key={`step-${i}`} wrap="truncate-end" dimColor={i < p.done}>
561 <Text color={i < p.done ? DONE : i === p.done ? CLAY : undefined}>{i < p.done ? '✓' : i === p.done ? '▸' : '○'}</Text>{' '}
562 {step}
563 </Text>
564 ))}
565 </Box>
566 )
567 const empty = !p && list.length === 0 && (
568 <Text dimColor>
569 Nothing to show yet. Claude's plan shows up here for tasks with several steps, and subagents as soon as one starts.
570 </Text>
571 )
572 const summary = list.length > 0 && (
573 <Text dimColor wrap="truncate-end">
574 Subagents · {s.running} running · {s.finished} finished · {fmtCost(s.cost)}
575 </Text>
576 )
577 const controls = finished.length > 0 && (
578 <Box key="controls" flexDirection="row" gap={2}>
579 <Button
580 key="toggle-done"
581 label={`${isCollapsed ? '▸' : '▾'} Finished · ${finished.length}`}
582 plain
583 onPress={() => update($, isDoneCollapsed, prev => !prev)}
584 />
585 <Button key="clear" label="Clear finished" plain onPress={() => update($, runs, prev => prev.filter(isLive))} />
586 </Box>
587 )
588
589 if (e.surface === 'desktop' && 'Svg' in ui) {
590 const { Svg } = ui
591 const W = Math.max(240, Math.min(900, (e.props.bodyColumns || 40) * 8 - 8))
592 const row = (r: AgentRun) => (
593 <Svg key={r.id} source={runSvg(r, W, at)} alt={`${r.description}: ${r.status}, ${runStats(r, at)}`} width={W} height={ROW_H} />
594 )
595 return (
596 <Box flexDirection="column">
597 {planView}
598 {empty}
599 {summary}
600 {running.map(row)}
601 {controls}
602 {!isCollapsed && finished.map(row)}
603 </Box>
604 )
605 }
606
607 const row = (r: AgentRun) => (
608 <Box key={r.id} flexDirection="column" marginTop={1}>
609 <Text wrap="truncate-end">
610 <Text color={STATUS_COLOR[r.status]}>{STATUS_GLYPH[r.status]}</Text> <Text bold>{r.description || r.type}</Text>
611 </Text>
612 <Text dimColor wrap="truncate-end">
613 {' '}
614 {runMeta(r)}
615 </Text>
616 <Text dimColor wrap="truncate-end">
617 {' '}
618 {runStats(r, at)}
619 </Text>
620 </Box>
621 )
622 return (
623 <Box flexDirection="column">
624 {planView}
625 {empty}
626 {summary}
627 {running.map(row)}
628 {controls && <Box marginTop={1}>{controls}</Box>}
629 {!isCollapsed && finished.map(row)}
630 </Box>
631 )
632 })
633}
634types/index.d.ts 43 lines1export type RunStatus = 'running' | 'done' | 'stopped' | 'failed'
2
3export type AgentRun = {
4 id: string
5 type: string
6 description: string
7 model: string
8 effort?: string
9 status: RunStatus
10 startedAt: number
11 endedAt?: number
12 contextTokens: number
13 costUsd: number
14 steps: number
15}
16
17export type Wave = {
18 ids: string[]
19 isHidden: boolean
20}
21
22export type Plan = {
23 title: string
24 steps: string[]
25 done: number
26 startedAt: number
27 endedAt?: number
28 isStale: boolean
29 isHidden: boolean
30}
31
32declare module 'claude-code' {
33 interface PluginState {
34 'agent-monitor': {
35 runs: AgentRun[]
36 wave: Wave
37 plan: Plan | null
38 isDoneCollapsed: boolean
39 now: number
40 }
41 }
42}
43