SLOPSHOPPER

plan-progress

Live plan progress bars above the Claude Code prompt: stages, steps, a pixel fill and soft sounds for decision, error and done

newbandguardcommandprompttool
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · plan-progress
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /progress ⎿ plan-progress: No plan yet. A bar appears when Claude starts a task with several steps. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

cc-mods

Claude Code mods (function-hook plugins), forked from other authors or written here, kept in one repo. Each mod lives under mods/<name>/. A forked one is a squashed git subtree of its upstream: the upstream's license stays with it, and each squash commit names the upstream commit it came from. One written here has no upstream and carries its own license.

The repo is also a plugin marketplace, so a forked mod can be installed from here once it carries local changes.

Mods

ModUpstreamLocal changesLicense
md-promptnogu66/md-prompt0.2.0: none. The dunder-path fix carried here as 0.1.1-cc.1 was taken upstream in 0.1.2 (nogu66/md-prompt#4), so the mod is upstream as isMIT
plan-progresszycck/claude-mods0.7.1-cc.10, on upstream 0.7.1: the usage rules ride in the tool description instead of prompt.compose (missing from the desktop engine when forked); no plan-mode import (ExitPlanMode → bar plan); no hidden demo-reel entry (it wrote a file to a model-given path); agent strip text and tool word follow the light/dark theme; only a running bar twinkles, and only near its head, and agent strips hold still; agent strips fold into one summary row by default (waiting and failed agents keep theirs), opened and folded again by the ▾ N / ▴ after the bar's title; finished bars leave after a minute; colours from the desktop app's own tokens, and a theme-aware state glyph; no Progress button in the footer (/progress and each bar's ✕ hide the bars); a permission dialog put to you and left unanswered sounds decision, on the engine's permission_prompt notification (upstream makes no sound for it); running clocks (a strip's, the pill's hover time) are text drawn again on each wall-clock second instead of CSS reels that run by themselves (the hover tips still flash and go: the desktop rebuilds an interactive frame about once a second whatever the plugins do; its own hover card, tried in 0.7.1-cc.5, holds but hangs on the whole track, so it was taken back), and a strip's status morph and the track head's slide play once (the desktop shows the band's last drawing again on repaints that never reach the mod, another plugin's redraw each second or a tool's timer, and that restarts its pictures: a clock stepping a second back, 1m 23s, 1m 24s, 1m 23s, its old word flashing, a finished bar's time sliding in again from mid-track), and a draw holding such a slide or morph is followed by one more as soon as it has played, since a finished bar has no running clock to bring that draw (its slide to the end stayed the last drawing, and each footer second of cache-timer flashed its head back to the step before); agents the main thread starts in a turn before it opens the bar for that work move onto that bar as it opens (an agent from an earlier turn stays where it is), and the mod's own Agents bar draws its strips alone, with no track, pill or percent (upstream counted finished agents as its steps, 0% until the last one ended), and leaves when they fold; an agent's strip names the agent definition it runs as before its model and effort (worker · sonnet 5.5 · high; none for general-purpose, the Agent tool's default) and, before its time, the context its last request carried (ctx 42k), with its share of the window only where the model id names that window ([1m]: ctx 251k · 25%), since the engine reports no subagent's window and Claude Code settles it from a model catalog a plugin cannot read; the head's twinkle and a running agent's dot are no CSS loops but step on the wall-clock second like the clocks, written in at every draw and still in between (the four twinkle groups take turns through their levels, the dot is lit one second and dimmed the next): every repaint starts a picture's CSS over, and a draw on an event lands off the second, so a loop jumped there and at the next second (upstream's 1.1 to 3.3 s loops were cut off each second); the test engine shims Uint8Array#toBase64 for Node before 25MIT
cache-timernone, written here (idea from @savvyntsev)0.3.2: counts down, as a dim label in the footer left of the model, how long the main thread's prompt cache stays warm; the lifetime starts at 1h and follows the API (a model switch names it, a hit or miss after 5 to 60 idle minutes tells 1h from 5m); it is drawn again just after each wall-clock second, the instant plan-progress draws its band, so the screen changes once a second, not twiceMIT

Install a mod from this repo

Function hooks are early access, so turn them on first in ~/.claude/settings.json:

{ "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
claude plugin marketplace add mtmtian/cc-mods
claude plugin install md-prompt@cc-mods

To install from a local checkout instead, so your own edits are what gets installed, pass its path: claude plugin marketplace add <path to this checkout>.

Install a mod from one marketplace only. With both md-prompt@nogu66 and md-prompt@cc-mods enabled, the prompt box is painted twice.

Change a mod

A local change bumps the mod's version in its .claude-plugin/plugin.json with a -cc.N suffix (0.1.1-cc.1, 0.1.1-cc.2, ...), since installed copies only update when the version changes. Then:

claude plugin marketplace update cc-mods
claude plugin update md-prompt@cc-mods

While working on a mod, --plugin-dir loads the checkout directly and reloads on save (for md-prompt: claude --plugin-dir mods/md-prompt/plugins/md-prompt); each mod's own README has its test commands.

Run the checks

Use the Node.js version in .node-version, then run from the repo root:

npm ci
npm run ci

The root CI workflow runs on every pull request, pushes to main, and manual dispatch. Test and plugin checks run on standard Ubuntu and macOS runners; workflow and shell checks run in a separate Ubuntu job:

CheckWhat it covers
npm testmd-prompt's Bun tests, plan-progress's asserted stub-engine regressions, and the upstream-status script's local Git fixtures
npm run check:pluginsMarketplace/source/name/license consistency, strict Claude manifest validation, native hook tests for md-prompt and cache-timer, and TypeScript checks for all three plugins
Workflow and shell checksactionlint for the active root workflow and ShellCheck for the repository's shell scripts

The plugin checks copy the plugins into a temporary directory, use a fresh Claude config, and generate SDK declarations for the locked CLI version there. /cost is a local command: no login or model API key is needed. The API endpoint is set to a closed local port, so an accidental model request fails. The temporary copies are removed even when a check fails. These checks do not replace testing real prompt editing, desktop rendering or audio in Claude Desktop.

Development tools are version-pinned in package.json and package-lock.json; Actions use full commit SHAs. Dependabot proposes weekly npm-tool and Action updates; update actionlint's version and checksum together in the workflow. Claude and Bun require their native-binary install scripts, approved by exact version in allowScripts. When updating either package, review its install script, update the approval with npm install-scripts approve <package>, and rerun the checks. CI has read-only repository permissions, cancels superseded PR runs, and uses neither uploaded artifacts nor dependency caches.

Check and pull upstream changes

upstreams.tsv lists each mod's upstream URL and branch. scripts/upstream-status.sh reads it and, per mod, prints the upstream commit last synced, the upstream commits since, the paths changed here, and the paths changed on both sides (overlap_path=, where a pull may conflict). It only fetches; it never pulls or edits a mod. scripts/test-upstream-status.sh checks it against a throwaway upstream.

bash scripts/upstream-status.sh

To take the upstream changes, with the URL and branch from upstreams.tsv (on a branch, merged with a merge commit so the squash commit's git-subtree-split trailer stays on main):

git subtree pull --prefix=mods/<name> <url> <branch> --squash

Resolve conflicts by keeping each local change on top of upstream's version, never by taking one side whole. Then check that every local change still holds: the mod's tests (each local change has a regress case where it can), and a strict type check against the engine's types, which catches call sites the two sides changed differently.

Add another mod

git subtree add --prefix=mods/<name> https://github.com/<owner>/<repo>.git <branch> --squash

Then add the mod to upstreams.tsv, a row to the table above, and an entry to .claude-plugin/marketplace.json whose source points at the directory holding that mod's .claude-plugin/plugin.json.

A mod written here goes in mods/<name>/plugins/<name>/ with its own LICENSE beside it; it gets a row in the table and a marketplace entry, but no upstreams.tsv line.

Notes

  • GitHub only runs workflows from the repo root's .github/workflows, so the CI files inside mods/*/.github/ never run here.
  • Each forked mod here is a personal build on upstream: upstream is the base and the local changes are kept on top of it, listed in the table. A local change leaves only when upstream ships the same behaviour (upstream's code then replaces ours, as md-prompt's dunder fix did in 0.1.2) or when we drop it ourselves; an upstream declining it is no reason to drop it. A fix useful to everyone may also be offered upstream as an issue or a pull request.
Source 2 files
hooks/register.tsx 1648 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRun, Plan, PlanStage, PlanState, PlanStep, StepStatus } from '../types'
5
6const TOOL = 'mcp__plan-progress__plan_progress'
7const plans = atom({ plugin: 'plan-progress', key: 'plans' } as const, [])
8const MAX_BARS = 3
9// a space as wide as a digit, so '  0%' and '100%' take the same room
10const FIGURE_SPACE = String.fromCharCode(0x2007)
11const isOpen = atom({ plugin: 'plan-progress', key: 'isOpen' } as const, true)
12const tick = atom({ plugin: 'plan-progress', key: 'tick' } as const, 0)
13// bars whose agent strips are opened out; the rest show waiting and failed agents and one summary row
14const expanded = atom({ plugin: 'plan-progress', key: 'expanded' } as const, [] as string[])
15const STRIP_H = 16
16const STRIP_GAP = 2
17// rows of strips per bar, the "+N more" row included: the band above the prompt has room for about 300 px
18const stripBudget = (bars: number) => (bars >= 3 ? 3 : bars === 2 ? 4 : 5)
19const FOLD_MS = 5000 // finished strips stay this long, failed ones stay until the bar closes
20const DONE_LINGER_MS = 60_000 // a finished bar goes away on its own after this; a failed one waits for its ✕
21const HEAD_TWINKLE = 48 // px behind the head of a running bar that still twinkle; the rest of the fill holds still
22const TOGGLE_W = 32 // px the agents button (▾ 4 / ▴) takes after a title; reserved on every desktop row, so a button
23// appearing with a second agent never narrows the tracks under the person's eyes
24
25// the desktop app's own tokens, so the band reads as part of it: running in the brand clay (--accent-brand), waiting on
26// the person in its accent blue (--accent-100), error and done in its danger and success (light theme's -100).
27// The pill is a shade deeper where it must be, so white on every pill reads at 4.5:1 or better
28const STATE_COLOR: Record<PlanState, string> = { running: '#D97757', needs_input: '#2C84DB', error: '#B53333', done: '#2F7613' }
29const PILL_COLOR: Record<PlanState, string> = { running: '#B55C3E', needs_input: '#1B67B2', error: '#B53333', done: '#2F7613' }
30const INK = '#FFFFFF'
31const STATE_GLYPH: Record<PlanState, string> = { running: '●', needs_input: '?', error: '×', done: '✓' }
32const STATUSES: StepStatus[] = ['pending', 'active', 'done', 'error', 'skipped']
33const TRACK_H = 22
34const NARROW = 360
35
36// the working rules ride in the tool's description: cached like the system prompt, and the description is there on
37// every engine that has tools, where the system-prompt event (prompt.compose) is not
38const RULES = `Live progress bar above the prompt, one per id. Tasks needing more than ~3 edits or commands get a bar: create it once with the full breakdown (2-7 stages of steps {title}, or kind "todo" for one flat list; titles of at most 4 words, in the user's language; the first open step becomes active), then move it with short calls: {id, next:true} when the active step is finished, or {id, done:[...], active:"..."}, {id, failed:"...", note}. When the plan changes, resend stages under the same id; steps sent without a status keep their done by title. Send state "needs_input" with a note before asking the user to decide. Never describe the bars to the user.`
39
40type Raw = Record<string, unknown>
41const str = (v: unknown, max = 120) => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim().slice(0, max) : '')
42const status = (v: unknown): StepStatus => (STATUSES.includes(v as StepStatus) ? (v as StepStatus) : 'pending')
43const list = (v: unknown): Raw[] => (Array.isArray(v) ? v.filter(x => x && typeof x === 'object') : []) as Raw[]
44const isFinished = (s: StepStatus) => s === 'done' || s === 'skipped'
45
46const same = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase()
47
48// short updates: {next:true}, {done:[titles]}, {active:title}, {failed:title} against the stored plan;
49// titles it cannot find come back in missing, so the call is refused instead of passing as a success
50function applyOps(stages: PlanStage[], input: Raw, now: number): { stages: PlanStage[]; missing: string[] } {
51  const next = stages.map(s => ({ ...s, steps: s.steps.map(st => ({ ...st })) }))
52  const steps = next.flatMap(s => s.steps)
53  const missing: string[] = []
54  // a step finishing now remembers when, for the time its checkpoint shows
55  const finish = (st: PlanStep) => {
56    if (!isFinished(st.status)) Object.assign(st, { status: 'done', doneAt: now })
57  }
58  // a title used twice means the one still open
59  const find = (title: string) => {
60    const found = steps.find(st => same(st.title, title) && !isFinished(st.status)) ?? steps.find(st => same(st.title, title))
61    if (!found) missing.push(title)
62    return found
63  }
64  if (input.next === true) {
65    const at = steps.findIndex(st => st.status === 'active') >= 0 ? steps.findIndex(st => st.status === 'active') : steps.findIndex(st => !isFinished(st.status))
66    const cur = steps[at]
67    if (cur) finish(cur)
68    // past the last step, work goes back to one left open earlier
69    const following = steps.slice(at + 1).find(st => st.status === 'pending') ?? steps.find(st => st.status === 'pending')
70    if (following) following.status = 'active'
71  }
72  let lastDone = -1
73  for (const t of Array.isArray(input.done) ? input.done : []) {
74    const st = typeof t === 'string' ? find(t) : undefined
75    if (st?.status === 'active') lastDone = steps.indexOf(st)
76    if (st) finish(st)
77  }
78  // finishing the step in progress moves on, as next does, unless the call names the new one itself
79  if (lastDone >= 0 && typeof input.active !== 'string' && !steps.some(st => st.status === 'active')) {
80    const following = steps.slice(lastDone + 1).find(st => st.status === 'pending') ?? steps.find(st => st.status === 'pending')
81    if (following) following.status = 'active'
82  }
83  const active = typeof input.active === 'string' ? find(input.active) : undefined
84  if (active) {
85    const at = steps.indexOf(active)
86    steps.forEach((st, i) => {
87      if (st.status === 'active' && i !== at) {
88        if (i < at) finish(st)
89        else st.status = 'pending'
90      }
91    })
92    active.status = 'active'
93  }
94  const failed = typeof input.failed === 'string' ? find(input.failed) : undefined
95  if (failed) failed.status = 'error'
96
97  return { stages: next, missing }
98}
99
100// the new bar, or the refusal for a call that names steps the bar does not have
101function normalize(input: Raw, prev: Plan | null, now: number, id: string): Plan | string {
102  const sent = list(input.stages)
103    .map(s => ({
104      name: str(s.name, 80) || 'Stage',
105      steps: list(s.steps).map(st => ({
106        title: str(st.title) || 'Step',
107        status: status(st.status),
108        substeps: list(st.substeps).map(sub => ({ title: str(sub.title) || '…', status: status(sub.status) })),
109      })),
110    }))
111    .filter(s => s.steps.length > 0) as PlanStage[]
112  const isPartial = sent.length === 0 && prev !== null
113  // a resent plan keeps what was finished; short ops sent along with it apply on top
114  const base = isPartial ? prev.stages : pointAt(prev ? carryDone(sent, prev.stages) : sent)
115  const { stages, missing } = applyOps(base, input, now)
116  if (missing.length > 0 && base.length > 0) {
117    const titles = stages.flatMap(s => s.steps.map(st => st.title)).join(', ')
118    return `plan_progress: "${id}" has no step ${missing.map(t => `"${str(t, 60)}"`).join(', ')}. Its steps: ${titles.slice(0, 400)}`
119  }
120  const title = str(input.title, 80) || prev?.title || 'Plan'
121  const steps = stages.flatMap(s => s.steps)
122  const isAllDone = steps.length > 0 && steps.every(s => isFinished(s.status))
123  const asked = input.state as PlanState
124  const failedNow = typeof input.failed === 'string'
125  const state: PlanState = ['running', 'needs_input', 'error', 'done'].includes(asked) ? asked : isAllDone ? 'done' : failedNow ? 'error' : 'running'
126
127  return {
128    id,
129    title,
130    kind: input.kind === 'todo' || (isPartial && prev?.kind === 'todo') ? 'todo' : 'plan',
131    stages,
132    state,
133    note: str(input.note, 160) || null,
134    startedAt: prev ? prev.startedAt : now,
135    endedAt: state === 'done' ? (prev?.endedAt ?? now) : null,
136  }
137}
138
139// a resent plan keeps what is finished: a step sent as pending under a title that was done stays done
140function carryDone(stages: PlanStage[], before: PlanStage[]): PlanStage[] {
141  const finished = new Map(before.flatMap(s => s.steps).filter(st => isFinished(st.status)).map(st => [st.title.trim().toLowerCase(), st]))
142  return stages.map(s => ({
143    ...s,
144    steps: s.steps.map(st => {
145      const was = finished.get(st.title.trim().toLowerCase())
146      return was && (st.status === 'pending' || st.status === was.status) ? { ...st, status: was.status, doneAt: was.doneAt } : st
147    }),
148  }))
149}
150
151// with nothing in progress, the first open step is the current one
152function pointAt(stages: PlanStage[]): PlanStage[] {
153  const steps = stages.flatMap(s => s.steps)
154  if (steps.some(st => st.status === 'active' || st.status === 'error')) return stages
155  const first = steps.find(st => st.status === 'pending')
156  return stages.map(s => ({ ...s, steps: s.steps.map(st => (st === first ? { ...st, status: 'active' as const } : st)) }))
157}
158
159// ---------- drawing ----------
160
161type Where = { pos: number; total: number; stage: number; step: number; stageSize: number }
162
163// pos counts the finished steps wherever they are; the current step is the active one, else the first still open
164function where(p: Plan): Where {
165  const steps = p.stages.flatMap((s, i) => s.steps.map((step, j) => ({ i, j, step })))
166  const pos = p.state === 'done' ? steps.length : steps.filter(x => isFinished(x.step.status)).length
167  const cur = p.state === 'done' ? undefined : (steps.find(x => x.step.status === 'active') ?? steps.find(x => !isFinished(x.step.status)))
168  const stage = (cur ?? steps[steps.length - 1])?.i ?? 0
169
170  return { pos, total: steps.length, stage, step: cur ? cur.j + 1 : (p.stages[stage]?.steps.length ?? 0), stageSize: p.stages[stage]?.steps.length ?? 0 }
171}
172
173const hex = (h: string) => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16))
174const mix = (a: number[], b: number[], m: number) => a.map((v, i) => Math.round(v + ((b[i] ?? 0) - v) * m))
175const rgb = (c: number[]) => `rgb(${c.join(',')})`
176const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
177const hash = (a: number, b: number, k: number) => {
178  const x = Math.sin(a * 127.1 + b * 311.7 + k * 74.7) * 43758.5453
179  return x - Math.floor(x)
180}
181const textWidth = (s: string, px = 6.7) => [...s].reduce((w, ch) => w + (/[ -鿿]/.test(ch) ? 12 : /[ilI.,:;'|!]/.test(ch) ? 3.4 : /[mwMWШЩЖМ]/.test(ch) ? 9.5 : px), 0)
182
183const ICON_PATH: Partial<Record<PlanState, string>> = {
184  needs_input: 'M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3M12 17h.01',
185  error: 'M18 6 6 18M6 6l12 12',
186  done: 'M20 6 9 17l-5-5',
187}
188
189// the state glyph before a title follows the theme as the strips do: the desktop draws it as a small image with its own
190// light rule, in the desktop's -100 tokens ([dark, light]), each 3.7:1 or more on its background; the terminal, which
191// has no images, keeps the coloured character
192const GLYPH_HUE: Record<PlanState, [string, string]> = {
193  running: ['#D97757', '#C6613F'],
194  needs_input: ['#2C84DB', '#2C84DB'], // --accent-100 is the same blue in both themes
195  error: ['#DD5353', '#B53333'],
196  done: ['#459315', '#2F7613'],
197}
198// the question mark fills little of its 24 px box, so it is drawn larger to weigh as much as the cross and the check;
199// every icon is centred in the 12 x 20 glyph and keeps a 1.8 px line whatever its scale
200const GLYPH_FIT: Partial<Record<PlanState, { scale: number; cy: number }>> = { needs_input: { scale: 0.8, cy: 12.25 } }
201const glyphSvg = (state: PlanState) => {
202  const [dark, light] = GLYPH_HUE[state]
203  const icon = ICON_PATH[state]
204  const { scale, cy } = GLYPH_FIT[state] ?? { scale: 0.5, cy: 12 }
205  const at = `translate(${(6 - 12 * scale).toFixed(2)} ${(10 - cy * scale).toFixed(2)}) scale(${scale})`
206  const mark = icon
207    ? `<path d="${icon}" transform="${at}" fill="none" class="gs" stroke-width="${(1.8 / scale).toFixed(2)}" stroke-linecap="round" stroke-linejoin="round"/>`
208    : '<circle cx="6" cy="10" r="4" class="gf"/>'
209  return `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="20" viewBox="0 0 12 20"><style>.gs{stroke:${dark}}.gf{fill:${dark}}@media (prefers-color-scheme:light){.gs{stroke:${light}}.gf{fill:${light}}}</style>${mark}</svg>`
210}
211const GLYPH_SVG = Object.fromEntries((Object.keys(GLYPH_HUE) as PlanState[]).map(state => [state, glyphSvg(state)])) as Record<PlanState, string>
212
213// how long each finished step took (from the previous finish, or the plan's start) and each finished stage
214function stepTimes(p: Plan): { steps: Map<PlanStep, number>; stages: (number | undefined)[] } {
215  const ends = p.stages.flatMap(s => s.steps).flatMap(st => (st.doneAt === undefined ? [] : [st.doneAt])).sort((a, b) => a - b)
216  const startOf = (at: number) => Math.max(p.startedAt, ...ends.filter(t => t < at))
217  const steps = new Map<PlanStep, number>()
218  const stages = p.stages.map(s => {
219    for (const st of s.steps) if (st.doneAt !== undefined) steps.set(st, st.doneAt - startOf(st.doneAt))
220    const times = s.steps.map(st => st.doneAt)
221    if (times.some(t => t === undefined)) return undefined
222    const done = times as number[]
223    return Math.max(...done) - Math.min(...done.map(startOf))
224  })
225  return { steps, stages }
226}
227
228// the 2 px dots of one look go into one path: a fraction of the markup of a rect each, and one node instead of thousands
229function addDot(dots: Map<string, string>, cls: string, x: number, y: number) {
230  dots.set(cls, `${dots.get(cls) ?? ''}M${x} ${y}h2v2h-2z`)
231}
232
233// last drawn head position per plan, so a redraw glides from where the bar was
234const lastHead = new Map<string, number>()
235
236// the track draws in a sandboxed frame (for hover); its page must stay see-through in either theme
237const SEE_THROUGH = '<style>:root,html,body{background:transparent!important;color-scheme:light dark;margin:0;overflow:hidden}svg{display:block}</style>'
238
239// cc-mods: a running clock is plain text, written at every draw ({{T:start}}, stamp), and the band is drawn again on
240// each wall-clock second while one shows (beatOnTheSecond). The desktop shows a band's last answer again, restarting
241// its pictures, on repaints that never reach the mod (another plugin's redraw each second, a tool's timer); a clock
242// that ran by itself in CSS started over from the time of that answer each time and stepped back a second
243const CLOCK_W = 48 // "59m 59s"
244const CLOCK_CSS = '.ck{font-variant-numeric:tabular-nums}'
245// whole wall-clock seconds, so every clock on the band steps at the same instant, the one the band is drawn again at
246const runFor = (start: number, end: number) => Math.max(0, Math.floor(end / 1000) - Math.floor(start / 1000)) * 1000
247
248function liveClock(x: number, y: number, start: number, cls: string, anchor: 'end' | 'middle'): string {
249  return `<text x="${x.toFixed(1)}" y="${y}" text-anchor="${anchor}" class="${cls} ck">{{T:${start}}}</text>`
250}
251
252// cc-mods: the head's twinkle and a running agent's dot step on the wall-clock second like the clocks, written in at
253// every draw ({{TW}}, {{P}}) and still in between. Every repaint starts a picture's CSS over from its first frame, and
254// a draw on an event (an agent done, a step) lands off the second, so an endless CSS loop jumped there and again at the
255// next second; a still picture shown again is the same picture. The four twinkle groups take turns through the levels,
256// so the head's brightness holds; the dot is lit one second and dimmed the next
257const TWINKLE_LEVELS = ['1', '.75', '.45', '.75']
258const PULSE_LEVELS = ['1', '.4']
259const twinkleCss = (sec: number) => [0, 1, 2, 3].map(k => `.t${k}{opacity:${TWINKLE_LEVELS[(sec + k) % 4]}}`).join('')
260// reduced motion holds the twinkle at full, after the stamped rules so it wins (the dot's rule is in STRIP_STYLE)
261const STILL_CSS = '@media (prefers-reduced-motion:reduce){.t0,.t1,.t2,.t3{opacity:1}}'
262
263const stamp = (template: string, now: number) => {
264  const sec = Math.floor(now / 1000)
265  return template
266    .replace(/\{\{T:(\d+)\}\}/g, (_, t: string) => elapsed(runFor(Number(t), now)))
267    .replaceAll('{{TW}}', twinkleCss(sec))
268    .replaceAll('{{P}}', PULSE_LEVELS[sec % 2] ?? '1')
269}
270// a drawing with anything stamped changes on the second, so the band is drawn again on each one while it shows; the
271// exact marks, since a title or an agent's description may hold braces of its own
272const isStamped = (template: string) => /\{\{(?:T:\d+|TW|P)\}\}/.test(template)
273
274// a bar is drawn twice: the track itself as a plain picture, and a see-through layer on top for the hover parts
275// (checkpoint times, the pill's clock). That layer needs an interactive frame, and the desktop rebuilds such frames
276// on every redraw of the band; empty until hovered, the rebuild is invisible. A plan is immutable, so both drawings
277// at one width are reused until the plan changes, or until the head's slide to a new step has played: a picture the
278// desktop shows again would slide it again
279type Track = { base: string; overlay: string; isGliding: boolean }
280const drawn = new WeakMap<Plan, { W: number; track: Track; until: number }>()
281
282function trackSvg(p: Plan, W: number, now: number): Track {
283  const cached = drawn.get(p)
284  if (cached?.W === W && now < cached.until) return cached.track
285  const track = drawTrack(p, W)
286  drawn.set(p, { W, track, until: track.isGliding ? now + GLIDE_MS : Infinity })
287  return track
288}
289
290function drawTrack(p: Plan, W: number): Track {
291  const H = TRACK_H
292  const w = where(p)
293  const done = p.state === 'done'
294  // the fill is exactly the finished share: a fresh plan starts empty
295  const frac = done ? 1 : Math.min(1, w.pos / Math.max(1, w.total))
296  const fx = frac * W
297  const key = p.id
298  const from = lastHead.get(key) ?? fx
299  lastHead.set(key, fx)
300
301  const acc = hex(STATE_COLOR[p.state])
302  const light = mix(acc, [255, 255, 255], 0.32)
303  const grey = [140, 138, 130]
304  const ease = 'calcMode="spline" keyTimes="0;1" keySplines=".2 .8 .2 1"'
305  const glide = Math.abs(from - fx) > 0.5
306
307  const bounds: number[] = []
308  let acc2 = 0
309  p.stages.forEach((s, i) => {
310    acc2 += s.steps.length
311    if (i < p.stages.length - 1) bounds.push((acc2 / w.total) * W)
312  })
313
314  // pixels: 3px grid, 7 rows, denser towards the head, warming from grey to the state colour. The band sits right over
315  // the prompt, so only a running bar twinkles, and only its last HEAD_TWINKLE px; a finished or paused one is still
316  const buckets = [0, 1, 2, 3, 4].map(b => {
317    const m = b / 4
318    const dense = 0.22 + 0.78 * Math.pow(m, 1.5)
319    return { color: rgb(mix(grey, light, m)), opacity: (0.35 + 0.65 * dense).toFixed(2) }
320  })
321  const dots = new Map<string, string>()
322  let hasTwinkle = false
323  for (let col = 0; col * 3 < fx; col++) {
324    const x = col * 3
325    const u = Math.min(1, (x + 1.5) / fx)
326    const dense = 0.22 + 0.78 * Math.pow(u, 1.5)
327    const bucket = Math.min(4, Math.floor(Math.min(1, Math.pow(u, 0.9) * 1.1) * 4.99))
328    for (let r = 0; r < 7; r++) {
329      if (hash(col, r, 1) > dense + 0.1) continue
330      const isLive = p.state === 'running' && fx - x <= HEAD_TWINKLE
331      if (isLive) hasTwinkle = true
332      addDot(dots, isLive ? `b${bucket} t${Math.floor(hash(col, r, 2) * 4)}` : `b${bucket}`, x, 1 + r * 3)
333    }
334  }
335  const px = [...dots].map(([cls, d]) => `<path class="${cls}" d="${d}"/>`).join('')
336  // cc-mods: a head with twinkling dots takes this second's levels at each draw (stamp)
337  const twinkle = hasTwinkle ? `{{TW}}${STILL_CSS}` : ''
338
339  const took = stepTimes(p)
340  const tipRules: string[] = []
341  let marks = ''
342  let hits = ''
343  let tips = ''
344  let k = 0
345  p.stages.forEach((s, i) => {
346    s.steps.forEach((_, j) => {
347      if (k > 0) {
348        const x = (k / w.total) * W
349        const isStage = j === 0
350        // a stage boundary is a short capsule, a step a dot; bright once passed
351        const passed = x < fx - 1
352        const fill = passed ? rgb(mix(light, [255, 255, 255], 0.45)) : '#A8A69E'
353        const opacity = passed ? (isStage ? 0.95 : 0.8) : isStage ? 0.75 : 0.6
354        marks += isStage
355          ? `<rect x="${(x - 1.5).toFixed(1)}" y="${(H - 10) / 2}" width="3" height="10" rx="1.5" fill="${fill}" opacity="${opacity}"/>`
356          : `<circle cx="${x.toFixed(1)}" cy="${H / 2}" r="1.4" fill="${fill}" opacity="${opacity}"/>`
357        const before = p.stages[isStage ? i - 1 : i]
358        const ended = isStage ? before?.steps[before.steps.length - 1] : s.steps[j - 1]
359        const label = isStage ? (before?.name ?? '') : (ended?.title ?? '')
360        const ms = isStage ? took.stages[i - 1] : took.steps.get(ended as PlanStep)
361        const text = ms === undefined ? label : `${label} · ${elapsed(ms)}`
362        const tw = textWidth(text, 6.2) + 16
363        const tx = Math.max(0, Math.min(W - tw, x - tw / 2))
364        hits += `<rect class="h${k}" x="${(x - 5).toFixed(1)}" width="10" height="${H}" fill="#000" fill-opacity="0"/>`
365        tips += `<g class="tp p${k}"><rect x="${tx.toFixed(1)}" y="2" width="${tw.toFixed(1)}" height="${H - 4}" rx="${(H - 4) / 2}" fill="#1F1E1D" fill-opacity=".94"/><text x="${(tx + 8).toFixed(1)}" y="${H / 2 + 3.8}" class="tt">${esc(text)}</text></g>`
366        tipRules.push(`.h${k}:hover~.p${k}`)
367      }
368      k++
369    })
370    void i
371  })
372
373  // knob: a pill with stage and count, or a round dot with the stage number when narrow
374  const isNarrow = W < NARROW
375  const color = PILL_COLOR[p.state]
376  const icon = ICON_PATH[p.state]
377  const single = p.stages.length === 1
378  const number = single ? w.step : w.stage + 1
379  let knob = ''
380  let timePill = ''
381  let kw = H
382  if (isNarrow) {
383    const label = done ? '' : String(number)
384    knob = `<circle cx="0" cy="${H / 2}" r="${H / 2}" fill="${color}"/>${
385      done ? `<path d="${ICON_PATH.done}" transform="translate(-6 5) scale(.5)" fill="none" stroke="${INK}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>` : `<text x="0" y="${H / 2 + 4.2}" text-anchor="middle" class="kt">${label}</text>`
386    }`
387  } else {
388    const name = done ? (p.endedAt ? elapsed(runFor(p.startedAt, p.endedAt)) : 'Done') : single ? (p.stages[0]?.name ?? 'Tasks') : (p.stages[w.stage]?.name ?? '')
389    // the pill carries the stage name alone; the fill and the percent already say how far along it is
390    const count = ''
391    const iconW = icon ? 16 : 0
392    const countW = count ? textWidth(count, 6.5) : -6
393    const maxW = Math.max(80, W * 0.55)
394    let shown = name
395    while (shown.length > 3 && 20 + iconW + textWidth(shown) + 6 + countW > maxW) shown = shown.slice(0, -1)
396    if (shown !== name) shown = shown.trimEnd() + '…'
397    // a running pill is wide enough for its clock too, so the hover swap does not change its size
398    const textW = done ? textWidth(shown) : Math.max(textWidth(shown), CLOCK_W)
399    kw = Math.round(20 + iconW + textW + 6 + countW)
400    const left = -(iconW + textW) / 2
401    const mid = left + iconW + textW / 2
402    knob = `<rect x="${-kw / 2}" y="0" width="${kw}" height="${H}" rx="${H / 2}" fill="${color}"/>`
403    if (icon) knob += `<path d="${icon}" transform="translate(${left.toFixed(1)} 5) scale(.5)" fill="none" stroke="${INK}" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round"/>`
404    knob += `<text x="${mid.toFixed(1)}" y="${H / 2 + 4.2}" text-anchor="middle" class="kt">${esc(shown)}${count ? `<tspan class="kc" dx="6">${count}</tspan>` : ''}</text>`
405    // hovering the pill lays a copy of it over the stage name, carrying the time the plan has run so far
406    if (!done) {
407      const face = `<rect x="${-kw / 2}" y="0" width="${kw}" height="${H}" rx="${H / 2}" fill="${color}"/>${
408        icon ? `<path d="${icon}" transform="translate(${left.toFixed(1)} 5) scale(.5)" fill="none" stroke="${INK}" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round"/>` : ''
409      }`
410      timePill = `<g class="kb"><rect x="${-kw / 2}" y="0" width="${kw}" height="${H}" fill="#000" fill-opacity="0"/><g class="kv">${face}${liveClock(mid, H / 2 + 4.2, p.startedAt, 'kc0 kt', 'middle')}</g></g>`
411    }
412  }
413  const clampX = (x: number) => Math.max(kw / 2, Math.min(W - kw / 2, x))
414  const kx = clampX(fx)
415  const kFrom = clampX(from)
416
417  const style = `<style>
418.b0{fill:${buckets[0]?.color};fill-opacity:${buckets[0]?.opacity}}.b1{fill:${buckets[1]?.color};fill-opacity:${buckets[1]?.opacity}}
419.b2{fill:${buckets[2]?.color};fill-opacity:${buckets[2]?.opacity}}.b3{fill:${buckets[3]?.color};fill-opacity:${buckets[3]?.opacity}}
420.b4{fill:${buckets[4]?.color};fill-opacity:${buckets[4]?.opacity}}
421${twinkle}
422.kt{font:500 12px 'Anthropic Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;fill:${INK}}
423.kc{font-weight:400;fill-opacity:.75}
424</style>`
425  const hoverStyle = `<style>
426.tp{opacity:0;transition:opacity .12s;pointer-events:none}${tipRules.length ? `${tipRules.join(',')}{opacity:1}` : ''}
427.tt{font:400 11px 'Anthropic Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;fill:#FAF9F5}
428.kt{font:500 12px 'Anthropic Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;fill:${INK}}
429.kv{opacity:0;filter:blur(3px);transition:opacity .2s,filter .2s}.kb:hover .kv{opacity:1;filter:none}
430.kb,rect[class^="h"]{cursor:pointer}
431${CLOCK_CSS}
432</style>`
433  const glideFill = glide ? `<animate attributeName="width" from="${from.toFixed(1)}" to="${fx.toFixed(1)}" dur=".45s" ${ease} fill="freeze"/>` : ''
434  const glideKnob = glide ? `<animateTransform attributeName="transform" type="translate" from="${kFrom.toFixed(1)} 0" to="${kx.toFixed(1)} 0" dur=".45s" ${ease} fill="freeze"/>` : ''
435
436  const open = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`
437  const base = `${open}${style}
438<defs><clipPath id="pill"><rect width="${W}" height="${H}" rx="${H / 2}"/></clipPath><clipPath id="fill"><rect width="${fx.toFixed(1)}" height="${H}">${glideFill}</rect></clipPath>
439<linearGradient id="base" x1="0" x2="${fx.toFixed(1)}" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="${rgb(acc)}" stop-opacity=".05"/><stop offset="1" stop-color="${rgb(acc)}" stop-opacity=".33"/></linearGradient></defs>
440<g clip-path="url(#pill)"><rect width="${W}" height="${H}" fill="#8C8A82" fill-opacity=".16"/>
441<g clip-path="url(#fill)"><rect width="${fx.toFixed(1)}" height="${H}" fill="url(#base)"/>${px}</g>${marks}</g>
442<g transform="translate(${kx.toFixed(1)} 0)">${glideKnob}${knob}</g></svg>`
443  // the hover layer: checkpoint areas under the pill's copy, so the pill wins where they meet; tips on top
444  const overlay = `${open}${SEE_THROUGH}${hoverStyle}${hits}<g transform="translate(${kx.toFixed(1)} 0)">${timePill}</g>${tips}</svg>`
445
446  return { base, overlay, isGliding: glide }
447}
448
449const AGENT_COLOR: Record<AgentRun['state'], string> = {
450  running: STATE_COLOR.running,
451  waiting: STATE_COLOR.needs_input,
452  done: STATE_COLOR.done,
453  error: STATE_COLOR.error,
454}
455
456// strip text follows the theme: the drawing is an image on a see-through band, and near-white text on a light
457// band reads at 1.1:1. The tool word sits on its own state's tint, where the bare state colour reads at about 3:1;
458// lifted towards the far end of the theme (45% white on dark, 35% black on light) it clears 4.5:1 on either
459const WORD_CLASS: Record<string, string> = Object.fromEntries(Object.values(AGENT_COLOR).map((c, i) => [c, `w${i}`]))
460const wordCss = (to: number[], m: number) => Object.entries(WORD_CLASS).map(([c, cls]) => `.sn.${cls}{fill:${rgb(mix(hex(c), to, m))}}`).join('')
461
462// the desktop drops an Svg whose alt is empty, so every drawing says what it shows
463function stripAlt(p: Plan, key: string, hidden: AgentRun[]): string {
464  const a = (p.agents ?? []).find(x => x.id === key)
465  return a ? `agent ${a.title}: ${a.state}, ${a.tool}${a.ctx !== undefined ? `, ${ctxText(a)}` : ''}` : `agents: ${tally(hidden)}`
466}
467
468// "3 running · 1 done": the agents a summary row stands for, by state, in a fixed order
469const TALLY: [AgentRun['state'], string][] = [['running', 'running'], ['waiting', 'waiting'], ['error', 'failed'], ['done', 'done']]
470function tally(list: AgentRun[]): string {
471  return TALLY.map(([state, word]) => [list.filter(a => a.state === state).length, word] as const)
472    .filter(([n]) => n > 0)
473    .map(([n, word]) => `${n} ${word}`)
474    .join(' · ')
475}
476
477const elapsed = (ms: number) => {
478  const sec = Math.max(0, Math.round(ms / 1000))
479  if (sec < 60) return `${sec}s`
480  return sec < 3600 ? `${Math.floor(sec / 60)}m ${sec % 60}s` : `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`
481}
482
483// a batch of agents that all finished folds its strips away a few seconds later; a failed one keeps them
484const isFolded = (p: Plan, now: number) => p.agentsDoneAt != null && now - p.agentsDoneAt > FOLD_MS && !(p.agents ?? []).some(a => a.state === 'error')
485// cc-mods: the Agents bar holds strips alone (syncAuto): no track, pill or percent, and it shows only while they do
486const hasTrack = (p: Plan) => p.id !== AGENTS
487const isShown = (p: Plan, now: number) => hasTrack(p) || !isFolded(p, now)
488
489// which strips show. Folded (the default) keeps a strip for each agent that needs the person (waiting) or failed,
490// and one summary row for the rest; a lone other agent keeps its own strip, as tall as the summary would be.
491// Opened out: all of a small batch; in a big one the unfinished first, the rest folded into one line
492function visibleAgents(p: Plan, now: number, max: number, isExpanded: boolean): { shown: AgentRun[]; hidden: AgentRun[] } | null {
493  const list = p.agents ?? []
494  if (list.length === 0 || isFolded(p, now)) return null
495  if (!isExpanded) {
496    const urgent = new Set(list.filter(a => a.state === 'waiting' || a.state === 'error').slice(0, max - 1).map(a => a.id))
497    const rest = list.filter(a => !urgent.has(a.id))
498    if (rest.length <= 1 && list.length <= max) return { shown: list, hidden: [] }
499    return { shown: list.filter(a => urgent.has(a.id)), hidden: rest }
500  }
501  if (list.length <= max) return { shown: list, hidden: [] }
502  const keep = new Set(list.filter(a => a.state !== 'done').slice(0, max - 1).map(a => a.id))
503  for (const a of [...list].reverse()) {
504    if (keep.size >= max - 1) break
505    keep.add(a.id)
506  }
507  return { shown: list.filter(a => keep.has(a.id)), hidden: list.filter(a => !keep.has(a.id)) }
508}
509
510// what each strip shows, and what it showed before its last change and when, so a change morphs from the old status
511// instead of jumping; only draws within the morph carry it, since the desktop shows a drawing again on later redraws
512type StripLook = { tool: string; color: string }
513const lastStrip = new Map<string, StripLook & { was?: StripLook; at: number }>()
514const MORPH = '.2s'
515const MORPH_MS = 200
516
517// a strip's markup per agent object: redrawn when the agent changes or its morph is over, so later redraws match
518const drawnRows = new WeakMap<AgentRun, { key: string; html: string }>()
519
520
521// one tinted strip per agent: state colour, name, what it does now and for how long; not a progress bar
522// Lucide "bot", drawn at 12 px in the gutter before each strip
523const BOT = '<rect width="16" height="12" x="4" y="8" rx="2"/><path d="M12 8V4H8M2 14h2M20 14h2M15 13v2M9 13v2"/>'
524const GUTTER = 36 // icon and agent number, left of the strip
525
526// claude-haiku-4-5-20251001 -> haiku 4.5; an alias stays as given
527const modelName = (m: string) => {
528  const r = /^claude-([a-z]+)-(\d+)-(\d+)/.exec(m)
529  return r ? `${r[1]} ${r[2]}.${r[3]}` : m
530}
531
532// cc-mods: what goes in the parenthesis after the name: the agent definition it runs as (left out for general-purpose,
533// the Agent tool's default, and without a plugin's prefix), then its model and effort
534const specOf = (a: AgentRun) => {
535  const role = (a.type ?? '').replace(/^[^:]*:/, '')
536  return [role === 'general-purpose' || role === a.title ? '' : role, a.model ? modelName(a.model) : '', a.effort ?? ''].filter(Boolean).join(' · ')
537}
538
539// cc-mods: the context an agent's last request carried. The engine reports no window for a subagent, and Claude Code
540// settles one from a model catalog a plugin cannot read (some models hold 1M without saying so), so the share is shown
541// only where the id itself names the window: [1m] asks for 1M (only CLAUDE_CODE_MAX_CONTEXT_TOKENS overrides it)
542const kTokens = (n: number) => (n >= 999_500 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
543const hasKnownWindow = (a: AgentRun) => a.model?.includes('[1m]') === true
544const ctxText = (a: AgentRun) =>
545  a.ctx === undefined ? '' : `ctx ${kTokens(a.ctx)}${hasKnownWindow(a) ? ` · ${Math.round((a.ctx / 1_000_000) * 100)}%` : ''}`
546// the room it keeps on a strip for its whole run, so the tool word never moves when the first count lands
547const ctxRoom = (a: AgentRun) => (hasKnownWindow(a) ? 'ctx 999k · 100%' : 'ctx 999k')
548
549// the agent's name, its model and effort in a dimmer parenthesis
550const nameMarkup = (name: string) => {
551  const at = name.indexOf(' (')
552  return at > 0 ? `${esc(name.slice(0, at))}<tspan class="st">${esc(name.slice(at))}</tspan>` : esc(name)
553}
554
555// one tinted strip per agent behind a bot icon and its number: the colour says how it went,
556// the word says what it does now (only while it runs or waits), the time how long it took
557type StripRow = { key: string; html: string; height: number; isMorphing: boolean }
558
559// each strip is its own drawing, so a change to one agent redraws that strip alone, never the bar or the others
560function stripsSvg(v: { shown: AgentRun[]; hidden: AgentRun[] }, all: AgentRun[], W: number, now: number): StripRow[] {
561  const isNarrow = W < NARROW
562  const SW = W - GUTTER
563  const rows: StripRow[] = []
564  const gutter = (y: number, label: string, isDim = false) =>
565    `<g transform="translate(1 ${y + 2}) scale(.5)" fill="none" class="gi${isDim ? ' gm' : ''}" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">${BOT}</g>` +
566    `<text x="16" y="${y + 11.5}" class="sn sg gl${isDim ? ' gm' : ''}">${label}</text>`
567  v.shown.forEach((a, i) => {
568    // the first strip keeps a little room from the track above it
569    const y = i === 0 ? 5 : STRIP_GAP
570    const c = AGENT_COLOR[a.state]
571    const word = a.state === 'running' || a.state === 'waiting' ? a.tool : ''
572    const last = lastStrip.get(a.id)
573    const look = last && last.tool === word && last.color === c ? last : { tool: word, color: c, was: last && { tool: last.tool, color: last.color }, at: now }
574    lastStrip.set(a.id, look)
575    // a status change: the old word blurs out while the new one blurs in, and the tint flows to the new colour
576    const was = now - look.at < MORPH_MS ? look.was : undefined
577    const rowKey = `${W}|${y}|${all.indexOf(a)}|${was ? 'morph' : ''}`
578    const cachedRow = drawnRows.get(a)
579    if (cachedRow?.key === rowKey) {
580      rows.push({ key: a.id, html: cachedRow.html, height: y + STRIP_H, isMorphing: was !== undefined })
581      return
582    }
583    const indent = a.depth > 0 ? 10 : 0
584    const dots = new Map<string, string>()
585    if (a.state === 'running') {
586      for (let col = 0; col * 3 < SW; col++) {
587        for (let r = 0; r < 4; r++) {
588          if (hash(col + i * 41, r, 5) > 0.2) continue
589          addDot(dots, 'still', GUTTER + col * 3, Math.round((y + 2.5 + r * 3.2) * 10) / 10)
590        }
591      }
592    }
593    // a still texture: the pulsing state dot already says the agent is live
594    const px = [...dots].map(([, d]) => `<path fill="${c}" fill-opacity=".32" d="${d}"/>`).join('')
595    const isWordChanged = was !== undefined && was.tool !== word
596    const flow = (attr: string) => (was && was.color !== c ? `<animate attributeName="${attr}" from="${was.color}" to="${c}" dur="${MORPH}" fill="freeze"/>` : '')
597    // the tool word, the context (cc-mods) and the clock sit at the right, so the name and its model get the rest of the row
598    const ctxEnd = W - 9 - CLOCK_W - 10
599    const toolEnd = ctxEnd - textWidth(ctxRoom(a), 6.2) - 10
600    const wordW = Math.max(word ? textWidth(word, 6.2) : 0, isWordChanged && was.tool ? textWidth(was.tool, 6.2) : 0)
601    const nameX = GUTTER + 19 + indent
602    const nameRoom = isNarrow ? SW - 24 - indent : toolEnd - (wordW > 0 ? wordW + 12 : 0) - nameX
603    const spec = specOf(a)
604    const full = (a.depth > 0 ? '↳ ' : '') + a.title + (spec ? ` (${spec})` : '')
605    let name = full
606    while (name.length > 4 && textWidth(name, 6.2) > nameRoom) name = name.slice(0, -1)
607    if (name !== full) name = name.trimEnd() + '…'
608    const time =
609      a.endedAt === null
610        ? liveClock(W - 9, y + 11.5, a.startedAt, 'sc sn st', 'end')
611        : `<text x="${W - 9}" y="${y + 11.5}" text-anchor="end" class="sn st">${elapsed(runFor(a.startedAt, a.endedAt))}</text>`
612    const tool = isNarrow
613      ? ''
614      : (isWordChanged && was.tool ? `<text x="${toolEnd}" y="${y + 11.5}" text-anchor="end" class="sn mo ${WORD_CLASS[was.color] ?? ''}">${esc(was.tool)}</text>` : '') +
615        (word ? `<text x="${toolEnd}" y="${y + 11.5}" text-anchor="end" class="sn${isWordChanged ? ' mi' : ''} ${WORD_CLASS[c] ?? ''}">${esc(word)}</text>` : '') +
616        (a.ctx !== undefined ? `<text x="${ctxEnd}" y="${y + 11.5}" text-anchor="end" class="sn st nm">${esc(ctxText(a))}</text>` : '') +
617        time
618    const html =
619      gutter(y, String(all.indexOf(a) + 1)) +
620      `<rect x="${GUTTER}" y="${y}" width="${SW}" height="${STRIP_H}" rx="${STRIP_H / 2}" fill="${c}" fill-opacity=".15">${flow('fill')}</rect>${px}` +
621      `<circle cx="${GUTTER + 10 + indent}" cy="${y + STRIP_H / 2}" r="3" fill="${c}"${a.state === 'running' ? ' class="sd" opacity="{{P}}"' : ''}>${flow('fill')}</circle>` +
622      `<text x="${nameX}" y="${y + 11.5}" class="sn">${nameMarkup(name)}</text>` +
623      tool
624    drawnRows.set(a, { key: rowKey, html })
625    rows.push({ key: a.id, html, height: y + STRIP_H, isMorphing: was !== undefined })
626  })
627  if (v.hidden.length > 0) {
628    // one grey row for the agents without a strip; a live dot while any of them runs
629    const y = v.shown.length === 0 ? 5 : STRIP_GAP
630    const isLive = v.hidden.some(a => a.state === 'running')
631    const label = v.shown.length === 0 ? plural(v.hidden.length, 'agent') : plural(v.hidden.length, 'more agent')
632    rows.push({
633      key: '+',
634      height: y + STRIP_H,
635      isMorphing: false,
636      html:
637        gutter(y, v.shown.length === 0 ? String(v.hidden.length) : `+${v.hidden.length}`, true) +
638        `<rect x="${GUTTER}" y="${y}" width="${SW}" height="${STRIP_H}" rx="${STRIP_H / 2}" fill="#8C8A82" fill-opacity=".14"/>` +
639        (isLive ? `<circle cx="${GUTTER + 10}" cy="${y + STRIP_H / 2}" r="3" fill="${AGENT_COLOR.running}" class="sd" opacity="{{P}}"/>` : '') +
640        `<text x="${GUTTER + (isLive ? 19 : 10)}" y="${y + 11.5}" class="sn st">${label} · ${tally(v.hidden)}</text>`,
641    })
642  }
643  return rows
644}
645
646const STRIP_STYLE = `<style>.sn{font:400 11.5px 'Anthropic Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;fill:#FAF9F5}.st{fill-opacity:.65}.sg{font-weight:500;font-variant-numeric:tabular-nums}.nm{font-variant-numeric:tabular-nums}
647.gi{stroke:#9C9A92}.sn.gl{fill:#9C9A92}.gi.gm{stroke:#8C8A82}.sn.gl.gm{fill:#8C8A82}${wordCss([255, 255, 255], 0.45)}
648@media (prefers-color-scheme:light){.sn{fill:#141413}.gi,.gi.gm{stroke:#73726C}.sn.gl,.sn.gl.gm{fill:#73726C}${wordCss([0, 0, 0], 0.35)}}
649.mi{animation:mi ${MORPH} ease-out both}@keyframes mi{from{opacity:0;filter:blur(3px)}}
650.mo{animation:mo ${MORPH} ease-in both}@keyframes mo{to{opacity:0;filter:blur(3px)}}
651${CLOCK_CSS}
652@media (prefers-reduced-motion:reduce){.mi,.mo{animation:none}.mo{opacity:0}.sd{opacity:1}}</style>`
653
654function plural(n: number, word: string) {
655  return `${n} ${word}${n === 1 ? '' : 's'}`
656}
657
658// '  7%' to '100%' in the same room; a bar without a track keeps that room blank, so its strips stand in the column
659// of the other bars' tracks
660const percentText = (p: Plan, pct: number) => (hasTrack(p) ? `${String(pct).padStart(3, FIGURE_SPACE)}%` : FIGURE_SPACE.repeat(4))
661
662const DEFAULT = 0x01000000
663let isLight = false
664const termBg = () => (isLight ? [255, 255, 255] : [24, 24, 27])
665const termFg = () => (isLight ? [34, 34, 38] : [240, 238, 252])
666const pack = (c: number[]) => ((c[0] ?? 0) << 16) | ((c[1] ?? 0) << 8) | (c[2] ?? 0)
667const isWide = (cp: number) => cp > 0xffff || (cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60) || (cp >= 0xffe0 && cp <= 0xffe6)
668const cellText = (s: string) => [...s].map(ch => (isWide(ch.codePointAt(0) ?? 63) || (ch.codePointAt(0) ?? 0) < 32 ? '·' : ch)).join('')
669const cellsOf = (s: string) => [...s].reduce((w, ch) => w + (isWide(ch.codePointAt(0) ?? 0) ? 2 : 1), 0)
670const fit = (s: string, w: number) => {
671  const chars = [...cellText(s)]
672  if (chars.length <= w) return chars.join('')
673  return w <= 1 ? '…'.slice(0, w) : chars.slice(0, w - 1).join('').trimEnd() + '…'
674}
675
676type Cell = [number, number, number]
677
678class Grid {
679  cells: Cell[]
680  constructor(
681    readonly columns: number,
682    readonly rows: number,
683  ) {
684    this.cells = Array.from({ length: columns * rows }, () => [32, DEFAULT, DEFAULT] as Cell)
685  }
686  set(x: number, y: number, ch: string | number, fg: number, bg: number) {
687    if (x < 0 || x >= this.columns || y < 0 || y >= this.rows) return
688    this.cells[y * this.columns + x] = [typeof ch === 'number' ? ch : (ch.codePointAt(0) ?? 32), fg, bg]
689  }
690  bg(x: number, y: number) {
691    return this.cells[y * this.columns + x]?.[2] ?? DEFAULT
692  }
693  text(x: number, y: number, s: string, fg: number, bg?: number) {
694    let i = 0
695    for (const ch of cellText(s)) {
696      this.set(x + i, y, ch, fg, bg ?? this.bg(x + i, y))
697      i++
698    }
699    return i
700  }
701  encode() {
702    const words = new Uint32Array(this.cells.length * 3)
703    this.cells.forEach((c, i) => words.set(c, i * 3))
704    return (new Uint8Array(words.buffer) as Uint8Array & { toBase64: () => string }).toBase64()
705  }
706}
707
708const BRAILLE_BITS = [
709  [0x01, 0x08],
710  [0x02, 0x10],
711  [0x04, 0x20],
712  [0x40, 0x80],
713]
714
715function pill(g: Grid, y: number, from: number, to: number, bgAt: (x: number) => number[]) {
716  for (let x = from; x < to; x++) g.set(x, y, ' ', DEFAULT, pack(bgAt(x)))
717}
718
719const LEVELS = 8
720const q = (m: number) => Math.round(Math.max(0, Math.min(1, m)) * LEVELS) / LEVELS
721const wave = (t: number, periodMs: number, offset = 0) => 0.5 + 0.5 * Math.sin(((t / periodMs) + offset) * Math.PI * 2)
722const easeOut = (x: number) => 1 - Math.pow(1 - Math.max(0, Math.min(1, x)), 4)
723
724const glide = new Map<string, { from: number; to: number; at: number }>()
725const GLIDE_MS = 450
726
727function headAt(id: string, target: number, t: number): number {
728  const g = glide.get(id)
729  if (!g) {
730    glide.set(id, { from: target, to: target, at: t })
731    return target
732  }
733  const cur = g.from + (g.to - g.from) * easeOut((t - g.at) / GLIDE_MS)
734  if (Math.abs(g.to - target) > 0.01) {
735    glide.set(id, { from: cur, to: target, at: t })
736    return cur
737  }
738  return cur
739}
740
741
742function trackCells(p: Plan, W: number, t: number): string {
743  const g = new Grid(W, 1)
744  const w = where(p)
745  const done = p.state === 'done'
746  const target = (done ? 1 : Math.min(1, w.pos / Math.max(1, w.total))) * W
747  const fx = headAt(p.id, target, t)
748  const back = termBg()
749  const acc = hex(STATE_COLOR[p.state])
750  const light = mix(acc, [255, 255, 255], 0.35)
751  const grey = [120, 118, 128]
752  const track = mix(back, [128, 128, 128], isLight ? 0.14 : 0.18)
753  const fill = mix(track, acc, done ? 0.3 : 0.17)
754  const under = (x: number) => (x + 0.5 < fx ? fill : track)
755  pill(g, 0, 0, W, under)
756
757  const TWINKLE = done ? [3200, 3800, 4400, 3500] : [2200, 2800, 1900, 3300]
758  const DELAY = [0, 700, 1300, 400]
759  const dim = done ? 0.2 : 0.55
760  for (let col = 0; col < Math.min(W, Math.ceil(fx)); col++) {
761    const u = Math.min(1, (col + 0.5) / Math.max(1, fx))
762    const dense = done ? 0.8 : 0.22 + 0.78 * Math.pow(u, 1.5)
763    let bits = 0
764    for (let r = 0; r < 4; r++) {
765      for (let c = 0; c < 2; c++) {
766        const sx = col * 2 + c
767        if (sx / 2 >= fx || hash(sx, r, 1) > dense * 0.6) continue
768        bits |= BRAILLE_BITS[r]?.[c] ?? 0
769      }
770    }
771    if (bits === 0) continue
772    const cls = Math.floor(hash(col, 0, 2) * 4)
773    const period = TWINKLE[cls] ?? 2200
774    const blink = 1 - dim * wave(t + (DELAY[cls] ?? 0), period, 0.25)
775    const bucket = done ? 1 : q(Math.min(1, Math.pow(u, 0.9) * 1.1))
776    const tone = mix(grey, light, bucket)
777    const opacity = (0.35 + 0.65 * dense) * blink
778    g.set(col, 0, 0x2800 + bits, pack(mix(fill, tone, q(opacity))), pack(fill))
779  }
780
781  let k = 0
782  p.stages.forEach(s => {
783    if (k > 0) {
784      const x = Math.round((k / w.total) * W)
785      if (x > 0 && x < W - 1) {
786        const passed = x < fx - 0.5
787        g.set(x, 0, '│', pack(passed ? mix(light, [255, 255, 255], 0.5) : mix(track, isLight ? [0, 0, 0] : [255, 255, 255], 0.3)), pack(under(x)))
788      }
789    }
790    k += s.steps.length
791  })
792
793  const base = hex(STATE_COLOR[p.state])
794  const color = base
795  const single = p.stages.length === 1
796  const number = single ? Math.min(w.total, w.pos + 1) : w.stage + 1
797  const icon = p.state === 'done' ? '✓' : p.state === 'error' ? '✕' : p.state === 'needs_input' ? '?' : ''
798  let name = ''
799  let count = ''
800  if (W < 28) {
801    name = icon || String(number)
802  } else {
803    const agents = p.agents ?? []
804    name = done ? 'Done' : single ? (p.stages[0]?.name ?? 'Tasks') : (p.stages[w.stage]?.name ?? '')
805    const baseCount = done ? `${w.total}/${w.total}` : single ? `${number}/${w.total}` : `${w.step}/${w.stageSize}`
806    count = baseCount + (agents.length > 0 ? ` · ${agents.filter(a => a.state === 'done').length}/${agents.length}` : '')
807  }
808  const lead = icon && W >= 28 ? `${icon} ` : ''
809  const maxName = Math.max(3, Math.floor(W * (W < 60 ? 0.75 : 0.55)) - cellsOf(lead) - cellsOf(count) - 5)
810  const shown = lead + fit(name, maxName)
811  const kw = cellsOf(shown) + (count ? count.length + 1 : 0) + 2
812  const kx = Math.round(Math.max(0, Math.min(W - kw, fx - kw / 2)))
813  const white = pack([255, 255, 255])
814  pill(g, 0, kx, kx + kw, () => color)
815  let at = kx + 1
816  at += g.text(at, 0, shown, white, pack(color))
817  if (count) g.text(at + 1, 0, count, pack(mix([255, 255, 255], color, 0.3)), pack(color))
818  return g.encode()
819}
820
821function stripCells(v: { shown: AgentRun[]; hidden: AgentRun[] }, W: number, now: number): { cells: string; rows: number } {
822  const rows = v.shown.length + (v.hidden.length > 0 ? 1 : 0)
823  const g = new Grid(W, rows)
824  const back = termBg()
825  const text = pack(termFg())
826  v.shown.forEach((a, y) => {
827    const c = hex(AGENT_COLOR[a.state])
828    const tint = mix(back, c, 0.18)
829    pill(g, y, 0, W, () => tint)
830    const running = a.state === 'running' || a.state === 'waiting'
831    if (running) {
832      for (let col = 1; col < W - 1; col++) {
833        let bits = 0
834        let glow = 0
835        for (let r = 0; r < 4; r++) {
836          for (let cc = 0; cc < 2; cc++) {
837            const sx = col * 2 + cc + y * 83
838            if (hash(sx, r, 5) >= 0.08) continue
839            const b = 1 - 0.55 * wave(now, 1900 + hash(sx, r, 6) * 1400, hash(sx, r, 7))
840            bits |= BRAILLE_BITS[r]?.[cc] ?? 0
841            glow = Math.max(glow, b)
842          }
843        }
844        if (bits) g.set(col, y, 0x2800 + bits, pack(mix(tint, c, 0.2 + 0.4 * q(glow))), pack(tint))
845      }
846    }
847    const indent = a.depth > 0 ? 2 : 0
848    const dotColor = running ? mix(tint, c, 0.3 + 0.7 * q(wave(now, 1100))) : c
849    g.text(2 + indent, y, '●', pack(dotColor), pack(tint))
850    const time = elapsed(runFor(a.startedAt, a.endedAt ?? now))
851    const narrow = W < 30
852    const tx = W - 2 - time.length
853    // cc-mods: the context sits just before the time, in a room kept for the whole run; a row too short for it goes without
854    const room = W >= 48 ? cellsOf(ctxRoom(a)) : 0
855    const ctx = room ? ctxText(a) : ''
856    const cx = room ? tx - 1 - room : tx
857    // the tool word sits at the right, before the context and the time, so the name and its model get the rest of the row
858    const word = narrow || !running ? '' : fit(a.tool, Math.max(0, Math.floor(W * 0.25)))
859    const toolAt = cx - 1 - word.length
860    let at = 4 + indent
861    const spec = specOf(a)
862    const full = (a.depth > 0 ? '↳ ' : '') + a.title + (spec ? ` (${spec})` : '')
863    const name = fit(full, Math.max(3, (narrow ? W - 2 : word ? toolAt - 1 : cx - 1) - at))
864    // the model and effort are drawn dimmer than the name
865    const cut = spec ? name.indexOf(' (') : -1
866    const head = cut > 0 ? name.slice(0, cut) : name
867    for (let i = -1; i < name.length + 1 && at + i < W - 1; i++) g.set(at + i, y, ' ', DEFAULT, pack(tint))
868    at += g.text(at, y, head, text, pack(tint))
869    if (head !== name) g.text(at, y, name.slice(head.length), pack(mix(termFg(), tint, 0.4)), pack(tint))
870    if (narrow) return
871    if (word) {
872      for (let i = -1; i <= word.length; i++) g.set(toolAt + i, y, ' ', DEFAULT, pack(tint))
873      g.text(toolAt, y, word, pack(c), pack(tint))
874    }
875    if (ctx) g.text(cx + room - cellsOf(ctx), y, ctx, pack(mix(termFg(), tint, 0.35)), pack(tint))
876    for (let i = -1; i < time.length; i++) g.set(tx + i, y, ' ', DEFAULT, pack(tint))
877    g.text(tx, y, time, pack(mix(termFg(), tint, 0.35)), pack(tint))
878  })
879  if (v.hidden.length > 0) {
880    const y = v.shown.length
881    const tint = mix(back, [128, 128, 128], 0.16)
882    pill(g, y, 0, W, () => tint)
883    const doneCount = v.hidden.filter(a => a.state === 'done').length
884    g.text(2, y, fit(`+${plural(v.hidden.length, 'more agent')} · ${doneCount} done`, W - 4), pack(mix(termFg(), tint, 0.35)), pack(tint))
885  }
886  return { cells: g.encode(), rows }
887}
888
889type Band = { requestId: string; W: number; list: readonly Plan[] }
890let band: Band | null = null
891let isFrameBusy = false
892// a bar twinkles only while Claude works on it; one waiting on the person or left open after the turn stands still
893let isTurnLive = false
894let frames: { cancel: () => void } | null = null
895
896const hasRunningAgents = (p: Plan) => (p.agents ?? []).some(a => a.state === 'running' || a.state === 'waiting')
897const isGliding = (p: Plan, t: number) => {
898  const g = glide.get(p.id)
899  return g !== undefined && g.from !== g.to && t - g.at < GLIDE_MS + 100
900}
901const isAnimated = (p: Plan, t: number) => (isTurnLive && p.state === 'running') || hasRunningAgents(p) || isGliding(p, t)
902
903// cc-mods: whether the band last drawn on the desktop holds anything stamped on the second (a running clock, the
904// head's twinkle, a running agent's dot); while it does, the band is drawn again just after each wall-clock second
905let hasStamp = false
906let isBeating = false
907function beatOnTheSecond($: EngineInterface, now: number) {
908  isBeating = true
909  $.clock.after(1000 - (now % 1000) + 25, async () => {
910    if (hasStamp && (await read($, isOpen))) await update($, tick, n => n + 1)
911    beatOnTheSecond($, await $.clock.now())
912  })
913}
914
915// cc-mods: a one-shot animation (the head's slide to a new step, a strip's morph) plays again each time the desktop
916// shows its drawing afresh, and only the next draw takes it out (trackSvg, stripsSvg). A running clock brings that draw
917// within a second; a finished bar has none, so its slide to the end stayed the band's last answer, and each repaint
918// that never reached the mod (cache-timer's footer second) flashed its head back to the step it came from. A draw that
919// holds one is followed by another as soon as it has played
920let isSettling = false
921function drawAgainOncePlayed($: EngineInterface) {
922  if (isSettling) return
923  isSettling = true
924  $.clock.after(Math.max(GLIDE_MS, MORPH_MS) + 25, async () => {
925    isSettling = false
926    if (await read($, isOpen)) await update($, tick, n => n + 1)
927  })
928}
929
930// the 30 fps clock runs only while a terminal band has something moving; the second timer starts and stops it
931function syncFrames($: EngineInterface, now: number) {
932  const isWanted = band !== null && band.list.some(p => isAnimated(p, now))
933  if (isWanted && !frames) frames = $.clock.every(33, () => void animate($))
934  if (!isWanted && frames) {
935    frames.cancel()
936    frames = null
937  }
938}
939
940async function animate($: EngineInterface) {
941  const b = band
942  if (!b || isFrameBusy) return
943  const now = await $.clock.now()
944  const live = b.list.filter(p => isAnimated(p, now))
945  if (live.length === 0) return
946  isFrameBusy = true
947  try {
948    await Promise.all(
949      live.flatMap(p => {
950        // the terminal has no ▾ to open folded strips, so it shows all it has room for
951        const v = visibleAgents(p, now, stripBudget(b.list.length), true)
952        const strips = v ? stripCells(v, b.W, now) : null
953        const calls = !hasTrack(p) ? [] : [$.ui.blit({ requestId: b.requestId, key: `track-${p.id}`, cells: trackCells(p, b.W, now) })]
954        if (strips) calls.push($.ui.blit({ requestId: b.requestId, key: `strips-${p.id}`, cells: strips.cells }))
955        return calls.map(c => c.catch(() => undefined))
956      }),
957    )
958  } finally {
959    isFrameBusy = false
960  }
961}
962
963
964// ---------- engine glue ----------
965
966// the engine's player first (afplay on macOS); PowerShell where it cannot play
967function play($: EngineInterface, name: 'decision' | 'error' | 'done') {
968  const file = `${$.plugin.root}/sounds/${name}.wav`.replace(/\//g, '\\')
969  const viaPowerShell = () =>
970    $.process
971      .run(['powershell', '-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', `(New-Object Media.SoundPlayer '${file}').PlaySync()`], { timeoutMs: 5000 })
972      .catch(() => undefined)
973  // on Windows the engine's player can report success and stay silent, so the system player plays it directly
974  if (/^[A-Za-z]:/.test($.plugin.root)) {
975    void viaPowerShell()
976    return
977  }
978  void $.audio.play({ asset: `sounds/${name}.wav` }).catch(viaPowerShell)
979}
980
981// the agents bar is the mod's own; the model never owes it an update
982const AGENTS = 'agents:auto' // slug() never yields ':', so no model id can take it
983const isOpenPlan = (p: Plan) => p.id !== AGENTS && p.state === 'running' && !p.stages.flatMap(s => s.steps).every(s => isFinished(s.status))
984
985const slug = (s: string) =>
986  s
987    .toLowerCase()
988    .replace(/[^\p{L}\p{N}]+/gu, '-')
989    .replace(/^-|-$/g, '')
990    .slice(0, 40) || 'plan'
991
992// adds or replaces one bar by id; keeps at most MAX_BARS, dropping finished ones first
993// computed inside update() from the latest list, so concurrent writers (parallel agents) do not drop each other
994function placeBar(list: readonly Plan[], next: Plan): Plan[] {
995  const prev = list.find(p => p.id === next.id)
996  // an update keeps its row and, unless it brings its own, the agent strips already on it; a new bar goes to the bottom
997  const kept = prev && !('agents' in next) ? { ...next, agents: prev.agents, agentsDoneAt: prev.agentsDoneAt } : next
998  const rest = prev ? list.map(p => (p.id === next.id ? kept : p)) : [...list, next]
999  while (rest.length > MAX_BARS) {
1000    const doneAt = rest.findIndex(p => p.state === 'done')
1001    rest.splice(doneAt >= 0 ? doneAt : 0, 1)
1002  }
1003  return rest
1004}
1005
1006function chime($: EngineInterface, prev: PlanState | undefined, next: PlanState) {
1007  if (next === prev) return
1008  if (next === 'needs_input') play($, 'decision')
1009  if (next === 'error') play($, 'error')
1010  if (next === 'done') play($, 'done')
1011}
1012
1013async function putPlan($: EngineInterface, next: Plan) {
1014  await editPlan($, next.id, () => next)
1015}
1016
1017// builds a bar from the latest stored one inside update(), so back-to-back calls never work from a stale copy;
1018// make returns a string to refuse, and the list stays as it was
1019async function editPlan($: EngineInterface, id: string, make: (prev: Plan | null) => Plan | string, isQuiet = false): Promise<Plan | string> {
1020  let prev: Plan | undefined
1021  let made = '' as Plan | string
1022  await update($, plans, list => {
1023    prev = list.find(p => p.id === id)
1024    made = make(prev ?? null)
1025    return typeof made === 'string' ? [...list] : placeBar(list, made)
1026  })
1027  if (typeof made === 'string') return made
1028  if (!isQuiet) chime($, prev?.state, made.state)
1029  if (!prev) await update($, isOpen, () => true)
1030  return made
1031}
1032
1033// ---------- agents: drawn from engine events alone, no model calls ----------
1034// each subagent lives on a bar as one state strip: the open task bar it was started under,
1035// the bar of its parent agent, or the mod's own "Agents" bar when no task is open.
1036// Module maps: a reload forgets running agents, whose strips then stay until the bar is closed.
1037const agentHome = new Map<string, string>() // agentId -> bar id
1038const toolUses = new Map<string, string>() // tool_use_id -> agentId, to find who waits on a permission
1039const waiting = new Set<string>()
1040let foldUntil = 0 // keep ticking until finished strips have folded
1041
1042// cc-mods: the mod's own bar holds strips and nothing else: an agent reports no progress, and upstream's count of
1043// finished agents as steps sat at 0% until the last one ended. Its state follows its agents
1044function syncAuto(p: Plan, now: number): Plan {
1045  const agents = p.agents ?? []
1046  const isOver = agents.length > 0 && agents.every(a => a.state === 'done' || a.state === 'error')
1047  const agentsDoneAt = isOver ? (p.agentsDoneAt ?? now) : null
1048  if (p.id !== AGENTS) return { ...p, agentsDoneAt }
1049  const state: PlanState = isOver
1050    ? agents.some(a => a.state === 'error') ? 'error' : 'done'
1051    : agents.some(a => a.state === 'waiting') ? 'needs_input' : 'running'
1052  return { ...p, agentsDoneAt, state }
1053}
1054
1055// cc-mods: agents the main thread started this turn, before it opened the bar for that work, move from the Agents
1056// bar onto it; the Agents bar goes once it holds none
1057function adoptAgents(list: readonly Plan[], id: string, ids: ReadonlySet<string>, now: number): Plan[] {
1058  const moving = (list.find(p => p.id === AGENTS)?.agents ?? []).filter(a => ids.has(a.id))
1059  if (moving.length === 0) return [...list]
1060  return list.flatMap(p => {
1061    if (p.id === AGENTS) {
1062      const left = (p.agents ?? []).filter(a => !ids.has(a.id))
1063      return left.length > 0 ? [syncAuto({ ...p, agents: left }, now)] : []
1064    }
1065    if (p.id !== id) return [p]
1066    // as addRun does, a batch that has finished makes room
1067    return [syncAuto({ ...p, agents: [...(p.agentsDoneAt ? [] : (p.agents ?? [])), ...moving], agentsDoneAt: null }, now)]
1068  })
1069}
1070
1071function addRun(p: Plan, run: AgentRun, parentId: string | undefined, now: number): Plan {
1072  // a batch that has finished makes room for the next one
1073  const list = p.agentsDoneAt ? [] : [...(p.agents ?? [])]
1074  let at = list.length
1075  const parentAt = parentId ? list.findIndex(a => a.id === parentId) : -1
1076  if (parentAt >= 0) {
1077    at = parentAt + 1
1078    while (at < list.length && (list[at]?.depth ?? 0) > 0) at++
1079  }
1080  list.splice(at, 0, run)
1081  return syncAuto({ ...p, agents: list, agentsDoneAt: null }, now)
1082}
1083
1084// changes one agent's strip inside the latest list; silent: a subagent answers to Claude, and a prompt put to the person sounds through the engine's notification
1085async function editAgent($: EngineInterface, agentId: string, change: (a: AgentRun) => AgentRun) {
1086  const home = agentHome.get(agentId)
1087  if (!home) return
1088  const now = await $.clock.now()
1089  let isFolding = false
1090  await update($, plans, list =>
1091    list.map(p => {
1092      if (p.id !== home || !p.agents?.some(a => a.id === agentId)) return p
1093      const next = syncAuto({ ...p, agents: p.agents.map(a => (a.id === agentId ? change(a) : a)) }, now)
1094      isFolding = !p.agentsDoneAt && next.agentsDoneAt !== null
1095      return next
1096    }),
1097  )
1098  if (isFolding) foldUntil = now + FOLD_MS + 200
1099}
1100
1101// module maps outlive the bars they describe: a bar pushed out past MAX_BARS, a cleared list, an agent
1102// whose finish never arrived (killed, or started before a reload); drop what no bar holds any more
1103function forgetGone(list: readonly Plan[]) {
1104  const bars = new Set(list.map(p => p.id))
1105  const live = new Set(list.flatMap(p => (p.agents ?? []).filter(a => a.state === 'running' || a.state === 'waiting').map(a => a.id)))
1106  const shown = new Set(list.flatMap(p => (p.agents ?? []).map(a => a.id)))
1107  for (const id of lastHead.keys()) if (!bars.has(id)) lastHead.delete(id)
1108  for (const id of glide.keys()) if (!bars.has(id)) glide.delete(id)
1109  for (const id of lastStrip.keys()) if (!shown.has(id)) lastStrip.delete(id)
1110  for (const [id, home] of agentHome) {
1111    if (bars.has(home) && live.has(id)) continue
1112    agentHome.delete(id)
1113    waiting.delete(id)
1114  }
1115}
1116
1117async function dropPlan($: EngineInterface, id: string) {
1118  lastHead.delete(id)
1119  glide.delete(id)
1120  for (const p of await read($, plans)) if (p.id === id) for (const a of p.agents ?? []) lastStrip.delete(a.id)
1121  await update($, plans, list => list.filter(p => p.id !== id))
1122}
1123
1124// substeps are left out: the bar never draws them and short ops cannot reach them
1125const STEP_SCHEMA = {
1126  type: 'object',
1127  required: ['title'],
1128  properties: {
1129    title: { type: 'string' },
1130    status: { enum: STATUSES, description: 'Default pending' },
1131  },
1132}
1133
1134// edits are the work the enforcement below counts. Shell calls are never refused or counted: the host marks only
1135// plain reads (ls) read-only, so a compound read (cd x && git log) would look like work and be refused
1136const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
1137const SHELL_TOOLS = new Set(['Bash', 'PowerShell'])
1138const WORK_BEFORE_PLAN = 3 // the 4th edit without a plan is refused once
1139const CALLS_BEFORE_NUDGE = 6 // edits without a plan update before a reminder
1140
1141
1142
1143// $.state lives as long as the process, so the bars are kept per session in the plugin's store as well
1144const SAVED = 'plans:'
1145const KEEP_SESSIONS = 20
1146let lastSaved: Plan[] | null = null
1147
1148async function savePlans($: EngineInterface, list: Plan[]) {
1149  lastSaved = list
1150  const key = SAVED + (await $.session.id())
1151  if (list.length === 0) {
1152    await $.store.delete(key)
1153    return
1154  }
1155  await $.store.set(key, list)
1156  const keys = (await $.store.keys()).filter(k => k.startsWith(SAVED))
1157  for (const old of keys.slice(0, Math.max(0, keys.length - KEEP_SESSIONS))) await $.store.delete(old)
1158}
1159
1160// agents do not outlive the process that ran them, so a restored bar comes back without strips
1161async function restorePlans($: EngineInterface) {
1162  const saved = await $.store.get(SAVED + (await $.session.id()))
1163  if (!Array.isArray(saved) || saved.length === 0) return
1164  const list = (saved as Plan[]).map(p => ({ ...p, agents: [], agentsDoneAt: null }))
1165  await update($, plans, () => list)
1166  lastSaved = list
1167}
1168
1169export const register: Register = on => {
1170  // per-turn bookkeeping; module variables are fine here, a reload just starts a fresh count
1171  let workCalls = 0
1172  let sinceUpdate = 0
1173  let isPlanTouched = false
1174  let hasRefused = false
1175  let isWaitingOnBackground = false
1176  // agents started this turn onto the Agents bar, for the bar the turn opens next (adoptAgents)
1177  const toAdopt = new Set<string>()
1178
1179  on('turn.start', async ($, e, next) => {
1180    isTurnLive = true
1181    workCalls = 0
1182    sinceUpdate = 0
1183    isPlanTouched = false
1184    hasRefused = false
1185    isWaitingOnBackground = false
1186    toAdopt.clear()
1187
1188    return next(e)
1189  })
1190
1191  // the rule lives in the cached system prompt; a message only carries one short line when bars are open,
1192  // and the person answering clears any "needs input" without a model call
1193  on('prompt.submit', async ($, e, next) => {
1194    if (e.origin.kind !== 'composer') return next(e)
1195    const list = await read($, plans)
1196    if (list.some(p => p.state === 'needs_input')) {
1197      await update($, plans, all => all.map(p => (p.state === 'needs_input' ? { ...p, state: 'running' as const, note: null } : p)))
1198    }
1199    const open = list.filter(p => p.state !== 'done' && p.id !== AGENTS)
1200    if (open.length === 0) return next(e)
types/index.d.ts 51 lines
1export type StepStatus = 'pending' | 'active' | 'done' | 'error' | 'skipped'
2export type PlanSubstep = { title: string; status: StepStatus }
3// doneAt: when the step was finished, so a checkpoint can tell how long it took
4export type PlanStep = { title: string; status: StepStatus; substeps: PlanSubstep[]; doneAt?: number }
5export type PlanStage = { name: string; steps: PlanStep[] }
6export type PlanState = 'running' | 'needs_input' | 'error' | 'done'
7// one subagent shown as a state strip under a bar; depth 1 sits under its parent agent
8export type AgentRun = {
9  id: string
10  title: string
11  state: 'running' | 'waiting' | 'done' | 'error'
12  tool: string
13  startedAt: number
14  endedAt: number | null
15  depth: number
16  // the model it runs on and its effort, as the engine resolved them
17  model?: string
18  effort?: string
19  // cc-mods: the agent definition it runs as (subagent_type: worker, Explore, a plugin's agent)
20  type?: string
21  // cc-mods: the input tokens its last request carried (uncached, cache read and cache written), as the status line counts them
22  ctx?: number
23}
24export type Plan = {
25  id: string
26  title: string
27  kind: 'plan' | 'todo'
28  stages: PlanStage[]
29  state: PlanState
30  note: string | null
31  startedAt: number
32  // when the plan was finished; the pill then shows the time it took
33  endedAt?: number | null
34  agents?: AgentRun[]
35  // when the current batch of agents all finished; their strips fold a few seconds later
36  agentsDoneAt?: number | null
37}
38
39declare module 'claude-code' {
40  interface PluginState {
41    'plan-progress': {
42      plans: Plan[]
43      isOpen: boolean
44      // bumped every second while agents run, so elapsed times and folding redraw
45      tick: number
46      // bars whose agent strips are opened out (cc-mods: strips fold by default)
47      expanded: string[]
48    }
49  }
50}
51