SLOPSHOPPER

agent-watch

Docks a narrow pane beside the transcript that follows each running subagent live

newpaneguardcommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-watch
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ No subagents running. │ ⏺ 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 │ │ › /agents-pane │ ⎿ agent-watch: Agents pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
No subagents running.
README

claude-code-mods

Two small Claude Code mods (function-hook plugins) as a plugin marketplace.

usage-bars

One row under the prompt, refreshed after every turn:

usage-bars

GroupShows
⎇git branch, ● uncommitted files, ↑/↓ commits ahead of / behind upstream, ✓ when clean
◆the model the main loop runs and its effort level
ctxcontext-window fill over the last 8 turns, then the live % and tokens / window
tokwhat each turn cost (bars), then live session figures, subagents included: in tokens read (cached or not), out tokens generated, the latest request's cache hit rate (yellow below 90 %, red below 50 %: the cache went cold) and the session cost in $ as Claude Code prices it
5h / 7dyour rate-limit windows: % used and time until reset (subscription accounts)

Charts turn yellow from 60 % and red from 85 %. The row wraps on narrow terminals.

What the numbers mean (and don't)

  • $ is an estimate. It is Claude Code's own figure (the one /cost shows): each response's reported token counts times the published per-model prices, so input, output, cache writes and cache reads are already weighted correctly. On the API that is close to your bill, but the Console's usage page is authoritative: server-tool fees (web search), 1-hour cache writes, fast mode, data-residency and other pricing modifiers, or Bedrock / Vertex prices can make the invoice differ.
  • On a subscription (Pro / Max) you are not billed per token. Anthropic does not publish how the 5-hour and weekly limits are counted, so $ is only a relative gauge of which turns were heavy. The 5h / 7d percentages are the official figures; they come straight from Anthropic's rate-limit data.
  • in is volume, not cost. It counts cached and uncached input alike; a cache read costs a fraction of a fresh input token (for example 0.05x on Opus 5.5) and output costs 5x input. Only $ applies those weights.
  • in, out and hit count what the mod has seen since it loaded in a session; $ covers the whole session.

agent-watch

When a subagent starts, a narrow pane docks beside the transcript and follows each agent live: type, elapsed time, tool count, its latest tool calls (› running, ✓ / ✗ done) and the tail of what it is writing. Finished agents collapse to one line; the pane closes itself 20 s after the last one ends.

agent-watch

The pane opens on its own from 144 terminal columns (a Claude Code rule for panes nobody asked for); on a narrower terminal run /agents-pane.

Install

/plugin marketplace add AndreasOA/claude-code-mods
/plugin install usage-bars@ao-claude-mods
/plugin install agent-watch@ao-claude-mods

Restart Claude Code (or open a new session) to load them. /plugin marketplace update ao-claude-mods pulls new versions.

Develop

Each plugin is plugins/<name>/ with hooks/register.tsx, its state contract in types/index.d.ts and tests in tests/.

claude plugin validate plugins/usage-bars
claude plugin test plugins/usage-bars
claude --plugin-dir plugins/usage-bars   # load a working copy, hot-reloaded on save

The images in docs/ are illustrations of the layout, not screenshots.

License

MIT

Source 2 files
hooks/register.tsx 185 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentView, Step } from '../types'
5
6const PANE = 'agent-watch'
7// How wide the docked pane asks to be
8const COLUMNS = 56
9// How many tool calls each agent keeps
10const MAX_STEPS = 12
11// How long the pane stays after the last agent finished
12const LINGER_MS = 20_000
13// Spinner frames for a running agent, one per tick
14const SPIN = ['◐', '◓', '◑', '◒']
15
16const agents = atom({ plugin: 'agent-watch', key: 'agents' } as const, [] as AgentView[])
17const tick = atom({ plugin: 'agent-watch', key: 'tick' } as const, 0)
18
19// The ticker and the pending close; the module's own, so a reload starts them over
20let ticker: { cancel: () => void } | null = null
21let closer: { cancel: () => void } | null = null
22
23// The argument worth showing for a tool call: the command, the path, the pattern
24function summarize(input: Record<string, unknown>): string {
25  for (const key of ['command', 'file_path', 'path', 'pattern', 'url', 'query', 'description', 'prompt']) {
26    const value = input[key]
27    if (typeof value === 'string' && value) return value.replace(/\s+/g, ' ').replace(/^\/Users\/[^/]+/, '~')
28  }
29  return ''
30}
31
32// 72000 -> "1m12s"
33function elapsed(ms: number): string {
34  const s = Math.max(Math.round(ms / 1000), 0)
35  return s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `${s}s`
36}
37
38// Change one agent's view in place
39function patch($: EngineInterface, id: string, change: (a: AgentView) => AgentView) {
40  return update($, agents, list => list.map(a => (a.id === id ? change(a) : a)))
41}
42
43// Know the agent the first time its loop shows up; false for a loop the list never names (compaction, memory)
44async function track($: EngineInterface, id: string): Promise<boolean> {
45  if ((await read($, agents)).some(a => a.id === id)) return true
46  const info = (await $.agent.list()).find(a => a.id === id)
47  if (!info) return false
48  const now = await $.clock.now()
49  const view: AgentView = {
50    id,
51    type: info.type,
52    description: info.description,
53    status: 'running',
54    startedAt: now,
55    endedAt: null,
56    tools: 0,
57    steps: [],
58    text: '',
59  }
60  await update($, agents, list => (list.some(a => a.id === id) ? list : [...list, view]))
61  closer?.cancel()
62  closer = null
63  if (!ticker) ticker = $.clock.every(1000, () => update($, tick, n => n + 1))
64  await $.ui.open({ id: PANE, title: 'Agents', columns: COLUMNS })
65  return true
66}
67
68// An agent ended: stop the ticker once none runs, and close the pane a while later
69async function finish($: EngineInterface, id: string, failed: boolean) {
70  const now = await $.clock.now()
71  await patch($, id, a => ({ ...a, status: failed ? 'failed' : 'done', endedAt: now }))
72  if ((await read($, agents)).some(a => a.status === 'running')) return
73  ticker?.cancel()
74  ticker = null
75  closer?.cancel()
76  closer = $.clock.after(LINGER_MS, async () => {
77    if ((await read($, agents)).some(a => a.status === 'running')) return
78    await update($, agents, () => [])
79    await $.ui.close({ id: PANE })
80  })
81}
82
83export const register: Register = on => {
84  on('session.start', async ($, e, next) => {
85    await $.command.register({ name: 'agents-pane', description: 'Show the subagents of this session in a pane' })
86    return next(e)
87  })
88
89  on('command.run', { command: 'agents-pane' }, async $ => {
90    await $.ui.open({ id: PANE, title: 'Agents', columns: COLUMNS })
91    return { text: 'Agents pane opened.' }
92  })
93
94  // A subagent's tool call: a step that runs, then turns ✓ or ✗
95  on('tool.call', async ($, e, next) => {
96    if (e.agentId === undefined || !(await track($, e.agentId))) return next(e)
97    const id = e.agentId
98    const step: Step = { id: e.tool_use_id, tool: e.tool, summary: summarize(e as unknown as Record<string, unknown>), state: 'run' }
99    await patch($, id, a => ({ ...a, tools: a.tools + 1, steps: [...a.steps, step].slice(-MAX_STEPS) }))
100    const ran = await next(e)
101    const state = ran.deny !== undefined || ran.isError ? 'err' : 'ok'
102    await patch($, id, a => ({ ...a, steps: a.steps.map(s => (s.id === step.id ? { ...s, state } : s)) }))
103    return ran
104  })
105
106  // A subagent's model output: keep the tail of its text, written at most twice a second
107  on('turn.step', async function* ($, e, next) {
108    const id = e.agentId
109    const stream = next(e)
110    if (id === undefined || !(await track($, id))) return yield* stream
111    let text = ''
112    let written = 0
113    for (let r = await stream.next(); ; r = await stream.next()) {
114      if (r.done) {
115        if (text.trim()) await patch($, id, a => ({ ...a, text: text.trim().replace(/\s+/g, ' ').slice(-240) }))
116        return r.value
117      }
118      if (r.value.kind === 'text') {
119        text += r.value.text
120        const now = Date.now()
121        if (now - written > 500) {
122          written = now
123          await patch($, id, a => ({ ...a, text: text.trim().replace(/\s+/g, ' ').slice(-240) }))
124        }
125      }
126      yield r.value
127    }
128  })
129
130  // A subagent's run ended
131  on('turn.complete', async ($, e, next) => {
132    const done = await next(e)
133    if (e.agentId !== undefined && (await read($, agents)).some(a => a.id === e.agentId)) {
134      await finish($, e.agentId, e.reason === 'error' || e.reason === 'aborted')
135    }
136    return done
137  })
138
139  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
140    const { Box, Text } = $.ui.resolve(e)
141    const list = await read($, agents)
142    const n = await read($, tick)
143    const now = await $.clock.now()
144    if (list.length === 0) return <Text dimColor>No subagents running.</Text>
145
146    // Running agents first, newest first; finished ones after them
147    const shown = [...list].sort((a, b) => (a.status === 'running') === (b.status === 'running') ? b.startedAt - a.startedAt : a.status === 'running' ? -1 : 1)
148    const rows = e.viewport?.rows ?? 40
149    const perAgent = Math.max(2, Math.floor((rows - 2) / shown.length) - 4)
150
151    return (
152      <Box flexDirection="column" rowGap={1}>
153        {shown.map(a => {
154          const running = a.status === 'running'
155          const glyph = running ? SPIN[n % SPIN.length] : a.status === 'done' ? '✓' : '✗'
156          const color = running ? 'cyan' : a.status === 'done' ? 'green' : 'red'
157          return (
158            <Box key={a.id} flexDirection="column">
159              <Text wrap="truncate-end">
160                <Text color={color}>{glyph} </Text>
161                <Text bold dimColor={!running}>{a.type}</Text>
162                <Text dimColor>  {elapsed((a.endedAt ?? now) - a.startedAt)} · {a.tools} tools</Text>
163              </Text>
164              <Text dimColor wrap="truncate-end">  {a.description}</Text>
165              {(running ? a.steps.slice(-perAgent) : a.steps.slice(-1)).map(s => (
166                <Text key={s.id} wrap="truncate-end">
167                  {'  '}
168                  <Text color={s.state === 'run' ? 'yellow' : s.state === 'ok' ? 'green' : 'red'}>{s.state === 'run' ? '›' : s.state === 'ok' ? '✓' : '✗'}</Text>
169                  <Text dimColor={!running}> {s.tool} </Text>
170                  <Text dimColor>{s.summary}</Text>
171                </Text>
172              ))}
173              {running && a.text && (
174                <Text dimColor italic wrap="truncate-start">
175                  {'  '}“{a.text}”
176                </Text>
177              )}
178            </Box>
179          )
180        })}
181      </Box>
182    )
183  })
184}
185
types/index.d.ts 29 lines
1// One tool call a subagent made: running, answered, or failed
2export type Step = { id: string; tool: string; summary: string; state: 'run' | 'ok' | 'err' }
3
4// One subagent as the pane shows it
5export type AgentView = {
6  id: string
7  type: string
8  description: string
9  status: 'running' | 'done' | 'failed'
10  startedAt: number
11  endedAt: number | null
12  tools: number
13  // The newest tool calls, oldest first
14  steps: Step[]
15  // The tail of what the agent last wrote
16  text: string
17}
18
19declare module 'claude-code' {
20  interface PluginState {
21    'agent-watch': {
22      // The agents of this session, oldest first
23      agents: AgentView[]
24      // Bumped every second while one runs, so elapsed times redraw
25      tick: number
26    }
27  }
28}
29