SLOPSHOPPER

run-watcher

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

newpanebandguardprompttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · run-watcher
│ ┃ run-watcher-list ✕ › fix the failing auth test and add an audit log call │ ┃ No background tasks right now. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · run-watcher-list
No background tasks right now.
README

run-watcher

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.

What it touches

Watcher draws on screen only. It sends nothing anywhere, makes no network calls, starts no programs, and adds nothing to the model's context.

HookWhat 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.submitReads 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.SubagentStopRead 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).

Source 2 files
hooks/register.tsx 831 lines
1// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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}
831
types/index.d.ts 21 lines
1export 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