SLOPSHOPPER

job-progress

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

newpaneguardcommandtoastprocess
v0.1.0no licenseupdated 2026-10-06jkhaynes/ai-agent-skills/mods/job-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · job-progress
│ ┃ Progress ✕ › fix the failing auth test and add an audit log call │ ┃ No jobs yet. │ ┃ Test runs and PokeJudge case runs show up ⏺ Read(src/auth.ts) │ ┃ here. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ [ Close ] ⎿ 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 │ │ › /progress │ ⎿ job-progress: Progress pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Progress
No jobs yet. Test runs and PokeJudge case runs show up here. [ Close ]
README

job-progress

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:

The Progress pane running three jobs: a capture run and an e2e suite filling pink bars, and a unit test run that has bloomed

<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>

RunningFinished
Two jobs mid-run with ETAs, and unit tests already bloomedAll three finished: two bloomed, one wilted with 2 failures

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:

  • run /progress close
  • press Esc at an empty prompt
  • click Close, or press x while the pane has focus

The 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.

Tuned for my projects

This copy is set up for my own repos rather than general use:

  • Followed in the background: test runs (vitest, jest, Playwright, pytest, 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.
  • No sleep-waiting: while a job (not a server) is running, a foreground 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.
  • Output it understands: PokeJudge 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.

How it works

PieceWhat 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.appendPicks 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.describeAdds 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.

Progress parsing (hooks/parse.ts)

In order of preference:

  1. PROGRESS 3/10 label lines: the convention for custom scripts.
  2. [12/40] counters (Playwright list reporter).
  3. A bare n/m.
  4. A percentage, plus a planned total from 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.

Install

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"
  }
}

Develop

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.

Limits

  • Output files over 4 MiB stop updating (the $.fs.read cap); the pane keeps the last reading.
  • Auto-backgrounding applies to quick unit test runs too, so Claude waits for the completion notice instead of reading output inline. Remove the RUNNER rewrite in hooks/register.tsx if that gets in the way.
  • Commands and labels are kept in module memory, so a job started just before a mod reload gets no row.
  • A job piped through | 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 buffers output written to a file; scripts should run with python -u (or flush) for live PROGRESS lines.
Source 4 files
hooks/register.tsx 397 lines
1import { 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}
397
hooks/classify.ts 150 lines
1// 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}
150
hooks/parse.ts 147 lines
1// 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}
147
types/index.d.ts 28 lines
1export 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