SLOPSHOPPER

live-progress

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

newbandguardprompttoolprocess
A shopper browsing a rack in a slop shop
README

live-progress

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.

Install

claude plugin install live-progress --marketplace sruthik27/claude-foundry

Requires Claude Code 2.1.296+. Reload plugins or start a new session after installation.

Try it

Run tests in the background, write their output to /tmp/tests.log, and watch that log.

Details

MIT licensed. Maintained by Sruthik Issac.

Source 3 files
hooks/register.tsx 181 lines
1import { 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}
181
hooks/track.ts 79 lines
1/* 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}
79
types/index.d.ts 10 lines
1export 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