SLOPSHOPPER

agent-monitor

Live steps of the subagents Claude starts (their text and grouped tool calls), in up to 3 stacked windows (the rest queued)

newpanecommandtoaststatustimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-monitor
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ 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 › /agents-list ⎿ agent-monitor: No agents yet. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

agent-monitor

A Claude Code mod that shows the steps of the subagents Claude starts, live, in an Agents pane beside the transcript: each agent's text in full, and its tool calls grouped the way the transcript groups them ("Ran 2 shell commands", "Read 3 files"). Thinking is never shown.

  • Captures every subagent, background and foreground. Teammates are left out.
  • Up to 3 windows stacked in one pane, equal height. Further agents wait in a queue; closing a window lets the next one in.
  • A subagent started by another subagent gets its own window, with from #n in its title.
  • Windows never close on their own when an agent finishes. The line under the steps and the border show the status: ● running, ◐ waiting (on its own background work), ✓ done, ✗ failed, ■ killed. A failed tool call shows its group as ✗.
  • That status line, right under the last step, also shows the tool-use count and the run time, which ticks each second while the agent runs.
  • Each window scrolls on its own: the mouse wheel moves the window under the pointer, the scroll keys the one last wheeled. Scrolled up, a window stays put (↑N ↓N in its title); [ end ] or scrolling back down follows new steps again.
  • Each window has a clickable [ close ] (keys 1–3 while the pane is focused); [ close finished ] sits at the bottom.

Commands

CommandWhat it does
/agents-listList this session's subagents: number, status, run time, shown/queued/hidden, type, parent
/agent [n]Show agent n. With no number: every running agent, or the newest if none run. Full pane: goes to the front of the queue
`/agent-close <n\done\all>`Close windows (done is the default)
/agent-clearForget finished agents

Closing the whole pane (its [X], Esc, or ctrl+x x) hides every window and clears the queue; /agent brings them back.

Install

/plugin install agent-monitor --marketplace isaac-pathlab/claude-monitor

Notes

  • Steps come from the rows each subagent's loop writes to the transcript; status comes from the session's agent list, checked every second.
  • The agent list lives for the session only; the last 500 steps of each agent are kept.
  • Panes the mod opens on its own appear when the terminal is at least 144 columns wide; otherwise a toast points to /agent n.

Development

claude --plugin-dir <path-to-clone>/agent-monitor
claude plugin validate agent-monitor
claude plugin test agent-monitor

License

MIT

Source 2 files
hooks/register.tsx 566 lines
1import { atom, read, update } from 'claude-code'
2import type { AgentStatus, EngineInterface, Register } from 'claude-code'
3
4import type { AgentRun, AgentRunStatus, FeedLine } from '../types'
5
6const MAX_PANES = 3
7const POLL_MS = 1000
8const FEED_CAP = 500
9// One pane holding up to MAX_PANES stacked agent windows: the engine shows
10// several panes as tabs, so the stacking is drawn inside a single pane.
11const PANE_ID = 'agents'
12
13const agentsAtom = atom({ plugin: 'agent-monitor', key: 'agents' } as const, [])
14const slotsAtom = atom({ plugin: 'agent-monitor', key: 'slots' } as const, [])
15const queueAtom = atom({ plugin: 'agent-monitor', key: 'queue' } as const, [])
16const nextNAtom = atom({ plugin: 'agent-monitor', key: 'nextN' } as const, 1)
17// Per window, the first row shown while scrolled up; absent means follow the end.
18const scrollAtom = atom({ plugin: 'agent-monitor', key: 'scroll' } as const, {})
19// The time the windows' run times count to; the poll moves it while any agent runs.
20const nowAtom = atom({ plugin: 'agent-monitor', key: 'now' } as const, 0)
21
22type $ = EngineInterface
23
24const glyph: Record<AgentRunStatus, string> = {
25  running: '●',
26  waiting: '◐',
27  done: '✓',
28  failed: '✗',
29  killed: '■',
30}
31
32const isFinal = (run: AgentRun) => run.status === 'done' || run.status === 'failed' || run.status === 'killed'
33
34const statusLabel = (run: AgentRun) => `${glyph[run.status]} ${run.status}`
35
36const clip = (text: string, max: number) =>
37  text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`
38
39const label = (run: AgentRun) => clip(run.description.replace(/\s+/g, ' ').trim() || run.subagentType, 40)
40
41const stripAnsi = (text: string) =>
42  text.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/\x1b\][^\x07]*\x07/g, '')
43
44const statusOf = (status: AgentStatus): AgentRunStatus =>
45  status === 'waiting' || status === 'idle'
46    ? 'waiting'
47    : status === 'completed'
48      ? 'done'
49      : status === 'failed' || status === 'killed'
50        ? status
51        : 'running'
52
53const plural = (count: number, one: string, many = `${one}s`) => `${count} ${count === 1 ? one : many}`
54
55/** A group of consecutive calls of one tool, in the transcript's words. */
56function toolLabel(tool: string, count: number) {
57  switch (tool) {
58    case 'Bash':
59    case 'PowerShell':
60      return `Ran ${plural(count, 'shell command')}`
61    case 'Read':
62      return `Read ${plural(count, 'file')}`
63    case 'Edit':
64      return `Edited ${plural(count, 'file')}`
65    case 'Write':
66      return `Wrote ${plural(count, 'file')}`
67    case 'Grep':
68    case 'Glob':
69      return `Searched ${plural(count, 'time')}`
70    case 'WebFetch':
71      return `Fetched ${plural(count, 'page')}`
72    case 'WebSearch':
73      return `Searched the web ${plural(count, 'time')}`
74    case 'Agent':
75      return `Spawned ${plural(count, 'agent')}`
76    default:
77      return `Called ${tool} ${plural(count, 'time')}`
78  }
79}
80
81const elapsed = (ms: number) => {
82  const seconds = Math.max(0, Math.round(ms / 1000))
83  const minutes = Math.floor(seconds / 60)
84
85  return minutes > 0 ? `${minutes}m ${seconds % 60}s` : `${seconds}s`
86}
87
88type Row = { text: string; isDim: boolean; isError: boolean }
89
90/** The feed as the window draws it: text wrapped to `columns`, one row per tool group. */
91function rowsOf(feed: readonly FeedLine[], columns: number) {
92  const rows: Row[] = []
93  for (const line of feed) {
94    if (line.kind === 'tools') {
95      const text = `${line.isError ? '✗ ' : '⎿ '}${toolLabel(line.tool, line.count)}`
96      rows.push({ text: clip(text, columns), isDim: true, isError: line.isError })
97      continue
98    }
99    if (rows.length > 0) rows.push({ text: '', isDim: false, isError: false })
100    for (const part of stripAnsi(line.text).replace(/\r\n/g, '\n').split('\n')) {
101      if (part === '') {
102        rows.push({ text: '', isDim: false, isError: false })
103        continue
104      }
105      for (let at = 0; at < part.length; at += columns) {
106        rows.push({ text: part.slice(at, at + columns), isDim: false, isError: false })
107      }
108    }
109  }
110
111  return rows
112}
113
114/** Equal window heights: the footer row takes 1, each window splits the rest. */
115const layout = (total: number, count: number) => {
116  const height = Math.max(5, Math.floor((total - 1) / count))
117
118  // Border 2 + header 1 + the status line under the steps 1.
119  return { height, room: Math.max(1, height - 4) }
120}
121
122/** The first row a window shows, its saved place clamped to the rows. */
123const topOf = (saved: number | undefined, length: number, room: number) => {
124  const last = Math.max(0, length - room)
125
126  return saved === undefined ? last : Math.min(Math.max(0, saved), last)
127}
128
129/** The window's text width, from the pane body's columns. */
130const columnsOf = (bodyColumns: number) => Math.max(10, bodyColumns - 2)
131
132const readFeed = async ($: $, n: number) => (await $.state.get({ plugin: 'agent-monitor', key: 'feed', id: String(n) })).value ?? []
133
134function patchAgent($: $, n: number, patch: Partial<AgentRun>) {
135  return update($, agentsAtom, list => list.map(one => (one.n === n ? { ...one, ...patch } : one)))
136}
137
138// Module-local bookkeeping; lost on reload, which the scroll keys tolerate.
139let isPolling = false
140// The window the wheel last moved: where the scroll keys go.
141let lastScrolled: number | undefined
142// The pane's body width at the last draw, which the scroll hook wraps to.
143let lastColumns = 58
144
145/** Saves window n's first row; at the end it follows new rows again. */
146function setTop($: $, n: number, top: number | undefined) {
147  return update($, scrollAtom, map => {
148    const { [String(n)]: _, ...rest } = map
149
150    return top === undefined ? rest : { ...rest, [String(n)]: top }
151  })
152}
153
154async function openPane($: $, n: number, isAsked: boolean) {
155  const opened = await $.ui.open({ id: PANE_ID, title: 'Agents' })
156  if (!opened.isPlaced && !isAsked) {
157    $.ui.toast(`agent #${n} waiting: widen the terminal or run /agent ${n}`)
158  }
159}
160
161async function promote($: $, isAsked: boolean) {
162  for (;;) {
163    const n = (await read($, queueAtom))[0]
164    if (n === undefined) return
165    // Seat inside update() so concurrent promotes never exceed MAX_PANES.
166    let isSeated = false
167    await update($, slotsAtom, list => {
168      isSeated = list.includes(n) || list.length < MAX_PANES
169
170      return isSeated && !list.includes(n) ? [...list, n] : list
171    })
172    if (!isSeated) return
173    await update($, queueAtom, list => list.filter(one => one !== n))
174    await openPane($, n, isAsked)
175  }
176}
177
178/** Shows agent n: a free slot now, else the queue (its head when asked). */
179async function offer($: $, n: number, isAsked: boolean) {
180  const slots = await read($, slotsAtom)
181  if (slots.includes(n)) return
182  await update($, queueAtom, list => {
183    const rest = list.filter(one => one !== n)
184
185    return isAsked ? [n, ...rest] : [...rest, n]
186  })
187  await promote($, isAsked)
188}
189
190/** Takes agent n out of its pane or the queue and fills the freed slot. */
191async function release($: $, n: number, isPaneGone = false) {
192  await update($, slotsAtom, list => list.filter(one => one !== n))
193  await update($, queueAtom, list => list.filter(one => one !== n))
194  await setTop($, n, undefined)
195  await promote($, true)
196  if (!isPaneGone && (await read($, slotsAtom)).length === 0) await $.ui.close({ id: PANE_ID })
197}
198
199async function refreshStatus($: $) {
200  const running = (await read($, agentsAtom)).filter(one => !isFinal(one)).length
201  $.ui.status(running > 0 ? `agents: ${running} running` : undefined)
202}
203
204/**
205 * Brings the tracked agents' status in line with `$.agent.list()`. A finished
206 * foreground agent leaves the list: one missing from it has ended, as `gone` says.
207 */
208async function syncStatus($: $, only?: string, gone: AgentRunStatus = 'done') {
209  const live = (await read($, agentsAtom)).filter(one => !isFinal(one) && (!only || one.agentId === only))
210  if (live.length === 0) return
211  const listed = await $.agent.list()
212  let isEnded = false
213  for (const run of live) {
214    const info = listed.find(one => one.id === run.agentId)
215    const status = info ? statusOf(info.status) : gone
216    if (status === run.status) continue
217    const patch: Partial<AgentRun> = { status }
218    if (status === 'done' || status === 'failed' || status === 'killed') {
219      patch.endedAt = await $.clock.now()
220      isEnded = true
221    }
222    await patchAgent($, run.n, patch)
223  }
224  if (isEnded) await refreshStatus($)
225}
226
227async function poll($: $) {
228  if (isPolling) return
229  isPolling = true
230  try {
231    await syncStatus($)
232    // Each write redraws the pane, so the run times tick once a second.
233    if ((await read($, agentsAtom)).some(one => !isFinal(one))) {
234      const now = await $.clock.now()
235      await update($, nowAtom, () => now)
236    }
237  } finally {
238    isPolling = false
239  }
240}
241
242/** Adds one assistant or tool-result row's visible steps to agent n's feed. */
243async function record($: $, run: AgentRun, type: string, content: readonly { type: string; [field: string]: unknown }[]) {
244  let added = 0
245  await update($, { plugin: 'agent-monitor', key: 'feed', id: String(run.n) }, current => {
246    const feed = [...(current ?? [])]
247    added = 0
248    if (type === 'assistant') {
249      for (const block of content) {
250        const last = feed[feed.length - 1]
251        if (block.type === 'text') {
252          const text = String(block.text ?? '').trim()
253          if (text !== '') feed.push({ kind: 'text', text })
254        } else if (block.type === 'tool_use') {
255          const tool = String(block.name ?? 'tool')
256          const id = String(block.id ?? '')
257          added += 1
258          if (last?.kind === 'tools' && last.tool === tool) {
259            feed[feed.length - 1] = { ...last, count: last.count + 1, toolUseIds: [...last.toolUseIds, id] }
260          } else {
261            feed.push({ kind: 'tools', tool, count: 1, isError: false, toolUseIds: [id] })
262          }
263        }
264        // thinking, redacted_thinking and unknown blocks are never shown.
265      }
266    } else {
267      for (const block of content) {
268        if (block.type !== 'tool_result' || block.is_error !== true) continue
269        const id = String(block.tool_use_id ?? '')
270        const at = feed.findIndex(line => line.kind === 'tools' && line.toolUseIds.includes(id))
271        const line = feed[at]
272        if (line?.kind === 'tools') feed[at] = { ...line, isError: true }
273      }
274    }
275
276    return feed.length <= FEED_CAP ? feed : feed.slice(feed.length - FEED_CAP)
277  })
278  if (added > 0) await patchAgent($, run.n, { toolUses: run.toolUses + added })
279}
280
281/** Closes matching shown windows; with `isQueueToo`, drops matching queued ones as well. */
282async function closeWhere($: $, pick: (run: AgentRun) => boolean, isQueueToo = false) {
283  const agents = await read($, agentsAtom)
284  const slots = await read($, slotsAtom)
285  const queue = await read($, queueAtom)
286  const targets = agents.filter(
287    one => pick(one) && (slots.includes(one.n) || (isQueueToo && queue.includes(one.n))),
288  )
289  for (const run of targets) await release($, run.n)
290
291  return targets.length
292}
293
294export const register: Register = on => {
295  on('session.start', async ($, e, next) => {
296    for (const spec of [
297      { name: 'agents-list', description: 'List the subagents Claude started this session' },
298      { name: 'agent', description: 'Show an agent in a pane; with no number, every running one (else the newest)', argumentHint: '[n]' },
299      { name: 'agent-close', description: 'Close agent panes', argumentHint: '<n|done|all>' },
300      { name: 'agent-clear', description: 'Forget finished agents' },
301    ]) {
302      await $.command.register({ ...spec, immediate: true })
303    }
304
305    $.clock.every(POLL_MS, () => void poll($))
306
307    // After a reload, show again whatever was seated.
308    const seated = await read($, slotsAtom)
309    if (seated.length > 0) void $.ui.open({ id: PANE_ID, title: 'Agents' })
310
311    return next(e)
312  })
313
314  on('agent.spawn', async ($, e, next) => {
315    const spawned = await next(e)
316    if (spawned.deny !== undefined || !spawned.agentId) return spawned
317    // Teammates live their own lives; this pane follows subagents only.
318    if (spawned.teammateId || e.isTeammate) return spawned
319
320    // update() retries on a version miss, so parallel spawns each get their own number.
321    const n = (await update($, nextNAtom, value => value + 1)) - 1
322    const run: AgentRun = {
323      n,
324      agentId: spawned.agentId,
325      parentAgentId: e.parentAgentId,
326      description: e.description,
327      subagentType: e.subagentType,
328      isBackground: e.background,
329      status: 'running',
330      startedAt: await $.clock.now(),
331      toolUses: 0,
332    }
333    await update($, agentsAtom, list => [...list, run])
334    await $.state.set({ plugin: 'agent-monitor', key: 'feed', id: String(n) }, [])
335    await refreshStatus($)
336    await offer($, n, false)
337
338    return spawned
339  })
340
341  // Each row a subagent's loop keeps: its text and tool calls, never its thinking.
342  on('session.append', async ($, e, next) => {
343    const { message } = e
344    if (!e.agentId || message.isMeta || (message.type !== 'assistant' && message.type !== 'user')) return next(e)
345    const run = (await read($, agentsAtom)).find(one => one.agentId === e.agentId)
346    if (!run) return next(e)
347
348    // A user row counts only for its tool results: its text is the agent's prompt.
349    await record($, run, message.type, message.content ?? [])
350
351    return next(e)
352  })
353
354  // A subagent's run ended: settle its status now rather than at the next poll.
355  on('turn.complete', async ($, e, next) => {
356    const done = await next(e)
357    if (e.agentId) {
358      await syncStatus($, e.agentId, e.reason === 'aborted' ? 'killed' : e.reason === 'answer' ? 'done' : 'failed')
359    }
360
361    return done
362  })
363
364  // The person closed the whole pane (its close mark or Esc): hide everything.
365  on('ui.close', async ($, e, next) => {
366    const closed = await next(e)
367    if (e.origin.kind === 'person' && e.id === PANE_ID) {
368      await update($, slotsAtom, () => [])
369      await update($, queueAtom, () => [])
370    }
371
372    return closed
373  })
374
375  on('command.run', { command: 'agents-list' }, async $ => {
376    const agents = await read($, agentsAtom)
377    if (agents.length === 0) return { text: 'No agents yet.' }
378    const slots = await read($, slotsAtom)
379    const queue = await read($, queueAtom)
380    const now = await $.clock.now()
381    const lines = agents.map(run => {
382      const where = slots.includes(run.n) ? 'shown' : queue.includes(run.n) ? 'queued' : 'hidden'
383      const parent = agents.find(one => one.agentId === run.parentAgentId)
384      const from = parent ? ` [from #${parent.n}]` : ''
385      const mode = run.isBackground ? ' [background]' : ''
386
387      return `#${run.n}  ${statusLabel(run)}  ${elapsed((run.endedAt ?? now) - run.startedAt)}  ${where}  ${run.subagentType}${mode}${from}  ${clip(run.description.replace(/\s+/g, ' '), 80)}`
388    })
389
390    return { text: lines.join('\n') }
391  })
392
393  on('command.run', { command: 'agent' }, async ($, e) => {
394    const agents = await read($, agentsAtom)
395    const arg = e.args.trim().replace(/^#/, '')
396    // Bare /agent shows every running agent; with none running, the newest.
397    const running = agents.filter(one => !isFinal(one))
398    const picked =
399      arg !== ''
400        ? agents.filter(one => one.n === Number(arg))
401        : running.length > 0
402          ? running
403          : agents.slice(-1)
404    if (picked.length === 0) {
405      return { text: arg === '' ? 'No agents yet.' : `No agent #${arg}. /agents-list lists them.` }
406    }
407
408    // Offered newest first so the oldest ends up at the head of the queue.
409    for (const run of [...picked].reverse()) await offer($, run.n, true)
410    const slots = await read($, slotsAtom)
411    const shown = picked.filter(one => slots.includes(one.n)).map(one => `#${one.n}`)
412    const queued = picked.filter(one => !slots.includes(one.n)).map(one => `#${one.n}`)
413
414    return {
415      text: [
416        shown.length > 0 ? `Showing ${shown.join(', ')}.` : '',
417        queued.length > 0 ? `Queued next: ${queued.join(', ')}.` : '',
418      ].filter(Boolean).join(' '),
419    }
420  })
421
422  on('command.run', { command: 'agent-close' }, async ($, e) => {
423    const arg = e.args.trim().replace(/^#/, '') || 'done'
424    const count =
425      arg === 'all'
426        ? await closeWhere($, () => true, true)
427        : arg === 'done'
428          ? await closeWhere($, isFinal)
429          : await closeWhere($, one => one.n === Number(arg), true)
430
431    return { text: `Closed ${count} agent pane${count === 1 ? '' : 's'}.` }
432  })
433
434  on('command.run', { command: 'agent-clear' }, async $ => {
435    const agents = await read($, agentsAtom)
436    const finished = agents.filter(isFinal)
437    for (const run of finished) {
438      await release($, run.n)
439      await $.state.set({ plugin: 'agent-monitor', key: 'feed', id: String(run.n) }, [])
440    }
441    await update($, agentsAtom, list => list.filter(one => !isFinal(one)))
442
443    return { text: `Forgot ${finished.length} finished agent${finished.length === 1 ? '' : 's'}.` }
444  })
445
446  // Each window scrolls on its own, in wrapped rows: the wheel moves the one
447  // under the pointer, the scroll keys the one the wheel last moved (or the only one shown).
448  on('ui.scroll', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
449    const slots = await read($, slotsAtom)
450    if (slots.length === 0) return {}
451    const { height, room } = layout(e.bodyRows, slots.length)
452    const n = e.pointer
453      ? slots[Math.floor(e.pointer.row / height)]
454      : lastScrolled !== undefined && slots.includes(lastScrolled)
455        ? lastScrolled
456        : slots.length === 1
457          ? slots[0]
458          : undefined
459    if (n === undefined) return {}
460    if (e.pointer) lastScrolled = n
461
462    const length = rowsOf(await readFeed($, n), lastColumns).length
463    const last = Math.max(0, length - room)
464    const top = topOf((await read($, scrollAtom))[String(n)], length, room)
465    const moved = Math.min(Math.max(0, top + e.by), last)
466    await setTop($, n, moved >= last ? undefined : moved)
467
468    return {}
469  })
470
471  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e, next) => {
472    const agents = await read($, agentsAtom)
473    const shown = (await read($, slotsAtom))
474      .map(n => agents.find(one => one.n === n))
475      .filter((one): one is AgentRun => one !== undefined)
476    if (shown.length === 0) return next(e)
477    const feeds = await Promise.all(shown.map(run => readFeed($, run.n)))
478    const queued = (await read($, queueAtom)).length
479    const saved = await read($, scrollAtom)
480    // Read so the poll's once-a-second write redraws the run times.
481    const now = Math.max(await read($, nowAtom), await $.clock.now())
482    const { Box, Text, Button } = $.ui.resolve(e)
483
484    const columns = columnsOf(e.props.bodyColumns)
485    lastColumns = columns
486    const total = e.props.scroll?.bodyRows ?? e.viewport?.rows ?? 30
487    const { height, room } = layout(total, shown.length)
488
489    return (
490      <Box flexDirection="column">
491        {shown.map((run, index) => {
492          const rows = rowsOf(feeds[index] ?? [], columns)
493          const top = topOf(saved[String(run.n)], rows.length, room)
494          const tail = rows.slice(top, top + room)
495          const below = rows.length - top - tail.length
496          const color = run.status === 'failed' ? 'error' : run.status === 'done' ? 'success' : undefined
497          const parent = agents.find(one => one.agentId === run.parentAgentId)
498
499          return (
500            <Box
501              key={`agent-${run.n}`}
502              flexDirection="column"
503              borderStyle="round"
504              borderColor={color}
505              height={height}
506              overflow="hidden"
507            >
508              <Box flexDirection="row" justifyContent="space-between">
509                {/* Scroll marks lead: truncation cuts the end of the title. */}
510                <Text bold wrap="truncate-end">
511                  #{run.n}
512                  {top > 0 && <Text dimColor>{` ↑${top}`}</Text>}
513                  {below > 0 && <Text dimColor>{` ↓${below}`}</Text>}
514                  {` ${label(run)}`}
515                  <Text dimColor>{` · ${run.subagentType}${parent ? ` from #${parent.n}` : ''}`}</Text>
516                </Text>
517                <Box flexDirection="row" gap={1}>
518                  {below > 0 && (
519                    <Button
520                      key={`end-${run.n}`}
521                      label="end"
522                      dimColor
523                      onPress={() => setTop($, run.n, undefined)}
524                    />
525                  )}
526                  <Button
527                    key={`close-${run.n}`}
528                    hotkey={String(index + 1)}
529                    label="close"
530                    dimColor
531                    onPress={() => release($, run.n)}
532                  />
533                </Box>
534              </Box>
535              {/* The status line follows the last step, not the window's bottom edge. */}
536              <Box flexDirection="column" overflow="hidden">
537                {tail.length === 0 && <Text dimColor>{isFinal(run) ? '(no steps)' : 'starting…'}</Text>}
538                {tail.map(row => (
539                  <Text wrap="truncate-end" dimColor={row.isDim && !row.isError} color={row.isError ? 'error' : undefined}>
540                    {row.text === '' ? ' ' : row.text}
541                  </Text>
542                ))}
543                <Text wrap="truncate-end">
544                  <Text color={color}>{statusLabel(run)}</Text>
545                  <Text dimColor>
546                    {` · ${plural(run.toolUses, 'tool use')} · ${elapsed((run.endedAt ?? now) - run.startedAt)}`}
547                  </Text>
548                </Text>
549              </Box>
550            </Box>
551          )
552        })}
553        <Box flexDirection="row" gap={1}>
554          <Button
555            key="close-done"
556            label="close finished"
557            dimColor
558            onPress={() => closeWhere($, isFinal)}
559          />
560          {queued > 0 && <Text dimColor>+{queued} queued</Text>}
561        </Box>
562      </Box>
563    )
564  })
565}
566
types/index.d.ts 33 lines
1export type AgentRunStatus = 'running' | 'waiting' | 'done' | 'failed' | 'killed'
2
3export type FeedLine =
4  | { kind: 'text'; text: string }
5  | { kind: 'tools'; tool: string; count: number; isError: boolean; toolUseIds: string[] }
6
7export type AgentRun = {
8  n: number
9  agentId: string
10  parentAgentId?: string
11  description: string
12  subagentType: string
13  isBackground: boolean
14  status: AgentRunStatus
15  startedAt: number
16  endedAt?: number
17  toolUses: number
18}
19
20declare module 'claude-code' {
21  interface PluginState {
22    'agent-monitor': {
23      agents: AgentRun[]
24      slots: number[]
25      queue: number[]
26      nextN: number
27      scroll: Record<string, number>
28      now: number
29      feed: StateFamily<FeedLine[]>
30    }
31  }
32}
33