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

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.
from #n in its title.● running, ◐ waiting (on its own background work), ✓ done, ✗ failed, ■ killed. A failed tool call shows its group as ✗.↑N ↓N in its title); [ end ] or scrolling back down follows new steps again.[ close ] (keys 1–3 while the pane is focused); [ close finished ] sits at the bottom.| Command | What it does | ||
|---|---|---|---|
/agents-list | List 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-clear | Forget finished agents |
Closing the whole pane (its [X], Esc, or ctrl+x x) hides every window and clears the queue; /agent brings them back.
/plugin install agent-monitor --marketplace isaac-pathlab/claude-monitor
/agent n.claude --plugin-dir <path-to-clone>/agent-monitor
claude plugin validate agent-monitor
claude plugin test agent-monitor
hooks/register.tsx 566 lines1import { 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}
566types/index.d.ts 33 lines1export 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