Crew: a side panel of every sub-agent at work, each with its own worker avatar, model, live activity, cache hit bar, tokens and warnings

Five side-panel mods for Claude Code (function hooks, early access API):
| Mod | Panel | Command | |
|---|---|---|---|
| agent-progress | Crew: each sub-agent as a little Claude crab dressed for its job (builder with a hammer, inspector with a monocle, scout with a compass, architect with a set square, librarian with a book), model, live activity, steps, elapsed time, cache hit bar, tokens, and a warning when a worker goes quiet | /crew | |
| cache-tax | Cache: prompt cache countdown gauge, hit-rate sparkline, cold-start warnings, keep-warm pings, and what caching cost: spent, saved by reads, the cold-start tax, what pings cost and what going cold now would add (estimated from /cost with the published cache multipliers) | /keepwarm, /keepwarm now, `/keepwarm ttl 5\ | 60, /keepwarm panel` |
| spend-ledger | Spend: what each prompt and each sub-agent cost, a STATS dashboard modelled on /usage stats: views Overview, Tokens, Cost, Cache and In/Out; ranges This session, 7 days, 30 days and All time; a model filter; an activity heatmap, charts in four styles (line, area, bars, dots) with axes, stat tiles (favorite model, total tokens, sessions, active days, longest session, streaks) and per-model cards. Past days come from Claude Code's own history (~/.claude/stats-cache.json, the file /usage reads), with their in/out/cache split and cost estimated from each model's totals; today is exact, from the mod's own tracking. An active-hours chart shows when you work, a by-model breakdown (cost, share, requests, tokens, cache hit rate), burn rate, budget, running insights. On an API key it budgets in dollars (/spend budget 50, warnings at 80% and 100%). On a Claude subscription it shows the plan's 5-hour and weekly windows with reset times and how long the 5-hour window lasts at your pace, and budgets as a share of it (/spend budget 80%); the dollar figures are then the API-price value of the work, not a charge | /spend, /spend budget 50, /spend budget 80%, add hard to refuse new prompts once reached (/spend budget 50 hard), `/spend hard on\ | off, /spend budget off` |
| session-link | Session Link: the live Claude sessions on this machine, each with its own label (the session sets it itself early in its first turn; until then its first prompt stands in; /link name overrides) and its own Claude crab badge (a pose and colour no two live sessions share), its directory, branch and the files it edited; the messages the sessions send each other; files two sessions both edited. It also stops one session overwriting another: an edit to a file another live session edited in the last six hours, or a broad git command (add -A, commit -a, reset --hard, checkout, switch, stash, rebase, merge, pull) in a repository another session works in, is refused once with the other session's name so the model messages it first; the same call again goes through | /link, /link name A: finish UA, /link release; the model's set_session_label tool | |
| mission-control | Mission Control: all four stacked in one scrollable panel, with buttons for every command | /mission |
Every panel has buttons with hotkeys for its commands.
At the prompt of a terminal session:
/plugin install mission-control --marketplace kimathinjoki/claude-session-dashboard
Answer y to add the marketplace, choose the user scope, then install the others the same way (agent-progress, cache-tax, spend-ledger, session-link). Mission Control reads their state, so it needs all four.
Side panels dock beside the transcript in fullscreen mode ("tui": "fullscreen" in ~/.claude/settings.json, or /config) from 110 columns. They open on their own at session start from 144 columns; at any width the commands above open them. With several open they share the dock as tabs.
The Cache panel follows your promptCacheTtl setting ("1h" keeps the cache for an hour; 1-hour cache writes cost more than 5-minute ones).
hooks/register.tsx 246 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { AgentRow } from '../types'
5
6const agents = atom({ plugin: 'agent-progress', key: 'agents' } as const, [])
7const sessionUsd = atom({ plugin: 'agent-progress', key: 'sessionUsd' } as const, null)
8const tick = atom({ plugin: 'agent-progress', key: 'tick' } as const, 0)
9
10const PANE = 'crew'
11// A finished worker stays on the panel this long, so its last figures can be read.
12const LINGER_MS = 5 * 60_000
13// A running worker that has made no model request for this long is flagged.
14const QUIET_MS = 3 * 60_000
15const ACTIVE = ['pending', 'running', 'waiting']
16const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
17
18// The crew: who a worker is, read from what it was asked to do. Every worker is a little Claude
19// crab, dressed for the job: a hard hat and hammer, a monocle, a compass, a set square, a book.
20export const CRAB = '▐▛█▜▌'
21export type Role = { name: string; glyph: string; hue: string }
22const ROLES: Array<{ test: RegExp; role: Role }> = [
23 { test: /re-?review|review|verify|audit|check/i, role: { name: 'INSPECTOR', glyph: '◎' + CRAB, hue: '#22d3ee' } },
24 { test: /implement|build|fix|write|add|create/i, role: { name: 'BUILDER', glyph: '⚒' + CRAB, hue: '#fb923c' } },
25 { test: /explore|map|find|search|survey|locate|sweep/i, role: { name: 'SCOUT', glyph: '⌖' + CRAB, hue: '#4ade80' } },
26 { test: /plan|design|architect|spec/i, role: { name: 'ARCHITECT', glyph: '△' + CRAB, hue: '#a78bfa' } },
27 { test: /guide|docs|setting|how do|research/i, role: { name: 'LIBRARIAN', glyph: '≡' + CRAB, hue: '#f472b6' } },
28]
29const WORKER: Role = { name: 'WORKER', glyph: '·' + CRAB, hue: '#93c5fd' }
30
31export const roleFor = (row: Pick<AgentRow, 'description' | 'type'>): Role => {
32 if (/explore/i.test(row.type)) return ROLES[2]!.role
33 if (/plan/i.test(row.type)) return ROLES[3]!.role
34 if (/guide/i.test(row.type)) return ROLES[4]!.role
35 return ROLES.find(entry => entry.test.test(row.description))?.role ?? WORKER
36}
37
38const MODEL_HUES: Array<[RegExp, string]> = [
39 [/opus/i, '#c084fc'],
40 [/sonnet/i, '#60a5fa'],
41 [/haiku/i, '#34d399'],
42 [/fable/i, '#f9a8d4'],
43]
44const modelHue = (model: string) => MODEL_HUES.find(([test]) => test.test(model))?.[1] ?? '#cbd5e1'
45
46export const tokens = (n: number) =>
47 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
48
49export const elapsed = (ms: number) => {
50 const s = Math.max(0, Math.round(ms / 1000))
51 if (s >= 3600) return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
52 return s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `${s}s`
53}
54
55// A bar of `width` cells, filled to `share` (0 to 1).
56export const bar = (share: number, width: number) => {
57 const cells = Math.max(0, Math.min(width, Math.round(share * width)))
58 return '█'.repeat(cells) + '░'.repeat(width - cells)
59}
60
61export const cacheShare = (row: Pick<AgentRow, 'inputTokens' | 'cacheReadTokens'>) =>
62 row.inputTokens > 0 ? row.cacheReadTokens / row.inputTokens : 0
63
64const shortModel = (model: string) => model.replace(/^claude-/, '').replace(/-\d{8}$/, '')
65
66export const register: Register = on => {
67 on('session.start', async ($, e, next) => {
68 await $.command.register({ name: 'crew', description: 'Open the Crew panel: every sub-agent at work' })
69 void $.ui.open({ id: PANE, title: 'Crew' })
70
71 // The engine's own list says where each worker stands; a spawn hook only sees the start.
72 $.clock.every(2000, async () => {
73 const listed = await $.agent.list()
74 const now = await $.clock.now()
75 await update($, agents, rows =>
76 rows
77 .map(row => {
78 const info = listed.find(one => one.id === row.id)
79 if (!info) return row
80 const isDone = !ACTIVE.includes(info.status) && info.status !== 'idle'
81 return { ...row, status: info.status, endedAt: isDone ? (row.endedAt ?? now) : null }
82 })
83 .filter(row => row.endedAt === null || now - row.endedAt < LINGER_MS),
84 )
85 const usage = await $.session.usage()
86 await update($, sessionUsd, () => usage.cost?.usd ?? null)
87 })
88 // The spinner and the clocks.
89 $.clock.every(1000, () => void update($, tick, n => n + 1))
90
91 return next(e)
92 })
93
94 on('command.run', { command: 'crew' }, async $ => {
95 await $.ui.open({ id: PANE, title: 'Crew', focus: true })
96 return { text: 'Crew panel open.' }
97 })
98
99 on('agent.spawn', async ($, e, next) => {
100 const started = await next(e)
101 if (started.deny || !started.agentId) return started
102
103 const row: AgentRow = {
104 id: started.agentId,
105 description: e.description || e.subagentType,
106 type: e.subagentType,
107 model: started.model,
108 status: 'running',
109 steps: 0,
110 inputTokens: 0,
111 outputTokens: 0,
112 cacheReadTokens: 0,
113 startedAt: await $.clock.now(),
114 lastStepAt: null,
115 endedAt: null,
116 }
117 await update($, agents, rows => [...rows.filter(one => one.id !== row.id), row])
118
119 return started
120 }).catch(($, e, next) => next(e))
121
122 // Every model request a sub-agent makes: count it and add what it cost.
123 on('turn.step', async function* ($, e, next) {
124 const result = yield* next(e)
125 const agentId = e.agentId
126 if (!agentId || !result.usage) return result
127
128 const usage = result.usage
129 const now = await $.clock.now()
130 await update($, agents, rows =>
131 rows.map(row =>
132 row.id === agentId
133 ? {
134 ...row,
135 model: usage.model || row.model,
136 steps: row.steps + 1,
137 lastStepAt: now,
138 inputTokens: row.inputTokens + usage.input_tokens + usage.cache_creation_input_tokens + usage.cache_read_input_tokens,
139 cacheReadTokens: row.cacheReadTokens + usage.cache_read_input_tokens,
140 outputTokens: row.outputTokens + usage.output_tokens,
141 }
142 : row,
143 ),
144 )
145
146 return result
147 })
148
149 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
150 const { Box, Text, Button } = $.ui.resolve(e)
151 const rows = await read($, agents)
152 const frame = await read($, tick)
153 const usd = await read($, sessionUsd)
154 const now = await $.clock.now()
155 const width = Math.max(24, (e.props.bodyColumns ?? 40) - 2)
156 const barWidth = Math.max(8, Math.min(24, width - 22))
157
158 const running = rows.filter(row => row.endedAt === null)
159 const done = rows.filter(row => row.endedAt !== null)
160 const ordered = [...running, ...done]
161 const totalIn = rows.reduce((sum, row) => sum + row.inputTokens, 0)
162 const totalOut = rows.reduce((sum, row) => sum + row.outputTokens, 0)
163 const totalCached = rows.reduce((sum, row) => sum + row.cacheReadTokens, 0)
164
165 return (
166 <Box flexDirection="column">
167 <Box justifyContent="space-between">
168 <Text bold color="#fb923c">⚑ CREW</Text>
169 <Text>
170 <Text color="#4ade80" bold>{running.length}</Text>
171 <Text dimColor> at work </Text>
172 <Text color="#93c5fd">{done.length}</Text>
173 <Text dimColor> done</Text>
174 </Text>
175 </Box>
176 <Text dimColor>{'─'.repeat(width)}</Text>
177
178 {rows.length === 0 && (
179 <Box flexDirection="column" paddingY={1}>
180 <Text dimColor>The yard is quiet.</Text>
181 <Text dimColor>Workers appear here when a sub-agent starts.</Text>
182 </Box>
183 )}
184
185 {ordered.map(row => {
186 const role = roleFor(row)
187 const isDone = row.endedAt !== null
188 const failed = row.status === 'failed' || row.status === 'killed'
189 const quiet = !isDone && now - (row.lastStepAt ?? row.startedAt) > QUIET_MS
190 const share = cacheShare(row)
191 const spent = (row.endedAt ?? now) - row.startedAt
192 const mark = failed ? '✗' : isDone ? '✓' : SPIN[frame % SPIN.length]
193 const markHue = failed ? '#ef4444' : isDone ? '#22c55e' : role.hue
194
195 return (
196 <Box key={row.id} flexDirection="column" marginTop={1}>
197 <Text wrap="truncate-end">
198 <Text color={isDone ? '#64748b' : role.hue} bold>{role.glyph} </Text>
199 <Text color={role.hue} bold={!isDone} dimColor={isDone}>{role.name} </Text>
200 <Text bold={!isDone} dimColor={isDone}>{row.description}</Text>
201 </Text>
202 <Text>
203 <Text color={markHue}> {mark} </Text>
204 <Text color={modelHue(row.model)}>{shortModel(row.model)}</Text>
205 <Text dimColor> · {failed ? row.status : isDone ? 'finished' : row.status} · {elapsed(spent)} · {row.steps} steps</Text>
206 </Text>
207 <Text>
208 <Text dimColor> cache </Text>
209 <Text color={share >= 0.8 ? '#22c55e' : share >= 0.5 ? '#f97316' : '#ef4444'}>{bar(share, barWidth)}</Text>
210 <Text dimColor> {Math.round(share * 100)}%</Text>
211 </Text>
212 <Text dimColor> in {tokens(row.inputTokens)} · out {tokens(row.outputTokens)}</Text>
213 {quiet && <Text color="#f97316"> ⚠ no model request for {elapsed(now - (row.lastStepAt ?? row.startedAt))}</Text>}
214 {failed && <Text color="#ef4444"> ✗ this worker {row.status === 'killed' ? 'was stopped' : 'failed'}</Text>}
215 </Box>
216 )
217 })}
218
219 {rows.length > 0 && (
220 <Box flexDirection="column" marginTop={1}>
221 <Text dimColor>{'─'.repeat(width)}</Text>
222 <Text>
223 <Text dimColor>crew total </Text>
224 <Text>{tokens(totalIn)}</Text>
225 <Text dimColor> in ({Math.round((totalIn ? totalCached / totalIn : 0) * 100)}% cached) </Text>
226 <Text>{tokens(totalOut)}</Text>
227 <Text dimColor> out</Text>
228 </Text>
229 </Box>
230 )}
231 {usd !== null && (
232 <Text>
233 <Text dimColor>session cost </Text>
234 <Text color="#4ade80" bold>${usd.toFixed(2)}</Text>
235 </Text>
236 )}
237 <Box gap={1} marginTop={1} flexWrap="wrap">
238 <Button key="mission" hotkey="m" label="Mission Control" onPress={() => void $.command.run({ command: 'mission' })} />
239 <Button key="spend" hotkey="s" label="Spend" onPress={() => void $.command.run({ command: 'spend' })} />
240 <Button key="cache" hotkey="c" label="Cache" onPress={() => void $.command.run({ command: 'keepwarm', args: 'panel' })} />
241 </Box>
242 </Box>
243 )
244 })
245}
246types/index.d.ts 21 lines1export type AgentRow = {
2 id: string
3 description: string
4 type: string
5 model: string
6 status: string
7 steps: number
8 inputTokens: number
9 outputTokens: number
10 cacheReadTokens: number
11 startedAt: number
12 lastStepAt: number | null
13 endedAt: number | null
14}
15
16declare module 'claude-code' {
17 interface PluginState {
18 'agent-progress': { agents: AgentRow[]; sessionUsd: number | null; tick: number }
19 }
20}
21