Live progress bands above the prompt for long-running work Claude starts: tests, builds, scripts, logs

Ask Claude to use watch_progress with an absolute log path and short label. Known test runners are counted automatically; custom done/fail/percent/end regexes are supported. stop_progress removes a band. Runner summaries are provisional until the log is quiet; polling cannot prove process exit.
claude plugin install live-progress --marketplace sruthik27/claude-foundry
Requires Claude Code 2.1.296+. Reload plugins or start a new session after installation.
Run tests in the background, write their output to /tmp/tests.log, and watch that log.
MIT licensed. Maintained by Sruthik Issac.
hooks/register.tsx 181 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { EMPTY, clock, feed, percent } from './track.js'
5import type { Spec } from './track.js'
6import type { Job } from '../types'
7
8/**
9 * Live progress bands above the prompt. Claude starts a long job with its output going to a log
10 * and calls `watch_progress`; the mod reads only the log's new bytes once a second (`tail -c`),
11 * counts done / failed / skipped (known runners for free, or Claude's own patterns) and redraws.
12 * A run that finishes, or a log that stops growing for `idle` seconds, stays shown for 3 turns.
13 */
14const jobs = atom({ plugin: 'live-progress', key: 'jobs' } as const, [])
15const S: { tick: { cancel: () => void } | null } = { tick: null }
16let generation = 0
17let polling: Promise<void> = Promise.resolve()
18
19const GUIDE = 'To show the user a live progress indicator for anything long-running (tests, builds, deploys, scripts, data jobs, log tails), run it in the background with output to a log file (e.g. `cmd > /tmp/x.log 2>&1`) and call watch_progress with that path; a band above the prompt then updates every second while you keep working. Test runners (pytest, jest, vitest, go, node:test, cargo) are counted automatically; for anything else pass regex `done`/`fail` (lines that count) and `total`, or `percent` (a capture of N% or N/M).'
20
21export const register: Register = on => {
22 on('session.start', async ($, e, next) => {
23 const r = await next(e)
24 stopTicking()
25 await update($, jobs, () => [])
26 await $.tool.register({
27 name: 'watch_progress',
28 description: 'Show the user a live progress band above the prompt for a long-running background job, read from its log file every second. Auto-counts pytest/jest/vitest/go/node:test/cargo output; otherwise give regexes. Use for tests, builds, scripts, migrations, batch jobs, or any log you want the user to watch live. Returns at once; keep working.',
29 inputSchema: { type: 'object', required: ['log', 'label'], properties: {
30 log: { type: 'string', description: 'Absolute path of the log file the job writes (stdout+stderr).' },
31 label: { type: 'string', description: 'Short name shown on the band, e.g. "pytest tests/" or "migrate 2.4M rows".' },
32 total: { type: 'number', description: 'How many items to expect, when known (makes a percentage).' },
33 done: { type: 'string', description: 'Regex: a line that counts one item done.' },
34 fail: { type: 'string', description: 'Regex: a line that counts one item failed (its text is listed).' },
35 skip: { type: 'string', description: 'Regex: a line that counts one item skipped.' },
36 percent: { type: 'string', description: 'Regex with one capture group holding "42" (percent) or "17/250".' },
37 end: { type: 'string', description: 'Regex: a line that means the job finished.' },
38 idle: { type: 'number', description: 'Seconds without new output after which the job counts as stopped (default 120).' },
39 } },
40 } as never)
41 await $.tool.register({
42 name: 'stop_progress',
43 description: 'Stop showing a progress band (pass its log path, or "all").',
44 inputSchema: { type: 'object', required: ['log'], properties: { log: { type: 'string' } } },
45 } as never)
46 return r
47 })
48
49 on('classic.SessionStart', async ($, e, next) => {
50 const r = await next(e)
51 if (['clear', 'resume', 'fork'].includes(e.source)) { stopTicking(); await update($, jobs, () => []) }
52 return r
53 })
54
55 on('prompt.submit', async ($, e, next) => next({ ...e, context: [...(e.context ?? []), GUIDE] }))
56
57 on('tool.call', { tool: /^mcp__live-progress__watch_progress$/ }, async ($, e) => {
58 const a = e as unknown as Spec & { idle?: number }
59 if (!a.log || !a.log.startsWith('/')) return { result: 'Give the absolute path of the log file.' }
60 const now = await $.clock.now()
61 const spec: Spec = { id: a.log, label: String(a.label || a.log.split('/').pop()).slice(0, 60), log: a.log,
62 ...(a.total ? { total: Number(a.total) } : {}), ...pick(a, ['done', 'fail', 'skip', 'percent', 'end']) }
63 for (const k of ['done', 'fail', 'skip', 'percent', 'end'] as const) {
64 if (spec[k]) try { new RegExp(spec[k]!) } catch { return { result: `"${k}" is not a valid regex: ${spec[k]}` } }
65 }
66 await update($, jobs, js => [...js.filter(j => j.spec.id !== spec.id), { generation: ++generation, spec, p: EMPTY, offset: 0, started: now, quietSince: now, idle: Math.max(10, Number(a.idle ?? 120)), turnsDone: 0 }])
67 await poll($)
68 if (!S.tick) S.tick = $.clock.every(1000, () => { void poll($).catch(() => undefined) }) as never
69 return { result: `Watching ${spec.log} as "${spec.label}"; the user sees a live band above the prompt. Keep working; call stop_progress when you no longer want it shown.` }
70 })
71
72 on('tool.call', { tool: /^mcp__live-progress__stop_progress$/ }, async ($, e) => {
73 const log = String((e as unknown as { log?: string }).log ?? '')
74 await update($, jobs, js => (log === 'all' ? [] : js.filter(j => j.spec.id !== log)))
75 $.ui.invalidate('ui.render')
76 return { result: 'Stopped.' }
77 })
78
79 // Finished bands linger for 3 of the person's turns, then go.
80 on('turn.complete', async ($, e, next) => {
81 const r = await next(e)
82 if (e.agentId) return r
83 const js = await update($, jobs, list => list.map(j => (j.ended ? { ...j, turnsDone: j.turnsDone + 1 } : j)).filter(j => j.turnsDone < 3))
84 if (js.length === 0) stopTicking()
85 $.ui.invalidate('ui.render')
86 return r
87 })
88
89 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
90 const js = await read($, jobs)
91 if (e.props.hasSurvey || js.length === 0) return next(e)
92 const { Box, Text } = $.ui.resolve(e)
93 const cols = Math.max(30, e.props.bodyColumns - 2)
94 const now = Date.now()
95 const rows = js.slice(-3).map((j, i) => {
96 const pct = percent(j.p, j.spec)
97 const state = j.stopped && !j.p.finished ? 'stopped' : j.p.finished ? (j.p.fail > 0 ? 'failed' : 'done') : 'running'
98 const hue = state === 'failed' || (state === 'running' && j.p.fail > 0) ? '#f38ba8' : state === 'done' ? '#a6e3a1' : state === 'stopped' ? '#6c7086' : '#89b4fa'
99 const icon = state === 'done' ? '✔' : state === 'failed' ? '✗' : state === 'stopped' ? '◼' : SPIN[Math.floor(now / 120) % SPIN.length]!
100 const counts = [
101 j.p.done ? { t: `✓ ${j.p.done}`, c: '#a6e3a1' } : null,
102 j.p.fail ? { t: `✗ ${j.p.fail}`, c: '#f38ba8' } : null,
103 j.p.skip ? { t: `⊘ ${j.p.skip}`, c: '#f9e2af' } : null,
104 (j.spec.total ?? j.p.total) ? { t: `of ${j.spec.total ?? j.p.total}`, c: '#6c7086' } : null,
105 ].filter((x): x is { t: string; c: string } => x !== null)
106 const time = clock((j.ended ?? now) - j.started)
107 const label = j.spec.label.slice(0, Math.max(10, Math.floor(cols * 0.28)))
108 const right = `${pct === undefined ? '' : `${Math.round(pct)}% `}${counts.map(c => c.t).join(' ')} ${time}`
109 const barW = Math.max(8, cols - label.length - right.length - 6)
110 const fill = pct === undefined ? -1 : Math.round((pct / 100) * barW)
111 const indet = pct === undefined && state === 'running'
112 const pos = Math.floor(now / 90) % (barW + 6) - 6
113 const barCells = Array.from({ length: barW }, (_, k) => (fill >= 0 ? k < fill : indet ? k >= pos && k < pos + 6 : state !== 'stopped'))
114 const detail = j.p.failures.length ? `✗ ${j.p.failures.slice(-2).join(' ✗ ')}` : j.p.finished ? (j.p.summary ?? '') : j.p.last
115 return (
116 <Box key={`j${i}`} flexDirection="column">
117 <Box flexDirection="row">
118 <Text color={hue} bold>{`${icon} `}</Text>
119 <Text bold>{label}</Text>
120 <Text>{' '}</Text>
121 <Text color={hue}>{barCells.map(x => (x ? '█' : '')).join('')}</Text>
122 <Text color="#45475a">{barCells.map(x => (x ? '' : '░')).join('')}</Text>
123 <Text color={hue} bold>{pct === undefined ? ' ' : ` ${Math.round(pct)}% `}</Text>
124 {counts.map((c, k) => <Text key={`c${k}`} color={c.c}>{`${c.t} `}</Text>)}
125 <Text color="#6c7086">{time}</Text>
126 </Box>
127 {detail ? <Text color={j.p.failures.length ? '#f38ba8' : '#6c7086'} wrap="truncate-end">{` ${detail}`}</Text> : null}
128 </Box>
129 )
130 })
131 return <Box flexDirection="column" paddingX={1}>{await next(e)}{rows}</Box>
132 })
133}
134
135const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
136const pick = <T extends object>(o: T, keys: string[]) => Object.fromEntries(keys.filter(k => (o as Record<string, unknown>)[k]).map(k => [k, String((o as Record<string, unknown>)[k])]))
137
138function stopTicking(): void { S.tick?.cancel(); S.tick = null }
139
140/** Read each running job's new bytes, fold them in, and redraw. */
141function poll($: import('claude-code').EngineInterface): Promise<void> {
142 const run = polling.then(() => pollOnce($))
143 polling = run.catch(() => undefined)
144 return run
145}
146async function pollOnce($: import('claude-code').EngineInterface): Promise<void> {
147 const js = await read($, jobs)
148 const live = js.filter(j => !j.stopped)
149 if (live.length === 0) { stopTicking(); $.ui.invalidate('ui.render'); return }
150 const now = await $.clock.now()
151 const next: Job[] = []
152 for (const j of js) {
153 if (j.stopped) { next.push(j); continue }
154 let size = j.offset
155 try { size = (await $.fs.stat(j.spec.log)).size } catch { /* not created yet */ }
156 let chunk = ''
157 const truncated = size < j.offset
158 const offset = truncated ? 0 : j.offset
159 if (size > offset) {
160 // only the new bytes, at most 256 KB a second, so a huge log never stalls the band
161 const from = Math.max(offset, size - 262_144)
162 try { chunk = (await $.process.run(['tail', '-c', `+${from + 1}`, j.spec.log], { timeoutMs: 3000 })).stdout } catch { chunk = '' }
163 // keep a partial last line for the next read
164 const cut = chunk.lastIndexOf('\n')
165 const used = cut >= 0 ? chunk.slice(0, cut + 1) : ''
166 chunk = used
167 size = from + new TextEncoder().encode(used).length
168 }
169 const p = chunk ? feed(truncated ? EMPTY : j.p, chunk, j.spec) : truncated ? EMPTY : j.p
170 const quietSince = chunk ? now : j.quietSince
171 const stalled = now - quietSince > j.idle * 1000
172 next.push({ ...j, p, offset: size, quietSince, ended: p.finished || stalled ? (chunk ? now : j.ended ?? now) : undefined, turnsDone: chunk ? 0 : j.turnsDone, ...(stalled ? { stopped: p.finished ? 'complete' : 'no output' } : {}) })
173 }
174 await update($, jobs, current => current.map(j => {
175 const snapshot = js.find(x => x.generation === j.generation)
176 const result = next.find(x => x.generation === j.generation)
177 return snapshot && result ? { ...result, turnsDone: result.turnsDone === 0 ? 0 : j.turnsDone } : j
178 }))
179 $.ui.invalidate('ui.render')
180}
181hooks/track.ts 79 lines1/* Pure: turn the new lines of a log into progress. Runners we recognise are counted for free;
2 anything else is counted with the patterns Claude passes. */
3
4import type { Progress, Spec } from '../types'
5export type { Progress, Spec }
6export const EMPTY: Progress = { done: 0, fail: 0, skip: 0, failures: [], last: '', finished: false }
7
8type Runner = { name: string; test: RegExp; done?: RegExp; fail?: RegExp; skip?: RegExp; total?: RegExp; end?: RegExp; failName?: (m: RegExpExecArray) => string }
9const strip = (s: string) => s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').replace(/\r/g, '')
10
11/** Line-level matchers for common runners; the first one whose `test` hits a line is kept. */
12export const RUNNERS: Runner[] = [
13 { name: 'pytest', test: /^(collected \d+ items?|=+ test session starts|\S+\.py::\S+ (PASSED|FAILED|SKIPPED|ERROR))/,
14 total: /^collected (\d+) items?/, done: /::\S+ PASSED/, fail: /::\S+ (FAILED|ERROR)/, skip: /::\S+ (SKIPPED|XFAIL)/,
15 end: /^=+ .*(passed|failed|error).* in [\d.]+s/, failName: m => m.input.split(' ')[0]! },
16 { name: 'jest', test: /^\s*(PASS|FAIL)\s+\S+\.(t|j)sx?\b|^Tests:\s+\d+/, done: /^\s*✓ /, fail: /^\s*✕ /, skip: /^\s*○ /, end: /^Tests:\s+.*\d+ total/, failName: m => m.input.trim().slice(2) },
17 { name: 'vitest', test: /^\s*(✓|×|↓) \S+\.(test|spec)\.|^\s*Test Files\s+\d+/, done: /^\s*✓ /, fail: /^\s*[×✗] /, skip: /^\s*↓ /, end: /^\s*Tests\s+\d+/, failName: m => m.input.trim().slice(2) },
18 { name: 'go', test: /^(=== RUN|--- (PASS|FAIL|SKIP):|ok\s+\S+|FAIL\s+\S+)/, done: /^\s*--- PASS:/, fail: /^\s*--- FAIL:/, skip: /^\s*--- SKIP:/, end: /^(ok|FAIL)\s+\S+\s+[\d.]+s$|^PASS$/, failName: m => m.input.replace(/^\s*--- FAIL:\s*/, '').split(' ')[0]! },
19 { name: 'node:test', test: /^\s*(ok|not ok) \d+ - |^[ℹ✔✖] /, done: /^\s*(ok \d+ - |✔ )/, fail: /^\s*(not ok \d+ - |✖ )/, end: /^(?:ℹ|#) duration_ms \d/, failName: m => m.input.replace(/^\s*(not ok \d+ - |✖ )/, '') },
20 { name: 'cargo', test: /^test \S+ \.\.\. (ok|FAILED|ignored)|^running \d+ tests?/, total: /^running (\d+) tests?/, done: /^test \S+ \.\.\. ok/, fail: /^test \S+ \.\.\. FAILED/, skip: /^test \S+ \.\.\. ignored/, end: /^test result:/, failName: m => m.input.split(' ')[1]! },
21 { name: 'claude-plugin-test', test: /^\((pass|fail)\) /, done: /^\(pass\) /, fail: /^\(fail\) /, end: /^Ran \d+ tests? across/, failName: m => m.input.replace(/^\(fail\) /, '').replace(/ \[[\d.]+ms\]$/, '') },
22]
23
24const rx = (s?: string) => { if (!s) return undefined; try { return new RegExp(s) } catch { return undefined } }
25
26/** Folds new lines into the progress so far. `runner` sticks once one is recognised. */
27export function feed(p: Progress, chunk: string, spec: Spec): Progress {
28 const lines = strip(chunk).split('\n').filter(l => l.trim() !== '')
29 if (lines.length === 0) return p
30 const own = { done: rx(spec.done), fail: rx(spec.fail), skip: rx(spec.skip), pct: rx(spec.percent), end: rx(spec.end) }
31 let runner = RUNNERS.find(r => r.name === p.runner)
32 const out: Progress = { ...p, finished: false, summary: undefined, failures: [...p.failures] }
33 for (const line of lines) {
34 if (!runner && !own.done && !own.fail) { runner = RUNNERS.find(r => r.test.test(line)); if (runner) out.runner = runner.name }
35 let m: RegExpExecArray | null
36 if (own.pct && (m = own.pct.exec(line))) {
37 const g = m[1] ?? m[0]
38 const frac = /(\d+)\s*\/\s*(\d+)/.exec(g)
39 out.pct = frac ? (Number(frac[1]) / Math.max(1, Number(frac[2]))) * 100 : parseFloat(g)
40 }
41 const total = runner?.total && (m = runner.total.exec(line)) ? Number(m[1]) : undefined
42 if (total !== undefined) out.total = runner?.name === 'cargo' ? (out.total ?? 0) + total : total
43 const isFail = (own.fail ?? runner?.fail)?.exec(line)
44 if (isFail) {
45 out.fail++
46 const name = runner?.failName && !own.fail ? runner.failName(isFail) : line.trim()
47 out.failures = [...out.failures.filter(f => f !== name), name.slice(0, 120)].slice(-5)
48 } else if ((own.done ?? runner?.done)?.test(line)) out.done++
49 else if ((own.skip ?? runner?.skip)?.test(line)) out.skip++
50 if (runner?.name === 'node:test') {
51 const summary = /^(?:ℹ|#) (tests|pass|fail|skipped) (\d+)/.exec(line)
52 if (summary) {
53 const n = Number(summary[2])
54 if (summary[1] === 'tests') out.total = n
55 if (summary[1] === 'pass') out.done = Math.max(out.done, n)
56 if (summary[1] === 'fail') out.fail = Math.max(out.fail, n)
57 if (summary[1] === 'skipped') out.skip = Math.max(out.skip, n)
58 }
59 }
60 if ((own.end ?? runner?.end)?.test(line)) { out.finished = true; out.summary = line.trim().replace(/^=+\s*|\s*=+$/g, '').slice(0, 120) }
61 out.last = line.trim().slice(0, 200)
62 }
63 return out
64}
65
66/** 0-100 when it can be known: the pattern's percentage, else counted / total. */
67export function percent(p: Progress, spec: Spec): number | undefined {
68 if (p.finished) return 100
69 if (p.pct !== undefined && Number.isFinite(p.pct)) return Math.max(0, Math.min(100, p.pct))
70 const total = spec.total ?? p.total
71 if (!total) return undefined
72 return Math.min(99, ((p.done + p.fail + p.skip) / total) * 100)
73}
74
75export const clock = (ms: number) => {
76 const s = Math.floor(ms / 1000)
77 return s >= 3600 ? `${Math.floor(s / 3600)}:${String(Math.floor(s / 60) % 60).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
78}
79types/index.d.ts 10 lines1export type Spec = { id: string; label: string; log: string; total?: number; done?: string; fail?: string; skip?: string; percent?: string; end?: string }
2export type Progress = { done: number; fail: number; skip: number; total?: number; pct?: number; failures: string[]; last: string; finished: boolean; runner?: string; summary?: string }
3export type Job = { generation: number; spec: Spec; p: Progress; offset: number; started: number; ended?: number; quietSince: number; idle: number; turnsDone: number; stopped?: string }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'live-progress': { jobs: Job[] }
8 }
9}
10