SLOPSHOPPER

clawd-tracker

A Domino's-style order tracker above the prompt. Clawd acts out every step next to an honest progress bar, a learned ETA, a delivery receipt, plan usage and a…

newbandguardcommandtoastprompt
★ 1v0.4.0MITupdated 2026-10-06IKnowJot/clawd-plugins/plugins/clawd-tracker
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd-tracker
› 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 › /tracker ⎿ clawd-tracker: Clawd tracker hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

clawd-tracker

Your Claude Code task, tracked like a pizza order.

A whole order, from ticket to delivery

A bar above the prompt walks through five stages, Reading → Prepping → Cooking → Taste test → Delivered, while Clawd acts out exactly what Claude is doing right now. Underneath: the current step, a live ETA, elapsed time, tokens, context fill, estimated cost and your plan usage. When it's done you get a receipt and a ding.

Clawd's 20 costumes

Nine jobs mid-flight, each with its own Clawd

Claude is…Clawd…Stage
Starting your requestholds up the order ticketReading
Reading a fileputs on reading glasses, turns the pagesReading
Searching code (Grep)sweeps a detective's magnifierReading
Listing files (Glob)ties on a bandana, scans with a spyglassReading
On the web / in a browserrides a surfboardReading
Writing a task list or planscribbles on a clipboardPrepping
Thinking between stepsgets a thought bubble(stays put)
Editing codeflips an omelette in a chef's hatCooking
Creating a fileswings a hammer in a hard hatCooking
Editing CSS / SVGpaints in a beret, the brush changes colorCooking
Editing docs (.md, .txt)writes with a quillCooking
Editing config / other toolsturns a wrench, a gear spinsCooking
Installing packageshauls a parcel on its headCooking
Running subagentsjuggles, while a mini Clawd per helper shows what each is doingCooking
Running tests, builds, lintersswirls a bubbling flask in lab gogglesTaste test
Running other commandstypes on a tiny laptop(stays put)
Using git / ghdelivers a stamped envelope in a mail cap(stays put)
Asking you a question, or waiting for your OKholds up a "?" sign; the bar says "Waiting on you"(stays put)
Doneparty hat, confetti, pizza boxDelivered
A step failsdrops the pizza: "Bash hit a snag. Regrouping…"(stays put)
Interrupteddrops the pizzaStopped

Each activity has a few verbs that rotate ("Simmering ThemeToggle.tsx", "Seasoning api.ts"), so the line never reads the same twice.

Progress and the ETA

Claude Code can't know how far along a task is, so the bar only moves on real events:

  • With a task list (TodoWrite or the task tools), the fill is the share of tasks done and slides forward each time one finishes.
  • Without one, it fills through the furthest stage Claude has reached. Only real test, build and lint commands count as Taste test; git and other shell commands don't move it.
  • Delivered fills only when the work is done. The stage being worked on pulses.

The top right shows the job size (Small, Medium or Large, from the task count or steps so far) and an estimate:

  • With a task list: this run's pace per finished task ("about 3 min left"), or, before the first one finishes, your usual pace per task ("~6 min job").
  • Without one: how long your past jobs that got this far usually took ("usually about 2 min left"), or "running longer than usual". The tracker remembers your last 60 delivered jobs to learn this, and prefers the current project's own history once it has a few jobs there. Until it has some history it says "sizing up…".

Little moments

  • Stage up: when the work reaches a new stage, Clawd hops and sparkles.
  • Snags: when a step fails, Clawd reacts and keeps that look while Claude works out a fix.
  • Waiting on you: when Claude needs your answer or your OK for a step, the top right turns amber and counts up ("Waiting on you · 1:12"), and the line underneath says what it needs. It shows after a couple of seconds, so an auto-mode check that answers itself doesn't flash it.
  • Helpers: each subagent gets a mini Clawd next to the ETA, in the costume of whatever it's doing; it leaves when its work is done.
  • The receipt: the delivery line sums up the job, "3 files · +42 −10 · checks ✓ · 23 steps · order #3 today". Lines are counted from Claude's edits; "checks" is the last test, build or lint run.
  • The ding: a short chime when a job that took 20 seconds or more is delivered, for when you've looked away.

Themes

Same Clawd, different shop. Switch any time with /tracker theme <name>, for example /tracker theme rocket; /tracker theme lists them:

ThemeStages
pizza (default)Reading → Prepping → Cooking → Taste test → Delivered
coffeeGrinding → Brewing → Pouring → Tasting → Served
rocketFueling → Countdown → Liftoff → Systems check → In orbit
constructionSurveying → Blueprints → Building → Inspection → Handed over

The four themes: pizza, coffee, rocket and construction

Usage numbers

  • tok: tokens used this run, including subagents.
  • ctx: how full Claude's context window (its working memory for the conversation) is. Amber from 80%, red from 90%, and a one-time tip at 85% to start a fresh conversation for the next task.
  • ≈$: an estimate at API list prices. On a Pro or Max plan you aren't billed per token, so treat it as a sense of scale.
  • 5h / wk rings: how much of your plan's 5-hour and weekly limits you've used, as reported by the last API response, drawn as two rings on the right. Blue, then amber from 75% and red from 90%, with a one-time heads-up at 90%. Only shown on a subscription; the terminal shows them as text.

Settings

Change the theme and sound with the commands above, or any setting in Claude Code's /config menu (terminal) or settings.json under pluginConfigs:

SettingDefaultWhat it does
themepizzaStage names: pizza, coffee, rocket or construction
soundonThe delivery ding
showCostonShow the ≈$ estimate
showPlanUsageonShow the 5h / weekly usage and the 90% heads-up

Commands

  • /tracker: show or hide the tracker. The ✕ on the bar hides it too; /tracker brings it back.
  • /tracker stats: today's orders, time spent, steps, tokens and the longest job.
  • /tracker theme <name>: switch theme (pizza, coffee, rocket, construction); /tracker theme lists them.
  • /tracker sound on|off: turn the delivery ding on or off.

Works in the desktop app (animated SVG) and in the terminal (text version with an emoji per activity).

A delivered order

What it hooks

The tracker only watches; it never blocks, rewrites or answers anything for Claude Code. Every hook passes the event on unchanged.

EventWhat the tracker does with it
session.startRegisters /tracker, loads your past job times (for the ETA) and starts a 1-second clock while a job runs
prompt.submitKeeps the first line of your prompt as the order's title
turn.start / turn.completeOpens and delivers the order; a subagent's turn adds or removes its mini Clawd
tool.callPicks Clawd's costume and the stage from the tool and its arguments, and counts lines for the receipt. Before a step runs it asks Claude Code's permission rules, read-only, whether the step will stop for your OK, so the band can say "Waiting on you". It never makes or changes that decision
turn.stepAdds up tokens and the estimated cost, reads how full the context is, and shows the thought bubble between steps
session.measureReads your 5-hour and weekly plan usage for the rings
command.run (/tracker)Shows or hides the band, or prints /tracker stats
ui.render (AbovePrompt)Draws the band

It stores two things: whether the band is hidden (this session only) and your last 60 job times (to learn the ETA). It reads no files, makes no network calls and keeps no credentials. The delivery ding is synthesized in code, so the plugin ships no audio files.

Development

claude plugin validate plugins/clawd-tracker
claude plugin test plugins/clawd-tracker
Source 2 files
hooks/register.tsx 1325 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Activity, Limit, Past, Run } from '../types'
5
6// One run per prompt: it starts on the main turn's start and is delivered on its completion.
7const run = atom({ plugin: 'clawd-tracker', key: 'run' } as const, null)
8const now = atom({ plugin: 'clawd-tracker', key: 'now' } as const, 0)
9const view = atom({ plugin: 'clawd-tracker', key: 'view' } as const, { isHidden: false })
10const history = atom({ plugin: 'clawd-tracker', key: 'history' } as const, [] as Past[])
11const limits = atom({ plugin: 'clawd-tracker', key: 'limits' } as const, [] as Limit[])
12
13// The manifest's userConfig, set by register; a change reloads the module.
14type Options = { showCost: boolean; showPlanUsage: boolean; sound: boolean; theme: string }
15let opts: Options = { showCost: true, showPlanUsage: true, sound: true, theme: 'pizza' }
16// The directory the session runs in, from session.start.
17let currentProject = ''
18
19// Themes rename the five stages and the finish; Clawd's costumes stay.
20type Theme = { stages: string[]; doneIn: string; deliver: string[] }
21const THEMES: Record<string, Theme> = {
22  pizza: { stages: ['Reading', 'Prepping', 'Cooking', 'Taste test', 'Delivered'], doneIn: 'Delivered in', deliver: ['Delivered', 'Order up', 'Served hot'] },
23  coffee: { stages: ['Grinding', 'Brewing', 'Pouring', 'Tasting', 'Served'], doneIn: 'Served in', deliver: ['Order up', 'Served', 'Fresh pour'] },
24  rocket: { stages: ['Fueling', 'Countdown', 'Liftoff', 'Systems check', 'In orbit'], doneIn: 'Reached orbit in', deliver: ['Mission complete', 'Touchdown', 'Orbit reached'] },
25  construction: { stages: ['Surveying', 'Blueprints', 'Building', 'Inspection', 'Handed over'], doneIn: 'Handed over in', deliver: ['Keys handed over', 'Signed off', 'Built'] },
26}
27let theme: Theme = THEMES.pizza
28let STAGES: string[] = theme.stages
29const DELIVERED = 4
30
31const CLAY = '#D97757'
32const CLAY_DARK = '#B45F43'
33const INK = '#1F1E1D'
34const DONE = '#3B9C5F'
35const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
36
37// ---------------------------------------------------------------------------
38// What Clawd is doing: each tool call maps to an activity (a costume), a stage
39// on the tracker (null keeps the current one) and a short line of text.
40
41type Step = { activity: Activity; stage: number | null; note: string }
42
43const STAGE_OF: Record<Activity, number | null> = {
44  order: 0,
45  read: 0,
46  search: 0,
47  explore: 0,
48  web: 0,
49  plan: 1,
50  think: null,
51  cook: 2,
52  build: 2,
53  paint: 2,
54  quill: 2,
55  tinker: 2,
56  install: 2,
57  juggle: 2,
58  test: 3,
59  terminal: null,
60  git: null,
61  wait: null,
62  deliver: DELIVERED,
63  oops: null,
64}
65
66// Several verbs per activity so the line under the bar never feels canned.
67const VERBS: Record<Activity, string[]> = {
68  order: ['Order received', 'Taking your order', 'Reading the ticket'],
69  read: ['Reading', 'Studying', 'Skimming', 'Poring over'],
70  search: ['Searching for', 'Hunting for', 'Sniffing out', 'Tracking down'],
71  explore: ['Scouting', 'Exploring', 'Mapping out', 'Charting'],
72  web: ['Surfing to', 'Catching a wave to', 'Browsing'],
73  plan: ['Planning', 'Jotting down the plan', 'Sketching the steps'],
74  think: ['Thinking', 'Pondering', 'Connecting the dots', 'Mulling it over', 'Brewing ideas'],
75  cook: ['Cooking up', 'Seasoning', 'Simmering', 'Stirring'],
76  build: ['Building', 'Hammering out', 'Framing up', 'Constructing'],
77  paint: ['Painting', 'Styling', 'Touching up'],
78  quill: ['Writing', 'Penning', 'Drafting'],
79  tinker: ['Tinkering with', 'Tuning', 'Tightening'],
80  test: ['Taste-testing', 'Running', 'Quality-checking'],
81  terminal: ['Running', 'Typing', 'Executing'],
82  install: ['Unpacking', 'Installing', 'Hauling in'],
83  git: ['Shipping', 'Stamping', 'Sending out'],
84  juggle: ['Juggling helpers on', 'Delegating', 'Rallying the crew for'],
85  wait: ['Waiting on you', 'Needs your call', 'Holding for your answer'],
86  deliver: ['Delivered', 'Order up', 'Served hot'],
87  oops: ['Stopped', 'Dropped the pizza', 'Interrupted'],
88}
89
90const hash = (s: string): number => {
91  let acc = 0
92  for (const ch of s) acc = (acc * 31 + ch.charCodeAt(0)) | 0
93  return Math.abs(acc)
94}
95
96const pick = (activity: Activity, seed: string): string => {
97  const list = VERBS[activity]
98  return list[hash(seed) % list.length] ?? list[0] ?? ''
99}
100
101const clip = (s: string, max: number): string => (s.length > max ? s.slice(0, Math.max(1, max - 1)) + '…' : s)
102const extOf = (p: string): string => (/\.([a-z0-9]+)$/i.exec(p)?.[1] ?? '').toLowerCase()
103const baseOf = (p: string): string => p.split(/[\\/]/).filter(Boolean).pop() ?? p
104const str = (v: unknown): string => (typeof v === 'string' ? v : '')
105
106// A tool call's own arguments: the event minus the keys the engine adds.
107const argsOf = (e: Record<string, unknown>): Record<string, unknown> => {
108  const { tool: _t, tool_use_id: _id, agentId: _a, ...args } = e
109  return args
110}
111
112const PAINT = new Set(['css', 'scss', 'sass', 'less', 'styl', 'svg'])
113const QUILL = new Set(['md', 'mdx', 'txt', 'rst', 'adoc'])
114const TINKER = new Set(['json', 'yml', 'yaml', 'toml', 'ini', 'env', 'lock', 'xml', 'plist', 'conf', 'cfg'])
115
116const fileActivity = (path: string, fallback: Activity): Activity => {
117  const ext = extOf(path)
118  if (PAINT.has(ext)) return 'paint'
119  if (QUILL.has(ext)) return 'quill'
120  if (TINKER.has(ext) || /(^|\/)\.[a-z]+rc$/i.test(path)) return 'tinker'
121  return fallback
122}
123
124const TEST_CMD = /\b(test|tests|jest|vitest|pytest|mocha|playwright|cypress|tsc|typecheck|lint|eslint|ruff|mypy|clippy|check|build|xcodebuild|cargo (test|build|check)|go (test|build|vet))\b/
125const INSTALL_CMD = /\b(npm (i|install|ci|add)|pnpm (i|install|add)|yarn( add| install)?$|yarn add|bun (i|install|add)|pip3? install|uv (add|sync|pip)|poetry (add|install)|brew install|cargo add|go get|gem install|pod install)\b/
126const GIT_CMD = /^\s*(git|gh)\b/
127
128const classify = (tool: string, e: Record<string, unknown>): Step => {
129  const seed = str(e.tool_use_id) || tool
130  const step = (activity: Activity, target = ''): Step => ({
131    activity,
132    stage: STAGE_OF[activity],
133    note: target ? `${pick(activity, seed)} ${target}` : pick(activity, seed),
134  })
135  const file = str(e.file_path) || str(e.notebook_path) || str(e.path)
136
137  switch (tool) {
138    case 'Read':
139    case 'NotebookRead':
140      return step('read', baseOf(file))
141    case 'Grep':
142      return step('search', `"${clip(str(e.pattern), 28)}"`)
143    case 'Glob':
144    case 'LS':
145      return step('explore', clip(str(e.pattern) || baseOf(file) || 'the project', 30))
146    case 'WebFetch':
147      return step('web', clip(str(e.url).replace(/^https?:\/\//, ''), 34))
148    case 'WebSearch':
149      return step('web', `"${clip(str(e.query), 30)}"`)
150    case 'TodoWrite':
151    case 'TaskCreate':
152    case 'TaskUpdate':
153    case 'TaskList':
154    case 'EnterPlanMode':
155    case 'ExitPlanMode':
156      return step('plan')
157    case 'Edit':
158    case 'MultiEdit':
159    case 'NotebookEdit':
160      return step(fileActivity(file, 'cook'), baseOf(file))
161    case 'Write':
162      return step(fileActivity(file, 'build'), baseOf(file))
163    case 'Agent':
164    case 'Task':
165      return step('juggle', clip(str(e.description), 32))
166    case 'AskUserQuestion':
167      return step('wait')
168    case 'Skill':
169      return step('read', `the ${clip(str(e.skill), 24)} skill`)
170    case 'Bash':
171    case 'PowerShell': {
172      const cmd = str(e.command).trim()
173      const shown = clip(cmd.split('\n')[0] ?? cmd, 34)
174      if (GIT_CMD.test(cmd)) return step('git', shown)
175      if (INSTALL_CMD.test(cmd)) return step('install', shown)
176      if (TEST_CMD.test(cmd)) return step('test', shown)
177      return step('terminal', shown)
178    }
179  }
180  if (/browser|playwright|chrome/i.test(tool)) return step('web', clip(str(e.url) || tool.split('__').pop() || tool, 30))
181  return step('tinker', clip(tool.replace(/^mcp__/, '').split('__').pop() || tool, 30))
182}
183
184// ---------------------------------------------------------------------------
185// Usage: tokens come from the engine, the dollar figure is an estimate from
186// list prices (a subscription is not billed per token).
187
188type Usage = {
189  input_tokens?: number
190  output_tokens?: number
191  cache_read_input_tokens?: number
192  cache_creation_input_tokens?: number
193  model?: string
194}
195
196// USD per million tokens: input, output, cache read, cache write.
197const PRICES: [RegExp, [number, number, number, number]][] = [
198  [/fable|mythos/, [10, 50, 1, 12.5]],
199  [/opus-5|opus-4-[5-9]/, [5, 25, 0.5, 6.25]],
200  [/opus/, [15, 75, 1.5, 18.75]],
201  [/sonnet/, [3, 15, 0.3, 3.75]],
202  [/haiku/, [1, 5, 0.1, 1.25]],
203]
204
205const costOf = (model: string, u: Usage): number => {
206  const [i, o, r, w] = PRICES.find(([re]) => re.test(model.toLowerCase()))?.[1] ?? [5, 25, 0.5, 6.25]
207  return (
208    ((u.input_tokens || 0) * i + (u.output_tokens || 0) * o + (u.cache_read_input_tokens || 0) * r + (u.cache_creation_input_tokens || 0) * w) / 1e6
209  )
210}
211
212const sumUsage = (u: Usage): number =>
213  (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
214
215const windowOf = (model: string): number => (/\[1m\]|1m/i.test(model) || /fable|opus-5|sonnet-5/i.test(model) ? 1_000_000 : 200_000)
216
217// ---------------------------------------------------------------------------
218// Formatting.
219
220const fmtCount = (n: number): string => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`)
221const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
222const fmtTime = (ms: number): string => {
223  const s = Math.max(0, Math.round(ms / 1000))
224  const hrs = Math.floor(s / 3600)
225  const m = Math.floor((s % 3600) / 60)
226  const ss = String(s % 60).padStart(2, '0')
227  return hrs ? `${hrs}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
228}
229
230const isWaiting = (r: Run, at: number): boolean => r.status === 'running' && r.waitingSince !== undefined && at - r.waitingSince >= WAIT_SHOW_MS
231
232const etaText = (r: Run, at: number, past: Past[]): string => {
233  if (r.status === 'done') return `${theme.doneIn} ${fmtTime((r.endedAt ?? at) - r.startedAt)}`
234  if (r.status === 'stopped') return `Stopped at ${fmtTime((r.endedAt ?? at) - r.startedAt)}`
235  if (isWaiting(r, at)) return `Waiting on you · ${fmtTime(at - (r.waitingSince ?? at))}`
236  return `${sizeOf(r)} · ${estimate(r, at, past)}`
237}
238
239type Piece = { text: string; tone?: 'warn' | 'hot' }
240
241const LIMIT_LABEL: Record<string, string> = { five_hour: '5h', seven_day: 'wk', spend_limit: 'spend' }
242
243const statsPieces = (r: Run, at: number, lims: Limit[], withLimits = true): Piece[] => {
244  const parts: Piece[] = []
245  if (r.tasksTotal) parts.push({ text: `${r.tasksDone}/${r.tasksTotal} tasks` })
246  parts.push({ text: fmtTime((r.endedAt ?? at) - r.startedAt) })
247  parts.push({ text: `${fmtCount(r.tokens)} tok` })
248  if (r.ctxTokens) {
249    const ctx = Math.min(100, Math.round((r.ctxTokens / r.ctxMax) * 100))
250    parts.push({ text: `ctx ${ctx}%`, tone: ctx >= 90 ? 'hot' : ctx >= 80 ? 'warn' : undefined })
251  }
252  if (opts.showCost) parts.push({ text: `≈${fmtCost(r.cost)}` })
253  if (opts.showPlanUsage && withLimits) {
254    for (const l of lims) {
255      const pct = Math.round(l.percentUsed)
256      parts.push({ text: `${LIMIT_LABEL[l.kind] ?? l.kind} ${pct}%`, tone: pct >= 90 ? 'hot' : pct >= 75 ? 'warn' : undefined })
257    }
258  }
259  return parts
260}
261
262const statsText = (r: Run, at: number, lims: Limit[]): string => statsPieces(r, at, lims).map(p => p.text).join(' · ')
263
264const titleOf = (text: string): string => {
265  const plain = text.replace(/<\/?[A-Za-z_][^>]*>/g, ' ')
266  const line = plain.split('\n').map(l => l.trim()).find(Boolean) ?? ''
267  if (!line) return 'Your order'
268  const words = line.split(/\s+/)
269  return clip(words.slice(0, 8).join(' ') + (words.length > 8 ? '…' : ''), 60)
270}
271
272// ---------------------------------------------------------------------------
273// Run state.
274
275const fresh = (title: string, at: number, prev: Run | null): Run => ({
276  title,
277  startedAt: at,
278  stage: 0,
279  reached: 0,
280  activity: 'order',
281  note: pick('order', title),
282  tools: 0,
283  helpers: 0,
284  tasksDone: 0,
285  tasksTotal: 0,
286  taskStatus: {},
287  tokens: 0,
288  cost: 0,
289  ctxTokens: prev?.ctxTokens ?? 0,
290  ctxMax: prev?.ctxMax ?? 200_000,
291  model: prev?.model ?? '',
292  status: 'running',
293  pos: 0,
294  prevPos: 0,
295  posAt: at,
296  lastStage: 0,
297  files: [],
298  added: 0,
299  removed: 0,
300  agents: {},
301})
302
303// ---------------------------------------------------------------------------
304// Progress moves only on real events. With a task list the fill is the share
305// of tasks done; without one it fills through the furthest stage reached.
306// Delivered fills on delivery alone. A change slides over SLIDE_MS.
307
308const SLIDE_MS = 700
309
310const posOf = (r: Run): number => {
311  if (r.status === 'done') return 1
312  if (r.tasksTotal > 0) return (r.tasksDone / r.tasksTotal) * 0.8
313  return (Math.min(3, r.reached) + 1) / 5
314}
315
316// Where the fill is drawn at `at`, partway through a slide.
317const posNow = (r: Run, at: number): number => {
318  const t = Math.min(1, Math.max(0, (at - r.posAt) / SLIDE_MS))
319  return r.prevPos + (r.pos - r.prevPos) * t
320}
321
322// Call after any change to a run: starts a slide when the fill should move.
323const settle = (r: Run, at: number): Run => {
324  const pos = posOf(r)
325  const moved = pos === r.pos ? r : { ...r, prevPos: posNow(r, at), pos, posAt: at }
326  const stage = stageOf(moved)
327  return stage > moved.lastStage ? { ...moved, lastStage: stage, cheerAt: at } : moved
328}
329
330// The stage being worked on, the one that pulses.
331const stageOf = (r: Run): number => {
332  if (r.status === 'done') return DELIVERED
333  // The track the fill's edge is in, so the bold label always sits under it.
334  if (r.tasksTotal > 0) return Math.min(3, Math.floor((r.tasksDone / r.tasksTotal) * 4))
335  return Math.min(3, r.reached)
336}
337
338// ---------------------------------------------------------------------------
339// ETA. With a task list: this run's pace per task, or the learned pace before
340// the first task lands. Without one: of past runs that took at least as many
341// steps as this one has, how long they usually ran. Both sharpen as work goes.
342
343const HISTORY_MAX = 60
344const SOUND_AFTER_MS = 20_000
345
346// The delivery ding, made in code: two bell tones (E6, then B6) with a soft
347// overtone each, fading out over a second, as 16-bit mono WAV in base64.
348const CHIME_RATE = 22_050
349const CHIME_NOTES: [number, number, number][] = [
350  [0, 1318.5, 0.5],
351  [0.12, 1975.5, 0.42],
352]
353const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
354const base64 = (bytes: Uint8Array): string => {
355  let out = ''
356  for (let i = 0; i < bytes.length; i += 3) {
357    const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
358    out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63] + (i + 1 < bytes.length ? B64[(n >> 6) & 63] : '=') + (i + 2 < bytes.length ? B64[n & 63] : '=')
359  }
360  return out
361}
362let chimeCache = ''
363const chime = (): string => {
364  if (chimeCache) return chimeCache
365  const n = Math.round(CHIME_RATE * 1.15)
366  const wave = new Float64Array(n)
367  for (const [start, freq, amp] of CHIME_NOTES) {
368    const from = Math.round(start * CHIME_RATE)
369    for (let i = 0; from + i < n; i++) {
370      const t = i / CHIME_RATE
371      const env = Math.exp(-t * 5.5) * Math.min(1, t * 400)
372      wave[from + i] += amp * env * (Math.sin(2 * Math.PI * freq * t) + 0.25 * Math.sin(2 * Math.PI * freq * 2.01 * t) + 0.1 * Math.sin(2 * Math.PI * freq * 3.02 * t))
373    }
374  }
375  const peak = wave.reduce((m, x) => Math.max(m, Math.abs(x)), 0) || 1
376  const bytes = new Uint8Array(44 + n * 2)
377  const view = new DataView(bytes.buffer)
378  const text = (at: number, s: string) => [...s].forEach((ch, i) => view.setUint8(at + i, ch.charCodeAt(0)))
379  text(0, 'RIFF')
380  view.setUint32(4, 36 + n * 2, true)
381  text(8, 'WAVEfmt ')
382  view.setUint32(16, 16, true)
383  view.setUint16(20, 1, true)
384  view.setUint16(22, 1, true)
385  view.setUint32(24, CHIME_RATE, true)
386  view.setUint32(28, CHIME_RATE * 2, true)
387  view.setUint16(32, 2, true)
388  view.setUint16(34, 16, true)
389  text(36, 'data')
390  view.setUint32(40, n * 2, true)
391  for (let i = 0; i < n; i++) view.setInt16(44 + i * 2, Math.round((wave[i] / peak) * 0.8 * 32767), true)
392  chimeCache = base64(bytes)
393  return chimeCache
394}
395const WAIT_SHOW_MS = 2_500
396const CTX_WARN = 0.85
397
398// Past runs to learn from: this project's when it has a few, else all of them.
399const peersOf = (past: Past[], project: string): Past[] => {
400  const own = project ? past.filter(p => p.project === project) : []
401  return own.length >= 3 ? own : past
402}
403
404// `/tracker theme`: the themes, the one in use marked.
405const themesReport = (): string =>
406  [
407    'Clawd themes (switch with /tracker theme <name>):',
408    ...Object.entries(THEMES).map(([name, t]) => `  ${name === opts.theme ? '●' : '○'} ${name}: ${t.stages.join(' → ')}`),
409  ].join('\n')
410
411// `/tracker stats`: today's orders and a few records.
412const statsReport = (past: Past[], at: number): string => {
413  const today = new Date(at).toDateString()
414  const day = past.filter(p => p.when !== undefined && new Date(p.when).toDateString() === today)
415  if (!day.length) return 'Clawd: no orders delivered yet today.'
416  const time = day.reduce((t, p) => t + p.ms, 0)
417  const tokens = day.reduce((t, p) => t + (p.tokens ?? 0), 0)
418  const longest = Math.max(...day.map(p => p.ms))
419  const steps = day.reduce((t, p) => t + p.tools, 0)
420  const lines = [
421    `Clawd's day: ${day.length} order${day.length > 1 ? 's' : ''} delivered`,
422    `  Time cooking: ${fmtTime(time)} (longest ${fmtTime(longest)})`,
423    `  Steps: ${steps}`,
424  ]
425  if (tokens) lines.push(`  Tokens: ${fmtCount(tokens)}`)
426  return lines.join('\n')
427}
428
429const median = (xs: number[]): number => {
430  const v = [...xs].sort((x, y) => x - y)
431  const m = v.length >> 1
432  return v.length % 2 ? v[m] : (v[m - 1] + v[m]) / 2
433}
434
435const sizeOf = (r: Run): string => {
436  if (r.tasksTotal > 0) return r.tasksTotal <= 2 ? 'Small job' : r.tasksTotal <= 5 ? 'Medium job' : 'Large job'
437  if (r.tools < 6) return 'Small job'
438  return r.tools < 25 ? 'Medium job' : 'Large job'
439}
440
441const fmtLeft = (ms: number): string => (ms < 60_000 ? 'under a minute left' : `about ${Math.round(ms / 60_000)} min left`)
442
443const estimate = (r: Run, at: number, all: Past[]): string => {
444  const past = peersOf(all, currentProject)
445  const elapsed = at - r.startedAt
446  if (r.tasksTotal > 0) {
447    if (r.tasksDone >= r.tasksTotal) return 'wrapping up'
448    if (r.tasksDone > 0 && r.tasksSince !== undefined) {
449      return fmtLeft(((at - r.tasksSince) / r.tasksDone) * (r.tasksTotal - r.tasksDone))
450    }
451    const paces = past.filter(p => p.tasks > 0).map(p => p.ms / p.tasks)
452    if (paces.length >= 3) return `~${Math.max(1, Math.round((median(paces) * r.tasksTotal) / 60_000))} min job`
453    return 'sizing up…'
454  }
455  const peers = past.filter(p => p.tasks === 0 && p.tools >= Math.max(1, r.tools)).map(p => p.ms)
456  if (peers.length < 3) return 'sizing up…'
457  const left = median(peers) - elapsed
458  return left > 0 ? `usually ${fmtLeft(left)}` : 'running longer than usual'
459}
460
461// ---------------------------------------------------------------------------
462// The receipt: files touched, lines in and out, and how the last check went.
463
464const lineCount = (v: unknown): number => (typeof v === 'string' && v.length ? v.split('\n').length : 0)
465
466const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
467
468const tally = (r: Run, tool: string, e: Record<string, unknown>, isError: boolean): Run => {
469  if (isError) return TEST_CMD.test(str(e.command)) && !GIT_CMD.test(str(e.command)) ? { ...r, lastTest: 'fail' } : r
470  if ((tool === 'Bash' || tool === 'PowerShell') && TEST_CMD.test(str(e.command)) && !GIT_CMD.test(str(e.command))) return { ...r, lastTest: 'pass' }
471  if (!EDIT_TOOLS.has(tool)) return r
472  const file = str(e.file_path) || str(e.notebook_path)
473  let { added, removed } = r
474  if (tool === 'Edit') {
475    added += lineCount(e.new_string)
476    removed += lineCount(e.old_string)
477  } else if (tool === 'MultiEdit' && Array.isArray(e.edits)) {
478    for (const ed of e.edits as Record<string, unknown>[]) {
479      added += lineCount(ed.new_string)
480      removed += lineCount(ed.old_string)
481    }
482  } else if (tool === 'Write') {
483    added += lineCount(e.content)
484  } else {
485    added += lineCount(e.new_source)
486  }
487  return { ...r, added, removed, files: file && !r.files.includes(file) ? [...r.files, file] : r.files }
488}
489
490const receipt = (r: Run): string => {
491  const parts: string[] = []
492  if (r.files.length) parts.push(`${r.files.length} file${r.files.length > 1 ? 's' : ''} · +${r.added} −${r.removed}`)
493  if (r.lastTest) parts.push(r.lastTest === 'pass' ? 'checks ✓' : 'checks ✗')
494  parts.push(`${r.tools} step${r.tools === 1 ? '' : 's'}`)
495  return parts.join(' · ')
496}
497
498const live = (r: Run | null): r is Run => r !== null && r.status === 'running'
499
500const apply = (r: Run | null, s: Step): Run | null => {
501  if (!live(r)) return r
502  const stage = s.stage ?? r.stage
503  return { ...r, activity: s.activity, note: s.note, stage, reached: Math.max(r.reached, stage), tools: r.tools + 1 }
504}
505
506// Task lists: TodoWrite sends the whole list; TaskCreate/TaskUpdate send one change each.
507const trackTasks = (r: Run | null, tool: string, e: Record<string, unknown>, at: number): Run | null => {
508  if (!live(r)) return r
509  let { tasksDone, tasksTotal, note } = r
510  const taskStatus = { ...r.taskStatus }
511
512  if (tool === 'TodoWrite' && Array.isArray(e.todos)) {
513    const todos = e.todos as { status?: string; activeForm?: string; content?: string }[]
514    tasksTotal = todos.length
515    tasksDone = todos.filter(t => t.status === 'completed').length
516    const active = todos.find(t => t.status === 'in_progress')
517    if (active) note = clip(active.activeForm || active.content || note, 60)
518  } else if (tool === 'TaskCreate') {
519    tasksTotal += 1
520  } else if (tool === 'TaskUpdate') {
521    const id = str(e.taskId) || str(e.id)
522    const status = str(e.status)
523    const was = taskStatus[id]
524    if (status === 'completed' && was !== 'completed') tasksDone += 1
525    if (status === 'deleted' && was !== 'deleted') {
526      tasksTotal = Math.max(0, tasksTotal - 1)
527      if (was === 'completed') tasksDone = Math.max(0, tasksDone - 1)
528    }
529    if (status === 'in_progress' && str(e.activeForm)) note = clip(str(e.activeForm), 60)
530    if (id && status) taskStatus[id] = status
531  } else {
532    return r
533  }
534  return {
535    ...r,
536    tasksDone: Math.min(tasksDone, tasksTotal),
537    tasksTotal,
538    taskStatus,
539    note,
540    tasksSince: tasksTotal ? (r.tasksSince ?? at) : undefined,
541  }
542}
543
544// ---------------------------------------------------------------------------
545// Clawd: the Claude Code crab on a 30×28 pixel grid, one costume per activity.
546// Body and crab drawing adapted from johnnyvizz/claude-kit (MIT).
547
548type Fill = (x: number, y: number, w: number, tall: number, c: string, cls?: string) => void
549
550const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
551  rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
552
553type Pose = { front?: number; back?: number; frontCls?: string; backCls?: string }
554
555const body = (f: Fill, p: Pose = {}): void => {
556  f(7, 10, 16, 12, CLAY)
557  f(3, 14 + (p.back ?? 0), 4, 4, CLAY, p.backCls)
558  f(23, 14 + (p.front ?? 0), 4, 4, CLAY, p.frontCls)
559  f(9, 12, 2, 2, INK)
560  f(19, 12, 2, 2, INK)
561  f(7, 22, 2, 4, CLAY, 'la')
562  f(17, 22, 2, 4, CLAY, 'la')
563  f(11, 22, 2, 4, CLAY, 'lb')
564  f(21, 22, 2, 4, CLAY, 'lb')
565}
566
567const glasses = (f: Fill, c = INK): void => {
568  for (const x of [8, 18]) {
569    f(x, 11, 4, 1, c)
570    f(x, 14, 4, 1, c)
571    f(x, 11, 1, 4, c)
572    f(x + 3, 11, 1, 4, c)
573  }
574  f(12, 12, 6, 1, c)
575}
576
577const CHEF_HAT = ['........lll.......', '.......lllll......', '.wwwwgwwwwwwgwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwgwwwwwggwwwww', '.wwwwgwwwwwggwwwww', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.']
578const HARD_HAT = ['.....yyyyyyyy.....', '...yyyyyvvyyyyy...', '..yyyyyyvvyyyyyy..', '..yyyyyyvvyyyyyy..', '.yyyyyyyvvyyyyyyy.', 'dddddddddddddddddd']
579
580const COSTUMES: Record<Activity, (f: Fill) => void> = {
581  // Holds up the order ticket.
582  order: f => {
583    body(f, { front: -4, frontCls: 'it' })
584    f(23, 2, 6, 8, '#F4F3EE', 'it')
585    f(24, 4, 4, 1, '#9A9A96', 'it')
586    f(24, 6, 3, 1, '#9A9A96', 'it')
587    f(24, 8, 4, 1, CLAY, 'it')
588  },
589  // Reading glasses and a book whose page keeps turning.
590  read: f => {
591    body(f)
592    glasses(f)
593    f(8, 17, 15, 5, '#7A4A26')
594    f(9, 17, 6, 4, '#F4F3EE')
595    f(16, 17, 6, 4, '#F4F3EE')
596    f(15, 17, 1, 5, '#5A3519')
597    f(10, 18, 4, 1, '#C9C7BE')
598    f(10, 20, 3, 1, '#C9C7BE')
599    f(17, 18, 4, 1, '#C9C7BE')
600    f(17, 20, 3, 1, '#C9C7BE')
601    f(16, 17, 6, 4, '#FFFFFF', 'pg')
602  },
603  // Detective magnifier sweeping for matches.
604  search: f => {
605    body(f, { front: -4, frontCls: 'it' })
606    stamp(f, 23, 1, ['.kkk.', 'k...k', 'k...k', 'k...k', '.kkk.'], { k: '#3A3A3C' }, 'it')
607    f(24, 2, 3, 3, 'rgba(169,214,245,.7)', 'it')
608    f(25, 6, 1, 4, '#7A4A26', 'it')
609    f(24, 2, 1, 1, '#FFFFFF', 'gl')
610  },
611  // Bandana and spyglass, scouting the project.
612  explore: f => {
613    body(f, { front: -4, frontCls: 'it' })
614    f(7, 8, 16, 2, '#D0453F')
615    f(10, 8, 1, 1, '#F4F3EE')
616    f(16, 9, 1, 1, '#F4F3EE')
617    f(3, 9, 4, 1, '#D0453F')
618    f(4, 10, 2, 2, '#D0453F')
619    stamp(f, 23, 2, ['....gg', '...gbg', '..bb..', '.bb...', 'bb....'], { b: '#7A4A26', g: '#E0B04A' }, 'it')
620  },
621  // Rides a surfboard over the wave: fetching from the web.
622  web: f => {
623    body(f)
624    f(15, 4, 1, 6, '#4FA3E0')
625    f(2, 25, 26, 2, '#F5C542')
626    f(13, 25, 4, 2, '#D0453F')
627    f(0, 27, 30, 1, '#4FA3E0', 'wv')
628    f(4, 26, 2, 1, '#BEE3F8', 'wv')
629    f(19, 26, 2, 1, '#BEE3F8', 'wv')
630  },
631  // Clipboard and pencil: writing the plan.
632  plan: f => {
633    body(f, { front: -3, frontCls: 'it' })
634    f(0, 9, 7, 10, '#B08D57')
635    f(1, 11, 5, 7, '#F4F3EE')
636    f(2, 9, 3, 2, '#8E929A')
637    f(2, 12, 3, 1, '#9A9A96')
638    f(2, 14, 3, 1, '#9A9A96')
639    f(2, 16, 1, 1, DONE)
640    f(4, 16, 1, 1, '#9A9A96')
641    f(25, 4, 1, 6, '#F5C542', 'it')
642    f(25, 10, 1, 1, '#F4C7A1', 'it')
643    f(25, 3, 1, 1, '#E58FA0', 'it')
644  },
645  // Thought bubble with dots, between moves.
646  think: f => {
647    body(f, { front: -2 })
648    f(21, 0, 8, 1, '#E4E2DA')
649    f(20, 1, 10, 3, '#E4E2DA')
650    f(21, 4, 8, 1, '#E4E2DA')
651    f(19, 6, 2, 2, '#E4E2DA')
652    f(17, 8, 1, 1, '#E4E2DA')
653    f(22, 2, 1, 1, INK, 'd1')
654    f(24, 2, 1, 1, INK, 'd2')
655    f(26, 2, 1, 1, INK, 'd3')
656  },
657  // Chef's toque; flips an omelette: editing code.
658  cook: f => {
659    body(f, { front: -4, frontCls: 'pan' })
660    stamp(f, 6, 1, CHEF_HAT, { w: '#F4F3EE', l: '#F7F6F2', g: '#D2D1C8', b: '#E4E2DA', d: '#C9C7BE' })
661    f(22, 8, 7, 2, '#4A4A48', 'pan')
662    f(28, 10, 1, 1, '#4A4A48', 'pan')
663    f(24, 7, 3, 1, '#F5B731', 'egg')
664    f(23, 7, 1, 1, '#FFFFFF', 'egg')
665  },
666  // Hard hat and hammer: writing a new file.
667  build: f => {
668    body(f, { front: -4, frontCls: 'it' })
669    stamp(f, 6, 4, HARD_HAT, { y: '#F5C542', v: '#FBE08A', d: '#C99A1E' })
670    stamp(f, 23, 2, ['sssss', 'sssss', '..w..', '..w..', '..w..', '..w..', '..w..', '..w..'], { s: '#8E929A', w: '#7A4A26' }, 'it')
671  },
672  // Beret, palette and a brush that changes color: styles.
673  paint: f => {
674    body(f, { front: -4, frontCls: 'it' })
675    f(14, 5, 1, 2, '#2B2B4A')
676    f(9, 6, 12, 2, '#3A3A6A')
677    f(7, 8, 15, 2, '#3A3A6A')
678    f(0, 15, 7, 5, '#E8D5B0')
679    f(1, 16, 1, 1, '#D0453F')
680    f(3, 16, 1, 1, '#4FA3E0')
681    f(5, 17, 1, 1, '#F5C542')
682    f(2, 18, 1, 1, DONE)
683    f(26, 3, 1, 7, '#7A4A26', 'it')
684    f(26, 2, 1, 1, '#C9CCD2', 'it')
685    f(26, 0, 1, 2, '#7F77DD', 'tip')
686  },
687  // Quill and ink pot: docs and text.
688  quill: f => {
689    body(f, { front: -4, frontCls: 'it' })
690    f(0, 18, 5, 4, '#2B2B4A')
691    f(1, 17, 3, 1, '#55514C')
692    stamp(f, 23, 0, ['....ww', '...www', '..www.', '..ww..', '.ww...', '.w....', 'w.....', 'k.....'], { w: '#F4F3EE', k: INK }, 'it')
693    f(8, 19, 14, 3, '#F4F3EE')
694    f(9, 20, 8, 1, '#9A9A96')
695  },
696  // Goggles up, wrench turning, a gear spinning: config and other tools.
697  tinker: f => {
698    body(f)
699    f(7, 8, 16, 2, '#55514C')
700    f(9, 8, 3, 2, '#9FD3F0')
701    f(18, 8, 3, 2, '#9FD3F0')
702    stamp(f, 0, 10, ['.s.s', 'sss.', '.s..', '.s..'], { s: '#8E929A' }, 'it')
703    stamp(f, 24, 2, ['.g.g.', 'ggggg', 'gg.gg', 'ggggg', '.g.g.'], { g: '#C9CCD2' }, 'gr')
704  },
705  // Lab goggles and a bubbling flask: tests, builds, linters.
706  test: f => {
707    body(f, { front: -4, frontCls: 'it' })
708    f(8, 11, 4, 4, 'rgba(120,190,235,.45)')
709    f(18, 11, 4, 4, 'rgba(120,190,235,.45)')
710    f(12, 12, 6, 1, '#55514C')
711    f(7, 12, 1, 1, '#55514C')
712    f(22, 12, 1, 1, '#55514C')
713    stamp(f, 23, 3, ['..gg..', '..gg..', '..gg..', '.gggg.', 'gllllg', 'gllllg', '.gggg.'], { g: '#CDE7F5', l: '#5FCB8A' }, 'it')
714    f(25, 1, 1, 1, '#5FCB8A', 'b1')
715    f(24, 0, 1, 1, '#5FCB8A', 'b2')
716  },
717  // Typing away at a tiny laptop: shell commands.
718  terminal: f => {
719    body(f, { frontCls: 'tb', backCls: 'ta' })
720    f(9, 15, 12, 7, '#2B2B2E')
721    f(9, 15, 12, 1, '#55514C')
722    f(10, 17, 6, 1, '#5FCB8A')
723    f(10, 19, 4, 1, '#5FCB8A')
724    f(15, 19, 1, 1, '#5FCB8A', 'cur')
725    f(6, 22, 18, 1, '#8E929A')
726  },
727  // Carries a parcel on its head: installing packages.
728  install: f => {
729    body(f, { front: -6, back: -6 })
730    f(8, 1, 14, 9, '#C8935A', 'bx')
731    f(14, 1, 2, 9, '#E8C48A', 'bx')
732    f(8, 9, 14, 1, '#A87442', 'bx')
733    f(10, 4, 3, 2, '#F4F3EE', 'bx')
734  },
735  // Mail carrier's cap and a stamped envelope: git and GitHub.
736  git: f => {
737    body(f, { front: -5, frontCls: 'it' })
738    f(8, 6, 14, 3, '#2F4E8C')
739    f(17, 9, 7, 1, '#1E3360')
740    f(14, 7, 2, 1, '#F5C542')
741    f(23, 3, 7, 5, '#F4F3EE', 'it')
742    stamp(f, 23, 3, ['k.....k', '.k...k.', '..k.k..', '...k...'], { k: '#C9C7BE' }, 'it')
743    f(28, 4, 1, 1, '#D0453F', 'it')
744  },
745  // Juggles three balls: subagents at work.
746  juggle: f => {
747    body(f, { front: -3, back: -3, frontCls: 'tb', backCls: 'ta' })
748    f(8, 5, 2, 2, '#7F77DD', 'j1')
749    f(14, 3, 2, 2, '#1D9E75', 'j2')
750    f(20, 5, 2, 2, '#F5C542', 'j3')
751  },
752  // Holds up a "?" sign: Claude needs your answer.
753  wait: f => {
754    body(f, { front: -6, back: -6 })
755    f(7, 0, 16, 9, '#F4F3EE', 'sg')
756    f(7, 8, 16, 1, '#C9C7BE', 'sg')
757    stamp(f, 13, 1, ['.ccc.', 'c...c', '...c.', '..c..', '.....', '..c..'], { c: CLAY_DARK }, 'sg')
758  },
759  // Party hat, confetti and the pizza box: delivered.
760  deliver: f => {
761    body(f)
762    stamp(f, 13, 3, ['..r..', '..p..', '.pyp.', '.ypy.', 'pypyp', 'ypypy'], { p: '#7F77DD', y: '#F5C542', r: '#D0453F' })
763    f(5, 17, 20, 4, '#C8935A')
764    f(5, 17, 20, 1, '#E8C48A')
765    f(13, 18, 4, 2, '#D0453F')
766    f(14, 18, 2, 1, '#F4F3EE')
767    f(2, 0, 1, 1, '#7F77DD', 'c1')
768    f(27, 2, 1, 1, '#1D9E75', 'c2')
769    f(5, 5, 1, 1, '#F5C542', 'c3')
770    f(24, 0, 1, 1, '#D0453F', 'c4')
771  },
772  // Sweat drop and a dropped pizza: interrupted.
773  oops: f => {
774    body(f)
775    f(23, 8, 2, 3, '#7EC3F0', 'dr')
776    f(0, 24, 6, 2, '#F5B731')
777    f(1, 24, 1, 1, '#D0453F')
778    f(4, 25, 1, 1, '#D0453F')
779  },
780}
781
782// Pure CSS, run by the compositor. Periods divide one second, so the
783// once-a-second redraw while working restarts them in phase.
784const CRAB_CSS = `<style>
785.run,.run g{transform-box:fill-box}
786.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
787.run .bd{animation:bob .5s steps(1) infinite -.125s}
788@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
789@keyframes blink{50%{opacity:.15}}
790.a-order.run .it{animation:bob .5s steps(1) infinite}
791.a-read.run .la,.a-read.run .lb,.a-read.run .bd{animation:none}
792.a-read.run .pg{transform-origin:0 50%;animation:page 1s ease-in-out infinite}
793@keyframes page{0%,30%{transform:scaleX(1)}60%,100%{transform:scaleX(-1)}}
794.a-search.run .it{animation:scan 1s steps(1) infinite}.a-search.run .gl{animation:blink 1s steps(1) infinite -.5s}
795@keyframes scan{25%{transform:translate(-1px,1px)}50%{transform:translate(-2px,2px)}75%{transform:translate(-1px,1px)}}
796.a-explore.run .it{transform-origin:0 100%;animation:sweep 1s ease-in-out infinite}
797@keyframes sweep{50%{transform:rotate(-14deg)}}
798.a-web.run{transform-origin:50% 100%;animation:sway 1s ease-in-out infinite}
799.a-web.run .la,.a-web.run .lb,.a-web.run .bd{animation:none}
800.a-web.run .wv{animation:wave 1s steps(2) infinite}
801@keyframes sway{25%{transform:rotate(-3deg)}75%{transform:rotate(3deg)}}@keyframes wave{50%{transform:translateX(-2px)}}
802.a-plan.run .it{animation:scribble .5s steps(1) infinite}
803@keyframes scribble{25%{transform:translate(-1px,1px)}50%{transform:translate(1px,0)}75%{transform:translate(0,1px)}}
804.a-think.run .la,.a-think.run .lb{animation:none}
805.a-think.run .d1{animation:blink 1s steps(1) infinite}.a-think.run .d2{animation:blink 1s steps(1) infinite -.66s}.a-think.run .d3{animation:blink 1s steps(1) infinite -.33s}
806.a-cook.run .pan{transform-origin:0 50%;animation:tilt 1s ease-in-out infinite}
807.a-cook.run .egg{animation:flip 1s ease-in-out infinite}
808@keyframes tilt{20%,40%{transform:rotate(-12deg)}}@keyframes flip{30%{transform:translateY(-5px) scaleY(-1)}60%{transform:translateY(0)}}
809.a-build.run .it{transform-origin:50% 100%;animation:hammer .5s ease-in infinite}
810@keyframes hammer{50%{transform:rotate(-35deg)}}
811.a-paint.run .it,.a-paint.run .tip{transform-origin:50% 100%;animation:brush .5s ease-in-out infinite}
812.a-paint.run .tip rect{animation:hue 1s steps(1) infinite}
813@keyframes brush{50%{transform:rotate(18deg)}}@keyframes hue{0%{fill:#7F77DD}33%{fill:#D0453F}66%{fill:#1D9E75}}
814.a-quill.run .it{transform-origin:0 100%;animation:write .5s ease-in-out infinite}
815@keyframes write{50%{transform:rotate(10deg) translateX(1px)}}
816.a-tinker.run .it{transform-origin:100% 100%;animation:twist .5s ease-in-out infinite}
817.a-tinker.run .gr{transform-origin:50% 50%;animation:spin 1s linear infinite}
818@keyframes twist{50%{transform:rotate(-35deg)}}@keyframes spin{to{transform:rotate(90deg)}}
819.a-test.run .it{transform-origin:50% 100%;animation:swirl .5s ease-in-out infinite}
820.a-test.run .b1{animation:rise 1s linear infinite}.a-test.run .b2{animation:rise 1s linear infinite -.5s}
821@keyframes swirl{25%{transform:rotate(-8deg)}75%{transform:rotate(8deg)}}@keyframes rise{0%{transform:translateY(2px);opacity:1}100%{transform:translateY(-3px);opacity:0}}
822.a-terminal.run .la,.a-terminal.run .lb,.a-terminal.run .bd{animation:none}
823.a-terminal.run .ta{animation:st .25s steps(1) infinite}.a-terminal.run .tb{animation:st .25s steps(1) infinite -.125s}
824.a-terminal.run .cur{animation:blink .5s steps(1) infinite}
825.a-install.run .la{animation-duration:.25s}.a-install.run .lb{animation-duration:.25s;animation-delay:-.125s}
826.a-install.run .bx{animation:bob .25s steps(1) infinite}
827.a-git.run .it{animation:bob .5s steps(1) infinite}
828.a-juggle.run .la,.a-juggle.run .lb{animation:none}
829.a-juggle.run .ta{animation:st .5s steps(1) infinite}.a-juggle.run .tb{animation:st .5s steps(1) infinite -.25s}
830.a-juggle.run .j1{animation:hop 1s ease-in-out infinite}.a-juggle.run .j2{animation:hop 1s ease-in-out infinite -.33s}.a-juggle.run .j3{animation:hop 1s ease-in-out infinite -.66s}
831@keyframes hop{50%{transform:translateY(-4px)}}
832.a-wait.run .la,.a-wait.run .lb{animation:none}
833.a-wait.run .sg{transform-origin:50% 100%;animation:sway 1s ease-in-out infinite}
834.a-deliver.run .la{animation-duration:.25s}.a-deliver.run .lb{animation-duration:.25s;animation-delay:-.125s}
835.a-deliver.run .c1,.a-deliver.run .c2,.a-deliver.run .c3,.a-deliver.run .c4{animation:fall 1s linear infinite}
836.a-deliver.run .c2{animation-delay:-.25s}.a-deliver.run .c3{animation-delay:-.5s}.a-deliver.run .c4{animation-delay:-.75s}
837@keyframes fall{0%{transform:translateY(0);opacity:1}100%{transform:translateY(8px);opacity:0}}
838.a-oops.run .la,.a-oops.run .lb,.a-oops.run .bd{animation:none}
839.a-oops.run .dr{animation:fall 1s linear infinite}
840@media (prefers-reduced-motion: reduce){.run,.run g,.run rect{animation:none!important}}
841</style>`
842
843// Props nest inside `bd` so they ride the bob; the legs step on their own.
844const crab = (x: number, y: number, activity: Activity, isMoving: boolean, scale: number): string => {
845  const groups = new Map<string, string[]>([['bd', []]])
846  const f: Fill = (cx, cy, w, tall, c, cls = 'bd') => {
847    if (!groups.has(cls)) groups.set(cls, [])
848    groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${tall}" fill="${c}"/>`)
849  }
850  ;(COSTUMES[activity] ?? body)(f)
851  const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
852  const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
853  const inner = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
854  return `<g transform="translate(${x},${y}) scale(${scale})" shape-rendering="crispEdges"><g class="a-${activity}${isMoving ? ' run' : ''}">${inner}${group('la')}${group('lb')}</g></g>`
855}
856
857// ---------------------------------------------------------------------------
858// The tracker row: one SVG, since the desktop wraps sibling elements.
859
860const H = 66
861const RINGS_W = 92
862const RING_R = 9
863const RING_OK = '#5B8DEF'
864
865// One plan-usage ring: the share used, colored by how close the limit is.
866const ring = (l: Limit, cx: number, cy: number): string => {
867  const pct = Math.max(0, Math.min(100, l.percentUsed))
868  const c = 2 * Math.PI * RING_R
869  const tone = pct >= 90 ? HOT : pct >= 75 ? WARN : RING_OK
870  const label = `${LIMIT_LABEL[l.kind] ?? l.kind} ${Math.round(l.percentUsed)}%`
871  return `<circle class="kr" cx="${cx}" cy="${cy}" r="${RING_R}" fill="none" stroke-width="3.2"/>` +
872    `<circle cx="${cx}" cy="${cy}" r="${RING_R}" fill="none" stroke="${tone}" stroke-width="3.2" stroke-linecap="round" stroke-dasharray="${((c * pct) / 100).toFixed(2)} ${c.toFixed(2)}" transform="rotate(-90 ${cx} ${cy})"/>` +
873    `<text x="${cx + RING_R + 7}" y="${cy + 4}" font-family="${FONT}" font-size="11.5" font-variant-numeric="tabular-nums"${pct >= 75 ? ` fill="${tone}" font-weight="600"` : ' class="s"'}>${xml(label)}</text>`
874}
875const MINI_MAX = 4
876const MINI_SCALE = 0.45
877const MINI_STEP = 17
878const WARN = '#E0A33B'
879const HOT = '#D9534F'
880
881// A stage up: Clawd hops and sparkles for CHEER_MS. Each redraw restarts CSS,
882// so the animation starts that far back in and picks up where it was.
883const CHEER_MS = 1200
884const CHEER_CSS = `<style>
885.hop{animation:hop2 ${CHEER_MS}ms ease-out 1 both}
886@keyframes hop2{0%{transform:translateY(0)}20%{transform:translateY(-8px)}40%{transform:translateY(0)}55%{transform:translateY(-3px)}70%,100%{transform:translateY(0)}}
887.sp{transform-box:fill-box;transform-origin:50% 50%;opacity:0;animation:spark ${CHEER_MS}ms ease-out 1 both}
888@keyframes spark{0%{opacity:0;transform:scale(.3)}25%{opacity:1;transform:scale(1.15)}100%{opacity:0;transform:translateY(-6px) scale(.6)}}
889@media (prefers-reduced-motion: reduce){.hop,.sp{animation:none}}
890</style>`
891const SPARKS: [number, number][] = [[4, 10], [56, 6], [2, 40], [62, 34]]
892const spark = ([x, y]: [number, number], delay: string): string =>
893  `<g class="sp" style="${delay}"><rect x="${x + 2}" y="${y}" width="2" height="6" fill="#F2C94C"/><rect x="${x}" y="${y + 2}" width="6" height="2" fill="#F2C94C"/></g>`
894
895const cheer = (r: Run, at: number, drawn: string): string => {
896  const since = r.cheerAt === undefined ? Infinity : at - r.cheerAt
897  if (since >= CHEER_MS) return drawn
898  const delay = `animation-delay:-${Math.round(since)}ms`
899  return `${CHEER_CSS}<g class="hop" style="${delay}">${drawn}</g>${SPARKS.map(p => spark(p, delay)).join('')}`
900}
901
902const xml = (s: string): string => s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
903
904const charEm = (ch: string): number => (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%mw]/.test(ch) ? 0.72 : 0.56)
905const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
906const fitText = (s: string, size: number, maxW: number): string => {
907  if (textWidth(s, size) <= maxW) return s
908  let out = ''
909  for (const ch of s) {
910    if (textWidth(out + ch + '…', size) > maxW) break
911    out += ch
912  }
913  return out + '…'
914}
915
916const TRACK_CSS = `<style>
917.t{fill:#1f1f1f}.s{fill:#6b6b68}.m{fill:#9a9a96}.k{fill:#e7e5e0}.kr{stroke:#e7e5e0}
918@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.m{fill:#7d7d79}.k{fill:#30302e}.kr{stroke:#3a3a37}}
919.now{animation:pulse 1s ease-in-out infinite}@keyframes pulse{50%{opacity:.45}}
920@media (prefers-reduced-motion: reduce){.now{animation:none}}
921</style>`
922
923const trackerSvg = (r: Run, W: number, at: number, past: Past[], lims: Limit[]): string => {
924  const x0 = 72
925  // Plan usage: a ring per window, stacked in a column on the right.
926  const rings = opts.showPlanUsage ? lims.filter(l => l.kind in LIMIT_LABEL).slice(0, 2) : []
927  const ringsW = rings.length ? RINGS_W : 0
928  const barW = Math.max(160, W - x0 - 4 - ringsW)
929  const gap = 5
930  const segW = (barW - gap * 4) / 5
931  const isDone = r.status === 'done'
932  const waiting = isWaiting(r, at)
933  const color = r.status === 'stopped' ? '#9A9A96' : isDone ? DONE : CLAY
934  const etaColor = waiting ? WARN : color
935
936  const eta = etaText(r, at, past)
937  const etaW = textWidth(eta, 12) + 6
938  // Subagents at work: a mini Clawd each, in its own costume, left of the ETA.
939  const helpers = r.status === 'running' ? Object.values(r.agents).slice(0, MINI_MAX) : []
940  const extra = r.status === 'running' ? Object.keys(r.agents).length - helpers.length : 0
941  const minisW = helpers.length ? helpers.length * MINI_STEP + (extra > 0 ? 18 : 0) + 6 : 0
942  const minisX = x0 + barW - etaW - minisW
943  const minis = helpers.map((a, i) => crab(minisX + i * MINI_STEP, 1, a, true, MINI_SCALE)).join('') +
944    (extra > 0 ? `<text class="m" x="${minisX + helpers.length * MINI_STEP + 2}" y="14" font-family="${FONT}" font-size="10.5">+${extra}</text>` : '')
945  const title = fitText(r.title, 13, Math.max(60, barW - etaW - minisW - 10))
946  const pieces = statsPieces(r, at, lims, false)
947  const stats = pieces.map(p => p.text).join(' · ')
948  const statsW = textWidth(stats, 11.5)
949  const note = fitText(waiting ? `Needs ${r.waitingFor ?? 'you'}` : r.note, 12, Math.max(40, barW - statsW - 14))
950
951  // Where the fill's edge sits, in px, for a progress value over all five tracks.
952  const edge = (p: number): number => {
953    const i = Math.min(4, Math.floor(p * 5))
954    return x0 + i * (segW + gap) + Math.min(1, p * 5 - i) * segW
955  }
956  const from = edge(posNow(r, at))
957  const to = edge(r.pos)
958  const ms = Math.max(0, SLIDE_MS - (at - r.posAt))
959  const sx = (px: number): string => ((px - x0) / barW).toFixed(5)
960  // A slide in flight animates the clip from where the fill is drawn now to
961  // where it lands, so a redraw mid-slide carries on from the same spot. The
962  // resting transform is the end state, which is what reduced motion shows.
963  const fillCss = `<style>.cf{transform-origin:${x0}px 0;transform:scaleX(${sx(to)})${
964    ms > 0 && from !== to ? `;animation:fill ${Math.round(ms)}ms ease-out forwards` : ''
965  }}@keyframes fill{from{transform:scaleX(${sx(from)})}to{transform:scaleX(${sx(to)})}}@media (prefers-reduced-motion: reduce){.cf{animation:none}}</style>`
966
967  // The stage being worked on pulses, filled or not.
968  const stage = stageOf(r)
969  const pulse = (i: number): string => (r.status === 'running' && i === stage ? ' now' : '')
970  const tracks = STAGES.map((_, i) => `<rect class="k${pulse(i)}" x="${x0 + i * (segW + gap)}" y="24" width="${segW}" height="7" rx="3.5"/>`).join('')
971  const fills = STAGES.map((_, i) => `<rect class="${pulse(i).trim()}" x="${x0 + i * (segW + gap)}" y="24" width="${segW}" height="7" rx="3.5" fill="${color}"/>`).join('')
972  const labels = STAGES.map((name, i) => {
973    const x = x0 + i * (segW + gap)
974    const isCurrent = i === stage
975    const cls = isCurrent ? 't' : i < stage ? 's' : 'm'
976    return `<text class="${cls}" x="${x}" y="44" font-family="${FONT}" font-size="10.5"${isCurrent ? ' font-weight="600"' : ''}>${xml(fitText(name, 10.5, segW))}</text>`
977  }).join('')
978  const segs = `<clipPath id="fc"><rect class="cf" x="${x0}" y="20" width="${barW}" height="15"/></clipPath>${tracks}<g clip-path="url(#fc)">${fills}</g>${labels}`
979
980  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${TRACK_CSS}${fillCss}${CRAB_CSS}
981${cheer(r, at, crab(0, 5, waiting ? 'wait' : r.activity, r.status !== 'stopped' || r.activity === 'oops', 2))}
982<text class="t" x="${x0}" y="15" font-family="${FONT}" font-size="13" font-weight="600">${xml(title)}</text>
983${minis}
984<text x="${x0 + barW}" y="15" text-anchor="end" font-family="${FONT}" font-size="12" fill="${etaColor}" font-weight="${waiting ? 600 : 500}"${waiting ? ' class="now"' : ''}>${xml(eta)}</text>
985${segs}
986<text class="t" x="${x0}" y="61" font-family="${FONT}" font-size="12">${xml(note)}</text>
987<text class="m" x="${x0 + barW}" y="61" text-anchor="end" font-family="${FONT}" font-size="11.5" font-variant-numeric="tabular-nums">${pieces
988    .map((p, i) => `${i ? ' · ' : ''}${p.tone ? `<tspan fill="${p.tone === 'hot' ? HOT : WARN}" font-weight="600">${xml(p.text)}</tspan>` : xml(p.text)}`)
989    .join('')}</text>
990${rings.map((l, k) => ring(l, x0 + barW + 16 + RING_R, rings.length === 1 ? 33 : 17 + k * 32)).join('')}
991</svg>`
992}
993
994// Terminal: the same tracker in text.
995const GLYPH: Record<Activity, string> = {
996  order: '🧾',
997  read: '📖',
998  search: '🔍',
999  explore: '🧭',
1000  web: '🏄',
1001  plan: '📋',
1002  think: '💭',
1003  cook: '🍳',
1004  build: '🔨',
1005  paint: '🎨',
1006  quill: '🪶',
1007  tinker: '🔧',
1008  test: '🧪',
1009  terminal: '⌨️',
1010  install: '📦',
1011  git: '📮',
1012  juggle: '🤹',
1013  wait: '✋',
1014  deliver: '🍕',
1015  oops: '💦',
1016}
1017
1018const segText = (r: Run, width: number, at: number): string => {
1019  const filled = Math.round(posNow(r, at) * 5 * width)
1020  return STAGES.map((_, i) => '█'.repeat(Math.max(0, Math.min(width, filled - i * width))).padEnd(width, '░')).join(' ')
1021}
1022
1023// ---------------------------------------------------------------------------
1024
1025const toggle = async ($: EngineInterface): Promise<boolean> => {
1026  const next = await update($, view, v => ({ ...v, isHidden: !v.isHidden }))
1027  return !next.isHidden
1028}
1029
1030export const register: Register = (on, options) => {
1031  const o = (options ?? {}) as Partial<Options>
1032  opts = {
1033    showCost: o.showCost !== false,
1034    showPlanUsage: o.showPlanUsage !== false,
1035    sound: o.sound !== false,
1036    theme: typeof o.theme === 'string' && o.theme in THEMES ? o.theme : 'pizza',
1037  }
1038  theme = THEMES[opts.theme] ?? THEMES.pizza
1039  STAGES = theme.stages
1040  VERBS.deliver = theme.deliver
1041  let pendingTitle = ''
1042  // Limit windows already warned about, keyed by kind and reset time.
1043  const warned = new Set<string>()
1044  let ctxWarned = false
1045
1046  on('session.start', async ($, e, next) => {
1047    const started = await next(e)
1048    currentProject = e.cwd
1049    await $.command.register({
1050      name: 'tracker',
1051      description: 'Show or hide the Clawd tracker · /tracker theme <pizza|coffee|rocket|construction> · /tracker sound on|off · /tracker stats',
1052    })
1053    const saved = await $.store.get('history')
1054    if (Array.isArray(saved)) await update($, history, () => saved as Past[])
1055    // Keeps the clock moving while a run is live; quiet otherwise.
1056    $.clock.every(1000, () => {
1057      void (async () => {
1058        if (!live(await read($, run))) return
1059        const at = await $.clock.now()
1060        await update($, now, () => at)
1061      })()
1062    })
1063    return started
1064  })
1065
1066  on('command.run', { command: 'tracker' }, async ($, e) => {
1067    const [word = '', value = ''] = e.args.trim().toLowerCase().split(/\s+/)
1068    if (word === 'stats') return { text: statsReport(await read($, history), await $.clock.now()) }
1069    // `/tracker theme <name>` and `/tracker sound on|off` change the plugin's
1070    // own settings; the engine reloads the tracker with them.
1071    if (word === 'theme') {
1072      if (!value) return { text: themesReport() }
1073      if (!(value in THEMES)) return { text: `Clawd: there's no "${value}" theme. ${themesReport()}` }
1074      await $.config.set({ key: 'clawd-tracker.theme', value })
1075      return { text: `Clawd: switched to the ${value} theme (${THEMES[value].stages.join(' → ')}).` }
1076    }
1077    if (word === 'sound') {
1078      if (value !== 'on' && value !== 'off') return { text: `Clawd: the delivery ding is ${opts.sound ? 'on' : 'off'}. Use /tracker sound on or /tracker sound off.` }
1079      await $.config.set({ key: 'clawd-tracker.sound', value: value === 'on' })
1080      return { text: `Clawd: delivery ding ${value}.` }
1081    }
1082    const isShown = await toggle($)
1083    return { text: isShown ? 'Clawd tracker shown.' : 'Clawd tracker hidden.' }
1084  })
1085
1086  on('prompt.submit', async ($, e, next) => {
1087    pendingTitle = titleOf(str((e as Record<string, unknown>).text))
1088    return next(e)
1089  })
1090
1091  // The main loop's turn opens a new order; subagents' turns don't.
1092  on('turn.start', async ($, e, next) => {
1093    const agent = str((e as Record<string, unknown>).agentId)
1094    if (agent) {
1095      await update($, run, r => (live(r) ? { ...r, agents: { ...r.agents, [agent]: 'think' } } : r))
1096    } else {
1097      const at = await $.clock.now()
1098      const title = pendingTitle || 'Your order'
1099      pendingTitle = ''
1100      await update($, run, prev => fresh(title, at, prev))
1101      await update($, now, () => at)
1102    }
1103    return next(e)
1104  })
1105
1106  on('tool.call', async ($, e, next) => {
1107    const ev = e as unknown as Record<string, unknown>
1108    const tool = str(ev.tool)
1109    const isMain = !ev.agentId
1110
1111    if (isMain) {
1112      const at = await $.clock.now()
1113      // Will this step stop for your OK? A read-only question to the permission
1114      // rules: the decision stays with Claude Code and you. In auto mode a
1115      // reviewer answers in a moment, so the band only calls for you once the
1116      // wait passes WAIT_SHOW_MS.
1117      const asks = tool !== 'AskUserQuestion' && (await $.tool.check({ tool, input: argsOf(ev) })).decision === 'ask'
1118      const what = clip(tool.replace(/^mcp__/, '').split('__').pop() || tool, 24)
1119      await update($, run, r => {
1120        const stepped = apply(r, classify(tool, ev))
1121        if (!live(stepped)) return stepped
1122        const asked =
1123          tool === 'AskUserQuestion'
1124            ? { ...stepped, waitingSince: at, waitingFor: 'your answer' }
1125            : asks
1126              ? { ...stepped, waitingSince: at, waitingFor: `your OK on ${what}` }
1127              : stepped
1128        return settle(tool === 'Agent' || tool === 'Task' ? { ...asked, helpers: asked.helpers + 1 } : asked, at)
1129      })
1130    } else {
1131      const agent = str(ev.agentId)
1132      const { activity } = classify(tool, ev)
1133      await update($, run, r => (live(r) ? { ...r, tools: r.tools + 1, agents: { ...r.agents, [agent]: activity } } : r))
1134    }
1135
1136    const ran = await next(e)
1137
1138    if (isMain) {
1139      const at = await $.clock.now()
1140      const isError = Boolean((ran as { isError?: boolean } | undefined)?.isError)
1141      await update($, run, r => {
1142        if (!live(r)) return r
1143        const tracked = trackTasks(tally({ ...r, waitingSince: undefined, waitingFor: undefined }, tool, ev, isError), tool, ev, at)
1144        if (!live(tracked)) return tracked
1145        // A failed step: Clawd reacts, and keeps the look while Claude regroups.
1146        if (isError) {
1147          return settle({ ...tracked, activity: 'oops', note: `Oops, ${clip(tool.replace(/^mcp__/, '').split('__').pop() || tool, 24)} hit a snag. Regrouping…` }, at)
1148        }
1149        // An answered question hands the floor back to Claude.
1150        return settle(tool === 'AskUserQuestion' ? { ...tracked, activity: 'think', note: pick('think', String(at)) } : tracked, at)
1151      })
1152    }
1153    return ran
1154  })
1155
1156
1157  // Every model request: tokens and cost for the run, context for the main loop.
1158  on('turn.step', async function* ($, e, next) {
1159    const ev = e as unknown as Record<string, unknown>
1160    // Back to the model between actions: Clawd thinks, unless it's still
1161    // reacting to a snag or waiting on you.
1162    if (!ev.agentId) {
1163      await update($, run, r =>
1164        live(r) && r.tools > 0 && r.activity !== 'oops' && r.activity !== 'wait'
1165          ? { ...r, activity: 'think', note: pick('think', `${r.title}${r.tools}`) }
1166          : r,
1167      )
1168    }
1169    const result = yield* next(e)
1170    const usage = (result as { usage?: Usage } | undefined)?.usage
1171    if (!usage) return result
1172
1173    const model = usage.model || str(ev.model)
1174    await update($, run, r => {
1175      if (!live(r)) return r
1176      const base = { ...r, tokens: r.tokens + sumUsage(usage), cost: r.cost + costOf(model, usage) }
1177      if (ev.agentId) return base
1178      const isFirstThought = r.activity === 'order' && r.tools === 0
1179      return {
1180        ...base,
1181        model,
1182        ctxTokens: sumUsage(usage),
1183        ctxMax: windowOf(model),
1184        ...(isFirstThought ? { activity: 'think' as Activity, note: pick('think', r.title) } : {}),
1185      }
1186    })
1187    if (!ev.agentId && !ctxWarned) {
1188      const r = await read($, run)
1189      if (r && r.ctxTokens / r.ctxMax >= CTX_WARN) {
1190        ctxWarned = true
1191        await $.ui.toast(`Clawd: context is ${Math.round((r.ctxTokens / r.ctxMax) * 100)}% full. A fresh conversation keeps the next task sharp.`)
1192      }
1193    }
1194    return result
1195  })
1196
1197  // Plan usage: the 5-hour and weekly windows, from the last API response.
1198  on('session.measure', async ($, e, next) => {
1199    const ran = await next(e)
1200    if (!e.changed.includes('rateLimits')) return ran
types/index.d.ts 101 lines
1export type Activity =
2  | 'order'
3  | 'read'
4  | 'search'
5  | 'explore'
6  | 'web'
7  | 'plan'
8  | 'think'
9  | 'cook'
10  | 'build'
11  | 'paint'
12  | 'quill'
13  | 'tinker'
14  | 'test'
15  | 'terminal'
16  | 'install'
17  | 'git'
18  | 'juggle'
19  | 'wait'
20  | 'deliver'
21  | 'oops'
22
23export type RunStatus = 'running' | 'done' | 'stopped'
24
25export type Run = {
26  title: string
27  startedAt: number
28  endedAt?: number
29  /** Stage Clawd is in now: 0 Reading, 1 Prepping, 2 Cooking, 3 Taste test, 4 Delivered. */
30  stage: number
31  /** Furthest stage reached this run; the tracker never fills backwards. */
32  reached: number
33  activity: Activity
34  note: string
35  tools: number
36  helpers: number
37  tasksDone: number
38  tasksTotal: number
39  /** When the task list first appeared; the ETA is measured from here. */
40  tasksSince?: number
41  taskStatus: Record<string, string>
42  tokens: number
43  cost: number
44  ctxTokens: number
45  ctxMax: number
46  model: string
47  status: RunStatus
48  /** Where the fill lands, 0..1 across all five tracks. */
49  pos: number
50  /** Where the fill was drawn when it last started sliding toward `pos`. */
51  prevPos: number
52  /** When that slide started. */
53  posAt: number
54  /** The furthest stage celebrated so far. */
55  lastStage: number
56  /** When Clawd last celebrated a stage up. */
57  cheerAt?: number
58  /** Files edited or written this run, for the receipt. */
59  files: string[]
60  /** Lines written and replaced, counted from the edits. */
61  added: number
62  removed: number
63  /** How the last test, build or lint command went. */
64  lastTest?: 'pass' | 'fail'
65  /** When Claude started waiting on you (a question or a permission prompt). */
66  waitingSince?: number
67  waitingFor?: string
68  /** Subagents at work, by id, with what each is doing. */
69  agents: Record<string, Activity>
70}
71
72/** A delivered run, kept across sessions to estimate how long work takes. */
73export type Past = {
74  ms: number
75  tools: number
76  tasks: number
77  /** When it was delivered; counts today's orders. */
78  when?: number
79  tokens?: number
80  /** The directory it ran in; the ETA prefers a project's own history. */
81  project?: string
82}
83
84/** A plan usage window: `five_hour`, `seven_day`, or a gateway's `spend_limit`. */
85export type Limit = {
86  kind: string
87  percentUsed: number
88}
89
90declare module 'claude-code' {
91  interface PluginState {
92    'clawd-tracker': {
93      run: Run | null
94      now: number
95      view: { isHidden: boolean }
96      history: Past[]
97      limits: Limit[]
98    }
99  }
100}
101