Agents panel (/crew) with the main session's own row (model, effort, context, estimated cost, time) and a pixel-crab costume per workflow profile, plus the…

A Claude Code mod: a live panel of your crew (the main session plus every subagent) and a progress bar above the prompt. It is a fork of savvy-progress by johnnyvizz, MIT licensed, with three additions:
Profile: tag, and its reported steps start over. Its cost and tokens are kept, and its timer leaves out the time it sat finished.Everything else works as in savvy-progress: running, finished and planned subagents with model, effort, task progress, context, estimated cost and time; the savvy-flow tiers (their names, colours and models, drawn as the plain crab since 0.4.0); the progress bar for orchestrated flows.
Every crab the mod ships, as it looks while its agent runs (prefers-reduced-motion stops the animation). The images are generated from the drawing code; see Development.
<table> <tr><td align="center" width="25%"><img src="media/costumes/root.svg" width="128" height="128" alt="root crab"><br><b><a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/workflows/AGENTS.root.md">root</a></b><br><sub>gold crown with jewels; the jewels sparkle</sub></td><td align="center" width="25%"><img src="media/costumes/manager.svg" width="128" height="128" alt="manager crab"><br><b><a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/workflows/AGENTS.manager.md">manager</a></b><br><sub>headset with mic and a conductor's baton; the baton conducts</sub></td><td align="center" width="25%"><img src="media/costumes/explorer.svg" width="128" height="128" alt="explorer crab"><br><b><a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/profiles/AGENTS.explorer.md">explorer</a></b><br><sub>pith helmet and a lantern; the lantern swings</sub></td><td align="center" width="25%"><img src="media/costumes/implementer.svg" width="128" height="128" alt="implementer crab"><br><b><a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/profiles/AGENTS.implementer.md">implementer</a></b><br><sub>bandana and a claw hammer; the hammer strikes</sub></td></tr> <tr><td align="center" width="25%"><img src="media/costumes/documenter.svg" width="128" height="128" alt="documenter crab"><br><b><a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/profiles/AGENTS.documenter.md">documenter</a></b><br><sub>scroll and a quill; the quill writes</sub></td><td align="center" width="25%"><img src="media/costumes/tester.svg" width="128" height="128" alt="tester crab"><br><b><a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/profiles/AGENTS.tester.md">tester</a></b><br><sub>lab goggles and a flask; the bubbles rise</sub></td><td align="center" width="25%"><img src="media/costumes/validator.svg" width="128" height="128" alt="validator crab"><br><b><a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/profiles/AGENTS.validator.md">validator</a></b><br><sub>visor cap and a clipboard; the check mark blinks</sub></td><td align="center" width="25%"><img src="media/costumes/critic.svg" width="128" height="128" alt="critic crab"><br><b><a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/profiles/AGENTS.critic.md">critic</a></b><br><sub>beret and a gold monocle; the monocle glints</sub></td></tr> </table>
Domain profiles
<table> <tr><td align="center" width="25%"><img src="media/costumes/researcher.svg" width="128" height="128" alt="researcher crab"><br><b><a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/profiles/AGENTS.researcher.md">researcher</a></b><br><sub>mortarboard and an open book; the page flips</sub><br><sub>workflow: <a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/workflows/AGENTS.research.md">research</a></sub></td><td align="center" width="25%"><img src="media/costumes/debugger.svg" width="128" height="128" alt="debugger crab"><br><b><a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/profiles/AGENTS.debugger.md">debugger</a></b><br><sub>bug net and a ladybird; the net swings, the bug buzzes</sub><br><sub>workflow: <a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/workflows/AGENTS.debugging.md">debugging</a></sub></td><td align="center" width="25%"><img src="media/costumes/analyst.svg" width="128" height="128" alt="analyst crab"><br><b><a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/profiles/AGENTS.analyst.md">analyst</a></b><br><sub>tie and a bar-chart board; the bars rise</sub><br><sub>workflow: <a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/workflows/AGENTS.data-analysis.md">data-analysis</a></sub></td><td align="center" width="25%"><img src="media/costumes/architect.svg" width="128" height="128" alt="architect crab"><br><b><a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/profiles/AGENTS.architect.md">architect</a></b><br><sub>blueprint roll and a set square; the set square measures</sub><br><sub>workflow: <a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/workflows/AGENTS.architecture.md">architecture</a></sub></td></tr> <tr><td align="center" width="25%"><img src="media/costumes/auditor.svg" width="128" height="128" alt="auditor crab"><br><b><a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/profiles/AGENTS.auditor.md">auditor</a></b><br><sub>shield with a padlock; the padlock glints</sub><br><sub>workflow: <a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/workflows/AGENTS.security.md">security</a></sub></td><td align="center" width="25%"><img src="media/costumes/navigator.svg" width="128" height="128" alt="navigator crab"><br><b><a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/profiles/AGENTS.navigator.md">navigator</a></b><br><sub>browser window with a cursor; the cursor clicks a link</sub><br><sub>workflow: <a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/workflows/AGENTS.browser.md">browser</a></sub></td><td align="center" width="25%"><img src="media/costumes/inspector.svg" width="128" height="128" alt="inspector crab"><br><b><a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/profiles/AGENTS.inspector.md">inspector</a></b><br><sub>headphones and a camera; the flash fires, a note rises</sub><br><sub>workflow: <a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/workflows/AGENTS.vision.md">vision</a>, <a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/workflows/AGENTS.audio.md">audio</a>, <a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/workflows/AGENTS.video.md">video</a></sub></td><td align="center" width="25%"><img src="media/costumes/other.svg" width="128" height="128" alt="other crab"><br><b>any other agent</b><br><sub>the plain crab, labelled with its agent type or savvy-flow tier; walks</sub></td></tr> </table>
Claude Code built-in agents
The built-in subagent types wear a costume too: those that overlap a workflow profile reuse its costume, the rest have their own. The agent type, not the profile name, is shown under the title.
<table> <tr><td align="center" width="25%"><img src="media/costumes/explorer.svg" width="128" height="128" alt="explorer crab"><br><b><a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/profiles/AGENTS.explorer.md">Explore</a></b><br><sub>pith helmet and a lantern; the lantern swings</sub><br><sub>uses the <a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/profiles/AGENTS.explorer.md">explorer</a> costume</sub></td><td align="center" width="25%"><img src="media/costumes/architect.svg" width="128" height="128" alt="architect crab"><br><b><a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/profiles/AGENTS.architect.md">Plan</a></b><br><sub>blueprint roll and a set square; the set square measures</sub><br><sub>uses the <a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/profiles/AGENTS.architect.md">architect</a> costume</sub></td><td align="center" width="25%"><img src="media/costumes/researcher.svg" width="128" height="128" alt="researcher crab"><br><b><a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/profiles/AGENTS.researcher.md">web-fetch</a></b><br><sub>mortarboard and an open book; the page flips</sub><br><sub>uses the <a href="https://github.com/Densetsu152637/Agent-Custom-Workflow/blob/main/profiles/AGENTS.researcher.md">researcher</a> costume</sub></td><td align="center" width="25%"><img src="media/costumes/generalist.svg" width="128" height="128" alt="generalist crab"><br><b><a href="https://code.claude.com/docs/en/sub-agents">general-purpose</a> / <a href="https://code.claude.com/docs/en/sub-agents">claude</a></b><br><sub>beanie and a red Swiss army knife; the blade flicks</sub><br><sub>its own costume</sub></td></tr> <tr><td align="center" width="25%"><img src="media/costumes/guide.svg" width="128" height="128" alt="guide crab"><br><b><a href="https://code.claude.com/docs/en/sub-agents">claude-code-guide</a></b><br><sub>red info cap and a tour flag; the flag waves</sub><br><sub>its own costume</sub></td><td align="center" width="25%"><img src="media/costumes/statusline.svg" width="128" height="128" alt="statusline crab"><br><b><a href="https://code.claude.com/docs/en/sub-agents">statusline-setup</a></b><br><sub>paint roller and a status bar; the roller rolls</sub><br><sub>its own costume</sub></td></tr> </table>
Retired in 0.4.0: the savvy-flow tier costumes (fable astronaut, heavy detective, careful engineer, medium chef, light racer) and the built-in Explore agent's pirate. The savvy-flow agents now wear the plain crab and keep their tier word, colour and model, and the progress tools are unchanged. Since 0.5.0 the built-in agents have costumes again (see above).
At the prompt of a terminal session:
/plugin install the-crew --marketplace Densetsu152637/The-Crew
From a shell:
claude plugin install the-crew --marketplace Densetsu152637/The-Crew
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with Densetsu152637/The-Crew, then Add plugin.
the-crew uses its own names throughout (/crew, the crew-agents pane, mcp__the-crew__* tools), so it can be installed beside savvy-progress; you will usually want only one of them enabled, since both draw a panel.
/crew shows or hides the panel; /crew open opens it, or brings it forward, without toggling.autoOpen: any, the panel opens by itself once per session when a subagent starts, and whenever a flow reports a plan.| Shown | Where it comes from |
|---|---|
| Model and effort | The main loop's latest model request (turn.step); — until the first one after the mod loads. |
| Context | The engine's own measure of the live window after each main turn (session.measure, SessionContextUsage), and the request's input side between measures. |
| Input and output tokens | Each request's prompt side (uncached input plus cache reads and writes) and its output, summed separately; the panel totals show them as two figures. |
| Estimated cost | Each main-loop request's token counts priced with the same table as the subagents (PRICES in hooks/register.tsx). |
| Time | Since the session began ($.session.usage().startedAt). |
The cost is an estimate and counts only requests made while the mod is loaded: requests from before it loaded are not in it. The row is not reset by /clear (its cost, tokens and time keep counting from the first start). The engine's own ledger (/cost) also includes subagents, so it is not used for this row.
A subagent's costume is picked in this order, the first match winning:
implementer or my-plugin:implementer.Role: leaf; Profile: implementer anywhere in it (the brief tag convention); the first Profile: counts.Explore (or explore) draws as explorer, Plan (plan) as architect, web-fetch as researcher, general-purpose and claude as generalist, claude-code-guide as guide and statusline-setup as statusline. The label under the title stays the agent type (e.g. Explore), so a built-in is not mistaken for the profile it borrows.savvy-careful shows careful) or else its agent type.The profiles are those of Agent-Custom-Workflow: manager; the generic explorer, implementer, validator, critic, documenter and tester; and the domain profiles researcher, debugger, analyst, architect, auditor, navigator and inspector. The costume gallery shows each one and links its profile and workflow files.
The profile's name appears under the agent's title, where savvy-flow agents show their tier. prefers-reduced-motion stops every animation.
mcp__the-crew__progress: an orchestrator reports the plan, phase and accepted tasks for the progress bar. Only the main session can; a call from a subagent is ignored.mcp__the-crew__step: a subagent reports its own steps (done, total, note). A worker that does not report shows its context fill in grey instead. savvy-* workers get one line at the end of their brief asking for these reports.Costs are rough estimates from token counts and list prices (as of 2026-10-10), not a bill. Cache writes are priced at the 1-hour rate.
autoOpen: any (default) or savvy. With any the panel also opens, once per session, when any subagent starts; with savvy only for savvy-flow workers and reported plans.
openAtStart: on (default) or off. With on the panel opens when a session starts (once per session, so closing it stays closed); with off it opens only per autoOpen or /crew.
Set them in /config, or in ~/.claude/settings.json:
{ "pluginConfigs": { "the-crew": { "options": { "autoOpen": "any", "openAtStart": "on" } } } }
See CHANGELOG.md. From 1.0.0 the public surface (tool schemas, brief tag, costume mapping, /crew, options) follows semantic versioning; docs/live-check.md is the manual checklist for the desktop app.
Changes go to the experimental branch; main is the release branch and takes a release only with the maintainer's explicit confirmation. See CONTRIBUTING.md.
claude plugin validate . checks the manifest and the hooks module; claude plugin test . runs the tests in hooks/*.test.ts(x).
tsc -p . type-checks the hooks and the tests against types/vendor/claude-code.d.ts, a committed copy of the engine's API declarations, so it works from a clean clone with no engine loaded (TypeScript 5.4 or newer). The engine lays the same file at .claude-plugin/types/claude-code/index.d.ts only after it has loaded the mod, which is why the repository does not rely on it. On a Claude Code bump, load the mod once with the new CLI, copy .claude-plugin/types/claude-code/index.d.ts over types/vendor/claude-code.d.ts (its first line names the version), set CLAUDE_CODE_VERSION in .github/workflows/ci.yml to the same version, and re-run the checks.
The CI workflow (.github/workflows/ci.yml) installs the pinned CLI with npm and runs claude plugin validate ., claude plugin test ., tsc -p . and the costume check on every push to main or experimental and every pull request. None of them calls the model API.
The crab drawings live in hooks/crabs.ts (pixel grids, COSTUMES, CRAB_CSS), which hooks/register.tsx imports. After changing a costume, regenerate the gallery images with Node 22.18 or later, which runs the TypeScript directly:
node tools/render-costumes.mjs # writes media/costumes/<costume>.svg, one per COSTUMES entry
node tools/render-costumes.mjs --check # exits 1 when an image is missing, stale or has no costume
The test harness cannot read files, so the Costumes GitHub Actions workflow (.github/workflows/costumes.yml) and the CI workflow run the --check on every push to main or experimental and every pull request.
The gallery images are generated, but there is no script for a screenshot of the panel: any panel screenshot is taken by hand in the desktop app (see docs/live-check.md).
Built on savvy-progress by johnnyvizz: the panel, the progress bar, the cost table and the pixel crabs (themselves after DockCrab's Clawd). MIT licence; see LICENSE, which carries both copyrights.
hooks/register.tsx 1683 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextUsage, SessionMessage, SessionUsage, Timer } from 'claude-code'
3
4import type { AgentRun, Flow, Panel, Phase, PlannedTask } from '../types'
5
6import { CLAY, COSTUMES, CRAB_BOX, CRAB_CSS, CRAB_SCALE, crab, colorOf, TIER_COLOR } from './crabs'
7
8// the-crew: a fork of johnnyvizz's savvy-progress (MIT) that adds the main
9// session's own row and a costume per workflow profile. Every namespace below
10// (state keys, pane, command, tools) is its own, so both mods can load together.
11const flow = atom({ plugin: 'the-crew', key: 'flow' } as const, null)
12const agents = atom({ plugin: 'the-crew', key: 'agents' } as const, [])
13const panel = atom({ plugin: 'the-crew', key: 'panel' } as const, {
14 isCompact: false,
15 isDoneCollapsed: false,
16 autoOpenedFor: '',
17 viewing: '',
18})
19const now = atom({ plugin: 'the-crew', key: 'now' } as const, 0)
20// The main session's own usage, drawn as the panel's pinned first row.
21const root = atom({ plugin: 'the-crew', key: 'root' } as const, null)
22// Counts the rows the viewed subagent's conversation has kept: the open thread reads it, so a new row redraws the pane.
23const threadRows = atom({ plugin: 'the-crew', key: 'threadRows' } as const, 0)
24
25const TOOL = 'mcp__the-crew__progress'
26const STEP_TOOL = 'mcp__the-crew__step'
27const COMMAND = 'crew'
28const ROOT_ID = 'root'
29// Added to the end of each savvy worker's brief. Their system prompt words step
30// reporting as a condition ("if the step tool appears among your tools") and they
31// skip it even with the tool listed; the same request in the brief is followed.
32const STEP_BRIEF = `
33
34Progress reporting: before any other work, call \`${STEP_TOOL}\` with \`total\` (your plan in 3-8 steps) and \`done: 0\`; call it again with \`done\` and a few-word \`note\` as each step finishes. It only draws a progress bar.`
35const PANE = 'crew-agents'
36const PHASES: readonly Phase[] = ['plan', 'design', 'delegate', 'review', 'close']
37const TIERS: readonly string[] = ['fable', 'heavy', 'careful', 'medium', 'light']
38// Workflow profiles a dispatched agent can carry; `root` is the main session's row alone.
39const PROFILES: readonly string[] = [
40 'explorer', 'implementer', 'validator', 'critic', 'documenter', 'tester', 'manager',
41 'researcher', 'debugger', 'analyst', 'architect', 'auditor', 'navigator', 'inspector',
42]
43// Claude Code's built-in subagent types, drawn as the profile costume they overlap with,
44// or as a costume of their own (`generalist`, `guide`, `statusline`).
45const BUILTIN_ROLE: Readonly<Record<string, string>> = {
46 Explore: 'explorer', explore: 'explorer', Plan: 'architect', plan: 'architect', 'web-fetch': 'researcher',
47 'general-purpose': 'generalist', claude: 'generalist', 'claude-code-guide': 'guide', 'statusline-setup': 'statusline',
48}
49// The brief's tag line, e.g. `Role: leaf; Profile: implementer`: the first match counts.
50const PROFILE_TAG = /\bProfile:\s*([a-z-]+)/i
51// The tools' schemas are advice to the model, not a check: what a call may ask
52// the band and the panel to draw is bounded here.
53const MAX_TASKS = 200
54const MAX_AFTER = 20
55const MAX_TITLE = 120
56const ACCENT = '#8f8cf4'
57const DONE = '#5fbf8f'
58
59type ProgressInput = {
60 title?: string
61 total?: number
62 done?: number
63 phase?: Phase
64 finished?: boolean
65 tasks?: { title?: string; tier?: string; after?: number[] }[]
66}
67
68// ---------------------------------------------------------------------------
69// The panel's words, English only (Russian and Hebrew were removed in 0.14.1).
70
71const STRINGS = {
72 en: {
73 pane: 'Agents',
74 cost: 'Cost',
75 input: 'Input',
76 output: 'Output',
77 time: 'Time',
78 collapse: 'Collapse',
79 expand: 'Expand',
80 running: 'Running',
81 finished: 'Finished',
82 planned: 'Planned',
83 empty: 'No subagents yet.',
84 round: 'round',
85 failed: 'error',
86 after: 'after',
87 inWord: 'in',
88 outWord: 'out',
89 agentsCount: 'agents',
90 isRunning: 'running',
91 isFinished: 'finished',
92 isPlanned: 'planned',
93 opened: 'Agents panel opened.',
94 closed: 'Agents panel closed.',
95 done: 'Done',
96 plan: 'Plan',
97 design: 'Design',
98 tasks: 'Tasks',
99 review: 'Review',
100 busy: 'running',
101 ctx: 'ctx',
102 main: 'Main session',
103 isIdle: 'idle',
104 backToAgents: 'Agents',
105 noThread: 'No messages yet.',
106 tier: {
107 light: 'light', medium: 'medium', careful: 'careful', heavy: 'heavy', fable: 'fable', other: 'other',
108 root: 'root', manager: 'manager', explorer: 'explorer', implementer: 'implementer', validator: 'validator',
109 critic: 'critic', documenter: 'documenter', tester: 'tester', researcher: 'researcher', debugger: 'debugger',
110 analyst: 'analyst', architect: 'architect', auditor: 'auditor', navigator: 'navigator', inspector: 'inspector',
111 generalist: 'generalist', guide: 'guide', statusline: 'statusline',
112 },
113 effort: { low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max' },
114 },
115} as const
116
117const tr = () => STRINGS.en
118
119// Tier and effort words as shown; anything unknown shows as it came.
120const tierWord = (tier: string): string => (tr().tier as Record<string, string>)[tier] ?? tier
121const effortWord = (effort: string): string => (tr().effort as Record<string, string>)[effort] ?? effort
122
123const blank = (): Flow => ({
124 title: 'savvy-flow',
125 total: 0,
126 done: 0,
127 running: 0,
128 phase: 'plan',
129 isFinished: false,
130 tasks: [],
131})
132
133const isNewFlow = (prev: Flow | null, input: ProgressInput): boolean =>
134 !prev || prev.isFinished || (input.title !== undefined && clip(input.title.trim(), MAX_TITLE) !== prev.title)
135
136// Only a known tier is kept: the name is later a key into the tier tables.
137const tierName = (v: string): string => {
138 const t = v.replace(/^savvy-/, '').trim().toLowerCase()
139 return TIERS.includes(t) ? t : ''
140}
141
142const cleanTasks = (tasks: ProgressInput['tasks']): PlannedTask[] | undefined =>
143 tasks
144 ?.filter(t => t.title?.trim())
145 .slice(0, MAX_TASKS)
146 .map(t => ({
147 title: (t.title ?? '').trim(),
148 tier: tierName(t.tier ?? ''),
149 after: (t.after ?? []).filter(n => Number.isInteger(n) && n > 0).slice(0, MAX_AFTER),
150 }))
151
152const merge = (prev: Flow | null, input: ProgressInput): Flow => {
153 // A new title means a new flow: never carry counters over from an earlier one.
154 const base = isNewFlow(prev, input) || !prev ? blank() : { ...blank(), ...prev }
155 const tasks = cleanTasks(input.tasks) ?? base.tasks
156 const total = Math.min(MAX_TASKS, Math.max(0, Math.round(input.total ?? (input.tasks ? tasks.length : base.total))))
157 const done = Math.min(total || Infinity, Math.max(0, Math.round(input.done ?? base.done)))
158 const phase = input.phase && PHASES.includes(input.phase) ? input.phase : base.phase
159 return {
160 ...base,
161 title: clip(input.title?.trim() || base.title, MAX_TITLE),
162 total,
163 done,
164 phase: input.finished ? 'close' : phase,
165 isFinished: input.finished === true,
166 tasks,
167 }
168}
169
170const label = (f: Flow): string => {
171 const s = tr()
172 if (f.isFinished) return s.done
173 if (f.phase === 'plan') return s.plan
174 if (f.phase === 'design') return s.design
175 const count = f.total ? `${f.done}/${f.total}` : `${f.running} ${s.busy}`
176 return `${f.phase === 'review' ? s.review : s.tasks} ${count}`
177}
178
179const ratio = (f: Flow): number => (f.isFinished ? 1 : f.total ? f.done / f.total : 0)
180
181// Deterministic noise so the dither does not shimmer between redraws.
182const noise = (x: number, y: number): number => {
183 const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
184 return s - Math.floor(s)
185}
186
187// The whole row is one SVG: the desktop wraps sibling elements onto new lines,
188// so title, bar, percent and the crab live in one drawing; only the count and
189// the dismiss are Buttons beside it.
190const H = 22
191const BAR_H = 16
192const CRAB_W = 26
193const CELL = 3
194const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
195
196const xml = (s: string): string =>
197 s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
198
199const clip = (s: string, max: number): string => (s.length > max ? s.slice(0, Math.max(1, max - 1)) + '…' : s)
200
201// CJK, fullwidth forms and emoji advance about 1em; at 0.56em a Chinese title was sized at half its width and the bar drew over it.
202// 中日韩文字、全角标点和 emoji 约占 1em;按 0.56em 算时中文标题宽度被低估近一半,进度条会压到标题上。
203const WIDE =
204 /[\u1100-\u115F\u2E80-\u303E\u3041-\u33FF\u3400-\u4DBF\u4E00-\u9FFF\uA960-\uA97F\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF01-\uFF60\uFFE0-\uFFE6\u{1F300}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u
205
206// Rough advance of system UI text, in em; good enough to size the title's slot.
207const charEm = (ch: string): number =>
208 WIDE.test(ch) ? 1 : /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-ZА-ЯЁmwшщжюМШЩЖЮ@%]/.test(ch) ? 0.72 : 0.56
209
210const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
211
212// Cuts `s` to fit `maxW` pixels, with an ellipsis when it had to cut.
213const fitText = (s: string, size: number, maxW: number): string => {
214 if (textWidth(s, size) <= maxW) return s
215 let out = ''
216 for (const ch of s) {
217 if (textWidth(out + ch + '…', size) > maxW) break
218 out += ch
219 }
220 return out + '…'
221}
222
223// Pixels twinkle in four out-of-phase groups while the bar's work runs. A drawing that is loaded again
224// starts its animations over, so each group's delay is set from the clock at the time of drawing: the
225// restart then lands where the cycle would have been, and the twinkle does not jump. Per group: its
226// period and its offset, in seconds.
227const BAR_GROUPS: [period: number, offset: number][] = [[2.2, 0], [2.8, 0.7], [1.9, 1.3], [3.3, 0.4]]
228const barMotionCss = (at: number): string =>
229 `${BAR_GROUPS.map(([period, offset], i) => `.t${i}{animation:tw ${period}s ease-in-out -${(((at / 1000 + offset) % period)).toFixed(2)}s infinite}`).join('')}
230@keyframes tw{0%,100%{opacity:1}50%{opacity:.3}}
231@media (prefers-reduced-motion: reduce){.t0,.t1,.t2,.t3{animation:none}}
232`
233
234const rowSvg = (f: Flow, W: number, isWorking: boolean, at: number): string => {
235 // The title takes what it needs, up to 40% of the row; the bar takes the rest.
236 const title = fitText(f.title, 13, Math.max(60, W * 0.4))
237 const BAR_X = Math.round(16 + textWidth(title, 13) + 12)
238 const BAR_W = Math.max(60, W - BAR_X - 46 - CRAB_W)
239 const color = f.isFinished ? DONE : ACCENT
240 const y0 = (H - BAR_H) / 2
241 const fillW = Math.round(BAR_W * ratio(f))
242 const runW = f.total ? Math.round((BAR_W * Math.min(f.total, f.done + f.running)) / f.total) : 0
243 // Only a bar with work under way moves; a finished or waiting one is still, with no animation at all.
244 const isMoving = isWorking && !f.isFinished
245 // The pixels twinkle in four out-of-phase groups, so four elements animate, not hundreds of rects.
246 const dots: string[][] = [[], [], [], []]
247 const faint: string[][] = [[], [], [], []]
248 const group = (rects: string[][], fill: string, extra = ''): string =>
249 rects
250 .map((r, i) => (r.length ? `<g${isMoving ? ` class="t${i}"` : ''} fill="${fill}"${extra}>${r.join('')}</g>` : ''))
251 .join('')
252
253 // Dithered fill: sparse at the start, dense toward the head.
254 const cols = Math.floor(fillW / CELL)
255 const rows = Math.floor(BAR_H / CELL)
256 for (let c = 0; c < cols; c++) {
257 const density = 0.35 + 0.6 * Math.pow(c / Math.max(1, cols), 1.2)
258 for (let r = 0; r < rows; r++) {
259 if (noise(c, r) < density) dots[Math.floor(noise(r, c) * 4)]?.push(`<rect x="${c * CELL + 1}" y="${r * CELL + 1}" width="2" height="2"/>`)
260 }
261 }
262 // Handed to workers, not yet accepted: a faint second layer.
263 for (let c = cols; c < Math.floor(runW / CELL); c++) {
264 for (let r = 0; r < rows; r++) {
265 if (noise(c + 7, r + 3) < 0.2) faint[Math.floor(noise(r + 5, c) * 4)]?.push(`<rect x="${c * CELL + 1}" y="${r * CELL + 1}" width="1.7" height="1.7"/>`)
266 }
267 }
268
269 const ticks: string[] = []
270 for (let i = 1; i < f.total; i++) {
271 const x = Math.round((BAR_W * i) / f.total)
272 if (x > fillW + 4) ticks.push(`<rect x="${x}" y="${BAR_H / 2 - 4}" width="1.5" height="8" rx="0.75"/>`)
273 }
274
275 const text = label(f)
276 const pillW = Math.round(18 + text.length * 6.6)
277 const pillX = Math.max(0, Math.min(BAR_W - pillW, fillW - pillW))
278 const percent = `${Math.round(ratio(f) * 100)}%`
279 const titleAt = 'x="16"'
280 const percentAt = `x="${W - CRAB_W - 6}" y="${H / 2 + 4.5}" text-anchor="end"`
281 const crabW = CRAB_BOX * 0.8
282
283 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
284<style>
285.t{fill:#1f1f1f}.m{fill:#8a8a8a}.k{fill:#e4e4e2}.tk{fill:#b4b4b0}
286@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}.k{fill:#2c2c2c}.tk{fill:#5a5a5a}}
287${isMoving ? barMotionCss(at) : ''}</style>
288<defs><clipPath id="c"><rect x="0" y="0" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/></clipPath></defs>
289<circle cx="5" cy="${H / 2}" r="4" fill="${color}"/>
290<text class="t" ${titleAt} y="${H / 2 + 4.5}" font-family="${FONT}" font-size="13" font-weight="500">${xml(title)}</text>
291<g transform="translate(${BAR_X},${y0})">
292<rect class="k" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/>
293<g clip-path="url(#c)">
294${group(dots, color)}
295${group(faint, color, ' opacity="0.45"')}
296<g class="tk">${ticks.join('')}</g>
297</g>
298<rect x="${pillX}" width="${pillW}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${color}"/>
299<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>
300</g>
301<text class="m" ${percentAt} font-family="${FONT}" font-size="12.5" font-variant-numeric="tabular-nums">${percent}</text>
302${isWorking ? CRAB_CSS : ''}${crab(W - CRAB_W + 1, 0, 'other', false, isWorking, 0.8)}
303</svg>`
304}
305
306const barText = (f: Flow, width: number): string => {
307 const filled = Math.round(width * ratio(f))
308 return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
309}
310
311// ---------------------------------------------------------------------------
312// Agents panel: every subagent of the session, plus the tasks the flow planned.
313
314const TIER_MODEL: Record<string, [model: string, effort: string]> = {
315 fable: ['Fable', 'high'],
316 heavy: ['Opus', 'xhigh'],
317 careful: ['Opus', 'high'],
318 medium: ['Opus', 'medium'],
319 light: ['Opus', 'low'],
320}
321
322// "Opus · xhigh", with the effort word; '' for an unknown tier.
323const tierModel = (tier: string): string => {
324 const m = TIER_MODEL[tier]
325 return m ? `${m[0]} · ${effortWord(m[1])}` : ''
326}
327
328// "Opus 5.5 · low" for a run, or just the model when no effort was seen.
329const runModel = (a: AgentRun): string => (a.effort ? `${modelName(a.model)} · ${effortWord(a.effort)}` : modelName(a.model))
330
331// USD per million tokens: input, output, cache read, cache write. The first row that
332// matches a model id wins, so a release sits above its family.
333// The engine reports tokens, not money, so the panel's cost is an estimate. It reports
334// one cache-write count with no split by lifetime; writes are priced at the 1-hour
335// rate (2x input), which is what Claude Code writes on a subscription.
336type Price = [input: number, output: number, cacheRead: number, cacheWrite: number]
337
338const PRICES: [RegExp, Price][] = [
339 [/(fable|mythos)-5-1/, [10, 50, 0.25, 20]],
340 [/fable|mythos/, [10, 50, 1, 20]],
341 [/opus-5-5/, [4, 20, 0.2, 8]],
342 [/opus/, [5, 25, 0.5, 10]],
343 [/sonnet-5-5/, [2, 10, 0.1, 4]],
344 [/sonnet-5/, [2, 10, 0.2, 4]],
345 [/sonnet/, [3, 15, 0.3, 6]],
346 [/haiku-5-5/, [0.1, 0.5, 0.01, 0.2]],
347 [/haiku/, [1, 5, 0.1, 2]],
348]
349
350// Haiku 5.5 is priced by prompt length: a request whose prompt (input, cache reads and
351// cache writes together) is over 100,000 tokens pays these prices instead.
352const HAIKU_LONG_PROMPT = 100_000
353const HAIKU_LONG: Price = [0.5, 2.5, 0.05, 1]
354
355type Usage = {
356 input_tokens: number
357 output_tokens: number
358 cache_read_input_tokens: number
359 cache_creation_input_tokens: number
360}
361
362const priceOf = (model: string, prompt: number): Price => {
363 const id = model.toLowerCase()
364 if (prompt > HAIKU_LONG_PROMPT && /haiku-5-5/.test(id)) return HAIKU_LONG
365 return PRICES.find(([re]) => re.test(id))?.[1] ?? [4, 20, 0.2, 8]
366}
367
368// `isRequest` says the usage is one model request, which is what a prompt-length price
369// is set by; a turn's sum is priced at the model's ordinary prices.
370const costOf = (model: string, u: Usage, isRequest: boolean): number => {
371 const prompt = isRequest ? (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0) : 0
372 const [i, o, r, w] = priceOf(model, prompt)
373 return (
374 ((u.input_tokens || 0) * i +
375 (u.output_tokens || 0) * o +
376 (u.cache_read_input_tokens || 0) * r +
377 (u.cache_creation_input_tokens || 0) * w) /
378 1e6
379 )
380}
381
382// Haiku 4.5 and the Claude 3 models have a 200K window; the rest, Haiku 5.5 included, 1M.
383const windowOf = (model: string): number => (/haiku-4|claude-3/i.test(model) ? 200_000 : 1_000_000)
384
385// `savvy-careful`, or `savvy-flow:savvy-careful` when the agents ship in a plugin.
386const tierOf = (type: string): string => {
387 const bare = type.replace(/^[^:]*:/, '')
388 const t = bare.replace(/^savvy-/, '').toLowerCase()
389 return Object.hasOwn(TIER_COLOR, t) && bare.startsWith('savvy-') ? t : 'other'
390}
391
392// The role a dispatched agent is drawn as, first match wins: (a) its type names a
393// profile (`implementer`, or `plugin:implementer`); (b) its brief carries a tag line
394// such as `Role: leaf; Profile: implementer`, the first `Profile:` counting; (c) a
395// built-in type (`Explore`, `general-purpose`, ...); (d) a `savvy-*` tier; else `other`. A tag naming no known profile falls through to (c).
396const roleOfSpawn = (type: string, prompt: string): string => {
397 const bare = type.replace(/^[^:]*:/, '').trim().toLowerCase()
398 if (PROFILES.includes(bare)) return bare
399 const tag = PROFILE_TAG.exec(prompt)?.[1]?.toLowerCase() ?? ''
400 if (PROFILES.includes(tag)) return tag
401 const builtin = Object.hasOwn(BUILTIN_ROLE, type.replace(/^[^:]*:/, '').trim()) ? BUILTIN_ROLE[type.replace(/^[^:]*:/, '').trim()] : undefined
402 return builtin ?? tierOf(type)
403}
404
405// A run's role: what the spawn detected, else (runs stored before roles were) its tier.
406const roleOf = (a: AgentRun): string => a.role ?? tierOf(a.type)
407
408// A run's costume: its role's, else the plain crab (savvy tiers have none since 0.4.0).
409const costumeOf = (a: AgentRun): string => {
410 const role = roleOf(a)
411 return Object.hasOwn(COSTUMES, role) ? role : 'other'
412}
413
414// The word under a run's title: its role, or its type when it has none.
415const roleLabel = (a: AgentRun): string => {
416 const role = roleOf(a)
417 const bare = a.type.replace(/^[^:]*:/, '').trim()
418 // A built-in type shows its own name, so it is not mistaken for the profile it borrows.
419 if (role === 'other' || (Object.hasOwn(BUILTIN_ROLE, bare) && BUILTIN_ROLE[bare] === role)) return a.type
420 return tierWord(role)
421}
422
423const modelName = (id: string): string => {
424 const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
425 const [, family = '', major = '', minor] = m ?? []
426 if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
427 return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
428}
429
430const norm = (s: string): string => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
431
432const fmtTokens = (n: number): string =>
433 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
434
435// "12M in · 45k out".
436const fmtInOut = (t: { input: number; output: number }): string => `${fmtTokens(t.input)} ${tr().inWord} · ${fmtTokens(t.output)} ${tr().outWord}`
437
438const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
439
440const fmtTime = (ms: number): string => {
441 const s = Math.max(0, Math.round(ms / 1000))
442 const h = Math.floor(s / 3600)
443 const m = Math.floor((s % 3600) / 60)
444 const ss = String(s % 60).padStart(2, '0')
445 return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
446}
447
448const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt - (a.idleMs ?? 0)
449
450// A finished run reused (resumed by a message, a turn started in its loop) is running
451// again: its totals stay, and the time it sat finished is left out of its elapsed time.
452const reactivated = (a: AgentRun, at: number): AgentRun =>
453 a.status === 'running'
454 ? a
455 : { ...a, status: 'running', endedAt: undefined, idleMs: (a.idleMs ?? 0) + (a.endedAt === undefined ? 0 : Math.max(0, at - a.endedAt)) }
456
457// What a reused agent is handed: SendMessage's `summary` and its message, or the
458// message alone a resume or a waking turn carries.
459type Task = { summary?: string; prompt?: string }
460
461// A task's title: its summary, else its message's first line.
462const taskTitle = (t: Task): string =>
463 clip((t.summary?.trim() || (t.prompt ?? '').split('\n').find(l => l.trim()) || '').trim().replace(/\s+/g, ' '), 80)
464
465// A finished row given a new task is running again under the task's title: its role
466// follows a new brief's `Profile:` tag and its reported steps start over. A running
467// row is left as it is: a message to a busy agent waits behind its task.
468const reused = (a: AgentRun, at: number, task: Task = {}): AgentRun => {
469 if (a.status === 'running') return a
470 const title = taskTitle(task)
471 if (!title) return reactivated(a, at)
472 const tag = PROFILE_TAG.exec(task.prompt ?? '')?.[1]?.toLowerCase() ?? ''
473 return {
474 ...reactivated(a, at),
475 description: title,
476 role: PROFILES.includes(tag) ? roleOfSpawn(a.type, task.prompt ?? '') : a.role,
477 stepDone: undefined,
478 stepTotal: undefined,
479 stepNote: undefined,
480 }
481}
482
483// The list with one agent's row renewed and moved last, the newest under Running.
484const renew = (list: AgentRun[], agentId: string, at: number, task?: Task): AgentRun[] => {
485 const row = list.find(a => a.agentId === agentId)
486 if (!row || row.status === 'running') return list
487 return [...list.filter(a => a !== row), reused(row, at, task)]
488}
489
490type Planned = PlannedTask & { n: number }
491
492const plannedOf = (f: Flow | null, list: AgentRun[]): Planned[] => {
493 if (!f || f.isFinished) return []
494 const started = new Set(list.map(a => norm(a.description)))
495 return (f.tasks ?? []).map((t, i) => ({ ...t, n: i + 1 })).filter(t => !started.has(norm(t.title)))
496}
497
498const totals = (list: AgentRun[], at: number) => {
499 const cost = list.reduce((s, a) => s + a.costUsd, 0)
500 // `|| 0`: a row kept from before the split has neither count.
501 const input = list.reduce((s, a) => s + (a.inputTokens || 0), 0)
502 const output = list.reduce((s, a) => s + (a.outputTokens || 0), 0)
503 const start = Math.min(...list.map(a => a.startedAt))
504 const end = Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt)))
505 return { cost, input, output, time: list.length ? end - start : 0 }
506}
507
508// --- desktop drawings: each row is one SVG, as the band above the prompt is.
509
510// The pane's backdrop: a dotted grid as tall as the content laid over it; the
511// row SVGs are transparent, so it shows through between their marks.
512const GRID_STEP = 16
513// A Text or Button row on the desktop is about 20px; the grid errs taller.
514const LINE_H = 24
515// The engine refuses an Svg taller than 4096px, so a long grid is stacked from
516// tiles of at most this height; a multiple of GRID_STEP keeps the dots aligned.
517const GRID_TILE = 4096
518const gridTiles = (H: number) => Array.from({ length: Math.ceil(H / GRID_TILE) }, (_, i) => Math.min(GRID_TILE, H - i * GRID_TILE))
519const gridSvg = (W: number, H: number) => `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
520<style>.d{fill:#d9d8d4}@media (prefers-color-scheme: dark){.d{fill:#3a3a38}}</style>
521<defs><pattern id="g" width="${GRID_STEP}" height="${GRID_STEP}" patternUnits="userSpaceOnUse"><circle class="d" cx="${GRID_STEP / 2}" cy="${GRID_STEP / 2}" r="1"/></pattern></defs>
522<rect width="${W}" height="${H}" fill="url(#g)"/>
523</svg>`
524
525const PANE_CSS = `<style>
526.t{fill:#1f1f1f}.s{fill:#6b6b68}.m{fill:#9a9a96}.k{fill:#ecebe8}.ln{stroke:#e4e4e1}.tile{fill:#f4f3f0}
527@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.m{fill:#7d7d79}.k{fill:#2c2c2b}.ln{stroke:#333331}.tile{fill:#262625}}
528</style>`
529
530// Only a drawing with a running mark carries this; finished and idle drawings hold no animation.
531const LIVE_CSS = `<style>.live{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
532@media (prefers-reduced-motion: reduce){.live{animation:none}}</style>`
533
534const statusMark = (x: number, y: number, status: string, color: string): string => {
535 if (status === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
536 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"/>`
537 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"/>`
538 // The main session between turns: a quiet ring in its own colour.
539 if (status === 'idle') return `<circle cx="${x}" cy="${y}" r="3.5" fill="none" stroke="${color}" stroke-width="1.4"/>`
540 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"/>`
541}
542
543// Text that starts at x, and text that ends at x (right-aligned).
544const startText = (x: number, y: number): string => `x="${x}" y="${y}"`
545const endText = (x: number, y: number): string => `x="${x}" y="${y}" text-anchor="end"`
546
547// A running time that counts by itself. The desktop loads a drawing anew whenever its markup changes, and
548// a time written as text changed it every second: a new image a second per running row, which the app
549// kept. Each place of the time is instead a strip of glyphs moved by a stepped CSS animation whose delay is
550// set from the elapsed time at drawing, so the markup holds while the run goes on and is redrawn once a
551// minute (DESKTOP_MS) to correct a strip that was loaded again. Per strip: its glyphs, and the seconds
552// each shows for.
553type Strip = { glyphs: string[]; unit: number }
554const DIGITS = [...'0123456789']
555// The minutes' tens with the hours before them, over ten hours: nothing under ten minutes, then 1–5, then 1:0 on.
556const HEAD = Array.from({ length: 60 }, (_, i) => {
557 const h = Math.floor(i / 6)
558 const t = i % 6
559 return h ? `${h}:${t}` : t ? String(t) : ''
560})
561// The head strip turns over at ten hours; a longer run is written as text.
562const CLOCK_MAX = 10 * 3600_000 - 120_000
563const SECONDS: Strip = { glyphs: DIGITS, unit: 1 }
564const TENS: Strip = { glyphs: DIGITS.slice(0, 6), unit: 10 }
565const MINUTES: Strip = { glyphs: DIGITS, unit: 60 }
566const HEADS: Strip = { glyphs: HEAD, unit: 600 }
567
568const digitW = (size: number): number => size * 0.6
569const colonW = (size: number): number => size * 0.32
570
571// The time's width as it will be by the next redraw, so it does not grow into the text before it.
572const clockWidth = (ms: number, size: number): number => {
573 const s = Math.floor((ms + 2 * DESKTOP_MS) / 1000)
574 const dw = digitW(size)
575 return 3 * dw + colonW(size) + (s >= 3600 ? 2 * dw + colonW(size) : s >= 600 ? dw : 0)
576}
577
578// The time `ms` ending at x, on the baseline y; `attrs` styles its glyphs as the text around it.
579const clock = (x: number, y: number, size: number, ms: number, attrs: string): string => {
580 const dw = digitW(size)
581 const lh = Math.ceil(size * 1.4)
582 const base = Math.ceil(size)
583 const rules: string[] = []
584 const cell = (right: number, w: number, s: Strip): string => {
585 const n = s.glyphs.length
586 const period = n * s.unit
587 rules.push(`.q${rules.length}{animation:q${n}x${lh} ${period}s steps(${n}) -${((ms / 1000) % period).toFixed(2)}s infinite}`)
588 const glyphs = s.glyphs.map((g, i) => (g ? `<text ${endText(w, base + i * lh)}${attrs}>${g}</text>` : '')).join('')
589 return `<svg x="${right - w}" y="${y - base}" width="${w}" height="${lh}" overflow="hidden"><g class="q${rules.length - 1}">${glyphs}</g></svg>`
590 }
591 const colon = x - 2 * dw - colonW(size) / 2
592 const cells = [
593 cell(x, dw, SECONDS),
594 cell(x - dw, dw, TENS),
595 `<text x="${colon}" y="${y}" text-anchor="middle"${attrs}>:</text>`,
596 cell(x - 2 * dw - colonW(size), dw, MINUTES),
597 cell(x - 3 * dw - colonW(size), 2 * dw + colonW(size), HEADS),
598 ].join('')
599 const frames = [10, 6, 60].map(n => `@keyframes q${n}x${lh}{to{transform:translateY(-${n * lh}px)}}`).join('')
600 return `<g class="clk" aria-label="${fmtTime(ms)}"><style>${rules.join('')}${frames}</style>${cells}</g>`
601}
602
603const crabWidth = (scale: number): number => Math.round(CRAB_BOX * scale * 100) / 100
604
605// A panel crab at x.
606const rowCrab = (x: number, y: number, costume: string, dim = false, isWalking = false): string =>
607 crab(x, y, costume, dim, isWalking, CRAB_SCALE)
608
609// A thread in the pane: its newest messages, each cut to THREAD_TEXT, as many as fit
610// THREAD_CHARS. A drawing holds 100000 characters and cuts from the end, which is
611// where the newest messages are, so the budget is spent newest first.
612const THREAD_TEXT = 4000
613const THREAD_CHARS = 90000
614
615// One line per tool call in a thread: the tool, and the argument that says most about it.
616const toolLine = (use: { tool: string; input: Record<string, unknown> }): string => {
617 const arg = ['description', 'file_path', 'path', 'command', 'pattern', 'url', 'query'].map(k => use.input[k]).find(v => typeof v === 'string' && v)
618 const name = use.tool.startsWith('mcp__') ? use.tool.split('__').slice(2).join('__') || use.tool : use.tool
619 return typeof arg === 'string' ? `${name} · ${arg.replace(/\s+/g, ' ')}` : name
620}
621
622// Each row is its own SVG, so each carries the motion CSS only when its body uses it:
623// a pulsing mark (`live`) or a walking crab (`run`).
624const svg = (W: number, H: number, body: string): string =>
625 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PANE_CSS}${body.includes('class="live"') ? LIVE_CSS : ''}${body.includes(' run"') ? CRAB_CSS : ''}${body}</svg>`
626
627// The pane's own title already says "Agents": the header names the flow, if any.
628// `isTicking`: the time runs on, and counts by itself (see `clock`).
629const headerSvg = (W: number, title: string, t: ReturnType<typeof totals>, isTicking = false): string => {
630 const s = tr()
631 const gap = 6
632 const tw = (W - gap * 3) / 4
633 // A narrow pane shrinks the figures so "≈$12.3" still fits its tile.
634 const size = tw >= 80 ? 15 : 13
635 const top = title ? 28 : 0
636 const figure = `font-family="${FONT}" font-size="${size}" font-weight="600" font-variant-numeric="tabular-nums"`
637 const figureAt = (i: number) => i * (tw + gap) + 9
638 // `drawn`: the figure's own markup, in place of `v` written as text.
639 const tile = (i: number, k: string, v: string, drawn = '') =>
640 `<rect class="tile" x="${i * (tw + gap)}" y="${top}" width="${tw}" height="40" rx="8"/>
641<text class="s" ${startText(figureAt(i), top + 16)} font-family="${FONT}" font-size="11">${k}</text>
642${drawn || `<text class="t" ${startText(figureAt(i), top + 33)} ${figure}>${v}</text>`}`
643 const time =
644 isTicking && t.time < CLOCK_MAX ? clock(figureAt(3) + clockWidth(t.time, size), top + 33, size, t.time, ` class="t" ${figure}`) : ''
645 return svg(
646 W,
647 headerHeight(title),
648 `${title ? `<text class="t" ${startText(0, 15)} font-family="${FONT}" font-size="14" font-weight="600">${xml(fitText(title, 14, W))}</text>` : ''}
649${tile(0, s.cost, '≈' + fmtCost(t.cost))}${tile(1, s.input, fmtTokens(t.input))}${tile(2, s.output, fmtTokens(t.output))}${tile(3, s.time, fmtTime(t.time), time)}`,
650 )
651}
652
653const headerHeight = (title: string): number => (title ? 72 : 44)
654
655// The task's own progress when the worker reports steps; a finished run is full.
656const progressOf = (a: AgentRun): number | null => {
657 if (a.status === 'done') return 1
658 if (a.stepTotal) return Math.min(1, (a.stepDone ?? 0) / a.stepTotal)
659 return null
660}
661
662const ctxOf = (a: AgentRun): number => (a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0)
663
664// `titled: false` leaves the title out, for a row whose title is drawn as a pressable over it.
665// A row is three images side by side, in flow: the crab, the text, the status mark. Only the text
666// changes every second (the elapsed time); the other two depend on the row's costume and state alone,
667// so their markup holds while it runs and their CSS animations are not started over.
668const CRAB_COL = 40
669const MARK_COL = 22
670const rowTextW = (W: number): number => W - CRAB_COL - MARK_COL
671
672const agentSvg = (W: number, a: AgentRun, at: number, titled = true, isTicking = false): string => {
673 const TW = rowTextW(W)
674 const s = tr()
675 const color = colorOf(roleOf(a))
676 const ctx = ctxOf(a)
677 const textW = TW - 2
678 const meta = [runModel(a)]
679 if (a.round > 1) meta.push(`${s.round} ${a.round}`)
680 if (a.status === 'failed') meta.push(s.failed)
681 const barW = textW
682 const progress = progressOf(a)
683 const time = elapsed(a, at)
684 const usage = `${s.ctx} ${ctx}% · ${fmtTokens(a.contextTokens)} ≈${fmtCost(a.costUsd)}`
685 const stats = `${usage} ${fmtTime(time)}`
686 const statsFont = `font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums"`
687 // A running time counts by itself, after the usage; the two spaces before it are kept as a gap.
688 const ticks = isTicking && time < CLOCK_MAX
689 const clockW = ticks ? clockWidth(time, 11) + textWidth(' ', 11) : 0
690 const statsSvg = ticks
691 ? `<text class="s" ${endText(2 + barW - clockW, 49)} ${statsFont}>${usage}</text>${clock(2 + barW, 49, 11, time, ` class="s" ${statsFont}`)}`
692 : `<text class="s" ${endText(2 + barW, 49)} ${statsFont}>${stats}</text>`
693 const steps = a.stepTotal ? `${a.stepDone ?? 0}/${a.stepTotal}${a.stepNote ? ' · ' + a.stepNote : ''}` : ''
694 const stepsW = Math.max(0, barW - (ticks ? textWidth(usage, 11) + clockW : textWidth(stats, 11)) - 12)
695 // Without reported steps the bar falls back to the context, drawn grey.
696 const fillW = Math.round(barW * (progress ?? ctx / 100))
697 return svg(
698 TW,
699 66,
700 `${titled ? `<text class="t" ${startText(2, 18)} font-family="${FONT}" font-size="13" font-weight="600">${xml(fitText(a.description || a.type, 13, textW))}</text>` : ''}
701<text ${startText(2, 34)} font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(roleLabel(a))}</tspan><tspan class="s"> ${xml(meta.join(' · '))}</tspan></text>
702${steps && stepsW > 30 ? `<text class="t" ${startText(2, 49)} font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${xml(fitText(steps, 11, stepsW))}</text>` : ''}
703${statsSvg}
704<rect class="k" x="2" y="55" width="${barW}" height="4" rx="2"/><rect${progress === null ? ' class="m"' : ''} x="2" y="55" width="${fillW}" height="4" rx="2"${progress === null ? '' : ` fill="${color}"`}/>
705<line class="ln" x1="0" y1="65.5" x2="${TW}" y2="65.5"/>`,
706 )
707}
708
709// The status word a row's mark is read as.
710const statusWord = (status: string): string => {
711 const s = tr()
712 return status === 'running' ? s.isRunning : status === 'failed' ? s.failed : status === 'idle' ? s.isIdle : s.isFinished
713}
714
715const rowLine = (w: number): string => `<line class="ln" x1="0" y1="65.5" x2="${w}" y2="65.5"/>`
716
717const agentCrabSvg = (a: AgentRun): string =>
718 svg(CRAB_COL, 66, `${rowCrab(0, 14, costumeOf(a), false, a.status === 'running')}${rowLine(CRAB_COL)}`)
719
720const agentMarkSvg = (a: AgentRun): string =>
721 svg(MARK_COL, 66, `${statusMark(MARK_COL - 8, 16, a.status, colorOf(roleOf(a)))}${rowLine(MARK_COL)}`)
722
723const plannedSvg = (W: number, p: Planned): string => {
724 const tier = p.tier in TIER_COLOR ? p.tier : 'other'
725 const color = colorOf(tier)
726 const textW = W - 42 - 22
727 const meta = [tierModel(tier)]
728 if (p.after.length) meta.push(`${tr().after} ${p.after.join(', ')}`)
729 return svg(
730 W,
731 46,
732 `${rowCrab(0, 6, 'other', true)}
733<text class="s" ${startText(42, 18)} font-family="${FONT}" font-size="13" font-weight="600">${xml(fitText(`${p.n}. ${p.title}`, 13, textW))}</text>
734<text ${startText(42, 34)} font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(tierWord(tier))}</tspan><tspan class="m"> ${xml(meta.filter(Boolean).join(' · '))}</tspan></text>
735${statusMark(W - 8, 16, 'planned', color)}
736<line class="ln" x1="0" y1="45.5" x2="${W}" y2="45.5"/>`,
737 )
738}
739
740// Collapsed, the header's tiles stay and the rows shrink to one strip of crabs.
741const compactSvg = (W: number, list: AgentRun[], planned: Planned[]): string => {
742 const icons = [
743 ...list.filter(a => a.status === 'running').map(a => ({ k: costumeOf(a), c: colorOf(roleOf(a)), s: 'running', dim: false })),
744 ...list.filter(a => a.status !== 'running').map(a => ({ k: costumeOf(a), c: colorOf(roleOf(a)), s: a.status, dim: false })),
745 ...planned.map(p => ({ k: 'other', c: colorOf(p.tier), s: 'planned', dim: true })),
746 ]
747 // Room is kept for the "+N" overflow count.
748 const fit = Math.max(1, Math.floor((W - 36) / 36))
749 const shown = icons.slice(0, fit)
750 const more = icons.length - shown.length
751 const body = shown
752 .map((ic, i) => rowCrab(i * 36, 0, ic.k, ic.dim, ic.s === 'running') + (ic.s === 'running' ? `<circle class="live" cx="${i * 36 + 32}" cy="4" r="3" fill="${ic.c}"/>` : ''))
753 .join('')
754 const x = shown.length * 36 + (more ? 4 : 0)
755 return svg(
756 W,
757 32,
758 `${body}${more ? `<text class="s" ${startText(x, 21)} font-family="${FONT}" font-size="12">+${more}</text>` : ''}`,
759 )
760}
761
762// --- terminal drawing: the same rows in text.
763
764const ctxBar = (pct: number, width: number): string => {
765 const filled = Math.round((width * pct) / 100)
766 return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
767}
768
769const STATUS_GLYPH: Record<string, string> = { running: '●', done: '✓', failed: '✗', planned: '◷', idle: '○' }
770
771// --- the main session's row: one AgentRun kept apart from the subagents.
772
773// A fresh row for the main session, counted from `startedAt` (SessionUsage.startedAt).
774const newRoot = (startedAt: number): AgentRun => ({
775 id: `crew-${ROOT_ID}`,
776 type: ROOT_ID,
777 role: ROOT_ID,
778 description: '',
779 model: '',
780 status: 'idle',
781 startedAt,
782 contextTokens: 0,
783 contextMax: 0,
784 inputTokens: 0,
785 outputTokens: 0,
786 costUsd: 0,
787 steps: 0,
788 round: 1,
789})
790
791// The live window as the engine measures it (SessionContextUsage): its fill and its size.
792const withContext = (r: AgentRun, c: SessionContextUsage): AgentRun => ({
793 ...r,
794 contextTokens: c.tokens ?? r.contextTokens,
795 contextMax: c.window || r.contextMax,
796})
797
798// The prompt side, cache reads and writes included, as the status line counts the window.
799const inputOf = (u: Usage): number => (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
800
801// The row as drawn: its title.
802const rootRow = (r: AgentRun | null): AgentRun | null => (r ? { ...r, description: tr().main } : null)
803
804// Drawings kept between redraws, keyed by everything a drawing depends on, so a row
805// that did not change is not built again. Two generations: a redraw reuses either, and
806// keeps only what it used, so rows no longer shown drop out.
807type Memo = { begin: () => void; reset: () => void; get: (key: string, build: () => string) => string }
808
809const makeMemo = (): Memo => {
810 let prev = new Map<string, string>()
811 let next = new Map<string, string>()
812 return {
813 begin: () => {
814 prev = next
815 next = new Map()
816 },
817 reset: () => {
818 prev = new Map()
819 next = new Map()
820 },
821 get: (key, build) => {
822 const hit = next.get(key) ?? prev.get(key) ?? build()
823 next.set(key, hit)
824 return hit
825 },
826 }
827}
828
829// A subagent's thread as last fetched; `version` says which events it was fetched after.
830type Thread = { version: number; found: SessionMessage[] | { deny: string } }
831
832// Opens the agents pane, or closes it when it is up; true when it ends up open.
833// Moves the pane's window; a pane that cannot be scrolled (no surface, a test)
834// still shows what it holds, so a refusal or a throw is let go.
835async function scrollPane($: EngineInterface, to: 'start' | 'end'): Promise<void> {
836 try {
837 await $.ui.scroll({ in: PANE, to })
838 } catch {
839 // Nothing to move.
840 }
841}
842
843// A thread opens on its newest message and keeps up with the ones that follow,
844// as the pane's end does until the person scrolls away from it.
845// The agent whose thread is open ('' for none), kept apart from the panel's state so a row stored for
846// any other agent is let go without a read.
847let viewingAgent = ''
848// Finished subagents (by agent id): a row stored in the conversation of one of these means it was reused.
849const finishedAgents = new Set<string>()
850// A reused subagent always starts from a new input: a brief (`prompt`) or a message sent to it (`delivery`).
851// Its replies and tool results after it finished are not reuse.
852const REUSE_DOORS = new Set(['prompt', 'delivery'])
853
854async function openThread($: EngineInterface, id: string, agentId: string, refresh: () => void): Promise<void> {
855 viewingAgent = agentId
856 refresh()
857 await update($, panel, prev => ({ ...prev, viewing: id }))
858 await scrollPane($, 'end')
859}
860
861async function togglePane($: EngineInterface): Promise<boolean> {
862 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
863 if (isOpen) {
864 await $.ui.close({ id: PANE })
865 return false
866 }
867 const at = await $.clock.now()
868 await update($, now, () => at)
869 await $.ui.open({ id: PANE, title: tr().pane })
870 return true
871}
872
873async function autoOpen($: EngineInterface, key: string): Promise<void> {
874 const p = await read($, panel)
875 if (p.autoOpenedFor === key) return
876 await update($, panel, prev => ({ ...prev, autoOpenedFor: key }))
877 void $.ui.open({ id: PANE, title: tr().pane })
878}
879
880const memo = makeMemo()
881// Per agent: how many events changed its thread, and the thread as fetched at some count.
882const threadVersion = new Map<string, number>()
883const threads = new Map<string, Thread>()
884const touchThread = (agentId: string | undefined): void => {
885 if (agentId) threadVersion.set(agentId, (threadVersion.get(agentId) ?? 0) + 1)
886}
887
888// The clock ticks only while something runs, for the elapsed times. It is cancelled when nothing does
889// and started again by the event that starts work. A subagent reused after it finished is noticed by
890// the rows its conversation then stores (`session.append`) and by its turn, spawn and message events,
891// not by polling.
892const FAST_MS = 1000
893// The desktop's drawings count the seconds themselves (see `clock`): there the clock only redraws them once a minute.
894const DESKTOP_MS = 60_000
895let ticker: { ms: number; timer: Timer } | undefined
896// The surfaces that drew the panel this session; any but the desktop writes its times as text.
897const paneSurfaces = new Set<string>()
898
899const isBusy = async ($: EngineInterface): Promise<boolean> =>
900 (await read($, agents)).some(a => a.status === 'running') || (await read($, root))?.status === 'running'
901
902async function syncTicker($: EngineInterface): Promise<void> {
903 const isDesktop = paneSurfaces.size > 0 && [...paneSurfaces].every(s => s === 'desktop')
904 const ms = (await isBusy($)) ? (isDesktop ? DESKTOP_MS : FAST_MS) : 0
905 if (ticker?.ms === ms) return
906 ticker?.timer.cancel()
907 ticker = ms ? { ms, timer: $.clock.every(ms, () => void tick($)) } : undefined
908}
909
910// Ticks the running clocks (agents', and the main session's mid-turn).
911async function tick($: EngineInterface): Promise<void> {
912 const at = await $.clock.now()
913 if (await isBusy($)) await update($, now, () => at)
914 await syncTicker($)
915}
916
917// The band's drawing as last made. While its inputs stand it is the same string, so the engine does not
918// load it again; a changed input draws it anew, from the clock at that time (see `barMotionCss`).
919let band: { key: string; source: string } | undefined
920const bandDrawing = (f: Flow, width: number, isWorking: boolean, at: number): string => {
921 const key = JSON.stringify([f, width, isWorking])
922 if (band?.key !== key) band = { key, source: rowSvg(f, width, isWorking, at) }
923 return band.source
924}
925
926// The time for the band's phase; a host with no clock answers with the process's.
927const bandClock = async ($: EngineInterface): Promise<number> => {
928 try {
929 return await $.clock.now()
930 } catch {
931 return Date.now()
932 }
933}
934
935export const register: Register = (on, options) => {
936 on('session.start', async ($, e, next) => {
937 const started = await next(e)
938 // A reload starts from nothing: no timer of the old module, no drawing kept.
939 ticker?.timer.cancel()
940 ticker = undefined
941 memo.reset()
942 threads.clear()
943 paneSurfaces.clear()
944 band = undefined
945 // What the panel's state says now: finished subagents and the open thread, for the hooks that mirror them.
946 const kept = await read($, agents)
947 finishedAgents.clear()
948 for (const a of kept) if (a.agentId && a.status !== 'running') finishedAgents.add(a.agentId)
949 const open = (await read($, panel)).viewing
950 viewingAgent = kept.find(a => a.id === open)?.agentId ?? ''
951 await $.tool.register({
952 name: 'progress',
953 description:
954 'Report multi-agent flow progress (a /savvy-flow run or any orchestrated plan) to the progress bar above the prompt and the agents panel. ' +
955 'Call it after presenting the plan (title, total, tasks, phase "delegate"), each time a task is accepted (done), ' +
956 'when switching phase or re-planning (tasks), and once at the end with finished: true. Fields left out keep their previous value. ' +
957 'For the main session only: a call from a subagent is ignored (workers report with `step`).',
958 inputSchema: {
959 type: 'object',
960 properties: {
961 title: { type: 'string', description: 'Short name of the overall task, a few words.' },
962 total: { type: 'integer', minimum: 0, description: 'Number of planned worker tasks.' },
963 done: { type: 'integer', minimum: 0, description: 'Number of tasks accepted after review.' },
964 phase: { type: 'string', enum: [...PHASES] },
965 finished: { type: 'boolean', description: 'True once the flow is closed.' },
966 tasks: {
967 type: 'array',
968 description:
969 'The planned worker tasks in order, numbered from 1. Each title must equal the Agent tool `description` the task will be delegated with, so the panel can match runs to tasks.',
970 items: {
971 type: 'object',
972 properties: {
973 title: { type: 'string', description: 'A few words; reused verbatim as the Agent description.' },
974 tier: { type: 'string', enum: [...TIERS] },
975 after: { type: 'array', items: { type: 'integer' }, description: 'Numbers of the tasks this one waits for.' },
976 },
977 required: ['title', 'tier'],
978 },
979 },
980 },
981 },
982 })
983 await $.tool.register({
984 name: 'step',
985 description:
986 'For subagents: report progress on your own task to the agents panel. ' +
987 'Right after reading the brief, call it with `total` (your plan in 3-8 steps) and `done: 0`; ' +
988 'call it again as each step finishes. Cheap and silent: it only draws a bar.',
989 inputSchema: {
990 type: 'object',
991 properties: {
992 done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
993 total: { type: 'integer', minimum: 1, description: 'Steps planned; may change if the plan changes.' },
994 note: { type: 'string', description: 'The step in progress, a few words.' },
995 },
996 required: ['done'],
997 },
998 // In every agent's tool list, not behind ToolSearch: workers do not go looking for it.
999 isDeferred: false,
1000 })
1001 await $.command.register({
1002 name: COMMAND,
1003 description: 'Show or hide the crew panel: the main session plus running, finished and planned subagents, with model, context, cost and time',
1004 argumentHint: 'open',
1005 })
1006
1007 // The main session's row: when the session began, and its live window, as the
1008 // status line has them (SessionUsage). A reload keeps the counts already made.
1009 const at = await $.clock.now()
1010 let usage: SessionUsage | undefined
1011 try {
1012 usage = await $.session.usage()
1013 } catch {
1014 // No figures from this host: count from now, the window filled in by the first measure or step.
1015 }
1016 await update($, root, prev => {
1017 const base = prev ?? newRoot(usage?.startedAt ?? at)
1018 const dated = usage ? { ...base, startedAt: usage.startedAt } : base
1019 return usage ? withContext(dated, usage.context) : dated
1020 })
1021
1022 await syncTicker($)
1023 // Once per session: a hot reload after the person closed the panel does not reopen it.
1024 if (options.openAtStart !== 'off') await autoOpen($, 'session-start')
1025 return started
1026 })
1027
1028 on('command.run', { command: COMMAND }, async ($, e) => {
1029 // `/crew open` opens the panel, or brings it forward from behind another tab, without toggling it.
1030 if ((e.args ?? '').trim() === 'open') {
1031 const at = await $.clock.now()
1032 await update($, now, () => at)
1033 await $.ui.open({ id: PANE, title: tr().pane })
1034 return { text: tr().opened }
1035 }
1036 const isOpen = await togglePane($)
1037 return { text: isOpen ? tr().opened : tr().closed }
1038 })
1039
1040 on('tool.call', { tool: TOOL }, async ($, e) => {
1041 // The flow is the main session's: a call from a worker's loop carries its agentId
1042 // and changes nothing, or any subagent could rename, finish or reset the flow.
1043 if (e.agentId) return { result: 'ignored: only the main session reports the flow' }
1044 const input = e as unknown as ProgressInput
1045 const prev = await read($, flow)
1046 if (isNewFlow(prev, input) && input.title !== undefined) {
1047 // A new flow starts with a clean list; agents still running stay.
1048 await update($, agents, list => list.filter(a => a.status === 'running'))
1049 await syncTicker($)
1050 }
1051 const next = await update($, flow, p => merge(p, input))
1052 if (next && input.tasks?.length) await autoOpen($, next.title)
1053 return { result: `ok: ${label(next ?? blank())}` }
1054 })
1055
1056 // A worker's own progress: the call runs in the worker's loop, so agentId names it.
1057 on('tool.call', { tool: STEP_TOOL }, async ($, e) => {
1058 const input = e as unknown as { done?: number; total?: number; note?: string }
1059 const agentId = e.agentId
1060 if (!agentId) return { result: 'ignored: only subagents report steps' }
1061 let matched = false
1062 await update($, agents, list =>
1063 list.map(a => {
1064 if (a.agentId !== agentId) return a
1065 matched = true
1066 const total = Math.max(0, Math.round(input.total ?? a.stepTotal ?? 0))
1067 const done = Math.max(0, Math.round(input.done ?? a.stepDone ?? 0))
1068 return { ...a, stepTotal: total, stepDone: total ? Math.min(total, done) : done, stepNote: input.note?.trim() || undefined }
1069 }),
1070 )
1071 return { result: matched ? 'ok' : `ignored: no panel row for agent ${agentId}` }
1072 })
1073
1074 // Safety net: worker launches move the faint layer even if the orchestrator forgets to report.
1075 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
1076 const type = String(e.subagent_type ?? '')
1077 if (!type.startsWith('savvy-')) return next(e)
1078
1079 await update($, flow, prev => {
1080 const base = prev && !prev.isFinished ? { ...blank(), ...prev } : blank()
1081 return { ...base, running: base.running + 1, phase: base.phase === 'plan' ? 'delegate' : base.phase }
1082 })
1083 try {
1084 return await next(e)
1085 } finally {
1086 await update($, flow, prev => (prev ? { ...prev, running: Math.max(0, prev.running - 1) } : prev))
1087 }
1088 })
1089
1090 // A message to a finished subagent resumes it with a new task: its row is renewed
1091 // before the send, as a resumed run may end before the call answers.
1092 on('tool.call', { tool: 'SendMessage' }, async ($, e, next) => {
1093 const input = e as unknown as { to?: unknown; summary?: string; message?: string }
1094 const to = typeof input.to === 'string' ? input.to.replace(/\s*\[[^\]]*\]$/, '').trim() : ''
1095 const list = await read($, agents)
1096 if (!to || !list.some(a => a.agentId && a.status !== 'running')) return next(e)
1097 let id = list.some(a => a.agentId === to) ? to : undefined
1098 if (!id) {
1099 try {
1100 id = (await $.agent.list()).find(l => l.id === to || l.name === to)?.id
1101 } catch {
1102 // No agent list from this host: the resume's own spawn renews the row.
1103 }
1104 }
1105 const row = id ? list.find(a => a.agentId === id) : undefined
1106 if (!row || row.status === 'running') return next(e)
1107 const at = await $.clock.now()
1108 finishedAgents.delete(row.agentId as string)
1109 await update($, agents, rows => renew(rows, row.agentId as string, at, { summary: input.summary, prompt: input.message }))
1110 await update($, now, () => at)
1111 await syncTicker($)
1112 const sent = await next(e)
1113 // Refused, nothing resumed: the row is as it was.
1114 if (sent.deny !== undefined) {
1115 await update($, agents, rows => rows.map(a => (a.id === row.id && a.status === 'running' ? row : a)))
1116 await syncTicker($)
1117 }
1118 return sent
1119 })
1120
1121 on('agent.spawn', async ($, e, next) => {
1122 // A resume renews its row before the run starts, as the run may end before the
1123 // spawn answers; a refused resume leaves it as it was.
1124 const resumed = e.resume?.agentId
1125 const before = resumed ? (await read($, agents)).find(a => a.agentId === resumed) : undefined
1126 if (resumed && before) {
1127 const at = await $.clock.now()
1128 finishedAgents.delete(resumed)
1129 await update($, agents, list => renew(list, resumed, at, { prompt: e.prompt }))
1130 await update($, now, () => at)
1131 await syncTicker($)
1132 const started = await next(e)
1133 if (started.deny !== undefined) {
1134 await update($, agents, list => list.map(a => (a.id === before.id && a.status === 'running' && before.status !== 'running' ? before : a)))
1135 await syncTicker($)
1136 }
1137 return started
1138 }
1139
1140 const isUnbriefed = e.subagentType.startsWith('savvy-') && !e.prompt.includes(STEP_TOOL)
1141 const started = await next(isUnbriefed ? { ...e, prompt: e.prompt + STEP_BRIEF } : e)
1142 if (started.deny !== undefined) return started
1143
1144 const at = await $.clock.now()
1145 await update($, agents, list => {
1146 const round = 1 + list.filter(a => norm(a.description) === norm(e.description) && e.description).length
1147 const run: AgentRun = {
1148 id: started.agentId ?? e.tool_use_id,
1149 agentId: started.agentId,
1150 type: e.subagentType,
1151 role: roleOfSpawn(e.subagentType, e.prompt),
1152 description: e.description,
1153 model: started.model,
1154 status: 'running',
1155 startedAt: at,
1156 contextTokens: 0,
1157 contextMax: windowOf(started.model),
1158 inputTokens: 0,
1159 outputTokens: 0,
1160 costUsd: 0,
1161 steps: 0,
1162 round,
1163 }
1164 return [...list.filter(a => a.id !== run.id), run].slice(-200)
1165 })
1166 await update($, now, () => at)
1167 await syncTicker($)
1168 if (e.subagentType.startsWith('savvy-')) {
1169 const f = await read($, flow)
1170 await autoOpen($, f && !f.isFinished ? f.title : 'savvy-flow')
1171 } else if (options.autoOpen === 'any') {
1172 await autoOpen($, 'any-agent')
1173 }
1174 return started
1175 })
1176
1177 // A turn starts: the main loop's row walks while it runs, and a subagent's finished
1178 // row, its agent reused, is running again.
1179 on('turn.start', async ($, e, next) => {
1180 const started = await next(e)
1181 const agentId = e.agentId
1182 const at = await $.clock.now()
1183 if (agentId === undefined) {
1184 await update($, root, (prev): AgentRun => ({ ...(prev ?? newRoot(at)), status: 'running' }))
1185 await update($, now, () => at)
1186 await syncTicker($)
1187 } else if ((await read($, agents)).some(a => a.agentId === agentId && a.status !== 'running')) {
1188 finishedAgents.delete(agentId)
1189 await update($, agents, list => renew(list, agentId, at, { prompt: e.text }))
1190 await update($, now, () => at)
1191 await syncTicker($)
1192 }
1193 return started
1194 })
1195
1196 // Each model request: live context, tokens and cost, of a subagent or of the main loop.
1197 on('turn.step', async function* ($, e, next) {
1198 const result = yield* next(e)
1199 const agentId = e.agentId
1200 const usage = result.usagehooks/crabs.ts 280 lines1// The crab drawings: pixel grids, costumes and their CSS motion. Pure and
2// dependency-free, so register.tsx draws with it and tools/render-costumes.mjs
3// renders the README gallery from the same source.
4
5// The crab's drawing grid is 30 units wide, props included.
6export const CRAB_BOX = 30
7
8export const TIER_COLOR: Record<string, string> = {
9 fable: '#7F77DD',
10 heavy: '#D85A30',
11 careful: '#BA7517',
12 medium: '#378ADD',
13 light: '#1D9E75',
14 other: '#888780',
15}
16
17// Each workflow profile's accent; `root` is the main session's crown.
18export const PROFILE_COLOR: Record<string, string> = {
19 root: '#D4A017',
20 manager: '#7C5CD6',
21 explorer: '#1AA6B7',
22 implementer: '#E07B39',
23 validator: '#3BA55C',
24 critic: '#D64550',
25 documenter: '#8D6E4A',
26 tester: '#3C7DD9',
27 researcher: '#A64CA6',
28 debugger: '#8DB23A',
29 analyst: '#5E6AD2',
30 architect: '#24508F',
31 auditor: '#16806A',
32 navigator: '#E0588F',
33 inspector: '#8E2F45',
34 // Claude Code's own agent types that have no workflow profile.
35 generalist: '#F2B91F',
36 guide: '#4DB6F0',
37 statusline: '#B0B34A',
38}
39
40// A tier's or a profile's accent; grey for anything else.
41export const colorOf = (role: string): string =>
42 Object.hasOwn(TIER_COLOR, role) ? (TIER_COLOR[role] ?? '#888780') : Object.hasOwn(PROFILE_COLOR, role) ? (PROFILE_COLOR[role] ?? '#888780') : '#888780'
43
44// Pixel Clawd from DockCrab (Clawdy): a 24×18 crab on a 30×28 grid, one costume per profile.
45// The body keeps the brand clay; the profile's color lives in the costume's accent.
46export const CLAY = '#D97757'
47export const INK = '#1F1E1D'
48
49// `cls` puts a pixel in a named group: `bd` (the default) is the body and its
50// costume, `la`/`lb` the leg pairs, anything else a prop with its own motion.
51export type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
52
53export const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
54 rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
55
56// `armCls` lets a raised claw travel with the prop it holds.
57export const crabBody = (f: Fill, armFront = 0, armCls?: string): void => {
58 f(7, 10, 16, 12, CLAY)
59 f(3, 14, 4, 4, CLAY)
60 f(23, 14 + armFront, 4, 4, CLAY, armCls)
61 f(9, 12, 2, 2, INK)
62 f(19, 12, 2, 2, INK)
63 f(7, 22, 2, 4, CLAY, 'la')
64 f(17, 22, 2, 4, CLAY, 'la')
65 f(11, 22, 2, 4, CLAY, 'lb')
66 f(21, 22, 2, 4, CLAY, 'lb')
67}
68
69// Pure CSS, run by the compositor: no redraws. Periods divide one second, so the
70// once-a-second redraw of a running row restarts them in phase. Every crab walks;
71// each costume adds its prop's own motion on top.
72export const CRAB_CSS = `<style>
73.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
74.run .bd{animation:bob .5s steps(1) infinite -.125s}
75.run g{transform-box:fill-box}
76@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
77@keyframes blink{50%{opacity:0}}
78.c-root.run .gem{animation:blink 1s steps(1) infinite}
79.c-root.run .gem2{animation:blink 1s steps(1) infinite -.5s}
80.c-root.run .spark{animation:blink .5s steps(1) infinite}
81.c-manager.run .baton{transform-origin:50% 100%;animation:conduct .5s steps(1) infinite}
82@keyframes conduct{50%{transform:rotate(-30deg)}}
83.c-explorer.run .lan{transform-origin:50% 0;animation:swing 1s steps(1) infinite}
84@keyframes swing{0%{transform:rotate(10deg)}50%{transform:rotate(-10deg)}}
85.c-implementer.run .ham{transform-origin:50% 100%;animation:hammer .5s steps(1) infinite}
86@keyframes hammer{50%{transform:rotate(-40deg)}}
87.c-validator.run .ck{animation:blink .5s steps(1) infinite}
88.c-critic.run .mono{animation:blink 1s steps(1) infinite -.5s}
89.c-documenter.run .quill{transform-origin:0 100%;animation:write .5s steps(1) infinite}
90@keyframes write{50%{transform:rotate(15deg)}}
91.c-tester.run .bub{animation:rise .5s steps(2) infinite}
92@keyframes rise{100%{transform:translateY(-2px);opacity:0}}
93.c-researcher.run .pg{transform-origin:0 50%;animation:leaf 1s steps(1) infinite}
94@keyframes leaf{50%{transform:scaleX(-1)}}
95.c-debugger.run .net{transform-origin:50% 100%;animation:sweep .5s steps(1) infinite}
96.c-debugger.run .bug{animation:buzz .25s steps(1) infinite}
97@keyframes sweep{50%{transform:rotate(-25deg)}}@keyframes buzz{50%{transform:translate(1px,-1px)}}
98.c-analyst.run .bars{transform-origin:50% 100%;animation:grow 1s steps(1) infinite}
99@keyframes grow{0%,100%{transform:scaleY(.4)}50%{transform:scaleY(1)}}
100.c-architect.run .sq{transform-origin:0 100%;animation:measure 1s steps(1) infinite}
101@keyframes measure{50%{transform:rotate(-15deg)}}
102.c-auditor.run .glint{animation:blink .5s steps(1) infinite}
103.c-navigator.run .cur{animation:click .5s steps(1) infinite}
104.c-navigator.run .link{animation:blink .5s steps(1) infinite -.25s}
105@keyframes click{50%{transform:translate(1px,1px)}}
106.c-inspector.run .flash{animation:blink 1s steps(1) infinite}
107.c-inspector.run .note{animation:rise 1s steps(4) infinite}
108.c-generalist.run .blade{transform-origin:50% 100%;animation:sweep .5s steps(1) infinite}
109.c-guide.run .flag{transform-origin:0 50%;animation:wave .5s steps(1) infinite}
110@keyframes wave{50%{transform:scaleX(.5)}}
111.c-statusline.run .roller{animation:roll 1s steps(2) infinite}
112@keyframes roll{50%{transform:translateX(-4px)}}
113@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}
114</style>`
115
116export const COSTUMES: Record<string, (f: Fill, t: string) => void> = {
117 // Root: the main session, crowned; the jewels glint and the sparkle twinkles.
118 root: (f, t) => {
119 crabBody(f)
120 stamp(f, 9, 4, ['g....gg....g', 'gg..gggg..gg', 'gggggggggggg', 'gggggggggggg', 'gggggggggggg', 'dddddddddddd'], { g: t, d: '#A67C00' })
121 f(10, 7, 1, 1, '#D64550'); f(14, 7, 2, 1, '#3C7DD9'); f(19, 7, 1, 1, '#D64550')
122 f(10, 7, 1, 1, '#FFFFFF', 'gem'); f(19, 7, 1, 1, '#FFFFFF', 'gem'); f(14, 7, 1, 1, '#FFFFFF', 'gem2')
123 f(25, 3, 1, 3, '#FFD84D', 'spark'); f(24, 4, 3, 1, '#FFD84D', 'spark')
124 },
125 // Manager: headset with a mic boom; the baton conducts the crew.
126 manager: (f, t) => {
127 crabBody(f, -2)
128 stamp(f, 6, 5, ['...kkkkkkkkkkkk...', '.kk............kk.', 'k................k'], { k: '#3A3A3C' })
129 f(5, 8, 2, 4, t); f(23, 8, 2, 4, t)
130 f(6, 12, 1, 2, '#3A3A3C'); f(7, 14, 3, 1, '#3A3A3C'); f(10, 14, 1, 1, t)
131 f(26, 3, 1, 9, '#C9CCD2', 'baton'); f(26, 2, 1, 1, t, 'baton')
132 },
133 // Explorer: pith helmet; the lantern swings from the raised claw.
134 explorer: (f, t) => {
135 crabBody(f, -4)
136 stamp(f, 6, 4, ['......kkkkkk......', '....kkkkkkkkkk....', '...kkkkkkkkkkkk...', '...kkkkkkkkkkkk...', '...tttttttttttt...', 'bbbbbbbbbbbbbbbbbb'], { k: '#D9C38C', t, b: '#BFA76A' })
137 f(26, 1, 1, 1, '#3A3A3C', 'lan'); f(25, 2, 3, 1, '#3A3A3C', 'lan'); f(25, 3, 3, 3, '#FFD25A', 'lan')
138 f(26, 4, 1, 1, '#FFF4C2', 'lan'); f(25, 6, 3, 1, '#3A3A3C', 'lan'); f(26, 7, 1, 3, '#3A3A3C', 'lan')
139 },
140 // Implementer: bandana and a claw hammer that strikes.
141 implementer: (f, t) => {
142 crabBody(f)
143 stamp(f, 7, 6, ['....tttttttt....', '..tttttttttttt..', '.tttwttttttwttt.', 'tttttttttttttttt'], { t, w: '#F8F6F1' })
144 f(5, 8, 2, 1, t); f(4, 9, 2, 1, t)
145 f(25, 5, 1, 9, '#8B5A2B', 'ham'); f(23, 2, 5, 3, '#6E7178', 'ham'); f(23, 2, 1, 1, '#A9ADB5', 'ham')
146 },
147 // Validator: visor cap and a clipboard whose check mark blinks.
148 validator: (f, t) => {
149 crabBody(f, -4)
150 stamp(f, 7, 6, ['...tttttttttt...', '..tttttttttttt..', '.tttttttttttttt.'], { t })
151 f(3, 9, 20, 1, '#2C7A44')
152 stamp(f, 24, 1, ['..kk..', 'bbbbbb', 'bwwwwb', 'bwwwwb', 'bwwwwb', 'bwwwwb', 'bwwwwb', 'bbbbbb'], { k: '#8E929A', b: '#7A4A26', w: '#F8F6F1' })
153 f(25, 9, 2, 1, '#7A4A26')
154 f(28, 3, 1, 1, t, 'ck'); f(27, 4, 1, 1, t, 'ck'); f(25, 4, 1, 1, t, 'ck'); f(26, 5, 1, 1, t, 'ck')
155 },
156 // Critic: beret and a gold monocle whose lens glints.
157 critic: (f, t) => {
158 crabBody(f)
159 stamp(f, 8, 5, ['......dd......', '...tttttttt...', '.tttttttttttt.', 'tttttttttttttt', '.tttttttttttt.'], { t, d: '#7A1F27' })
160 stamp(f, 17, 10, ['.gggg.', 'g....g', 'g....g', 'g....g', '.gggg.'], { g: '#C9A227' })
161 f(18, 11, 4, 3, 'rgba(169,214,245,.45)'); f(22, 15, 1, 3, '#C9A227'); f(21, 18, 1, 1, '#C9A227')
162 f(18, 11, 1, 1, '#fff', 'mono')
163 },
164 // Documenter: a scroll in one claw and a quill that writes in the other.
165 documenter: (f, t) => {
166 crabBody(f, -4)
167 stamp(f, 24, 1, ['....w', '...ww', '..wwg', '.wwg.', 'wwg..', 'w....'], { w: '#E6E3D8', g: '#B5B0A0' }, 'quill')
168 f(24, 7, 1, 3, INK, 'quill')
169 stamp(f, 0, 18, ['rrrrrr', '.pppp.', '.plll.', '.pppp.', '.plll.', 'rrrrrr'], { r: '#C9A66B', p: '#EBD9A8', l: t })
170 },
171 // Tester: lab goggles and a flask that bubbles.
172 tester: (f, t) => {
173 crabBody(f, -4)
174 f(7, 12, 16, 1, '#55514C'); f(8, 11, 4, 4, '#3A3A3C'); f(18, 11, 4, 4, '#3A3A3C')
175 f(9, 12, 2, 2, 'rgba(169,214,245,.9)'); f(19, 12, 2, 2, 'rgba(169,214,245,.9)')
176 stamp(f, 23, 2, ['..gg..', '..gg..', '.gaag.', 'gaaaag', 'gllllg', 'gllllg', '.gggg.'], { g: '#C9CCD2', a: 'rgba(169,214,245,.35)', l: t })
177 f(25, 0, 1, 1, '#9CC4EE', 'bub'); f(26, 1, 1, 1, '#BFD4F2', 'bub')
178 },
179 // Researcher: mortarboard with a tassel; the open book's page flips.
180 researcher: (f, t) => {
181 crabBody(f, -4)
182 f(10, 5, 10, 1, '#2B2B2E'); f(7, 6, 15, 1, '#2B2B2E'); f(9, 7, 12, 3, '#3A3A3C'); f(14, 5, 2, 1, t)
183 f(7, 7, 1, 3, t); f(6, 10, 2, 2, t)
184 stamp(f, 24, 4, ['www', 'gg.', 'www', 'gg.', 'www'], { w: '#F4F3EE', g: '#C9C5BA' })
185 f(23, 9, 7, 1, t); f(23, 5, 1, 4, t)
186 stamp(f, 27, 4, ['www', '.gg', 'www', '.gg', 'www'], { w: '#FFFFFF', g: '#C9C5BA' }, 'pg')
187 },
188 // Debugger: a bug net that swings at the ladybird buzzing by.
189 debugger: (f, t) => {
190 crabBody(f)
191 stamp(f, 22, 0, ['..rrr..', '.rmmmr.', 'rmmmmmr', 'rmmmmmr', '.rmmmr.', '..rrr..'], { r: t, m: 'rgba(244,243,238,.55)' }, 'net')
192 f(25, 6, 1, 8, '#8B5A2B', 'net')
193 stamp(f, 1, 6, ['k.k', '.r.', 'rkr', '.r.'], { k: INK, r: '#D64550' }, 'bug')
194 },
195 // Analyst: a tie and a chart board whose bars rise.
196 analyst: (f, t) => {
197 crabBody(f, -4)
198 f(14, 16, 2, 1, t); f(14, 17, 2, 3, t); f(14, 20, 1, 1, t)
199 stamp(f, 22, 0, ['kkkkkkkk', 'kwwwwwwk', 'kwwwwwwk', 'kwwwwwwk', 'kwwwwwwk', 'kwwwwwwk', 'kwwwwwwk', 'kwwwwwwk', 'kwwwwwwk', 'kkkkkkkk'], { k: '#55514C', w: '#F8F6F1' })
200 f(23, 6, 1, 3, t, 'bars'); f(25, 4, 1, 5, t, 'bars'); f(27, 2, 1, 7, t, 'bars')
201 },
202 // Architect: a blueprint roll under one claw; the set square measures in the other.
203 architect: (f, t) => {
204 crabBody(f, -4)
205 stamp(f, 0, 11, ['lttttttt', 'ltwtwtwt', 'lttttttt'], { l: '#BFD4F2', t, w: '#BFD4F2' })
206 stamp(f, 23, 4, ['y.....', 'yy....', 'y.y...', 'y..y..', 'y...y.', 'yyyyyy'], { y: '#F2B134' }, 'sq')
207 },
208 // Auditor: a shield with a padlock whose shackle glints.
209 auditor: (f, t) => {
210 crabBody(f)
211 stamp(f, 0, 9, ['sssssss', 'sttttts', 'sttttts', 'sttttts', 'sttttts', '.sttts.', '.sttts.', '..sts..', '...s...'], { s: '#C9CCD2', t })
212 stamp(f, 2, 10, ['.g.', 'g.g', 'yyy', 'yky'], { g: '#E6E8EE', y: '#F5C542', k: INK })
213 f(2, 11, 1, 1, '#FFFFFF', 'glint')
214 },
215 // Navigator: a browser window whose cursor clicks a link.
216 navigator: (f, t) => {
217 crabBody(f, -4)
218 stamp(f, 21, 1, ['ttttttttt', 'tRtYtGttt', 'twwwwwwwt', 'twwwwwwwt', 'twwwwwwwt', 'twwwwwwwt', 'twwwwwwwt', 'twwwwwwwt', 'ttttttttt'], { t, w: '#F8F6F1', R: '#FF5F57', Y: '#FEBC2E', G: '#28C840' })
219 f(23, 4, 5, 1, '#C9CCD2'); f(23, 6, 3, 1, t, 'link')
220 stamp(f, 25, 5, ['k..', 'kk.', 'kkk', '.k.'], { k: INK }, 'cur')
221 },
222 // Inspector: headphones and a camera on its chest; the flash fires, a note rises.
223 inspector: (f, t) => {
224 crabBody(f)
225 f(10, 5, 10, 1, t); f(8, 6, 2, 1, t); f(20, 6, 2, 1, t); f(7, 7, 1, 2, t); f(22, 7, 1, 2, t)
226 f(4, 9, 3, 5, '#3A3A3C'); f(23, 9, 3, 5, '#3A3A3C'); f(5, 10, 1, 3, t); f(24, 10, 1, 3, t)
227 stamp(f, 11, 16, ['kkkkkkkk', 'kkkggkkk', 'kkgllgkk', 'kkgllgkk', 'kkkggkkk'], { k: '#3A3A3C', g: t, l: 'rgba(169,214,245,.85)' })
228 f(12, 15, 2, 1, '#FFF4C2', 'flash')
229 stamp(f, 26, 3, ['..kk', '..k.', '..k.', 'kkk.', 'kk..'], { k: t }, 'note')
230 },
231 // Generalist: a plain beanie and a Swiss army knife; the main blade flicks.
232 generalist: (f, t) => {
233 crabBody(f, -4)
234 stamp(f, 8, 5, ['..tttttttttt..', '.tttttttttttt.', 'tttttttttttttt', 'cccccccccccccc', 'cccccccccccccc'], { t, c: '#B8881A' })
235 f(11, 6, 2, 1, '#FFFFFF')
236 const lt = '#C4C9D2', dk = '#7D838F'
237 stamp(f, 23, 0, ['ld', 'ld', 'ld', 'ld', 'ld', 'ld', 'ld'], { l: lt, d: dk }, 'blade')
238 f(23, 0, 1, 1, '#FFFFFF', 'blade')
239 stamp(f, 26, 2, ['ld', 'ld', 'ld', 'ld', 'ld'], { l: lt, d: dk })
240 stamp(f, 29, 4, ['d', 'd', 'd'], { d: dk })
241 stamp(f, 22, 7, ['rrrrrrrr', 'rwrrrrrr', 'wwwrrrrr', 'rwrrrrrr', 'dddddddd'], { r: '#D52B1E', w: '#FFFFFF', d: '#8F1A12' })
242 },
243 // Guide: a red info cap and a tour flag on a pole; the flag waves.
244 guide: (f, t) => {
245 crabBody(f, -4)
246 stamp(f, 8, 5, ['..rrrrrrrrrr..', '.rrrrrrrrrrrr.', 'rrrrrrrrrrrrrr', 'rrrrrrrrrrrrrr', 'ddddddddddddddddddd'], { r: '#FF3B30', d: '#A31218' })
247 f(13, 6, 2, 1, '#FFFFFF'); f(13, 8, 2, 1, '#FFFFFF')
248 f(25, 0, 1, 13, '#8B5A2B')
249 f(26, 0, 4, 5, t, 'flag'); f(27, 1, 2, 1, '#FFFFFF', 'flag'); f(27, 3, 2, 1, '#FFFFFF', 'flag')
250 },
251 // Statusline: a paint roller that rolls over the status bar it is painting.
252 statusline: (f, t) => {
253 crabBody(f, -4)
254 stamp(f, 3, 3, ['kkkkkkkkkkkkkkkk', 'kttttttwwwwwwwwk', 'kttttttwwwwwwwwk', 'kkkkkkkkkkkkkkkk'], { k: '#3A3A3C', t, w: '#8E929A' })
255 f(8, 6, 1, 1, '#FFFFFF'); f(10, 6, 1, 1, '#FFFFFF')
256 f(25, 5, 1, 9, '#6E7178', 'roller'); f(22, 7, 4, 1, '#6E7178', 'roller')
257 f(22, 1, 7, 3, t, 'roller'); f(22, 4, 7, 1, '#7C8035', 'roller'); f(22, 0, 7, 1, '#D8DB8A', 'roller')
258 },
259 other: f => crabBody(f),
260}
261
262export const CRAB_SCALE = 1.1
263
264// Body and props nest inside `bd` so a prop rides the bob and adds its own motion;
265// legs stay outside it and step on their own.
266export const crab = (x: number, y: number, costume: string, dim = false, isWalking = false, scale = CRAB_SCALE): string => {
267 const groups = new Map<string, string[]>([['bd', []]])
268 const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
269 if (!groups.has(cls)) groups.set(cls, [])
270 groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
271 }
272 const draw = COSTUMES[costume] ?? ((g: Fill) => crabBody(g))
273 draw(f, colorOf(costume))
274 const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
275 const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
276 const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
277 const place = `translate(${x},${y}) scale(${scale})`
278 return `<g transform="${place}" opacity="${dim ? 0.45 : 1}" shape-rendering="crispEdges"><g class="c-${costume}${isWalking ? ' run' : ''}">${body}${group('la')}${group('lb')}</g></g>`
279}
280types/index.d.ts 74 lines1export type Phase = 'plan' | 'design' | 'delegate' | 'review' | 'close'
2
3export type PlannedTask = {
4 title: string
5 tier: string
6 after: number[]
7}
8
9export type Flow = {
10 title: string
11 total: number
12 done: number
13 running: number
14 phase: Phase
15 isFinished: boolean
16 tasks: PlannedTask[]
17}
18
19/** `idle` is the main session's row between turns. */
20export type AgentStatus = 'running' | 'done' | 'failed' | 'idle'
21
22export type AgentRun = {
23 id: string
24 agentId?: string
25 type: string
26 /**
27 * What the run is drawn as: a workflow profile (`implementer`, from its type or its
28 * brief's `Profile:` tag), a savvy tier, `root` for the main session, or `other`.
29 */
30 role?: string
31 description: string
32 model: string
33 effort?: string
34 status: AgentStatus
35 startedAt: number
36 endedAt?: number
37 /** Time spent finished between runs of a reused agent, left out of its elapsed time. */
38 idleMs?: number
39 contextTokens: number
40 contextMax: number
41 /** Prompt-side tokens summed over requests, cache reads and writes included. */
42 inputTokens: number
43 outputTokens: number
44 costUsd: number
45 steps: number
46 round: number
47 /** Self-reported by the worker through the `step` tool. */
48 stepDone?: number
49 stepTotal?: number
50 stepNote?: string
51}
52
53export type Panel = {
54 isCompact: boolean
55 isDoneCollapsed: boolean
56 autoOpenedFor: string
57 /** The run whose thread the desktop pane shows in place of the agents; '' for none. */
58 viewing?: string
59}
60
61declare module 'claude-code' {
62 interface PluginState {
63 'the-crew': {
64 flow: Flow | null
65 agents: AgentRun[]
66 /** The main session's own usage, drawn as the panel's first row. */
67 root: AgentRun | null
68 panel: Panel
69 now: number
70 threadRows: number
71 }
72 }
73}
74