A panel of your subagents with model, context, cost and time, a per-agent Steps accordion, and a crab whose costume follows the model. Fork of the agents panel…

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