SLOPSHOPPER

agent-watcher

Band above the prompt showing running subagents, progress and tokens; hides itself 10 min after the last one finishes.

newpanebandguardprompttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-watcher
│ ┃ agent-watcher-list ✕ › fix the failing auth test and add an audit log call │ ┃ No agents right now. │ ⏺ 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 │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · agent-watcher-list
No agents right now.
README

agent-watcher

Part of Watcher. A band above the prompt shows each running subagent with a pixel crab, its model and tokens. Press a for the panel. The band hides 10 minutes after the last agent finishes.

What it touches

Watcher draws on screen only. It sends nothing anywhere, starts no programs, reads no files, and adds nothing to the model's context.

HookWhat it does
agent.spawnNotes the new subagent's name, type and model for the band. The call passes through unchanged; its prompt, settings and tools are never edited.
tool.call, turn.completeRead progress and token counts from subagent turns. Nothing is changed.
prompt.submitA new prompt lifts the pause key (h). The prompt passes through unchanged.
session.start, ui.*Restore the band's own state and draw the band and panel.

Settings: lingerMinutes (10) and bandRows (2).

Source 2 files
hooks/register.tsx 524 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { AgentRow } from '../types'
5
6// How long the band and panel linger after the last agent finishes.
7// Set from the plugin's settings when it loads (see plugin.json userConfig).
8let LINGER_MS = 10 * 60 * 1000
9let BAND_ROWS = 2
10const PANE = 'agent-watcher-list'
11// The ⤢ chip's column beside the rows.
12const CHIP_COLS = 8
13const CHIP_PX = CHIP_COLS * 8 + 16
14
15const rows = atom({ plugin: 'agent-watcher', key: 'rows' } as const, [] as AgentRow[])
16const tick = atom({ plugin: 'agent-watcher', key: 'tick' } as const, 0)
17const isPanelOpen = atom({ plugin: 'agent-watcher', key: 'isPanelOpen' } as const, false)
18const isPaused = atom({ plugin: 'agent-watcher', key: 'isPaused' } as const, false)
19
20// Each agent wears its own color (a bandana on the crab, its name, its bar).
21// Each agent type gets a stable accent from this palette, picked by its name.
22const ACCENTS = ['#3FA7D6', '#E0A526', '#D65DB1', '#8F8CF4', '#3B9C5F', '#E0703A', '#5B8DEF']
23const accentOf = (type: string) => {
24  let hash = 0
25  for (const c of type) hash = (hash * 31 + c.charCodeAt(0)) >>> 0
26  return ACCENTS[hash % ACCENTS.length]
27}
28const DONE = '#3B9C5F'
29const FAILED = '#D0453F'
30const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
31
32const shortModel = (model: string) =>
33  /opus/i.test(model) ? 'opus' : /sonnet/i.test(model) ? 'sonnet' : /haiku/i.test(model) ? 'haiku' : model
34
35const formatTokens = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`)
36
37const formatElapsed = (ms: number) => {
38  const s = Math.max(0, Math.round(ms / 1000))
39  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
40}
41
42// Stats read model · time · tools · tokens; a narrow band keeps only the most useful few,
43// dropping the model first, then tokens, then tools (elapsed time always stays).
44const stats = (row: AgentRow, now: number, keep = 4) => {
45  const parts = [
46    { rank: 3, text: row.model },
47    { rank: 0, text: formatElapsed((row.endedAt ?? now) - row.startedAt) },
48    { rank: 1, text: `${row.tools} tools` },
49    { rank: 2, text: row.tokens > 0 ? formatTokens(row.tokens) : '' },
50  ]
51  return parts.filter(p => p.text && p.rank < keep).map(p => p.text).join(' · ')
52}
53
54// Running agents first, newest first within each group.
55const ordered = (all: AgentRow[]) =>
56  [...all].sort((a, b) => {
57    const aRunning = a.status === 'running'
58    const bRunning = b.status === 'running'
59    if (aRunning !== bRunning) {
60      return aRunning ? -1 : 1
61    }
62    return b.startedAt - a.startedAt
63  })
64
65const xml = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
66
67// Rough advance of system UI text, in em; enough to keep text out of the stats.
68const textWidth = (s: string, size: number) =>
69  [...s].reduce((w, ch) => w + (/[\s.,:;'|!il1()]/.test(ch) ? 0.3 : /[A-Zmw@%]/.test(ch) ? 0.72 : 0.56) * size, 0)
70
71const fitText = (s: string, size: number, maxW: number) => {
72  if (textWidth(s, size) <= maxW) {
73    return s
74  }
75  let out = ''
76  for (const ch of s) {
77    if (textWidth(out + ch + '…', size) > maxW) {
78      break
79    }
80    out += ch
81  }
82  return out + '…'
83}
84
85// ---------------------------------------------------------------------------
86// Pixel Clawd, adapted from savvy-progress by JohnnyVizz (MIT,
87// github.com/JohnnyVizz/claude-kit): a clay crab on a 30×28 grid whose legs step
88// while it works. Here every crab wears a bandana in its agent's color.
89
90const CLAY = '#D97757'
91const INK = '#1F1E1D'
92const CRAB_BOX = 30
93
94const CRAB_CSS = `<style>
95.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
96.run .bd{animation:bob .5s steps(1) infinite -.125s}
97.run g{transform-box:fill-box}
98@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
99@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}
100</style>`
101
102const crab = (x: number, y: number, accent: string, isWalking: boolean, scale: number) => {
103  const px = (cx: number, cy: number, w: number, ht: number, c: string) =>
104    `<rect x="${cx}" y="${cy}" width="${w}" height="${ht}" fill="${c}"/>`
105  const body = [
106    px(7, 10, 16, 12, CLAY),
107    px(3, 14, 4, 4, CLAY),
108    px(23, 14, 4, 4, CLAY),
109    px(9, 13, 2, 2, INK),
110    px(19, 13, 2, 2, INK),
111    px(6, 9, 18, 3, accent),
112    px(3, 10, 3, 2, accent),
113  ].join('')
114  const legsA = px(7, 22, 2, 4, CLAY) + px(17, 22, 2, 4, CLAY)
115  const legsB = px(11, 22, 2, 4, CLAY) + px(21, 22, 2, 4, CLAY)
116  return `<g transform="translate(${x},${y}) scale(${scale})" shape-rendering="crispEdges"><g class="${isWalking ? 'run' : ''}"><g class="bd">${body}</g><g class="la">${legsA}</g><g class="lb">${legsB}</g></g></g>`
117}
118
119// The Watcher track: a thin pixel bar adapted from savvy-progress by JohnnyVizz (MIT,
120// github.com/JohnnyVizz/claude-kit). Cells sit on a fixed noise pattern and only twinkle in
121// opacity, in four out-of-phase groups: nothing moves, nothing is blurred, so it never jitters,
122// and its source changes only with the status. The band never shows progress: running twinkles
123// across the whole width, done settles to a slow glow, failed is a still red bar. Only an expanded
124// panel passes a real percentage (2% or more), and the pixels then fill up to it.
125const noise = (x: number, y: number) => {
126  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
127  return s - Math.floor(s)
128}
129const CELL = 3
130
131const glowTrack = (id: string, x: number, y: number, w: number, color: string, status: 'running' | 'done' | 'failed', percent: number | null = null) => {
132  const tint = status === 'failed' ? FAILED : color
133  const cols = Math.floor(w / CELL)
134  const isProgress = percent !== null && percent >= 2 && status === 'running'
135  const shown = isProgress ? Math.round((cols * Math.min(100, percent as number)) / 100) : cols
136  // Running: dots on a fixed noise pattern; finished, failed or real progress: every dot lit.
137  const density = status === 'running' && !isProgress ? 0.62 : 1
138  const cells: string[] = []
139  for (let c = 0; c < shown; c++) {
140    for (let r = 0; r < 2; r++) {
141      if (noise(c, r) < density) {
142        cells.push(`<rect class="t${Math.floor(noise(r, c) * 4)}" x="${x + c * CELL}" y="${y - 1 + r * CELL}" width="2" height="2"/>`)
143      }
144    }
145  }
146  const rest = shown < cols ? `<rect class="k" x="${x + shown * CELL}" y="${y + 0.5}" width="${w - shown * CELL}" height="2" rx="1"/>` : ''
147  const mode = status === 'running' ? (isProgress ? 'pg' : 'tw') : status === 'done' ? 'tw-done' : 'still'
148  return `${rest}<g class="${mode}" fill="${tint}" data-id="${id}">${cells.join('')}</g>`
149}
150
151// Opacity only, on one image whose source never changes while it runs, so nothing jitters. The
152// dots breathe slowly in four out-of-phase groups (3.6–5.2 s, never below 45%), calm rather than
153// flickery. Done: every dot lit with a slow glow. Failed: every dot lit red, still.
154// Panel bars: the same dots over a soft, still glow in the bar's color. The glow covers the lit part
155// (all of it, or up to the real percentage), never moves, so it reads as light, not flicker.
156const halo = (id: string, x: number, y: number, w: number, color: string, status: 'running' | 'done' | 'failed', percent: number | null) => {
157  const tint = status === 'failed' ? FAILED : color
158  const lit = percent !== null && percent >= 2 && status === 'running' ? (w * Math.min(100, percent)) / 100 : w
159  const strength = status === 'running' ? 0.45 : status === 'done' ? 0.55 : 0.35
160  return `<defs><filter id="h${id}" x="-5%" y="-150%" width="110%" height="400%"><feGaussianBlur stdDeviation="2.2"/></filter></defs>
161<rect x="${x}" y="${y - 1}" width="${lit}" height="5" rx="2.5" fill="${tint}" opacity="${strength}" filter="url(#h${id})"/>`
162}
163
164const TWINKLE_CSS = `.tw .t0,.tw .t1,.tw .t2,.tw .t3{animation:tw 4.2s ease-in-out infinite}
165.tw .t1{animation-duration:5.2s;animation-delay:-1.3s}.tw .t2{animation-duration:3.6s;animation-delay:-2.4s}.tw .t3{animation-duration:4.8s;animation-delay:-.8s}
166.tw-done{animation:twd 4.8s ease-in-out infinite}
167@keyframes tw{0%,100%{opacity:1}50%{opacity:.45}}@keyframes twd{0%,100%{opacity:1}50%{opacity:.78}}
168@media (prefers-reduced-motion: reduce){.tw rect,.tw-done{animation:none}}`
169
170const BASE_CSS = `<style>
171.t{fill:#1f1f1f}.s{fill:#6b6b68}.k{fill:#ecebe8}.ln{stroke:#e4e4e1}.tile{fill:#f4f3f0}
172@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.k{fill:#2c2c2b}.ln{stroke:#333331}.tile{fill:#262625}}
173.sweep{animation:sw 2s ease-in-out infinite;animation-delay:var(--sweep-delay,0s)}@keyframes sw{0%{transform:translateX(-30%)}100%{transform:translateX(130%)}}
174.live{animation:p 1s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
175.fill{transform-box:fill-box;transform-origin:left;animation:gr .6s ease-out 1}@keyframes gr{from{transform:scaleX(0)}}
176.glow{animation:gw 2.4s ease-in-out infinite}@keyframes gw{50%{opacity:.25}}
177.fail{animation:fl .45s ease-in-out 3}@keyframes fl{50%{opacity:.25}}
178@media (prefers-reduced-motion: reduce){.sweep,.live,.fill,.fail,.glow{animation:none}}
179${TWINKLE_CSS}
180</style>`
181
182// A band row is three images: the crab, the text line and the track. Only the text changes as the
183// clock ticks; the crab and the track keep the same source while an agent runs, so their animations
184// (walking legs, the sweep) are never restarted by redraws, ours or other mods'.
185const svg = (W: number, H: number, body: string) =>
186  `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${BASE_CSS}${CRAB_CSS}${body}</svg>`
187
188
189const track = (id: string, x: number, y: number, w: number, row: AgentRow) => glowTrack(id, x, y, w, accentOf(row.type), row.status)
190
191const mark = (x: number, y: number, row: AgentRow) => {
192  if (row.status === 'running') {
193    return `<circle class="live" cx="${x}" cy="${y}" r="3" fill="${accentOf(row.type)}"/>`
194  }
195  if (row.status === 'failed') {
196    return `<path d="M${x - 3.5} ${y - 3.5}l7 7M${x + 3.5} ${y - 3.5}l-7 7" stroke="${FAILED}" stroke-width="1.6" stroke-linecap="round"/>`
197  }
198  return `<path d="M${x - 4.5} ${y}l3 3 6-6.5" fill="none" stroke="${DONE}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>`
199}
200
201// One band row: crab, name, task, stats on the right, a thin track beneath. The row respaces to
202// its width: stats drop their least useful parts first, then the task, then the name truncates.
203const bandRowSvg = (W: number, row: AgentRow, now: number, more: number) => {
204  const extra = more > 0 ? `   +${more}` : ''
205  const nameFull = textWidth(row.type, 12.5)
206  let keep = 4
207  let right = stats(row, now, keep) + extra
208  // Keep the name and at least ~90px of task whenever there is room for them.
209  while (keep > 1 && W - 34 - nameFull - 8 - textWidth(right, 11.5) - 28 < 90) {
210    keep--
211    right = stats(row, now, keep) + extra
212  }
213  const rightW = textWidth(right, 11.5)
214  const name = fitText(row.type, 12.5, Math.max(40, W - 34 - rightW - 28))
215  const nameW = textWidth(name, 12.5) + 8
216  const descW = W - 34 - nameW - rightW - 28
217  const desc = row.description && descW >= 48 ? fitText(row.description, 12, descW) : ''
218  return svg(
219    W - 32,
220    18,
221    `<text x="0" y="13" font-family="${FONT}" font-size="12.5" font-weight="600" fill="${accentOf(row.type)}">${xml(name)}</text>
222<text class="s" x="${nameW}" y="13" font-family="${FONT}" font-size="12">${xml(desc)}</text>
223<text class="s" x="${W - 46}" y="13" text-anchor="end" font-family="${FONT}" font-size="11.5" font-variant-numeric="tabular-nums">${xml(right)}</text>
224${mark(W - 37, 9, row)}`,
225  )
226}
227
228const bandTrackSvg = (W: number, row: AgentRow) => svg(W - 32, 8, track(row.id, 0, 2, W - 32, row))
229const bandCrabSvg = (row: AgentRow) => svg(28, 28, crab(0, 2, accentOf(row.type), row.status === 'running', 0.8))
230
231const headerSvg = (W: number, all: AgentRow[]) => {
232  const running = all.filter(row => row.status === 'running').length
233  const tokens = all.reduce((sum, row) => sum + row.tokens, 0)
234  const gap = 6
235  const tw = (W - gap * 2) / 3
236  const tile = (i: number, k: string, v: string) =>
237    `<rect class="tile" x="${i * (tw + gap)}" y="0" width="${tw}" height="40" rx="8"/>
238<text class="s" x="${i * (tw + gap) + 9}" y="16" font-family="${FONT}" font-size="11">${k}</text>
239<text class="t" x="${i * (tw + gap) + 9}" y="33" font-family="${FONT}" font-size="15" font-weight="600" font-variant-numeric="tabular-nums">${v}</text>`
240  return svg(W, 44, tile(0, 'Running', `${running}`) + tile(1, 'Finished', `${all.length - running}`) + tile(2, 'Tokens', formatTokens(tokens)))
241}
242
243// A panel card is three images like a band row: crab and track keep their source while running,
244// only the text line ticks, so the panel's animations never restart either.
245const paneTextSvg = (W: number, row: AgentRow, now: number) => {
246  const textW = W - 44 - 18
247  return svg(
248    W - 44,
249    40,
250    `<text class="t" x="0" y="18" font-family="${FONT}" font-size="13" font-weight="600">${xml(fitText(row.description || row.type, 13, textW))}</text>
251<text x="0" y="34" font-family="${FONT}" font-size="11"><tspan fill="${accentOf(row.type)}">${xml(row.type)}</tspan><tspan class="s">  ${xml(stats(row, now))}</tspan></text>
252${mark(W - 52, 14, row)}`,
253  )
254}
255const paneTrackSvg = (W: number, row: AgentRow) =>
256  svg(W - 44, 22, `${halo(`p${row.id}`, 0, 5, W - 44 - 8, accentOf(row.type), row.status, null)}${track(`p${row.id}`, 0, 5, W - 44 - 8, row)}<line class="ln" x1="0" y1="21.5" x2="${W - 44}" y2="21.5"/>`)
257const paneCrabSvg = (row: AgentRow) => svg(44, 62, crab(0, 12, accentOf(row.type), row.status === 'running', 1.1))
258
259// Terminal fallback: a colored dot, a block bar and plain text.
260const barText = (row: AgentRow, t: number, width: number) => {
261  if (row.status !== 'running') {
262    return '█'.repeat(width)
263  }
264  const span = width * 2 - 2
265  const pos = t % span
266  const head = pos < width ? pos : span - pos
267  return Array.from({ length: width }, (_, i) => {
268    const d = Math.abs(i - head)
269    return d === 0 ? '█' : d === 1 ? '▓' : d === 2 ? '▒' : '░'
270  }).join('')
271}
272
273export const register: Register = (on, options) => {
274  LINGER_MS = Math.max(1, Number(options.lingerMinutes ?? 10)) * 60 * 1000
275  BAND_ROWS = Math.max(1, Math.min(4, Number(options.bandRows ?? 2)))
276  let pulse: { cancel: () => void } | null = null
277  let clear: { cancel: () => void } | null = null
278
279  // A reload keeps the rows but drops the timers: restart the pulse or the hide timer.
280  on('session.start', async ($, e, next) => {
281    const result = await next(e)
282    try {
283      const all = await read($, rows)
284      if (all.some(row => row.status === 'running')) {
285        pulse = $.clock.every(1000, () => {
286          void update($, tick, t => t + 1).catch(() => {})
287        })
288      } else if (all.length > 0) {
289        clear = $.clock.after(LINGER_MS, () => {
290          clear = null
291          void update($, rows, list => list.filter(row => row.status === 'running')).catch(() => {})
292          void update($, isPanelOpen, () => false).catch(() => {})
293          void $.ui.close({ id: PANE }).catch(() => {})
294        })
295      }
296    } catch {}
297    return result
298  })
299
300  on('agent.spawn', async ($, e, next) => {
301    const result = await next(e)
302    if (!result.agentId) {
303      return result
304    }
305
306    // The band is cosmetic: a failure here must never affect the spawn.
307    try {
308      clear?.cancel()
309      clear = null
310      const row: AgentRow = {
311        id: result.agentId,
312        type: e.subagentType || 'general-purpose',
313        description: e.description ?? '',
314        model: shortModel(result.model ?? ''),
315        startedAt: await $.clock.now(),
316        endedAt: null,
317        tools: 0,
318        tokens: 0,
319        status: 'running',
320      }
321      await update($, rows, all => [...all, row])
322
323      // The crabs and sweeps animate in CSS; this tick only refreshes the elapsed time.
324      if (!pulse) {
325        pulse = $.clock.every(1000, () => {
326          void update($, tick, t => t + 1).catch(() => {})
327        })
328      }
329    } catch {}
330    return result
331  }).catch(($, e, next) => next(e)) // display only: a failure never blocks Claude Code
332
333  on('tool.call', async ($, e, next) => {
334    if (e.agentId) {
335      const id = e.agentId
336      // Display bookkeeping must never hold up or fail the tool call.
337      void update($, rows, all => all.map(row => (row.id === id ? { ...row, tools: row.tools + 1 } : row))).catch(() => {})
338    }
339    return next(e)
340  }).catch(($, e, next) => next(e)) // display only: a failure never blocks Claude Code
341
342  on('turn.complete', async ($, e, next) => {
343    const result = await next(e)
344    if (!e.agentId) {
345      return result
346    }
347
348    try {
349      const usage = result.usage ?? e.usage
350      const spent = usage
351        ? usage.input_tokens + usage.output_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
352        : 0
353      const now = await $.clock.now()
354      const id = e.agentId
355      // Anything but a normal answer (an error, an abort, a refusal) ends the sweep as a failure.
356      const status: AgentRow['status'] = e.reason !== 'answer' || e.isAborted ? 'failed' : 'done'
357      await update($, rows, all =>
358        all.map(row => (row.id === id ? { ...row, tokens: row.tokens + spent, status, endedAt: now } : row)),
359      )
360
361      const isAnyRunning = (await read($, rows)).some(row => row.status === 'running')
362      if (!isAnyRunning) {
363        pulse?.cancel()
364        pulse = null
365        clear?.cancel()
366        clear = $.clock.after(LINGER_MS, () => {
367          clear = null
368          void update($, rows, all => all.filter(row => row.status === 'running')).catch(() => {})
369          void update($, isPanelOpen, () => false).catch(() => {})
370          void $.ui.close({ id: PANE }).catch(() => {})
371        })
372      }
373    } catch {}
374    return result
375  })
376
377  // The pause key (h, in any Watcher band) hides every band until the person's next prompt.
378  on('ui.press', { element: 'watcher-pause' }, async ($, e, next) => {
379    await update($, isPaused, () => true).catch(() => {})
380    return next(e)
381  }).catch(($, e, next) => next(e))
382
383  on('prompt.submit', async ($, e, next) => {
384    if (e.origin?.kind !== 'task-notification') {
385      await update($, isPaused, () => false).catch(() => {})
386    }
387    return next(e)
388  }).catch(($, e, next) => next(e))
389
390  // The band steps aside while the panel is open and comes back when it closes.
391  on('ui.close', async ($, e, next) => {
392    if (e.id === PANE) {
393      await update($, isPanelOpen, () => false).catch(() => {})
394    }
395    return next(e)
396  }).catch(($, e, next) => next(e)) // display only: a failure never blocks Claude Code
397
398  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
399    const all = await read($, rows)
400    if (e.props.hasSurvey || all.length === 0 || (await read($, isPanelOpen)) || (await read($, isPaused))) {
401      return next(e)
402    }
403    // Other bands (context-watcher) draw beneath these rows; the engine's own placeholder is never nested.
404    const drawn = await Promise.resolve(next(e)).catch(() => null)
405    const below = drawn && typeof drawn === 'object' && (drawn as { type?: string }).type !== 'engine' ? drawn : null
406
407    const t = await read($, tick)
408    // Whole seconds: within a second the images come out identical, so other mods' redraws don't rebuild them.
409    const now = Math.floor((await $.clock.now()) / 1000) * 1000
410    const ui = $.ui.resolve(e)
411    const { Box, Button, Text } = ui
412    const shown = ordered(all).slice(0, BAND_ROWS)
413    const more = all.length - shown.length
414    const openPanel = async () => {
415      await update($, isPanelOpen, () => true)
416      await $.ui.open({ id: PANE, title: 'Agents' })
417    }
418
419    // The terminal's element table carries an Svg that draws nothing: test the surface too.
420    // The pause key shows only when another Watcher band is up too, once, in this band's second
421    // row's free chip slot, so it never crowds the open button.
422    const showPause = below !== null && !hasKey(below, 'watcher-pause')
423
424    if (e.surface !== 'terminal' && 'Svg' in ui) {
425      const { Svg } = ui
426      // Desktop cells are ~8px. Every row reserves the chip's column, so the rows' right edges line up.
427      const W = Math.max(200, Math.min(1400, (e.props.bodyColumns || 100) * 8 - CHIP_PX))
428      return (
429        <Box flexDirection="column" paddingTop={1}>
430          {shown.map((row, i) => (
431            <Box key={`band-${row.id}`} flexDirection="row" alignItems="center" gap={1}>
432              <Svg key={`crab-${row.id}`} source={bandCrabSvg(row)} alt={row.type} width={28} height={28} />
433              <Box key={`body-${row.id}`} flexDirection="column" flexShrink={0}>
434                <Svg
435                  key={`text-${row.id}`}
436                  source={bandRowSvg(W, row, now, i === shown.length - 1 ? more : 0)}
437                  alt={`${row.type}: ${row.status}, ${stats(row, now)}`}
438                  width={W - 32}
439                  height={18}
440                />
441                <Svg key={`track-${row.id}`} source={bandTrackSvg(W, row)} alt={`${row.type} progress`} width={W - 32} height={8} />
442              </Box>
443              <Box key={`chip-${row.id}`} width={CHIP_COLS} flexShrink={0}>
444                {i === 0 && <Button key="agent-watcher" label={`⤢ ×${all.length}`} hotkey="a" plain onPress={openPanel} />}
445                {i === 1 && showPause ? <Button key="watcher-pause" label="–" hotkey="h" plain onPress={() => {}} /> : null}
446              </Box>
447            </Box>
448          ))}
449          {below && <Box key="agent-watcher-gap" height={1} />}
450          {below}
451        </Box>
452      )
453    }
454
455    return (
456      <Box flexDirection="column">
457        {shown.map((row, i) => (
458          <Button key={i === 0 ? 'agent-watcher' : `band-${row.id}`} plain onPress={openPanel}>
459            <Text color={accentOf(row.type)}>{`${row.status === 'failed' ? '✗' : '●'} ${barText(row, t, 12)} `}</Text>
460            <Text bold color={accentOf(row.type)}>
461              {row.type}
462            </Text>
463            <Text dimColor wrap="truncate-end">{` ${stats(row, now, (e.props.bodyColumns || 100) < 70 ? 1 : (e.props.bodyColumns || 100) < 90 ? 3 : 4)}${i === shown.length - 1 && more > 0 ? `  +${more}` : ''}`}</Text>
464          </Button>
465        ))}
466        {below}
467      </Box>
468    )
469  })
470
471  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
472    const all = ordered(await read($, rows))
473    const t = await read($, tick)
474    // Whole seconds: within a second the images come out identical, so other mods' redraws don't rebuild them.
475    const now = Math.floor((await $.clock.now()) / 1000) * 1000
476    const ui = $.ui.resolve(e)
477    const { Box, Text } = ui
478
479    if (e.surface !== 'terminal' && 'Svg' in ui) {
480      const { Svg } = ui
481      const W = Math.max(240, Math.min(900, (e.props.bodyColumns || 40) * 8 - 8))
482      return (
483        <Box flexDirection="column">
484          <Svg key="pane-header" source={headerSvg(W, all)} alt={`${all.length} agents`} width={W} height={44} />
485          {all.map(row => (
486            <Box key={`pane-${row.id}`} flexDirection="row">
487              <Svg key={`pcrab-${row.id}`} source={paneCrabSvg(row)} alt={row.type} width={44} height={62} />
488              <Box key={`pbody-${row.id}`} flexDirection="column" flexShrink={0}>
489                <Svg key={`ptext-${row.id}`} source={paneTextSvg(W, row, now)} alt={`${row.type}: ${row.status}, ${stats(row, now)}`} width={W - 44} height={40} />
490                <Svg key={`ptrack-${row.id}`} source={paneTrackSvg(W, row)} alt={`${row.type} progress`} width={W - 44} height={22} />
491              </Box>
492            </Box>
493          ))}
494        </Box>
495      )
496    }
497
498    return (
499      <Box flexDirection="column" gap={1}>
500        {all.length === 0 && <Text dimColor>No agents right now.</Text>}
501        {all.map(row => (
502          <Box key={`pane-${row.id}`} flexDirection="column">
503            <Text bold color={accentOf(row.type)}>{`● ${row.type}`}</Text>
504            <Text color={accentOf(row.type)}>{barText(row, t, 20)}</Text>
505            <Text dimColor>{[row.description, stats(row, now)].filter(Boolean).join(' · ')}</Text>
506          </Box>
507        ))}
508      </Box>
509    )
510  })
511}
512
513// Whether a drawn tree already holds an element with this key (another band's pause button).
514function hasKey(node: unknown, key: string): boolean {
515  if (!node || typeof node !== 'object') {
516    return false
517  }
518  if (Array.isArray(node)) {
519    return node.some(n => hasKey(n, key))
520  }
521  const el = node as { key?: unknown; props?: { key?: unknown; children?: unknown }; children?: unknown }
522  return el.key === key || el.props?.key === key || hasKey(el.props?.children, key) || hasKey(el.children, key)
523}
524
types/index.d.ts 18 lines
1export type AgentRow = {
2  id: string
3  type: string
4  description: string
5  model: string
6  startedAt: number
7  endedAt: number | null
8  tools: number
9  tokens: number
10  status: 'running' | 'done' | 'failed'
11}
12
13declare module 'claude-code' {
14  interface PluginState {
15    'agent-watcher': { rows: AgentRow[]; tick: number; isPanelOpen: boolean; isPaused: boolean }
16  }
17}
18