SLOPSHOPPER

mission-control

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

newpanecommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mission-control
│ ┃ Mission ✕ › fix the failing auth test and add an audit log call │ ┃ ◈ MISSION CONTROL 0 working ● cache 0¢ │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⚑ CREW 0 at work · 0 done ⎿ Read 6 lines │ ┃ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ⏺ Update(src/auth.ts) │ ┃ ━━━━━━━━━━ ⎿ Added 2 lines, removed 1 line │ ┃ No sub-agents right now. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ❄ CACHE EMPTY │ ┃ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ━━━━━━━━━━ │ ┃ Nothing cached yet. ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ $ SPEND 0¢/h › /mission │ ┃ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ⎿ mission-control: Mission Control open. Focus it and use the arro │ ┃ ━━━━━━━━━━ │ ┃ No spend recorded yet. │ ┃ │ ┃ ⇄ SESSIONS Session Link off │ ┃ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ ┃ ━━━━━━━━━━ │ ┃ Session Link is not loaded. │ ┃ │ ┃ ▸ CONTROLS │ ┃ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ ┃ ━━━━━━━━━━ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mission
◈ MISSION CONTROL 0 working ● cache 0¢ ⚑ CREW 0 at work · 0 done ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ No sub-agents right now. ❄ CACHE EMPTY ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Nothing cached yet. $ SPEND 0¢/h ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ No spend recorded yet. ⇄ SESSIONS Session Link off ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Session Link is not loaded. ▸ CONTROLS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ [ Keep cache warm ] [ Ping cache ] [ Budget $50 ] [ Crew ] [ Cache ] [ Spend ] [ Sessions ]
README

Session dashboard for Claude Code

Five side-panel mods for Claude Code (function hooks, early access API):

ModPanelCommand
agent-progressCrew: 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-taxCache: 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-ledgerSpend: 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-linkSession 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-controlMission Control: all four stacked in one scrollable panel, with buttons for every command/mission

Every panel has buttons with hotkeys for its commands.

Install

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.

Best viewed

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).

Source 1 files
hooks/register.tsx 235 lines
1import { 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