Shows one line per running subagent above the prompt: model, task, minutes and its latest tool call

A Claude Code mod. While subagents run, the band above the prompt shows one line per agent:
Sonnet · Build snap screenshot mod · 6 min · now: Bash claude plugin test
now: is the agent's latest tool call (file name, first 40 characters of a Bash command, or pattern), cut to the band width.done (green) for 30 s, or stopped (yellow) if it failed or was stopped, with one toast Agent finished: <description>.agent.spawn. next(e) answers { model, agentId }; the input holds description and subagentType.tool.call with agentId (set only inside a subagent loop). Hooks always pass through.turn.complete with agentId (reason answer is done, anything else stopped), and every 15 s $.agent.list() status (completed, failed, killed) as a fallback.agent-watch.agents in $.state, so a hot reload keeps the rows.turn.complete or $.agent.list().type: teammate) never pass agent.spawn, so they are not shown.claude plugin validate .
claude plugin test .
npx tsc -p .hooks/register.tsx 158 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRow, AgentStatus } from '../types'
5import { DONE_VISIBLE_MS, TICK_MS, describeTool, labelFor, rowText, visibleRows } from './agent'
6
7const agents = atom({ plugin: 'agent-watch', key: 'agents' } as const, {} as Record<string, AgentRow>)
8
9type Ticker = { timer: { cancel: () => void } | null }
10
11// Ends a running agent once: marks the row, toasts, and drops the row after 30 s.
12async function finish($: EngineInterface, id: string, status: AgentStatus): Promise<void> {
13 const now = await $.clock.now()
14 let description: string | null = null
15 await update($, agents, rows => {
16 const row = rows[id]
17 if (!row || row.status !== 'running') return rows
18 description = row.description
19 return { ...rows, [id]: { ...row, status, endedAt: now } }
20 })
21 if (description === null) return
22 $.ui.toast(`Agent finished: ${description}`)
23 $.ui.invalidate('ui.render')
24 $.clock.after(DONE_VISIBLE_MS, async () => {
25 await update($, agents, rows => {
26 const { [id]: _gone, ...rest } = rows
27 return rest
28 })
29 $.ui.invalidate('ui.render')
30 })
31}
32
33// Ends agents the engine lists as no longer running (covers a stop that raises no turn.complete).
34async function reconcile($: EngineInterface): Promise<void> {
35 let list: { id: string; status: string }[] = []
36 try {
37 list = await $.agent.list()
38 } catch {
39 return
40 }
41 const rows = await read($, agents)
42 for (const info of list) {
43 const row = rows[info.id]
44 if (!row || row.status !== 'running') continue
45 if (info.status === 'completed') await finish($, info.id, 'done')
46 else if (info.status === 'failed' || info.status === 'killed') await finish($, info.id, 'stopped')
47 }
48}
49
50async function tick($: EngineInterface, ticker: Ticker): Promise<void> {
51 try {
52 await reconcile($)
53 const now = await $.clock.now()
54 const rows = await read($, agents)
55 const left = visibleRows(rows, now)
56 if (left.length !== Object.keys(rows).length) {
57 await update($, agents, all => Object.fromEntries(left.map(r => [r.id, all[r.id] ?? r])))
58 }
59 if (left.length === 0 && ticker.timer) {
60 ticker.timer.cancel()
61 ticker.timer = null
62 }
63 } catch {}
64 $.ui.invalidate('ui.render')
65}
66
67// Starts the 15 s redraw timer once per module load; a hot reload drops it, so render restarts it.
68function ensureTick($: EngineInterface, ticker: Ticker): void {
69 if (ticker.timer) return
70 ticker.timer = $.clock.every(TICK_MS, () => {
71 void tick($, ticker)
72 })
73}
74
75export const register: Register = on => {
76 const ticker: Ticker = { timer: null }
77 // The latest tool of an agent whose row does not exist yet (its first call can beat the spawn answer).
78 const early = new Map<string, string>()
79
80 on('agent.spawn', async ($, e, next) => {
81 const res = await next(e)
82 try {
83 const id = res.agentId
84 if (id !== undefined) {
85 const startedAt = await $.clock.now()
86 const row: AgentRow = {
87 id,
88 label: labelFor(res.model, e.subagentType),
89 description: e.description,
90 startedAt,
91 tool: early.get(id) ?? '',
92 status: 'running',
93 }
94 early.delete(id)
95 await update($, agents, rows => ({ ...rows, [id]: row }))
96 ensureTick($, ticker)
97 $.ui.invalidate('ui.render')
98 }
99 } catch {}
100 return res
101 })
102
103 on('tool.call', async ($, e, next) => {
104 try {
105 if (e.agentId !== undefined) {
106 const id = e.agentId
107 const tool = describeTool(e as any)
108 const rows = await read($, agents)
109 if (rows[id]) {
110 await update($, agents, all => (all[id] ? { ...all, [id]: { ...all[id], tool } } : all))
111 $.ui.invalidate('ui.render')
112 } else if (early.size < 50) {
113 early.set(id, tool)
114 }
115 }
116 } catch {}
117 return next(e)
118 })
119
120 on('turn.complete', async ($, e, next) => {
121 const res = await next(e)
122 try {
123 if (e.agentId !== undefined) await finish($, e.agentId, e.reason === 'answer' ? 'done' : 'stopped')
124 } catch {}
125 return res
126 })
127
128 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
129 if (e.props.hasSurvey) return next(e)
130 // The band has one slot shared with other mods: our rows on top, their drawing below.
131 const below = await next(e)
132 try {
133 const now = await $.clock.now()
134 const rows = visibleRows(await read($, agents), now)
135 if (rows.length === 0) return below
136 ensureTick($, ticker)
137
138 const { Box, Text } = $.ui.resolve(e)
139 const columns = e.props.bodyColumns
140 return (
141 <Box flexDirection="column">
142 {rows.map(row => (
143 <Text
144 key={`aw-${row.id}`}
145 color={row.status === 'done' ? 'green' : row.status === 'stopped' ? 'yellow' : undefined}
146 >
147 {rowText(row, now, columns)}
148 </Text>
149 ))}
150 {below.type !== 'engine' ? below : null}
151 </Box>
152 )
153 } catch {
154 return below
155 }
156 })
157}
158hooks/agent.ts 54 lines1import type { AgentRow } from '../types'
2
3export const TICK_MS = 15000
4export const DONE_VISIBLE_MS = 30000
5
6const MODELS = ['sonnet', 'opus', 'haiku', 'fable']
7
8// The model's short name when the id or alias holds one, else the subagent type.
9export function labelFor(model: string | undefined, subagentType: string | undefined): string {
10 const lower = (model ?? '').toLowerCase()
11 const known = MODELS.find(m => lower.includes(m))
12 if (known) return known[0]!.toUpperCase() + known.slice(1)
13 return subagentType && subagentType !== '' ? subagentType : 'Agent'
14}
15
16const base = (path: unknown): string => String(path ?? '').split('/').pop() ?? ''
17
18// A short "Tool arg" for the latest tool call. `e` is the tool.call event.
19export function describeTool(e: { tool: string } & Record<string, unknown>): string {
20 const tool = String(e.tool)
21 let arg = ''
22 if (tool === 'Read' || tool === 'Edit' || tool === 'Write') arg = base(e.file_path)
23 else if (tool === 'Bash') arg = String(e.command ?? '').replace(/\s+/g, ' ').trim().slice(0, 40)
24 else if (tool === 'Grep' || tool === 'Glob') arg = String(e.pattern ?? '')
25 return arg === '' ? tool : `${tool} ${arg}`
26}
27
28export function truncate(text: string, columns: number): string {
29 if (columns <= 0 || text.length <= columns) return text
30 return columns === 1 ? '…' : `${text.slice(0, columns - 1)}…`
31}
32
33export function minutes(startedAt: number, now: number): number {
34 return Math.max(0, Math.floor((now - startedAt) / 60000))
35}
36
37// One band line. The `now:` part is cut to the space the columns leave.
38export function rowText(row: AgentRow, now: number, columns: number): string {
39 const head = `${row.label} · ${row.description}`
40 if (row.status === 'done') return truncate(`${head} · done`, columns)
41 if (row.status === 'stopped') return truncate(`${head} · stopped`, columns)
42 const mins = minutes(row.startedAt, now)
43 const withTime = `${head} · ${mins < 1 ? '<1' : mins} min`
44 if (row.tool === '') return truncate(withTime, columns)
45 return truncate(`${withTime} · now: ${row.tool}`, columns)
46}
47
48// Rows to draw: running ones, and finished ones for DONE_VISIBLE_MS.
49export function visibleRows(rows: Record<string, AgentRow>, now: number): AgentRow[] {
50 return Object.values(rows)
51 .filter(r => r.status === 'running' || now - (r.endedAt ?? 0) < DONE_VISIBLE_MS)
52 .sort((a, b) => a.startedAt - b.startedAt)
53}
54types/index.d.ts 24 lines1export type AgentStatus = 'running' | 'done' | 'stopped'
2
3export type AgentRow = {
4 /** The agent's id, as `agent.spawn` answered it and `tool.call` carries it. */
5 id: string
6 /** Model name (`Sonnet`) or the subagent type when no model is known. */
7 label: string
8 /** The Agent call's description. */
9 description: string
10 /** `$.clock.now()` when it started, in ms. */
11 startedAt: number
12 /** Latest tool call, as `Bash claude plugin test`; empty before the first. */
13 tool: string
14 status: AgentStatus
15 /** `$.clock.now()` when it finished, in ms; set once status is not running. */
16 endedAt?: number
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 'agent-watch': { agents: Record<string, AgentRow> }
22 }
23}
24