SLOPSHOPPER

agent-watch

Shows one line per running subagent above the prompt: model, task, minutes and its latest tool call

newbandguardtoasttimeragents
v0.1.0MITupdated 2026-10-020xGondarxyz/claude-code-mods/agent-watch
A shopper browsing a rack in a slop shop
README

agent-watch

A Claude Code mod. While subagents run, the band above the prompt shows one line per agent:

Sonnet · Build snap screenshot mod · 6 min · now: Bash claude plugin test
  • Label: the agent's model (Sonnet, Opus, Haiku, Fable), else its subagent type.
  • Minutes update every 15 s.
  • now: is the agent's latest tool call (file name, first 40 characters of a Bash command, or pattern), cut to the band width.
  • When an agent ends, its line shows done (green) for 30 s, or stopped (yellow) if it failed or was stopped, with one toast Agent finished: <description>.
  • With no agents the mod draws nothing. Other mods' rows stay below ours.

Engine data used

  • Start: agent.spawn. next(e) answers { model, agentId }; the input holds description and subagentType.
  • Progress: tool.call with agentId (set only inside a subagent loop). Hooks always pass through.
  • Finish: turn.complete with agentId (reason answer is done, anything else stopped), and every 15 s $.agent.list() status (completed, failed, killed) as a fallback.
  • State: agent-watch.agents in $.state, so a hot reload keeps the rows.

Limits

  • The engine does not tell a background agent's end apart from a foreground one; both end through turn.complete or $.agent.list().
  • Teammates (type: teammate) never pass agent.spawn, so they are not shown.

Develop

claude plugin validate .
claude plugin test .
npx tsc -p .
Source 3 files
hooks/register.tsx 158 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRow, AgentStatus } from '../types'
5import { DONE_VISIBLE_MS, TICK_MS, describeTool, labelFor, rowText, visibleRows } from './agent'
6
7const agents = atom({ plugin: 'agent-watch', key: 'agents' } as const, {} as Record<string, AgentRow>)
8
9type Ticker = { timer: { cancel: () => void } | null }
10
11// Ends a running agent once: marks the row, toasts, and drops the row after 30 s.
12async function finish($: EngineInterface, id: string, status: AgentStatus): Promise<void> {
13  const now = await $.clock.now()
14  let description: string | null = null
15  await update($, agents, rows => {
16    const row = rows[id]
17    if (!row || row.status !== 'running') return rows
18    description = row.description
19    return { ...rows, [id]: { ...row, status, endedAt: now } }
20  })
21  if (description === null) return
22  $.ui.toast(`Agent finished: ${description}`)
23  $.ui.invalidate('ui.render')
24  $.clock.after(DONE_VISIBLE_MS, async () => {
25    await update($, agents, rows => {
26      const { [id]: _gone, ...rest } = rows
27      return rest
28    })
29    $.ui.invalidate('ui.render')
30  })
31}
32
33// Ends agents the engine lists as no longer running (covers a stop that raises no turn.complete).
34async function reconcile($: EngineInterface): Promise<void> {
35  let list: { id: string; status: string }[] = []
36  try {
37    list = await $.agent.list()
38  } catch {
39    return
40  }
41  const rows = await read($, agents)
42  for (const info of list) {
43    const row = rows[info.id]
44    if (!row || row.status !== 'running') continue
45    if (info.status === 'completed') await finish($, info.id, 'done')
46    else if (info.status === 'failed' || info.status === 'killed') await finish($, info.id, 'stopped')
47  }
48}
49
50async function tick($: EngineInterface, ticker: Ticker): Promise<void> {
51  try {
52    await reconcile($)
53    const now = await $.clock.now()
54    const rows = await read($, agents)
55    const left = visibleRows(rows, now)
56    if (left.length !== Object.keys(rows).length) {
57      await update($, agents, all => Object.fromEntries(left.map(r => [r.id, all[r.id] ?? r])))
58    }
59    if (left.length === 0 && ticker.timer) {
60      ticker.timer.cancel()
61      ticker.timer = null
62    }
63  } catch {}
64  $.ui.invalidate('ui.render')
65}
66
67// Starts the 15 s redraw timer once per module load; a hot reload drops it, so render restarts it.
68function ensureTick($: EngineInterface, ticker: Ticker): void {
69  if (ticker.timer) return
70  ticker.timer = $.clock.every(TICK_MS, () => {
71    void tick($, ticker)
72  })
73}
74
75export const register: Register = on => {
76  const ticker: Ticker = { timer: null }
77  // The latest tool of an agent whose row does not exist yet (its first call can beat the spawn answer).
78  const early = new Map<string, string>()
79
80  on('agent.spawn', async ($, e, next) => {
81    const res = await next(e)
82    try {
83      const id = res.agentId
84      if (id !== undefined) {
85        const startedAt = await $.clock.now()
86        const row: AgentRow = {
87          id,
88          label: labelFor(res.model, e.subagentType),
89          description: e.description,
90          startedAt,
91          tool: early.get(id) ?? '',
92          status: 'running',
93        }
94        early.delete(id)
95        await update($, agents, rows => ({ ...rows, [id]: row }))
96        ensureTick($, ticker)
97        $.ui.invalidate('ui.render')
98      }
99    } catch {}
100    return res
101  })
102
103  on('tool.call', async ($, e, next) => {
104    try {
105      if (e.agentId !== undefined) {
106        const id = e.agentId
107        const tool = describeTool(e as any)
108        const rows = await read($, agents)
109        if (rows[id]) {
110          await update($, agents, all => (all[id] ? { ...all, [id]: { ...all[id], tool } } : all))
111          $.ui.invalidate('ui.render')
112        } else if (early.size < 50) {
113          early.set(id, tool)
114        }
115      }
116    } catch {}
117    return next(e)
118  })
119
120  on('turn.complete', async ($, e, next) => {
121    const res = await next(e)
122    try {
123      if (e.agentId !== undefined) await finish($, e.agentId, e.reason === 'answer' ? 'done' : 'stopped')
124    } catch {}
125    return res
126  })
127
128  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
129    if (e.props.hasSurvey) return next(e)
130    // The band has one slot shared with other mods: our rows on top, their drawing below.
131    const below = await next(e)
132    try {
133      const now = await $.clock.now()
134      const rows = visibleRows(await read($, agents), now)
135      if (rows.length === 0) return below
136      ensureTick($, ticker)
137
138      const { Box, Text } = $.ui.resolve(e)
139      const columns = e.props.bodyColumns
140      return (
141        <Box flexDirection="column">
142          {rows.map(row => (
143            <Text
144              key={`aw-${row.id}`}
145              color={row.status === 'done' ? 'green' : row.status === 'stopped' ? 'yellow' : undefined}
146            >
147              {rowText(row, now, columns)}
148            </Text>
149          ))}
150          {below.type !== 'engine' ? below : null}
151        </Box>
152      )
153    } catch {
154      return below
155    }
156  })
157}
158
hooks/agent.ts 54 lines
1import type { AgentRow } from '../types'
2
3export const TICK_MS = 15000
4export const DONE_VISIBLE_MS = 30000
5
6const MODELS = ['sonnet', 'opus', 'haiku', 'fable']
7
8// The model's short name when the id or alias holds one, else the subagent type.
9export function labelFor(model: string | undefined, subagentType: string | undefined): string {
10  const lower = (model ?? '').toLowerCase()
11  const known = MODELS.find(m => lower.includes(m))
12  if (known) return known[0]!.toUpperCase() + known.slice(1)
13  return subagentType && subagentType !== '' ? subagentType : 'Agent'
14}
15
16const base = (path: unknown): string => String(path ?? '').split('/').pop() ?? ''
17
18// A short "Tool arg" for the latest tool call. `e` is the tool.call event.
19export function describeTool(e: { tool: string } & Record<string, unknown>): string {
20  const tool = String(e.tool)
21  let arg = ''
22  if (tool === 'Read' || tool === 'Edit' || tool === 'Write') arg = base(e.file_path)
23  else if (tool === 'Bash') arg = String(e.command ?? '').replace(/\s+/g, ' ').trim().slice(0, 40)
24  else if (tool === 'Grep' || tool === 'Glob') arg = String(e.pattern ?? '')
25  return arg === '' ? tool : `${tool} ${arg}`
26}
27
28export function truncate(text: string, columns: number): string {
29  if (columns <= 0 || text.length <= columns) return text
30  return columns === 1 ? '…' : `${text.slice(0, columns - 1)}…`
31}
32
33export function minutes(startedAt: number, now: number): number {
34  return Math.max(0, Math.floor((now - startedAt) / 60000))
35}
36
37// One band line. The `now:` part is cut to the space the columns leave.
38export function rowText(row: AgentRow, now: number, columns: number): string {
39  const head = `${row.label} · ${row.description}`
40  if (row.status === 'done') return truncate(`${head} · done`, columns)
41  if (row.status === 'stopped') return truncate(`${head} · stopped`, columns)
42  const mins = minutes(row.startedAt, now)
43  const withTime = `${head} · ${mins < 1 ? '<1' : mins} min`
44  if (row.tool === '') return truncate(withTime, columns)
45  return truncate(`${withTime} · now: ${row.tool}`, columns)
46}
47
48// Rows to draw: running ones, and finished ones for DONE_VISIBLE_MS.
49export function visibleRows(rows: Record<string, AgentRow>, now: number): AgentRow[] {
50  return Object.values(rows)
51    .filter(r => r.status === 'running' || now - (r.endedAt ?? 0) < DONE_VISIBLE_MS)
52    .sort((a, b) => a.startedAt - b.startedAt)
53}
54
types/index.d.ts 24 lines
1export type AgentStatus = 'running' | 'done' | 'stopped'
2
3export type AgentRow = {
4  /** The agent's id, as `agent.spawn` answered it and `tool.call` carries it. */
5  id: string
6  /** Model name (`Sonnet`) or the subagent type when no model is known. */
7  label: string
8  /** The Agent call's description. */
9  description: string
10  /** `$.clock.now()` when it started, in ms. */
11  startedAt: number
12  /** Latest tool call, as `Bash claude plugin test`; empty before the first. */
13  tool: string
14  status: AgentStatus
15  /** `$.clock.now()` when it finished, in ms; set once status is not running. */
16  endedAt?: number
17}
18
19declare module 'claude-code' {
20  interface PluginState {
21    'agent-watch': { agents: Record<string, AgentRow> }
22  }
23}
24