Task progress bar above the prompt and a live side pane of running subagents with tokens, context and cost

A Claude Code mod that shows what is running right now:
TaskCreate, TaskUpdate, TodoWrite): Tâches ████████░░░░ 2/5 · Running tests, plus how many subagents are still running./agent-board reopens the pane.UI labels are in French.
At the prompt of a Claude Code terminal session:
/plugin install agent-board --marketplace Acrazie/agent-board
Answer y to add the marketplace, then pick a scope (user scope loads it in every session).
hooks/pricing.ts. On a Pro/Max subscription it is an API-equivalent figure, not what you are billed. A model missing from the table shows ?./agent-board.claude --plugin-dir /path/to/agent-board/plugins/agent-board
claude plugin validate plugins/agent-board
claude plugin test plugins/agent-board
The plugin API is early access and may change between Claude Code releases (built against 2.1.293).
hooks/register.tsx 383 lines1import { atom, read, update } from 'claude-code'
2import type { Color, EngineInterface, Register } from 'claude-code'
3
4import type { BoardAgent, BoardAgentStatus, BoardTask, BoardTokens } from '../types'
5import { costOf, windowOf } from './pricing'
6
7const PANE = 'agent-board'
8const MAIN = 'main'
9
10const tasks = atom({ plugin: 'agent-board', key: 'tasks' } as const, {})
11const agents = atom({ plugin: 'agent-board', key: 'agents' } as const, [])
12
13// Claude Code's mascot, three rows high.
14const AVATAR = [' ▐▛███▜▌ ', '▝▜█████▛▘', ' ▘▘ ▝▝ ']
15
16const TYPE_COLORS: Record<string, Color> = {
17 'general-purpose': 'claude',
18 Explore: 'suggestion',
19 Plan: 'planMode',
20 fork: 'merged',
21 teammate: 'ide',
22}
23const PALETTE: readonly Color[] = ['success', 'warning', 'merged', 'ide', 'autoAccept', 'permission']
24
25const STATUS_LABEL: Record<BoardAgentStatus, string> = {
26 running: '● en cours',
27 completed: '✓ terminé',
28 failed: '✗ échec',
29 killed: '■ arrêté',
30}
31const STATUS_COLOR: Record<BoardAgentStatus, Color> = {
32 running: 'warning',
33 completed: 'success',
34 failed: 'error',
35 killed: 'inactive',
36}
37
38const colorOf = (type: string): Color => {
39 const known = TYPE_COLORS[type]
40
41 if (known !== undefined) {
42 return known
43 }
44
45 let hash = 0
46 for (const char of type) {
47 hash = (hash * 31 + char.charCodeAt(0)) >>> 0
48 }
49
50 return PALETTE[hash % PALETTE.length] ?? 'claude'
51}
52
53const bar = (ratio: number, width: number): string => {
54 const filled = Math.round(Math.min(1, Math.max(0, ratio)) * width)
55
56 return '█'.repeat(filled) + '░'.repeat(width - filled)
57}
58
59const count = (n: number): string =>
60 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1_000 ? `${(n / 1_000).toFixed(1)}k` : `${n}`
61
62const usd = (n: number | null): string =>
63 n === null ? '?' : n < 0.01 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)}`
64
65const seconds = (ms: number): string => {
66 const s = Math.max(0, Math.round(ms / 1000))
67
68 return s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `${s}s`
69}
70
71const shortModel = (model: string): string => model.replace(/^claude-/, '')
72
73const totalTokens = (t: BoardTokens): number => t.input + t.output + t.cacheRead + t.cacheWrite
74
75const progressOf = (list: readonly BoardTask[]) => {
76 const done = list.filter(task => task.status === 'completed').length
77 const active = list.find(task => task.status === 'in_progress')
78
79 return { done, total: list.length, label: active?.activeForm ?? active?.subject }
80}
81
82const STATUS_BY_REASON: Record<string, BoardAgentStatus> = {
83 answer: 'completed',
84 aborted: 'killed',
85 refusal: 'failed',
86 error: 'failed',
87}
88
89function patchAgent($: EngineInterface, id: string, fn: (agent: BoardAgent) => BoardAgent) {
90 return update($, agents, list => list.map(agent => (agent.id === id ? fn(agent) : agent)))
91}
92
93function setTasks($: EngineInterface, loop: string, fn: (list: BoardTask[]) => BoardTask[]) {
94 return update($, tasks, map => ({ ...map, [loop]: fn(map[loop] ?? []) }))
95}
96
97export const register: Register = on => {
98 let hasHinted = false
99
100 on('session.start', async ($, e, next) => {
101 await $.command.register({
102 name: 'agent-board',
103 description: 'Show the subagents pane (type, tokens, context, cost, progress)',
104 })
105
106 return next(e)
107 })
108
109 on('session.end', async ($, e, next) => {
110 if (e.reason === 'clear') {
111 await update($, tasks, () => ({}))
112 await update($, agents, () => [])
113 }
114
115 return next(e)
116 })
117
118 on('command.run', { command: 'agent-board' }, async $ => {
119 const opened = await $.ui.open({ id: PANE, title: 'Sub-agents' })
120
121 return { text: opened.isPlaced ? 'Sub-agents pane opened.' : 'Sub-agents pane is waiting for room.' }
122 })
123
124 // A subagent starts: give it a card and show the pane.
125 on('agent.spawn', async ($, e, next) => {
126 const started = await next(e)
127
128 if (started.deny !== undefined || started.agentId === undefined) {
129 return started
130 }
131
132 const card: BoardAgent = {
133 id: started.agentId,
134 type: e.subagentType,
135 description: e.description,
136 model: started.model,
137 parentId: e.parentAgentId,
138 isBackground: e.background,
139 status: 'running',
140 startedAt: await $.clock.now(),
141 steps: 0,
142 toolCalls: 0,
143 tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
144 contextTokens: 0,
145 costUsd: 0,
146 }
147 await update($, agents, list => [...list.filter(one => one.id !== card.id), card].slice(-50))
148
149 const opened = await $.ui.open({ id: PANE, title: 'Sub-agents' })
150 if (!opened.isPlaced && !hasHinted) {
151 hasHinted = true
152 $.ui.toast('agent-board: /agent-board pour afficher le panneau des sub-agents')
153 }
154
155 return started
156 })
157
158 // Every model request of a subagent: add its tokens and cost.
159 on('turn.step', async function* ($, e, next) {
160 const response = yield* next(e)
161 const usage = response.usage
162
163 if (e.agentId !== undefined && usage !== null) {
164 const tokens: BoardTokens = {
165 input: usage.input_tokens,
166 output: usage.output_tokens,
167 cacheRead: usage.cache_read_input_tokens,
168 cacheWrite: usage.cache_creation_input_tokens,
169 }
170 const cost = costOf(usage.model, tokens)
171
172 await patchAgent($, e.agentId, agent => ({
173 ...agent,
174 model: usage.model,
175 steps: agent.steps + 1,
176 tokens: {
177 input: agent.tokens.input + tokens.input,
178 output: agent.tokens.output + tokens.output,
179 cacheRead: agent.tokens.cacheRead + tokens.cacheRead,
180 cacheWrite: agent.tokens.cacheWrite + tokens.cacheWrite,
181 },
182 contextTokens: tokens.input + tokens.cacheRead + tokens.cacheWrite,
183 costUsd: agent.costUsd === null || cost === null ? null : agent.costUsd + cost,
184 }))
185 }
186
187 return response
188 })
189
190 on('turn.complete', async ($, e, next) => {
191 if (e.agentId !== undefined) {
192 const status = STATUS_BY_REASON[e.reason] ?? 'completed'
193 const now = await $.clock.now()
194 await patchAgent($, e.agentId, agent => ({ ...agent, status, endedAt: now }))
195 }
196
197 return next(e)
198 })
199
200 // Any tool a subagent runs counts as its activity.
201 on('tool.call', async ($, e, next) => {
202 if (e.agentId !== undefined) {
203 const tool = String(e.tool)
204 await patchAgent($, e.agentId, agent => ({ ...agent, toolCalls: agent.toolCalls + 1, lastTool: tool }))
205 }
206
207 return next(e)
208 })
209
210 // Task lists, per loop: TaskCreate / TaskUpdate and the older TodoWrite.
211 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
212 const ran = await next(e)
213
214 if (ran.deny === undefined && ran.isError !== true) {
215 const task: BoardTask = {
216 id: ran.result.task.id,
217 subject: e.subject,
218 activeForm: e.activeForm,
219 status: 'pending',
220 }
221 await setTasks($, e.agentId ?? MAIN, list => [...list.filter(one => one.id !== task.id), task])
222 }
223
224 return ran
225 })
226
227 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
228 const ran = await next(e)
229
230 if (ran.deny === undefined && ran.isError !== true && ran.result.success) {
231 const { status, subject, activeForm } = e
232 await setTasks($, e.agentId ?? MAIN, list =>
233 status === 'deleted'
234 ? list.filter(task => task.id !== e.taskId)
235 : list.map(task =>
236 task.id === e.taskId
237 ? {
238 ...task,
239 status: status ?? task.status,
240 subject: subject ?? task.subject,
241 activeForm: activeForm ?? task.activeForm,
242 }
243 : task,
244 ),
245 )
246 }
247
248 return ran
249 })
250
251 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
252 const ran = await next(e)
253
254 if (ran.deny === undefined && ran.isError !== true) {
255 const list: BoardTask[] = e.todos.map((todo, index) => ({
256 id: `todo-${index}`,
257 subject: todo.content,
258 activeForm: todo.activeForm,
259 status: todo.status,
260 }))
261 await setTasks($, e.agentId ?? MAIN, () => list)
262 }
263
264 return ran
265 })
266
267 // The band above the prompt: the main loop's task progress.
268 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
269 if (e.props.hasSurvey) {
270 return next(e)
271 }
272
273 const map = await read($, tasks)
274 const list = map[MAIN] ?? []
275 const running = (await read($, agents)).filter(agent => agent.status === 'running').length
276 const { done, total, label } = progressOf(list)
277 const hasTasks = total > 0 && done < total
278
279 if (!hasTasks && running === 0) {
280 return next(e)
281 }
282
283 const { Box, Text } = $.ui.resolve(e)
284 const width = Math.max(8, Math.min(30, e.props.bodyColumns - 40))
285
286 return (
287 <Box>
288 {hasTasks && (
289 <Text color="claude" key="tasks">
290 Tâches {bar(done / total, width)} {done}/{total}
291 </Text>
292 )}
293 {hasTasks && label !== undefined && <Text dimColor wrap="truncate-end"> · {label}</Text>}
294 {running > 0 && (
295 <Text color="warning">
296 {hasTasks ? ' · ' : ''}
297 {running} sub-agent{running > 1 ? 's' : ''} en cours
298 </Text>
299 )}
300 </Box>
301 )
302 })
303
304 // The side pane: one card per subagent.
305 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
306 const { Box, Text } = $.ui.resolve(e)
307 const list = await read($, agents)
308 const map = await read($, tasks)
309 const now = await $.clock.now()
310 // Used only for a model the price table does not know.
311 const sessionWindow = await $.session.usage().then(
312 usage => usage.context.window,
313 () => 200_000,
314 )
315 const width = Math.max(8, Math.min(24, e.props.bodyColumns - 26))
316
317 if (list.length === 0) {
318 return <Text dimColor>Aucun sub-agent pour l'instant.</Text>
319 }
320
321 const running = list.filter(agent => agent.status === 'running').length
322 const prices = list.map(agent => agent.costUsd)
323 const total = prices.some(cost => cost === null) ? null : prices.reduce<number>((sum, cost) => sum + (cost ?? 0), 0)
324
325 return (
326 <Box flexDirection="column" gap={1}>
327 <Text bold>
328 {list.length} sub-agent{list.length > 1 ? 's' : ''} · {running} en cours · {usd(total)} au total
329 </Text>
330 {[...list].reverse().map(agent => {
331 const color = colorOf(agent.type)
332 const window = windowOf(agent.model, sessionWindow)
333 const fill = agent.contextTokens / window
334 const own = progressOf(map[agent.id] ?? [])
335 const elapsed = seconds((agent.endedAt ?? now) - agent.startedAt)
336 const progress =
337 agent.status !== 'running'
338 ? `${bar(1, width)} ${elapsed}`
339 : own.total > 0
340 ? `${bar(own.done / own.total, width)} ${own.done}/${own.total}${own.label ? ` · ${own.label}` : ''}`
341 : `étape ${agent.steps} · ${agent.toolCalls} outils${agent.lastTool ? ` · ${agent.lastTool}` : ''} · ${elapsed}`
342
343 return (
344 <Box key={agent.id} flexDirection="row" gap={1} borderStyle="round" borderColor={color} paddingX={1}>
345 <Box flexDirection="column" flexShrink={0}>
346 {AVATAR.map(row => (
347 <Text color={color}>{row}</Text>
348 ))}
349 </Box>
350 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
351 <Text>
352 <Text bold color={color}>
353 {agent.type}
354 </Text>{' '}
355 <Text color={STATUS_COLOR[agent.status]}>{STATUS_LABEL[agent.status]}</Text>
356 </Text>
357 <Text dimColor wrap="truncate-end">
358 {agent.description}
359 </Text>
360 <Text dimColor wrap="truncate-end">
361 {shortModel(agent.model)} · {agent.isBackground ? 'arrière-plan' : 'premier plan'}
362 {agent.parentId ? ' · imbriqué' : ''}
363 </Text>
364 <Text wrap="truncate-end">
365 Tokens {count(totalTokens(agent.tokens))} (in {count(agent.tokens.input)} · out{' '}
366 {count(agent.tokens.output)} · cache {count(agent.tokens.cacheRead + agent.tokens.cacheWrite)})
367 </Text>
368 <Text wrap="truncate-end">
369 Contexte {bar(fill, width)} {Math.round(fill * 100)}% de {count(window)}
370 </Text>
371 <Text wrap="truncate-end">Coût ≈ {usd(agent.costUsd)}</Text>
372 <Text color={STATUS_COLOR[agent.status]} wrap="truncate-end">
373 {progress}
374 </Text>
375 </Box>
376 </Box>
377 )
378 })}
379 </Box>
380 )
381 })
382}
383hooks/pricing.ts 63 lines1import type { BoardTokens } from '../types'
2
3// Anthropic first-party list prices in USD per million tokens (cached 2026-10-06).
4// Cache writes are billed at 1.25x input (5-minute TTL). Order matters: the
5// first prefix that matches the model id wins.
6type Price = { input: number; output: number; cacheRead: number; window: number }
7
8const PRICES: ReadonlyArray<readonly [prefix: string, price: Price]> = [
9 ['claude-fable-5-1', { input: 10, output: 50, cacheRead: 0.25, window: 1_000_000 }],
10 ['claude-mythos-5-1', { input: 10, output: 50, cacheRead: 0.25, window: 1_000_000 }],
11 ['claude-fable-5', { input: 10, output: 50, cacheRead: 1, window: 1_000_000 }],
12 ['claude-mythos-5', { input: 10, output: 50, cacheRead: 1, window: 1_000_000 }],
13 ['claude-opus-5-5', { input: 4, output: 20, cacheRead: 0.2, window: 1_000_000 }],
14 ['claude-opus-5', { input: 5, output: 25, cacheRead: 0.5, window: 1_000_000 }],
15 ['claude-opus-4', { input: 5, output: 25, cacheRead: 0.5, window: 1_000_000 }],
16 ['claude-sonnet-5-5', { input: 2, output: 10, cacheRead: 0.2, window: 1_000_000 }],
17 ['claude-sonnet-5', { input: 2, output: 10, cacheRead: 0.2, window: 1_000_000 }],
18 ['claude-sonnet-4', { input: 3, output: 15, cacheRead: 0.3, window: 1_000_000 }],
19 ['claude-haiku-5-5', { input: 0.1, output: 0.5, cacheRead: 0.01, window: 1_000_000 }],
20 ['claude-haiku-4', { input: 1, output: 5, cacheRead: 0.1, window: 200_000 }],
21]
22
23const ALIASES: Record<string, string> = {
24 fable: 'claude-fable-5-1',
25 opus: 'claude-opus-5-5',
26 sonnet: 'claude-sonnet-5-5',
27 haiku: 'claude-haiku-5-5',
28}
29
30/** Strips provider prefixes (`anthropic.`, `us.anthropic.`) and suffixes (`[1m]`, `@date`). */
31const normalize = (model: string): string => {
32 const bare = model.toLowerCase().replace(/^.*anthropic\./, '').replace(/[[@].*$/, '')
33
34 return ALIASES[bare] ?? bare
35}
36
37const priceOf = (model: string): Price | undefined => {
38 const id = normalize(model)
39
40 return PRICES.find(([prefix]) => id.startsWith(prefix))?.[1]
41}
42
43/** Cost of one response's tokens; null when the model has no known price. */
44export const costOf = (model: string, tokens: BoardTokens): number | null => {
45 const price = priceOf(model)
46
47 if (price === undefined) {
48 return null
49 }
50
51 const millions =
52 tokens.input * price.input +
53 tokens.output * price.output +
54 tokens.cacheRead * price.cacheRead +
55 tokens.cacheWrite * price.input * 1.25
56
57 return millions / 1_000_000
58}
59
60/** Context window of the model in tokens, or `fallback` when unknown. */
61export const windowOf = (model: string, fallback: number): number =>
62 priceOf(model)?.window ?? fallback
63types/index.d.ts 48 lines1export type BoardTaskStatus = 'pending' | 'in_progress' | 'completed'
2
3export type BoardTask = {
4 id: string
5 subject: string
6 activeForm?: string
7 status: BoardTaskStatus
8}
9
10export type BoardTokens = {
11 input: number
12 output: number
13 cacheRead: number
14 cacheWrite: number
15}
16
17export type BoardAgentStatus = 'running' | 'completed' | 'failed' | 'killed'
18
19export type BoardAgent = {
20 id: string
21 type: string
22 description: string
23 model: string
24 parentId?: string
25 isBackground: boolean
26 status: BoardAgentStatus
27 startedAt: number
28 endedAt?: number
29 steps: number
30 toolCalls: number
31 lastTool?: string
32 tokens: BoardTokens
33 /** Prompt size of the agent's latest request: how full its context window is. */
34 contextTokens: number
35 /** Estimated list-price cost in USD; null when a model had no known price. */
36 costUsd: number | null
37}
38
39declare module 'claude-code' {
40 interface PluginState {
41 'agent-board': {
42 /** Task lists keyed by loop: 'main' or a subagent's id. */
43 tasks: Record<string, BoardTask[]>
44 agents: BoardAgent[]
45 }
46 }
47}
48