SLOPSHOPPER

the-crew

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…

newpanebandguardcommandtool
v1.0.1MITupdated 2026-10-26Densetsu152637/The-Crew
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · the-crew
│ ┃ crew-agents ✕ › fix the failing auth test and add an audit log call │ ┃ Collapse │ ┃ ≈$0.00 · 0 in · 0 out · 30:00 ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ▣ Main session ○ ⏺ Update(src/auth.ts) │ ┃ root · — ⎿ Added 2 lines, removed 1 line │ ┃ ██████████░░░░░░░░░░ ctx 49% · 97k ≈$0.00… ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ No subagents yet. │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /crew │ ⎿ the-crew: Agents panel closed. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · crew-agents
Collapse ≈$0.00 · 0 in · 0 out · 30:00 ▣ Main session ○ root · — ██████████░░░░░░░░░░ ctx 49% · 97k ≈$0.00 30:00 No subagents yet.
README

the-crew

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:

  • Main session row. The panel's first row is the main session itself, wearing a crown: its model and effort, how full its context window is, an estimated cost, and how long the session has run. It walks while a main turn runs and rests between turns.
  • A costume per workflow profile. A subagent is drawn as the profile it was dispatched with, each with its own pixel-crab costume and colour, instead of the generic crab.
  • A subagent's thread in the panel. In the desktop app's panel, clicking a subagent row's name replaces the agents list with that agent's conversation: its brief, its replies and a line per tool call. It opens at the newest message and follows new ones while you stay at the bottom; scroll up to read back, and scroll to the bottom again to resume following. ‹ Agents at the top and again below the newest message goes back to the list.
  • Reused subagents run again. A finished subagent that is messaged again (SendMessage, a resume, or any run the engine reports) moves back to the running list on its new task: its title becomes the message's summary (or its first line), its role follows a new brief's 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.

Costume gallery

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

Install

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.

Using it

  • /crew shows or hides the panel; /crew open opens it, or brings it forward, without toggling.
  • With the default autoOpen: any, the panel opens by itself once per session when a subagent starts, and whenever a flow reports a plan.

The main session row

ShownWhere it comes from
Model and effortThe main loop's latest model request (turn.step); — until the first one after the mod loads.
ContextThe 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 tokensEach 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 costEach main-loop request's token counts priced with the same table as the subagents (PRICES in hooks/register.tsx).
TimeSince 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.

Profiles and costumes

A subagent's costume is picked in this order, the first match winning:

  1. Its agent type is a profile name, e.g. implementer or my-plugin:implementer.
  2. Its brief carries a tag such as Role: leaf; Profile: implementer anywhere in it (the brief tag convention); the first Profile: counts.
  3. Its agent type is a Claude Code built-in: 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.
  4. Otherwise the plain crab, labelled with its savvy-flow tier (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.

Tools it adds

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

Settings

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" } } } }

Changes

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.

Development

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

Credits and licence

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.

Source 3 files
hooks/register.tsx 1683 lines
1import { 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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.usage
hooks/crabs.ts 280 lines
1// 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}
280
types/index.d.ts 74 lines
1export type Phase = 'plan' | 'design' | 'delegate' | 'review' | 'close'
2
3export type PlannedTask = {
4  title: string
5  tier: string
6  after: number[]
7}
8
9export type Flow = {
10  title: string
11  total: number
12  done: number
13  running: number
14  phase: Phase
15  isFinished: boolean
16  tasks: PlannedTask[]
17}
18
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