Mission Control: Crew, Cache, Spend and Sessions stacked in one scrollable side panel

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 235 lines1import { atom, read } from 'claude-code'
2import type { Register } from 'claude-code'
3
4// Read-only views of the three mods' state: any plugin reads any value, its owner alone writes it.
5const agents = atom({ plugin: 'agent-progress', key: 'agents' } as const, [])
6const crewTick = atom({ plugin: 'agent-progress', key: 'tick' } as const, 0)
7const reading = atom({ plugin: 'cache-tax', key: 'reading' } as const, null as never)
8const ledger = atom({ plugin: 'spend-ledger', key: 'ledger' } as const, null as never)
9const link = atom({ plugin: 'session-link', key: 'view' } as const, null as never)
10
11const PANE = 'mission-control'
12const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
13
14// The same crabs the Crew panel draws, each dressed for its job.
15const CRAB = '▐▛█▜▌'
16type Role = { glyph: string; hue: string }
17const ROLES: Array<[RegExp, Role]> = [
18 [/re-?review|review|verify|audit|check/i, { glyph: '◎' + CRAB, hue: '#22d3ee' }],
19 [/implement|build|fix|write|add|create/i, { glyph: '⚒' + CRAB, hue: '#fb923c' }],
20 [/explore|map|find|search|survey|locate/i, { glyph: '⌖' + CRAB, hue: '#4ade80' }],
21 [/plan|design|architect|spec/i, { glyph: '△' + CRAB, hue: '#a78bfa' }],
22 [/guide|docs|setting|research/i, { glyph: '≡' + CRAB, hue: '#f472b6' }],
23]
24export const roleOf = (description: string, type: string): Role => {
25 if (/explore/i.test(type)) return ROLES[2]![1]
26 if (/guide/i.test(type)) return ROLES[4]![1]
27 return ROLES.find(([test]) => test.test(description))?.[1] ?? { glyph: '·' + CRAB, hue: '#93c5fd' }
28}
29
30export const tokens = (n: number) =>
31 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
32export const usd = (n: number) => (n >= 100 ? `$${n.toFixed(0)}` : n >= 1 ? `$${n.toFixed(2)}` : `${Math.round(n * 100)}¢`)
33export const bar = (share: number, width: number, on = '█', off = '░') => {
34 const cells = Math.max(0, Math.min(width, Math.round(share * width)))
35 return on.repeat(cells) + off.repeat(width - cells)
36}
37const elapsed = (ms: number) => {
38 const s = Math.max(0, Math.round(ms / 1000))
39 return s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}` : `${s}s`
40}
41const clock = (ms: number) => {
42 const s = Math.max(0, Math.floor(ms / 1000))
43 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
44}
45
46export const register: Register = on => {
47 on('session.start', async ($, e, next) => {
48 await $.command.register({ name: 'mission', description: 'Open Mission Control: Crew, Cache, Spend and Sessions stacked in one panel' })
49 void $.ui.open({ id: PANE, title: 'Mission' })
50 return next(e)
51 })
52
53 on('command.run', { command: 'mission' }, async $ => {
54 await $.ui.open({ id: PANE, title: 'Mission', focus: true })
55 return { text: 'Mission Control open. Focus it and use the arrows to scroll.' }
56 })
57
58 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
59 const { Box, Text, Button } = $.ui.resolve(e)
60 const now = await $.clock.now()
61 const width = Math.max(26, (e.props.bodyColumns ?? 44) - 2)
62 const rows = (await read($, agents)) ?? []
63 const frame = (await read($, crewTick)) ?? 0
64 const cache = (await read($, reading)) as any
65 const spend = (await read($, ledger)) as any
66 const sessions = (await read($, link)) as any
67
68 const Section = ({ title, hue, right }: { title: string; hue: string; right?: string }) => (
69 <Box flexDirection="column" marginTop={1}>
70 <Box justifyContent="space-between">
71 <Text bold color={hue}>{title}</Text>
72 {right ? <Text dimColor>{right}</Text> : null}
73 </Box>
74 <Text color={hue} dimColor>{'━'.repeat(width)}</Text>
75 </Box>
76 )
77
78 // Crew
79 const running = rows.filter((r: any) => r.endedAt === null)
80 const crew = [...running, ...rows.filter((r: any) => r.endedAt !== null)].slice(0, 10)
81
82 // Cache
83 const ttl = (cache?.ttlMinutes ?? 60) * 60_000
84 const left = cache?.lastRequestAt ? cache.lastRequestAt + ttl - now : null
85 const cacheState = !cache || left === null || !cache.cachedTokens ? 'empty' : left <= 0 ? 'cold' : left < ttl * 0.25 ? 'fading' : 'warm'
86 const cacheHue = { warm: '#22c55e', fading: '#f97316', cold: '#ef4444', empty: '#64748b' }[cacheState]
87 const reads = cache?.readTokens ?? 0
88 const writes = cache?.writtenTokens ?? 0
89 const hit = reads + writes > 0 ? reads / (reads + writes) : 0
90
91 // Spend
92 const entries = [...(spend?.entries ?? [])].sort((a: any, b: any) => b.usd - a.usd)
93 const biggest = entries[0]?.usd ?? 0
94 const samples = (spend?.samples ?? []).filter((s: any) => now - s.at <= 15 * 60_000)
95 const burn = samples.length > 1 ? ((samples.at(-1).usd - samples[0].usd) / ((samples.at(-1).at - samples[0].at) / 3_600_000 || 1)) : 0
96 const budget = spend?.budgetUsd ?? null
97 const budgetShare = budget ? (spend?.totalUsd ?? 0) / budget : 0
98 const budgetHue = budgetShare >= 1 ? '#ef4444' : budgetShare >= 0.8 ? '#f97316' : '#22c55e'
99
100 return (
101 <Box flexDirection="column">
102 <Box justifyContent="space-between">
103 <Text bold color="#e2e8f0">◈ MISSION CONTROL</Text>
104 <Text>
105 <Text color="#4ade80" bold>{running.length}</Text>
106 <Text dimColor> working </Text>
107 <Text color={cacheHue}>●</Text>
108 <Text dimColor> cache </Text>
109 <Text color="#4ade80" bold>{usd(spend?.totalUsd ?? 0)}</Text>
110 </Text>
111 </Box>
112
113 <Section title="⚑ CREW" hue="#fb923c" right={`${running.length} at work · ${rows.length - running.length} done`} />
114 {crew.length === 0 && <Text dimColor>No sub-agents right now.</Text>}
115 {crew.map((r: any) => {
116 const role = roleOf(r.description, r.type)
117 const done = r.endedAt !== null
118 const failed = r.status === 'failed' || r.status === 'killed'
119 const share = r.inputTokens > 0 ? r.cacheReadTokens / r.inputTokens : 0
120 const quiet = !done && now - (r.lastStepAt ?? r.startedAt) > 3 * 60_000
121 return (
122 <Text key={r.id} wrap="truncate-end">
123 <Text color={failed ? '#ef4444' : done ? '#22c55e' : role.hue}>{failed ? '✗' : done ? '✓' : SPIN[frame % SPIN.length]} </Text>
124 <Text color={done ? '#64748b' : role.hue} bold>{role.glyph} </Text>
125 <Text dimColor={done} bold={!done}>{r.description.slice(0, Math.max(12, width - 30))}</Text>
126 <Text dimColor> {r.steps}st {elapsed((r.endedAt ?? now) - r.startedAt)} </Text>
127 <Text color={share >= 0.8 ? '#22c55e' : share >= 0.5 ? '#f97316' : '#ef4444'}>{Math.round(share * 100)}%</Text>
128 {quiet ? <Text color="#f97316"> ⚠ quiet</Text> : null}
129 </Text>
130 )
131 })}
132
133 <Section title="❄ CACHE" hue="#22d3ee" right={cacheState.toUpperCase()} />
134 {cacheState === 'empty' ? (
135 <Text dimColor>Nothing cached yet.</Text>
136 ) : (
137 <Box flexDirection="column">
138 <Text>
139 <Text color={cacheHue}>{bar(cacheState === 'cold' ? 0 : (left ?? 0) / ttl, Math.max(10, width - 14), '▰', '▱')}</Text>
140 <Text bold color={cacheHue}> {cacheState === 'cold' ? 'lapsed' : clock(left ?? 0)}</Text>
141 </Text>
142 <Text dimColor>
143 {tokens(cache.cachedTokens)} held · {Math.round(hit * 100)}% hits · {cache.keepWarm ? 'kept warm' : 'keep-warm off'}
144 </Text>
145 {(() => {
146 // Same estimate as the Cache panel: a token priced from /cost, then the cache multipliers.
147 const factor = (m: string) => (/haiku/i.test(m) ? 1 : /sonnet/i.test(m) ? 3 : /opus|fable/i.test(m) ? 5 : 3)
148 const spentSince = cache.sessionUsd != null && cache.usdBaseline != null ? cache.sessionUsd - cache.usdBaseline : 0
149 const base = spentSince > 0 && (cache.weightedAll ?? 0) >= 200_000 ? (spentSince / cache.weightedAll) * factor(cache.mainModel || 'opus') : null
150 if (base === null) return null
151 const write = (cache.ttlMinutes ?? 60) >= 60 ? 2 : 1.25
152 const saved = (cache.readTokens ?? 0) * 0.9 * base
153 const coldTax = (cache.rewrittenTokens ?? 0) * (write - 0.1) * base
154 const ifCold = (cache.cachedTokens ?? 0) * (write - 0.1) * base
155 return (
156 <Text>
157 <Text dimColor>saved </Text><Text color="#4ade80" bold>{usd(saved)}</Text>
158 <Text dimColor> cold tax </Text><Text color={coldTax > 0 ? '#f97316' : '#64748b'} bold>{usd(coldTax)}</Text>
159 <Text dimColor> going cold +</Text><Text color="#f97316">{usd(ifCold)}</Text>
160 </Text>
161 )
162 })()}
163 {cacheState === 'cold' && <Text color="#ef4444">✗ next prompt re-writes the cache</Text>}
164 {cacheState === 'fading' && <Text color="#f97316">⚠ under a quarter of the window left</Text>}
165 </Box>
166 )}
167
168 <Section title={spend?.limits?.length ? '◆ USAGE' : '$ SPEND'} hue="#4ade80" right={spend?.limits?.length ? 'plan' : `${usd(burn)}/h`} />
169 {(spend?.limits ?? []).map((w: any) => {
170 const hue = w.percentUsed >= 85 ? '#ef4444' : w.percentUsed >= 60 ? '#f97316' : '#22c55e'
171 return (
172 <Text key={w.kind}>
173 <Text dimColor>{(w.kind === 'five_hour' ? '5-hour' : w.kind === 'seven_day' ? 'weekly' : w.kind).padEnd(8)}</Text>
174 <Text color={hue}>{bar(Math.min(1, w.percentUsed / 100), Math.max(10, width - 16))}</Text>
175 <Text bold color={hue}> {Math.round(w.percentUsed)}%</Text>
176 </Text>
177 )
178 })}
179 {budget !== null && (
180 <Text>
181 <Text color={budgetHue}>{bar(Math.min(1, budgetShare), Math.max(10, width - 18))}</Text>
182 <Text bold color={budgetHue}> {Math.round(budgetShare * 100)}%</Text>
183 <Text dimColor> of {usd(budget)}</Text>
184 </Text>
185 )}
186 {entries.length === 0 && <Text dimColor>No spend recorded yet.</Text>}
187 {entries.slice(0, 6).map((entry: any) => {
188 const hue = entry.kind === 'prompt' ? '#93c5fd' : entry.kind === 'helpers' ? '#64748b' : roleOf(entry.label, '').hue
189 return (
190 <Text key={entry.id} wrap="truncate-end">
191 <Text color={hue}>{bar(biggest > 0 ? entry.usd / biggest : 0, 8)}</Text>
192 <Text bold> {usd(entry.usd).padStart(6)}</Text>
193 <Text dimColor> {entry.label}</Text>
194 </Text>
195 )
196 })}
197 {entries.length > 6 && <Text dimColor>+ {entries.length - 6} more in /spend</Text>}
198 <Section title="⇄ SESSIONS" hue="#f472b6" right={sessions ? `${sessions.sessions.length} live · ${sessions.overlaps.length} overlap` : 'Session Link off'} />
199 {!sessions && <Text dimColor>Session Link is not loaded.</Text>}
200 {(sessions?.sessions ?? []).map((s: any) => (
201 <Text key={s.name} wrap="truncate-end">
202 <Text color={s.hue} bold>{s.glyph} {s.label ?? s.name}</Text>
203 <Text dimColor>{s.isSelf ? ' (this one)' : ''}{s.branch ? ` ⎇ ${s.branch}` : ''} </Text>
204 <Text color={s.status === 'busy' ? '#4ade80' : '#94a3b8'}>● {s.status}</Text>
205 </Text>
206 ))}
207 {(sessions?.overlaps ?? []).slice(0, 3).map((o: any) => (
208 <Text key={o.path} wrap="truncate-start" color="#f97316">⚠ {o.path} {o.names.join(' + ')}</Text>
209 ))}
210 {(sessions?.messages ?? []).slice(-2).map((m: any) => (
211 <Text key={`${m.from}-${m.at}`} wrap="truncate-end">
212 <Text color={m.fromHue}>{m.fromGlyph}</Text>
213 <Text dimColor> → </Text>
214 <Text color={m.toHue}>{m.toGlyph}</Text>
215 <Text dimColor> {m.text}</Text>
216 </Text>
217 ))}
218 <Section title="▸ CONTROLS" hue="#e2e8f0" />
219 <Box gap={1} flexWrap="wrap">
220 <Button key="warm" hotkey="w" label={cache?.keepWarm ? 'Stop keeping warm' : 'Keep cache warm'} onPress={() => void $.command.run({ command: 'keepwarm' })} />
221 <Button key="ping" hotkey="p" label="Ping cache" onPress={() => void $.command.run({ command: 'keepwarm', args: 'now' })} />
222 <Button key="budget" hotkey="b" label={budget ? `Budget ${usd(budget)}: +$50` : 'Budget $50'} onPress={() => void $.command.run({ command: 'spend', args: `budget ${(budget ?? 0) + 50}` })} />
223 {budget !== null && <Button key="nobudget" hotkey="0" label="Clear budget" onPress={() => void $.command.run({ command: 'spend', args: 'budget off' })} />}
224 </Box>
225 <Box gap={1} flexWrap="wrap" marginTop={1}>
226 <Button key="crew" hotkey="c" label="Crew" onPress={() => void $.command.run({ command: 'crew' })} />
227 <Button key="cachepane" hotkey="k" label="Cache" onPress={() => void $.command.run({ command: 'keepwarm', args: 'panel' })} />
228 <Button key="spendpane" hotkey="s" label="Spend" onPress={() => void $.command.run({ command: 'spend' })} />
229 <Button key="linkpane" hotkey="l" label="Sessions" onPress={() => void $.command.run({ command: 'link' })} />
230 </Box>
231 </Box>
232 )
233 })
234}
235