Docks a narrow pane beside the transcript that follows each running subagent live

Two small Claude Code mods (function-hook plugins) as a plugin marketplace.
One row under the prompt, refreshed after every turn:

| Group | Shows |
|---|---|
⎇ | git branch, ● uncommitted files, ↑/↓ commits ahead of / behind upstream, ✓ when clean |
◆ | the model the main loop runs and its effort level |
ctx | context-window fill over the last 8 turns, then the live % and tokens / window |
tok | what each turn cost (bars), then live session figures, subagents included: in tokens read (cached or not), out tokens generated, the latest request's cache hit rate (yellow below 90 %, red below 50 %: the cache went cold) and the session cost in $ as Claude Code prices it |
5h / 7d | your rate-limit windows: % used and time until reset (subscription accounts) |
Charts turn yellow from 60 % and red from 85 %. The row wraps on narrow terminals.
$ is an estimate. It is Claude Code's own figure (the one /cost shows): each response's reported token counts times the published per-model prices, so input, output, cache writes and cache reads are already weighted correctly. On the API that is close to your bill, but the Console's usage page is authoritative: server-tool fees (web search), 1-hour cache writes, fast mode, data-residency and other pricing modifiers, or Bedrock / Vertex prices can make the invoice differ.$ is only a relative gauge of which turns were heavy. The 5h / 7d percentages are the official figures; they come straight from Anthropic's rate-limit data.in is volume, not cost. It counts cached and uncached input alike; a cache read costs a fraction of a fresh input token (for example 0.05x on Opus 5.5) and output costs 5x input. Only $ applies those weights.in, out and hit count what the mod has seen since it loaded in a session; $ covers the whole session.When a subagent starts, a narrow pane docks beside the transcript and follows each agent live: type, elapsed time, tool count, its latest tool calls (› running, ✓ / ✗ done) and the tail of what it is writing. Finished agents collapse to one line; the pane closes itself 20 s after the last one ends.

The pane opens on its own from 144 terminal columns (a Claude Code rule for panes nobody asked for); on a narrower terminal run /agents-pane.
/plugin marketplace add AndreasOA/claude-code-mods
/plugin install usage-bars@ao-claude-mods
/plugin install agent-watch@ao-claude-mods
Restart Claude Code (or open a new session) to load them. /plugin marketplace update ao-claude-mods pulls new versions.
Each plugin is plugins/<name>/ with hooks/register.tsx, its state contract in types/index.d.ts and tests in tests/.
claude plugin validate plugins/usage-bars
claude plugin test plugins/usage-bars
claude --plugin-dir plugins/usage-bars # load a working copy, hot-reloaded on save
The images in docs/ are illustrations of the layout, not screenshots.
MIT
hooks/register.tsx 185 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentView, Step } from '../types'
5
6const PANE = 'agent-watch'
7// How wide the docked pane asks to be
8const COLUMNS = 56
9// How many tool calls each agent keeps
10const MAX_STEPS = 12
11// How long the pane stays after the last agent finished
12const LINGER_MS = 20_000
13// Spinner frames for a running agent, one per tick
14const SPIN = ['◐', '◓', '◑', '◒']
15
16const agents = atom({ plugin: 'agent-watch', key: 'agents' } as const, [] as AgentView[])
17const tick = atom({ plugin: 'agent-watch', key: 'tick' } as const, 0)
18
19// The ticker and the pending close; the module's own, so a reload starts them over
20let ticker: { cancel: () => void } | null = null
21let closer: { cancel: () => void } | null = null
22
23// The argument worth showing for a tool call: the command, the path, the pattern
24function summarize(input: Record<string, unknown>): string {
25 for (const key of ['command', 'file_path', 'path', 'pattern', 'url', 'query', 'description', 'prompt']) {
26 const value = input[key]
27 if (typeof value === 'string' && value) return value.replace(/\s+/g, ' ').replace(/^\/Users\/[^/]+/, '~')
28 }
29 return ''
30}
31
32// 72000 -> "1m12s"
33function elapsed(ms: number): string {
34 const s = Math.max(Math.round(ms / 1000), 0)
35 return s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `${s}s`
36}
37
38// Change one agent's view in place
39function patch($: EngineInterface, id: string, change: (a: AgentView) => AgentView) {
40 return update($, agents, list => list.map(a => (a.id === id ? change(a) : a)))
41}
42
43// Know the agent the first time its loop shows up; false for a loop the list never names (compaction, memory)
44async function track($: EngineInterface, id: string): Promise<boolean> {
45 if ((await read($, agents)).some(a => a.id === id)) return true
46 const info = (await $.agent.list()).find(a => a.id === id)
47 if (!info) return false
48 const now = await $.clock.now()
49 const view: AgentView = {
50 id,
51 type: info.type,
52 description: info.description,
53 status: 'running',
54 startedAt: now,
55 endedAt: null,
56 tools: 0,
57 steps: [],
58 text: '',
59 }
60 await update($, agents, list => (list.some(a => a.id === id) ? list : [...list, view]))
61 closer?.cancel()
62 closer = null
63 if (!ticker) ticker = $.clock.every(1000, () => update($, tick, n => n + 1))
64 await $.ui.open({ id: PANE, title: 'Agents', columns: COLUMNS })
65 return true
66}
67
68// An agent ended: stop the ticker once none runs, and close the pane a while later
69async function finish($: EngineInterface, id: string, failed: boolean) {
70 const now = await $.clock.now()
71 await patch($, id, a => ({ ...a, status: failed ? 'failed' : 'done', endedAt: now }))
72 if ((await read($, agents)).some(a => a.status === 'running')) return
73 ticker?.cancel()
74 ticker = null
75 closer?.cancel()
76 closer = $.clock.after(LINGER_MS, async () => {
77 if ((await read($, agents)).some(a => a.status === 'running')) return
78 await update($, agents, () => [])
79 await $.ui.close({ id: PANE })
80 })
81}
82
83export const register: Register = on => {
84 on('session.start', async ($, e, next) => {
85 await $.command.register({ name: 'agents-pane', description: 'Show the subagents of this session in a pane' })
86 return next(e)
87 })
88
89 on('command.run', { command: 'agents-pane' }, async $ => {
90 await $.ui.open({ id: PANE, title: 'Agents', columns: COLUMNS })
91 return { text: 'Agents pane opened.' }
92 })
93
94 // A subagent's tool call: a step that runs, then turns ✓ or ✗
95 on('tool.call', async ($, e, next) => {
96 if (e.agentId === undefined || !(await track($, e.agentId))) return next(e)
97 const id = e.agentId
98 const step: Step = { id: e.tool_use_id, tool: e.tool, summary: summarize(e as unknown as Record<string, unknown>), state: 'run' }
99 await patch($, id, a => ({ ...a, tools: a.tools + 1, steps: [...a.steps, step].slice(-MAX_STEPS) }))
100 const ran = await next(e)
101 const state = ran.deny !== undefined || ran.isError ? 'err' : 'ok'
102 await patch($, id, a => ({ ...a, steps: a.steps.map(s => (s.id === step.id ? { ...s, state } : s)) }))
103 return ran
104 })
105
106 // A subagent's model output: keep the tail of its text, written at most twice a second
107 on('turn.step', async function* ($, e, next) {
108 const id = e.agentId
109 const stream = next(e)
110 if (id === undefined || !(await track($, id))) return yield* stream
111 let text = ''
112 let written = 0
113 for (let r = await stream.next(); ; r = await stream.next()) {
114 if (r.done) {
115 if (text.trim()) await patch($, id, a => ({ ...a, text: text.trim().replace(/\s+/g, ' ').slice(-240) }))
116 return r.value
117 }
118 if (r.value.kind === 'text') {
119 text += r.value.text
120 const now = Date.now()
121 if (now - written > 500) {
122 written = now
123 await patch($, id, a => ({ ...a, text: text.trim().replace(/\s+/g, ' ').slice(-240) }))
124 }
125 }
126 yield r.value
127 }
128 })
129
130 // A subagent's run ended
131 on('turn.complete', async ($, e, next) => {
132 const done = await next(e)
133 if (e.agentId !== undefined && (await read($, agents)).some(a => a.id === e.agentId)) {
134 await finish($, e.agentId, e.reason === 'error' || e.reason === 'aborted')
135 }
136 return done
137 })
138
139 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
140 const { Box, Text } = $.ui.resolve(e)
141 const list = await read($, agents)
142 const n = await read($, tick)
143 const now = await $.clock.now()
144 if (list.length === 0) return <Text dimColor>No subagents running.</Text>
145
146 // Running agents first, newest first; finished ones after them
147 const shown = [...list].sort((a, b) => (a.status === 'running') === (b.status === 'running') ? b.startedAt - a.startedAt : a.status === 'running' ? -1 : 1)
148 const rows = e.viewport?.rows ?? 40
149 const perAgent = Math.max(2, Math.floor((rows - 2) / shown.length) - 4)
150
151 return (
152 <Box flexDirection="column" rowGap={1}>
153 {shown.map(a => {
154 const running = a.status === 'running'
155 const glyph = running ? SPIN[n % SPIN.length] : a.status === 'done' ? '✓' : '✗'
156 const color = running ? 'cyan' : a.status === 'done' ? 'green' : 'red'
157 return (
158 <Box key={a.id} flexDirection="column">
159 <Text wrap="truncate-end">
160 <Text color={color}>{glyph} </Text>
161 <Text bold dimColor={!running}>{a.type}</Text>
162 <Text dimColor> {elapsed((a.endedAt ?? now) - a.startedAt)} · {a.tools} tools</Text>
163 </Text>
164 <Text dimColor wrap="truncate-end"> {a.description}</Text>
165 {(running ? a.steps.slice(-perAgent) : a.steps.slice(-1)).map(s => (
166 <Text key={s.id} wrap="truncate-end">
167 {' '}
168 <Text color={s.state === 'run' ? 'yellow' : s.state === 'ok' ? 'green' : 'red'}>{s.state === 'run' ? '›' : s.state === 'ok' ? '✓' : '✗'}</Text>
169 <Text dimColor={!running}> {s.tool} </Text>
170 <Text dimColor>{s.summary}</Text>
171 </Text>
172 ))}
173 {running && a.text && (
174 <Text dimColor italic wrap="truncate-start">
175 {' '}“{a.text}”
176 </Text>
177 )}
178 </Box>
179 )
180 })}
181 </Box>
182 )
183 })
184}
185types/index.d.ts 29 lines1// One tool call a subagent made: running, answered, or failed
2export type Step = { id: string; tool: string; summary: string; state: 'run' | 'ok' | 'err' }
3
4// One subagent as the pane shows it
5export type AgentView = {
6 id: string
7 type: string
8 description: string
9 status: 'running' | 'done' | 'failed'
10 startedAt: number
11 endedAt: number | null
12 tools: number
13 // The newest tool calls, oldest first
14 steps: Step[]
15 // The tail of what the agent last wrote
16 text: string
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 'agent-watch': {
22 // The agents of this session, oldest first
23 agents: AgentView[]
24 // Bumped every second while one runs, so elapsed times redraw
25 tick: number
26 }
27 }
28}
29