SLOPSHOPPER

agent-monitor

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…

newpanebandrowsguardcommand
★ 6v0.2.1MITupdated 2026-10-11GarvitNangru/claude-code-mods/mods/agent-monitor
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-monitor
│ ┃ Agent Monitor ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing to show yet. Claude's plan shows up │ ┃ here for tasks with several steps, and ⏺ Read(src/auth.ts) │ ┃ subagents as soon as one starts. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ 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 │ │ › /agent-monitor │ ⎿ agent-monitor: Agent Monitor panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent Monitor
Nothing to show yet. Claude's plan shows up here for tasks with several steps, and subagents as soon as one starts.
README

Agent Monitor

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.

The bar above the prompt

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.

The panel

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.

Installing

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.

How it knows how far along Claude is

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.

  • In the transcript, the plan shows as one dim line, such as ▸ 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.
  • You're never asked for permission to run it, because it only draws the bar. If you add a rule that denies the tool, your rule wins.
  • It costs a little: the tool and the instructions add a few hundred tokens to each request (they are cached), and each step adds one short tool call.
  • Claude decides what counts as a step, and occasionally skips the bar on a borderline task.

What the subagent numbers mean

  • steps: how many model requests the agent has made so far.
  • ctx: how much context the agent's latest request carried, and what share of the model's context window that is.
  • ≈$: an estimate worked out from token counts and Anthropic's published API prices, with cache writes counted at the one-hour rate Claude Code uses. It is not a bill. On a Claude subscription you aren't charged per token at all, so treat it as a rough measure of how heavy a run was. The price table is USD_PER_MTOK in hooks/register.tsx.
  • time: wall-clock time since the agent started.

A finished agent is marked ✓, one you interrupted ■, and one that failed ✗. Everything the mod shows is kept for the current session only.

Development

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.

Credits

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.

Source 2 files
hooks/register.tsx 634 lines
1import { 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}
634
types/index.d.ts 43 lines
1export 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