Live progress pane for long-running test, scenario and batch jobs

A Claude Code mod that shows a live Progress pane for the long-running jobs Claude runs that are on its list: unit, integration and e2e test runs, and PokeJudge case runs. Nothing else gets a row, even when Claude runs it in the background. To follow a new kind of job, add it to FOLLOWED in hooks/classify.ts.
Each job gets a soft pink bar with a green growing tip, a plant that grows as the job runs, pass/fail counts, elapsed time, an ETA and the latest output line. When the job ends it either blooms or wilts:

<sub>Three demo jobs over 20 seconds: the unit tests bloom, the e2e suite wilts after two failures, and a capture run of 30 test cards blooms. The frames come from the mod's own render hook on the terminal surface, mounted with claude plugin test and fed demo job states, then painted in Cascadia Mono. They show what the pane draws, but they aren't a screen recording.</sub>
| Running | Finished |
|---|---|
![]() | ![]() |
A toast says "🌸 … bloomed" or "🥀 … wilted" when a job finishes. To remove finished jobs, run /progress clear, which works everywhere. Clicking Clear finished works in the desktop app and the fullscreen terminal. In the regular terminal, the button only responds to the c key while the pane has focus.
/progress toggles the pane. To close it you can also:
/progress closex while the pane has focusThe pane never opens on its own: jobs are tracked in the background, and /progress opens it to show them.
Dev servers (npm run dev:e2e, bench:serve, vite, wrangler dev, uvicorn) get their own rows at the top: 🌿, the port when the command or output names one, and uptime. They have no bar and never bloom or wilt. To stop them from any terminal, run /progress stop (all servers) or /progress stop 2 (the second row). Each row also has a Stop button, which in the regular terminal answers 1, 2, … while the pane has focus.
Stopping a server kills its whole process tree, not just its task. On Windows, TaskStop alone leaves an npm-launched server running. Git Bash starts each program through a helper process that exits, so no Windows parent link reaches the server, and the stop only kills the outer shell. The mod marks each background server's command with JOB_PROGRESS_TAG. To stop a server, it finds the marked shell, kills every process in that shell's Git Bash process group with taskkill /T /F, and only then calls TaskStop. It reports a stop only when none of the group is left. A server started before this marking existed gets a warning instead of "Stopped". This happens on every TaskStop, including when you just ask Claude to stop a server. The mod handles TaskStop itself, kills the marked tree first, then updates the pane. A TaskStop raises no task notification, so without this the pane would never hear about the stop. Stopping a job that isn't a server marks its row as stopped, with no bloom or wilt.
A runner only counts when it's the program a step of the command runs. Quoted text is ignored, and the start of each &&, ;, | or loop step is checked. A command that only mentions one (-notmatch 'playwright|vitest', cat vitest.config.ts) is left alone.
Every running job shows a bar from the start, at 0% until its output gives a count or percentage. A job that finishes without ever giving a count, percentage or pass/fail numbers gets one line with ✓ or ✗ instead of a full bar and a bloom. If it finishes in under 30 seconds, it's removed without a toast. Bars are at most 40 columns wide.
This copy is set up for my own repos rather than general use:
dotnet test, go test, cargo test, and npm/pnpm/yarn/bun scripts named test* or e2e*) and PokeJudge case runs (dotnet run … evaluate). Anything else, such as CI watches, log tails or one-off scripts, is left alone, backgrounded or not.sleep of 60 seconds or more is refused. The refusal tells Claude it will be notified when the job ends. Claude is also told not to redirect a background job into a log file of its own, since the pane can't see that file.Result: 13/20 scenarios fully passed counts as 13 passed and 7 failed. ten-or-not 165 cases 2 WRONG lines count as passed/failed. Card numbers (096/182) and centering ratios (42/58) aren't progress. 0.85 failed (a reading) and attempt 1 failed (a retry) aren't failures.| Piece | What it does |
|---|---|
tool.call (Bash, PowerShell) | Remembers each shell call's command and description (the job's label). Moves followed jobs to the background when the call didn't choose. Refuses a long sleep while a job runs. The rules live in hooks/classify.ts. |
session.append | Picks up a followed job or dev server from the shell result row ("running in background with ID: X. Output is being written to: P") and marks it finished from its <task-notification> row. Any other background task is ignored. |
$.clock.every(1500) | Re-reads each running job's output file when it grows and parses progress from it. |
tool.describe | Adds a line to the Bash/PowerShell tool descriptions asking Claude to run test runs and PokeJudge case runs in the background, unpiped. |
ui.render (Pane) | Draws the pane. /progress opens or closes it on demand. |
Only background jobs are tracked: a foreground command's output isn't visible until it ends.
hooks/parse.ts)In order of preference:
PROGRESS 3/10 label lines: the convention for custom scripts.[12/40] counters (Playwright list reporter).n/m.Running 40 tests / collected 40 items.Pass/fail counts come from runner summaries (88 passed, 3 failed, Passed: 41, Failed: 1), or from per-test marks (✓ ✗ PASS FAIL ok / not ok) while a run is still going.
The mod is a plugin folder. Load it in any session with:
claude --plugin-dir <path-to>/ai-agent-skills/mods/job-progress
To load it in every session (including the desktop app), add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "<path-to>/ai-agent-skills/mods/job-progress"
}
}
claude plugin validate mods/job-progress
node mods/job-progress/hooks/parse.check.mts
node mods/job-progress/hooks/classify.check.mts
node mods/job-progress/hooks/followed.check.mts
claude plugin test mods/job-progress
parse.check.mts, classify.check.mts and followed.check.mts are plain Node self-checks for the parser and the command rules (Node 22+ runs the TypeScript import directly). The parser checks use real output lines from my projects. hooks/tool-call.test.ts and hooks/my-runs.test.ts check the shell-call rewrite (backgrounding, dropping tail/head pipes, PYTHONUNBUFFERED), and hooks/pane.test.ts checks /progress opening and closing the pane, all under claude plugin test.
Gotcha found while building it: inside a Text, use arrays of Text children, not fragments (<>…</>). A fragment there is refused as "Box inside an inline element", and the engine draws an empty pane instead.
$.fs.read cap); the pane keeps the last reading.RUNNER rewrite in hooks/register.tsx if that gets in the way.| tail -N or | head -N shows no progress until it ends. The mod drops a trailing tail/head pipe from commands it backgrounds and asks Claude not to add one, but other filters (| grep …) still hide progress.python -u (or flush) for live PROGRESS lines.hooks/register.tsx 397 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Job } from '../types'
5import { KILL_TREE, isFollowed, isServerCommand, killResult, portOf, rowKind, serversToStop, sleepDenial, stopFailure, tagCommand } from './classify'
6import { parse } from './parse'
7
8const PANE = 'job-progress'
9// closeOnEscape: in the terminal's main screen the pane has no close mark, so Esc (at an idle,
10// empty prompt) closes it, as do /progress and the pane's Close button.
11const OPEN = { id: PANE, title: 'Progress', closeOnEscape: true } as const
12const SHELLS = ['Bash', 'PowerShell']
13const jobs = atom({ plugin: 'job-progress', key: 'jobs' } as const, [])
14
15const HINT =
16 '\n\nUnit, integration and e2e test runs and PokeJudge case runs (`dotnet run … evaluate`): run them with ' +
17 'run_in_background so the person can watch them in their Progress pane. Do not pipe them through tail, head ' +
18 'or grep: that holds their output back until they end and the pane stays blank; read the output file ' +
19 'afterwards instead. Do not redirect them into a log file of your own (`> "$LOG"`, `> /tmp/e2e.log`): the ' +
20 'output file is already the log, and the pane can only follow that one. Do not `sleep` to wait for a ' +
21 'background job: you are notified when it ends, so do other work or end your turn.'
22
23// `| tail -N` / `| head -N` ending a step (before &&, ||, ; or the end) of a command the pane follows:
24// dropped, since a background job's output goes to a file anyway and the pipe would hide all
25// progress until that step ends.
26const TAIL_PIPE = /\s*\|\s*(tail|head)(\s+-n)?\s+-?\d+(?=\s*(&&|\|\||;|$))/g
27
28// Python block-buffers stdout written to a file, so a background job's progress lines would only
29// land in big chunks. Bash gets PYTHONUNBUFFERED; PowerShell sets it on $env.
30const unbuffered = (tool: string, command: string) =>
31 tool === 'PowerShell' ? `$env:PYTHONUNBUFFERED = '1'; ${command}` : `export PYTHONUNBUFFERED=1; ${command}`
32
33function backgroundCommand(tool: string, command: string): string {
34 return unbuffered(tool, command.replace(TAIL_PIPE, ''))
35}
36
37const PINK = '#ED93B1'
38const DEEP_PINK = '#D4537E'
39const LEAF = '#639922'
40const WILT = '#A32D2D'
41const STAGES = ['🌰', '🌱', '🌿', '🪴', '🌸']
42
43const isBloomed = (job: Job) => job.status === 'completed' && !job.failed
44const isServer = (job: Job) => job.kind === 'server'
45// A job whose output gave a count, a percentage or pass/fail numbers. One without any gets
46// no bar and no plant: there is nothing to draw them from.
47const hasProgress = (job: Job) =>
48 job.total !== undefined || job.pct !== undefined || job.passed !== undefined || job.failed !== undefined
49// A job with no progress that ends this fast was a quick command Claude ran in the background,
50// not a run worth a row or a toast.
51const QUICK_MS = 30_000
52const BAR_MAX = 40
53
54const STARTED = /running in background with ID: (\w+)\. Output is being written to: (.+?\.output)/
55
56// Every string in a row's content (text blocks, tool_result bodies), joined, so the
57// regexes see real text rather than JSON-escaped text.
58function rowText(content: unknown): string {
59 if (typeof content === 'string') return content
60 if (Array.isArray(content)) return content.map(rowText).join('\n')
61 if (content && typeof content === 'object') {
62 const block = content as { text?: unknown; content?: unknown; tool_use_id?: unknown }
63 const id = typeof block.tool_use_id === 'string' ? `tool_use_id=${block.tool_use_id}` : ''
64 return [id, rowText(block.text), rowText(block.content)].join('\n')
65 }
66 return ''
67}
68
69async function refresh($: EngineInterface, job: Job, status?: string) {
70 let patch: Partial<Job> = {}
71 try {
72 const stat = await $.fs.stat(job.path)
73 if (stat.size !== job.size || status) {
74 // ponytail: whole-file read, capped at 4 MiB by $.fs.read; past that the pane keeps the last reading.
75 const text = stat.size < 4 * 1024 * 1024 ? await $.fs.read(job.path) : undefined
76 patch = { size: stat.size, ...(text === undefined ? {} : isServer(job) ? { port: job.port ?? portOf(text) } : parse(text)) }
77 }
78 } catch {
79 // Output file not there yet, or gone: keep what the pane already shows.
80 }
81 if (status) patch = { ...patch, status, endedAt: await $.clock.now() }
82 if (Object.keys(patch).length === 0) return
83 await update($, jobs, list => (list ?? []).map(j => (j.id === job.id ? { ...j, ...patch } : j)))
84}
85
86function summary(job: Job): string {
87 const bits: string[] = []
88 if (job.total) bits.push(`${job.done ?? 0}/${job.total}`)
89 else if (job.pct !== undefined) bits.push(`${Math.round(job.pct)}%`)
90 if (job.unit && (job.passed !== undefined || job.failed !== undefined)) {
91 // "2 scenarios passed · 1 failed", where counting in the run's own terms reads better than ✓/✗.
92 bits.push(`${job.passed ?? 0} ${job.unit} passed`, `${job.failed ?? 0} failed`)
93 return bits.join(' · ')
94 }
95 if (job.passed !== undefined) bits.push(`✓${job.passed}`)
96 if (job.failed) bits.push(`✗${job.failed}`)
97 return bits.join(' · ')
98}
99
100function duration(ms: number): string {
101 const s = Math.max(0, Math.round(ms / 1000))
102 return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
103}
104
105const clearFinished = ($: EngineInterface) => update($, jobs, l => (l ?? []).filter(j => j.status === 'running'))
106const closePane = ($: EngineInterface) => $.ui.close({ id: PANE })
107const GIT_BASH = 'C:/Program Files/Git/bin/bash.exe'
108
109// Kills a marked server's whole process tree (see KILL_TREE); undefined when that couldn't run
110// (no Git Bash, or the script hung).
111async function killTree($: EngineInterface, tag: string) {
112 try {
113 return killResult((await $.process.run([GIT_BASH, '-c', KILL_TREE, 'kill-tree', tag], { timeoutMs: 20000 })).stdout)
114 } catch {
115 return undefined
116 }
117}
118
119// Stops a dev server for /progress stop and the Stop button. It goes through TaskStop like Claude's
120// own stops, so the TaskStop hook in register does the whole-tree kill and drops the row.
121// Returns why it didn't stop, and then the row stays.
122async function stopServer($: EngineInterface, job: Job): Promise<string | undefined> {
123 let failure: string | undefined
124 try {
125 failure = stopFailure(await $.tool.call({ tool: 'TaskStop', task_id: job.id }))
126 } catch (err) {
127 failure = err instanceof Error ? err.message : String(err)
128 }
129 if (failure) return failure
130 // Without the marker only TaskStop ran, which can report success and leave an npm server running.
131 if (!job.tag) return UNCONFIRMED
132 return undefined
133}
134
135const UNCONFIRMED =
136 'its task was stopped, but it started before job-progress marked servers, so its processes may still be running. Check its port'
137
138export const register: Register = on => {
139 on('session.start', async ($, e, next) => {
140 await $.command.register({
141 name: 'progress',
142 description: 'Toggle the progress pane; /progress close hides it, /progress clear removes finished jobs, /progress stop [n] stops dev servers',
143 })
144 $.clock.every(1500, () => {
145 void (async () => {
146 const list = await read($, jobs)
147 const running = (list ?? []).filter(j => j.status === 'running')
148 for (const job of running) await refresh($, job)
149 // Keeps elapsed time and ETA ticking while the output is quiet.
150 if (running.length) $.ui.invalidate('ui.render')
151 })()
152 })
153 return next(e)
154 })
155
156 on('command.run', { command: 'progress' }, async ($, e) => {
157 const arg = e.args.trim()
158 if (arg === 'clear') {
159 await clearFinished($)
160 return { text: 'Cleared finished jobs.' }
161 }
162 const stop = /^stop(?:\s+(\S+))?$/.exec(arg)
163 if (stop) {
164 const servers = serversToStop((await read($, jobs)) ?? [], stop[1] ?? '')
165 if (typeof servers === 'string') return { text: servers }
166 const lines: string[] = []
167 for (const server of servers) {
168 const failure = await stopServer($, server)
169 lines.push(
170 failure === UNCONFIRMED
171 ? `${server.label}: ${failure}.`
172 : failure
173 ? `Could not stop ${server.label} (${server.id}): ${failure}`
174 : `Stopped ${server.label}.`,
175 )
176 }
177 return { text: lines.join('\n') }
178 }
179 const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
180 if (arg === 'close' || (arg === '' && isOpen)) {
181 await closePane($)
182 return { text: 'Progress pane closed.' }
183 }
184 await $.ui.open(OPEN)
185 return { text: 'Progress pane opened.' }
186 })
187
188 on('tool.describe', async ($, e, next) => {
189 const described = await next(e)
190 return SHELLS.includes(e.tool) ? { ...described, description: described.description + HINT } : described
191 })
192
193 // Every TaskStop, Claude's own and the pane's, comes through here. A TaskStop raises no
194 // <task-notification>, so without this the pane never hears that a job was stopped. And on Windows
195 // TaskStop leaves an npm server's process tree running, so a marked server's tree is killed first.
196 on('tool.call', { tool: 'TaskStop' }, async ($, e, next) => {
197 const input = e as typeof e & { task_id?: string; shell_id?: string }
198 const id = input.task_id ?? input.shell_id
199 const job = ((await read($, jobs)) ?? []).find(j => j.id === id)
200 if (!job || job.status !== 'running') return next(e)
201 const tree = job.tag ? await killTree($, job.tag) : undefined
202 if (tree && tree.left > 0) {
203 return { deny: `job-progress: ${tree.left} of the processes of "${job.label}" are still running after taskkill.` }
204 }
205 const answer = await next(e)
206 const failure = stopFailure(answer)
207 // Once the tree is gone the task may already have ended, and TaskStop then has nothing to stop.
208 if (failure && !(tree && tree.killed > 0)) return answer
209 if (isServer(job)) {
210 await update($, jobs, list => (list ?? []).filter(j => j.id !== job.id))
211 } else {
212 const endedAt = await $.clock.now()
213 await update($, jobs, list => (list ?? []).map(j => (j.id === job.id ? { ...j, status: 'killed', endedAt } : j)))
214 }
215 if (failure) {
216 return { result: { message: `Stopped ${job.label}: its process tree was killed.`, task_id: job.id, task_type: 'local_bash', command: '' } }
217 }
218 return answer
219 })
220
221 // Labels and commands of shell calls, by tool_use_id, until their result row names the background task.
222 const labels = new Map<string, { label: string; command: string }>()
223
224 on('tool.call', async ($, e, next) => {
225 if (!SHELLS.includes(e.tool)) return next(e)
226 const args = e as typeof e & { command?: string; description?: string; run_in_background?: boolean }
227 const command = args.command ?? ''
228 labels.set(e.tool_use_id, { label: args.description || command.slice(0, 60), command })
229 const deny = args.run_in_background ? undefined : sleepDenial(command, (await read($, jobs)) ?? [])
230 if (deny) return { deny }
231 if (args.run_in_background === false) return next(e)
232 // Followed jobs go to the background; a server Claude backgrounds is marked for stopping.
233 // Any other command, backgrounded or not, is left as it is.
234 if (isFollowed(command)) return next({ ...e, run_in_background: true, command: backgroundCommand(e.tool, command) })
235 if (args.run_in_background && isServerCommand(command)) {
236 return next({ ...e, command: tagCommand(e.tool, backgroundCommand(e.tool, command), e.tool_use_id) })
237 }
238 return next(e)
239 })
240
241 // The shell's result row says where a background job writes ("running in background with ID: X.
242 // Output is being written to: P"); its <task-notification> row says when it ended.
243 on('session.append', async ($, e, next) => {
244 const raw = rowText((e.message as { content?: unknown }).content)
245
246 const started = STARTED.exec(raw)
247 const useId = started ? /tool_use_id=(\S+)/.exec(raw)?.[1] : undefined
248 const call = useId ? labels.get(useId) : undefined
249 const kind = rowKind(call?.command)
250 const server = kind === 'server'
251 // Only followed jobs and dev servers get a row; any other background task is not the pane's.
252 if (started && call && kind) {
253 const [, id, path] = started
254 const job: Job = {
255 id,
256 path,
257 label: call.label || 'Background job',
258 startedAt: await $.clock.now(),
259 status: 'running',
260 ...(server ? { kind: 'server' as const, port: portOf(call.command), tag: useId } : {}),
261 }
262 // No auto-open: the pane only opens on /progress.
263 await update($, jobs, list => [...(list ?? []).filter(j => j.id !== id), job].slice(-20))
264 }
265
266 const id = /<task-id>([^<]+)<\/task-id>/.exec(raw)?.[1]
267 const status = /<status>([^<]+)<\/status>/.exec(raw)?.[1]
268 if (id && status) {
269 const job = (await read($, jobs))?.find(j => j.id === id)
270 if (job && isServer(job)) {
271 // A server that stops is gone from the pane; no bloom, no wilt.
272 await update($, jobs, list => (list ?? []).filter(j => j.id !== id))
273 } else if (job && job.status === 'running') {
274 // A job already marked stopped by the TaskStop hook keeps that, with no bloom or wilt.
275 await refresh($, job, status)
276 const done = (await read($, jobs))?.find(j => j.id === id)
277 if (done && !hasProgress(done) && (done.endedAt ?? 0) - done.startedAt < QUICK_MS) {
278 await update($, jobs, list => (list ?? []).filter(j => j.id !== id))
279 } else if (done) $.ui.toast(`${isBloomed(done) ? '🌸' : '🥀'} ${done.label} ${isBloomed(done) ? 'bloomed' : 'wilted'} ${summary(done)}`.trim())
280 }
281 }
282 return next(e)
283 })
284
285 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
286 const { Box, Text, Button } = $.ui.resolve(e)
287 const list = (await read($, jobs)) ?? []
288 const now = await $.clock.now()
289 const width = Math.max(10, (e.props as { bodyColumns?: number }).bodyColumns ?? 40)
290 const barWidth = Math.min(BAR_MAX, Math.max(10, width - 14))
291
292 // Clicks reach buttons on the desktop and the fullscreen terminal; elsewhere press the hotkey while the pane has focus.
293 const close = <Button label="Close" role="dismiss" hotkey="x" onPress={() => closePane($)} />
294
295 if (list.length === 0) {
296 return (
297 <Box flexDirection="column">
298 <Text dimColor>No jobs yet.</Text>
299 <Text dimColor>Test runs and PokeJudge case runs show up here.</Text>
300 <Box marginTop={1}>{close}</Box>
301 </Box>
302 )
303 }
304
305 const servers = list.filter(isServer)
306 const runs = list.filter(j => !isServer(j))
307
308 return (
309 <Box flexDirection="column">
310 {servers.length ? (
311 <Box flexDirection="column" marginBottom={1}>
312 {servers.map((job, i) => (
313 <Box flexDirection="row" gap={1}>
314 <Text wrap="truncate-end">
315 <Text color={LEAF}>🌿 </Text>
316 <Text bold>{job.label}</Text>
317 <Text dimColor>{[job.port ? ` :${job.port}` : '', ` · up ${duration(now - job.startedAt)}`].join('')}</Text>
318 </Text>
319 {/* Digits so each server's Stop has its own key in the plain terminal. */}
320 <Button label="Stop" hotkey={i < 9 ? String(i + 1) : undefined} onPress={async () => {
321 const failure = await stopServer($, job)
322 if (failure) $.ui.toast(`Could not stop ${job.label}: ${failure}`)
323 }} />
324 </Box>
325 ))}
326 </Box>
327 ) : null}
328 {[...runs].reverse().map(job => {
329 const running = job.status === 'running'
330 if (!running && !hasProgress(job)) {
331 // Finished without ever giving progress: one line, no full bar or bloom to fake.
332 // ✓ when it exited cleanly, ✗ otherwise. While running it gets a 0% bar like any job.
333 const ok = job.status === 'completed'
334 return (
335 <Box flexDirection="column" marginBottom={1}>
336 <Text wrap="truncate-end">
337 <Text color={running ? PINK : ok ? DEEP_PINK : WILT}>{running ? '● ' : ok ? '✓ ' : '✗ '}</Text>
338 <Text bold>{job.label}</Text>
339 <Text dimColor>{` ${duration((job.endedAt ?? now) - job.startedAt)}${running || ok ? '' : ` · ${job.status}`}`}</Text>
340 </Text>
341 {running && job.last ? <Text dimColor wrap="truncate-end">› {job.last}</Text> : null}
342 </Box>
343 )
344 }
345 const frac = job.total ? (job.done ?? 0) / job.total : job.pct !== undefined ? job.pct / 100 : undefined
346 const elapsed = (job.endedAt ?? now) - job.startedAt
347 const eta = running && frac && frac > 0.02 && frac < 1 ? ` · ~${duration(elapsed / frac - elapsed)} left` : ''
348 const bloomed = isBloomed(job)
349 const filled = running ? (frac === undefined ? 0 : Math.round(Math.min(1, frac) * barWidth)) : barWidth
350 // Plant grows seed → sprout → leaves → potted while running; blooms or wilts at the end.
351 const stage = running ? Math.min(3, Math.floor((frac ?? 0) * 4)) : 4
352 const stages = STAGES.slice(0, stage + 1)
353 if (!running) stages[4] = bloomed ? '🌸' : '🥀'
354 return (
355 <Box flexDirection="column" marginBottom={1}>
356 <Text wrap="truncate-end">
357 <Text color={running ? PINK : bloomed ? DEEP_PINK : WILT}>{running ? '● ' : bloomed ? '✿ ' : '✗ '}</Text>
358 <Text bold>{job.label}</Text>
359 <Text dimColor> {duration(elapsed)}</Text>
360 </Text>
361 {/* Shown from the start: an empty bar at 0% until the output gives a count or percentage. */}
362 <Text>
363 {/* Plain arrays, not fragments: a Fragment inside Text is refused as "Box inside an inline element". */}
364 {running
365 ? [
366 <Text color={PINK}>{'▰'.repeat(Math.max(0, filled - 1))}</Text>,
367 filled > 0 ? <Text color={LEAF}>▰</Text> : null,
368 <Text dimColor>{'▱'.repeat(barWidth - filled)}</Text>,
369 <Text> {Math.round((frac ?? 0) * 100)}%</Text>,
370 ]
371 : [
372 <Text color={bloomed ? DEEP_PINK : undefined} dimColor={!bloomed}>{'▰'.repeat(filled)}</Text>,
373 <Text color={bloomed ? DEEP_PINK : WILT}>{bloomed ? ' 🌸 bloomed' : ' 🥀 wilted'}</Text>,
374 ]}
375 </Text>
376 <Text wrap="truncate-end">
377 {stages.join(' → ')}
378 <Text dimColor>{' · '.repeat(4 - stage)}</Text>
379 </Text>
380 <Text dimColor wrap="truncate-end">
381 {[summary(job), running || job.status === 'completed' ? '' : job.status].filter(Boolean).join(' · ') + eta}
382 </Text>
383 {running && job.last ? <Text dimColor wrap="truncate-end">› {job.last}</Text> : null}
384 </Box>
385 )
386 })}
387 <Box flexDirection="row" gap={2}>
388 {runs.some(j => j.status !== 'running') ? (
389 <Button label="Clear finished" hotkey="c" onPress={() => clearFinished($)} />
390 ) : null}
391 {close}
392 </Box>
393 </Box>
394 )
395 })
396}
397hooks/classify.ts 150 lines1// Which shell commands the pane follows, which are dev servers, and when a sleep is refused.
2// Plain functions with no engine in them, so `classify.check.mts` runs them under Node.
3
4import type { Job } from '../types'
5
6// Every step of a shell command (split at &&, ||, ;, | and newlines) as the program it runs.
7// Quoted text is blanked first, so a runner named inside a filter, a pattern or a message
8// (`-notmatch 'playwright|vitest'`, `grep "pytest"`) doesn't count. Leading env assignments,
9// `time`, `timeout N` and wrappers (npx, bunx, pnpm exec, uv run, poetry run) are dropped so
10// the step starts with its program.
11function steps(command: string): { program: string }[] {
12 const unquoted = command.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, '""')
13 return unquoted
14 .split(/&&|\|\||[;|\n]/)
15 .map(raw => {
16 let program = raw.trim().replace(/^[({&\s]+/, '')
17 for (;;) {
18 const next = program
19 .replace(/^[({]\s*/, '')
20 .replace(/^(do|then|else|!)\s+/, '')
21 .replace(/^export\s+\w+=\S*\s*/, '')
22 .replace(/^\$env:\w+\s*=\s*\S+\s*/i, '')
23 .replace(/^\w+=\S*\s+/, '')
24 .replace(/^time\s+(-p\s+)?/, '')
25 .replace(/^timeout\s+\d+\s+/, '')
26 .replace(/^(npx|bunx|pnpm\s+exec|uv\s+run|poetry\s+run)\s+(--?[\w-]+(=\S+)?\s+)*/, '')
27 if (next === program) break
28 program = next
29 }
30 return { program }
31 })
32 .filter(step => step.program)
33}
34
35const anyOf = (...parts: string[]) => new RegExp(`^(?:${parts.join('|')})`, 'i')
36
37// The only jobs the pane follows. Nothing else gets a row, even when Claude backgrounds it:
38// to follow a new kind of job, add it here. Each one is moved to the background (when the call
39// left run_in_background unset) so the pane can see its output.
40const FOLLOWED = anyOf(
41 // Unit, integration and e2e test runs.
42 String.raw`(vitest|jest|pytest)\b`,
43 String.raw`playwright\s+test\b`,
44 String.raw`python\d?\s+-m\s+pytest\b`,
45 String.raw`(dotnet|go|cargo)\s+test\b`,
46 String.raw`(npm|pnpm|yarn|bun)\s+(run\s+)?(test|e2e)[\w:-]*`,
47 String.raw`node\s+(\S+\s+)*?\S*cli\.js\s+test\b`,
48 // PokeJudge case runs.
49 String.raw`dotnet\s+run\b.*\bevaluate\b`,
50)
51
52// Dev servers (LMI's dev:e2e and bench:serve, vite, wrangler dev, uvicorn): they never finish,
53// so the pane lists them apart, with their port and a Stop button, and stopping one isn't a wilt.
54const SERVER = anyOf(
55 String.raw`(npm|pnpm|yarn|bun)\s+(run\s+)?(dev|serve|start)[\w:-]*`,
56 String.raw`(npm|pnpm|yarn|bun)\s+run\s+bench:serve\b`,
57 String.raw`(node\s+\S*)?wrangler(\.js)?\s+dev\b`,
58 String.raw`vite\b(?!\s+build)`,
59 String.raw`(python\d?\s+-m\s+)?uvicorn\b`,
60 String.raw`dotnet\s+watch\b`,
61)
62const PORT = /(?:localhost|127\.0\.0\.1|0\.0\.0\.0):(\d{2,5})|--port[= ](\d{2,5})|\bPORT=(\d{2,5})/
63
64// A foreground sleep of a minute or more, which Claude uses to wait on its own background jobs.
65const LONG_SLEEP = /^\s*(?:Start-)?sleep\s+(?:-Seconds\s+)?(\d+)/i
66
67export const isFollowed = (command: string) =>
68 steps(command).some(({ program }) => FOLLOWED.test(program))
69
70export const isServerCommand = (command: string) => steps(command).some(({ program }) => SERVER.test(program))
71
72// What row a background task gets from the command that started it: a dev server, a followed
73// job, or none (undefined). A task whose command isn't known gets none either.
74export function rowKind(command: string | undefined): 'server' | 'job' | undefined {
75 if (!command) return undefined
76 if (isServerCommand(command)) return 'server'
77 return isFollowed(command) ? 'job' : undefined
78}
79
80export function portOf(text: string): number | undefined {
81 const m = PORT.exec(text)
82 return m ? Number(m[1] ?? m[2] ?? m[3]) : undefined
83}
84
85// The deny for a foreground `sleep 60+` while a job (not a server) is running, or undefined.
86export function sleepDenial(command: string, jobs: readonly Job[]): string | undefined {
87 const sleep = LONG_SLEEP.exec(command)
88 if (!sleep || Number(sleep[1]) < 60) return undefined
89 const waiting = jobs.filter(j => j.status === 'running' && j.kind !== 'server')
90 if (!waiting.length) return undefined
91 return (
92 `job-progress: ${waiting.map(j => `"${j.label}"`).join(', ')} is still running in the background. ` +
93 'You get a <task-notification> when it ends, so do not sleep to wait for it: do other work or end your turn.'
94 )
95}
96
97// The servers `/progress stop [n]` stops: all of them, or the nth as the pane numbers them
98// (the same order as their Stop hotkeys). An error message when there is nothing to stop.
99export function serversToStop(jobs: readonly Job[], which: string): Job[] | string {
100 const servers = jobs.filter(j => j.kind === 'server' && j.status === 'running')
101 if (!servers.length) return 'No dev servers are running.'
102 if (!which) return servers
103 const n = Number(which)
104 const server = Number.isInteger(n) ? servers[n - 1] : undefined
105 if (!server) return `There is no server ${which}; the pane lists ${servers.length} (1–${servers.length}).`
106 return [server]
107}
108
109// Why a plugin's TaskStop call did not stop the task, or undefined when it did. The call
110// answers `{ deny }` when refused and `{ isError, text }` when the tool failed.
111export function stopFailure(answer: { deny?: string; isError?: boolean; text?: string }): string | undefined {
112 if (answer.deny) return answer.deny
113 if (answer.isError) return answer.text || 'TaskStop returned an error'
114 return undefined
115}
116
117// Background dev servers carry a marker in their shell's command line so a stop can find them.
118// TaskStop alone can't stop them on Windows: an npm script runs in Git Bash, which starts each
119// program through a helper process that exits, so no Windows parent link reaches the server
120// and TaskStop leaves the whole tree running. Git Bash's own process table still groups
121// everything the task started under one process group, which killTreeScript uses.
122export const TAG_VAR = 'JOB_PROGRESS_TAG'
123
124export function tagCommand(tool: string, command: string, tag: string): string {
125 return tool === 'PowerShell' ? `$env:${TAG_VAR} = '${tag}'; ${command}` : `export ${TAG_VAR}=${tag}; ${command}`
126}
127
128// Run by Git Bash as `bash -c KILL_TREE kill-tree <tag>`. Finds the processes whose command line
129// carries the marker, kills the Git Bash process group they lead, then the Windows tree under
130// each marked process (a PowerShell task has no group). Prints "killed <n> left <m>".
131export const KILL_TREE = String.raw`
132tag="$1"
133# The pattern is built in two parts so this script's own command line never matches it.
134wins=$(powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object { \$_.CommandLine -like ('*JOB_PROGRESS' + '_TAG' + '*$tag*') -and \$_.Name -notmatch 'powershell|pwsh' } | ForEach-Object { \$_.ProcessId }" | tr -d '\r' | tr '\n' ' ')
135pgid=$(ps | awk -v ws=" $wins " 'NR>1 && index(ws, " " $4 " ") { print $3; exit }')
136targets="$wins"
137[ -n "$pgid" ] && targets="$targets $(ps | awk -v g="$pgid" 'NR>1 && $3 == g { print $4 }' | tr '\n' ' ')"
138n=0
139for w in $targets; do taskkill //PID "$w" //T //F >/dev/null 2>&1 && n=$((n + 1)); done
140left=0
141[ -n "$pgid" ] && left=$(ps | awk -v g="$pgid" 'NR>1 && $3 == g' | wc -l)
142echo "killed $n left $left"
143`
144
145// What the kill script printed: how many it killed and how many are still running.
146export function killResult(stdout: string): { killed: number; left: number } | undefined {
147 const m = /killed (\d+) left (\d+)/.exec(stdout)
148 return m ? { killed: Number(m[1]), left: Number(m[2]) } : undefined
149}
150hooks/parse.ts 147 lines1// Reads progress out of a job's raw output. Works on whatever the runner prints:
2// an explicit `PROGRESS 3/10 label` line wins, then [n/m] counters (Playwright),
3// then a bare n/m, then a percentage; pass/fail counts come from runner summaries
4// (vitest, jest, pytest, Playwright, dotnet test) or per-test marks.
5
6export type Progress = {
7 done?: number
8 total?: number
9 pct?: number
10 passed?: number
11 failed?: number
12 /** What passed and failed count (`2 scenarios passed · 1 failed`); absent for plain tests. */
13 unit?: string
14 last?: string
15}
16
17const ANSI = /\x1b\[[0-9;?]*[ -\/]*[@-~]/g
18
19function lastMatch(re: RegExp, text: string): RegExpExecArray | undefined {
20 let m: RegExpExecArray | null
21 let found: RegExpExecArray | undefined
22 re.lastIndex = 0
23 while ((m = re.exec(text))) found = m
24 return found
25}
26
27// The last n/m pair that reads as progress. Card numbers (`096/182`, zero-padded) and
28// centering ratios (`42/58`, the two sides summing to 100) are skipped.
29function lastProgress(re: RegExp, text: string): RegExpExecArray | undefined {
30 const all = [...text.matchAll(re)] as RegExpExecArray[]
31 for (const m of all.reverse()) {
32 const [, a = '', b = ''] = m
33 const done = Number(a), total = Number(b)
34 if (/^0\d/.test(a) || /^0\d/.test(b)) continue
35 if (done + total === 100 && done !== total && total !== 100) continue
36 if (total > 1 && done <= total) return m
37 }
38 return undefined
39}
40
41function lastNum(re: RegExp, text: string): number | undefined {
42 const m = lastMatch(re, text)
43 return m ? Number(m[1]) : undefined
44}
45
46export function parse(raw: string): Progress {
47 const text = raw.replace(ANSI, '').replace(/\r(?!\n)/g, '\n')
48 const out: Progress = {}
49
50 const explicit = lastMatch(/^PROGRESS\s+(\d+)\s*\/\s*(\d+)/gm, text)
51 const bracket = lastProgress(/\[\s*(\d+)\s*\/\s*(\d+)\s*\]/g, text)
52 const bare = lastProgress(/(?<![\w\/.:-])(\d{1,6}) ?\/ ?(\d{1,6})(?![\w\/.:-])/g, text)
53 for (const m of [explicit, bracket, bare]) {
54 if (m && Number(m[2]) > 1 && Number(m[1]) <= Number(m[2])) {
55 out.done = Number(m[1])
56 out.total = Number(m[2])
57 break
58 }
59 }
60 if (out.total === undefined) {
61 const pct = lastNum(/(?<![\d.])(\d{1,3}(?:\.\d+)?)\s?%/g, text)
62 if (pct !== undefined && pct <= 100) out.pct = pct
63 // A runner that announces its size up front ("Running 40 tests", "collected 40 items").
64 const planned = lastNum(/(?:Running|collected)\s+(\d+)\s+(?:tests?|items?)/gi, text)
65 if (planned) out.total = planned
66 }
67
68 // Summary lines first; per-test marks as a fallback while the run is still going.
69 // Only counts shaped like a summary: "3 failed | 88 passed", "1 failed, 41 passed in 2s", " 2 failed".
70 // Not "0.85 failed" (a reading), "attempt 1 failed" (a retry) or "2/3 passed" (a sub-score).
71 const count = (word: string) =>
72 new RegExp(String.raw`(?<![\d./])(?<!attempt )(\d+) +${word}(?=[ \t]*(?:$|[,|(;=]|in\b))`, 'gim')
73 out.passed = lastNum(count('passed'), text) ?? lastNum(/Passed:\s+(\d+)/g, text)
74 out.failed = lastNum(count('failed'), text) ?? lastNum(/Failed:\s+(\d+)/g, text)
75
76 pokeJudge(text, out)
77
78 // ten-or-not attack sweeps: one "shadow-dirs 165 cases 2 WRONG" line per family.
79 const families = [...text.matchAll(/^\s*[\w-]+\s+(\d+) cases\s+(\d+) WRONG\s*$/gm)]
80 if (families.length && out.passed === undefined && out.failed === undefined) {
81 out.failed = families.reduce((n, m) => n + Number(m[2]), 0)
82 out.passed = families.reduce((n, m) => n + Number(m[1]), 0) - out.failed
83 }
84
85 if (out.passed === undefined && out.failed === undefined) {
86 const pass = text.match(/^\s*(?:✓|✔|√|ok\b|PASS\b|Passed\s)/gm)?.length ?? 0
87 const fail = text.match(/^\s*(?:✘|✗|×|not ok\b|FAIL\b|Failed\s)/gm)?.length ?? 0
88 if (pass || fail) {
89 out.passed = pass
90 out.failed = fail
91 }
92 }
93 if (out.done === undefined && out.total && (out.passed ?? 0) + (out.failed ?? 0) > 0) {
94 out.done = Math.min(out.total, (out.passed ?? 0) + (out.failed ?? 0))
95 }
96
97 const lines = text.split('\n').map(l => l.trim()).filter(Boolean)
98 out.last ??= lines.at(-1)?.slice(0, 200)
99 return out
100}
101
102// PokeJudge evals. Each scenario opens with "--- [id] Category ---" and lists [PASS]/[FAIL]
103// criteria; each eval process ends with "Result: 13/20 scenarios fully passed …". A loop of
104// one-scenario evals (each wrapped in "=== id ===") has a Result line per scenario.
105//
106// The pane names the scenario being worked on rather than the last line ("Pacing model calls…").
107// Counts are scenarios: the Result lines summed once any exist, otherwise the finished blocks so
108// far (a block is finished when the next one or "--- By category ---" starts), passed when it has
109// no [FAIL]. A one-process eval also gets its bar from "N scenario(s), R run(s) each".
110function pokeJudge(text: string, out: Progress) {
111 const results = [...text.matchAll(/^Result:\s+(\d+)\/(\d+) scenarios fully passed/gm)]
112 if (results.length) {
113 out.unit = 'scenarios'
114 out.passed = results.reduce((n, m) => n + Number(m[1]), 0)
115 out.failed = results.reduce((n, m) => n + Number(m[2]) - Number(m[1]), 0)
116 }
117
118 const marks = [...text.matchAll(/^--- \[([\w-]+)\] (.+?) ---$|^=== ([\w-]+) ===$|^--- By category ---$/gm)]
119 if (!marks.length) return
120 out.unit = 'scenarios'
121
122 const current = marks.filter(m => m[1] || m[3]).at(-1)
123 if (current) out.last = current[1] ? `${current[1]} · ${current[2]}` : current[3]
124
125 // A loop gets its bar from its own PROGRESS lines; one process for the whole eval counts its
126 // finished scenario blocks.
127 if (marks.some(m => m[3])) return
128 let passed = 0
129 let failed = 0
130 marks.forEach((mark, i) => {
131 const next = marks[i + 1]
132 if (!mark[1] || !next) return
133 if (/^\s*\[FAIL\]/m.test(text.slice(mark.index, next.index))) failed++
134 else passed++
135 })
136 if (!results.length) {
137 out.passed = passed
138 out.failed = failed
139 }
140 // Over any n/m read earlier: "Result: 2/3" is a score, not how far the eval has got.
141 const planned = /(\d+) scenario\(s\), (\d+) run\(s\) each/.exec(text)
142 if (planned) {
143 out.total = Number(planned[1]) * Number(planned[2])
144 out.done = results.length ? out.total : passed + failed
145 }
146}
147types/index.d.ts 28 lines1export type Job = {
2 id: string
3 label: string
4 path: string
5 startedAt: number
6 status: string
7 /** A dev server: no bar, no bloom or wilt, a Stop button. */
8 kind?: 'job' | 'server'
9 /** The marker its shell's command line carries, for stopping the whole process tree. */
10 tag?: string
11 port?: number
12 endedAt?: number
13 size?: number
14 done?: number
15 total?: number
16 pct?: number
17 passed?: number
18 failed?: number
19 unit?: string
20 last?: string
21}
22
23declare module 'claude-code' {
24 interface PluginState {
25 'job-progress': { jobs: Job[] }
26 }
27}
28