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

A mod: a progress bar above the prompt and a live panel of subagents for the /orchestrate skill. A port of savvy-progress to orchestrate's agents and phases.
/orchestrate runs, or when an orchestrate agent starts./agents-info toggles it): a header with the estimated cost, tokens and time, then running, finished and planned subagents. Each run is drawn as the background-tasks list draws a task: the crab, the task description, the role (or agent type), model and effort, and at the right its status and elapsed time. Effort is the one the engine reported, or the role's default until it does. Each role is a crab in its own costume: the worker's wrench turns, the specialist floats in a space helmet, the explorer fences with a cutlass, the tester tosses an omelette, the bug-hunter sweeps a magnifier, and the reviewer waves the checkered flag. prefers-reduced-motion stops them./plugin install orchestrate-progress --marketplace RayenTellissy/skills
The skill and agent files stay unchanged. The mod adds its instructions to the skill as it loads:
/orchestrate expands, a "Progress bar" section is appended telling the orchestrator to report only the plan (and the full plan again on a re-plan) and the end through mcp__orchestrate-progress__progress, launching each task with an Agent description equal to its title.Everything else follows the agents. A planned task is done when its latest run (matched by description) finished without error; a run still going or failed, or no run yet, leaves it open, and runs that match no task do not count. A non-explorer launch moves Plan to Tasks, and a reviewer launched once every planned task is done moves the bar to Verify (group reviews while other pieces still run leave it on Tasks), so the bar also works when the orchestrator never reports.
Cost is a rough estimate from token counts and a built-in per-model price table (PRICES in hooks/draw.ts), not a bill.
hooks/register.tsx: the hooks (tools, command, skill and spawn hooks, usage tracking, drawing).hooks/draw.ts: pure drawing and formatting, the crab costumes included.types/index.d.ts: the $.state contract.tests/progress.test.ts: run with claude plugin test <this folder>.hooks/register.tsx 520 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRun, Flow, Panel, Phase, PlannedTask } from '../types'
5import {
6 ACCENT,
7 AGENT_H,
8 CLAY,
9 COMPACT_H,
10 DEFAULT_TITLE,
11 DONE,
12 H,
13 PLANNED_H,
14 ROLES,
15 S,
16 STATUS_GLYPH,
17 agentAlt,
18 agentMeta,
19 agentSvg,
20 allDoneOf,
21 barText,
22 colorOf,
23 compactSvg,
24 costOf,
25 costumeOf,
26 doneOf,
27 elapsed,
28 fmtCost,
29 fmtTime,
30 fmtTokens,
31 headerHeight,
32 headerSvg,
33 isRole,
34 label,
35 norm,
36 plannedModel,
37 plannedOf,
38 plannedSvg,
39 ratio,
40 roleOf,
41 rowSvg,
42 runningOf,
43 tokensOf,
44 totals,
45 windowOf,
46} from './draw'
47
48const flow = atom({ plugin: 'orchestrate-progress', key: 'flow' } as const, null)
49const agents = atom({ plugin: 'orchestrate-progress', key: 'agents' } as const, [])
50const panel = atom({ plugin: 'orchestrate-progress', key: 'panel' } as const, {
51 isCompact: false,
52 isDoneCollapsed: false,
53 autoOpenedFor: '',
54})
55const now = atom({ plugin: 'orchestrate-progress', key: 'now' } as const, 0)
56
57const TOOL = 'mcp__orchestrate-progress__progress'
58const PANE = 'orchestrate-agents'
59const PHASES: readonly Phase[] = ['plan', 'delegate', 'verify']
60
61// Appended to the orchestrate skill as it expands, so the skill itself stays
62// unchanged and runs as before without the mod.
63const ORCH_NOTE = `## Progress bar
64
65The orchestrate-progress mod draws a progress bar above the prompt and an agents panel. Keep it current with \`${TOOL}\`: load it with ToolSearch (\`select:${TOOL}\`), in the same call as the session-title tool. Call it:
66
67- Once the plan is set, before the first launch: \`title\` (the goal in a few words), \`phase: "delegate"\`, and \`tasks\`, one per Agent launch you plan (not explorers or reviewers), in launch order, each with the \`agent\` type it goes to and \`after\` listing the task numbers it waits for. Launch each with an Agent \`description\` equal to its task title, so the panel matches runs to tasks.
68- When you re-plan (a piece added, split or dropped): the full \`tasks\` list again.
69- Once, with the final report: \`finished: true\`.
70
71The rest follows the agents on their own: a task counts as done once its latest run finishes without error, and a reviewer launched once every task is done moves the bar to Verify. Fields left out keep their value. Each call is cheap and silent: make it in the same message as the launches or status note it goes with, never as a turn of its own. Skip it if ToolSearch does not find the tool.`
72
73type ProgressInput = {
74 title?: string
75 total?: number
76 phase?: Phase
77 finished?: boolean
78 tasks?: { title?: string; agent?: string; after?: number[] }[]
79}
80
81const blank = (): Flow => ({
82 title: DEFAULT_TITLE,
83 total: 0,
84 phase: 'plan',
85 isFinished: false,
86 tasks: [],
87})
88
89// A new title means a new flow, unless the flow so far had no name of its own
90// (opened by /orchestrate or a first launch before the plan was reported).
91const isNewFlow = (prev: Flow | null, input: ProgressInput): boolean => {
92 if (!prev || prev.isFinished) return true
93 const title = input.title?.trim()
94 return !!title && title !== prev.title && prev.title !== DEFAULT_TITLE
95}
96
97const cleanTasks = (tasks: ProgressInput['tasks']): PlannedTask[] | undefined =>
98 tasks
99 ?.filter(t => t.title?.trim())
100 .map(t => ({
101 title: (t.title ?? '').trim(),
102 agent: (t.agent ?? '').replace(/^[^:]*:/, '').trim().toLowerCase(),
103 after: (t.after ?? []).filter(n => Number.isInteger(n) && n > 0),
104 }))
105
106const merge = (prev: Flow | null, input: ProgressInput): Flow => {
107 // Never carry counters over from an earlier flow.
108 const base = isNewFlow(prev, input) || !prev ? blank() : prev
109 const tasks = cleanTasks(input.tasks) ?? base.tasks
110 const total = Math.max(0, Math.round(input.total ?? (input.tasks ? tasks.length : base.total)))
111 const phase = input.phase && PHASES.includes(input.phase) ? input.phase : base.phase
112 return {
113 ...base,
114 title: input.title?.trim() || base.title,
115 total,
116 phase,
117 isFinished: input.finished === true,
118 tasks,
119 }
120}
121
122// Opens the agents pane, or closes it when it is up; true when it ends up open.
123async function togglePane($: EngineInterface): Promise<boolean> {
124 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
125 if (isOpen) {
126 await $.ui.close({ id: PANE })
127 return false
128 }
129 const at = await $.clock.now()
130 await update($, now, () => at)
131 await $.ui.open({ id: PANE, title: S.pane })
132 return true
133}
134
135// Once per flow, so closing the pane sticks until the next flow.
136async function autoOpen($: EngineInterface, key: string): Promise<void> {
137 const p = await read($, panel)
138 if (p.autoOpenedFor === key) return
139 await update($, panel, prev => ({ ...prev, autoOpenedFor: key }))
140 void $.ui.open({ id: PANE, title: S.pane })
141}
142
143export const register: Register = on => {
144 on('session.start', async ($, e, next) => {
145 const started = await next(e)
146 await $.tool.register({
147 name: 'progress',
148 description:
149 'Report /orchestrate progress to the progress bar above the prompt and the agents panel. ' +
150 'Call it once the plan is set (title, tasks, phase "delegate"), on re-plans (tasks), and once at the end with finished: true. ' +
151 'Done tasks and the verify phase follow the agents on their own: a task is done once its latest run finishes without error, and a reviewer launched once every task is done moves the bar to Verify. Fields left out keep their previous value.',
152 inputSchema: {
153 type: 'object',
154 properties: {
155 title: { type: 'string', description: 'Short name of the overall goal, a few words.' },
156 total: { type: 'integer', minimum: 0, description: 'Number of planned tasks; defaults to the length of tasks.' },
157 phase: { type: 'string', enum: [...PHASES] },
158 finished: { type: 'boolean', description: 'True once the final report is given.' },
159 tasks: {
160 type: 'array',
161 description:
162 'The planned Agent launches in order, numbered from 1 (not explorers or reviewers). Each title must equal the Agent tool `description` the task will be launched with, so the panel can match runs to tasks.',
163 items: {
164 type: 'object',
165 properties: {
166 title: { type: 'string', description: 'A few words; reused verbatim as the Agent description.' },
167 agent: { type: 'string', enum: [...ROLES] },
168 after: { type: 'array', items: { type: 'integer' }, description: 'Numbers of the tasks this one waits for.' },
169 },
170 required: ['title', 'agent'],
171 },
172 },
173 },
174 },
175 })
176 await $.command.register({
177 name: 'agents-info',
178 description: 'Show or hide the panel of subagents: running, finished and planned, with role, model, effort and time',
179 })
180
181 // Ticks the running agents' clocks; quiet when nothing runs.
182 $.clock.every(1000, () => {
183 void (async () => {
184 const list = await read($, agents)
185 if (!list.some(a => a.status === 'running')) return
186 const at = await $.clock.now()
187 await update($, now, () => at)
188 })()
189 })
190 return started
191 })
192
193 on('command.run', { command: 'agents-info' }, async $ => {
194 const isOpen = await togglePane($)
195 return { text: isOpen ? S.opened : S.closed }
196 })
197
198 on('skill.prompt', async ($, e, next) => {
199 const result = await next(e)
200 if (e.skill.replace(/^[^:]*:/, '') !== 'orchestrate') return result
201 await update($, flow, prev => (prev && !prev.isFinished ? prev : blank()))
202 return { ...result, text: `${result.text}\n\n${ORCH_NOTE}` }
203 })
204
205 on('tool.call', { tool: TOOL }, async ($, e) => {
206 const input = e as unknown as ProgressInput
207 const prev = await read($, flow)
208 if (isNewFlow(prev, input) && input.title !== undefined) {
209 // A new flow starts with a clean list; agents still running stay.
210 await update($, agents, list => list.filter(a => a.status === 'running'))
211 }
212 const f = await update($, flow, p => merge(p, input))
213 if (f && input.tasks?.length) await autoOpen($, f.title)
214 const list = await read($, agents)
215 return { result: `ok: ${f ? label(f, runningOf(f, list), doneOf(f, list)) : S.plan}` }
216 }).catch(() => ({ result: 'ok' }))
217
218 // Fails open: a fault here never stops a launch, nor launches twice.
219 on('agent.spawn', async ($, e, next) => {
220 const role = roleOf(e.subagentType)
221 const started = await next(e)
222 if (started.deny !== undefined) return started
223
224 const at = await $.clock.now()
225 await update($, agents, list => {
226 const round = 1 + list.filter(a => e.description && norm(a.description) === norm(e.description)).length
227 const run: AgentRun = {
228 id: started.agentId ?? e.tool_use_id,
229 agentId: started.agentId,
230 type: e.subagentType,
231 description: e.description,
232 model: started.model,
233 status: 'running',
234 startedAt: at,
235 contextTokens: 0,
236 contextMax: windowOf(started.model),
237 tokens: 0,
238 costUsd: 0,
239 steps: 0,
240 round,
241 }
242 return [...list.filter(a => a.id !== run.id), run].slice(-200)
243 })
244 await update($, now, () => at)
245
246 // Safety net: an orchestrate launch shows the bar even when the orchestrator
247 // has not reported, and moves its phase along.
248 if (role !== 'other') {
249 const list = await read($, agents)
250 const f = await update($, flow, prev => {
251 const base = prev && !prev.isFinished ? prev : blank()
252 // Reviewers also check groups of pieces while the rest still run; only one
253 // launched once every planned task is done starts Verify.
254 const phase: Phase =
255 role === 'reviewer' && allDoneOf(base, list)
256 ? 'verify'
257 : role !== 'explorer' && base.phase === 'plan'
258 ? 'delegate'
259 : base.phase
260 return { ...base, phase }
261 })
262 await autoOpen($, f?.title ?? DEFAULT_TITLE)
263 }
264 return started
265 }).catch(($, e, next) => next(e))
266
267 // Each model request of a subagent: live context, tokens and cost.
268 on('turn.step', async function* ($, e, next) {
269 const result = yield* next(e)
270 const agentId = e.agentId
271 const usage = result.usage
272 if (!agentId || !usage) return result
273
274 const model = usage.model || e.model
275 await update($, agents, list =>
276 list.map(
277 (a): AgentRun =>
278 a.agentId !== agentId
279 ? a
280 : {
281 ...a,
282 model,
283 effort: typeof e.effort === 'string' ? e.effort : a.effort,
284 status: 'running',
285 endedAt: undefined,
286 contextTokens: tokensOf(usage),
287 contextMax: windowOf(model),
288 tokens: a.tokens + tokensOf(usage),
289 costUsd: a.costUsd + costOf(model, usage),
290 steps: a.steps + 1,
291 },
292 ),
293 )
294 return result
295 })
296
297 on('turn.complete', async ($, e, next) => {
298 const agentId = e.agentId
299 if (agentId) {
300 const at = await $.clock.now()
301 const usage = e.usage
302 await update($, agents, list =>
303 list.map((a): AgentRun => {
304 if (a.agentId !== agentId) return a
305 // A run whose steps went unseen still gets the turn's own sum.
306 const fallback =
307 a.steps === 0 && usage
308 ? { model: usage.model || a.model, tokens: tokensOf(usage), costUsd: costOf(usage.model || a.model, usage) }
309 : {}
310 return { ...a, status: e.reason === 'answer' ? 'done' : 'failed', endedAt: at, ...fallback }
311 }),
312 )
313 await update($, now, () => at)
314 }
315 return next(e)
316 })
317
318 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
319 const ui = $.ui.resolve(e)
320 const { Box, Text, Button } = ui
321 const list = await read($, agents)
322 const f = await read($, flow)
323 const p: Panel = await read($, panel)
324 const at = Math.max(await read($, now), ...list.map(a => a.startedAt), 0)
325
326 const running = list.filter(a => a.status === 'running').reverse()
327 const finished = list.filter(a => a.status !== 'running').reverse()
328 const planned = plannedOf(f, list)
329 const t = totals(list, at)
330 // The pane's title says "Agents"; inside, only the flow's own name.
331 const title = f && !f.isFinished ? f.title : ''
332
333 const toggleCompact = (
334 <Button
335 key="compact"
336 label={p.isCompact ? S.expand : S.collapse}
337 plain
338 onPress={() => update($, panel, prev => ({ ...prev, isCompact: !prev.isCompact }))}
339 />
340 )
341 const toggleDone = (
342 <Button
343 key="done"
344 label={`${p.isDoneCollapsed ? '▸' : '▾'} ${S.finished} · ${finished.length}`}
345 plain
346 onPress={() => update($, panel, prev => ({ ...prev, isDoneCollapsed: !prev.isDoneCollapsed }))}
347 />
348 )
349 const isEmpty = list.length === 0 && planned.length === 0
350 const summary = `≈${fmtCost(t.cost)}, ${fmtTokens(t.tokens)} ${S.tokensWord}, ${fmtTime(t.time)}`
351
352 if (e.surface === 'desktop' && 'Svg' in ui) {
353 const { Svg } = ui
354 const W = Math.max(240, Math.min(900, (e.props.bodyColumns || 40) * 8 - 8))
355 // A gap above each section, so its heading clears the divider of the row above.
356 const section = (key: string, text: string) => (
357 <Box key={key} marginTop={1}>
358 <Text dimColor>{text}</Text>
359 </Box>
360 )
361
362 if (p.isCompact) {
363 return (
364 <Box flexDirection="column" gap={1}>
365 <Svg source={compactSvg(W, list, planned, t)} alt={`${list.length} ${S.agentsCount}, ${summary}`} width={W} height={COMPACT_H} />
366 {toggleCompact}
367 </Box>
368 )
369 }
370 return (
371 <Box flexDirection="column">
372 <Svg source={headerSvg(W, title, t)} alt={title ? `${title}: ${summary}` : summary} width={W} height={headerHeight(title)} />
373 {toggleCompact}
374 {isEmpty && <Text dimColor>{S.empty}</Text>}
375 {running.length > 0 && section('h-run', `${S.running} · ${running.length}`)}
376 {running.map(a => (
377 <Svg key={a.id} source={agentSvg(W, a, at)} alt={agentAlt(a)} width={W} height={AGENT_H} />
378 ))}
379 {finished.length > 0 && (
380 <Box key="h-done" marginTop={1}>
381 {toggleDone}
382 </Box>
383 )}
384 {!p.isDoneCollapsed &&
385 finished.map(a => (
386 <Svg key={a.id} source={agentSvg(W, a, at)} alt={agentAlt(a)} width={W} height={AGENT_H} />
387 ))}
388 {planned.length > 0 && section('h-plan', `${S.planned} · ${planned.length}`)}
389 {planned.map(pl => (
390 <Svg key={`plan-${pl.n}`} source={plannedSvg(W, pl)} alt={`${pl.n}. ${pl.title}: ${S.isPlanned}`} width={W} height={PLANNED_H} />
391 ))}
392 </Box>
393 )
394 }
395
396 // Terminal: the same content in text rows.
397 const row = (a: AgentRun) => {
398 const role = roleOf(a.type)
399 const color = colorOf(costumeOf(a.type))
400 const meta = [role === 'other' ? a.type : role, ...agentMeta(a).filter(m => m !== S.failed), fmtTime(elapsed(a, at))]
401 return (
402 <Box key={a.id} flexDirection="column" marginBottom={1}>
403 <Box flexDirection="row" gap={1}>
404 <Text color={color}>▣</Text>
405 <Text bold wrap="truncate-end">
406 {a.description || a.type}
407 </Text>
408 <Text color={a.status === 'failed' ? 'red' : a.status === 'done' ? 'green' : color}>{STATUS_GLYPH[a.status]}</Text>
409 </Box>
410 <Text dimColor wrap="truncate-end">
411 {' '}
412 {meta.join(' · ')}
413 </Text>
414 </Box>
415 )
416 }
417
418 return (
419 <Box flexDirection="column">
420 <Box flexDirection="row" justifyContent="space-between">
421 <Text bold wrap="truncate-end">
422 {title}
423 </Text>
424 {toggleCompact}
425 </Box>
426 <Text dimColor>
427 ≈{fmtCost(t.cost)} · {fmtTokens(t.tokens)} {S.tokensWord} · {fmtTime(t.time)}
428 </Text>
429 {p.isCompact ? (
430 <Text wrap="truncate-end">
431 {[...running, ...finished].map(a => (
432 <Text key={a.id} color={colorOf(costumeOf(a.type))}>
433 {STATUS_GLYPH[a.status]}{' '}
434 </Text>
435 ))}
436 {planned.map(pl => (
437 <Text key={`plan-${pl.n}`} dimColor>
438 ◷{' '}
439 </Text>
440 ))}
441 </Text>
442 ) : (
443 <Box flexDirection="column" marginTop={1}>
444 {isEmpty && <Text dimColor>{S.empty}</Text>}
445 {running.length > 0 && <Text dimColor>{S.running} · {running.length}</Text>}
446 {running.map(row)}
447 {finished.length > 0 && toggleDone}
448 {!p.isDoneCollapsed && finished.map(row)}
449 {planned.length > 0 && <Text dimColor>{S.planned} · {planned.length}</Text>}
450 {planned.map(pl => {
451 const role = isRole(pl.agent) ? pl.agent : 'other'
452 return (
453 <Box key={`plan-${pl.n}`} flexDirection="column" marginBottom={1}>
454 <Text dimColor wrap="truncate-end">
455 <Text color={colorOf(role)}>▢</Text> {pl.n}. {pl.title} ◷
456 </Text>
457 <Text dimColor wrap="truncate-end">
458 {' '}
459 {role === 'other' ? pl.agent || role : role} · {plannedModel(role)}
460 {pl.after.length ? ` · ${S.after} ${pl.after.join(', ')}` : ''}
461 </Text>
462 </Box>
463 )
464 })}
465 </Box>
466 )}
467 </Box>
468 )
469 })
470
471 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
472 const f = await read($, flow)
473 if (f === null || e.props.hasSurvey) return next(e)
474
475 const ui = $.ui.resolve(e)
476 const { Box, Text, Button } = ui
477 const list = await read($, agents)
478 const running = runningOf(f, list)
479 const done = doneOf(f, list)
480 const crew = list.length + plannedOf(f, list).length
481 const isWorking = list.some(a => a.status === 'running')
482 const crewButton = <Button key="crew" label={`×${crew}`} plain onPress={() => void togglePane($)} />
483 const percent = `${Math.round(ratio(f, done) * 100)}%`
484 const dismiss = <Button key="dismiss" label="✕" plain role="dismiss" onPress={() => update($, flow, () => null)} />
485
486 // The terminal's table answers `'Svg' in ui` too, then draws it as an empty box.
487 if (e.surface !== 'terminal' && 'Svg' in ui) {
488 const { Svg } = ui
489 // About 8 CSS px per reported column; the rest is the count, the dismiss
490 // and their gaps. No floor above the slot: a row wider than it would wrap.
491 const width = Math.max(180, Math.min(1600, (e.props.bodyColumns || 100) * 8 - 96))
492 return (
493 <Box flexDirection="row" alignItems="center" gap={1}>
494 <Svg source={rowSvg(f, running, done, width, isWorking)} alt={`${f.title}: ${label(f, running, done)}, ${percent}`} width={width} height={H} />
495 {crewButton}
496 {dismiss}
497 </Box>
498 )
499 }
500
501 const cols = e.props.bodyColumns
502 const titleW = Math.max(8, Math.min(30, f.title.length + 2, Math.floor(cols / 3)))
503 const width = Math.max(6, Math.min(40, cols - titleW - 32))
504 return (
505 <Box flexDirection="row" gap={2}>
506 <Box width={titleW} flexShrink={0}>
507 <Text color={f.isFinished ? DONE : ACCENT}>● </Text>
508 <Text wrap="truncate-end">{f.title}</Text>
509 </Box>
510 <Text color={f.isFinished ? DONE : ACCENT}>{barText(f, done, width)}</Text>
511 <Text bold>{label(f, running, done)}</Text>
512 <Text dimColor>{percent}</Text>
513 <Text color={CLAY}>▣</Text>
514 {crewButton}
515 {dismiss}
516 </Box>
517 )
518 })
519}
520hooks/draw.ts 561 lines1// Pure drawing and formatting: no `$`, so a preview script can render it too.
2import type { AgentRun, Flow, PlannedTask } from '../types'
3
4export const ACCENT = '#8f8cf4'
5export const DONE = '#5fbf8f'
6export const DEFAULT_TITLE = 'orchestrate'
7
8export const S = {
9 pane: 'Agents',
10 cost: 'Cost',
11 tokens: 'Tokens',
12 time: 'Time',
13 collapse: 'Collapse',
14 expand: 'Expand',
15 running: 'Running',
16 finished: 'Finished',
17 planned: 'Planned',
18 empty: 'No subagents yet.',
19 round: 'round',
20 failed: 'error',
21 after: 'after',
22 tokensWord: 'tokens',
23 agentsCount: 'agents',
24 isRunning: 'running',
25 isFinished: 'finished',
26 isFailed: 'failed',
27 isPlanned: 'planned',
28 opened: 'Agents panel opened.',
29 closed: 'Agents panel closed.',
30 done: 'Done',
31 plan: 'Plan',
32 tasks: 'Tasks',
33 verify: 'Verify',
34 busy: 'running',
35} as const
36
37// ---------------------------------------------------------------------------
38// The orchestrate skill's agent types. Effort mirrors each agent's frontmatter
39// (all pinned to Opus 5.5), shown for planned tasks and for runs whose effort
40// the engine has not reported yet.
41
42export const ROLES = ['worker', 'specialist', 'explorer', 'explainer', 'tester', 'bug-hunter', 'reviewer'] as const
43export type Role = (typeof ROLES)[number]
44
45const ROLE_COLOR: Record<Role | 'other', string> = {
46 worker: '#378ADD',
47 specialist: '#7F77DD',
48 explorer: '#1D9E75',
49 explainer: '#639922',
50 tester: '#C9487F',
51 'bug-hunter': '#D85A30',
52 reviewer: '#BA7517',
53 other: '#888780',
54}
55
56const ROLE_EFFORT: Record<Role, string> = {
57 worker: 'medium',
58 specialist: 'high',
59 explorer: 'low',
60 explainer: 'xhigh',
61 tester: 'high',
62 'bug-hunter': 'xhigh',
63 reviewer: 'xhigh',
64}
65
66export const isRole = (s: string): s is Role => (ROLES as readonly string[]).includes(s)
67
68// `worker`, or `<plugin>:worker` when the agents ship in a plugin.
69export const roleOf = (type: string): Role | 'other' => {
70 const bare = type.replace(/^[^:]*:/, '').toLowerCase()
71 return isRole(bare) ? bare : 'other'
72}
73
74export const colorOf = (role: string): string => ROLE_COLOR[isRole(role) ? role : 'other']
75
76export const plannedModel = (role: string): string => (isRole(role) ? `Opus 5.5 · ${ROLE_EFFORT[role]}` : '')
77
78// ---------------------------------------------------------------------------
79// Cost: USD per million tokens, input, output, cache read, cache write (5-minute TTL).
80// The engine reports tokens, not money, so the panel's cost is an estimate.
81
82const PRICES: [RegExp, [number, number, number, number]][] = [
83 [/fable|mythos/, [10, 50, 0.25, 12.5]],
84 [/opus-5-5/, [4, 20, 0.2, 5]],
85 [/opus/, [5, 25, 0.5, 6.25]],
86 [/sonnet/, [2, 10, 0.2, 2.5]],
87 [/haiku/, [1, 5, 0.1, 1.25]],
88]
89
90export type Usage = {
91 input_tokens: number
92 output_tokens: number
93 cache_read_input_tokens: number
94 cache_creation_input_tokens: number
95}
96
97const priceOf = (model: string): [number, number, number, number] =>
98 PRICES.find(([re]) => re.test(model.toLowerCase()))?.[1] ?? [4, 20, 0.2, 5]
99
100export const costOf = (model: string, u: Usage): number => {
101 const [i, o, r, w] = priceOf(model)
102 return (
103 ((u.input_tokens || 0) * i +
104 (u.output_tokens || 0) * o +
105 (u.cache_read_input_tokens || 0) * r +
106 (u.cache_creation_input_tokens || 0) * w) /
107 1e6
108 )
109}
110
111export const tokensOf = (u: Usage): number =>
112 (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
113
114export const windowOf = (model: string): number => (/haiku/i.test(model) ? 200_000 : 1_000_000)
115
116export const modelName = (id: string): string => {
117 const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
118 const [, family = '', major = '', minor] = m ?? []
119 if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
120 return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
121}
122
123export const norm = (s: string): string => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
124
125export const fmtTokens = (n: number): string =>
126 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
127
128export const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
129
130export const fmtTime = (ms: number): string => {
131 const s = Math.max(0, Math.round(ms / 1000))
132 const h = Math.floor(s / 3600)
133 const m = Math.floor((s % 3600) / 60)
134 const ss = String(s % 60).padStart(2, '0')
135 return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
136}
137
138export const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
139
140// ---------------------------------------------------------------------------
141// The flow: the plan the orchestrator reported, and what is running against it.
142
143export type Planned = PlannedTask & { n: number }
144
145export const plannedOf = (f: Flow | null, list: AgentRun[]): Planned[] => {
146 if (!f || f.isFinished) return []
147 const started = new Set(list.map(a => norm(a.description)))
148 return f.tasks.map((t, i) => ({ ...t, n: i + 1 })).filter(t => !started.has(norm(t.title)))
149}
150
151// Pieces handed out and still running. Counted from the runs, not from Agent
152// calls in flight: orchestrate's agents run in the background.
153export const runningOf = (f: Flow, list: AgentRun[]): number => {
154 const live = list.filter(a => a.status === 'running' && roleOf(a.type) !== 'other')
155 if (!f.tasks.length) return live.length
156 const titles = new Set(f.tasks.map(t => norm(t.title)))
157 return live.filter(a => titles.has(norm(a.description))).length
158}
159
160// Planned tasks whose latest run (by title, in launch order) finished well. A
161// task whose latest run is still going or failed, or that never ran, is not done.
162const doneTasks = (f: Flow, list: AgentRun[]): number => {
163 const latest = new Map<string, AgentRun>()
164 for (const a of list) latest.set(norm(a.description), a)
165 return f.tasks.filter(t => latest.get(norm(t.title))?.status === 'done').length
166}
167
168export const doneOf = (f: Flow, list: AgentRun[]): number => {
169 const done = doneTasks(f, list)
170 return f.total ? Math.min(f.total, done) : done
171}
172
173export const allDoneOf = (f: Flow, list: AgentRun[]): boolean => doneTasks(f, list) === f.tasks.length
174
175export const label = (f: Flow, running: number, done: number): string => {
176 if (f.isFinished) return S.done
177 if (f.phase === 'plan') return S.plan
178 if (f.phase === 'verify') return S.verify
179 return `${S.tasks} ${f.total ? `${done}/${f.total}` : `${running} ${S.busy}`}`
180}
181
182export const ratio = (f: Flow, done: number): number => (f.isFinished ? 1 : f.total ? done / f.total : 0)
183
184export const totals = (list: AgentRun[], at: number) => {
185 const cost = list.reduce((s, a) => s + a.costUsd, 0)
186 const tokens = list.reduce((s, a) => s + a.tokens, 0)
187 const start = Math.min(...list.map(a => a.startedAt))
188 const end = Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt)))
189 return { cost, tokens, time: list.length ? end - start : 0 }
190}
191export type Totals = ReturnType<typeof totals>
192
193// The effort the engine reported; until then an orchestrate role's default.
194export const effortOf = (a: AgentRun): string | undefined => {
195 if (a.effort) return a.effort
196 const role = roleOf(a.type)
197 return role === 'other' ? undefined : ROLE_EFFORT[role]
198}
199
200// The row's second line: model, effort, then round and error when they apply.
201export const agentMeta = (a: AgentRun): string[] => {
202 const effort = effortOf(a)
203 const meta = [effort ? `${modelName(a.model)} · ${effort}` : modelName(a.model)]
204 if (a.round > 1) meta.push(`${S.round} ${a.round}`)
205 if (a.status === 'failed') meta.push(S.failed)
206 return meta
207}
208
209export const agentAlt = (a: AgentRun): string => {
210 const role = roleOf(a.type)
211 const effort = effortOf(a)
212 const status = a.status === 'running' ? S.isRunning : a.status === 'done' ? S.isFinished : S.isFailed
213 return `${a.description || a.type}: ${role === 'other' ? a.type : role}, ${modelName(a.model)}${effort ? `, ${effort}` : ''}, ${status}`
214}
215
216// ---------------------------------------------------------------------------
217// Text measuring for SVG rows.
218
219export const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
220
221const xml = (s: string): string =>
222 s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
223
224// Rough advance of system UI text, in em; good enough to size the title's slot.
225const charEm = (ch: string): number => (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Zmw@%]/.test(ch) ? 0.72 : 0.56)
226
227const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
228
229// Cuts `s` to fit `maxW` pixels, with an ellipsis when it had to cut.
230const fitText = (s: string, size: number, maxW: number): string => {
231 if (textWidth(s, size) <= maxW) return s
232 let out = ''
233 for (const ch of s) {
234 if (textWidth(out + ch + '…', size) > maxW) break
235 out += ch
236 }
237 return out + '…'
238}
239
240// Deterministic noise so the dither does not shimmer between redraws.
241const noise = (x: number, y: number): number => {
242 const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
243 return s - Math.floor(s)
244}
245
246// ---------------------------------------------------------------------------
247// Pixel Clawd: a 24×18 crab on a 30×28 grid, one costume per orchestrate role.
248// The body keeps the brand clay; the role's color lives in the costume's accent.
249
250export const CLAY = '#D97757'
251const INK = '#1F1E1D'
252
253// `cls` puts a pixel in a named group: `bd` (the default) is the body and its
254// costume, `la`/`lb` the leg pairs, anything else a prop with its own motion.
255type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
256
257const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
258 rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
259
260// `armCls` lets a raised claw travel with the prop it holds.
261const crabBody = (f: Fill, armFront = 0, armCls?: string): void => {
262 f(7, 10, 16, 12, CLAY)
263 f(3, 14, 4, 4, CLAY)
264 f(23, 14 + armFront, 4, 4, CLAY, armCls)
265 f(9, 12, 2, 2, INK)
266 f(19, 12, 2, 2, INK)
267 f(7, 22, 2, 4, CLAY, 'la')
268 f(17, 22, 2, 4, CLAY, 'la')
269 f(11, 22, 2, 4, CLAY, 'lb')
270 f(21, 22, 2, 4, CLAY, 'lb')
271}
272
273// Pure CSS, run by the compositor: no redraws. Periods divide one second, so the
274// once-a-second redraw of a running row restarts them in phase. Every crab walks;
275// each costume adds its prop's own motion on top.
276export const CRAB_CSS = `<style>
277.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
278.run .bd{animation:bob .5s steps(1) infinite -.125s}
279.run g{transform-box:fill-box}
280@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
281@keyframes blink{50%{opacity:.15}}
282.c-specialist.run{animation:float 1s ease-in-out infinite}
283.c-specialist.run .la,.c-specialist.run .lb,.c-specialist.run .bd{animation:none}
284.c-specialist.run .ant{animation:blink 1s steps(1) infinite}
285.c-specialist.run .star{animation:blink .5s steps(1) infinite -.25s}
286@keyframes float{50%{transform:translateY(-2px)}}
287.c-bug-hunter.run .it{animation:scan 1s steps(1) infinite}
288.c-bug-hunter.run .gl{animation:blink 1s steps(1) infinite -.5s}
289@keyframes scan{25%{transform:translate(-1px,1px)}50%{transform:translate(-2px,2px)}75%{transform:translate(-1px,1px)}}
290.c-worker.run .it{transform-origin:100% 100%;animation:twist .5s ease-in-out infinite}
291@keyframes twist{50%{transform:rotate(-35deg)}}
292.c-tester.run .pan{transform-origin:0 50%;animation:tilt 1s ease-in-out infinite}
293.c-tester.run .egg{animation:flip 1s ease-in-out infinite}
294@keyframes tilt{20%,40%{transform:rotate(-12deg)}}@keyframes flip{30%{transform:translateY(-5px) scaleY(-1)}60%{transform:translateY(0)}}
295.c-reviewer.run .la{animation-duration:.25s}.c-reviewer.run .lb{animation-duration:.25s;animation-delay:-.125s}
296.c-reviewer.run .flag{transform-origin:0 50%;animation:wave .25s steps(1) infinite}
297@keyframes wave{50%{transform:skewY(-12deg) scaleX(.85)}}
298.c-explorer.run .it{transform-origin:50% 100%;animation:fence .5s ease-in-out infinite}
299@keyframes fence{50%{transform:rotate(25deg)}}
300@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}
301</style>`
302
303const COSTUMES: Record<string, (f: Fill, t: string) => void> = {
304 // Specialist: astronaut in a glass dome; floats instead of walking, the antenna and the star blink.
305 specialist: (f, t) => {
306 crabBody(f)
307 f(6, 7, 18, 1, '#E6E8EE'); f(5, 8, 1, 14, '#E6E8EE'); f(24, 8, 1, 14, '#E6E8EE'); f(6, 22, 18, 1, '#C9CCD2')
308 f(6, 8, 18, 14, 'rgba(169,214,245,.32)'); f(8, 9, 2, 1, '#fff'); f(8, 10, 1, 2, '#fff')
309 f(14, 4, 2, 3, '#C9CCD2'); f(14, 2, 2, 2, t, 'ant'); f(13, 18, 4, 2, t)
310 f(27, 3, 1, 3, '#F5C542', 'star'); f(26, 4, 3, 1, '#F5C542', 'star')
311 },
312 // Bug-hunter: detective with a deerstalker; the magnifier sweeps and glints.
313 'bug-hunter': (f, t) => {
314 crabBody(f, -4, 'it')
315 stamp(f, 6, 3, ['......bbbbbb......', '....bbcbbcbbbb....', '...bbbbbbbbbbbb...', '..bcbbcbbcbbcbbb..', '.bbbbbbbbbbbbbbbb.', 'dddddddddddddddddd'], { b: '#7A4A26', c: '#A0703F', d: '#5A3519' })
316 f(6, 9, 18, 1, t)
317 stamp(f, 23, 1, ['.kkk.', 'k...k', 'k...k', 'k...k', '.kkk.'], { k: '#3A3A3C' }, 'it')
318 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')
319 },
320 // Worker: engineer in a hard hat; the wrench turns a bolt.
321 worker: (f, t) => {
322 crabBody(f)
323 stamp(f, 6, 4, ['.....yyyyyyyy.....', '...yyyyyhhyyyyy...', '..yyyyyyhhyyyyyy..', '..yyyyyyhhyyyyyy..', '.yyyyyyyhhyyyyyyy.', 'dddddddddddddddddd'], { y: '#F5C542', h: '#FBE08A', d: '#C99A1E' })
324 f(13, 5, 4, 2, t)
325 stamp(f, 0, 10, ['.s.s', 'sss.', '.s..', '.s..'], { s: '#8E929A' }, 'it')
326 },
327 // Tester: chef taste-testing; tosses the omelette.
328 tester: (f, t) => {
329 crabBody(f, -4, 'pan')
330 stamp(f, 6, 0, ['........lll.......', '.......lllll......', '.wwwwgwwwwwwgwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwgwwwwwggwwwww', '.wwwwgwwwwwggwwwww', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.'], { w: '#F4F3EE', l: '#F7F6F2', g: '#D2D1C8', b: t, d: '#8E3560' })
331 f(22, 8, 7, 2, '#4A4A48', 'pan'); f(26, 10, 1, 1, '#4A4A48', 'pan'); f(24, 7, 3, 1, '#F5B731', 'egg')
332 },
333 // Reviewer: racer in a helmet at the finish line; runs at double pace, the checkered flag flutters.
334 reviewer: (f, t) => {
335 crabBody(f, -4)
336 stamp(f, 6, 5, ['....rrrrrrrrrr....', '..rrrrrrwwrrrrrr..', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.kkkkkkkkkkkkkkkkr'], { r: t, w: '#F8F6F1', k: INK })
337 f(25, 1, 1, 9, '#8E929A')
338 stamp(f, 26, 1, ['wkwk', 'kwkw', 'wkwk'], { w: '#F8F6F1', k: INK }, 'flag')
339 },
340 // Explorer: pirate scouting the code; the cutlass fences.
341 explorer: f => {
342 crabBody(f)
343 stamp(f, 5, 3, ['.kk..............kk.', '.kkk....kkkk....kkk.', '..kkkkkkkwwkkkkkkk..', '..kkkkkkkkkkkkkkkk..', '.gggggggggggggggggg.'], { k: '#55514C', w: '#F8F6F1', g: '#F5C542' })
344 f(7, 11, 11, 1, INK); f(18, 11, 4, 3, INK)
345 f(27, 6, 1, 9, '#C9CCD2', 'it'); f(26, 15, 3, 1, '#7A4A26', 'it')
346 },
347 other: f => crabBody(f),
348}
349
350export const COSTUME_NAMES = Object.keys(COSTUMES)
351
352// The built-in Explore agent borrows the explorer's costume.
353export const costumeOf = (type: string): string => (type === 'Explore' ? 'explorer' : roleOf(type))
354
355const CRAB_SCALE = 1.1
356
357// Body and props nest inside `bd` so a prop rides the bob and adds its own motion;
358// legs stay outside it and step on their own.
359export const crab = (x: number, y: number, costume: string, dim = false, isWalking = false, scale = CRAB_SCALE): string => {
360 const groups = new Map<string, string[]>([['bd', []]])
361 const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
362 if (!groups.has(cls)) groups.set(cls, [])
363 groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
364 }
365 const draw = COSTUMES[costume] ?? ((g: Fill) => crabBody(g))
366 draw(f, colorOf(costume))
367 const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
368 const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
369 const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
370 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>`
371}
372
373// ---------------------------------------------------------------------------
374// The band above the prompt. The whole row is one SVG: the desktop wraps sibling
375// elements onto new lines, so title, bar, percent and the crab live in one
376// drawing; only the crew count and the dismiss are Buttons beside it.
377
378export const H = 22
379const BAR_H = 16
380const CRAB_W = 26
381const CELL = 3
382
383export const rowSvg = (f: Flow, running: number, done: number, W: number, isWorking: boolean): string => {
384 // The title takes what it needs, up to 40% of the row; the bar takes the rest.
385 const title = fitText(f.title, 13, Math.max(60, W * 0.4))
386 const BAR_X = Math.round(16 + textWidth(title, 13) + 12)
387 const BAR_W = Math.max(60, W - BAR_X - 46 - CRAB_W)
388 const color = f.isFinished ? DONE : ACCENT
389 const y0 = (H - BAR_H) / 2
390 const fillW = Math.round(BAR_W * ratio(f, done))
391 const runW = f.total ? Math.round((BAR_W * Math.min(f.total, done + running)) / f.total) : 0
392 const dots: string[] = []
393
394 // Dithered fill: sparse at the start, dense toward the head.
395 const cols = Math.floor(fillW / CELL)
396 const rows = Math.floor(BAR_H / CELL)
397 for (let c = 0; c < cols; c++) {
398 const density = 0.35 + 0.6 * Math.pow(c / Math.max(1, cols), 1.2)
399 for (let r = 0; r < rows; r++) {
400 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"/>`)
401 }
402 }
403 // Handed to agents, not yet done: a faint second layer.
404 const faint: string[] = []
405 for (let c = cols; c < Math.floor(runW / CELL); c++) {
406 for (let r = 0; r < rows; r++) {
407 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"/>`)
408 }
409 }
410
411 const ticks: string[] = []
412 for (let i = 1; i < f.total; i++) {
413 const x = Math.round((BAR_W * i) / f.total)
414 if (x > fillW + 4) ticks.push(`<rect x="${x}" y="${BAR_H / 2 - 4}" width="1.5" height="8" rx="0.75"/>`)
415 }
416
417 const text = label(f, running, done)
418 const pillW = Math.round(18 + text.length * 6.6)
419 const pillX = Math.max(0, Math.min(BAR_W - pillW, fillW - pillW))
420 const percent = `${Math.round(ratio(f, done) * 100)}%`
421
422 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
423<style>
424.t{fill:#1f1f1f}.m{fill:#8a8a8a}.k{fill:#e4e4e2}.tk{fill:#b4b4b0}
425@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}.k{fill:#2c2c2c}.tk{fill:#5a5a5a}}
426/* Pixels twinkle in four out-of-phase groups; a finished bar settles to a slow glow. */
427.t0,.t1,.t2,.t3{animation:tw ${f.isFinished ? 3.2 : 2.2}s ease-in-out infinite}
428.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}
429@keyframes tw{0%,100%{opacity:1}50%{opacity:${f.isFinished ? 0.8 : 0.3}}}
430@media (prefers-reduced-motion: reduce){.t0,.t1,.t2,.t3{animation:none}}
431</style>
432<defs><clipPath id="c"><rect x="0" y="0" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/></clipPath></defs>
433<circle cx="5" cy="${H / 2}" r="4" fill="${color}"/>
434<text class="t" x="16" y="${H / 2 + 4.5}" font-family="${FONT}" font-size="13" font-weight="500">${xml(title)}</text>
435<g transform="translate(${BAR_X},${y0})">
436<rect class="k" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/>
437<g clip-path="url(#c)">
438<g fill="${color}">${dots.join('')}</g>
439<g fill="${color}" opacity="0.45">${faint.join('')}</g>
440<g class="tk">${ticks.join('')}</g>
441</g>
442<rect x="${pillX}" width="${pillW}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${color}"/>
443<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>
444</g>
445<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>
446${CRAB_CSS}${crab(W - CRAB_W + 1, 0, 'other', false, isWorking, 0.8)}
447</svg>`
448}
449
450export const barText = (f: Flow, done: number, width: number): string => {
451 const filled = Math.round(width * ratio(f, done))
452 return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
453}
454
455// ---------------------------------------------------------------------------
456// The agents pane on the desktop: each row is one SVG, as the band is.
457
458const PANE_CSS = `<style>
459.t{fill:#1f1f1f}.s{fill:#6b6b68}.m{fill:#9a9a96}.k{fill:#ecebe8}.ln{stroke:#e4e4e1}.tile{fill:#f4f3f0}
460@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.m{fill:#7d7d79}.k{fill:#2c2c2b}.ln{stroke:#333331}.tile{fill:#262625}}
461.live{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
462@media (prefers-reduced-motion: reduce){.live{animation:none}}
463</style>`
464
465const statusMark = (x: number, y: number, status: string, color: string): string => {
466 if (status === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
467 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"/>`
468 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"/>`
469 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"/>`
470}
471
472const svg = (W: number, H: number, body: string): string =>
473 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PANE_CSS}${CRAB_CSS}${body}</svg>`
474
475export const headerHeight = (title: string): number => (title ? 72 : 44)
476
477// The pane's own title already says "Agents": the header names the flow, if any.
478export const headerSvg = (W: number, title: string, t: Totals): string => {
479 const gap = 6
480 const tw = (W - gap * 2) / 3
481 const top = title ? 28 : 0
482 const tile = (i: number, k: string, v: string) =>
483 `<rect class="tile" x="${i * (tw + gap)}" y="${top}" width="${tw}" height="40" rx="8"/>
484<text class="s" x="${i * (tw + gap) + 9}" y="${top + 16}" font-family="${FONT}" font-size="11">${k}</text>
485<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>`
486 return svg(
487 W,
488 headerHeight(title),
489 `${title ? `<text class="t" x="0" y="15" font-family="${FONT}" font-size="14" font-weight="600">${xml(fitText(title, 14, W))}</text>` : ''}
490${tile(0, S.cost, '≈' + fmtCost(t.cost))}${tile(1, S.tokens, fmtTokens(t.tokens))}${tile(2, S.time, fmtTime(t.time))}`,
491 )
492}
493
494export const PLANNED_H = 46
495export const AGENT_H = PLANNED_H
496export const COMPACT_H = 32
497
498// As the background-tasks list draws a task: the crab, the description, then
499// role, model and effort; the status and elapsed time at the right.
500export const agentSvg = (W: number, a: AgentRun, at: number): string => {
501 const role = roleOf(a.type)
502 const color = colorOf(costumeOf(a.type))
503 const time = fmtTime(elapsed(a, at))
504 const textW = W - 42 - 22
505 const roleText = role === 'other' ? a.type : role
506 const metaW = Math.max(0, W - 42 - textWidth(time, 11) - 12 - textWidth(roleText + ' ', 11))
507 return svg(
508 W,
509 AGENT_H,
510 `${crab(0, 6, costumeOf(a.type), false, a.status === 'running')}
511<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>
512<text x="42" y="34" font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(roleText)}</tspan><tspan class="s"> ${xml(fitText(agentMeta(a).join(' · '), 11, metaW))}</tspan></text>
513${statusMark(W - 8, 16, a.status, color)}
514<text class="s" x="${W}" y="34" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${time}</text>
515<line class="ln" x1="0" y1="45.5" x2="${W}" y2="45.5"/>`,
516 )
517}
518
519export const plannedSvg = (W: number, p: Planned): string => {
520 const role = isRole(p.agent) ? p.agent : 'other'
521 const color = colorOf(role)
522 const textW = W - 42 - 22
523 const meta = [plannedModel(role)]
524 if (p.after.length) meta.push(`${S.after} ${p.after.join(', ')}`)
525 return svg(
526 W,
527 PLANNED_H,
528 `${crab(0, 6, role, true)}
529<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>
530<text x="42" y="34" font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(role === 'other' ? p.agent || role : role)}</tspan><tspan class="m"> ${xml(meta.filter(Boolean).join(' · '))}</tspan></text>
531${statusMark(W - 8, 16, 'planned', color)}
532<line class="ln" x1="0" y1="45.5" x2="${W}" y2="45.5"/>`,
533 )
534}
535
536export const compactSvg = (W: number, list: AgentRun[], planned: Planned[], t: Totals): string => {
537 const icons = [
538 ...list.filter(a => a.status === 'running').map(a => ({ k: costumeOf(a.type), s: 'running', dim: false })),
539 ...list.filter(a => a.status !== 'running').map(a => ({ k: costumeOf(a.type), s: a.status, dim: false })),
540 ...planned.map(p => ({ k: isRole(p.agent) ? p.agent : 'other', s: 'planned', dim: true })),
541 ]
542 const fit = Math.max(1, Math.floor((W - 150) / 36))
543 const shown = icons.slice(0, fit)
544 const more = icons.length - shown.length
545 const body = shown
546 .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="${colorOf(ic.k)}"/>` : ''))
547 .join('')
548 const x = shown.length * 36 + (more ? 4 : 0)
549 return svg(
550 W,
551 COMPACT_H,
552 `${body}${more ? `<text class="s" x="${x}" y="21" font-family="${FONT}" font-size="12">+${more}</text>` : ''}
553<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>`,
554 )
555}
556
557// ---------------------------------------------------------------------------
558// Terminal: the same rows in text.
559
560export const STATUS_GLYPH: Record<string, string> = { running: '●', done: '✓', failed: '✗', planned: '◷' }
561types/index.d.ts 55 lines1export type Phase = 'plan' | 'delegate' | 'verify'
2
3export type PlannedTask = {
4 title: string
5 /** The orchestrate agent type the piece goes to: `worker`, `tester`, ... */
6 agent: string
7 after: number[]
8}
9
10export type Flow = {
11 title: string
12 total: number
13 phase: Phase
14 isFinished: boolean
15 tasks: PlannedTask[]
16}
17
18export type AgentStatus = 'running' | 'done' | 'failed'
19
20export type AgentRun = {
21 id: string
22 agentId?: string
23 type: string
24 description: string
25 model: string
26 /** As the engine reported it; the panel falls back to the role's default. */
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}
38
39export type Panel = {
40 isCompact: boolean
41 isDoneCollapsed: boolean
42 autoOpenedFor: string
43}
44
45declare module 'claude-code' {
46 interface PluginState {
47 'orchestrate-progress': {
48 flow: Flow | null
49 agents: AgentRun[]
50 panel: Panel
51 now: number
52 }
53 }
54}
55