SLOPSHOPPER

agent-watch

Shows the main chat, helper agents, their models, tasks and skills for the current turn.

newpanebandguardcommandprompt
★ 2v1.3.0no licenseupdated 2026-10-22Abdallah-Abdelazim/claude-code-mods/plugins/agent-watch
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 agents yet this turn. │ ⏺ 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 │ │ › /agent-watch │ ⎿ agent-watch: Agents pane opened. │ │ ◎ AGENTS idle ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◎ AGENTS idle ⟨Claude Code's own drawing⟩
Pane · Agents
No agents yet this turn.
README

claude-code-mods

<img width="1096" height="808" alt="Screenshot 2026-10-19 at 8 25 29 PM" src="https://github.com/user-attachments/assets/b22cc708-5a70-4333-b3ba-79517a3b7603" />

Mods for Claude Code that add a band above the prompt, a side pane or a slash command. Each is its own plugin, so you can install only the ones you want.

The mods

PluginBandWhat it doesCommand
band-rulelineDraws a dim line above the bands to separate them from Claude's output. List it before the other mods (see below).none
cache-clockCACHEShows whether the prompt cache is still warm, the time left, % reused and the cold-rewrite cost, with a Compact button. Toasts shortly before the cache goes cold.`/cache-clock on \off \status`
usage-gaugeUSAGEPlan usage windows (5h, week, others) with reset times, the session cost and a pace warning. Toasts at 75% and 90%./usage-gauge (opens a pane), `on \off \warn <a> <b> \status`
usage-limitsfooterSession (5h) and week (7d) rate-limit bars in the prompt footer.none
auto-handoffCONTEXTWatches how full the chat is and saves a HANDOFF.md when it reaches a threshold, then offers to clear and resume.`/auto-handoff on \off \now \resume \threshold <n> \mode auto\ask \hide \show \status`
agent-watchAGENTSShows the main chat and helper agents with their models and tasks, plus the skills that ran this turn. Opens an Agents pane./agent-watch (opens the Agents pane), `on \off \clear \help`

Bands stack in the order their plugins appear in enabledPlugins in ~/.claude/settings.json, first on top. To reorder, move the lines and run /reload-plugins; claude plugin install adds new entries at the end. band-rule draws its line above the whole stack only when it is listed first. The author's order, top to bottom: band-rule, agent-watch, auto-handoff, cache-clock, usage-gauge, usage-limits. The last one draws in the footer, so its place doesn't matter. The Band column is the title each mod shows in its band.

Each band is one row. In a narrow terminal the end of the row is cut off with … instead of wrapping.

usage-gauge and usage-limits both show plan usage, so enable only one.

In auto mode, auto-handoff sends the handoff prompt as soon as the threshold is crossed, even while Claude is mid-task. The default ask mode only offers the button.

cache-clock has a cacheMinutes option (5 or 60, default 60). Other settings are set through each mod's commands and persist between sessions.

Install

Install a mod from the marketplace:

/plugin install cache-clock --marketplace Abdallah-Abdelazim/claude-code-mods

Run it once per mod, changing the plugin name. From a local clone, use --marketplace ~/path/to/claude-code-mods instead.

To try one without installing: claude --plugin-dir plugins/<name>.

Requirements

These mods use Claude Code's function-hook plugin API, which is in early access; they were written against Claude Code 2.1.303. Where an organization manages Claude Code, plugins you install don't run the session.compact, skill.prompt and prompt.compose hooks.

Develop

Each folder under plugins/ is a standalone mod: .claude-plugin/plugin.json, hooks/, tsconfig.json, a *.test.ts and, for a mod that keeps state, types/index.d.ts. See CLAUDE.md for the conventions and the release steps.

claude plugin validate plugins/<name>
claude plugin test plugins/<name>

The engine writes .claude-plugin/types/ inside a mod when it loads it; the folder is git-ignored.

Source 3 files
hooks/register.tsx 240 lines
1import { atom, read, update } from 'claude-code'
2import type { AgentSpawnInput, AgentSpawnResult, EngineInterface as Engine, Register } from 'claude-code'
3
4import type { WatchAgent } from '../types'
5import { CHIP_WIDTH, SKILL_COLOR, bandSegs, cut, markOf, modelColor, modelTag } from './layout'
6
7const KEY = 'agent-watch'
8const PANE = 'agent-watch'
9const HELP = 'Usage: /agent-watch (opens the Agents pane) | on | off | clear | help'
10
11const isOnA = atom({ plugin: 'agent-watch', key: 'isOn' } as const, true)
12const agentsA = atom({ plugin: 'agent-watch', key: 'agents' } as const, [])
13const skillsA = atom({ plugin: 'agent-watch', key: 'skills' } as const, [])
14const modelsA = atom({ plugin: 'agent-watch', key: 'models' } as const, {})
15const timelineA = atom({ plugin: 'agent-watch', key: 'timeline' } as const, [])
16const turnStartA = atom({ plugin: 'agent-watch', key: 'turnStart' } as const, null)
17const activeA = atom({ plugin: 'agent-watch', key: 'active' } as const, 'main')
18const fmtTokens = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : `${n}`)
19
20async function logEvent($: Engine, text: string) {
21  const at = await $.clock.now()
22  await update($, timelineA, t => [...t, { at, text }].slice(-60))
23}
24
25async function patch($: Engine, id: string, fn: (a: WatchAgent) => WatchAgent) {
26  await update($, agentsA, rows => rows.map(r => (r.id === id ? fn(r) : r)))
27}
28
29async function loadOn($: Engine) {
30  const raw = (await $.store.get(KEY)) as { isOn?: boolean } | null
31  await update($, isOnA, () => raw?.isOn ?? true)
32}
33
34async function setOn($: Engine, isOn: boolean) {
35  await $.store.set(KEY, { isOn })
36  await update($, isOnA, () => isOn)
37}
38
39async function clearAll($: Engine) {
40  await update($, agentsA, () => [])
41  await update($, skillsA, () => [])
42  await update($, modelsA, () => ({}))
43  await update($, timelineA, () => [])
44  await update($, turnStartA, () => null)
45}
46
47async function startTurn($: Engine, text: string) {
48  const now = await $.clock.now()
49  const main: WatchAgent = {
50    id: 'main', parent: null, type: 'main', task: text.split('\n')[0] ?? '',
51    model: null, status: 'running', steps: 0, calls: 0, tokens: 0, skills: [],
52  }
53  await clearAll($)
54  await update($, agentsA, () => [main])
55  await update($, turnStartA, () => now)
56  await update($, activeA, () => 'main')
57  await logEvent($, 'turn started')
58}
59
60async function recordSpawn($: Engine, e: AgentSpawnInput, r: AgentSpawnResult) {
61  const parent = e.parentAgentId ?? 'main'
62  const id = r.agentId ?? e.tool_use_id
63  const task = e.description || e.prompt.split('\n')[0] || ''
64  const row: WatchAgent = {
65    id, parent, type: e.subagentType, task, model: r.model ?? null,
66    status: r.deny === undefined ? 'running' : 'failed', steps: 0, calls: 0, tokens: 0, skills: [],
67  }
68  await update($, agentsA, rows => [...rows.filter(x => x.id !== id), row])
69  await logEvent($, `${e.subagentType} ${r.deny === undefined ? 'started' : 'refused'}`)
70}
71
72async function recordStep($: Engine, agentId: string | undefined, model: string, tokens: number) {
73  const id = agentId ?? 'main'
74  await patch($, id, a => ({ ...a, model, steps: a.steps + 1, tokens: a.tokens + tokens }))
75  await update($, modelsA, m => {
76    const prev = m[model] ?? { steps: 0, tokens: 0 }
77    return { ...m, [model]: { steps: prev.steps + 1, tokens: prev.tokens + tokens } }
78  })
79}
80
81async function recordComplete($: Engine, agentId: string | undefined, isFailed: boolean) {
82  const id = agentId ?? 'main'
83  const status = isFailed ? 'failed' : 'done'
84  const row = (await read($, agentsA)).find(a => a.id === id)
85  await patch($, id, a => ({ ...a, status }))
86  if (row) await logEvent($, `${row.type} ${status}`)
87}
88
89// A skill counts once per turn, under the agent that was running when it loaded.
90async function recordSkill($: Engine, skill: string) {
91  if ((await read($, skillsA)).includes(skill)) return
92  const id = await read($, activeA)
93  await update($, skillsA, s => [...s, skill])
94  await patch($, id, a => ({ ...a, skills: [...a.skills, skill] }))
95  await logEvent($, `skill ${skill}`)
96}
97
98export const register: Register = on => {
99  on('session.start', async ($, e, next) => {
100    await $.command.register({
101      name: 'agent-watch',
102      description: 'Show which agents, models and skills are working this turn.',
103      argumentHint: 'on | off | clear | help',
104    })
105    await loadOn($)
106
107    return next(e)
108  })
109
110  on('turn.start', async ($, e, next) => {
111    if (e.agentId === undefined) await startTurn($, e.text)
112
113    return next(e)
114  })
115
116  on('turn.step', async function* ($, e, next) {
117    await update($, activeA, () => e.agentId ?? 'main')
118    const result = yield* next(e)
119    const u = result.usage
120    await recordStep($, e.agentId, u?.model ?? e.model, u ? u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens : 0)
121
122    return result
123  })
124
125  on('tool.call', async ($, e, next) => {
126    await patch($, e.agentId ?? 'main', a => ({ ...a, calls: a.calls + 1 }))
127
128    return next(e)
129  }).catch(($, e, next) => next(e))
130
131  on('agent.spawn', async ($, e, next) => {
132    const result = await next(e)
133    await recordSpawn($, e, result)
134
135    return result
136  }).catch(($, e, next) => next(e))
137
138  on('skill.prompt', async ($, e, next) => {
139    await recordSkill($, e.skill)
140
141    return next(e)
142  })
143
144  on('turn.complete', async ($, e, next) => {
145    await recordComplete($, e.agentId, e.isAborted || 'refusal' in e)
146
147    return next(e)
148  })
149
150  on('command.run', { command: 'agent-watch' }, async ($, e) => {
151    const arg = e.args.trim()
152    if (arg === '') {
153      await $.ui.open({ id: PANE, title: 'Agents' })
154      return { text: 'Agents pane opened.' }
155    }
156    if (arg === 'on' || arg === 'off') {
157      await setOn($, arg === 'on')
158      return { text: `Agent watch ${arg}.` }
159    }
160    if (arg === 'clear') {
161      await clearAll($)
162      return { text: 'Agent watch cleared.' }
163    }
164
165    return { text: HELP }
166  })
167
168  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
169    const below = await next(e)
170    const agents = await read($, agentsA)
171    if (!(await read($, isOnA))) return below
172
173    const { Box, Text } = $.ui.resolve(e)
174    const segs =
175      agents.length === 0
176        ? [{ text: ' idle', dim: true }]
177        : bandSegs(agents, await read($, skillsA), e.props.bodyColumns - CHIP_WIDTH)
178
179    return (
180      <Box flexDirection="column">
181        <Box>
182          <Box flexShrink={0}><Text bold color="#0b0b0f" backgroundColor="#bb9af7"> ◎ AGENTS  </Text></Box>
183          <Text wrap="truncate-end">
184            {segs.map(s => (
185              <Text color={s.color} dimColor={s.dim}>{s.text}</Text>
186            ))}
187          </Text>
188        </Box>
189        {below}
190      </Box>
191    )
192  })
193
194  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
195    const { Box, Text } = $.ui.resolve(e)
196    const agents = await read($, agentsA)
197    const models = await read($, modelsA)
198    const timeline = await read($, timelineA)
199    const turnStart = await read($, turnStartA)
200
201    const rows: { a: WatchAgent; depth: number }[] = []
202    const walk = (parent: string | null, depth: number) => {
203      for (const a of agents.filter(x => x.parent === parent)) {
204        rows.push({ a, depth })
205        walk(a.id, depth + 1)
206      }
207    }
208    walk(null, 0)
209
210    return (
211      <Box flexDirection="column">
212        {agents.length === 0 && <Text dimColor>No agents yet this turn.</Text>}
213        {rows.map(({ a, depth }) => {
214          const pad = '  '.repeat(depth)
215          const mark = markOf(a.status)
216          return (
217            <Box flexDirection="column">
218              <Text bold>{pad}{a.type} — {a.task || 'no task'} <Text color={mark.color}>{mark.text}</Text></Text>
219              <Text dimColor>
220                {pad}{'  '}{modelTag(a.model).label} · {a.steps} steps · {a.calls} calls · {fmtTokens(a.tokens)} tokens
221                {a.skills.length ? ` · skills: ${a.skills.join(', ')}` : ''}
222              </Text>
223            </Box>
224          )
225        })}
226        {Object.keys(models).length > 0 && <Text bold>Models</Text>}
227        {Object.entries(models).map(([id, m]) => (
228          <Text color={modelColor(id)}>{'  '}{modelTag(id).label}{'  '}{m.steps} steps · {fmtTokens(m.tokens)} tokens</Text>
229        ))}
230        {e.surface === 'terminal' && timeline.length > 0 && <Text bold>Timeline</Text>}
231        {e.surface === 'terminal' &&
232          timeline.slice(-12).map(t => {
233            const s = Math.max(0, Math.round((t.at - (turnStart ?? t.at)) / 1000))
234            return <Text dimColor>{'  '}+{Math.floor(s / 60)}:{String(s % 60).padStart(2, '0')} {cut(t.text, 60)}</Text>
235          })}
236      </Box>
237    )
238  })
239}
240
hooks/layout.ts 76 lines
1import type { WatchAgent } from '../types'
2
3export type Seg = { text: string; color?: string; dim?: boolean }
4
5const MODEL_COLOR: Record<string, string> = {
6  fable: '#d16bff',
7  opus: '#ff8c1a',
8  sonnet: '#3d9bff',
9  haiku: '#19c3a6',
10}
11const MARK = { running: '⟳', done: '✓', failed: '✗' } as const
12const MARK_COLOR = { running: '#f59e0b', done: '#22c55e', failed: '#ef4444' } as const
13export const SKILL_COLOR = '#a3e635'
14export const CHIP_WIDTH = 11 // " ◎ AGENTS  ", every band chip is 11 cells
15
16// claude-opus-5-5 -> opus·5.5; a dated id keeps only the version.
17export function modelTag(id: string | null) {
18  if (!id) return { family: '?', label: '?' }
19  const m = /(fable|opus|sonnet|haiku)(?:-(\d+)(?:-(\d{1,2}))?)?/.exec(id)
20  if (!m) return { family: id, label: id }
21  const ver = m[2] ? (m[3] ? `${m[2]}.${m[3]}` : m[2]) : ''
22  return { family: m[1] ?? id, label: ver ? `${m[1]}·${ver}` : (m[1] ?? id) }
23}
24
25export const modelColor = (id: string | null) => MODEL_COLOR[modelTag(id).family]
26export const markOf = (s: WatchAgent['status']) => ({ text: MARK[s], color: MARK_COLOR[s] })
27
28export const cut = (task: string, max = 22) => (task.length > max ? `${task.slice(0, max - 1)}…` : task)
29
30function agentSegs(a: WatchAgent, showTask: boolean): Seg[] {
31  const tag = modelTag(a.model)
32  const mark = markOf(a.status)
33  const segs: Seg[] = [{ text: a.type }, { text: ` ${tag.label}`, color: MODEL_COLOR[tag.family] }]
34  if (showTask && a.task && a.id !== 'main') segs.push({ text: ` "${cut(a.task)}"`, dim: true })
35  segs.push({ text: ` ${mark.text}`, color: mark.color })
36  return segs
37}
38
39const width = (segs: Seg[]) => segs.reduce((n, s) => n + s.text.length, 0)
40
41// Too wide: drop task text from the oldest helpers, then fold the newest into "+N more",
42// then trim skills. The first helper's task and one skill always stay.
43export function bandSegs(agents: WatchAgent[], skills: string[], avail: number): Seg[] {
44  const [main, ...helpers] = agents
45  if (!main) return []
46  const keep = Math.max(0, helpers.findIndex(h => h.task))
47  const showTask = helpers.map(() => true)
48  let hidden = 0
49  let nSkills = skills.length
50
51  const build = () => {
52    const segs = agentSegs(main, false).map((s, i) => (i === 0 ? { ...s, text: ` ${s.text}` } : s))
53    helpers.slice(0, helpers.length - hidden).forEach((h, i) => {
54      segs.push({ text: i === 0 ? ' │ ' : ' · ', dim: true }, ...agentSegs(h, showTask[i] ?? false))
55    })
56    if (hidden) segs.push({ text: ` +${hidden} more`, dim: true })
57    if (skills.length) {
58      segs.push({ text: ' │ skills:', dim: true })
59      skills.slice(0, nSkills).forEach(s => segs.push({ text: ` ${s}`, color: SKILL_COLOR }))
60      if (nSkills < skills.length) segs.push({ text: ` +${skills.length - nSkills}`, dim: true })
61    }
62    return segs
63  }
64
65  let segs = build()
66  while (width(segs) > avail) {
67    const i = showTask.findIndex((s, j) => s && j !== keep)
68    if (i >= 0) showTask[i] = false
69    else if (helpers.length - hidden - 1 > keep) hidden += 1
70    else if (nSkills > 1) nSkills -= 1
71    else break
72    segs = build()
73  }
74  return segs
75}
76
types/index.d.ts 30 lines
1export type WatchStatus = 'running' | 'done' | 'failed'
2export type WatchAgent = {
3  id: string
4  parent: string | null
5  type: string
6  task: string
7  model: string | null
8  status: WatchStatus
9  steps: number
10  calls: number
11  tokens: number
12  skills: string[]
13}
14export type WatchModel = { steps: number; tokens: number }
15export type WatchEvent = { at: number; text: string }
16
17declare module 'claude-code' {
18  interface PluginState {
19    'agent-watch': {
20      isOn: boolean
21      agents: WatchAgent[]
22      skills: string[]
23      models: Record<string, WatchModel>
24      timeline: WatchEvent[]
25      turnStart: number | null
26      active: string
27    }
28  }
29}
30