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

newbandspinnerguardcommandtoast
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 [ Progress ] ⟨Claude Code's own drawing⟩
README

<h1 align="center">Awesome Claude Code Mods</h1>

<a id="showcase"></a>

Mod showcase

<table> <tr><td width="33%" align="center" valign="top"><p><a href="https://github.com/therahul-yo/clawdman"><img src="assets/showcase-01.gif" width="100%" alt="Clawdman: An animated companion that reacts to your coding session. Demo · 10×."></a></p><p><strong><a href="https://github.com/therahul-yo/clawdman">Clawdman</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="AgentMods/pi-agent/"><img src="assets/showcase-02.gif" width="100%" alt="Pi Agent: Run pi-powered models as native Claude Code subagents. Demo · 10×."></a></p><p><strong><a href="AgentMods/pi-agent/">Pi Agent</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="FocusMods/breathing-exercises/"><img src="assets/showcase-03.gif" width="100%" alt="Breathing Exercises: A guided breathing animation while Claude works."></a></p><p><strong><a href="FocusMods/breathing-exercises/">Breathing Exercises</a></strong></p></td></tr> <tr><td width="33%" align="center" valign="top"><p><a href="sources/creative-toolkit/"><img src="assets/showcase-04.png" width="100%" alt="Creative Toolkit: A collection of live panels, session tools and playful mods. Code Pet screenshot."></a></p><p><strong><a href="sources/creative-toolkit/">Creative Toolkit</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="GameMods/agent-cartoons/"><img src="assets/showcase-05.gif" width="100%" alt="Agent Cartoons: Coding activity becomes a cartoon in a choice of visual styles."></a></p><p><strong><a href="GameMods/agent-cartoons/">Agent Cartoons</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="https://github.com/adamholter/claude-subway-surfers"><img src="assets/showcase-06.gif" width="100%" alt="Subway Surfers Desktop: Gameplay beside Claude Desktop while it works. Desktop demo · 10×."></a></p><p><strong><a href="https://github.com/adamholter/claude-subway-surfers">Subway Surfers Desktop</a></strong></p></td></tr> <tr><td width="33%" align="center" valign="top"><p><a href="GameMods/dino-game/"><img src="assets/showcase-07.gif" width="100%" alt="Dino Game: Play the T-rex runner above your prompt."></a></p><p><strong><a href="GameMods/dino-game/">Dino Game</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="GameMods/spell-bar/"><img src="assets/showcase-08.gif" width="100%" alt="Spell Bar: An animated spell bar for your selected effort level."></a></p><p><strong><a href="GameMods/spell-bar/">Spell Bar</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="AgentMods/agent-dashboard/"><img src="assets/showcase-09.gif" width="100%" alt="Agent Dashboard: A live dashboard for context, costs, permissions and agents."></a></p><p><strong><a href="AgentMods/agent-dashboard/">Agent Dashboard</a></strong></p></td></tr> </table>

<a id="browse-by-category"></a>

<h2>Browse by category <img src="assets/category-magnifier.gif" width="52" height="40" alt="Clawd with a magnifying glass"></h2>

Usage Mods · Agent Mods · Planning Mods · File Mods · Git Mods · Safety Mods · Memory Mods · Interface Mods · Prompt Mods · Testing Mods · Web Mods · Focus Mods · Game Mods · Integration Mods

Source packages available here: 82 mods from 38 repositories, with their complete tracked files and notices. Local package links open inside this collection. Other entries currently lead to upstream references while the wider source import is reviewed.

Entries are grouped by their main purpose. Collections can contain several kinds of mods; hosted mod pages are categorized individually.

<a id="usage"></a>

Usage Mods

Browse Usage Mods

Track model usage, costs, effort and quotas.

  • Auto Effort · arthur-fontaine - Let Jev select Claude's reasoning effort for each prompt, adapting the configured effort level to the incoming request rather than choosing it manually every time.
  • Automatic Model and Effort Router · sofanaja44 - Choose a Claude model and reasoning effort according to task difficulty, with rules that limit unnecessary prompt-cache rewrites. Cost and cache statistics help you follow the routing decisions.
  • Braille Usage · kawase1295 - Check your current model, context usage and five-hour or weekly limits in compact text-based meters above the prompt.
  • Budget and Context Toolkit · Arunjay4213 - Track session spending, subscription quotas, cache use and per-turn token costs. Set budget limits that warn before exhaustion and stop tool calls when a configured limit is exceeded.
  • Budget Governor · ccdwyer - Set session and daily spending caps, monitor them above the prompt, and receive a wrap-up reminder at eighty percent. New prompts are refused once the cap is reached.
  • Cache, Context and Cost Panel · anthonyhungnguyen - Track token spending for the session and recent days, get context-limit warnings, and see what rebuilding the prompt cache cost. A completion alert announces long turns.
  • Clawd Usage Meters · Toshkee - Watch live context, rate-limit and cost meters above the prompt, accompanied by Clawd's wand animations and banner tricks as you work in Claude Code.
  • Cockpit and Bodyguard · Para-FR - Inspect session turns, tools, tokens, durations and changed files in a live cockpit. A companion guard blocks access to .env files and destructive Bash commands, displaying blocked-action alerts.
  • Context and Quota Forecast · imsalik - See a context forecast alongside five-hour and weekly rate-limit usage above the prompt. Reset countdowns help you understand when each plan allowance becomes available again.
  • Context Saver · AlmogBaku - Spot repeated tool calls and wasteful workflows during a Claude Code session. Review their measured time and context cost, then send a suggested correction with one click.
  • Context Weather and Usage · travisoa - Read context fullness as changing weather symbols alongside token totals and quota consumption. Recent-turn bars show how the conversation grew, helping you spot increasing pressure on the context window.
  • Cost and Output Token Band · Luma-Sa - Keep five-hour and seven-day usage limits, session cost and output-token totals visible in a band above the Claude Code prompt while you work.
  • Cost Meter · zaferayan - Track API-equivalent session cost above the prompt, including subagent usage, with a configurable budget warning. On subscription plans, the displayed amount estimates API pricing rather than your bill.
  • Desktop Next Steps and Limits · shichang4fun - See quota limits and reset countdowns above the prompt in Claude Code Desktop. After each turn, suggested next prompts can be sent with a click or copied for editing.
  • Desktop Session Status Bar · sgmonda - Keep five-hour and weekly usage limits, reset times, session clocks and turn duration visible in Claude Code Desktop. The bar also lists active subagents, background jobs and scheduled wakeups.
  • Desktop SVG Usage Meters · s-hiraoku - See context use and the five-hour and seven-day quota windows as SVG meters above the prompt in Claude Code Desktop. The compact display keeps capacity visible while you work.
  • Effort Guard · stefanochieli - Track context and token usage, review an effort log for each turn and receive an escalation alert after repeated command or test failures. It observes activity without blocking it.
  • Effort Router · totally-tim - Choose reasoning effort for each turn with Jev or a compatible classifier, and display the selected level. Shadow mode records the recommendation; enforce mode applies it to the session.
  • Effort Shortcut · daanqq - Cycle Claude's reasoning effort through low, medium, high, and extra-high with one command. Bind that command to a key to change effort quickly while skipping the maximum setting.
  • Focus Band · chaoshengsc - Keep your five-hour and weekly plan usage visible above the prompt. Progress bars, percentages, and reset times help you see how much of each allowance you have used.
  • Jet Router · jetsongdev - Experiment with per-turn reasoning effort recommendations while keeping your chosen model. Use fixed test decisions or opt into Jev classification, with observation and optional application modes.
  • Jev Claude Router · Flam1ngFir3ball - Let Jev choose the model and reasoning effort for each turn while considering the cost of switching. It also provides Jev-guided context compaction.
  • Jev Model and Effort Selection · unclecode - Observe or pin model and effort choices, or opt into automatic selection with Jev. The experimental router lets you compare recommendations before allowing them to steer the session.
  • Jev Model Router · satviksinha - Choose a model and reasoning effort for each turn using TypeSafe's Jev, through its direct API or Vercel AI Gateway. A visible routing decision shows which model will handle the work.
  • Jev Route · drewpayment - Choose a Claude model for each turn using Jev's estimate of what the task needs. Toggle routing with /route to balance model capability against cost.
  • Limitpace · fstandhartinger - Track subscription limits and spending pace through live usage bars. An optional delegation adviser can suggest using another provider as part of managing your available capacity.
  • Model and Effort Router · moritalous - Choose reasoning effort for each prompt while keeping the model fixed, or enable a separate router that selects Haiku, Sonnet, or Opus using Jev. Both tools adjust resources per turn.
  • Model Shortcuts · richkuo - Use keyboard shortcuts to switch models and adjust reasoning effort, with the active choices displayed in the prompt footer.
  • Netrunner HUD · ccdwyer - Watch context use, token activity, tool calls, costs, and job status in an animated cyberpunk dashboard. Git and test widgets keep related session information alongside the live usage gauges.
  • Next Steps and Usage Band · pawandeepdhall - Keep five-hour and weekly usage limits visible with reset countdowns, and choose from suggested next steps after each reply. Buttons also provide shortcuts for starting a new chat or pushing work.
  • Oxide Jev Model Router · Kiy-K - Use Jev to choose subagent models and adjust the main conversation's model and reasoning effort as work progresses. A built-in classifier provides a fallback without an API key.
  • Plan and Context Usage Line · KhadeerBasha1232 - Track five-hour and weekly plan limits, reset times, context usage, the active model and session cost in one line above the Claude Code prompt.
  • Plan Usage and Reset Times · jumoog - Keep five-hour and weekly plan usage visible above the prompt, together with each reset time. Check the current allowance windows without opening a separate usage screen.
  • Power Bottom Status Line · brianSchanbacher - See the model, folder, branch, context fill, cost, elapsed time, cache hits and live agents in a cross-platform status line at the bottom of Claude Code.
  • Project Spend and Cache Status · Sma1lboy - Track project spending alongside context usage, cache hits and the cache countdown. Inspect why a turn missed the cache, and open the project ledger with /usage.
  • Prompt Enhancement and Telemetry · MiguelMachado-dev - Inspect per-turn duration, first-token latency, token throughput, requests and stalls in a live band. An additional command rewrites a draft prompt with Sonnet and returns it for your review.
  • Prompt Footer Usage · WoBok - Keep context usage and rate-limit consumption visible in Claude Code's prompt footer, so you can check those readings without opening a separate usage view.
  • Prompt Usage Timeline · augiefra - Compare five-hour and weekly usage with elapsed time, inspect token bars for recent prompts and watch context status above the prompt. Labels are available in English or French.
  • Quota and Cost Pills · Sh0ckWaveZero - Track five-hour and seven-day rate limits, session tokens and cost in compact indicators above the prompt. The band can sit alongside other status bands.
  • Quota Reserve · chrisns - Reserve part of your five-hour and weekly quota for later work. The mod pauses at configured thresholds, asks whether to continue, and releases the reserve shortly before the reset.
  • Rate-Limit History Band · mohammed-alsalhi - Follow rate-limit consumption since each window last reset, including five-hour, weekly, and per-model weekly allowances. A band above the prompt graphs how usage accumulates over time.
  • Retro Usage Band · iamkhalid2 - Keep your plan's five-hour and weekly usage limits in view through a slim retro-style band above the prompt. Check both allowances as you work.
  • Sessclone · NotTahaAli - Send Claude Code usage to a SessClone deployment so laptop, cloud and CI sessions can contribute to a shared team ledger of usage and cost.
  • Session and Weekly Usage Band · Gooner44 - See session, weekly and Fable 5.1 usage together in a band above the Claude Code prompt, keeping these separate usage readings visible while you work.
  • Session Cockpit · nvr0x5 - Track context, spending, usage limits, reset countdowns and burn rates above the prompt. Plan progress and active subagents share the same cockpit, with display options for terminal and Desktop sessions.
  • Session Cost and Remaining Usage · HydrowZer - See the current session cost and remaining usage in a band above the Claude Code prompt, keeping spending and available capacity visible beside your conversation.
  • Session Dashboard and Alerts · lucenity0 - Follow turn durations, token counts, files touched, and tool activity through a timer and dashboard. Companion mods notify you when long turns finish and block destructive commands or edits to secrets.
  • Session Internals Dashboard · tomstagl - Inspect context, tokens, cost, limits, tools and agents in a live side-pane dashboard. An accompanying insights interface lets the session answer questions using the displayed measurements.
  • Session Sidebar · richard-parayno - Toggle a sidebar showing usage limits, context size, prompt-cache expiry, current activity and touched files. It keeps the session's resource picture visible beside the conversation.
  • Stack and Wait Time · ShahriarBijoy - Follow tasks, subagents, rate limits and monthly cost in a stacked progress band. A companion vocabulary game uses German words related to the session's topic while Claude works.
  • Terminal Usage Monitor · ilkayGkbdk - Monitor context fill, rate limits, token speed, cost, and active subagents in a live terminal dashboard. The display brings the session's resource use together in one place.
  • Turn Receipts and Error Cues · roaringsoul404 - Receive a receipt after each turn listing touched files, commands, tokens and cost. A companion animation turns the spinner into a pixel-art failure cue when a tool call fails.
  • Usage and Active Agent Dashboard · quango2304 - Follow usage bars, limits, session cost, cache expiry and active time alongside the agents currently running. The combined display makes resource consumption visible while work is underway.
  • Usage and Cache Warmth Band · Vosssa - See five-hour and weekly usage, context fill and prompt-cache warmth together above the Claude Code prompt, keeping the main resource readings visible while you work.
  • Usage and Spend Chips · kreddevils18 - Track five-hour and weekly allowances, session cost, and spending history through colored chips above the prompt. Open /usage-mod for a fuller view of the usage details.
  • Usage Line · Steven-ODell - See five-hour, weekly and context usage in the prompt footer, including whether consumption is ahead of or behind an even pace through the usage window.
  • Usage Log · tanuu5 - Record five-hour and weekly usage after each turn, then compare consumption with your chosen pace in a graph. The separate Clawd Dance mod can also show that pacing above the prompt.
  • Usage Report · Schweem - Monitor context usage, five-hour and weekly quotas, and session cost in the status line, with warnings as you approach your limits.
  • Usage Wrapup · DaKev - Ask running agents to wrap up when your remaining plan allowance gets low, helping them finish their current work before the usage limit interrupts it.
  • Weekly Usage and Spend Board · aycandv - Compare spending by model with a weekly-limit pace gauge, using a sliding ticker in the terminal or a strip and details pane in the desktop app.

<a id="agents"></a>

Agent Mods

Browse Agent Mods

Coordinate agents and follow their actual work.

  • 5dive Telemetry Toolkit · 5dive-ai - Connect Claude Code sessions to 5dive's task and gate controls, with a panel showing assignments and token budgets. Optional telemetry, compaction and tool-call policies support coordinated agent work.
  • Agent Control and Document Desk · masahide - Send messages and interruption requests to Claude through the agentctl command line and collect acknowledgments. A companion document desk gathers questionnaire answers and detailed document-review comments in a browser, then delivers them to Claude.
  • Agent Dashboard · scasella - Monitor the main agent and its subagents in a live dashboard that brings together model usage, permission requests, task activity and session logs.
  • Agent Flow · Charlie0113-T - Open /flow to follow a live tree of the session's subagents and teammates beside the transcript. A text view is available where panes cannot be displayed.
  • Agent Models and Peek · alex2481kobe - Show each subagent's model and reasoning effort beside its task in the native agent list. A companion viewer displays images inline, using real pixels or a terminal-compatible block
Source 2 files
hooks/register.tsx 1713 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)
13const STRIP_H = 16
14const STRIP_GAP = 2
15// rows of strips per bar, the "+N more" row included: the band above the prompt has room for about 300 px
16const stripBudget = (bars: number) => (bars >= 3 ? 3 : bars === 2 ? 4 : 5)
17const FOLD_MS = 5000 // finished strips stay this long, failed ones stay until the bar closes
18
19// one lightness for every state (OKLCH L .55, hues of the desktop's violet, amber, red and green), so no state
20// shouts louder than another, and white on each reads at 4.5:1 or better
21const STATE_COLOR: Record<PlanState, string> = { running: '#7858CA', needs_input: '#AD6400', error: '#C5353E', done: '#18883A' }
22const INK = '#FFFFFF'
23const STATE_GLYPH: Record<PlanState, string> = { running: '●', needs_input: '?', error: '!', done: '✓' }
24const STATUSES: StepStatus[] = ['pending', 'active', 'done', 'error', 'skipped']
25const TRACK_H = 22
26const NARROW = 360
27
28const RULES = `# Progress bars
29Tasks needing more than ~3 edits or commands get a bar via ${TOOL}: 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.`
30
31type Raw = Record<string, unknown>
32const str = (v: unknown, max = 120) => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim().slice(0, max) : '')
33const status = (v: unknown): StepStatus => (STATUSES.includes(v as StepStatus) ? (v as StepStatus) : 'pending')
34const list = (v: unknown): Raw[] => (Array.isArray(v) ? v.filter(x => x && typeof x === 'object') : []) as Raw[]
35const isFinished = (s: StepStatus) => s === 'done' || s === 'skipped'
36
37const same = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase()
38
39// short updates: {next:true}, {done:[titles]}, {active:title}, {failed:title} against the stored plan;
40// titles it cannot find come back in missing, so the call is refused instead of passing as a success
41function applyOps(stages: PlanStage[], input: Raw, now: number): { stages: PlanStage[]; missing: string[] } {
42  const next = stages.map(s => ({ ...s, steps: s.steps.map(st => ({ ...st })) }))
43  const steps = next.flatMap(s => s.steps)
44  const missing: string[] = []
45  // a step finishing now remembers when, for the time its checkpoint shows
46  const finish = (st: PlanStep) => {
47    if (!isFinished(st.status)) Object.assign(st, { status: 'done', doneAt: now })
48  }
49  // a title used twice means the one still open
50  const find = (title: string) => {
51    const found = steps.find(st => same(st.title, title) && !isFinished(st.status)) ?? steps.find(st => same(st.title, title))
52    if (!found) missing.push(title)
53    return found
54  }
55  if (input.next === true) {
56    const at = steps.findIndex(st => st.status === 'active') >= 0 ? steps.findIndex(st => st.status === 'active') : steps.findIndex(st => !isFinished(st.status))
57    const cur = steps[at]
58    if (cur) finish(cur)
59    // past the last step, work goes back to one left open earlier
60    const following = steps.slice(at + 1).find(st => st.status === 'pending') ?? steps.find(st => st.status === 'pending')
61    if (following) following.status = 'active'
62  }
63  let lastDone = -1
64  for (const t of Array.isArray(input.done) ? input.done : []) {
65    const st = typeof t === 'string' ? find(t) : undefined
66    if (st?.status === 'active') lastDone = steps.indexOf(st)
67    if (st) finish(st)
68  }
69  // finishing the step in progress moves on, as next does, unless the call names the new one itself
70  if (lastDone >= 0 && typeof input.active !== 'string' && !steps.some(st => st.status === 'active')) {
71    const following = steps.slice(lastDone + 1).find(st => st.status === 'pending') ?? steps.find(st => st.status === 'pending')
72    if (following) following.status = 'active'
73  }
74  const active = typeof input.active === 'string' ? find(input.active) : undefined
75  if (active) {
76    const at = steps.indexOf(active)
77    steps.forEach((st, i) => {
78      if (st.status === 'active' && i !== at) {
79        if (i < at) finish(st)
80        else st.status = 'pending'
81      }
82    })
83    active.status = 'active'
84  }
85  const failed = typeof input.failed === 'string' ? find(input.failed) : undefined
86  if (failed) failed.status = 'error'
87
88  return { stages: next, missing }
89}
90
91// the new bar, or the refusal for a call that names steps the bar does not have
92function normalize(input: Raw, prev: Plan | null, now: number, id: string): Plan | string {
93  const sent = list(input.stages)
94    .map(s => ({
95      name: str(s.name, 80) || 'Stage',
96      steps: list(s.steps).map(st => ({
97        title: str(st.title) || 'Step',
98        status: status(st.status),
99        substeps: list(st.substeps).map(sub => ({ title: str(sub.title) || '…', status: status(sub.status) })),
100      })),
101    }))
102    .filter(s => s.steps.length > 0) as PlanStage[]
103  const isPartial = sent.length === 0 && prev !== null
104  // a resent plan keeps what was finished; short ops sent along with it apply on top
105  const base = isPartial ? prev.stages : pointAt(prev ? carryDone(sent, prev.stages) : sent)
106  const { stages, missing } = applyOps(base, input, now)
107  if (missing.length > 0 && base.length > 0) {
108    const titles = stages.flatMap(s => s.steps.map(st => st.title)).join(', ')
109    return `plan_progress: "${id}" has no step ${missing.map(t => `"${str(t, 60)}"`).join(', ')}. Its steps: ${titles.slice(0, 400)}`
110  }
111  const title = str(input.title, 80) || prev?.title || 'Plan'
112  const steps = stages.flatMap(s => s.steps)
113  const isAllDone = steps.length > 0 && steps.every(s => isFinished(s.status))
114  const asked = input.state as PlanState
115  const failedNow = typeof input.failed === 'string'
116  const state: PlanState = ['running', 'needs_input', 'error', 'done'].includes(asked) ? asked : isAllDone ? 'done' : failedNow ? 'error' : 'running'
117
118  return {
119    id,
120    title,
121    kind: input.kind === 'todo' || (isPartial && prev?.kind === 'todo') ? 'todo' : 'plan',
122    stages,
123    state,
124    note: str(input.note, 160) || null,
125    startedAt: prev ? prev.startedAt : now,
126    endedAt: state === 'done' ? (prev?.endedAt ?? now) : null,
127  }
128}
129
130// a resent plan keeps what is finished: a step sent as pending under a title that was done stays done
131function carryDone(stages: PlanStage[], before: PlanStage[]): PlanStage[] {
132  const finished = new Map(before.flatMap(s => s.steps).filter(st => isFinished(st.status)).map(st => [st.title.trim().toLowerCase(), st]))
133  return stages.map(s => ({
134    ...s,
135    steps: s.steps.map(st => {
136      const was = finished.get(st.title.trim().toLowerCase())
137      return was && (st.status === 'pending' || st.status === was.status) ? { ...st, status: was.status, doneAt: was.doneAt } : st
138    }),
139  }))
140}
141
142// with nothing in progress, the first open step is the current one
143function pointAt(stages: PlanStage[]): PlanStage[] {
144  const steps = stages.flatMap(s => s.steps)
145  if (steps.some(st => st.status === 'active' || st.status === 'error')) return stages
146  const first = steps.find(st => st.status === 'pending')
147  return stages.map(s => ({ ...s, steps: s.steps.map(st => (st === first ? { ...st, status: 'active' as const } : st)) }))
148}
149
150// the bar an approved plan-mode plan lands on; a revised plan updates it instead of opening a second one
151const PLAN_ID = 'plan'
152
153const clean = (s: string) =>
154  s
155    .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
156    .replace(/[*_`]/g, '')
157    .replace(/^\s*(\d+[.)]|[-*+]|\[[ xX]\])\s+/, '')
158    .replace(/^(\d+[.)]|\[[ xX]\])\s+/, '')
159    .trim()
160
161function parsePlan(markdown: string, now: number): Plan | null {
162  let title = ''
163  const headed: PlanStage[] = []
164  const items: { depth: number; text: string; status: StepStatus }[] = []
165  for (const line of markdown.split(/\r?\n/)) {
166    const h = line.match(/^(#{1,4})\s+(.*)$/)
167    if (h) {
168      const text = clean(h[2] ?? '')
169      if (h[1] === '#' && !title) title = text
170      else headed.push({ name: text, steps: [] })
171      continue
172    }
173    const li = line.match(/^(\s*)(\d+[.)]|[-*+])\s+(.*)$/)
174    if (!li) continue
175    const depth = Math.floor((li[1] ?? '').replace(/\t/g, '  ').length / 2)
176    const text = clean(li[3] ?? '').slice(0, 120)
177    if (!text) continue
178    // a ticked box is a step the plan already counts as done
179    const status: StepStatus = /^\[[xX]\]\s/.test(li[3] ?? '') ? 'done' : 'pending'
180    items.push({ depth, text, status })
181    const stage = headed[headed.length - 1]
182    if (!stage) continue
183    const step = stage.steps[stage.steps.length - 1]
184    if (depth === 0 || !step) stage.steps.push({ title: text, status, substeps: [] })
185    else step.substeps.push({ title: text, status })
186  }
187  let stages = headed.filter(s => s.steps.length > 0)
188  if (stages.length === 0) {
189    if (items.some(i => i.depth > 0)) {
190      for (const item of items) {
191        const stage = stages[stages.length - 1]
192        if (item.depth === 0 || !stage) stages.push({ name: item.text, steps: [] })
193        else stage.steps.push({ title: item.text, status: item.status, substeps: [] })
194      }
195      stages = stages.map(s => (s.steps.length ? s : { ...s, steps: [{ title: s.name, status: 'pending', substeps: [] }] }))
196    } else if (items.length > 0) {
197      stages = [{ name: 'Tasks', steps: items.map(i => ({ title: i.text, status: i.status, substeps: [] })) }]
198    }
199  }
200  if (stages.length === 0) return null
201
202  return { id: PLAN_ID, title: title || 'Plan', kind: stages.length === 1 ? 'todo' : 'plan', stages, state: 'running', note: null, startedAt: now }
203}
204
205function st(title: string, s: StepStatus): PlanStep {
206  return { title, status: s, substeps: [] }
207}
208
209// ---------- drawing ----------
210
211type Where = { pos: number; total: number; stage: number; step: number; stageSize: number }
212
213// pos counts the finished steps wherever they are; the current step is the active one, else the first still open
214function where(p: Plan): Where {
215  const steps = p.stages.flatMap((s, i) => s.steps.map((step, j) => ({ i, j, step })))
216  const pos = p.state === 'done' ? steps.length : steps.filter(x => isFinished(x.step.status)).length
217  const cur = p.state === 'done' ? undefined : (steps.find(x => x.step.status === 'active') ?? steps.find(x => !isFinished(x.step.status)))
218  const stage = (cur ?? steps[steps.length - 1])?.i ?? 0
219
220  return { pos, total: steps.length, stage, step: cur ? cur.j + 1 : (p.stages[stage]?.steps.length ?? 0), stageSize: p.stages[stage]?.steps.length ?? 0 }
221}
222
223const hex = (h: string) => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16))
224const mix = (a: number[], b: number[], m: number) => a.map((v, i) => Math.round(v + ((b[i] ?? 0) - v) * m))
225const rgb = (c: number[]) => `rgb(${c.join(',')})`
226const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
227const hash = (a: number, b: number, k: number) => {
228  const x = Math.sin(a * 127.1 + b * 311.7 + k * 74.7) * 43758.5453
229  return x - Math.floor(x)
230}
231const 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)
232
233const ICON_PATH: Partial<Record<PlanState, string>> = {
234  needs_input: 'M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3M12 17h.01',
235  error: 'M18 6 6 18M6 6l12 12',
236  done: 'M20 6 9 17l-5-5',
237}
238
239// how long each finished step took (from the previous finish, or the plan's start) and each finished stage
240function stepTimes(p: Plan): { steps: Map<PlanStep, number>; stages: (number | undefined)[] } {
241  const ends = p.stages.flatMap(s => s.steps).flatMap(st => (st.doneAt === undefined ? [] : [st.doneAt])).sort((a, b) => a - b)
242  const startOf = (at: number) => Math.max(p.startedAt, ...ends.filter(t => t < at))
243  const steps = new Map<PlanStep, number>()
244  const stages = p.stages.map(s => {
245    for (const st of s.steps) if (st.doneAt !== undefined) steps.set(st, st.doneAt - startOf(st.doneAt))
246    const times = s.steps.map(st => st.doneAt)
247    if (times.some(t => t === undefined)) return undefined
248    const done = times as number[]
249    return Math.max(...done) - Math.min(...done.map(startOf))
250  })
251  return { steps, stages }
252}
253
254// 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
255function addDot(dots: Map<string, string>, cls: string, x: number, y: number) {
256  dots.set(cls, `${dots.get(cls) ?? ''}M${x} ${y}h2v2h-2z`)
257}
258
259// last drawn head position per plan, so a redraw glides from where the bar was
260const lastHead = new Map<string, number>()
261
262// the track draws in a sandboxed frame (for hover); its page must stay see-through in either theme
263const SEE_THROUGH = '<style>:root,html,body{background:transparent!important;color-scheme:light dark;margin:0;overflow:hidden}svg{display:block}</style>'
264
265// a clock that counts in the frame by itself, so the drawing never has to be redrawn each second (a redraw
266// reloads the frame and everything in it blinks): each digit is a reel of its figures behind a one-line window,
267// stepped by a CSS animation whose negative delay is the time already run. Plain SVG, since the host's frame
268// drops foreignObject. {{T:start}} becomes those seconds only when a bar's markup really changes (liveSource)
269const CLOCK_W = 48 // "59m 59s"
270const LINE = 16
271const CLOCK_CSS = `.ckt{font-variant-numeric:tabular-nums}
272.rs1{animation:r10 10s steps(10) var(--d) infinite}.rs10{animation:r6 60s steps(6) var(--d) infinite}
273.cc{animation:cc 600s linear var(--d) both}@keyframes cc{0%,9.99%{transform:translateX(-13.5px)}10%,99.99%{transform:translateX(-3.25px)}100%{transform:none}}
274.rm1{animation:r10 600s steps(10) var(--d) infinite}.rm10{animation:r10 6000s steps(10) var(--d) infinite}.rmm{animation:hm 60s steps(1,end) var(--d) both}
275@keyframes r10{to{transform:translateY(-${LINE * 10}px)}}@keyframes r6{to{transform:translateY(-${LINE * 6}px)}}@keyframes hm{from{opacity:0}to{opacity:1}}`
276
277// x is the clock's left edge, top the window's top; the minutes part stays hidden for the first minute
278function liveClock(x: number, top: number, start: number, cls: string, textCls: string, isCentered = false): string {
279  const base = top + 12
280  const reel = (cx: number, figures: string[], reelCls: string) =>
281    `<g class="${reelCls}"><text class="${textCls} ckt" text-anchor="middle">${figures
282      .map((f, i) => `<tspan x="${cx.toFixed(1)}" y="${base + i * LINE}">${f}</tspan>`)
283      .join('')}</text></g>`
284  const digits = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
285  const id = `ck${start}x${Math.round(x)}y${Math.round(top)}`
286  return (
287    `<style>.${id}{--d:-{{T:${start}}}s}</style><g class="${cls} ${id}"><clipPath id="${id}"><rect x="${(x - 2).toFixed(1)}" y="${top}" width="${CLOCK_W + 4}" height="${LINE}"/></clipPath>` +
288    `<g clip-path="url(#${id})"${isCentered ? ' class="cc"' : ''}><g class="rmm">${reel(x + 3.5, ['', ...digits.slice(1)], 'rm10')}${reel(x + 10.5, digits, 'rm1')}` +
289    `<text x="${(x + 14).toFixed(1)}" y="${base}" class="${textCls}">m</text></g>` +
290    `${reel(x + 31, digits.slice(0, 6), 'rs10')}${reel(x + 38, digits, 'rs1')}<text x="${(x + 41.5).toFixed(1)}" y="${base}" class="${textCls}">s</text></g></g>`
291  )
292}
293
294// a bar's frame reloads whenever its markup changes; markup that differs only in when it was drawn keeps the
295// source already shown, so its clocks run on instead of the frame blinking
296const lastSource = new Map<string, { template: string; source: string }>()
297function liveSource(id: string, template: string, now: number): string {
298  const last = lastSource.get(id)
299  if (last?.template === template) return last.source
300  const source = template.replace(/\{\{T:(\d+)\}\}/g, (_, t: string) => Math.max(0, (now - Number(t)) / 1000).toFixed(1))
301  lastSource.set(id, { template, source })
302  return source
303}
304
305// a bar is drawn twice: the track itself as a plain picture, which the desktop keeps steady whatever else redraws,
306// and a see-through layer on top for the hover parts (checkpoint times, the pill's clock). That layer needs an
307// interactive frame, and the desktop rebuilds such frames on every redraw of the band; empty until hovered, the
308// rebuild is invisible. A plan is immutable, so both drawings at one width are reused until the plan changes
309type Track = { base: string; overlay: string }
310const drawn = new WeakMap<Plan, { W: number; track: Track }>()
311
312function trackSvg(p: Plan, W: number): Track {
313  const cached = drawn.get(p)
314  if (cached?.W === W) return cached.track
315  const track = drawTrack(p, W)
316  drawn.set(p, { W, track })
317  return track
318}
319
320function drawTrack(p: Plan, W: number): Track {
321  const H = TRACK_H
322  const w = where(p)
323  const done = p.state === 'done'
324  // the fill is exactly the finished share: a fresh plan starts empty
325  const frac = done ? 1 : Math.min(1, w.pos / Math.max(1, w.total))
326  const fx = frac * W
327  const key = p.id
328  const from = lastHead.get(key) ?? fx
329  lastHead.set(key, fx)
330
331  const acc = hex(STATE_COLOR[p.state])
332  const light = mix(acc, [255, 255, 255], 0.32)
333  const grey = [132, 130, 138]
334  const ease = 'calcMode="spline" keyTimes="0;1" keySplines=".2 .8 .2 1"'
335  const glide = Math.abs(from - fx) > 0.5
336
337  const bounds: number[] = []
338  let acc2 = 0
339  p.stages.forEach((s, i) => {
340    acc2 += s.steps.length
341    if (i < p.stages.length - 1) bounds.push((acc2 / w.total) * W)
342  })
343
344  // pixels: 3px grid, 7 rows, denser towards the head, twinkling and warming from grey to the state colour
345  const buckets = [0, 1, 2, 3, 4].map(b => {
346    const m = b / 4
347    const dense = 0.22 + 0.78 * Math.pow(m, 1.5)
348    return { color: rgb(mix(grey, light, m)), opacity: (0.35 + 0.65 * dense).toFixed(2) }
349  })
350  const dots = new Map<string, string>()
351  for (let col = 0; col * 3 < fx; col++) {
352    const x = col * 3
353    const u = Math.min(1, (x + 1.5) / fx)
354    const dense = 0.22 + 0.78 * Math.pow(u, 1.5)
355    const bucket = Math.min(4, Math.floor(Math.min(1, Math.pow(u, 0.9) * 1.1) * 4.99))
356    for (let r = 0; r < 7; r++) {
357      if (hash(col, r, 1) > dense + 0.1) continue
358      addDot(dots, `b${bucket} t${Math.floor(hash(col, r, 2) * 4)}`, x, 1 + r * 3)
359    }
360  }
361  const px = [...dots].map(([cls, d]) => `<path class="${cls}" d="${d}"/>`).join('')
362
363  const took = stepTimes(p)
364  const tipRules: string[] = []
365  let marks = ''
366  let hits = ''
367  let tips = ''
368  let k = 0
369  p.stages.forEach((s, i) => {
370    s.steps.forEach((_, j) => {
371      if (k > 0) {
372        const x = (k / w.total) * W
373        const isStage = j === 0
374        // a stage boundary is a short capsule, a step a dot; bright once passed
375        const passed = x < fx - 1
376        const fill = passed ? rgb(mix(light, [255, 255, 255], 0.45)) : '#A8A69E'
377        const opacity = passed ? (isStage ? 0.95 : 0.8) : isStage ? 0.75 : 0.6
378        marks += isStage
379          ? `<rect x="${(x - 1.5).toFixed(1)}" y="${(H - 10) / 2}" width="3" height="10" rx="1.5" fill="${fill}" opacity="${opacity}"/>`
380          : `<circle cx="${x.toFixed(1)}" cy="${H / 2}" r="1.4" fill="${fill}" opacity="${opacity}"/>`
381        const before = p.stages[isStage ? i - 1 : i]
382        const ended = isStage ? before?.steps[before.steps.length - 1] : s.steps[j - 1]
383        const label = isStage ? (before?.name ?? '') : (ended?.title ?? '')
384        const ms = isStage ? took.stages[i - 1] : took.steps.get(ended as PlanStep)
385        const text = ms === undefined ? label : `${label} · ${elapsed(ms)}`
386        const tw = textWidth(text, 6.2) + 16
387        const tx = Math.max(0, Math.min(W - tw, x - tw / 2))
388        hits += `<rect class="h${k}" x="${(x - 5).toFixed(1)}" width="10" height="${H}" fill="#000" fill-opacity="0"/>`
389        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>`
390        tipRules.push(`.h${k}:hover~.p${k}`)
391      }
392      k++
393    })
394    void i
395  })
396
397  // knob: a pill with stage and count, or a round dot with the stage number when narrow
398  const isNarrow = W < NARROW
399  const color = STATE_COLOR[p.state]
400  const icon = ICON_PATH[p.state]
401  const single = p.stages.length === 1
402  const number = single ? w.step : w.stage + 1
403  let knob = ''
404  let timePill = ''
405  let kw = H
406  if (isNarrow) {
407    const label = done ? '' : String(number)
408    knob = `<circle cx="0" cy="${H / 2}" r="${H / 2}" fill="${color}"/>${
409      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>`
410    }`
411  } else {
412    const name = done ? (p.endedAt ? elapsed(p.endedAt - p.startedAt) : 'Done') : single ? (p.stages[0]?.name ?? 'Tasks') : (p.stages[w.stage]?.name ?? '')
413    // the pill carries the stage name alone; the fill and the percent already say how far along it is
414    const count = ''
415    const iconW = icon ? 16 : 0
416    const countW = count ? textWidth(count, 6.5) : -6
417    const maxW = Math.max(80, W * 0.55)
418    let shown = name
419    while (shown.length > 3 && 20 + iconW + textWidth(shown) + 6 + countW > maxW) shown = shown.slice(0, -1)
420    if (shown !== name) shown = shown.trimEnd() + '…'
421    // a running pill is wide enough for its clock too, so the hover swap does not change its size
422    const textW = done ? textWidth(shown) : Math.max(textWidth(shown), CLOCK_W)
423    kw = Math.round(20 + iconW + textW + 6 + countW)
424    const left = -(iconW + textW) / 2
425    const mid = left + iconW + textW / 2
426    knob = `<rect x="${-kw / 2}" y="0" width="${kw}" height="${H}" rx="${H / 2}" fill="${color}"/>`
427    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"/>`
428    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>`
429    // hovering the pill lays a copy of it over the stage name, carrying the time the plan has run so far
430    if (!done) {
431      const face = `<rect x="${-kw / 2}" y="0" width="${kw}" height="${H}" rx="${H / 2}" fill="${color}"/>${
432        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"/>` : ''
433      }`
434      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 - CLOCK_W / 2, 3, p.startedAt, 'kc0', 'kt', true)}</g></g>`
435    }
436  }
437  const clampX = (x: number) => Math.max(kw / 2, Math.min(W - kw / 2, x))
438  const kx = clampX(fx)
439  const kFrom = clampX(from)
440
441  const style = `<style>
442.b0{fill:${buckets[0]?.color};fill-opacity:${buckets[0]?.opacity}}.b1{fill:${buckets[1]?.color};fill-opacity:${buckets[1]?.opacity}}
443.b2{fill:${buckets[2]?.color};fill-opacity:${buckets[2]?.opacity}}.b3{fill:${buckets[3]?.color};fill-opacity:${buckets[3]?.opacity}}
444.b4{fill:${buckets[4]?.color};fill-opacity:${buckets[4]?.opacity}}
445.t0,.t1,.t2,.t3{animation:tw 2.2s ease-in-out infinite}
446.t1{animation-duration:2.8s;animation-delay:-.7s}.t2{animation-duration:1.9s;animation-delay:-1.3s}.t3{animation-duration:3.3s;animation-delay:-.4s}
447@keyframes tw{0%,100%{opacity:1}50%{opacity:.45}}
448.kt{font:500 12px 'Anthropic Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;fill:${INK}}
449.kc{font-weight:400;fill-opacity:.75}
450@media (prefers-reduced-motion:reduce){.t0,.t1,.t2,.t3{animation:none}}
451</style>`
452  const hoverStyle = `<style>
453.tp{opacity:0;transition:opacity .12s;pointer-events:none}${tipRules.length ? `${tipRules.join(',')}{opacity:1}` : ''}
454.tt{font:400 11px 'Anthropic Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;fill:#F0EEFC}
455.kt{font:500 12px 'Anthropic Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;fill:${INK}}
456.kv{opacity:0;filter:blur(3px);transition:opacity .2s,filter .2s}.kb:hover .kv{opacity:1;filter:none}
457.kb,rect[class^="h"]{cursor:pointer}
458${CLOCK_CSS}
459</style>`
460  const glideFill = glide ? `<animate attributeName="width" from="${from.toFixed(1)}" to="${fx.toFixed(1)}" dur=".45s" ${ease} fill="freeze"/>` : ''
461  const glideKnob = glide ? `<animateTransform attributeName="transform" type="translate" from="${kFrom.toFixed(1)} 0" to="${kx.toFixed(1)} 0" dur=".45s" ${ease} fill="freeze"/>` : ''
462
463  const open = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`
464  const base = `${open}${style}
465<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>
466<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>
467<g clip-path="url(#pill)"><rect width="${W}" height="${H}" fill="#808080" fill-opacity=".16"/>
468<g clip-path="url(#fill)"><rect width="${fx.toFixed(1)}" height="${H}" fill="url(#base)"/>${px}</g>${marks}</g>
469<g transform="translate(${kx.toFixed(1)} 0)">${glideKnob}${knob}</g></svg>`
470  // the hover layer: checkpoint areas under the pill's copy, so the pill wins where they meet; tips on top
471  const overlay = `${open}${SEE_THROUGH}${hoverStyle}${hits}<g transform="translate(${kx.toFixed(1)} 0)">${timePill}</g>${tips}</svg>`
472
473  return { base, overlay }
474}
475
476const AGENT_COLOR: Record<AgentRun['state'], string> = {
477  running: STATE_COLOR.running,
478  waiting: STATE_COLOR.needs_input,
479  done: STATE_COLOR.done,
480  error: STATE_COLOR.error,
481}
482
483// the desktop drops an Svg whose alt is empty, so every drawing says what it shows
484function stripAlt(p: Plan, key: string): string {
485  const a = (p.agents ?? []).find(x => x.id === key)
486  return a ? `agent ${a.title}: ${a.state}, ${a.tool}` : 'more agents'
487}
488
489const elapsed = (ms: number) => {
490  const sec = Math.max(0, Math.round(ms / 1000))
491  if (sec < 60) return `${sec}s`
492  return sec < 3600 ? `${Math.floor(sec / 60)}m ${sec % 60}s` : `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`
493}
494
495// which strips show: all of a small batch; in a big one the unfinished first, the rest folded into one line
496function visibleAgents(p: Plan, now: number, max: number): { shown: AgentRun[]; hidden: AgentRun[] } | null {
497  const list = p.agents ?? []
498  if (list.length === 0) return null
499  const hasError = list.some(a => a.state === 'error')
500  if (p.agentsDoneAt && now - p.agentsDoneAt > FOLD_MS && !hasError) return null
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 showed last time it was drawn, so a change morphs from the old status instead of jumping
511const lastStrip = new Map<string, { tool: string; color: string }>()
512const MORPH = '.2s'
513
514// a strip's markup per agent object: drawn once per change, so its morph plays once and later redraws match
515const drawnRows = new WeakMap<AgentRun, { key: string; html: string }>()
516
517
518// one tinted strip per agent: state colour, name, what it does now and for how long; not a progress bar
519// Lucide "bot", drawn at 12 px in the gutter before each strip
520const BOT = '<rect width="16" height="12" x="4" y="8" rx="2"/><path d="M12 8V4H8M2 14h2M20 14h2M15 13v2M9 13v2"/>'
521const GUTTER = 36 // icon and agent number, left of the strip
522
523// claude-haiku-4-5-20251001 -> haiku 4.5; an alias stays as given
524const modelName = (m: string) => {
525  const r = /^claude-([a-z]+)-(\d+)-(\d+)/.exec(m)
526  return r ? `${r[1]} ${r[2]}.${r[3]}` : m
527}
528
529// the agent's name, its model and effort in a dimmer parenthesis
530const nameMarkup = (name: string) => {
531  const at = name.indexOf(' (')
532  return at > 0 ? `${esc(name.slice(0, at))}<tspan class="st">${esc(name.slice(at))}</tspan>` : esc(name)
533}
534
535// one tinted strip per agent behind a bot icon and its number: the colour says how it went,
536// the word says what it does now (only while it runs or waits), the time how long it took
537type StripRow = { key: string; html: string; height: number }
538
539// each strip is its own drawing, so a change to one agent redraws that strip alone, never the bar or the others
540function stripsSvg(v: { shown: AgentRun[]; hidden: AgentRun[] }, all: AgentRun[], W: number): StripRow[] {
541  const isNarrow = W < NARROW
542  const SW = W - GUTTER
543  const rows: StripRow[] = []
544  const gutter = (y: number, label: string, color: string) =>
545    `<g transform="translate(1 ${y + 2}) scale(.5)" fill="none" stroke="${color}" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">${BOT}</g>` +
546    `<text x="16" y="${y + 11.5}" class="sn sg" style="fill:${color}">${label}</text>`
547  v.shown.forEach((a, i) => {
548    // the first strip keeps a little room from the track above it
549    const y = i === 0 ? 5 : STRIP_GAP
550    const rowKey = `${W}|${y}|${all.indexOf(a)}`
551    const cachedRow = drawnRows.get(a)
552    if (cachedRow?.key === rowKey) {
553      rows.push({ key: a.id, html: cachedRow.html, height: y + STRIP_H })
554      return
555    }
556    const c = AGENT_COLOR[a.state]
557    const indent = a.depth > 0 ? 10 : 0
558    const word = a.state === 'running' || a.state === 'waiting' ? a.tool : ''
559    const dots = new Map<string, string>()
560    if (a.state === 'running') {
561      for (let col = 0; col * 3 < SW; col++) {
562        for (let r = 0; r < 4; r++) {
563          if (hash(col + i * 41, r, 5) > 0.2) continue
564          addDot(dots, `t${Math.floor(hash(col, r, 6) * 4)}`, GUTTER + col * 3, Math.round((y + 2.5 + r * 3.2) * 10) / 10)
565        }
566      }
567    }
568    const px = [...dots].map(([cls, d]) => `<path class="${cls}" fill="${c}" fill-opacity=".32" d="${d}"/>`).join('')
569    // a status change: the old word blurs out while the new one blurs in, and the tint flows to the new colour
570    const was = lastStrip.get(a.id)
571    lastStrip.set(a.id, { tool: word, color: c })
572    const isWordChanged = was !== undefined && was.tool !== word
573    const flow = (attr: string) => (was && was.color !== c ? `<animate attributeName="${attr}" from="${was.color}" to="${c}" dur="${MORPH}" fill="freeze"/>` : '')
574    // the tool word sits at the right, just before the clock, so the name and its model get the rest of the row
575    const toolEnd = W - 9 - CLOCK_W - 10
576    const wordW = Math.max(word ? textWidth(word, 6.2) : 0, isWordChanged && was.tool ? textWidth(was.tool, 6.2) : 0)
577    const nameX = GUTTER + 19 + indent
578    const nameRoom = isNarrow ? SW - 24 - indent : toolEnd - (wordW > 0 ? wordW + 12 : 0) - nameX
579    const spec = [a.model ? modelName(a.model) : '', a.effort ?? ''].filter(Boolean).join(' · ')
580    const full = (a.depth > 0 ? '↳ ' : '') + a.title + (spec ? ` (${spec})` : '')
581    let name = full
582    while (name.length > 4 && textWidth(name, 6.2) > nameRoom) name = name.slice(0, -1)
583    if (name !== full) name = name.trimEnd() + '…'
584    const time =
585      a.endedAt === null
586        ? liveClock(W - 9 - CLOCK_W, y, a.startedAt, 'sc', 'sn st')
587        : `<text x="${W - 9}" y="${y + 11.5}" text-anchor="end" class="sn st">${elapsed(a.endedAt - a.startedAt)}</text>`
588    const tool = isNarrow
589      ? ''
590      : (isWordChanged && was.tool ? `<text x="${toolEnd}" y="${y + 11.5}" text-anchor="end" class="sn mo" style="fill:${was.color}">${esc(was.tool)}</text>` : '') +
591        (word ? `<text x="${toolEnd}" y="${y + 11.5}" text-anchor="end" class="sn${isWordChanged ? ' mi' : ''}" style="fill:${c}">${esc(word)}</text>` : '') +
592        time
593    const html =
594      gutter(y, String(all.indexOf(a) + 1), '#A8A69E') +
595      `<rect x="${GUTTER}" y="${y}" width="${SW}" height="${STRIP_H}" rx="${STRIP_H / 2}" fill="${c}" fill-opacity=".15">${flow('fill')}</rect>${px}` +
596      `<circle cx="${GUTTER + 10 + indent}" cy="${y + STRIP_H / 2}" r="3" fill="${c}"${a.state === 'running' ? ' class="sd"' : ''}>${flow('fill')}</circle>` +
597      `<text x="${nameX}" y="${y + 11.5}" class="sn">${nameMarkup(name)}</text>` +
598      tool
599    drawnRows.set(a, { key: rowKey, html })
600    rows.push({ key: a.id, html, height: y + STRIP_H })
601  })
602  if (v.hidden.length > 0) {
603    const y = v.shown.length === 0 ? 5 : STRIP_GAP
604    const doneCount = v.hidden.filter(a => a.state === 'done').length
605    rows.push({
606      key: '+',
607      height: y + STRIP_H,
608      html:
609        gutter(y, `+${v.hidden.length}`, '#8A8984') +
610        `<rect x="${GUTTER}" y="${y}" width="${SW}" height="${STRIP_H}" rx="${STRIP_H / 2}" fill="#808080" fill-opacity=".14"/>` +
611        `<text x="${GUTTER + 10}" y="${y + 11.5}" class="sn st">${plural(v.hidden.length, 'more agent')} · ${doneCount} done</text>`,
612    })
613  }
614  return rows
615}
616
617const STRIP_STYLE = `<style>.sn{font:400 11.5px 'Anthropic Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;fill:#F0EEFC}.st{fill-opacity:.65}.sg{font-weight:500;font-variant-numeric:tabular-nums}
618.sd{animation:sp 1.1s ease-in-out infinite}@keyframes sp{50%{opacity:.3}}
619.mi{animation:mi ${MORPH} ease-out both}@keyframes mi{from{opacity:0;filter:blur(3px)}}
620.mo{animation:mo ${MORPH} ease-in both}@keyframes mo{to{opacity:0;filter:blur(3px)}}
621.t0,.t1,.t2,.t3{animation:tw 2.2s ease-in-out infinite}.t1{animation-duration:2.8s;animation-delay:-.7s}.t2{animation-duration:1.9s;animation-delay:-1.3s}.t3{animation-duration:3.3s;animation-delay:-.4s}
622@keyframes tw{0%,100%{opacity:1}50%{opacity:.45}}
623${CLOCK_CSS}
624@media (prefers-reduced-motion:reduce){.sd,.mi,.mo,.t0,.t1,.t2,.t3{animation:none}.mo{opacity:0}}</style>`
625
626function plural(n: number, word: string) {
627  return `${n} ${word}${n === 1 ? '' : 's'}`
628}
629
630const DEFAULT = 0x01000000
631let isLight = false
632const termBg = () => (isLight ? [255, 255, 255] : [24, 24, 27])
633const termFg = () => (isLight ? [34, 34, 38] : [240, 238, 252])
634const pack = (c: number[]) => ((c[0] ?? 0) << 16) | ((c[1] ?? 0) << 8) | (c[2] ?? 0)
635const 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)
636const cellText = (s: string) => [...s].map(ch => (isWide(ch.codePointAt(0) ?? 63) || (ch.codePointAt(0) ?? 0) < 32 ? '·' : ch)).join('')
637const cellsOf = (s: string) => [...s].reduce((w, ch) => w + (isWide(ch.codePointAt(0) ?? 0) ? 2 : 1), 0)
638const fit = (s: string, w: number) => {
639  const chars = [...cellText(s)]
640  if (chars.length <= w) return chars.join('')
641  return w <= 1 ? '…'.slice(0, w) : chars.slice(0, w - 1).join('').trimEnd() + '…'
642}
643
644type Cell = [number, number, number]
645
646class Grid {
647  cells: Cell[]
648  constructor(
649    readonly columns: number,
650    readonly rows: number,
651  ) {
652    this.cells = Array.from({ length: columns * rows }, () => [32, DEFAULT, DEFAULT] as Cell)
653  }
654  set(x: number, y: number, ch: string | number, fg: number, bg: number) {
655    if (x < 0 || x >= this.columns || y < 0 || y >= this.rows) return
656    this.cells[y * this.columns + x] = [typeof ch === 'number' ? ch : (ch.codePointAt(0) ?? 32), fg, bg]
657  }
658  bg(x: number, y: number) {
659    return this.cells[y * this.columns + x]?.[2] ?? DEFAULT
660  }
661  text(x: number, y: number, s: string, fg: number, bg?: number) {
662    let i = 0
663    for (const ch of cellText(s)) {
664      this.set(x + i, y, ch, fg, bg ?? this.bg(x + i, y))
665      i++
666    }
667    return i
668  }
669  encode() {
670    const words = new Uint32Array(this.cells.length * 3)
671    this.cells.forEach((c, i) => words.set(c, i * 3))
672    return (new Uint8Array(words.buffer) as Uint8Array & { toBase64: () => string }).toBase64()
673  }
674}
675
676const BRAILLE_BITS = [
677  [0x01, 0x08],
678  [0x02, 0x10],
679  [0x04, 0x20],
680  [0x40, 0x80],
681]
682
683function pill(g: Grid, y: number, from: number, to: number, bgAt: (x: number) => number[]) {
684  for (let x = from; x < to; x++) g.set(x, y, ' ', DEFAULT, pack(bgAt(x)))
685}
686
687const LEVELS = 8
688const q = (m: number) => Math.round(Math.max(0, Math.min(1, m)) * LEVELS) / LEVELS
689const wave = (t: number, periodMs: number, offset = 0) => 0.5 + 0.5 * Math.sin(((t / periodMs) + offset) * Math.PI * 2)
690const easeOut = (x: number) => 1 - Math.pow(1 - Math.max(0, Math.min(1, x)), 4)
691
692const glide = new Map<string, { from: number; to: number; at: number }>()
693const GLIDE_MS = 450
694
695function headAt(id: string, target: number, t: number): number {
696  const g = glide.get(id)
697  if (!g) {
698    glide.set(id, { from: target, to: target, at: t })
699    return target
700  }
701  const cur = g.from + (g.to - g.from) * easeOut((t - g.at) / GLIDE_MS)
702  if (Math.abs(g.to - target) > 0.01) {
703    glide.set(id, { from: cur, to: target, at: t })
704    return cur
705  }
706  return cur
707}
708
709
710function trackCells(p: Plan, W: number, t: number): string {
711  const g = new Grid(W, 1)
712  const w = where(p)
713  const done = p.state === 'done'
714  const target = (done ? 1 : Math.min(1, w.pos / Math.max(1, w.total))) * W
715  const fx = headAt(p.id, target, t)
716  const back = termBg()
717  const acc = hex(STATE_COLOR[p.state])
718  const light = mix(acc, [255, 255, 255], 0.35)
719  const grey = [120, 118, 128]
720  const track = mix(back, [128, 128, 128], isLight ? 0.14 : 0.18)
721  const fill = mix(track, acc, done ? 0.3 : 0.17)
722  const under = (x: number) => (x + 0.5 < fx ? fill : track)
723  pill(g, 0, 0, W, under)
724
725  const TWINKLE = done ? [3200, 3800, 4400, 3500] : [2200, 2800, 1900, 3300]
726  const DELAY = [0, 700, 1300, 400]
727  const dim = done ? 0.2 : 0.55
728  for (let col = 0; col < Math.min(W, Math.ceil(fx)); col++) {
729    const u = Math.min(1, (col + 0.5) / Math.max(1, fx))
730    const dense = done ? 0.8 : 0.22 + 0.78 * Math.pow(u, 1.5)
731    let bits = 0
732    for (let r = 0; r < 4; r++) {
733      for (let c = 0; c < 2; c++) {
734        const sx = col * 2 + c
735        if (sx / 2 >= fx || hash(sx, r, 1) > dense * 0.6) continue
736        bits |= BRAILLE_BITS[r]?.[c] ?? 0
737      }
738    }
739    if (bits === 0) continue
740    const cls = Math.floor(hash(col, 0, 2) * 4)
741    const period = TWINKLE[cls] ?? 2200
742    const blink = 1 - dim * wave(t + (DELAY[cls] ?? 0), period, 0.25)
743    const bucket = done ? 1 : q(Math.min(1, Math.pow(u, 0.9) * 1.1))
744    const tone = mix(grey, light, bucket)
745    const opacity = (0.35 + 0.65 * dense) * blink
746    g.set(col, 0, 0x2800 + bits, pack(mix(fill, tone, q(opacity))), pack(fill))
747  }
748
749  let k = 0
750  p.stages.forEach(s => {
751    if (k > 0) {
752      const x = Math.round((k / w.total) * W)
753      if (x > 0 && x < W - 1) {
754        const passed = x < fx - 0.5
755        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)))
756      }
757    }
758    k += s.steps.length
759  })
760
761  const base = hex(STATE_COLOR[p.state])
762  const color = base
763  const single = p.stages.length === 1
764  const number = single ? Math.min(w.total, w.pos + 1) : w.stage + 1
765  const icon = p.state === 'done' ? '✓' : p.state === 'error' ? '✕' : p.state === 'needs_input' ? '?' : ''
766  let name = ''
767  let count = ''
768  if (W < 28) {
769    name = icon || String(number)
770  } else {
771    const agents = p.agents ?? []
772    name = done ? 'Done' : single ? (p.stages[0]?.name ?? 'Tasks') : (p.stages[w.stage]?.name ?? '')
773    const baseCount = p.id === AGENTS ? `${w.pos}/${w.total}` : done ? `${w.total}/${w.total}` : single ? `${number}/${w.total}` : `${w.step}/${w.stageSize}`
774    count = baseCount + (agents.length > 0 && p.id !== AGENTS ? ` · ${agents.filter(a => a.state === 'done').length}/${agents.length}` : '')
775  }
776  const lead = icon && W >= 28 ? `${icon} ` : ''
777  const maxName = Math.max(3, Math.floor(W * (W < 60 ? 0.75 : 0.55)) - cellsOf(lead) - cellsOf(count) - 5)
778  const shown = lead + fit(name, maxName)
779  const kw = cellsOf(shown) + (count ? count.length + 1 : 0) + 2
780  const kx = Math.round(Math.max(0, Math.min(W - kw, fx - kw / 2)))
781  const white = pack([255, 255, 255])
782  pill(g, 0, kx, kx + kw, () => color)
783  let at = kx + 1
784  at += g.text(at, 0, shown, white, pack(color))
785  if (count) g.text(at + 1, 0, count, pack(mix([255, 255, 255], color, 0.3)), pack(color))
786  return g.encode()
787}
788
789function stripCells(v: { shown: AgentRun[]; hidden: AgentRun[] }, W: number, now: number): { cells: string; rows: number } {
790  const rows = v.shown.length + (v.hidden.length > 0 ? 1 : 0)
791  const g = new Grid(W, rows)
792  const back = termBg()
793  const text = pack(termFg())
794  v.shown.forEach((a, y) => {
795    const c = hex(AGENT_COLOR[a.state])
796    const tint = mix(back, c, 0.18)
797    pill(g, y, 0, W, () => tint)
798    const running = a.state === 'running' || a.state === 'waiting'
799    if (running) {
800      for (let col = 1; col < W - 1; col++) {
801        let bits = 0
802        let glow = 0
803        for (let r = 0; r < 4; r++) {
804          for (let cc = 0; cc < 2; cc++) {
805            const sx = col * 2 + cc + y * 83
806            if (hash(sx, r, 5) >= 0.08) continue
807            const b = 1 - 0.55 * wave(now, 1900 + hash(sx, r, 6) * 1400, hash(sx, r, 7))
808            bits |= BRAILLE_BITS[r]?.[cc] ?? 0
809            glow = Math.max(glow, b)
810          }
811        }
812        if (bits) g.set(col, y, 0x2800 + bits, pack(mix(tint, c, 0.2 + 0.4 * q(glow))), pack(tint))
813      }
814    }
815    const indent = a.depth > 0 ? 2 : 0
816    const dotColor = running ? mix(tint, c, 0.3 + 0.7 * q(wave(now, 1100))) : c
817    g.text(2 + indent, y, '●', pack(dotColor), pack(tint))
818    const time = elapsed((a.endedAt ?? now) - a.startedAt)
819    const narrow = W < 30
820    const tx = W - 2 - time.length
821    // the tool word sits at the right, just before the time, so the name and its model get the rest of the row
822    const word = narrow || !running ? '' : fit(a.tool, Math.max(0, Math.floor(W * 0.25)))
823    const toolAt = tx - 1 - word.length
824    let at = 4 + indent
825    const spec = [a.model ? modelName(a.model) : '', a.effort ?? ''].filter(Boolean).join(' · ')
826    const full = (a.depth > 0 ? '↳ ' : '') + a.title + (spec ? ` (${spec})` : '')
827    const name = fit(full, Math.max(3, (narrow ? W - 2 : word ? toolAt - 1 : tx - 1) - at))
828    // the model and effort are drawn dimmer than the name
829    const cut = spec ? name.indexOf(' (') : -1
830    const head = cut > 0 ? name.slice(0, cut) : name
831    for (let i = -1; i < name.length + 1 && at + i < W - 1; i++) g.set(at + i, y, ' ', DEFAULT, pack(tint))
832    at += g.text(at, y, head, text, pack(tint))
833    if (head !== name) g.text(at, y, name.slice(head.length), pack(mix(termFg(), tint, 0.4)), pack(tint))
834    if (narrow) return
835    if (word) {
836      for (let i = -1; i <= word.length; i++) g.set(toolAt + i, y, ' ', DEFAULT, pack(tint))
837      g.text(toolAt, y, word, pack(c), pack(tint))
838    }
839    for (let i = -1; i < time.length; i++) g.set(tx + i, y, ' ', DEFAULT, pack(tint))
840    g.text(tx, y, time, pack(mix(termFg(), tint, 0.35)), pack(tint))
841  })
842  if (v.hidden.length > 0) {
843    const y = v.shown.length
844    const tint = mix(back, [128, 128, 128], 0.16)
845    pill(g, y, 0, W, () => tint)
846    const doneCount = v.hidden.filter(a => a.state === 'done').length
847    g.text(2, y, fit(`+${plural(v.hidden.length, 'more agent')} · ${doneCount} done`, W - 4), pack(mix(termFg(), tint, 0.35)), pack(tint))
848  }
849  return { cells: g.encode(), rows }
850}
851
852type Band = { requestId: string; W: number; list: readonly Plan[] }
853let band: Band | null = null
854let isFrameBusy = false
855// a bar twinkles only while Claude works on it; one waiting on the person or left open after the turn stands still
856let isTurnLive = false
857let frames: { cancel: () => void } | null = null
858
859const hasRunningAgents = (p: Plan) => (p.agents ?? []).some(a => a.state === 'running' || a.state === 'waiting')
860const isGliding = (p: Plan, t: number) => {
861  const g = glide.get(p.id)
862  return g !== undefined && g.from !== g.to && t - g.at < GLIDE_MS + 100
863}
864const isAnimated = (p: Plan, t: number) => (isTurnLive && p.state === 'running') || hasRunningAgents(p) || isGliding(p, t)
865
866// the 30 fps clock runs only while a terminal band has something moving; the second timer starts and stops it
867function syncFrames($: EngineInterface, now: number) {
868  const isWanted = band !== null && band.list.some(p => isAnimated(p, now))
869  if (isWanted && !frames) frames = $.clock.every(33, () => void animate($))
870  if (!isWanted && frames) {
871    frames.cancel()
872    frames = null
873  }
874}
875
876// ---------- terminal tint ----------
877// the track's light or dark tint: a theme the person picked wins; "auto", and the default "dark" inside Terminal.app
878// (whose stock profiles follow macOS), take the macOS appearance. The appearance is read only while a terminal bar
879// is on screen, at most every APPEARANCE_MS, and never again once the command turns out to be missing (not a Mac)
880const APPEARANCE_MS = 5000
881let themeSetting = 'dark'
882let mac: 'unknown' | 'yes' | 'no' = 'unknown'
883let termProgram = ''
884let isSystemDark: boolean | null = null
885let appearanceAt = -Infinity
886let isProbing = false
887
888const followsSystem = () => mac !== 'no' && (themeSetting === 'auto' || (themeSetting === 'dark' && (mac === 'unknown' || termProgram === 'Apple_Terminal')))
889const tintIsLight = () => (followsSystem() && isSystemDark !== null ? !isSystemDark : /light/i.test(themeSetting))
890
891async function probeAppearance($: EngineInterface, now: number) {
892  if (isProbing || !followsSystem() || now - appearanceAt < APPEARANCE_MS) return
893  isProbing = true
894  appearanceAt = now
895  try {
896    const r = await $.process.run(['defaults', 'read', '-g', 'AppleInterfaceStyle'], { timeoutMs: 2000 }).catch(() => null)
897    if (!r) {
898      // no such command: not a Mac, so the theme setting alone decides from here on
899      mac = 'no'
900      return
901    }
902    if (mac === 'unknown') {
903      const t = await $.process.run(['/bin/sh', '-c', 'printf %s "$TERM_PROGRAM"'], { timeoutMs: 2000 }).catch(() => null)
904      termProgram = t?.stdout.trim() ?? ''
905      mac = 'yes'
906    }
907    // the key is missing in light mode, so anything but "Dark" reads as light
908    isSystemDark = /dark/i.test(r.stdout)
909  } finally {
910    isProbing = false
911  }
912}
913
914// reads the appearance when due and repaints the bars at once if the tint changed
915async function syncTint($: EngineInterface, now: number) {
916  if (band) await probeAppearance($, now)
917  const next = tintIsLight()
918  if (next === isLight) return
919  isLight = next
920  const b = band
921  if (b) await blitPlans($, b, b.list, now)
922}
923
924function blitPlans($: EngineInterface, b: Band, list: readonly Plan[], now: number) {
925  return Promise.all(
926    list.flatMap(p => {
927      const v = visibleAgents(p, now, stripBudget(b.list.length))
928      const strips = v ? stripCells(v, b.W, now) : null
929      const calls = [$.ui.blit({ requestId: b.requestId, key: `track-${p.id}`, cells: trackCells(p, b.W, now) })]
930      if (strips) calls.push($.ui.blit({ requestId: b.requestId, key: `strips-${p.id}`, cells: strips.cells }))
931      return calls.map(c => c.catch(() => undefined))
932    }),
933  )
934}
935
936async function animate($: EngineInterface) {
937  const b = band
938  if (!b || isFrameBusy) return
939  const now = await $.clock.now()
940  const live = b.list.filter(p => isAnimated(p, now))
941  if (live.length === 0) return
942  isFrameBusy = true
943  try {
944    await blitPlans($, b, live, now)
945  } finally {
946    isFrameBusy = false
947  }
948}
949
950
951// ---------- engine glue ----------
952
953// the engine's player first (afplay on macOS); PowerShell where it cannot play
954// set while a demo reel records: every sound with its moment, so a video can carry them
955let soundLog: { at: number; name: string }[] | null = null
956
957function play($: EngineInterface, name: 'decision' | 'error' | 'done') {
958  if (soundLog) void $.clock.now().then(at => soundLog?.push({ at, name }))
959  const file = `${$.plugin.root}/sounds/${name}.wav`.replace(/\//g, '\\')
960  const viaPowerShell = () =>
961    $.process
962      .run(['powershell', '-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', `(New-Object Media.SoundPlayer '${file}').PlaySync()`], { timeoutMs: 5000 })
963      .catch(() => undefined)
964  // on Windows the engine's player can report success and stay silent, so the system player plays it directly
965  if (/^[A-Za-z]:/.test($.plugin.root)) {
966    void viaPowerShell()
967    return
968  }
969  void $.audio.play({ asset: `sounds/${name}.wav` }).catch(viaPowerShell)
970}
971
972// the agents bar is the mod's own; the model never owes it an update
973const AGENTS = 'agents:auto' // slug() never yields ':', so no model id can take it
974const isOpenPlan = (p: Plan) => p.id !== AGENTS && p.state === 'running' && !p.stages.flatMap(s => s.steps).every(s => isFinished(s.status))
975
976const slug = (s: string) =>
977  s
978    .toLowerCase()
979    .replace(/[^\p{L}\p{N}]+/gu, '-')
980    .replace(/^-|-$/g, '')
981    .slice(0, 40) || 'plan'
982
983// adds or replaces one bar by id; keeps at most MAX_BARS, dropping finished ones first
984// computed inside update() from the latest list, so concurrent writers (parallel agents) do not drop each other
985function placeBar(list: readonly Plan[], next: Plan): Plan[] {
986  const prev = list.find(p => p.id === next.id)
987  // an update keeps its row and, unless it brings its own, the agent strips already on it; a new bar goes to the bottom
988  const kept = prev && !('agents' in next) ? { ...next, agents: prev.agents, agentsDoneAt: prev.agentsDoneAt } : next
989  const rest = prev ? list.map(p => (p.id === next.id ? kept : p)) : [...list, next]
990  while (rest.length > MAX_BARS) {
991    const doneAt = rest.findIndex(p => p.state === 'done')
992    rest.splice(doneAt >= 0 ? doneAt : 0, 1)
993  }
994  return rest
995}
996
997function chime($: EngineInterface, prev: PlanState | undefined, next: PlanState) {
998  if (next === prev) return
999  if (next === 'needs_input') play($, 'decision')
1000  if (next === 'error') play($, 'error')
1001  if (next === 'done') play($, 'done')
1002}
1003
1004async function putPlan($: EngineInterface, next: Plan) {
1005  await editPlan($, next.id, () => next)
1006}
1007
1008// builds a bar from the latest stored one inside update(), so back-to-back calls never work from a stale copy;
1009// make returns a string to refuse, and the list stays as it was
1010async function editPlan($: EngineInterface, id: string, make: (prev: Plan | null) => Plan | string, isQuiet = false): Promise<Plan | string> {
1011  let prev: Plan | undefined
1012  let made = '' as Plan | string
1013  await update($, plans, list => {
1014    prev = list.find(p => p.id === id)
1015    made = make(prev ?? null)
1016    return typeof made === 'string' ? [...list] : placeBar(list, made)
1017  })
1018  if (typeof made === 'string') return made
1019  if (!isQuiet) chime($, prev?.state, made.state)
1020  if (!prev) await update($, isOpen, () => true)
1021  return made
1022}
1023
1024// ---------- agents: drawn from engine events alone, no model calls ----------
1025// each subagent lives on a bar as one state strip: the open task bar it was started under,
1026// the bar of its parent agent, or the mod's own "Agents" bar when no task is open.
1027// Module maps: a reload forgets running agents, whose strips then stay until the bar is closed.
1028const agentHome = new Map<string, string>() // agentId -> bar id
1029const toolUses = new Map<string, string>() // tool_use_id -> agentId, to find who waits on a permission
1030const waiting = new Set<string>()
1031let foldUntil = 0 // keep ticking until finished strips have folded
1032
1033// the mod's own bar mirrors its agents as steps, finished first, so percent and count read done/total
1034function syncAuto(p: Plan, now: number): Plan {
1035  const agents = p.agents ?? []
1036  const isOver = agents.length > 0 && agents.every(a => a.state === 'done' || a.state === 'error')
1037  const agentsDoneAt = isOver ? (p.agentsDoneAt ?? now) : null
1038  if (p.id !== AGENTS) return { ...p, agentsDoneAt }
1039  const rank = (a: AgentRun) => (a.state === 'done' ? 0 : a.state === 'error' ? 1 : 2)
1040  const steps: PlanStep[] = [...agents]
1041    .sort((a, b) => rank(a) - rank(b))
1042    .map(a => ({ title: a.title, status: a.state === 'done' ? 'done' : a.state === 'error' ? 'error' : 'active', substeps: [] }))
1043  const state: PlanState = isOver
1044    ? agents.some(a => a.state === 'error') ? 'error' : 'done'
1045    : agents.some(a => a.state === 'waiting') ? 'needs_input' : 'running'
1046  return { ...p, agentsDoneAt, stages: [{ name: 'Agents', steps }], state }
1047}
1048
1049function addRun(p: Plan, run: AgentRun, parentId: string | undefined, now: number): Plan {
1050  // a batch that has finished makes room for the next one
1051  const list = p.agentsDoneAt ? [] : [...(p.agents ?? [])]
1052  let at = list.length
1053  const parentAt = parentId ? list.findIndex(a => a.id === parentId) : -1
1054  if (parentAt >= 0) {
1055    at = parentAt + 1
1056    while (at < list.length && (list[at]?.depth ?? 0) > 0) at++
1057  }
1058  list.splice(at, 0, run)
1059  return syncAuto({ ...p, agents: list, agentsDoneAt: null }, now)
1060}
1061
1062// changes one agent's strip inside the latest list; silent, since a subagent answers to Claude, not to the person
1063async function editAgent($: EngineInterface, agentId: string, change: (a: AgentRun) => AgentRun) {
1064  const home = agentHome.get(agentId)
1065  if (!home) return
1066  const now = await $.clock.now()
1067  let isFolding = false
1068  await update($, plans, list =>
1069    list.map(p => {
1070      if (p.id !== home || !p.agents?.some(a => a.id === agentId)) return p
1071      const next = syncAuto({ ...p, agents: p.agents.map(a => (a.id === agentId ? change(a) : a)) }, now)
1072      isFolding = !p.agentsDoneAt && next.agentsDoneAt !== null
1073      return next
1074    }),
1075  )
1076  if (isFolding) foldUntil = now + FOLD_MS + 200
1077}
1078
1079// module maps outlive the bars they describe: a bar pushed out past MAX_BARS, a cleared list, an agent
1080// whose finish never arrived (killed, or started before a reload); drop what no bar holds any more
1081function forgetGone(list: readonly Plan[]) {
1082  const bars = new Set(list.map(p => p.id))
1083  const live = new Set(list.flatMap(p => (p.agents ?? []).filter(a => a.state === 'running' || a.state === 'waiting').map(a => a.id)))
1084  const shown = new Set(list.flatMap(p => (p.agents ?? []).map(a => a.id)))
1085  for (const id of lastHead.keys()) if (!bars.has(id)) lastHead.delete(id)
1086  for (const id of glide.keys()) if (!bars.has(id)) glide.delete(id)
1087  for (const id of lastSource.keys()) {
1088    const [bar, strip] = id.split('/')
1089    if (!bars.has(bar ?? '') || (strip !== undefined && strip !== '+' && !shown.has(strip))) lastSource.delete(id)
1090  }
1091  for (const id of lastStrip.keys()) if (!shown.has(id)) lastStrip.delete(id)
1092  for (const [id, home] of agentHome) {
1093    if (bars.has(home) && live.has(id)) continue
1094    agentHome.delete(id)
1095    waiting.delete(id)
1096  }
1097}
1098
1099async function dropPlan($: EngineInterface, id: string) {
1100  lastHead.delete(id)
1101  glide.delete(id)
1102  for (const p of await read($, plans)) if (p.id === id) for (const a of p.agents ?? []) lastStrip.delete(a.id)
1103  await update($, plans, list => list.filter(p => p.id !== id))
1104}
1105
1106// substeps are left out: the bar never draws them and short ops cannot reach them
1107const STEP_SCHEMA = {
1108  type: 'object',
1109  required: ['title'],
1110  properties: {
1111    title: { type: 'string' },
1112    status: { enum: STATUSES, description: 'Default pending' },
1113  },
1114}
1115
1116// edits are the work the enforcement below counts. Shell calls are never refused or counted: the host marks only
1117// plain reads (ls) read-only, so a compound read (cd x && git log) would look like work and be refused
1118const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
1119const SHELL_TOOLS = new Set(['Bash', 'PowerShell'])
1120const WORK_BEFORE_PLAN = 3 // the 4th edit without a plan is refused once
1121const CALLS_BEFORE_NUDGE = 6 // edits without a plan update before a reminder
1122
1123
1124
1125// ---------- demo reel: two pipelines and their agents, ~13 s, for screen recordings ----------
1126const run = (id: string, title: string, tool: string, at: number, isSonnet: boolean): AgentRun => ({
1127  id, title, state: 'running', tool, startedAt: at, endedAt: null, depth: 0,
1128  ...(isSonnet ? { model: 'claude-sonnet-5-5', effort: 'medium' } : { model: 'claude-haiku-4-5' }),
1129})
1130
1131// $.state lives as long as the process, so the bars are kept per session in the plugin's store as well
1132const SAVED = 'plans:'
1133const KEEP_SESSIONS = 20
1134let lastSaved: Plan[] | null = null
1135
1136async function savePlans($: EngineInterface, list: Plan[]) {
1137  lastSaved = list
1138  const key = SAVED + (await $.session.id())
1139  if (list.length === 0) {
1140    await $.store.delete(key)
1141    return
1142  }
1143  await $.store.set(key, list)
1144  const keys = (await $.store.keys()).filter(k => k.startsWith(SAVED))
1145  for (const old of keys.slice(0, Math.max(0, keys.length - KEEP_SESSIONS))) await $.store.delete(old)
1146}
1147
1148// agents do not outlive the process that ran them, so a restored bar comes back without strips
1149async function restorePlans($: EngineInterface) {
1150  const saved = await $.store.get(SAVED + (await $.session.id()))
1151  if (!Array.isArray(saved) || saved.length === 0) return
1152  const list = (saved as Plan[]).map(p => ({ ...p, agents: [], agentsDoneAt: null }))
1153  await update($, plans, () => list)
1154  lastSaved = list
1155}
1156
1157async function runReel($: EngineInterface, logPath: string) {
1158  const t0 = await $.clock.now()
1159  soundLog = []
1160  foldUntil = t0 + 20_000
1161  await update($, plans, () => [])
1162  const A = 'reel-checkout'
1163  const B = 'reel-release'
1164  const steps = (names: string[], active: number) => names.map((n, i) => st(n, i < active ? 'done' : i === active ? 'active' : 'pending'))
1165  // mid-run the checkout plan is rewritten: a Harden stage lands between Build and Verify
1166  let isReplanned = false
1167  const planA = (k: number, state: PlanState = 'running'): Plan => {
1168    const all = isReplanned
1169      ? ['Routes', 'Cart store', 'Payment API', 'Webhooks', 'Checkout UI', 'Rate limits', 'Security review', 'Tests', 'Build']
1170      : ['Routes', 'Cart store', 'Payment API', 'Webhooks', 'Checkout UI', 'Tests', 'Build']
1171    const s2 = steps(all, k)
1172    const stages = isReplanned
1173      ? [{ name: 'Explore', steps: s2.slice(0, 2) }, { name: 'Build', steps: s2.slice(2, 5) }, { name: 'Harden', steps: s2.slice(5, 7) }, { name: 'Verify', steps: s2.slice(7) }]
1174      : [{ name: 'Explore', steps: s2.slice(0, 2) }, { name: 'Build', steps: s2.slice(2, 5) }, { name: 'Verify', steps: s2.slice(5) }]
1175    return { id: A, title: 'Checkout flow', kind: 'plan', state, note: null, startedAt: t0, stages }
1176  }
1177  const planB = (k: number, state: PlanState = 'running'): Plan =>
1178    ({ id: B, title: 'Release notes', kind: 'todo', state, note: null, startedAt: t0, stages: [{ name: 'Release', steps: steps(['Collect PRs', 'Group changes', 'Draft notes', 'Publish'], k) }] })
1179  const setAgents = async (id: string, f: (a: AgentRun[]) => AgentRun[]) => {
1180    const now = await $.clock.now()
1181    await update($, plans, list => list.map(p => (p.id === id ? syncAuto({ ...p, agents: f(p.agents ?? []) }, now) : p)))
1182  }
1183  const edit = (id: string, agentId: string, ch: Partial<AgentRun>, at: number) =>
1184    setAgents(id, list => list.map(a => (a.id === agentId ? { ...a, ...ch, ...(ch.state === 'done' || ch.state === 'error' ? { endedAt: at } : {}) } : a)))
1185  const keep = async (id: string, f: (p: Plan) => Plan) => {
1186    let before: PlanState | undefined
1187    let after: PlanState | undefined
1188    const now = await $.clock.now()
1189    await update($, plans, list =>
1190      list.map(p => {
1191        if (p.id !== id) return p
1192        before = p.state
1193        const made = f(p)
1194        // a finished bar shows its time in the pill, so it needs the moment it ended
1195        const next = { ...made, endedAt: made.state === 'done' ? now : null, agents: p.agents, agentsDoneAt: p.agentsDoneAt }
1196        after = next.state
1197        return next
1198      }),
1199    )
1200    if (after) chime($, before, after)
types/index.d.ts 45 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}
20export type Plan = {
21  id: string
22  title: string
23  kind: 'plan' | 'todo'
24  stages: PlanStage[]
25  state: PlanState
26  note: string | null
27  startedAt: number
28  // when the plan was finished; the pill then shows the time it took
29  endedAt?: number | null
30  agents?: AgentRun[]
31  // when the current batch of agents all finished; their strips fold a few seconds later
32  agentsDoneAt?: number | null
33}
34
35declare module 'claude-code' {
36  interface PluginState {
37    'plan-progress': {
38      plans: Plan[]
39      isOpen: boolean
40      // bumped every second while agents run, so elapsed times and folding redraw
41      tick: number
42    }
43  }
44}
45