Live pane showing each subagent's progress, tokens, tools and time, with role-based pixel crabs

A Claude Code mod that opens an Agents pane showing every subagent as it works:
The pane opens by itself when the first subagent starts, or with /agents. In the terminal the crabs are shown as emoji.
In a terminal Claude Code session:
/plugin install agent-board --marketplace MasterZul/claude-agent-mod-progress
Answer y to add the marketplace, then pick the user scope. It then loads in every session, including the desktop app's Code tab.
Clone this repo and point Claude Code at it in ~/.claude/settings.json:
"env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-agent-mod-progress" }
hooks/crabs.ts, one letter per pixel; edit them to restyle or add roles.claude plugin validate .
claude plugin test .hooks/register.tsx 212 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRow } from '../types'
5import { CRAB_SIZE, crabSvg, ROLE_EMOJI, roleOf } from './crabs'
6
7const PANE = 'agent-board'
8const COLORS = ['#3fb27f', '#4f8ef7', '#e0952f', '#b07cf0', '#e05f7a', '#2fb8c8']
9
10const agents = atom({ plugin: 'agent-board', key: 'agents' } as const, [] as AgentRow[])
11const tick = atom({ plugin: 'agent-board', key: 'tick' } as const, 0)
12
13const fmtTime = (ms: number) => {
14 const s = Math.max(0, Math.floor(ms / 1000))
15 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
16}
17const fmtTokens = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`)
18const shortModel = (m: string) =>
19 m.replace(/^claude-/, '').replace(/-(\d+)-(\d+).*$/, ' $1.$2').replace(/^\w/, c => c.toUpperCase())
20
21const progressOf = (a: AgentRow) => {
22 if (a.status !== 'running') return 1
23 if (a.todoTotal) return (a.todoDone ?? 0) / a.todoTotal
24 return Math.min(0.9, a.tools / (a.tools + 8))
25}
26
27const patch = ($: EngineInterface, id: string, fn: (a: AgentRow) => AgentRow) =>
28 update($, agents, list => list.map(a => (a.id === id ? fn(a) : a)))
29
30let timer: { cancel: () => void } | undefined
31
32// Drives the crabs' two-frame hop and the live clocks; restarted from each spawn so a timer
33// lost to a failed hook or a reload never leaves the board frozen
34const restartTicker = ($: EngineInterface) => {
35 timer?.cancel()
36 timer = $.clock.every(400, () => {
37 void (async () => {
38 try {
39 const list = await read($, agents)
40 if (list.some(a => a.status === 'running')) await update($, tick, t => t + 1)
41 } catch {
42 // one missed frame is harmless
43 }
44 })()
45 })
46}
47
48export const register: Register = on => {
49 on('session.start', async ($, e, next) => {
50 restartTicker($)
51 try {
52 await $.command.register({ name: 'agents', description: 'Show the live subagent progress board' })
53 } catch {
54 // already registered by an earlier load of this mod
55 }
56 return next(e)
57 })
58
59 on('command.run', { command: 'agents' }, async $ => {
60 await $.ui.open({ id: PANE, title: 'Agents' })
61 return { text: 'Agents board opened.' }
62 })
63
64 on('agent.spawn', async ($, e, next) => {
65 const r = await next(e)
66 if (!r.agentId) return r
67 const id = r.agentId
68 const startedAt = await $.clock.now()
69 await update($, agents, list => [
70 ...list,
71 {
72 id,
73 description: e.description || e.subagentType,
74 type: e.subagentType,
75 model: r.model,
76 color: COLORS[list.length % COLORS.length] ?? '#3fb27f',
77 startedAt,
78 status: 'running' as const,
79 tools: 0,
80 tokens: 0,
81 },
82 ])
83 restartTicker($)
84 void $.ui.open({ id: PANE, title: 'Agents' })
85 return r
86 })
87
88 on('tool.call', async ($, e, next) => {
89 if (e.agentId) {
90 const input = (e as { input?: { todos?: { status?: string; content?: string }[] } }).input
91 const todos = Array.isArray(input?.todos) ? input!.todos! : undefined
92 await patch($, e.agentId, a => ({
93 ...a,
94 tools: a.tools + 1,
95 lastTool: e.tool,
96 ...(todos
97 ? {
98 todoTotal: todos.length,
99 todoDone: todos.filter(t => t.status === 'completed').length,
100 todoNow: todos.find(t => t.status === 'in_progress')?.content,
101 }
102 : {}),
103 }))
104 }
105 return next(e)
106 })
107
108 on('turn.step', async function* ($, e, next) {
109 const r = yield* next(e)
110 if (e.agentId && r.usage) {
111 const u = r.usage
112 const n = u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
113 await patch($, e.agentId, a => ({ ...a, tokens: a.tokens + n, model: u.model || a.model }))
114 }
115 return r
116 })
117
118 on('turn.complete', async ($, e, next) => {
119 if (e.agentId) {
120 const endedAt = await $.clock.now()
121 const failed = e.reason === 'error' || e.isAborted
122 await patch($, e.agentId, a => ({ ...a, endedAt, status: failed ? 'failed' : 'done' }))
123 }
124 return next(e)
125 })
126
127 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
128 const ui = $.ui.resolve(e)
129 const { Box, Text } = ui
130 const Svg = 'Svg' in ui ? ui.Svg : undefined
131 const list = await read($, agents)
132 const frame = ((await read($, tick)) % 2) as 0 | 1
133 const now = await $.clock.now()
134 const width = Math.max(20, (e.props.bodyColumns ?? 44) - 2)
135
136 const running = list.filter(a => a.status === 'running')
137 const finished = list.filter(a => a.status !== 'running')
138 const totalTokens = list.reduce((s, a) => s + a.tokens, 0)
139 const first = Math.min(...list.map(a => a.startedAt))
140 const last = running.length ? now : Math.max(...list.map(a => a.endedAt ?? now))
141 const elapsed = list.length ? last - first : 0
142
143 const Stat = ({ label, value }: { label: string; value: string }) => (
144 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1} flexGrow={1}>
145 <Text dimColor>{label}</Text>
146 <Text bold>{value}</Text>
147 </Box>
148 )
149
150 const Row = ({ a }: { a: AgentRow }) => {
151 const p = progressOf(a)
152 // Block glyphs draw wider than a cell outside the terminal; leave room so the bar stays on one line
153 const barW = Math.max(10, e.surface === 'terminal' ? width - 2 : Math.floor(width * 0.6))
154 const filled = Math.round(p * barW)
155 const time = fmtTime((a.endedAt ?? now) - a.startedAt)
156 const step = a.todoTotal
157 ? `${a.todoDone ?? 0}/${a.todoTotal}${a.todoNow ? ` · ${a.todoNow}` : ''}`
158 : a.status === 'running'
159 ? `${a.tools} tools${a.lastTool ? ` · ${a.lastTool}` : ''}`
160 : `${a.tools} tools`
161 const mark = a.status === 'running' ? '●' : a.status === 'done' ? '✓' : '✗'
162 const markColor = a.status === 'failed' ? '#e05f7a' : a.status === 'done' ? '#3fb27f' : a.color
163 const role = roleOf(a.description, a.type)
164 return (
165 <Box key={a.id} marginBottom={1} gap={1}>
166 <Box flexShrink={0} alignItems="center">
167 {Svg ? (
168 <Svg source={crabSvg(role, a.status === 'running' ? frame : 0)} alt={`${role} crab`} width={CRAB_SIZE.width} height={CRAB_SIZE.height} />
169 ) : (
170 <Text>{ROLE_EMOJI[role]}</Text>
171 )}
172 </Box>
173 <Box flexDirection="column" flexGrow={1}>
174 <Box justifyContent="space-between">
175 <Text bold wrap="truncate-end">{a.description}</Text>
176 <Text color={markColor}>{mark}</Text>
177 </Box>
178 <Text wrap="truncate-end">
179 <Text color={a.color}>{role}</Text>
180 <Text dimColor> · {a.type}</Text>
181 <Text dimColor> · {shortModel(a.model)}</Text>
182 </Text>
183 <Box justifyContent="space-between">
184 <Text dimColor wrap="truncate-end">{step}</Text>
185 <Text dimColor>{fmtTokens(a.tokens)} · {time}</Text>
186 </Box>
187 <Text>
188 <Text color={a.color}>{'█'.repeat(filled)}</Text>
189 <Text dimColor>{'░'.repeat(barW - filled)}</Text>
190 </Text>
191 </Box>
192 </Box>
193 )
194 }
195
196 return (
197 <Box flexDirection="column">
198 <Box gap={1}>
199 <Stat label="Agents" value={`${finished.length}/${list.length}`} />
200 <Stat label="Tokens" value={fmtTokens(totalTokens)} />
201 <Stat label="Time" value={fmtTime(elapsed)} />
202 </Box>
203 {list.length === 0 && <Text dimColor>No subagents yet. They'll show up here when spawned.</Text>}
204 {running.length > 0 && <Text dimColor>Running · {running.length}</Text>}
205 {running.map(a => <Row a={a} />)}
206 {finished.length > 0 && <Text dimColor>Finished · {finished.length}</Text>}
207 {finished.map(a => <Row a={a} />)}
208 </Box>
209 )
210 })
211}
212hooks/crabs.ts 143 lines1// Pixel-art crab avatars, one outfit per role, drawn as SVG on remote surfaces
2export type Role = 'designer' | 'developer' | 'researcher' | 'tester'
3
4export const ROLE_EMOJI: Record<Role, string> = {
5 designer: '🎨',
6 developer: '💻',
7 researcher: '🔎',
8 tester: '🧪',
9}
10
11// Most specific first: a "review the styles" task is a tester's, not a designer's
12const RULES: [Role, RegExp][] = [
13 ['tester', /test|qa\b|review|verify|check|audit|lint|debug|bug/i],
14 ['researcher', /research|explore|investigat|search|find|analy|compare|competitor|plan|study/i],
15 ['designer', /design|style|css|visual|graphic|brand|ux|ui\b|theme|layout|color|colour/i],
16]
17
18export const roleOf = (description: string, type: string): Role => {
19 for (const [role, re] of RULES) if (re.test(description)) return role
20 if (/explore|plan/i.test(type)) return 'researcher'
21 for (const [role, re] of RULES) if (re.test(type)) return role
22 return 'developer'
23}
24
25const PALETTE: Record<string, string> = {
26 o: '#e8835a', // shell
27 d: '#b85a3a', // claws & legs
28 k: '#14141a', // eyes
29 G: '#3fb27f', // developer cap
30 g: '#2a8a5f',
31 L: '#8a93a8', // laptop
32 l: '#c9d3ea',
33 P: '#9b6bd6', // designer beret
34 b: '#8a5a2b', // brush handle
35 R: '#ff6fa8', // paint
36 K: '#d8c08a', // researcher helmet
37 q: '#b39a62',
38 s: '#7fb6ff', // magnifier
39 Y: '#f2c230', // tester hard hat
40 y: '#c99a12',
41 c: '#47d16c', // check
42}
43
44const BODY = [
45 '................',
46 '................',
47 '................',
48 '................',
49 '................',
50 '....oooooooo....',
51 '...ookooookoo...',
52 '...ookooookoo...',
53 'dd.oooooooooo.dd',
54 'dooooooooooooood',
55 '...oooooooooo...',
56 '...oooooooooo...',
57 '...d.d....d.d...',
58 '...d.d....d.d...',
59]
60
61const OUTFIT: Record<Role, string[]> = {
62 developer: [
63 '................',
64 '................',
65 '....GGGGGG......',
66 '...GGGGGGGG.....',
67 '...ggggggggggg..',
68 '................',
69 '................',
70 '................',
71 '................',
72 '................',
73 '..........lllll.',
74 '..........lllll.',
75 '.........LLLLLLL',
76 ],
77 designer: [
78 '................',
79 '........P.......',
80 '.....PPPPPP.....',
81 '...PPPPPPPPPP..R',
82 '....PPPPPPPP..RR',
83 '..............b.',
84 '..............b.',
85 '..............b.',
86 ],
87 researcher: [
88 '................',
89 '......KKKK......',
90 '.....KKKKKK.....',
91 '....KKKKKKKK....',
92 '..qqqqqqqqqqqq..',
93 '.ss.............',
94 's..s............',
95 's..s............',
96 '.ss.............',
97 ],
98 tester: [
99 '................',
100 '......YYYY......',
101 '.....YYYYYY.....',
102 '....YYYYYYYY....',
103 '..yyyyyyyyyyyy..',
104 '................',
105 '................',
106 '................',
107 '................',
108 '.........c......',
109 '......c.c.......',
110 '.......c........',
111 ],
112}
113
114const SCALE = 3
115
116// A working crab alternates two frames: frame 1 hops up a pixel with its claws raised one more
117const isClaw = (x: number, y: number) => (y === 8 || y === 9) && (x <= 1 || x >= 14)
118
119export const crabSvg = (role: Role, frame: 0 | 1 = 0): string => {
120 const outfit = OUTFIT[role]
121 const body: string[] = []
122 const claws: string[] = []
123 BODY.forEach((row, y) => {
124 for (let x = 0; x < row.length; x++) {
125 const over = outfit[y]?.[x]
126 const ch = over && over !== '.' ? over : row[x]
127 const fill = ch ? PALETTE[ch] : undefined
128 if (!fill) continue
129 const rect = `<rect x="${x}" y="${y}" width="1" height="1" fill="${fill}"/>`
130 ;(frame === 1 && isClaw(x, y) && !(over && over !== '.') ? claws : body).push(rect)
131 }
132 })
133 const w = 16
134 const h = BODY.length
135 const inner =
136 frame === 1
137 ? `<g transform="translate(0 -1)">${body.join('')}<g transform="translate(0 -1)">${claws.join('')}</g></g>`
138 : body.join('')
139 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -2 ${w} ${h + 2}" width="${w * SCALE}" height="${(h + 2) * SCALE}" shape-rendering="crispEdges">${inner}</svg>`
140}
141
142export const CRAB_SIZE = { width: 16 * SCALE, height: (BODY.length + 2) * SCALE }
143types/index.d.ts 23 lines1export type AgentRow = {
2 id: string
3 description: string
4 type: string
5 model: string
6 color: string
7 startedAt: number
8 endedAt?: number
9 status: 'running' | 'done' | 'failed'
10 tools: number
11 lastTool?: string
12 tokens: number
13 todoDone?: number
14 todoTotal?: number
15 todoNow?: string
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'agent-board': { agents: AgentRow[]; tick: number }
21 }
22}
23