Watcher: a band above the prompt for background tasks Claude starts (API calls, LLM runs, experiments, tests...), each with its category, tags and progress…

Part of Watcher. A band above the prompt shows the background tasks Claude starts, each sorted into a category (API calls, LLM run, Experiment, Tests, Build, Server, Install, Data, Script) with tags and a cost tag. Press r for the panel. The band hides 10 minutes after the last task finishes.
Watcher draws on screen only. It sends nothing anywhere, makes no network calls, starts no programs, and adds nothing to the model's context.
| Hook | What it does |
|---|---|
tool.call (Bash) | Reads the result of a Bash call Claude made, to spot a background task's ID and output file. The call and its result pass through unchanged. |
prompt.submit | Reads a task-notification (task ID, status, exit code) to mark a task done or failed, and lifts the pause key (h). The prompt passes through unchanged. |
classic.Stop, classic.SubagentStop | Read the list of background tasks as a backup, so tasks started by subagents finish too. Nothing is changed. |
session.start, ui.* | Restore the band's own state and draw the band and panel. |
Files read: the last few lines of a task's own output file, read with the file API. This happens only while the panel is open, or once when a task ends. Those lines provide the progress and cost tag, and they stay on screen.
Commands: the category words in the source (tool and package-manager names) are patterns matched against the command text, to choose a label. run-watcher never runs them.
open log ↗: a plain file:// link to the output file. Your system opens it when you click it.
Settings: lingerMinutes (10), bandRows (2) and extraPatterns (your own category words).
hooks/register.tsx 831 lines1// Run watcher (part of Watcher): the background tasks Claude starts, each named by its category and
2// tags, with a sweep while it runs and a filled or failed track when it ends. Output files are read
3// only when they are shown (the panel is open, or once when a task ends), never on a timer behind
4// your back, and nothing here reaches the model.
5import { atom, read, update } from 'claude-code'
6import type { Register } from 'claude-code'
7
8import type { RunRow } from '../types'
9
10// Set from the plugin's settings when it loads (see plugin.json userConfig).
11let LINGER_MS = 10 * 60 * 1000
12let BAND_ROWS = 2
13// Extra command patterns per category, from settings: "Experiment: run_thesis; API calls: my_batch".
14let EXTRA: { name: string; match: RegExp }[] = []
15const PANE = 'run-watcher-list'
16const CHIP_COLS = 8
17const CHIP_PX = CHIP_COLS * 8 + 16
18// A new task reads "Background task" for this long before its category takes over.
19const INTRO_MS = 1200
20const PANEL_REFRESH_MS = 3000
21const TAIL_LINES = 6
22
23// The last few non-blank lines of a task's output file, read directly (no program is started).
24async function lastLines($: { fs: { read: (path: string) => Promise<unknown> } }, path: string): Promise<string[] | null> {
25 const text = await $.fs.read(path).catch(() => null)
26 return typeof text === 'string' ? text.split('\n').filter(l => l.trim()).slice(-TAIL_LINES) : null
27}
28
29const rows = atom({ plugin: 'run-watcher', key: 'rows' } as const, [] as RunRow[])
30const tick = atom({ plugin: 'run-watcher', key: 'tick' } as const, 0)
31const isPanelOpen = atom({ plugin: 'run-watcher', key: 'isPanelOpen' } as const, false)
32// The pause key (h, in any Watcher band) hides every band until the next prompt.
33const isPaused = atom({ plugin: 'run-watcher', key: 'isPaused' } as const, false)
34
35// ---------------------------------------------------------------------------
36// Categories: what a task is, from its command and the description Claude gave it. Each has a
37// color and tag rules that say what is happening inside it (provider, kind of run, tool).
38
39type Category = { name: string; color: string; match: RegExp; tags: [RegExp, string][] }
40
41const CATEGORIES: Category[] = [
42 {
43 name: 'API calls',
44 color: '#D97757',
45 match: /\b(anthropic|openai|gemini|claude|gpt|mistral|cohere|groq|together|openrouter|bedrock|vertex|api|batch(es)?|requests|httpx|http)\b|https?:\/\//i,
46 tags: [
47 [/anthropic|claude/i, 'anthropic'],
48 [/openai|gpt/i, 'openai'],
49 [/gemini|vertex/i, 'gemini'],
50 [/openrouter/i, 'openrouter'],
51 [/bedrock/i, 'bedrock'],
52 [/batch/i, 'batch'],
53 [/eval|judge|grade|score/i, 'eval'],
54 [/generat|synth|complet/i, 'generate'],
55 [/embed/i, 'embeddings'],
56 [/scrap|crawl/i, 'scrape'],
57 ],
58 },
59 {
60 name: 'LLM run',
61 color: '#8F8CF4',
62 match: /\b(train|training|finetun\w*|fine-tun\w*|lora|qlora|sft|dpo|rlhf|infer\w*|vllm|ollama|llama\.?cpp|torchrun|accelerate|deepspeed|transformers|unsloth|mlx_lm)\b/i,
63 tags: [
64 [/finetun|fine-tun|sft|lora|qlora|unsloth/i, 'fine-tuning'],
65 [/\btrain/i, 'training'],
66 [/dpo|rlhf|ppo|grpo/i, 'preference'],
67 [/infer|generate|serve/i, 'inference'],
68 [/ollama/i, 'ollama'],
69 [/vllm/i, 'vllm'],
70 [/llama\.?cpp/i, 'llama.cpp'],
71 [/mlx/i, 'mlx'],
72 [/torchrun|accelerate|deepspeed/i, 'distributed'],
73 ],
74 },
75 {
76 name: 'Experiment',
77 color: '#D65DB1',
78 match: /\b(experiment|exp_|run_exp|sweep|ablation|benchmark|bench|eval\w*|simulat\w*|sim|carla|gazebo|ros2?|wandb|mlflow|optuna)\b/i,
79 tags: [
80 [/sweep|optuna|grid/i, 'sweep'],
81 [/ablation/i, 'ablation'],
82 [/bench/i, 'benchmark'],
83 [/eval/i, 'eval'],
84 [/simulat|\bsim\b|carla|gazebo|ros/i, 'simulation'],
85 [/wandb|mlflow/i, 'tracked'],
86 ],
87 },
88 {
89 name: 'Tests',
90 color: '#3B9C5F',
91 match: /\b(pytest|unittest|jest|vitest|mocha|cargo test|go test|npm (run )?test|pnpm test|yarn test|tox|nox|test)\b/i,
92 tags: [
93 [/pytest|unittest|tox|nox/i, 'python'],
94 [/jest|vitest|mocha|npm|pnpm|yarn/i, 'js'],
95 [/cargo/i, 'rust'],
96 [/go test/i, 'go'],
97 [/integration|e2e/i, 'integration'],
98 [/unit/i, 'unit'],
99 [/coverage|--cov/i, 'coverage'],
100 ],
101 },
102 {
103 name: 'Build',
104 color: '#E0703A',
105 match: /\b(make|cmake|ninja|docker (build|compose)|npm run build|pnpm build|yarn build|cargo build|gradle|mvn|xcodebuild|tsc|webpack|vite build)\b/i,
106 tags: [
107 [/docker/i, 'docker'],
108 [/cmake|make|ninja/i, 'native'],
109 [/npm|pnpm|yarn|vite|webpack|tsc/i, 'js'],
110 [/cargo/i, 'rust'],
111 [/xcodebuild/i, 'xcode'],
112 ],
113 },
114 {
115 name: 'Server',
116 color: '#5B8DEF',
117 match: /\b(dev|serve|server|uvicorn|gunicorn|flask|fastapi|django|runserver|http\.server|jupyter|streamlit|gradio|next dev|vite)\b/i,
118 tags: [
119 [/uvicorn|fastapi/i, 'fastapi'],
120 [/flask/i, 'flask'],
121 [/django|runserver/i, 'django'],
122 [/jupyter/i, 'jupyter'],
123 [/streamlit|gradio/i, 'ui'],
124 [/next|vite|npm|pnpm/i, 'js'],
125 ],
126 },
127 {
128 name: 'Install',
129 color: '#9A9A96',
130 match: /\b(pip3? install|uv (pip|sync|add)|poetry install|conda (install|create)|npm (i|install|ci)|pnpm (i|install)|yarn( install)?|brew install|apt(-get)? install|cargo install)\b/i,
131 tags: [
132 [/pip|uv |poetry/i, 'python'],
133 [/conda/i, 'conda'],
134 [/npm|pnpm|yarn/i, 'js'],
135 [/brew/i, 'brew'],
136 [/apt/i, 'apt'],
137 ],
138 },
139 {
140 name: 'Data',
141 color: '#3FA7D6',
142 match: /\b(download|rsync|scp|aws s3|gsutil|huggingface-cli|hf download|preprocess\w*|etl|convert|extract|dataset|unzip|tar)\b/i,
143 tags: [
144 [/download|huggingface|hf download/i, 'download'],
145 [/rsync|scp|s3|gsutil/i, 'sync'],
146 [/preprocess|etl|convert|clean/i, 'preprocess'],
147 [/unzip|tar|extract/i, 'extract'],
148 ],
149 },
150]
151
152const SCRIPT: Category = {
153 name: 'Script',
154 color: '#E0A526',
155 match: /.*/,
156 tags: [
157 [/\bpython3?\b|\.py\b/i, 'python'],
158 [/\bnode\b|\.m?js\b|\bnpx\b/i, 'node'],
159 [/\bbash\b|\.sh\b|\bzsh\b/i, 'shell'],
160 ],
161}
162
163const categoryOf = (name: string) => CATEGORIES.find(c => c.name === name) ?? SCRIPT
164
165const classify = (command: string, description: string) => {
166 const text = `${command} ${description}`
167 const extra = EXTRA.find(x => x.match.test(text))
168 const category = (extra && CATEGORIES.find(c => c.name === extra.name)) || CATEGORIES.find(c => c.match.test(text)) || SCRIPT
169 const tags = [...new Set(category.tags.filter(([re]) => re.test(text)).map(([, tag]) => tag))].slice(0, 3)
170 return { category: category.name, tags }
171}
172
173// Progress from a log's last lines: a percentage (tqdm "45%"), else a count ("epoch 3/10").
174const percentOf = (lines: string[]) => {
175 for (const line of [...lines].reverse()) {
176 const pct = [...line.matchAll(/(\d{1,3}(?:\.\d+)?)\s?%/g)].map(m => Number(m[1])).filter(n => n >= 0 && n <= 100)
177 if (pct.length) {
178 return pct[pct.length - 1] ?? null
179 }
180 const count = /\b(\d+)\s?\/\s?(\d+)\b/.exec(line)
181 if (count) {
182 const a = Number(count[1])
183 const b = Number(count[2])
184 if (b > 1 && a <= b) {
185 return (100 * a) / b
186 }
187 }
188 }
189 return null
190}
191
192// What an API run spent, when its log prints it: the last token count and dollar cost seen.
193const spendOf = (lines: string[]) => {
194 const text = lines.join('\n')
195 const tokens = [...text.matchAll(/([\d][\d,]*(?:\.\d+)?)\s*([kKmM])?\s*(?:total\s+)?tokens/g)].pop()
196 const cost = [...text.matchAll(/\$\s?(\d+(?:\.\d+)?)/g)].pop()
197 const n = tokens ? Number((tokens[1] ?? '').replace(/,/g, '')) * (/k/i.test(tokens[2] ?? '') ? 1e3 : /m/i.test(tokens[2] ?? '') ? 1e6 : 1) : null
198 const tok = n === null || !Number.isFinite(n) ? '' : n >= 1e6 ? `${(n / 1e6).toFixed(1)}M tokens` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k tokens` : `${n} tokens`
199 const usd = cost ? `$${Number(cost[1]).toFixed(2)}` : ''
200 return [tok, usd].filter(Boolean).join(' · ') || null
201}
202
203// The path Claude Code writes a background command's output to, as its result names it.
204const outputPathOf = (text: string) => /(\/[^\s'"`]+\.(?:output|log|txt))/.exec(text)?.[1] ?? null
205
206// ---------------------------------------------------------------------------
207// Drawing (savvy-progress style; shared look with agent watcher).
208
209const DONE = '#3B9C5F'
210const FAILED = '#D0453F'
211const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
212const MONO = "'SF Mono',ui-monospace,Menlo,monospace"
213
214const xml = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
215
216const textWidth = (s: string, size: number) =>
217 [...s].reduce((w, ch) => w + (/[\s.,:;'|!il1()]/.test(ch) ? 0.3 : /[A-Zmw@%]/.test(ch) ? 0.72 : 0.56) * size, 0)
218
219const fitText = (s: string, size: number, maxW: number) => {
220 if (textWidth(s, size) <= maxW) {
221 return s
222 }
223 let out = ''
224 for (const ch of s) {
225 if (textWidth(out + ch + '…', size) > maxW) {
226 break
227 }
228 out += ch
229 }
230 return out + '…'
231}
232
233const formatElapsed = (ms: number) => {
234 const s = Math.max(0, Math.round(ms / 1000))
235 if (s < 60) {
236 return `${s}s`
237 }
238 const m = Math.floor(s / 60)
239 return m < 60 ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
240}
241
242// The Watcher track: a thin pixel bar adapted from savvy-progress by JohnnyVizz (MIT,
243// github.com/JohnnyVizz/claude-kit). Cells sit on a fixed noise pattern and only twinkle in
244// opacity, in four out-of-phase groups: nothing moves, nothing is blurred, so it never jitters,
245// and its source changes only with the status. The band never shows progress: running twinkles
246// across the whole width, done settles to a slow glow, failed is a still red bar. Only an expanded
247// panel passes a real percentage (2% or more), and the pixels then fill up to it.
248const noise = (x: number, y: number) => {
249 const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
250 return s - Math.floor(s)
251}
252const CELL = 3
253
254const glowTrack = (id: string, x: number, y: number, w: number, color: string, status: 'running' | 'done' | 'failed', percent: number | null = null) => {
255 const tint = status === 'failed' ? FAILED : color
256 const cols = Math.floor(w / CELL)
257 const isProgress = percent !== null && percent >= 2 && status === 'running'
258 const shown = isProgress ? Math.round((cols * Math.min(100, percent as number)) / 100) : cols
259 // Running: dots on a fixed noise pattern; finished, failed or real progress: every dot lit.
260 const density = status === 'running' && !isProgress ? 0.62 : 1
261 const cells: string[] = []
262 for (let c = 0; c < shown; c++) {
263 for (let r = 0; r < 2; r++) {
264 if (noise(c, r) < density) {
265 cells.push(`<rect class="t${Math.floor(noise(r, c) * 4)}" x="${x + c * CELL}" y="${y - 1 + r * CELL}" width="2" height="2"/>`)
266 }
267 }
268 }
269 const rest = shown < cols ? `<rect class="k" x="${x + shown * CELL}" y="${y + 0.5}" width="${w - shown * CELL}" height="2" rx="1"/>` : ''
270 const mode = status === 'running' ? (isProgress ? 'pg' : 'tw') : status === 'done' ? 'tw-done' : 'still'
271 return `${rest}<g class="${mode}" fill="${tint}" data-id="${id}">${cells.join('')}</g>`
272}
273
274// Opacity only, on one image whose source never changes while it runs, so nothing jitters. The
275// dots breathe slowly in four out-of-phase groups (3.6–5.2 s, never below 45%), calm rather than
276// flickery. Done: every dot lit with a slow glow. Failed: every dot lit red, still.
277// Panel bars: the same dots over a soft, still glow in the bar's color. The glow covers the lit part
278// (all of it, or up to the real percentage), never moves, so it reads as light, not flicker.
279const halo = (id: string, x: number, y: number, w: number, color: string, status: 'running' | 'done' | 'failed', percent: number | null) => {
280 const tint = status === 'failed' ? FAILED : color
281 const lit = percent !== null && percent >= 2 && status === 'running' ? (w * Math.min(100, percent)) / 100 : w
282 const strength = status === 'running' ? 0.45 : status === 'done' ? 0.55 : 0.35
283 return `<defs><filter id="h${id}" x="-5%" y="-150%" width="110%" height="400%"><feGaussianBlur stdDeviation="2.2"/></filter></defs>
284<rect x="${x}" y="${y - 1}" width="${lit}" height="5" rx="2.5" fill="${tint}" opacity="${strength}" filter="url(#h${id})"/>`
285}
286
287const TWINKLE_CSS = `.tw .t0,.tw .t1,.tw .t2,.tw .t3{animation:tw 4.2s ease-in-out infinite}
288.tw .t1{animation-duration:5.2s;animation-delay:-1.3s}.tw .t2{animation-duration:3.6s;animation-delay:-2.4s}.tw .t3{animation-duration:4.8s;animation-delay:-.8s}
289.tw-done{animation:twd 4.8s ease-in-out infinite}
290@keyframes tw{0%,100%{opacity:1}50%{opacity:.45}}@keyframes twd{0%,100%{opacity:1}50%{opacity:.78}}
291@media (prefers-reduced-motion: reduce){.tw rect,.tw-done{animation:none}}`
292
293const BASE_CSS = `<style>
294.t{fill:#1f1f1f}.s{fill:#6b6b68}.k{fill:#ecebe8}.ln{stroke:#e4e4e1}.tile{fill:#f4f3f0}.mono{fill:#55554f}
295@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.k{fill:#2c2c2b}.ln{stroke:#333331}.tile{fill:#262625}.mono{fill:#b9b9b4}}
296.sweep{animation:sw 2s ease-in-out infinite}@keyframes sw{0%{transform:translateX(-30%)}100%{transform:translateX(130%)}}
297.live{animation:p 1s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
298.cur{animation:cb 1s steps(1) infinite}@keyframes cb{50%{opacity:0}}
299.fill{transform-box:fill-box;transform-origin:left;animation:gr .6s ease-out 1}@keyframes gr{from{transform:scaleX(0)}}
300.glow{animation:gw 2.4s ease-in-out infinite}@keyframes gw{50%{opacity:.25}}
301.fail{animation:fl .45s ease-in-out 3}@keyframes fl{50%{opacity:.25}}
302.in{animation:fi .5s ease-out 1}@keyframes fi{from{opacity:0;transform:translateY(3px)}}
303@media (prefers-reduced-motion: reduce){.sweep,.live,.cur,.fill,.fail,.glow,.in{animation:none}}
304${TWINKLE_CSS}
305</style>`
306
307const svg = (W: number, H: number, body: string) =>
308 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${BASE_CSS}${body}</svg>`
309
310// The task's mark: a rounded tile in its category color with a prompt chevron and a cursor that
311// blinks while it runs. Its source changes only with the status, so the blink never restarts.
312const icon = (x: number, y: number, row: RunRow, size: number) => {
313 const color = row.status === 'failed' ? FAILED : categoryOf(row.category).color
314 const s = size / 24
315 const cursor = row.status === 'running' ? `<rect class="cur" x="13" y="14" width="6" height="2" rx="1" fill="#fff"/>` : ''
316 const badge =
317 row.status === 'failed'
318 ? `<path d="M15 13l4 4M19 13l-4 4" stroke="#fff" stroke-width="1.6" stroke-linecap="round"/>`
319 : row.status === 'done'
320 ? `<path d="M13.5 15l2 2 3.5-4" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>`
321 : cursor
322 return `<g transform="translate(${x},${y}) scale(${s})"><rect width="24" height="24" rx="6" fill="${color}"/>
323<path d="M6 8l4 4-4 4" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>${badge}</g>`
324}
325
326
327const track = (id: string, x: number, y: number, w: number, row: RunRow, percent: number | null) =>
328 glowTrack(id, x, y, w, categoryOf(row.category).color, row.status, percent)
329
330const tagPills = (x: number, y: number, row: RunRow, maxW: number) => {
331 const color = categoryOf(row.category).color
332 let out = ''
333 let cx = x
334 for (const tag of row.tags) {
335 const w = textWidth(tag, 10.5) + 12
336 if (cx + w - x > maxW) {
337 break
338 }
339 out += `<rect x="${cx}" y="${y - 11}" width="${w}" height="15" rx="7.5" fill="${color}" fill-opacity=".16"/>
340<text x="${cx + 6}" y="${y}" font-family="${FONT}" font-size="10.5" fill="${color}">${xml(tag)}</text>`
341 cx += w + 4
342 }
343 return { svg: out, width: cx - x }
344}
345
346const pillsWidth = (row: RunRow) => row.tags.reduce((w, tag) => w + textWidth(tag, 10.5) + 16, 0)
347
348// The label: "Background task" while a task is new, then its category; the change fades in once.
349const labelOf = (row: RunRow, now: number) => (now - row.startedAt < INTRO_MS ? 'Background task' : row.category)
350const isLabelFresh = (row: RunRow, now: number) => {
351 const age = now - row.startedAt
352 return age >= INTRO_MS && age < INTRO_MS + 1000
353}
354
355// The band never shows progress (its track is animation only); the percentage lives in the panel.
356const statsOf = (row: RunRow, now: number, keep: number, isBand = false) =>
357 [
358 { rank: 1, text: row.category === 'API calls' ? (spendOf(row.tail) ?? '') : '' },
359 { rank: 0, text: formatElapsed((row.endedAt ?? now) - row.startedAt) },
360 { rank: 1, text: isBand || row.percent === null ? '' : `${Math.round(row.percent)}%` },
361 { rank: 2, text: row.status === 'failed' && row.exitCode !== null ? `exit ${row.exitCode}` : '' },
362 ]
363 .filter(p => p.text && p.rank < keep)
364 .map(p => p.text)
365 .join(' · ')
366
367// One band row's text: label, tags, description; stats on the right. Respaces to its width: tags
368// go first, then the description, then the label truncates.
369const bandTextSvg = (W: number, row: RunRow, now: number, more: number) => {
370 const color = row.status === 'failed' ? FAILED : categoryOf(row.category).color
371 const label = labelOf(row, now)
372 const right = statsOf(row, now, 3, true) + (more > 0 ? ` +${more}` : '')
373 const rightW = textWidth(right, 11.5)
374 const room = W - 32 - rightW - 28
375 const name = fitText(label, 12.5, Math.max(40, room))
376 const nameW = textWidth(name, 12.5) + 8
377 const showTags = label === row.category && room - nameW - pillsWidth(row) >= 90
378 const pills = showTags ? tagPills(nameW, 13, row, room - nameW) : { svg: '', width: 0 }
379 const descX = nameW + (pills.width ? pills.width + 4 : 0)
380 const descW = room - descX
381 const desc = row.description && descW >= 48 ? fitText(row.description, 12, descW) : ''
382 const fresh = isLabelFresh(row, now) ? ' class="in"' : ''
383 const mark = row.status === 'running' ? `<circle class="live" cx="${W - 37}" cy="9" r="3" fill="${color}"/>` : ''
384 return svg(
385 W - 32,
386 18,
387 `<g${fresh}><text x="0" y="13" font-family="${FONT}" font-size="12.5" font-weight="600" fill="${color}">${xml(name)}</text>${pills.svg}</g>
388<text class="s" x="${descX}" y="13" font-family="${FONT}" font-size="12">${xml(desc)}</text>
389<text class="s" x="${W - 46}" y="13" text-anchor="end" font-family="${FONT}" font-size="11.5" font-variant-numeric="tabular-nums">${xml(right)}</text>${mark}`,
390 )
391}
392
393const bandTrackSvg = (W: number, row: RunRow) => svg(W - 32, 8, track(row.id, 0, 2, W - 32, row, null))
394const bandIconSvg = (row: RunRow) => svg(28, 28, icon(2, 4, row, 20))
395
396const headerSvg = (W: number, all: RunRow[]) => {
397 const running = all.filter(r => r.status === 'running').length
398 const failed = all.filter(r => r.status === 'failed').length
399 const gap = 6
400 const tw = (W - gap * 2) / 3
401 const tile = (i: number, k: string, v: string, color?: string) =>
402 `<rect class="tile" x="${i * (tw + gap)}" y="0" width="${tw}" height="40" rx="8"/>
403<text class="s" x="${i * (tw + gap) + 9}" y="16" font-family="${FONT}" font-size="11">${k}</text>
404<text ${color ? `fill="${color}"` : 'class="t"'} x="${i * (tw + gap) + 9}" y="33" font-family="${FONT}" font-size="15" font-weight="600" font-variant-numeric="tabular-nums">${v}</text>`
405 return svg(W, 44, tile(0, 'Running', `${running}`) + tile(1, 'Finished', `${all.length - running - failed}`) + tile(2, 'Failed', `${failed}`, failed ? FAILED : undefined))
406}
407
408// A panel card: label and tags, description, the command, stats, the track (a real fill when the
409// log shows a percentage) and the last output lines.
410const paneTextSvg = (W: number, row: RunRow, now: number) => {
411 const w = W - 44
412 const color = row.status === 'failed' ? FAILED : categoryOf(row.category).color
413 const name = fitText(row.category, 13, w * 0.5)
414 const nameW = textWidth(name, 13) + 8
415 const pills = tagPills(nameW, 16, row, w - nameW - 70)
416 const stats = statsOf(row, now, 3)
417 return svg(
418 w,
419 58,
420 `<text x="0" y="16" font-family="${FONT}" font-size="13" font-weight="600" fill="${color}">${xml(name)}</text>${pills.svg}
421<text class="s" x="${w - 4}" y="16" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${xml(stats)}</text>
422<text class="t" x="0" y="34" font-family="${FONT}" font-size="12">${xml(fitText(row.description || 'Background task', 12, w - 4))}</text>
423<text class="mono" x="0" y="51" font-family="${MONO}" font-size="10.5">${xml(fitText(`$ ${row.command}`, 10.5, w - 4))}</text>`,
424 )
425}
426
427const paneTrackSvg = (W: number, row: RunRow) =>
428 svg(W - 44, 14, `${halo(`p${row.id}`, 0, 5, W - 52, categoryOf(row.category).color, row.status, row.status === 'running' ? row.percent : null)}${track(`p${row.id}`, 0, 5, W - 52, row, row.status === 'running' ? row.percent : null)}`)
429
430const paneTailSvg = (W: number, row: RunRow) => {
431 const w = W - 44
432 const lines = row.tail.slice(-TAIL_LINES)
433 const H = lines.length * 14 + 12
434 return svg(
435 w,
436 H,
437 `${lines.map((l, i) => `<text class="mono" x="0" y="${12 + i * 14}" font-family="${MONO}" font-size="10">${xml(fitText(l, 10, w - 4))}</text>`).join('')}
438<line class="ln" x1="0" y1="${H - 0.5}" x2="${w}" y2="${H - 0.5}"/>`,
439 )
440}
441const paneIconSvg = (row: RunRow) => svg(44, 44, icon(4, 8, row, 28))
442
443// Terminal fallback: a colored mark, a block bar and plain text.
444const barText = (row: RunRow, t: number, width: number) => {
445 if (row.status !== 'running') {
446 return '█'.repeat(width)
447 }
448 const span = width * 2 - 2
449 const pos = t % span
450 const head = pos < width ? pos : span - pos
451 return Array.from({ length: width }, (_, i) => {
452 const d = Math.abs(i - head)
453 return d === 0 ? '█' : d === 1 ? '▓' : d === 2 ? '▒' : '░'
454 }).join('')
455}
456
457const ordered = (all: RunRow[]) =>
458 [...all].sort((a, b) => {
459 const aRunning = a.status === 'running'
460 const bRunning = b.status === 'running'
461 if (aRunning !== bRunning) {
462 return aRunning ? -1 : 1
463 }
464 return b.startedAt - a.startedAt
465 })
466
467// ---------------------------------------------------------------------------
468
469export const register: Register = (on, options) => {
470 LINGER_MS = Math.max(1, Number(options.lingerMinutes ?? 10)) * 60 * 1000
471 BAND_ROWS = Math.max(1, Math.min(4, Number(options.bandRows ?? 2)))
472 EXTRA = String(options.extraPatterns ?? '')
473 .split(';')
474 .map(part => part.split(':'))
475 .filter(([name, words]) => name && words)
476 .map(([name, words]) => ({
477 name: (name ?? '').trim(),
478 match: new RegExp(`(${(words ?? '').split(',').map(w => w.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).filter(Boolean).join('|')})`, 'i'),
479 }))
480 .filter(x => x.match.source !== '()')
481
482 let pulse: { cancel: () => void } | null = null
483 let clear: { cancel: () => void } | null = null
484 let refresh: { cancel: () => void } | null = null
485
486 on('session.start', async ($, e, next) => {
487 const result = await next(e)
488 try {
489 const all = await read($, rows)
490 if (all.some(r => r.status === 'running')) {
491 pulse = $.clock.every(1000, () => {
492 void update($, tick, t => t + 1).catch(() => {})
493 })
494 } else if (all.length > 0) {
495 clear = $.clock.after(LINGER_MS, () => {
496 clear = null
497 void update($, rows, list => list.filter(r => r.status === 'running')).catch(() => {})
498 void update($, isPanelOpen, () => false).catch(() => {})
499 void $.ui.close({ id: PANE }).catch(() => {})
500 })
501 }
502 } catch {}
503 return result
504 })
505
506 // A Bash call that went to the background (asked for, ctrl+b, or a timeout) becomes a task.
507 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
508 const result = await next(e)
509 try {
510 const out = result.result as { backgroundTaskId?: string } | undefined
511 const id = out?.backgroundTaskId
512 if (id) {
513 const command = e.command.trim()
514 const description = e.description ?? ''
515 const row: RunRow = {
516 id,
517 command,
518 description,
519 ...classify(command, description),
520 startedAt: await $.clock.now(),
521 endedAt: null,
522 status: 'running',
523 exitCode: null,
524 outputPath: outputPathOf(result.text ?? ''),
525 percent: null,
526 tail: [],
527 }
528 clear?.cancel()
529 clear = null
530 await update($, rows, all => [...all.filter(r => r.id !== id), row])
531 // Only the elapsed time ticks; the sweep and cursor animate in CSS.
532 if (!pulse) {
533 pulse = $.clock.every(1000, () => {
534 void update($, tick, t => t + 1).catch(() => {})
535 })
536 }
537 }
538 } catch {}
539 return result
540 }).catch(($, e, next) => next(e)) // display only: a failure never blocks Claude Code
541
542 on('ui.press', { element: 'watcher-pause' }, async ($, e, next) => {
543 await update($, isPaused, () => true).catch(() => {})
544 return next(e)
545 }).catch(($, e, next) => next(e))
546
547 // A background task ended: Claude Code tells the session with a task notification.
548 on('prompt.submit', async ($, e, next) => {
549 try {
550 if (e.origin?.kind !== 'task-notification') {
551 await update($, isPaused, () => false)
552 }
553 if (e.origin?.kind === 'task-notification') {
554 const id = /<task-id>([^<]+)<\/task-id>/.exec(e.text)?.[1]?.trim()
555 const status = /<status>([^<]+)<\/status>/.exec(e.text)?.[1]?.trim() ?? ''
556 const code = /exit(?:ed with)? code[:\s]+(-?\d+)/i.exec(e.text)?.[1]
557 const path = /<output-file>([^<]+)<\/output-file>/.exec(e.text)?.[1]?.trim()
558 const all = await read($, rows)
559 const row = all.find(r => r.id === id)
560 if (row && row.status === 'running') {
561 const exitCode = code === undefined ? null : Number(code)
562 const isFailed = /fail|kill|error|stop/i.test(status) || (exitCode !== null && exitCode !== 0)
563 const now = await $.clock.now()
564 // Read its output once now that it has ended, for the final line and percentage.
565 let tail = row.tail
566 const file = path ?? row.outputPath
567 if (file) {
568 const r = await lastLines($, file)
569 if (r) {
570 tail = r
571 }
572 }
573 const percent = percentOf(tail)
574 await update($, rows, list =>
575 list.map(x =>
576 x.id === row.id
577 ? { ...x, status: (isFailed ? 'failed' : 'done') as RunRow['status'], exitCode, endedAt: now, tail, percent: isFailed ? percent : (percent ?? 100), outputPath: file ?? null }
578 : x,
579 ),
580 )
581 if (!(await read($, rows)).some(r => r.status === 'running')) {
582 pulse?.cancel()
583 pulse = null
584 clear?.cancel()
585 clear = $.clock.after(LINGER_MS, () => {
586 clear = null
587 void update($, rows, list => list.filter(r => r.status === 'running')).catch(() => {})
588 void update($, isPanelOpen, () => false).catch(() => {})
589 void $.ui.close({ id: PANE }).catch(() => {})
590 })
591 }
592 }
593 }
594 } catch {}
595 return next(e)
596 }).catch(($, e, next) => next(e)) // display only: a failure never blocks Claude Code
597
598 // A task a subagent started tells that subagent when it ends, not this session. Every time the
599 // session or a subagent stops, Claude Code lists the background work still in flight: a running
600 // row that is no longer listed has ended (its exit code is unknown, so it is shown as finished).
601 on('classic.Stop', async ($, e, next) => {
602 const result = await next(e)
603 try {
604 const live = (e as { background_tasks?: { id: string }[] }).background_tasks
605 if (Array.isArray(live)) {
606 const ids = new Set(live.map(t => t.id))
607 const now = await $.clock.now()
608 const ended = (await read($, rows)).filter(r => r.status === 'running' && !ids.has(r.id) && now - r.startedAt > 3000)
609 for (const r of ended) {
610 let tail = r.tail
611 if (r.outputPath) {
612 const res = await lastLines($, r.outputPath)
613 if (res) {
614 tail = res
615 }
616 }
617 await update($, rows, xs => xs.map(x => (x.id === r.id ? { ...x, status: 'done' as RunRow['status'], endedAt: now, tail, percent: percentOf(tail) ?? 100 } : x)))
618 }
619 if (ended.length && !(await read($, rows)).some(r => r.status === 'running')) {
620 pulse?.cancel()
621 pulse = null
622 clear?.cancel()
623 clear = $.clock.after(LINGER_MS, () => {
624 clear = null
625 void update($, rows, list => list.filter(r => r.status === 'running')).catch(() => {})
626 void update($, isPanelOpen, () => false).catch(() => {})
627 void $.ui.close({ id: PANE }).catch(() => {})
628 })
629 }
630 }
631 } catch {}
632 return result
633 }).catch(($, e, next) => next(e))
634 on('classic.SubagentStop', async ($, e, next) => {
635 const result = await next(e)
636 try {
637 const live = (e as { background_tasks?: { id: string }[] }).background_tasks
638 if (Array.isArray(live)) {
639 const ids = new Set(live.map(t => t.id))
640 const now = await $.clock.now()
641 const ended = (await read($, rows)).filter(r => r.status === 'running' && !ids.has(r.id) && now - r.startedAt > 3000)
642 for (const r of ended) {
643 let tail = r.tail
644 if (r.outputPath) {
645 const res = await lastLines($, r.outputPath)
646 if (res) {
647 tail = res
648 }
649 }
650 await update($, rows, xs => xs.map(x => (x.id === r.id ? { ...x, status: 'done' as RunRow['status'], endedAt: now, tail, percent: percentOf(tail) ?? 100 } : x)))
651 }
652 if (ended.length && !(await read($, rows)).some(r => r.status === 'running')) {
653 pulse?.cancel()
654 pulse = null
655 clear?.cancel()
656 clear = $.clock.after(LINGER_MS, () => {
657 clear = null
658 void update($, rows, list => list.filter(r => r.status === 'running')).catch(() => {})
659 void update($, isPanelOpen, () => false).catch(() => {})
660 void $.ui.close({ id: PANE }).catch(() => {})
661 })
662 }
663 }
664 } catch {}
665 return result
666 }).catch(($, e, next) => next(e))
667
668 on('ui.close', async ($, e, next) => {
669 if (e.id === PANE) {
670 refresh?.cancel()
671 refresh = null
672 await update($, isPanelOpen, () => false).catch(() => {})
673 }
674 return next(e)
675 }).catch(($, e, next) => next(e)) // display only: a failure never blocks Claude Code
676
677 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
678 const all = await read($, rows)
679 if (e.props.hasSurvey || all.length === 0 || (await read($, isPanelOpen)) || (await read($, isPaused))) {
680 return next(e)
681 }
682 const drawn = await Promise.resolve(next(e)).catch(() => null)
683 const below = drawn && typeof drawn === 'object' && (drawn as { type?: string }).type !== 'engine' ? drawn : null
684
685 const t = await read($, tick)
686 // Whole seconds: within a second the text images come out identical.
687 const now = Math.floor((await $.clock.now()) / 1000) * 1000
688 const ui = $.ui.resolve(e)
689 const { Box, Button, Text } = ui
690 const shown = ordered(all).slice(0, BAND_ROWS)
691 const more = all.length - shown.length
692 // Expanding reads the running tasks' output now and every few seconds while the panel is open.
693 const openPanel = async () => {
694 await update($, isPanelOpen, () => true)
695 await $.ui.open({ id: PANE, title: 'Background tasks' })
696 const readTails = async () => {
697 const list = await read($, rows)
698 for (const r of list.filter(x => x.status === 'running' && x.outputPath)) {
699 const res = await lastLines($, r.outputPath)
700 if (res) {
701 const tail = res
702 await update($, rows, xs => xs.map(x => (x.id === r.id ? { ...x, tail, percent: percentOf(tail) } : x)))
703 }
704 }
705 }
706 await readTails().catch(() => {})
707 refresh?.cancel()
708 refresh = $.clock.every(PANEL_REFRESH_MS, () => {
709 void (async () => {
710 if (!(await read($, isPanelOpen))) {
711 refresh?.cancel()
712 refresh = null
713 return
714 }
715 await readTails()
716 })().catch(() => {})
717 })
718 }
719
720 // The pause key shows only when another Watcher band is up too, once, in this band's second
721 // row's free chip slot, so it never crowds the open button.
722 const showPause = below !== null && !hasKey(below, 'watcher-pause')
723
724 if (e.surface !== 'terminal' && 'Svg' in ui) {
725 const { Svg } = ui
726 const W = Math.max(200, Math.min(1400, (e.props.bodyColumns || 100) * 8 - CHIP_PX))
727 return (
728 <Box flexDirection="column" paddingTop={1}>
729 {shown.map((row, i) => (
730 <Box key={`run-${row.id}`} flexDirection="row" alignItems="center" gap={1}>
731 <Svg key={`icon-${row.id}`} source={bandIconSvg(row)} alt={row.category} width={28} height={28} />
732 <Box key={`body-${row.id}`} flexDirection="column" flexShrink={0}>
733 <Svg
734 key={`text-${row.id}`}
735 source={bandTextSvg(W, row, now, i === shown.length - 1 ? more : 0)}
736 alt={`${row.category}: ${row.status}, ${row.description}`}
737 width={W - 32}
738 height={18}
739 />
740 <Svg key={`track-${row.id}`} source={bandTrackSvg(W, row)} alt={`${row.category} progress`} width={W - 32} height={8} />
741 </Box>
742 <Box key={`chip-${row.id}`} width={CHIP_COLS} flexShrink={0}>
743 {i === 0 && <Button key="run-watcher" label={`⤢ ×${all.length}`} hotkey="r" plain onPress={openPanel} />}
744 {i === 1 && showPause ? <Button key="watcher-pause" label="–" hotkey="h" plain onPress={async () => {}} /> : null}
745 </Box>
746 </Box>
747 ))}
748 {below && <Box key="run-watcher-gap" height={1} />}
749 {below}
750 </Box>
751 )
752 }
753
754 const cols = e.props.bodyColumns || 100
755 return (
756 <Box flexDirection="column">
757 {shown.map((row, i) => (
758 <Button key={i === 0 ? 'run-watcher' : `run-${row.id}`} plain onPress={openPanel}>
759 <Text color={row.status === 'failed' ? FAILED : categoryOf(row.category).color}>{`${row.status === 'failed' ? '✗' : row.status === 'done' ? '✔' : '›'} ${barText(row, t, 12)} `}</Text>
760 <Text bold color={categoryOf(row.category).color}>{labelOf(row, now)}</Text>
761 <Text dimColor wrap="truncate-end">{`${cols >= 90 && row.tags.length ? ` [${row.tags.join(', ')}]` : ''} ${statsOf(row, now, cols < 70 ? 1 : 3, true)}${i === shown.length - 1 && more > 0 ? ` +${more}` : ''}`}</Text>
762 </Button>
763 ))}
764 {below}
765 </Box>
766 )
767 })
768
769 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
770 const all = ordered(await read($, rows))
771 const t = await read($, tick)
772 const now = Math.floor((await $.clock.now()) / 1000) * 1000
773 const ui = $.ui.resolve(e)
774 const { Box, Button, Text } = ui
775
776 if (e.surface !== 'terminal' && 'Svg' in ui) {
777 const { Svg, Link } = ui
778 const W = Math.max(240, Math.min(900, (e.props.bodyColumns || 40) * 8 - 8))
779 return (
780 <Box flexDirection="column">
781 <Svg key="pane-header" source={headerSvg(W, all)} alt={`${all.length} background tasks`} width={W} height={44} />
782 {all.map(row => (
783 <Box key={`pane-${row.id}`} flexDirection="row">
784 <Svg key={`picon-${row.id}`} source={paneIconSvg(row)} alt={row.category} width={44} height={44} />
785 <Box key={`pbody-${row.id}`} flexDirection="column" flexShrink={0}>
786 {row.outputPath ? (
787 <Box key={`plog-${row.id}`} flexDirection="row" justifyContent="flex-end">
788 <Link key={`open-log-${row.id}`} href={`file://${encodeURI(row.outputPath)}`} label="open log ↗" />
789 </Box>
790 ) : null}
791 <Svg key={`ptext-${row.id}`} source={paneTextSvg(W, row, now)} alt={`${row.category}: ${row.description}`} width={W - 44} height={58} />
792 <Svg key={`ptrack-${row.id}`} source={paneTrackSvg(W, row)} alt={`${row.category} progress`} width={W - 44} height={14} />
793 <Svg key={`ptail-${row.id}`} source={paneTailSvg(W, row)} alt={row.tail.slice(-1)[0] ?? 'no output yet'} width={W - 44} height={row.tail.slice(-TAIL_LINES).length * 14 + 12} />
794 </Box>
795 </Box>
796 ))}
797 </Box>
798 )
799 }
800
801 return (
802 <Box flexDirection="column" gap={1}>
803 {all.length === 0 && <Text dimColor>No background tasks right now.</Text>}
804 {all.map(row => (
805 <Box key={`pane-${row.id}`} flexDirection="column">
806 <Text bold color={categoryOf(row.category).color}>{`● ${row.category}${row.tags.length ? ` · ${row.tags.join(' · ')}` : ''}`}</Text>
807 <Text color={categoryOf(row.category).color}>{barText(row, t, 20)}</Text>
808 <Text dimColor>{[row.description, statsOf(row, now, 3)].filter(Boolean).join(' · ')}</Text>
809 <Text dimColor wrap="truncate-end">{`$ ${row.command}`}</Text>
810 {row.tail.slice(-TAIL_LINES).map((l, i) => (
811 <Text key={`tail-${row.id}-${i}`} dimColor wrap="truncate-end">{` ${l}`}</Text>
812 ))}
813 </Box>
814 ))}
815 </Box>
816 )
817 })
818}
819
820// Whether a drawn tree already holds an element with this key (another band's pause button).
821function hasKey(node: unknown, key: string): boolean {
822 if (!node || typeof node !== 'object') {
823 return false
824 }
825 if (Array.isArray(node)) {
826 return node.some(n => hasKey(n, key))
827 }
828 const el = node as { key?: unknown; props?: { key?: unknown; children?: unknown }; children?: unknown }
829 return el.key === key || el.props?.key === key || hasKey(el.props?.children, key) || hasKey(el.children, key)
830}
831types/index.d.ts 21 lines1export type RunRow = {
2 id: string
3 command: string
4 description: string
5 category: string
6 tags: string[]
7 startedAt: number
8 endedAt: number | null
9 status: 'running' | 'done' | 'failed'
10 exitCode: number | null
11 outputPath: string | null
12 percent: number | null
13 tail: string[]
14}
15
16declare module 'claude-code' {
17 interface PluginState {
18 'run-watcher': { rows: RunRow[]; tick: number; isPanelOpen: boolean; isPaused: boolean }
19 }
20}
21