SLOPSHOPPER

serhan-progress

Progress bar above the prompt for /serhan, plus an agents panel (/agents-info): running, finished and planned subagents with model, task progress, context…

newpanebandguardcommandtool
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · serhan-progress
│ ┃ 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 │ ⎿ serhan-progress: Agents panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

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

serhan-progress

A Claude Code mod: a progress bar above the prompt and a live panel of subagents. Made for the serhan skill, and useful with any subagents. Derived from johnnyvizz/claude-kit (savvy-progress, MIT).

  • Progress bar: the flow's title, phase, accepted tasks out of planned, and a button with the crew size that opens the panel. It appears once something reports progress or a serhan-* worker starts.
  • Agents panel (/agents-info toggles it): running, finished and planned subagents with model, effort, task progress, context, estimated cost and time. Each tier has its own costume: expert (astronaut), investigator (detective), precise (engineer), standard (chef), quick (racer).

Tools it adds

  • mcp__serhan-progress__progress: the orchestrator reports the plan, phase and accepted tasks.
  • mcp__serhan-progress__step: a worker reports its own steps (done, total, note).

Cost is a rough estimate from token counts and a built-in per-model price table (PRICES in hooks/register.tsx), not a bill.

Settings

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

Do not run it together with the original savvy-progress mod: both register /agents-info.

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