SLOPSHOPPER

agent-monitor

A boxed band above the prompt listing the main agent and each active subagent with its model and context usage.

bandguardpromptagents
v0.1.0no licenseupdated 2026-10-16jmseguin/agent-monitor
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-monitor
› fix the failing auth test and add an audit log call ⏺ 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 ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ main claude-opus-5-5 █████░░░░░ 49% 97.4k / 200.0k │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ main claude-opus-5-5 █████░░░░░ 49% 97.4k / 200.0k │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Source 2 files
hooks/register.tsx 257 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { LiveAgent, LiveShell } from '../types'
5
6const MAX_SUBAGENT_ROWS = 4
7const KEEP_AGENTS = 50
8const KEEP_SHELLS = 50
9
10const agents = atom({ plugin: 'agent-monitor', key: 'agents' } as const, {})
11const shells = atom(
12  { plugin: 'agent-monitor', key: 'shells' } as const,
13  {} as Record<string, LiveShell>,
14)
15
16const elapsed = (since: number): string => {
17  const s = Math.max(0, Math.round((Date.now() - since) / 1000))
18  if (s >= 3600) {
19    return `${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60)}m`
20  }
21  if (s >= 60) {
22    return `${Math.floor(s / 60)}m${s % 60}s`
23  }
24
25  return `${s}s`
26}
27const rev = atom({ plugin: 'agent-monitor', key: 'rev' } as const, 0)
28
29const ENDED = ['completed', 'failed', 'killed']
30
31const compact = (n: number | null | undefined): string => {
32  if (n === null || n === undefined) {
33    return '-'
34  }
35  if (n >= 1_000_000) {
36    return `${(n / 1_000_000).toFixed(1)}M`
37  }
38  if (n >= 1_000) {
39    return `${(n / 1_000).toFixed(1)}k`
40  }
41
42  return String(n)
43}
44
45const bar = (percent: number | null): string => {
46  const filled =
47    percent === null ? 0 : Math.round(Math.min(100, Math.max(0, percent)) / 10)
48
49  return '█'.repeat(filled) + '░'.repeat(10 - filled)
50}
51
52const percentColor = (percent: number | null): string => {
53  if (percent !== null && percent >= 85) {
54    return 'error'
55  }
56  if (percent !== null && percent >= 70) {
57    return 'warning'
58  }
59
60  return 'success'
61}
62
63async function addShell(
64  $: EngineInterface,
65  id: string,
66  kind: string,
67  command: string,
68) {
69  await update($, shells, all =>
70    Object.fromEntries(
71      Object.entries({
72        ...all,
73        [id]: { kind, command, startedAt: Date.now(), ended: false },
74      }).slice(-KEEP_SHELLS),
75    ),
76  )
77}
78
79async function endShell($: EngineInterface, id: string | undefined) {
80  if (id === undefined) {
81    return
82  }
83  await update($, shells, all =>
84    all[id] ? { ...all, [id]: { ...all[id], ended: true } } : all,
85  )
86}
87
88export const register: Register = on => {
89  on('session.measure', async ($, e, next) => {
90    await update($, rev, n => n + 1)
91
92    return next(e)
93  })
94
95  on('agent.spawn', async ($, e, next) => {
96    const spawned = await next(e)
97    await update($, rev, n => n + 1)
98
99    return spawned
100  })
101
102  on('turn.step', async function* ($, e, next) {
103    const result = yield* next(e)
104    const id = e.agentId
105
106    if (id !== undefined) {
107      const usage = result.usage
108      const tokens = usage
109        ? usage.input_tokens +
110          usage.cache_read_input_tokens +
111          usage.cache_creation_input_tokens
112        : null
113
114      await update($, agents, all => {
115        const known: LiveAgent | undefined = all[id]
116        const merged: Record<string, LiveAgent> = {
117          ...all,
118          [id]: {
119            model: usage?.model ?? e.model,
120            tokens: tokens ?? known?.tokens ?? null,
121          },
122        }
123
124        return Object.fromEntries(Object.entries(merged).slice(-KEEP_AGENTS))
125      })
126    }
127
128    return result
129  })
130
131  // Background shells (Bash/PowerShell) and monitors: registered from the
132  // tool result, which carries the task id however the backgrounding happened
133  // (run_in_background, Ctrl+B, or an auto-background on timeout).
134  on('tool.call', async ($, e, next) => {
135    const out = await next(e)
136    const input = e as unknown as Record<string, unknown>
137    const result = (out as { result?: Record<string, unknown> }).result
138
139    if (e.tool === 'Bash' || e.tool === 'PowerShell') {
140      const id = result?.backgroundTaskId
141      if (typeof id === 'string') {
142        await addShell($, id, e.tool, String(input.command ?? ''))
143      }
144    } else if (e.tool === 'Monitor') {
145      const id = result?.taskId
146      if (typeof id === 'string') {
147        await addShell(
148          $,
149          id,
150          'Monitor',
151          String(input.command ?? input.description ?? 'monitor'),
152        )
153      }
154    } else if (e.tool === 'TaskStop') {
155      const id = input.task_id ?? input.shell_id
156      await endShell($, typeof id === 'string' ? id : undefined)
157    }
158
159    return out
160  })
161
162  // A finished background task arrives as a task-notification prompt naming its id.
163  on('prompt.submit', async ($, e, next) => {
164    if (e.origin.kind === 'task-notification') {
165      const id = /<task-id>\s*([^<\s]+)\s*<\/task-id>/.exec(e.text)?.[1]
166      await endShell($, id)
167    }
168
169    return next(e)
170  })
171
172  on('turn.complete', async ($, e, next) => {
173    await update($, rev, n => n + 1)
174
175    return next(e)
176  })
177
178  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
179    if (e.props.hasSurvey || e.props.maxRows < 3) {
180      return next(e)
181    }
182
183    await read($, rev)
184    const live = await read($, agents)
185    const shellMap = await read($, shells)
186    const [model, usage, roster] = await Promise.all([
187      $.session.model(),
188      $.session.usage(),
189      $.agent.list(),
190    ])
191    const { Box, Text } = $.ui.resolve(e)
192
193    const context = usage.context
194    const percent = context.percent ?? null
195    const running = roster.filter(agent => !ENDED.includes(agent.status))
196    const activeShells = Object.entries(shellMap).filter(([, s]) => !s.ended)
197    const items = [
198      ...running.map(agent => ({ agent, shell: undefined })),
199      ...activeShells.map(([id, shell]) => ({ agent: undefined, id, shell })),
200    ]
201    const room = Math.max(0, Math.min(MAX_SUBAGENT_ROWS, e.props.maxRows - 3))
202    const overflow = items.length > room
203    const shown = items.slice(0, overflow ? Math.max(0, room - 1) : room)
204
205    return (
206      <Box
207        flexDirection="column"
208        borderStyle="round"
209        borderDimColor
210        paddingX={1}
211      >
212        <Box gap={2} height={1} overflow="hidden">
213          <Text bold color="claude">
214            main
215          </Text>
216          <Text>{model}</Text>
217          <Text color={percentColor(percent)}>
218            {bar(percent)} {percent === null ? '-' : `${Math.round(percent)}%`}
219          </Text>
220          <Text dimColor>
221            {compact(context.tokens)} / {compact(context.window)}
222          </Text>
223        </Box>
224        {shown.map(item =>
225          item.agent ? (
226            <Box gap={2} height={1} overflow="hidden">
227              <Text dimColor>└</Text>
228              <Text bold>{item.agent.type}</Text>
229              <Text>{live[item.agent.id]?.model ?? '(starting)'}</Text>
230              <Text dimColor>{compact(live[item.agent.id]?.tokens)} tok</Text>
231              <Text dimColor wrap="truncate-end">
232                {item.agent.status} · {item.agent.description}
233              </Text>
234            </Box>
235          ) : (
236            <Box gap={2} height={1} overflow="hidden">
237              <Text dimColor>└</Text>
238              <Text bold color="warning">
239                {item.shell.kind === 'Monitor' ? 'monitor' : 'shell'}
240              </Text>
241              <Text dimColor>{elapsed(item.shell.startedAt)}</Text>
242              <Text dimColor wrap="truncate-end">
243                {item.shell.command}
244              </Text>
245            </Box>
246          ),
247        )}
248        {overflow && (
249          <Text dimColor>
250            └ +{items.length - shown.length} more
251          </Text>
252        )}
253      </Box>
254    )
255  })
256}
257
types/index.d.ts 22 lines
1export type LiveAgent = {
2  model: string
3  tokens: number | null
4}
5
6export type LiveShell = {
7  kind: string
8  command: string
9  startedAt: number
10  ended: boolean
11}
12
13declare module 'claude-code' {
14  interface PluginState {
15    'agent-monitor': {
16      agents: Record<string, LiveAgent>
17      shells: Record<string, LiveShell>
18      rev: number
19    }
20  }
21}
22