SLOPSHOPPER

agent-progress

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

newpanecommandtimeragents
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-progress
│ ┃ Crew ✕ › fix the failing auth test and add an audit log call │ ┃ ⚑ CREW 0 at work 0 done │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ────────── ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ The yard is quiet. ⎿ Added 2 lines, removed 1 line │ ┃ Workers appear here when a sub-agent starts. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ [ Mission Control ] [ Spend ] [ Cache ] ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /crew │ ⎿ agent-progress: Crew panel open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Crew
⚑ CREW 0 at work 0 done ────────────────────────────────────────────────────── The yard is quiet. Workers appear here when a sub-agent starts. [ Mission Control ] [ Spend ] [ Cache ]
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 2 files
hooks/register.tsx 246 lines
1import { 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}
246
types/index.d.ts 21 lines
1export 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