SLOPSHOPPER

orchestrate-progress

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

newpanebandguardcommandprompt
★ 2v0.1.0MITupdated 2026-10-15RayenTellissy/skills/mods/orchestrate-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · orchestrate-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 │ ⎿ orchestrate-progress: Agents panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

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

orchestrate-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.

  • Progress bar: the goal's title, the phase (Plan, Tasks n/m, Verify, Done), done tasks out of planned, and a crew-size button that opens the panel. It appears as soon as /orchestrate runs, or when an orchestrate agent starts.
  • Agents panel (/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.

Install

/plugin install orchestrate-progress --marketplace RayenTellissy/skills

How it hooks into /orchestrate

The skill and agent files stay unchanged. The mod adds its instructions to the skill as it loads:

  • When /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.
  • Agent briefs reach the engine unchanged; the mod only records each launch and how it ended for the panel.

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.

Files

  • 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>.
Source 3 files
hooks/register.tsx 520 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'
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}
520
hooks/draw.ts 561 lines
1// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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: '◷' }
561
types/index.d.ts 55 lines
1export 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