SLOPSHOPPER

agent-watch

A progress bar above the prompt and a live agents panel for any subagents Claude starts, with model, context, cost and time. Fork of johnnyvizz's…

newpanebandguardcommandprompt
v0.5.0MITupdated 2026-10-13Barncore/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 │ ┃ Collapse │ ┃ ≈$0.00 · 0 tokens · 0:00 ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ No subagents yet. ⏺ 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-info │ ⎿ agent-watch: Agents panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
Collapse ≈$0.00 · 0 tokens · 0:00 No subagents yet.
README

agent-watch

A Claude Code mod that shows what your subagents are up to: a progress bar above the prompt box, and a live panel listing every subagent with its model, context, tokens, cost and time.

The bar while four subagents run:

The Subagents bar above the prompt box: "0/4", a progress track, "0%" and a ×4 crew button

The same bar once they've all finished:

The same bar when the run finishes: a full green track, a green "Done" badge, "100%" and a ×1 crew button

The panel, which the crew button opens, with every subagent's model, effort, context, tokens, cost and time:

The Agents panel: total cost, tokens and time, then four running subagents, each with its model, effort, context, tokens, cost and time

It's a fork of johnnyvizz's savvy-progress. savvy-progress only shows its bar for the savvy-flow workflow. agent-watch shows it for any subagent Claude starts, so you get it with Claude's own subagent work.

What it does

  • The bar. When Claude starts a subagent, a "Subagents" bar appears above the prompt box. It shows how many have finished out of how many started (1/3), a percentage, and a crew button (×3) that opens the panel. When the last one finishes it reads Done, and it goes at your next message, or when you press its ✕.
  • The panel. Every subagent in the session, running and finished, with its model, effort, how full its context is, tokens, an estimated cost and its time. It opens by itself the first time subagents start in a session. After that, the crew button or /agents-info opens and closes it.
  • savvy-flow. Its savvy-* workers show in the panel like any other subagent. Its plan bar is another matter: the savvy-flow skill reports its plan to savvy-progress's tools by name, so that bar needs savvy-progress itself (see Install).

The costs are estimates from token counts and a price table in hooks/register.tsx, not a bill.

Install

Mods install from a terminal. The desktop app's Code tab doesn't offer the /plugin command, so run these once in PowerShell, Terminal, or any shell where claude works:

claude plugin marketplace add Barncore/agent-watch
claude plugin install agent-watch@agent-watch --scope user

Then restart the desktop app, or start a new claude session.

If you have savvy-progress installed, turn it off, or you'll get two bars and two panels:

claude plugin disable savvy-progress@claude-kit

The one exception: if you use savvy-flow itself, keep savvy-progress. The savvy-flow skill reports to savvy-progress's tools by name, and agent-watch's tools have their own names.

To update later:

claude plugin marketplace update agent-watch
claude plugin update agent-watch@agent-watch --scope user

Then restart again. A running session keeps the copy it started with.

What it needs

  • Claude Code 2.1.287 or later, the release mods arrived in. They're on by default, but local settings, safe mode or an organisation policy can turn them off. See Anthropic's mod docs.

Tested on

  • Windows 11, the Claude desktop app's Code tab, Claude Code 2.1.295. The bar and the panel have been seen drawing there, with four subagents running (the screenshots above). The rest is covered by the tests: the bar's counts as subagents finish, the panel opening once, the shared band, and the batched updates.
  • macOS: untested. It should work. Mods are JavaScript run by Claude Code itself, and agent-watch touches no files, processes or OS features. But the desktop app draws mods itself, and a few drawing bugs have only been reported on Mac. If you try it on a Mac, an issue saying how it went would help.
  • Terminal: savvy-progress's text version of the bar and panel is still there, unchanged, but nobody has watched agent-watch in a real terminal yet.

What changed from savvy-progress

  • Any subagent starts the bar, not only savvy-flow's savvy-* workers.
  • It shares the band above the prompt. savvy-progress drew its bar on its own, which hid any other mod's rows in that band while it showed. agent-watch draws them above its bar.
  • The panel's live numbers update every 5 seconds. savvy-progress saved new numbers every second while anything ran, and again on every subagent request. In the desktop app's Code tab, any mod saving state redraws every mod's drawing, and a button press that lands during the redraw does nothing (anthropics/claude-code#99211). agent-watch holds a subagent's tokens, cost and context and saves them in one batch every 5 seconds (FLUSH_MS), which also moves the panel's clocks. That keeps the panel lively while making a lost button press five times rarer. The bar's counts don't wait: a subagent starting or finishing is saved straight away.
  • Its own name and tools: /agents-info is unchanged, and the tools are mcp__agent-watch__progress and mcp__agent-watch__step.

The changes are marked with agent-watch comments in hooks/register.tsx.

Settings

language: auto (the default), en or ru. auto follows Claude Code's language setting, then the system locale, and falls back to English.

What it can reach

From claude plugin validate on Claude Code 2.1.295:

hooks: session.start, command.run{command=agents-info}, tool.call{tool=mcp__agent-watch__progress}, tool.call{tool=mcp__agent-watch__step}, tool.call{tool=Agent}, agent.spawn, turn.step, turn.complete, prompt.submit, ui.render{component=Pane, requestId=savvy-agents}, ui.render{component=AbovePrompt}
calls: $.clock.after (via startTicking, tick), $.clock.now, $.command.register, $.env.get (via detectLang), $.settings.read (via detectLang), $.state.get, $.state.set, $.tool.register, $.ui.close (via togglePane), $.ui.invalidate (via tick), $.ui.open (via autoOpen, togglePane), $.ui.panes (via togglePane), $.ui.resolve
env reads: LANG, LC_ALL, LC_MESSAGES

In plain words: it sees when subagents start and finish, and the token counts of their requests. It reads your settings and locale only to pick the panel's language. It keeps its numbers in the session's own state and uses no store, so nothing carries over into other sessions. It has no file, network or process access.

Develop

claude plugin validate .
claude plugin test .

Licence

MIT, see LICENSE. agent-watch is built on savvy-progress by johnnyvizz, also MIT, and his copyright notice is kept.

Source 2 files
hooks/register.tsx 1161 lines
1// agent-watch: johnnyvizz's savvy-progress 1.2.0 (MIT), forked so the bar and the panel follow any
2// subagent Claude starts, the band is shared with other mods, and live numbers are written in batches.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register } from 'claude-code'
5
6import type { AgentRun, Flow, Panel, Phase, PlannedTask } from '../types'
7
8const flow = atom({ plugin: 'agent-watch', key: 'flow' } as const, null)
9const agents = atom({ plugin: 'agent-watch', key: 'agents' } as const, [])
10const panel = atom({ plugin: 'agent-watch', key: 'panel' } as const, {
11  isCompact: false,
12  isDoneCollapsed: false,
13  autoOpenedFor: '',
14})
15
16const TOOL = 'mcp__agent-watch__progress'
17const STEP_TOOL = 'mcp__agent-watch__step'
18const PANE = 'savvy-agents'
19const PHASES: readonly Phase[] = ['plan', 'design', 'delegate', 'review', 'close']
20const ACCENT = '#8f8cf4'
21const DONE = '#5fbf8f'
22
23type ProgressInput = {
24  title?: string
25  total?: number
26  done?: number
27  phase?: Phase
28  finished?: boolean
29  tasks?: { title?: string; tier?: string; after?: number[] }[]
30}
31
32// ---------------------------------------------------------------------------
33// Language: the `language` option, else Claude Code's `language` setting, else the
34// process locale; English when nothing says Russian.
35
36type Lang = 'en' | 'ru'
37
38const STRINGS = {
39  en: {
40    pane: 'Agents',
41    cost: 'Cost',
42    tokens: 'Tokens',
43    time: 'Time',
44    collapse: 'Collapse',
45    expand: 'Expand',
46    running: 'Running',
47    finished: 'Finished',
48    planned: 'Planned',
49    empty: 'No subagents yet.',
50    round: 'round',
51    failed: 'error',
52    after: 'after',
53    tokensWord: 'tokens',
54    agentsCount: 'agents',
55    isRunning: 'running',
56    isFinished: 'finished',
57    isPlanned: 'planned',
58    opened: 'Agents panel opened.',
59    closed: 'Agents panel closed.',
60    done: 'Done',
61    plan: 'Plan',
62    design: 'Design',
63    tasks: 'Tasks',
64    review: 'Review',
65    busy: 'running',
66    subagents: 'Subagents',
67  },
68  ru: {
69    pane: 'Агенты',
70    cost: 'Стоимость',
71    tokens: 'Токены',
72    time: 'Время',
73    collapse: 'Свернуть',
74    expand: 'Развернуть',
75    running: 'Работают',
76    finished: 'Завершены',
77    planned: 'Запланированы',
78    empty: 'Субагентов пока нет.',
79    round: 'раунд',
80    failed: 'ошибка',
81    after: 'после',
82    tokensWord: 'токенов',
83    agentsCount: 'агентов',
84    isRunning: 'работает',
85    isFinished: 'завершён',
86    isPlanned: 'запланирована',
87    opened: 'Панель агентов открыта.',
88    closed: 'Панель агентов закрыта.',
89    done: 'Готово',
90    plan: 'План',
91    design: 'Дизайн',
92    tasks: 'Задачи',
93    review: 'Ревью',
94    busy: 'в работе',
95    subagents: 'Субагенты',
96  },
97} as const
98
99// Module scope is fine here: session.start sets it again on every (re)load.
100let lang: Lang = 'en'
101const tr = () => STRINGS[lang]
102
103const isRussian = (v: unknown): boolean => typeof v === 'string' && /^(ru|russian|рус)/i.test(v.trim())
104
105async function detectLang($: EngineInterface, option: unknown): Promise<Lang> {
106  if (option === 'en' || option === 'ru') return option
107  try {
108    const settings = (await $.settings.read()) as Record<string, unknown>
109    if (typeof settings.language === 'string' && settings.language.trim()) return isRussian(settings.language) ? 'ru' : 'en'
110  } catch {
111    // No settings: fall through to the locale.
112  }
113  const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
114  return isRussian(locale) ? 'ru' : 'en'
115}
116
117const blank = (): Flow => ({
118  title: 'savvy-flow',
119  total: 0,
120  done: 0,
121  running: 0,
122  phase: 'plan',
123  isFinished: false,
124  tasks: [],
125})
126
127const isNewFlow = (prev: Flow | null, input: ProgressInput): boolean =>
128  !prev || prev.isFinished || (input.title !== undefined && input.title.trim() !== prev.title)
129
130const cleanTasks = (tasks: ProgressInput['tasks']): PlannedTask[] | undefined =>
131  tasks
132    ?.filter(t => t.title?.trim())
133    .map(t => ({
134      title: (t.title ?? '').trim(),
135      tier: (t.tier ?? '').replace(/^savvy-/, '').trim().toLowerCase(),
136      after: (t.after ?? []).filter(n => Number.isInteger(n) && n > 0),
137    }))
138
139const merge = (prev: Flow | null, input: ProgressInput): Flow => {
140  // A new title means a new flow: never carry counters over from an earlier one.
141  const base = isNewFlow(prev, input) || !prev ? blank() : { ...blank(), ...prev }
142  const tasks = cleanTasks(input.tasks) ?? base.tasks
143  const total = Math.max(0, Math.round(input.total ?? (input.tasks ? tasks.length : base.total)))
144  const done = Math.min(total || Infinity, Math.max(0, Math.round(input.done ?? base.done)))
145  const phase = input.phase && PHASES.includes(input.phase) ? input.phase : base.phase
146  return {
147    ...base,
148    title: input.title?.trim() || base.title,
149    total,
150    done,
151    phase: input.finished ? 'close' : phase,
152    isFinished: input.finished === true,
153    tasks,
154  }
155}
156
157const label = (f: Flow): string => {
158  const s = tr()
159  if (f.isFinished) return s.done
160  if (f.isAuto) return `${f.done}/${f.total}`
161  if (f.phase === 'plan') return s.plan
162  if (f.phase === 'design') return s.design
163  const count = f.total ? `${f.done}/${f.total}` : `${f.running} ${s.busy}`
164  return `${f.phase === 'review' ? s.review : s.tasks} ${count}`
165}
166
167const ratio = (f: Flow): number => (f.isFinished ? 1 : f.total ? f.done / f.total : 0)
168
169// Deterministic noise so the dither does not shimmer between redraws.
170const noise = (x: number, y: number): number => {
171  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
172  return s - Math.floor(s)
173}
174
175// The whole row is one SVG: the desktop wraps sibling elements onto new lines,
176// so title, bar, percent and the crab live in one drawing; only the count and
177// the dismiss are Buttons beside it.
178const H = 22
179const BAR_H = 16
180const CRAB_W = 26
181const CELL = 3
182const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
183
184const xml = (s: string): string =>
185  s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
186
187const clip = (s: string, max: number): string => (s.length > max ? s.slice(0, Math.max(1, max - 1)) + '…' : s)
188
189// Rough advance of system UI text, in em; good enough to size the title's slot.
190const charEm = (ch: string): number =>
191  /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-ZА-ЯЁmwшщжюМШЩЖЮ@%]/.test(ch) ? 0.72 : 0.56
192
193const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
194
195// Cuts `s` to fit `maxW` pixels, with an ellipsis when it had to cut.
196const fitText = (s: string, size: number, maxW: number): string => {
197  if (textWidth(s, size) <= maxW) return s
198  let out = ''
199  for (const ch of s) {
200    if (textWidth(out + ch + '…', size) > maxW) break
201    out += ch
202  }
203  return out + '…'
204}
205
206const rowSvg = (f: Flow, W: number, isWorking: boolean): string => {
207  // The title takes what it needs, up to 40% of the row; the bar takes the rest.
208  const title = fitText(f.title, 13, Math.max(60, W * 0.4))
209  const BAR_X = Math.round(16 + textWidth(title, 13) + 12)
210  const BAR_W = Math.max(60, W - BAR_X - 46 - CRAB_W)
211  const color = f.isFinished ? DONE : ACCENT
212  const y0 = (H - BAR_H) / 2
213  const fillW = Math.round(BAR_W * ratio(f))
214  const runW = f.total ? Math.round((BAR_W * Math.min(f.total, f.done + f.running)) / f.total) : 0
215  const dots: string[] = []
216
217  // Dithered fill: sparse at the start, dense toward the head.
218  const cols = Math.floor(fillW / CELL)
219  const rows = Math.floor(BAR_H / CELL)
220  for (let c = 0; c < cols; c++) {
221    const density = 0.35 + 0.6 * Math.pow(c / Math.max(1, cols), 1.2)
222    for (let r = 0; r < rows; r++) {
223      if (noise(c, r) < density) dots.push(`<rect class="t${Math.floor(noise(r, c) * 4)}" x="${c * CELL + 1}" y="${r * CELL + 1}" width="2" height="2"/>`)
224    }
225  }
226  // Handed to workers, not yet accepted: a faint second layer.
227  const faint: string[] = []
228  for (let c = cols; c < Math.floor(runW / CELL); c++) {
229    for (let r = 0; r < rows; r++) {
230      if (noise(c + 7, r + 3) < 0.2) faint.push(`<rect class="t${Math.floor(noise(r + 5, c) * 4)}" x="${c * CELL + 1}" y="${r * CELL + 1}" width="1.7" height="1.7"/>`)
231    }
232  }
233
234  const ticks: string[] = []
235  for (let i = 1; i < f.total; i++) {
236    const x = Math.round((BAR_W * i) / f.total)
237    if (x > fillW + 4) ticks.push(`<rect x="${x}" y="${BAR_H / 2 - 4}" width="1.5" height="8" rx="0.75"/>`)
238  }
239
240  const text = label(f)
241  const pillW = Math.round(18 + text.length * 6.6)
242  const pillX = Math.max(0, Math.min(BAR_W - pillW, fillW - pillW))
243  const percent = `${Math.round(ratio(f) * 100)}%`
244
245  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
246<style>
247.t{fill:#1f1f1f}.m{fill:#8a8a8a}.k{fill:#e4e4e2}.tk{fill:#b4b4b0}
248@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}.k{fill:#2c2c2c}.tk{fill:#5a5a5a}}
249/* Pixels twinkle in four out-of-phase groups; a finished bar settles to a slow glow. */
250.t0,.t1,.t2,.t3{animation:tw ${f.isFinished ? 3.2 : 2.2}s ease-in-out infinite}
251.t1{animation-duration:${f.isFinished ? 3.8 : 2.8}s;animation-delay:-.7s}.t2{animation-duration:${f.isFinished ? 4.4 : 1.9}s;animation-delay:-1.3s}.t3{animation-duration:${f.isFinished ? 3.5 : 3.3}s;animation-delay:-.4s}
252@keyframes tw{0%,100%{opacity:1}50%{opacity:${f.isFinished ? 0.8 : 0.3}}}
253@media (prefers-reduced-motion: reduce){.t0,.t1,.t2,.t3{animation:none}}
254</style>
255<defs><clipPath id="c"><rect x="0" y="0" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/></clipPath></defs>
256<circle cx="5" cy="${H / 2}" r="4" fill="${color}"/>
257<text class="t" x="16" y="${H / 2 + 4.5}" font-family="${FONT}" font-size="13" font-weight="500">${xml(title)}</text>
258<g transform="translate(${BAR_X},${y0})">
259<rect class="k" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/>
260<g clip-path="url(#c)">
261<g fill="${color}">${dots.join('')}</g>
262<g fill="${color}" opacity="0.45">${faint.join('')}</g>
263<g class="tk">${ticks.join('')}</g>
264</g>
265<rect x="${pillX}" width="${pillW}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${color}"/>
266<text x="${pillX + pillW / 2}" y="${BAR_H / 2 + 4}" text-anchor="middle" font-family="${FONT}" font-size="11" font-weight="600" fill="#ffffff">${xml(text)}</text>
267</g>
268<text class="m" x="${W - CRAB_W - 6}" y="${H / 2 + 4.5}" text-anchor="end" font-family="${FONT}" font-size="12.5" font-variant-numeric="tabular-nums">${percent}</text>
269${CRAB_CSS}${crab(W - CRAB_W + 1, 0, 'other', false, isWorking, 0.8)}
270</svg>`
271}
272
273const barText = (f: Flow, width: number): string => {
274  const filled = Math.round(width * ratio(f))
275  return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
276}
277
278// ---------------------------------------------------------------------------
279// Agents panel: every subagent of the session, plus the tasks the flow planned.
280
281const TIER_COLOR: Record<string, string> = {
282  fable: '#7F77DD',
283  heavy: '#D85A30',
284  careful: '#BA7517',
285  medium: '#378ADD',
286  light: '#1D9E75',
287  other: '#888780',
288}
289
290// What each savvy tier runs on, for planned tasks that have no run yet.
291const colorOf = (tier: string): string => TIER_COLOR[tier] ?? '#888780'
292
293const TIER_MODEL: Record<string, string> = {
294  fable: 'Fable · high',
295  heavy: 'Opus · xhigh',
296  careful: 'Opus · high',
297  medium: 'Opus · medium',
298  light: 'Opus · low',
299}
300
301// USD per million tokens: input, output, cache read, cache write (5-minute TTL).
302// The engine reports tokens, not money, so the panel's cost is an estimate.
303const PRICES: [RegExp, [number, number, number, number]][] = [
304  [/fable|mythos/, [10, 50, 0.25, 12.5]],
305  [/opus-5-5/, [4, 20, 0.2, 5]],
306  [/opus/, [5, 25, 0.5, 6.25]],
307  [/sonnet/, [2, 10, 0.2, 2.5]],
308  [/haiku/, [1, 5, 0.1, 1.25]],
309]
310
311type Usage = {
312  input_tokens: number
313  output_tokens: number
314  cache_read_input_tokens: number
315  cache_creation_input_tokens: number
316}
317
318const priceOf = (model: string): [number, number, number, number] =>
319  PRICES.find(([re]) => re.test(model.toLowerCase()))?.[1] ?? [4, 20, 0.2, 5]
320
321const costOf = (model: string, u: Usage): number => {
322  const [i, o, r, w] = priceOf(model)
323  return (
324    ((u.input_tokens || 0) * i +
325      (u.output_tokens || 0) * o +
326      (u.cache_read_input_tokens || 0) * r +
327      (u.cache_creation_input_tokens || 0) * w) /
328    1e6
329  )
330}
331
332const windowOf = (model: string): number => (/haiku/i.test(model) ? 200_000 : 1_000_000)
333
334// `savvy-careful`, or `savvy-flow:savvy-careful` when the agents ship in a plugin.
335const tierOf = (type: string): string => {
336  const bare = type.replace(/^[^:]*:/, '')
337  const t = bare.replace(/^savvy-/, '').toLowerCase()
338  return t in TIER_COLOR && bare.startsWith('savvy-') ? t : 'other'
339}
340
341const modelName = (id: string): string => {
342  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
343  const [, family = '', major = '', minor] = m ?? []
344  if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
345  return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
346}
347
348const norm = (s: string): string => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
349
350const fmtTokens = (n: number): string =>
351  n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
352
353const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
354
355const fmtTime = (ms: number): string => {
356  const s = Math.max(0, Math.round(ms / 1000))
357  const h = Math.floor(s / 3600)
358  const m = Math.floor((s % 3600) / 60)
359  const ss = String(s % 60).padStart(2, '0')
360  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
361}
362
363const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
364
365type Planned = PlannedTask & { n: number }
366
367const plannedOf = (f: Flow | null, list: AgentRun[]): Planned[] => {
368  if (!f || f.isFinished) return []
369  const started = new Set(list.map(a => norm(a.description)))
370  return (f.tasks ?? []).map((t, i) => ({ ...t, n: i + 1 })).filter(t => !started.has(norm(t.title)))
371}
372
373const totals = (list: AgentRun[], at: number) => {
374  const cost = list.reduce((s, a) => s + a.costUsd, 0)
375  const tokens = list.reduce((s, a) => s + a.tokens, 0)
376  const start = Math.min(...list.map(a => a.startedAt))
377  const end = Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt)))
378  return { cost, tokens, time: list.length ? end - start : 0 }
379}
380
381// --- desktop drawings: each row is one SVG, as the band above the prompt is.
382
383const PANE_CSS = `<style>
384.t{fill:#1f1f1f}.s{fill:#6b6b68}.m{fill:#9a9a96}.k{fill:#ecebe8}.ln{stroke:#e4e4e1}.tile{fill:#f4f3f0}
385@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.m{fill:#7d7d79}.k{fill:#2c2c2b}.ln{stroke:#333331}.tile{fill:#262625}}
386.live{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
387@media (prefers-reduced-motion: reduce){.live{animation:none}}
388</style>`
389
390// Pixel Clawd from DockCrab (Clawdy): a 24×18 crab on a 30×28 grid, one costume per tier.
391// The body keeps the brand clay; the tier's color lives in the costume's accent.
392const CLAY = '#D97757'
393const INK = '#1F1E1D'
394
395// `cls` puts a pixel in a named group: `bd` (the default) is the body and its
396// costume, `la`/`lb` the leg pairs, anything else a prop with its own motion.
397type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
398
399const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
400  rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
401
402// `armCls` lets a raised claw travel with the prop it holds.
403const crabBody = (f: Fill, armFront = 0, armCls?: string): void => {
404  f(7, 10, 16, 12, CLAY)
405  f(3, 14, 4, 4, CLAY)
406  f(23, 14 + armFront, 4, 4, CLAY, armCls)
407  f(9, 12, 2, 2, INK)
408  f(19, 12, 2, 2, INK)
409  f(7, 22, 2, 4, CLAY, 'la')
410  f(17, 22, 2, 4, CLAY, 'la')
411  f(11, 22, 2, 4, CLAY, 'lb')
412  f(21, 22, 2, 4, CLAY, 'lb')
413}
414
415// Pure CSS, run by the compositor: no redraws. Periods divide one second, so the
416// once-a-second redraw of a running row restarts them in phase. Every crab walks;
417// each costume adds its prop's own motion on top.
418const CRAB_CSS = `<style>
419.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
420.run .bd{animation:bob .5s steps(1) infinite -.125s}
421.run g{transform-box:fill-box}
422@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
423.c-fable.run{animation:float 1s ease-in-out infinite}
424.c-fable.run .la,.c-fable.run .lb,.c-fable.run .bd{animation:none}
425.c-fable.run .ant{animation:blink 1s steps(1) infinite}
426.c-fable.run .star{animation:blink .5s steps(1) infinite -.25s}
427@keyframes float{50%{transform:translateY(-2px)}}@keyframes blink{50%{opacity:.15}}
428.c-heavy.run .it{animation:scan 1s steps(1) infinite}
429.c-heavy.run .gl{animation:blink 1s steps(1) infinite -.5s}
430@keyframes scan{25%{transform:translate(-1px,1px)}50%{transform:translate(-2px,2px)}75%{transform:translate(-1px,1px)}}
431.c-careful.run .it{transform-origin:100% 100%;animation:twist .5s ease-in-out infinite}
432@keyframes twist{50%{transform:rotate(-35deg)}}
433.c-medium.run .pan{transform-origin:0 50%;animation:tilt 1s ease-in-out infinite}
434.c-medium.run .egg{animation:flip 1s ease-in-out infinite}
435@keyframes tilt{20%,40%{transform:rotate(-12deg)}}@keyframes flip{30%{transform:translateY(-5px) scaleY(-1)}60%{transform:translateY(0)}}
436.c-light.run .la{animation-duration:.25s}.c-light.run .lb{animation-duration:.25s;animation-delay:-.125s}
437.c-light.run .flag{transform-origin:0 50%;animation:wave .25s steps(1) infinite}
438@keyframes wave{50%{transform:skewY(-12deg) scaleX(.85)}}
439.c-explore.run .it{transform-origin:50% 100%;animation:fence .5s ease-in-out infinite}
440@keyframes fence{50%{transform:rotate(25deg)}}
441@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}
442</style>`
443
444const COSTUMES: Record<string, (f: Fill, t: string) => void> = {
445  // Fable: astronaut in a glass dome; floats instead of walking, the antenna and the star blink.
446  fable: (f, t) => {
447    crabBody(f)
448    f(6, 7, 18, 1, '#E6E8EE'); f(5, 8, 1, 14, '#E6E8EE'); f(24, 8, 1, 14, '#E6E8EE'); f(6, 22, 18, 1, '#C9CCD2')
449    f(6, 8, 18, 14, 'rgba(169,214,245,.32)'); f(8, 9, 2, 1, '#fff'); f(8, 10, 1, 2, '#fff')
450    f(14, 4, 2, 3, '#C9CCD2'); f(14, 2, 2, 2, t, 'ant'); f(13, 18, 4, 2, t)
451    f(27, 3, 1, 3, '#F5C542', 'star'); f(26, 4, 3, 1, '#F5C542', 'star')
452  },
453  // Heavy: detective with a deerstalker; the magnifier sweeps and glints.
454  heavy: (f, t) => {
455    crabBody(f, -4, 'it')
456    stamp(f, 6, 3, ['......bbbbbb......', '....bbcbbcbbbb....', '...bbbbbbbbbbbb...', '..bcbbcbbcbbcbbb..', '.bbbbbbbbbbbbbbbb.', 'dddddddddddddddddd'], { b: '#7A4A26', c: '#A0703F', d: '#5A3519' })
457    f(6, 9, 18, 1, t)
458    stamp(f, 23, 1, ['.kkk.', 'k...k', 'k...k', 'k...k', '.kkk.'], { k: '#3A3A3C' }, 'it')
459    f(24, 2, 3, 3, 'rgba(169,214,245,.7)', 'it'); f(25, 6, 1, 4, '#7A4A26', 'it'); f(24, 2, 1, 1, '#fff', 'gl')
460  },
461  // Careful: engineer in a hard hat; the wrench turns a bolt.
462  careful: (f, t) => {
463    crabBody(f)
464    stamp(f, 6, 4, ['.....yyyyyyyy.....', '...yyyyyhhyyyyy...', '..yyyyyyhhyyyyyy..', '..yyyyyyhhyyyyyy..', '.yyyyyyyhhyyyyyyy.', 'dddddddddddddddddd'], { y: '#F5C542', h: '#FBE08A', d: '#C99A1E' })
465    f(13, 5, 4, 2, t)
466    stamp(f, 0, 10, ['.s.s', 'sss.', '.s..', '.s..'], { s: '#8E929A' }, 'it')
467  },
468  // Medium: chef, the toque traced from DockCrab's Sprites.chefHat; tosses the omelette.
469  medium: (f, t) => {
470    crabBody(f, -4, 'pan')
471    stamp(f, 6, 0, ['........lll.......', '.......lllll......', '.wwwwgwwwwwwgwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwgwwwwwggwwwww', '.wwwwgwwwwwggwwwww', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.'], { w: '#F4F3EE', l: '#F7F6F2', g: '#D2D1C8', b: t, d: '#B45F43' })
472    f(22, 8, 7, 2, '#4A4A48', 'pan'); f(26, 10, 1, 1, '#4A4A48', 'pan'); f(24, 7, 3, 1, '#F5B731', 'egg')
473  },
474  // Light: racer in a helmet; runs at double pace, the checkered flag flutters.
475  light: (f, t) => {
476    crabBody(f, -4)
477    stamp(f, 6, 5, ['....rrrrrrrrrr....', '..rrrrrrwwrrrrrr..', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.kkkkkkkkkkkkkkkkr'], { r: t, w: '#F8F6F1', k: INK })
478    f(25, 1, 1, 9, '#8E929A')
479    stamp(f, 26, 1, ['wkwk', 'kwkw', 'wkwk'], { w: '#F8F6F1', k: INK }, 'flag')
480  },
481  // Explore: pirate scouting the code; the cutlass fences.
482  explore: f => {
483    crabBody(f)
484    stamp(f, 5, 3, ['.kk..............kk.', '.kkk....kkkk....kkk.', '..kkkkkkkwwkkkkkkk..', '..kkkkkkkkkkkkkkkk..', '.gggggggggggggggggg.'], { k: '#55514C', w: '#F8F6F1', g: '#F5C542' })
485    f(7, 11, 11, 1, INK); f(18, 11, 4, 3, INK)
486    f(27, 6, 1, 9, '#C9CCD2', 'it'); f(26, 15, 3, 1, '#7A4A26', 'it')
487  },
488  other: f => crabBody(f),
489}
490
491const costumeOf = (type: string): string => (type === 'Explore' ? 'explore' : tierOf(type))
492
493const CRAB_SCALE = 1.1
494
495// Body and props nest inside `bd` so a prop rides the bob and adds its own motion;
496// legs stay outside it and step on their own.
497const crab = (x: number, y: number, costume: string, dim = false, isWalking = false, scale = CRAB_SCALE): string => {
498  const groups = new Map<string, string[]>([['bd', []]])
499  const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
500    if (!groups.has(cls)) groups.set(cls, [])
501    groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
502  }
503  const draw = COSTUMES[costume] ?? ((g: Fill) => crabBody(g))
504  draw(f, colorOf(costume))
505  const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
506  const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
507  const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
508  return `<g transform="translate(${x},${y}) scale(${scale})" opacity="${dim ? 0.45 : 1}" shape-rendering="crispEdges"><g class="c-${costume}${isWalking ? ' run' : ''}">${body}${group('la')}${group('lb')}</g></g>`
509}
510
511const statusMark = (x: number, y: number, status: string, color: string): string => {
512  if (status === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
513  if (status === 'done') return `<path d="M${x - 5} ${y}l3.5 3.5 6.5-7" fill="none" stroke="#3B9C5F" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`
514  if (status === 'failed') return `<path d="M${x - 4} ${y - 4}l8 8M${x + 4} ${y - 4}l-8 8" stroke="#D0453F" stroke-width="1.8" stroke-linecap="round"/>`
515  return `<circle cx="${x}" cy="${y}" r="5" fill="none" stroke="#9a9a96" stroke-width="1.4"/><path d="M${x} ${y - 2.5}v2.8l1.8 1.2" fill="none" stroke="#9a9a96" stroke-width="1.4" stroke-linecap="round"/>`
516}
517
518const svg = (W: number, H: number, body: string): string =>
519  `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PANE_CSS}${CRAB_CSS}${body}</svg>`
520
521// The pane's own title already says "Agents": the header names the flow, if any.
522const headerSvg = (W: number, title: string, t: ReturnType<typeof totals>): string => {
523  const s = tr()
524  const gap = 6
525  const tw = (W - gap * 2) / 3
526  const top = title ? 28 : 0
527  const tile = (i: number, k: string, v: string) =>
528    `<rect class="tile" x="${i * (tw + gap)}" y="${top}" width="${tw}" height="40" rx="8"/>
529<text class="s" x="${i * (tw + gap) + 9}" y="${top + 16}" font-family="${FONT}" font-size="11">${k}</text>
530<text class="t" x="${i * (tw + gap) + 9}" y="${top + 33}" font-family="${FONT}" font-size="15" font-weight="600" font-variant-numeric="tabular-nums">${v}</text>`
531  return svg(
532    W,
533    headerHeight(title),
534    `${title ? `<text class="t" x="0" y="15" font-family="${FONT}" font-size="14" font-weight="600">${xml(fitText(title, 14, W))}</text>` : ''}
535${tile(0, s.cost, '≈' + fmtCost(t.cost))}${tile(1, s.tokens, fmtTokens(t.tokens))}${tile(2, s.time, fmtTime(t.time))}`,
536  )
537}
538
539const headerHeight = (title: string): number => (title ? 72 : 44)
540
541// The task's own progress when the worker reports steps; a finished run is full.
542const progressOf = (a: AgentRun): number | null => {
543  if (a.status === 'done') return 1
544  if (a.stepTotal) return Math.min(1, (a.stepDone ?? 0) / a.stepTotal)
545  return null
546}
547
548const ctxOf = (a: AgentRun): number => (a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0)
549
550const agentSvg = (W: number, a: AgentRun, at: number): string => {
551  const s = tr()
552  const tier = tierOf(a.type)
553  const color = colorOf(tier)
554  const ctx = ctxOf(a)
555  const textW = W - 42 - 22
556  const meta = [a.effort ? `${modelName(a.model)} · ${a.effort}` : modelName(a.model)]
557  if (a.round > 1) meta.push(`${s.round} ${a.round}`)
558  if (a.status === 'failed') meta.push(s.failed)
559  const barW = textW
560  const progress = progressOf(a)
561  const stats = `ctx ${ctx}% · ${fmtTokens(a.contextTokens)}  ≈${fmtCost(a.costUsd)}  ${fmtTime(elapsed(a, at))}`
562  const steps = a.stepTotal ? `${a.stepDone ?? 0}/${a.stepTotal}${a.stepNote ? ' · ' + a.stepNote : ''}` : ''
563  const stepsW = Math.max(0, barW - textWidth(stats, 11) - 12)
564  // Without reported steps the bar falls back to the context, drawn grey.
565  const fillW = Math.round(barW * (progress ?? ctx / 100))
566  return svg(
567    W,
568    66,
569    `${crab(0, 14, costumeOf(a.type), false, a.status === 'running')}
570<text class="t" x="42" y="18" font-family="${FONT}" font-size="13" font-weight="600">${xml(fitText(a.description || a.type, 13, textW))}</text>
571<text x="42" y="34" font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(tier === 'other' ? a.type : tier)}</tspan><tspan class="s">  ${xml(meta.join('  ·  '))}</tspan></text>
572${steps && stepsW > 30 ? `<text class="t" x="42" y="49" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${xml(fitText(steps, 11, stepsW))}</text>` : ''}
573<text class="s" x="${42 + barW}" y="49" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${stats}</text>
574<rect class="k" x="42" y="55" width="${barW}" height="4" rx="2"/><rect${progress === null ? ' class="m"' : ''} x="42" y="55" width="${fillW}" height="4" rx="2"${progress === null ? '' : ` fill="${color}"`}/>
575${statusMark(W - 8, 16, a.status, color)}
576<line class="ln" x1="0" y1="65.5" x2="${W}" y2="65.5"/>`,
577  )
578}
579
580const plannedSvg = (W: number, p: Planned): string => {
581  const tier = p.tier in TIER_COLOR ? p.tier : 'other'
582  const color = colorOf(tier)
583  const textW = W - 42 - 22
584  const meta = [TIER_MODEL[tier] ?? '']
585  if (p.after.length) meta.push(`${tr().after} ${p.after.join(', ')}`)
586  return svg(
587    W,
588    46,
589    `${crab(0, 6, tier, true)}
590<text class="s" x="42" y="18" font-family="${FONT}" font-size="13" font-weight="600">${xml(fitText(`${p.n}. ${p.title}`, 13, textW))}</text>
591<text x="42" y="34" font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(tier)}</tspan><tspan class="m">  ${xml(meta.filter(Boolean).join('  ·  '))}</tspan></text>
592${statusMark(W - 8, 16, 'planned', color)}
593<line class="ln" x1="0" y1="45.5" x2="${W}" y2="45.5"/>`,
594  )
595}
596
597const compactSvg = (W: number, list: AgentRun[], planned: Planned[], t: ReturnType<typeof totals>): string => {
598  const icons = [
599    ...list.filter(a => a.status === 'running').map(a => ({ k: costumeOf(a.type), c: colorOf(tierOf(a.type)), s: 'running', dim: false })),
600    ...list.filter(a => a.status !== 'running').map(a => ({ k: costumeOf(a.type), c: colorOf(tierOf(a.type)), s: a.status, dim: false })),
601    ...planned.map(p => ({ k: p.tier in TIER_COLOR ? p.tier : 'other', c: colorOf(p.tier), s: 'planned', dim: true })),
602  ]
603  const fit = Math.max(1, Math.floor((W - 150) / 36))
604  const shown = icons.slice(0, fit)
605  const more = icons.length - shown.length
606  const body = shown
607    .map((ic, i) => crab(i * 36, 0, ic.k, ic.dim, ic.s === 'running') + (ic.s === 'running' ? `<circle class="live" cx="${i * 36 + 32}" cy="4" r="3" fill="${ic.c}"/>` : ''))
608    .join('')
609  const x = shown.length * 36 + (more ? 4 : 0)
610  return svg(
611    W,
612    32,
613    `${body}${more ? `<text class="s" x="${x}" y="21" font-family="${FONT}" font-size="12">+${more}</text>` : ''}
614<text class="s" x="${W}" y="21" text-anchor="end" font-family="${FONT}" font-size="12" font-variant-numeric="tabular-nums">≈${fmtCost(t.cost)} · ${fmtTokens(t.tokens)} · ${fmtTime(t.time)}</text>`,
615  )
616}
617
618// --- terminal drawing: the same rows in text.
619
620const ctxBar = (pct: number, width: number): string => {
621  const filled = Math.round((width * pct) / 100)
622  return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
623}
624
625const STATUS_GLYPH: Record<string, string> = { running: '●', done: '✓', failed: '✗', planned: '◷' }
626
627// Opens the agents pane, or closes it when it is up; true when it ends up open.
628async function togglePane($: EngineInterface): Promise<boolean> {
629  const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
630  if (isOpen) {
631    await $.ui.close({ id: PANE })
632    return false
633  }
634  await $.ui.open({ id: PANE, title: tr().pane })
635  return true
636}
637
638async function autoOpen($: EngineInterface, key: string): Promise<void> {
639  const p = await read($, panel)
640  if (p.autoOpenedFor === key) return
641  await update($, panel, prev => ({ ...prev, autoOpenedFor: key }))
642  void $.ui.open({ id: PANE, title: tr().pane })
643}
644
645// ---------------------------------------------------------------------------
646// agent-watch: live numbers in batches. In the Code tab any mod's state write rebuilds every
647// drawing on screen and can drop a click, so a subagent's per-request usage waits here and is
648// written once per FLUSH_MS; the same tick moves the running clocks in the panel.
649
650export const FLUSH_MS = 5_000
651
652type Pending = { model: string; effort?: string; contextTokens: number; tokens: number; costUsd: number; steps: number }
653type Ticker = { pending: Map<string, Pending>; isTicking: boolean }
654
655export const isSavvy = (type: string): boolean => type.replace(/^[^:]*:/, '').startsWith('savvy-')
656
657/** Writes the held usage into the agents list; false when nothing was held. */
658async function flush($: EngineInterface, t: Ticker): Promise<boolean> {
659  if (t.pending.size === 0) return false
660  const batch = new Map(t.pending)
661  t.pending.clear()
662  await update($, agents, list =>
663    list.map(a => {
664      const p = a.agentId ? batch.get(a.agentId) : undefined
665      if (!p) return a
666      return {
667        ...a,
668        model: p.model,
669        effort: p.effort ?? a.effort,
670        status: 'running' as const,
671        endedAt: undefined,
672        contextTokens: p.contextTokens,
673        contextMax: windowOf(p.model),
674        tokens: a.tokens + p.tokens,
675        costUsd: a.costUsd + p.costUsd,
676        steps: a.steps + p.steps,
677      }
678    }),
679  )
680  return true
681}
682
683async function tick($: EngineInterface, t: Ticker): Promise<void> {
684  const wrote = await flush($, t)
685  const list = await read($, agents)
686  if (!list.some(a => a.status === 'running')) {
687    t.isTicking = false
688    return
689  }
690  // Nothing new to write: redraw anyway, so the running clocks move.
691  if (!wrote) $.ui.invalidate('ui.render')
692  $.clock.after(FLUSH_MS, () => {
693    void tick($, t)
694  })
695}
696
697function startTicking($: EngineInterface, t: Ticker): void {
698  if (t.isTicking) return
699  t.isTicking = true
700  $.clock.after(FLUSH_MS, () => {
701    void tick($, t)
702  })
703}
704
705export const register: Register = (on, options) => {
706  const ticker: Ticker = { pending: new Map(), isTicking: false }
707
708  on('session.start', async ($, e, next) => {
709    const started = await next(e)
710    lang = await detectLang($, options.language)
711    await $.tool.register({
712      name: 'progress',
713      description:
714        'Report /savvy-flow progress to the progress bar above the prompt and the agents panel. ' +
715        'Call it after presenting the plan (title, total, tasks, phase "delegate"), each time a task is accepted (done), ' +
716        'when switching phase or re-planning (tasks), and once at the end with finished: true. Fields left out keep their previous value.',
717      inputSchema: {
718        type: 'object',
719        properties: {
720          title: { type: 'string', description: 'Short name of the overall task, a few words.' },
721          total: { type: 'integer', minimum: 0, description: 'Number of planned worker tasks.' },
722          done: { type: 'integer', minimum: 0, description: 'Number of tasks accepted after review.' },
723          phase: { type: 'string', enum: [...PHASES] },
724          finished: { type: 'boolean', description: 'True once the flow is closed.' },
725          tasks: {
726            type: 'array',
727            description:
728              'The planned worker tasks in order, numbered from 1. Each title must equal the Agent tool `description` the task will be delegated with, so the panel can match runs to tasks.',
729            items: {
730              type: 'object',
731              properties: {
732                title: { type: 'string', description: 'A few words; reused verbatim as the Agent description.' },
733                tier: { type: 'string', enum: ['fable', 'heavy', 'careful', 'medium', 'light'] },
734                after: { type: 'array', items: { type: 'integer' }, description: 'Numbers of the tasks this one waits for.' },
735              },
736              required: ['title', 'tier'],
737            },
738          },
739        },
740      },
741    })
742    await $.tool.register({
743      name: 'step',
744      description:
745        'For savvy-flow workers: report progress on your own task to the agents panel. ' +
746        'Right after reading the brief, call it with `total` (your plan in 3-8 steps) and `done: 0`; ' +
747        'call it again as each step finishes. Cheap and silent: it only draws a bar.',
748      inputSchema: {
749        type: 'object',
750        properties: {
751          done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
752          total: { type: 'integer', minimum: 1, description: 'Steps planned; may change if the plan changes.' },
753          note: { type: 'string', description: 'The step in progress, a few words.' },
754        },
755        required: ['done'],
756      },
757    })
758    await $.command.register({
759      name: 'agents-info',
760      description: 'Show or hide the panel of subagents: running, finished and planned, with model, context, cost and time',
761    })
762
763    // The running clocks move on the batch tick that agent.spawn starts, not every second.
764    return started
765  })
766
767  on('command.run', { command: 'agents-info' }, async $ => {
768    const isOpen = await togglePane($)
769    return { text: isOpen ? tr().opened : tr().closed }
770  })
771
772  on('tool.call', { tool: TOOL }, async ($, e) => {
773    const input = e as unknown as ProgressInput
774    const prev = await read($, flow)
775    if (isNewFlow(prev, input) && input.title !== undefined) {
776      // A new flow starts with a clean list; agents still running stay.
777      await update($, agents, list => list.filter(a => a.status === 'running'))
778    }
779    const next = await update($, flow, p => merge(p, input))
780    if (next && input.tasks?.length) await autoOpen($, next.title)
781    return { result: `ok: ${label(next ?? blank())}` }
782  })
783
784  // A worker's own progress: the call runs in the worker's loop, so agentId names it.
785  on('tool.call', { tool: STEP_TOOL }, async ($, e) => {
786    const input = e as unknown as { done?: number; total?: number; note?: string }
787    const agentId = e.agentId
788    if (!agentId) return { result: 'ignored: only subagents report steps' }
789    await update($, agents, list =>
790      list.map(a => {
791        if (a.agentId !== agentId) return a
792        const total = Math.max(0, Math.round(input.total ?? a.stepTotal ?? 0))
793        const done = Math.max(0, Math.round(input.done ?? a.stepDone ?? 0))
794        return { ...a, stepTotal: total, stepDone: total ? Math.min(total, done) : done, stepNote: input.note?.trim() || undefined }
795      }),
796    )
797    return { result: 'ok' }
798  })
799
800  // Safety net: worker launches move the faint layer even if the orchestrator forgets to report.
801  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
802    const type = String(e.subagent_type ?? '')
803    if (!type.startsWith('savvy-')) return next(e)
804
805    await update($, flow, prev => {
806      const base = prev && !prev.isFinished ? { ...blank(), ...prev } : blank()
807      return { ...base, running: base.running + 1, phase: base.phase === 'plan' ? 'delegate' : base.phase }
808    })
809    try {
810      return await next(e)
811    } finally {
812      await update($, flow, prev => (prev ? { ...prev, running: Math.max(0, prev.running - 1) } : prev))
813    }
814  })
815
816  on('agent.spawn', async ($, e, next) => {
817    const started = await next(e)
818    if (started.deny !== undefined) return started
819
820    const at = await $.clock.now()
821    await update($, agents, list => {
822      const round = 1 + list.filter(a => norm(a.description) === norm(e.description) && e.description).length
823      const run: AgentRun = {
824        id: started.agentId ?? e.tool_use_id,
825        agentId: started.agentId,
826        type: e.subagentType,
827        description: e.description,
828        model: started.model,
829        status: 'running',
830        startedAt: at,
831        contextTokens: 0,
832        contextMax: windowOf(started.model),
833        tokens: 0,
834        costUsd: 0,
835        steps: 0,
836        round,
837      }
838      return [...list.filter(a => a.id !== run.id), run].slice(-200)
839    })
840    startTicking($, ticker)
841    if (isSavvy(e.subagentType)) {
842      const f = await read($, flow)
843      await autoOpen($, f && !f.isFinished ? f.title : 'savvy-flow')
844    } else {
845      // Any other subagent starts or joins a plain "Subagents" flow, unless a savvy-flow run owns the bar.
846      const f = await update($, flow, prev => {
847        if (prev && !prev.isFinished && !prev.isAuto) return prev
848        const base: Flow = prev && prev.isAuto && !prev.isFinished ? prev : { ...blank(), title: tr().subagents, phase: 'delegate', isAuto: true }
849        return { ...base, total: base.total + 1, running: base.running + 1 }
850      })
851      if (f?.isAuto) await autoOpen($, 'subagents')
852    }
853    return started
854  })
855
856  // Each model request of a subagent: live context, tokens and cost.
857  on('turn.step', async function* ($, e, next) {
858    const result = yield* next(e)
859    const agentId = e.agentId
860    const usage = result.usage
861    if (!agentId || !usage) return result
862
863    // Held until the next batch write; see FLUSH_MS.
864    const model = usage.model || e.model
865    const held = ticker.pending.get(agentId)
866    const tokens =
867      (usage.input_tokens || 0) +
868      (usage.output_tokens || 0) +
869      (usage.cache_read_input_tokens || 0) +
870      (usage.cache_creation_input_tokens || 0)
871    ticker.pending.set(agentId, {
872      model,
873      effort: typeof e.effort === 'string' ? e.effort : held?.effort,
874      contextTokens: tokens,
875      tokens: (held?.tokens ?? 0) + tokens,
876      costUsd: (held?.costUsd ?? 0) + costOf(model, usage),
877      steps: (held?.steps ?? 0) + 1,
878    })
879    startTicking($, ticker)
880    return result
881  })
882
883  on('turn.complete', async ($, e, next) => {
884    const agentId = e.agentId
885    if (agentId) {
886      // The run's last requests land before it is marked done.
887      await flush($, ticker)
888      const at = await $.clock.now()
889      const run = (await read($, agents)).find(a => a.agentId === agentId)
890      await update($, agents, list =>
891        list.map(a => {
892          if (a.agentId !== agentId) return a
893          // A run whose steps went unseen still gets the turn's own sum.
894          const fallback = a.steps === 0 && e.usage
895          return {
896            ...a,
897            status: e.reason === 'answer' ? 'done' : 'failed',
898            endedAt: at,
899            ...(fallback && e.usage
900              ? {
901                  model: e.usage.model || a.model,
902                  tokens:
903                    e.usage.input_tokens +
904                    e.usage.output_tokens +
905                    e.usage.cache_read_input_tokens +
906                    e.usage.cache_creation_input_tokens,
907                  costUsd: costOf(e.usage.model || a.model, e.usage),
908                }
909              : {}),
910          }
911        }),
912      )
913      if (run && !isSavvy(run.type)) {
914        await update($, flow, prev => {
915          if (!prev || !prev.isAuto || prev.isFinished) return prev
916          const running = Math.max(0, prev.running - 1)
917          const done = Math.min(prev.total, prev.done + 1)
918          return running === 0 ? { ...prev, running, done, isFinished: true, phase: 'close' as const } : { ...prev, running, done }
919        })
920      }
921    }
922    return next(e)
923  })
924
925  // A finished "Subagents" bar has been seen once the person writes again.
926  on('prompt.submit', async ($, e, next) => {
927    if (e.origin.kind !== 'plugin') {
928      const f = await read($, flow)
929      if (f?.isAuto && f.isFinished) await update($, flow, () => null)
930    }
931    return next(e)
932  })
933
934  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
935    const s = tr()
936    const ui = $.ui.resolve(e)
937    const { Box, Text, Button } = ui
938    const list = await read($, agents)
939    const f = await read($, flow)
940    const p: Panel = await read($, panel)
941    const at = Math.max(await $.clock.now(), ...list.map(a => a.startedAt), 0)
942
943    const running = list.filter(a => a.status === 'running').reverse()
944    const finished = list.filter(a => a.status !== 'running').reverse()
945    const planned = plannedOf(f, list)
946    const t = totals(list, at)
947    // The pane's title says "Agents"; inside, only the flow's own name.
948    const title = f && !f.isFinished ? f.title : ''
949
950    const toggleCompact = (
951      <Button
952        key="compact"
953        label={p.isCompact ? s.expand : s.collapse}
954        plain
955        onPress={() => update($, panel, prev => ({ ...prev, isCompact: !prev.isCompact }))}
956      />
957    )
958    const toggleDone = (
959      <Button
960        key="done"
961        label={`${p.isDoneCollapsed ? '▸' : '▾'} ${s.finished} · ${finished.length}`}
962        plain
963        onPress={() => update($, panel, prev => ({ ...prev, isDoneCollapsed: !prev.isDoneCollapsed }))}
964      />
965    )
966    const isEmpty = list.length === 0 && planned.length === 0
967    const summary = `≈${fmtCost(t.cost)}, ${fmtTokens(t.tokens)} ${s.tokensWord}, ${fmtTime(t.time)}`
968
969    if (e.surface === 'desktop' && 'Svg' in ui) {
970      const { Svg } = ui
971      const W = Math.max(240, Math.min(900, (e.props.bodyColumns || 40) * 8 - 8))
972      const section = (key: string, text: string) => (
973        <Text key={key} dimColor>
974          {text}
975        </Text>
976      )
977
978      if (p.isCompact) {
979        return (
980          <Box flexDirection="column" gap={1}>
981            <Svg source={compactSvg(W, list, planned, t)} alt={`${list.length} ${s.agentsCount}, ${summary}`} width={W} height={32} />
982            {toggleCompact}
983          </Box>
984        )
985      }
986      return (
987        <Box flexDirection="column">
988          <Svg source={headerSvg(W, title, t)} alt={title ? `${title}: ${summary}` : summary} width={W} height={headerHeight(title)} />
989          {toggleCompact}
990          {isEmpty && <Text dimColor>{s.empty}</Text>}
991          {running.length > 0 && section('h-run', `${s.running} · ${running.length}`)}
992          {running.map(a => (
993            <Svg key={a.id} source={agentSvg(W, a, at)} alt={`${a.description}: ${modelName(a.model)}, ${s.isRunning}`} width={W} height={66} />
994          ))}
995          {finished.length > 0 && toggleDone}
996          {!p.isDoneCollapsed &&
997            finished.map(a => (
998              <Svg key={a.id} source={agentSvg(W, a, at)} alt={`${a.description}: ${modelName(a.model)}, ${s.isFinished}`} width={W} height={66} />
999            ))}
1000          {planned.length > 0 && section('h-plan', `${s.planned} · ${planned.length}`)}
1001          {planned.map(pl => (
1002            <Svg key={`plan-${pl.n}`} source={plannedSvg(W, pl)} alt={`${pl.n}. ${pl.title}: ${s.isPlanned}`} width={W} height={46} />
1003          ))}
1004        </Box>
1005      )
1006    }
1007
1008    // Terminal: the same content in text rows.
1009    const cols = Math.max(24, e.props.bodyColumns || 40)
1010    const barW = Math.max(6, Math.min(20, cols - 34))
1011    const row = (a: AgentRun) => {
1012      const tier = tierOf(a.type)
1013      const color = colorOf(tier)
1014      const ctx = ctxOf(a)
1015      const progress = progressOf(a)
1016      const model = a.effort ? `${modelName(a.model)} · ${a.effort}` : modelName(a.model)
1017      const steps = a.stepTotal ? `${a.stepDone ?? 0}/${a.stepTotal}${a.stepNote ? ' ' + a.stepNote : ''} · ` : ''
1018      return (
1019        <Box key={a.id} flexDirection="column" marginBottom={1}>
1020          <Box flexDirection="row" gap={1}>
1021            <Text color={color}>▣</Text>
1022            <Text bold wrap="truncate-end">
1023              {a.description || a.type}
1024            </Text>
1025            <Text color={a.status === 'failed' ? 'red' : a.status === 'done' ? 'green' : color}>{STATUS_GLYPH[a.status]}</Text>
1026          </Box>
1027          <Text dimColor wrap="truncate-end">
1028            {'  '}
1029            {tier === 'other' ? a.type : tier} · {model}
1030            {a.round > 1 ? ` · ${s.round} ${a.round}` : ''}
1031          </Text>
1032          <Text wrap="truncate-end">
1033            {'  '}
1034            {progress === null ? <Text dimColor>{ctxBar(ctx, barW)}</Text> : <Text color={color}>{ctxBar(progress * 100, barW)}</Text>}
1035            <Text dimColor>
1036              {' '}
1037              {steps}ctx {ctx}% · {fmtTokens(a.contextTokens)} ≈{fmtCost(a.costUsd)} {fmtTime(elapsed(a, at))}
1038            </Text>
1039          </Text>
1040        </Box>
1041      )
1042    }
1043
1044    return (
1045      <Box flexDirection="column">
1046        <Box flexDirection="row" justifyContent="space-between">
1047          <Text bold wrap="truncate-end">
1048            {title}
1049          </Text>
1050          {toggleCompact}
1051        </Box>
1052        <Text dimColor>
1053          ≈{fmtCost(t.cost)} · {fmtTokens(t.tokens)} {s.tokensWord} · {fmtTime(t.time)}
1054        </Text>
1055        {p.isCompact ? (
1056          <Text wrap="truncate-end">
1057            {[...running, ...finished].map(a => (
1058              <Text key={a.id} color={colorOf(tierOf(a.type))}>
1059                {STATUS_GLYPH[a.status]}{' '}
1060              </Text>
1061            ))}
1062            {planned.map(pl => (
1063              <Text key={`plan-${pl.n}`} dimColor>
1064                ◷{' '}
1065              </Text>
1066            ))}
1067          </Text>
1068        ) : (
1069          <Box flexDirection="column" marginTop={1}>
1070            {isEmpty && <Text dimColor>{s.empty}</Text>}
1071            {running.length > 0 && <Text dimColor>{s.running} · {running.length}</Text>}
1072            {running.map(row)}
1073            {finished.length > 0 && toggleDone}
1074            {!p.isDoneCollapsed && finished.map(row)}
1075            {planned.length > 0 && <Text dimColor>{s.planned} · {planned.length}</Text>}
1076            {planned.map(pl => {
1077              const tier = pl.tier in TIER_COLOR ? pl.tier : 'other'
1078              return (
1079                <Box key={`plan-${pl.n}`} flexDirection="column" marginBottom={1}>
1080                  <Text dimColor wrap="truncate-end">
1081                    <Text color={colorOf(tier)}>▢</Text> {pl.n}. {pl.title} ◷
1082                  </Text>
1083                  <Text dimColor wrap="truncate-end">
1084                    {'  '}
1085                    {tier} · {TIER_MODEL[tier] ?? ''}
1086                    {pl.after.length ? ` · ${s.after} ${pl.after.join(', ')}` : ''}
1087                  </Text>
1088                </Box>
1089              )
1090            })}
1091          </Box>
1092        )}
1093      </Box>
1094    )
1095  })
1096
1097  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1098    const f = await read($, flow)
1099    if (f === null || e.props.hasSurvey) return next(e)
1100
1101    const ui = $.ui.resolve(e)
1102    const { Box, Text, Button } = ui
1103    // Other mods' rows stay in the band; an empty Box when nothing sits beneath.
1104    const rest = await next(e).catch(() => Box({ children: [] }))
1105    const list = await read($, agents)
1106    const crew = list.length + plannedOf(f, list).length
1107    const isWorking = list.some(a => a.status === 'running')
1108    const crewButton = (
1109      <Button key="savvy-agents" label={`×${crew}`} plain onPress={() => void togglePane($)} />
1110    )
1111    const percent = `${Math.round(ratio(f) * 100)}%`
1112    const dismiss = (
1113      <Button
1114        key="savvy-dismiss"
1115        label="✕"
1116        plain
1117        role="dismiss"
1118        onPress={() => update($, flow, () => null)}
1119      />
1120    )
1121
1122    if ('Svg' in ui) {
1123      const { Svg } = ui
1124      // About 8 CSS px per reported column; the rest is the count, the dismiss
1125      // and their gaps. No floor above the slot: a row wider than it would wrap.
1126      const width = Math.max(180, Math.min(1600, (e.props.bodyColumns || 100) * 8 - 96))
1127      return (
1128        <Box flexDirection="column">
1129          {rest}
1130          <Box flexDirection="row" alignItems="center" gap={1}>
1131            <Svg source={rowSvg(f, width, isWorking)} alt={`${f.title}: ${label(f)}, ${percent}`} width={width} height={H} />
1132            {crewButton}
1133            {dismiss}
1134          </Box>
1135        </Box>
1136      )
1137    }
1138
1139    const cols = e.props.bodyColumns
1140    const titleW = Math.max(8, Math.min(30, f.title.length + 2, Math.floor(cols / 3)))
1141    const width = Math.max(6, Math.min(40, cols - titleW - 32))
1142    return (
1143      <Box flexDirection="column">
1144        {rest}
1145      <Box flexDirection="row" gap={2}>
1146        <Box width={titleW} flexShrink={0}>
1147          <Text color={f.isFinished ? DONE : ACCENT}>● </Text>
1148          <Text wrap="truncate-end">{f.title}</Text>
1149        </Box>
1150        <Text color={f.isFinished ? DONE : ACCENT}>{barText(f, width)}</Text>
1151        <Text bold>{label(f)}</Text>
1152        <Text dimColor>{percent}</Text>
1153        <Text color={CLAY}>▣</Text>
1154        {crewButton}
1155        {dismiss}
1156      </Box>
1157      </Box>
1158    )
1159  })
1160}
1161
types/index.d.ts 60 lines
1export type Phase = 'plan' | 'design' | 'delegate' | 'review' | 'close'
2
3export type PlannedTask = {
4  title: string
5  tier: string
6  after: number[]
7}
8
9export type Flow = {
10  title: string
11  total: number
12  done: number
13  running: number
14  phase: Phase
15  isFinished: boolean
16  tasks: PlannedTask[]
17  /** agent-watch: a flow started by ordinary subagents, not by savvy-flow's progress tool. */
18  isAuto?: boolean
19}
20
21export type AgentStatus = 'running' | 'done' | 'failed'
22
23export type AgentRun = {
24  id: string
25  agentId?: string
26  type: string
27  description: string
28  model: string
29  effort?: string
30  status: AgentStatus
31  startedAt: number
32  endedAt?: number
33  contextTokens: number
34  contextMax: number
35  tokens: number
36  costUsd: number
37  steps: number
38  round: number
39  /** Self-reported by the worker through the `step` tool. */
40  stepDone?: number
41  stepTotal?: number
42  stepNote?: string
43}
44
45export type Panel = {
46  isCompact: boolean
47  isDoneCollapsed: boolean
48  autoOpenedFor: string
49}
50
51declare module 'claude-code' {
52  interface PluginState {
53    'agent-watch': {
54      flow: Flow | null
55      agents: AgentRun[]
56      panel: Panel
57    }
58  }
59}
60