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…

Your Claude Code task, tracked like a pizza order.
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.
![]()
| Claude is… | Clawd… | Stage |
|---|---|---|
| Starting your request | holds up the order ticket | Reading |
| Reading a file | puts on reading glasses, turns the pages | Reading |
Searching code (Grep) | sweeps a detective's magnifier | Reading |
Listing files (Glob) | ties on a bandana, scans with a spyglass | Reading |
| On the web / in a browser | rides a surfboard | Reading |
| Writing a task list or plan | scribbles on a clipboard | Prepping |
| Thinking between steps | gets a thought bubble | (stays put) |
| Editing code | flips an omelette in a chef's hat | Cooking |
| Creating a file | swings a hammer in a hard hat | Cooking |
| Editing CSS / SVG | paints in a beret, the brush changes color | Cooking |
Editing docs (.md, .txt) | writes with a quill | Cooking |
| Editing config / other tools | turns a wrench, a gear spins | Cooking |
| Installing packages | hauls a parcel on its head | Cooking |
| Running subagents | juggles, while a mini Clawd per helper shows what each is doing | Cooking |
| Running tests, builds, linters | swirls a bubbling flask in lab goggles | Taste test |
| Running other commands | types on a tiny laptop | (stays put) |
| Using git / gh | delivers a stamped envelope in a mail cap | (stays put) |
| Asking you a question, or waiting for your OK | holds up a "?" sign; the bar says "Waiting on you" | (stays put) |
| Done | party hat, confetti, pizza box | Delivered |
| A step fails | drops the pizza: "Bash hit a snag. Regrouping…" | (stays put) |
| Interrupted | drops the pizza | Stopped |
Each activity has a few verbs that rotate ("Simmering ThemeToggle.tsx", "Seasoning api.ts"), so the line never reads the same twice.
Claude Code can't know how far along a task is, so the bar only moves on real events:
TodoWrite or the task tools), the fill is the share of tasks done and slides forward each time one finishes.git and other shell commands don't move it.The top right shows the job size (Small, Medium or Large, from the task count or steps so far) and an estimate:
Same Clawd, different shop. Switch any time with /tracker theme <name>, for example /tracker theme rocket; /tracker theme lists them:
| Theme | Stages |
|---|---|
pizza (default) | Reading → Prepping → Cooking → Taste test → Delivered |
coffee | Grinding → Brewing → Pouring → Tasting → Served |
rocket | Fueling → Countdown → Liftoff → Systems check → In orbit |
construction | Surveying → Blueprints → Building → Inspection → Handed over |
![]()
Change the theme and sound with the commands above, or any setting in Claude Code's /config menu (terminal) or settings.json under pluginConfigs:
| Setting | Default | What it does |
|---|---|---|
theme | pizza | Stage names: pizza, coffee, rocket or construction |
sound | on | The delivery ding |
showCost | on | Show the ≈$ estimate |
showPlanUsage | on | Show the 5h / weekly usage and the 90% heads-up |
/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).
The tracker only watches; it never blocks, rewrites or answers anything for Claude Code. Every hook passes the event on unchanged.
| Event | What the tracker does with it |
|---|---|
session.start | Registers /tracker, loads your past job times (for the ETA) and starts a 1-second clock while a job runs |
prompt.submit | Keeps the first line of your prompt as the order's title |
turn.start / turn.complete | Opens and delivers the order; a subagent's turn adds or removes its mini Clawd |
tool.call | Picks 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.step | Adds up tokens and the estimated cost, reads how full the context is, and shows the thought bubble between steps |
session.measure | Reads 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.
claude plugin validate plugins/clawd-tracker
claude plugin test plugins/clawd-trackerhooks/register.tsx 1325 lines1import { 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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 rantypes/index.d.ts 101 lines1export 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