SLOPSHOPPER

subagent-monitor

A live pane of the session's subagents: what each is doing, how long it has run, and how much context it holds

newpaneguardcommandtimeragents
v0.1.0no licenseupdated 2026-10-15Ztubben/claude-code-mods/plugins/subagent-monitor
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · subagent-monitor
│ ┃ Subagents ✕ › fix the failing auth test and add an audit log call │ ┃ 0 active · 0 finished │ ┃ No subagents yet this session. ⏺ Read(src/auth.ts) │ ⎿ 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 │ │ › /subagents │ ⎿ subagent-monitor: Subagents pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Subagents
0 active · 0 finished No subagents yet this session.
README

claude-code-mods

Mods and other artifacts for Claude Code. Each mod lives in plugins/<name>/; the repository is a plugin marketplace listing them in .claude-plugin/marketplace.json.

Mods

ModWhat it does
usage-meterA band above the prompt: a context-window progress bar, plus the 5h and weekly usage limits as percentages with the time until each resets.
subagent-monitorA pane (/subagents, or opens by itself when a subagent starts) listing each subagent: its status, what it is doing now, how long it has run, and how much context it holds.

Install

At the prompt of a Claude Code terminal session:

/plugin install usage-meter --marketplace Ztubben/claude-code-mods
/plugin install subagent-monitor --marketplace Ztubben/claude-code-mods

Answer y to add the marketplace, then pick a scope (user scope loads it in every session).

From a shell:

claude plugin install usage-meter --marketplace Ztubben/claude-code-mods
claude plugin install subagent-monitor --marketplace Ztubben/claude-code-mods

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with Ztubben/claude-code-mods, then Add plugin.

Source 2 files
hooks/register.tsx 235 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ThemeKey, TurnUsage } from 'claude-code'
3
4import type { AgentRow } from '../types'
5
6const PANE = 'subagents'
7const TITLE = 'Subagents'
8const KEEP = 50
9
10const agents = atom({ plugin: 'subagent-monitor', key: 'agents' } as const, [])
11const now = atom({ plugin: 'subagent-monitor', key: 'now' } as const, 0)
12const contextWindow = atom({ plugin: 'subagent-monitor', key: 'window' } as const, 0)
13
14const ENDED: ReadonlySet<AgentRow['status']> = new Set(['completed', 'failed', 'killed'])
15const isActive = (row: AgentRow) => !ENDED.has(row.status)
16
17/** The tool argument that best says what a call is about, by the built-in tools' names for it. */
18const ARG_KEYS = ['command', 'file_path', 'pattern', 'path', 'url', 'query', 'description', 'prompt', 'skill']
19
20export function summarizeCall(tool: string, input: Readonly<Record<string, unknown>>): string {
21  for (const key of ARG_KEYS) {
22    const value = input[key]
23    if (typeof value === 'string' && value.trim() !== '') {
24      return `${tool} ${value.replace(/\s+/g, ' ').trim()}`
25    }
26  }
27  return tool
28}
29
30/** What the agent's next request re-sends: the last response's whole input side plus what it wrote. */
31export function contextOf(usage: TurnUsage): number {
32  return (
33    usage.input_tokens +
34    usage.cache_read_input_tokens +
35    usage.cache_creation_input_tokens +
36    usage.output_tokens
37  )
38}
39
40export function formatElapsed(ms: number): string {
41  const s = Math.max(0, Math.floor(ms / 1000))
42  if (s < 60) return `${s}s`
43  const m = Math.floor(s / 60)
44  if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
45  return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
46}
47
48export function formatTokens(n: number): string {
49  if (n < 1000) return `${n}`
50  if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
51  return `${(n / 1_000_000).toFixed(2)}M`
52}
53
54const patch = ($: EngineInterface, id: string, fn: (row: AgentRow) => AgentRow) =>
55  update($, agents, list => list.map(row => (row.id === id ? fn(row) : row)))
56
57/** Adds a row for an agent first seen by id alone (spawned before a reload, or by a path no spawn hook saw). */
58async function ensure($: EngineInterface, id: string): Promise<void> {
59  const list = await read($, agents)
60  if (list.some(row => row.id === id)) return
61  const info = (await $.agent.list()).find(one => one.id === id)
62  const row: AgentRow = {
63    id,
64    description: info?.description ?? id,
65    type: info?.type ?? 'agent',
66    status: info?.status ?? 'running',
67    startedAt: await $.clock.now(),
68    contextTokens: 0,
69    steps: 0,
70    toolCalls: 0,
71    activity: 'starting',
72  }
73  await update($, agents, rows => [...rows, row].slice(-KEEP))
74}
75
76/** Each second while any agent is active: advance the clock the pane reads, and take statuses from the engine. */
77async function tick($: EngineInterface): Promise<void> {
78  const list = await read($, agents)
79  if (!list.some(isActive)) return
80  const at = await $.clock.now()
81  const infos = new Map((await $.agent.list()).map(info => [info.id, info]))
82  await update($, agents, rows =>
83    rows.map(row => {
84      const status = infos.get(row.id)?.status ?? row.status
85      if (status === row.status) return row
86      return { ...row, status, endedAt: ENDED.has(status) ? (row.endedAt ?? at) : undefined }
87    }),
88  )
89  await update($, now, () => at)
90  const { context } = await $.session.usage()
91  await update($, contextWindow, () => context.window)
92}
93
94const STATUS_LOOK: Record<AgentRow['status'], { glyph: string; color: ThemeKey }> = {
95  pending: { glyph: '○', color: 'subtle' },
96  running: { glyph: '●', color: 'success' },
97  waiting: { glyph: '◐', color: 'warning' },
98  idle: { glyph: '◌', color: 'suggestion' },
99  completed: { glyph: '✓', color: 'subtle' },
100  failed: { glyph: '✗', color: 'error' },
101  killed: { glyph: '■', color: 'error' },
102}
103
104export const register: Register = on => {
105  on('session.start', async ($, e, next) => {
106    $.clock.every(1000, () => void tick($))
107    await $.command.register({
108      name: 'subagents',
109      description: 'Show what the subagents are doing, how long they have run and their context use',
110    })
111
112    return next(e)
113  })
114
115  on('command.run', { command: 'subagents' }, async $ => {
116    await $.ui.open({ id: PANE, title: TITLE })
117
118    return { text: 'Subagents pane opened.' }
119  })
120
121  on('agent.spawn', async ($, e, next) => {
122    const started = await next(e)
123    if (started.agentId === undefined) return started
124
125    const at = await $.clock.now()
126    const row: AgentRow = {
127      id: started.agentId,
128      description: e.description,
129      type: e.subagentType,
130      model: started.model,
131      status: 'running',
132      startedAt: at,
133      contextTokens: 0,
134      steps: 0,
135      toolCalls: 0,
136      activity: 'starting',
137    }
138    await update($, agents, list => [...list.filter(one => one.id !== row.id), row].slice(-KEEP))
139    await update($, now, () => at)
140    void $.ui.open({ id: PANE, title: TITLE })
141
142    return started
143  }).catch(($, e, next) => next(e))
144
145  on('turn.start', async ($, e, next) => {
146    if (e.agentId === undefined) return next(e)
147    await ensure($, e.agentId)
148    await patch($, e.agentId, row => ({ ...row, status: 'running', endedAt: undefined }))
149
150    return next(e)
151  })
152
153  on('tool.call', async ($, e, next) => {
154    if (e.agentId === undefined) return next(e)
155    const id = e.agentId
156    await ensure($, id)
157    const activity = summarizeCall(e.tool, e as unknown as Record<string, unknown>)
158    await patch($, id, row => ({ ...row, toolCalls: row.toolCalls + 1, activity }))
159
160    return next(e)
161  }).catch(($, e, next) => next(e))
162
163  on('turn.step', async function* ($, e, next) {
164    if (e.agentId === undefined) return yield* next(e)
165    const id = e.agentId
166    const response = yield* next(e)
167    await ensure($, id)
168    await patch($, id, row => ({
169      ...row,
170      steps: row.steps + 1,
171      model: response.usage?.model ?? row.model ?? e.model,
172      contextTokens: response.usage ? contextOf(response.usage) : row.contextTokens,
173      activity: response.toolUses.length === 0 && response.answer !== '' ? 'writing its answer' : row.activity,
174    }))
175
176    return response
177  })
178
179  on('turn.complete', async ($, e, next) => {
180    if (e.agentId === undefined) return next(e)
181    const id = e.agentId
182    const at = await $.clock.now()
183    await patch($, id, row => ({
184      ...row,
185      status: e.reason === 'answer' ? 'completed' : e.reason === 'aborted' ? 'killed' : 'failed',
186      endedAt: at,
187      activity: e.reason === 'answer' ? 'done' : e.reason,
188    }))
189    await update($, now, () => at)
190
191    return next(e)
192  })
193
194  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
195    const { Box, Text } = $.ui.resolve(e)
196    const list = await read($, agents)
197    const at = await read($, now)
198    const win = await read($, contextWindow)
199    const active = list.filter(isActive)
200    const ended = list.filter(row => !isActive(row)).reverse()
201    const rows = Math.max(1, (e.viewport?.rows ?? 24) - 3)
202    const shown = [...active, ...ended].slice(0, Math.max(1, Math.floor(rows / 3)))
203
204    return (
205      <Box flexDirection="column">
206        <Text bold>
207          {active.length} active · {ended.length} finished
208        </Text>
209        {list.length === 0 && <Text dimColor>No subagents yet this session.</Text>}
210        {shown.map(row => {
211          const look = STATUS_LOOK[row.status]
212          const elapsed = formatElapsed((row.endedAt ?? Math.max(at, row.startedAt)) - row.startedAt)
213          const percent = win > 0 && row.contextTokens > 0 ? ` (${Math.round((row.contextTokens / win) * 100)}%)` : ''
214          return (
215            <Box key={row.id} flexDirection="column">
216              <Text wrap="truncate-end">
217                <Text color={look.color}>{look.glyph}</Text> <Text bold>{row.type}</Text> {row.description}
218              </Text>
219              <Text dimColor wrap="truncate-end">
220                {'  '}
221                {elapsed} · ctx {formatTokens(row.contextTokens)}
222                {percent} · {row.toolCalls} tools · {row.steps} steps{row.model ? ` · ${row.model}` : ''}
223              </Text>
224              <Text dimColor={!isActive(row)} wrap="truncate-end">
225                {'  ↳ '}
226                {row.activity}
227              </Text>
228            </Box>
229          )
230        })}
231      </Box>
232    )
233  })
234}
235
types/index.d.ts 22 lines
1export type AgentRow = {
2  id: string
3  description: string
4  type: string
5  model?: string
6  status: 'pending' | 'running' | 'waiting' | 'idle' | 'completed' | 'failed' | 'killed'
7  startedAt: number
8  endedAt?: number
9  /** Tokens the agent's last model request was answered over, plus its output: what its next request re-sends. */
10  contextTokens: number
11  steps: number
12  toolCalls: number
13  /** What it is doing now: its latest tool call, summarized. */
14  activity: string
15}
16
17declare module 'claude-code' {
18  interface PluginState {
19    'subagent-monitor': { agents: AgentRow[]; now: number; window: number }
20  }
21}
22