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

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.
| Mod | Upstream | Local changes | License |
|---|---|---|---|
| md-prompt | nogu66/md-prompt | 0.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 is | MIT |
| plan-progress | zycck/claude-mods | 0.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 25 | MIT |
| cache-timer | none, 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 twice | MIT |
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.
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.
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:
| Check | What it covers |
|---|---|
npm test | md-prompt's Bun tests, plan-progress's asserted stub-engine regressions, and the upstream-status script's local Git fixtures |
npm run check:plugins | Marketplace/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 checks | actionlint 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.
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.
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.
.github/workflows, so the CI files inside mods/*/.github/ never run here.hooks/register.tsx 1648 lines1import { 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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 lines1export 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