SLOPSHOPPER

agent-dashboard

A live side pane that shows every sub-agent with status, progress, context usage, current activity, prompt and result.

newpaneguardcommandagents
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-dashboard
│ ┃ Sub-agents ✕ › fix the failing auth test and add an audit log call │ ┃ No sub-agents yet. │ ┃ They appear here as soon as an agent spawns ⏺ Read(src/auth.ts) │ ┃ one. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /dashboard │ ⎿ agent-dashboard: Sub-agent dashboard opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Sub-agents
No sub-agents yet. They appear here as soon as an agent spawns one.
README

agent-dashboard

A Claude Code mod that opens a live side pane showing every sub-agent in your session.

When one large agent orchestrates many sub-agents, you can't see what each one is doing. This pane shows it as it happens.

What it shows

For each sub-agent:

  • Status: running, done, failed or stopped, with elapsed time.
  • A work bar based on tool-call count.
  • A context bar showing tokens used against the context window.
  • The tool it is using right now.
  • The prompt it was given and the result it returned, behind a show and hide button.

The view is read-only. There are no stop or message controls yet.

Install

/plugin install agent-dashboard --marketplace leowei31/agent-dashboard-mod

Then start a new session. The pane opens at session start. Run /dashboard to open it again.

What the bars mean

A sub-agent has no known total amount of work, so the work bar can't be a true percentage. It fills as the agent makes tool calls and is full at 20 calls, or when the agent finishes. The context bar is exact. It uses the token counts of the agent's latest model request against the context window of your session's model.

Limits

  • The elapsed time of a running agent updates when something else redraws the pane, such as a tool call, not on a timer.
  • A sub-agent started by a workflow outside the normal Agent tool may not report its details.
  • The context window size is your session's model window, so a sub-agent on a different model can show a slightly wrong percentage.
  • It needs a Claude Code build that supports mods.

Development

Run claude plugin test ./agent-dashboard to run the tests. Run claude plugin validate ./agent-dashboard to validate the manifest.

Source 2 files
hooks/register.tsx 225 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Row } from '../types'
5
6const PANE = 'agent-dashboard'
7const MAX_ROWS = 100
8const TOOL_BAR_FULL = 20
9const BAR_WIDTH = 16
10const CLIP = 600
11
12const rows = atom({ plugin: 'agent-dashboard', key: 'rows' } as const, [])
13const windowSize = atom({ plugin: 'agent-dashboard', key: 'window' } as const, 200000)
14
15const blank = (id: string, now: number): Row => ({
16  id,
17  description: '',
18  type: '',
19  status: 'running',
20  startedAt: now,
21  endedAt: 0,
22  toolCalls: 0,
23  activity: '',
24  contextTokens: 0,
25  prompt: '',
26  result: '',
27  isOpen: false,
28})
29
30// Applies `change` to the row `id`, adding the row first when an agent was not seen to spawn.
31const patch = (list: readonly Row[], id: string, now: number, change: (row: Row) => Partial<Row>) => {
32  const base = list.some(row => row.id === id) ? list : [...list, blank(id, now)]
33
34  return base.map(row => (row.id === id ? { ...row, ...change(row) } : row)).slice(-MAX_ROWS)
35}
36
37const bar = (fraction: number) => {
38  const filled = Math.round(Math.min(1, Math.max(0, fraction)) * BAR_WIDTH)
39
40  return '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled)
41}
42
43const clip = (text: string) => (text.length > CLIP ? `${text.slice(0, CLIP)}...` : text)
44
45const compact = (tokens: number) =>
46  tokens >= 1_000_000 ? `${(tokens / 1_000_000).toFixed(1)}M` : tokens >= 1000 ? `${Math.round(tokens / 1000)}k` : `${tokens}`
47
48const seconds = (ms: number) => {
49  const total = Math.max(0, Math.round(ms / 1000))
50
51  return total >= 60 ? `${Math.floor(total / 60)}m${String(total % 60).padStart(2, '0')}s` : `${total}s`
52}
53
54const COLORS = { running: 'yellow', done: 'green', failed: 'red', stopped: 'gray' } as const
55const DOTS = { running: '●', done: '✓', failed: '✗', stopped: '■' } as const
56
57export const register: Register = on => {
58  on('session.start', async ($, e, next) => {
59    await $.command.register({
60      name: 'dashboard',
61      description: 'Open the live sub-agent dashboard pane',
62    })
63    void $.ui.open({ id: PANE, title: 'Sub-agents' })
64
65    return next(e)
66  })
67
68  on('command.run', { command: 'dashboard' }, async $ => {
69    await $.ui.open({ id: PANE, title: 'Sub-agents' })
70
71    return { text: 'Sub-agent dashboard opened.' }
72  })
73
74  on('agent.spawn', async ($, e, next) => {
75    const out = await next(e)
76    const id = out.agentId
77
78    if (id !== undefined) {
79      const now = await $.clock.now()
80      await update($, rows, list =>
81        patch(list, id, now, () => ({
82          description: e.description,
83          type: e.subagentType,
84          prompt: e.prompt,
85          status: 'running',
86        })),
87      )
88    }
89
90    return out
91  })
92
93  on('turn.start', async ($, e, next) => {
94    const id = e.agentId
95
96    if (id !== undefined) {
97      const now = await $.clock.now()
98      await update($, rows, list =>
99        patch(list, id, now, row => ({
100          status: 'running',
101          prompt: e.text === '' ? row.prompt : e.text,
102        })),
103      )
104    }
105
106    return next(e)
107  })
108
109  on('tool.call', async ($, e, next) => {
110    const id = e.agentId
111
112    if (id !== undefined) {
113      const now = await $.clock.now()
114      await update($, rows, list =>
115        patch(list, id, now, row => ({ toolCalls: row.toolCalls + 1, activity: e.tool })),
116      )
117    }
118
119    return next(e)
120  })
121
122  on('turn.step', async function* ($, e, next) {
123    const out = yield* next(e)
124    const id = e.agentId
125    const usage = out.usage
126
127    if (id !== undefined && usage !== null && usage !== undefined) {
128      const now = await $.clock.now()
129      const contextTokens =
130        usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens + usage.output_tokens
131      await update($, rows, list => patch(list, id, now, () => ({ contextTokens })))
132    }
133
134    return out
135  })
136
137  on('turn.complete', async ($, e, next) => {
138    const id = e.agentId
139
140    if (id !== undefined) {
141      const now = await $.clock.now()
142      const status = e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'stopped' : 'failed'
143      await update($, rows, list =>
144        patch(list, id, now, () => ({ status, endedAt: now, activity: '', result: e.answer })),
145      )
146    }
147
148    return next(e)
149  })
150
151  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
152    const { Box, Text, Button } = $.ui.resolve(e)
153    const list = await read($, rows)
154    const now = await $.clock.now()
155    const usage = await $.session.usage()
156    const window = usage.context.window > 0 ? usage.context.window : await read($, windowSize)
157    const running = list.filter(row => row.status === 'running').length
158    const sorted = [...list].sort((a, b) =>
159      a.status === 'running' && b.status !== 'running'
160        ? -1
161        : b.status === 'running' && a.status !== 'running'
162          ? 1
163          : b.startedAt - a.startedAt,
164    )
165
166    if (list.length === 0) {
167      return (
168        <Box flexDirection="column">
169          <Text dimColor>No sub-agents yet.</Text>
170          <Text dimColor>They appear here as soon as an agent spawns one.</Text>
171        </Box>
172      )
173    }
174
175    return (
176      <Box flexDirection="column" gap={1}>
177        <Text bold>
178          {running} running, {list.length - running} finished
179        </Text>
180        {sorted.map(row => {
181          const elapsed = seconds((row.endedAt > 0 ? row.endedAt : now) - row.startedAt)
182          const toolFraction = row.status === 'done' ? 1 : row.toolCalls / TOOL_BAR_FULL
183          const contextFraction = window > 0 ? row.contextTokens / window : 0
184
185          return (
186            <Box key={row.id} flexDirection="column">
187              <Box gap={1}>
188                <Text color={COLORS[row.status]}>
189                  {DOTS[row.status]} {row.status}
190                </Text>
191                <Text bold>{row.description === '' ? row.id : row.description}</Text>
192                <Text dimColor>
193                  {row.type} {elapsed}
194                </Text>
195              </Box>
196              <Text color={COLORS[row.status]}>
197                work {bar(toolFraction)} {row.toolCalls} tools
198              </Text>
199              <Text>
200                ctx {bar(contextFraction)} {compact(row.contextTokens)}/{compact(window)} {Math.round(contextFraction * 100)}%
201              </Text>
202              {row.activity !== '' && <Text dimColor>now: {row.activity}</Text>}
203              <Button
204                key={`toggle-${row.id}`}
205                label={row.isOpen ? 'Hide prompt and result' : 'Show prompt and result'}
206                onPress={() =>
207                  update($, rows, current => current.map(r => (r.id === row.id ? { ...r, isOpen: !r.isOpen } : r)))
208                }
209              />
210              {row.isOpen && (
211                <Box flexDirection="column">
212                  <Text bold>Prompt</Text>
213                  <Text dimColor>{clip(row.prompt) || '(none seen)'}</Text>
214                  <Text bold>Result</Text>
215                  <Text dimColor>{clip(row.result) || '(not finished)'}</Text>
216                </Box>
217              )}
218            </Box>
219          )
220        })}
221      </Box>
222    )
223  })
224}
225
types/index.d.ts 21 lines
1export type Row = {
2  id: string
3  description: string
4  type: string
5  status: 'running' | 'done' | 'failed' | 'stopped'
6  startedAt: number
7  endedAt: number
8  toolCalls: number
9  activity: string
10  contextTokens: number
11  prompt: string
12  result: string
13  isOpen: boolean
14}
15
16declare module 'claude-code' {
17  interface PluginState {
18    'agent-dashboard': { rows: Row[]; window: number }
19  }
20}
21