SLOPSHOPPER

task-progress

Live progress bars above the prompt for background shells and subagents

newbandguardpromptprocesstimer
v0.1.0MITupdated 2026-10-02genail/genail-mods/task-progress
A shopper browsing a rack in a slop shop
README

task-progress

Live progress bars above the Claude Code prompt for background shells and background subagents.

Three background agents reporting progress

Each running task gets a row: spinner, name, bar with count, current step, elapsed time. A finished row shows done or failed for 5 seconds, then disappears.

How progress gets reported

Tasks print lines in this format:

::progress::37/120 Migrating users     fraction
::progress::42% Uploading              percentage
::progress::? Indexing repo            no estimate

The last line wins, so a task can change its total mid-run.

  • Background shells: the mod reads new lines from the task's output file (stdout and stderr) every 500 ms.
  • Background subagents: the mod reads the agent's messages. Until an agent reports progress, its row shows the tool-call count and last action.

What it changes in Claude Code

  • Adds a section of about 140 tokens to the system prompt: long background scripts should print progress lines.
  • Appends a similar rule to the task prompt of every background subagent.
  • Runs tail on background tasks' output files while they run.

Limitations

  • Foreground commands, workflows and Claude's own task list are not tracked.
  • Tools that don't print progress lines (e.g. npm test) show only name and elapsed time.
  • Dev servers and watchers keep a row until stopped.
  • Collapse the band with ctrl+x ctrl+a.

Development

claude --plugin-dir ./task-progress   # load with hot reload
claude plugin test ./task-progress
claude plugin validate ./task-progress
Source 5 files
hooks/register.tsx 263 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { TaskRow } from '../types'
5import { TICK_MS, formatRow, nameCells } from './format'
6import { AGENT_RULE, PROGRESS_MARKER, SCRIPT_RULE, scanChunk } from './protocol'
7import type { ShellRead } from './tasks'
8import {
9  addTask,
10  applyAgentStatuses,
11  applyAppendedRow,
12  applyShellRead,
13  describeAction,
14  pruneFinished,
15  recordActivity,
16  settleShells,
17  shellOutputFile,
18} from './tasks'
19
20const tasksAtom = atom({ plugin: 'task-progress', key: 'tasks' } as const, [])
21const nowAtom = atom({ plugin: 'task-progress', key: 'now' } as const, 0)
22
23const RULE_SECTION_ID = 'task-progress:rule'
24const NOTIFICATION_TAG = '<task-notification>'
25const TAIL_TIMEOUT_MS = 5000
26
27/** Room for the collapse control (`[-]`) Claude Code draws at the band's top-right. */
28const COLLAPSE_CONTROL_CELLS = 4
29
30const TONE_COLOR = { running: 'cyan', done: 'green', failed: 'red' } as const
31
32/** Writes the tasks only when `change` changes them, so a no-op draws nothing. */
33async function changeTasks($: EngineInterface, change: (tasks: TaskRow[]) => TaskRow[]) {
34  const tasks = await read($, tasksAtom)
35
36  if (change(tasks) !== tasks) {
37    await update($, tasksAtom, change)
38  }
39}
40
41async function startTask($: EngineInterface, task: Omit<TaskRow, 'startedAt'>) {
42  const now = await $.clock.now()
43
44  await changeTasks($, tasks => addTask(tasks, { ...task, startedAt: now }))
45  await update($, nowAtom, () => now)
46}
47
48async function readShell($: EngineInterface, task: TaskRow): Promise<ShellRead | null> {
49  if (task.outputFile === undefined) {
50    return null
51  }
52
53  const offset = task.readOffset ?? 0
54  const { exitCode, stdout } = await $.process.run(
55    ['tail', '-c', `+${offset + 1}`, task.outputFile],
56    { timeoutMs: TAIL_TIMEOUT_MS },
57  )
58
59  return exitCode === 0 ? { id: task.id, ...scanChunk(stdout) } : null
60}
61
62async function tick($: EngineInterface) {
63  const tasks = await read($, tasksAtom)
64
65  if (tasks.length === 0) {
66    return
67  }
68
69  const now = await $.clock.now()
70  const running = tasks.filter(task => task.status === 'running')
71  const shellReads = await Promise.all(
72    running.filter(task => task.kind === 'shell').map(task => readShell($, task)),
73  )
74  const agents = running.some(task => task.kind === 'agent') ? await $.agent.list() : []
75
76  await update($, tasksAtom, current => {
77    let next = current
78
79    for (const shellRead of shellReads) {
80      next = shellRead === null ? next : applyShellRead(next, shellRead)
81    }
82
83    return pruneFinished(applyAgentStatuses(next, agents, now), now)
84  })
85  await update($, nowAtom, () => now)
86}
87
88function textOf(content: readonly { type: string; [field: string]: unknown }[]): string {
89  return content
90    .flatMap(block => (block.type === 'text' && typeof block.text === 'string' ? [block.text] : []))
91    .join('\n')
92}
93
94export const register: Register = on => {
95  on('session.start', async ($, e, next) => {
96    let isTicking = false
97    let lastError = ''
98
99    $.clock.every(TICK_MS, () => {
100      if (isTicking) {
101        return
102      }
103
104      isTicking = true
105      tick($)
106        .then(
107          () => {
108            lastError = ''
109          },
110          (error: unknown) => {
111            const message = error instanceof Error ? error.message : String(error)
112
113            if (message !== lastError) {
114              lastError = message
115              $.ui.log(`task-progress: ${message}`)
116            }
117          },
118        )
119        .finally(() => {
120          isTicking = false
121        })
122    })
123
124    return next(e)
125  })
126
127  on('prompt.compose', async ($, e, next) => {
128    const { sections } = await next(e)
129
130    return {
131      sections: [...sections, { id: RULE_SECTION_ID, text: SCRIPT_RULE, scope: 'session' }],
132    }
133  })
134
135  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
136    const ran = await next(e)
137    const taskId =
138      ran.deny === undefined && ran.isError !== true ? ran.result.backgroundTaskId : undefined
139
140    if (taskId !== undefined) {
141      await startTask($, {
142        id: taskId,
143        kind: 'shell',
144        name: e.description || e.command.split('\n')[0] || e.command,
145        status: 'running',
146        toolCalls: 0,
147        outputFile: shellOutputFile(ran.text ?? ''),
148        readOffset: 0,
149      })
150    }
151
152    return ran
153  })
154
155  on('tool.call', async ($, e, next) => {
156    const { agentId } = e
157
158    if (agentId !== undefined) {
159      const action = describeAction(String(e.tool), e)
160
161      await changeTasks($, tasks => recordActivity(tasks, agentId, action))
162    }
163
164    return next(e)
165  })
166
167  on('agent.spawn', async ($, e, next) => {
168    if (!e.background) {
169      return next(e)
170    }
171
172    const spawned = await next({ ...e, prompt: `${e.prompt}\n\n${AGENT_RULE}` })
173
174    if (spawned.agentId !== undefined) {
175      await startTask($, {
176        id: spawned.agentId,
177        kind: 'agent',
178        name: e.description || e.subagentType,
179        status: 'running',
180        toolCalls: 0,
181      })
182    }
183
184    return spawned
185  })
186
187  on('session.append', async ($, e, next) => {
188    const stored = await next(e)
189    const text = textOf(e.message.content)
190
191    if (text.includes(PROGRESS_MARKER) || text.includes(NOTIFICATION_TAG)) {
192      const now = await $.clock.now()
193      const appended = { type: e.message.type, agentId: e.agentId, text }
194
195      await changeTasks($, tasks => applyAppendedRow(tasks, appended, now))
196    }
197
198    return stored
199  })
200
201  on('classic.Stop', async ($, e, next) => {
202    const inFlight = e.background_tasks
203
204    if (inFlight !== undefined) {
205      const now = await $.clock.now()
206      const ids = inFlight.map(task => task.id)
207
208      await changeTasks($, tasks => settleShells(tasks, ids, now))
209    }
210
211    return next(e)
212  })
213
214  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
215    if (e.props.hasSurvey) {
216      return next(e)
217    }
218
219    const tasks = await read($, tasksAtom)
220
221    if (tasks.length === 0) {
222      return next(e)
223    }
224
225    const now = await read($, nowAtom)
226    const { Box, Text } = $.ui.resolve(e)
227    const views = tasks.map(task => formatRow(task, now, e.props.bodyColumns))
228    const nameWidth = nameCells(
229      views.map(view => view.name),
230      e.props.bodyColumns,
231    )
232
233    return (
234      <Box flexDirection="column" paddingRight={COLLAPSE_CONTROL_CELLS}>
235        {views.map(view => (
236          <Box key={view.id} gap={1}>
237            <Box flexShrink={0}>
238              <Text color={TONE_COLOR[view.tone]}>{view.spinner}</Text>
239            </Box>
240            <Box width={nameWidth} flexShrink={0}>
241              <Text wrap="truncate-end">{view.name}</Text>
242            </Box>
243            {view.meter === '' ? null : (
244              <Box flexShrink={0}>
245                <Text color={TONE_COLOR[view.tone]}>{view.meter}</Text>
246              </Box>
247            )}
248            {/* The one part that gives way when the row is too wide. */}
249            <Box flexGrow={1} flexShrink={1}>
250              <Text dimColor wrap="truncate-end">
251                {view.detail}
252              </Text>
253            </Box>
254            <Box flexShrink={0}>
255              <Text dimColor>{view.elapsed}</Text>
256            </Box>
257          </Box>
258        ))}
259      </Box>
260    )
261  })
262}
263
hooks/format.ts 91 lines
1import type { TaskRow, TaskStatus } from '../types'
2
3/** The band's refresh period; the spinner moves one frame per tick. */
4export const TICK_MS = 500
5
6const SPINNER = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷']
7const FINISHED_MARK = '•'
8const BAR_FULL = '█'
9const BAR_EMPTY = '░'
10const WIDE_BAR_CELLS = 20
11const NARROW_BAR_CELLS = 10
12const NARROW_COLUMNS = 80
13/** The most of the band's width the name column may take. */
14const NAME_MAX_SHARE = 0.4
15
16const MS_PER_SECOND = 1000
17const SECONDS_PER_MINUTE = 60
18const SECONDS_PER_HOUR = 3600
19
20export type RowView = {
21  id: string
22  spinner: string
23  name: string
24  meter: string
25  detail: string
26  elapsed: string
27  tone: TaskStatus
28}
29
30const twoDigits = (n: number) => String(n).padStart(2, '0')
31
32export function formatElapsed(ms: number): string {
33  const seconds = Math.max(0, Math.floor(ms / MS_PER_SECOND))
34  const hours = Math.floor(seconds / SECONDS_PER_HOUR)
35  const minutes = Math.floor(seconds / SECONDS_PER_MINUTE)
36
37  return hours > 0
38    ? `${hours}h${twoDigits(minutes % SECONDS_PER_MINUTE)}m`
39    : `${minutes}m${twoDigits(seconds % SECONDS_PER_MINUTE)}s`
40}
41
42/** The name column's width: the longest name, capped to a share of the band. */
43export function nameCells(names: readonly string[], columns: number): number {
44  const longest = Math.max(1, ...names.map(name => name.length))
45
46  return Math.max(1, Math.min(longest, Math.floor(columns * NAME_MAX_SHARE)))
47}
48
49export function renderBar(fraction: number, cells: number): string {
50  const filled = Math.round(Math.min(1, Math.max(0, fraction)) * cells)
51
52  return BAR_FULL.repeat(filled) + BAR_EMPTY.repeat(cells - filled)
53}
54
55function meterOf(task: TaskRow, barCells: number): string {
56  const { progress } = task
57
58  if (task.status !== 'running') {
59    return task.status
60  }
61
62  if (progress?.kind === 'fraction') {
63    return `${renderBar(progress.done / progress.total, barCells)} ${progress.done}/${progress.total}`
64  }
65
66  if (progress?.kind === 'percent') {
67    return `${renderBar(progress.percent / 100, barCells)} ${Math.round(progress.percent)}%`
68  }
69
70  if (progress === undefined && task.kind === 'agent') {
71    return `▸ ${task.toolCalls} tool calls`
72  }
73
74  return ''
75}
76
77export function formatRow(task: TaskRow, now: number, columns: number): RowView {
78  const barCells = columns < NARROW_COLUMNS ? NARROW_BAR_CELLS : WIDE_BAR_CELLS
79  const isRunning = task.status === 'running'
80
81  return {
82    id: task.id,
83    spinner: isRunning ? (SPINNER[Math.floor(now / TICK_MS) % SPINNER.length] ?? '') : FINISHED_MARK,
84    name: task.name,
85    meter: meterOf(task, barCells),
86    detail: task.progress?.label || task.lastAction || '',
87    elapsed: formatElapsed((task.endedAt ?? now) - task.startedAt),
88    tone: task.status,
89  }
90}
91
hooks/protocol.ts 75 lines
1import type { Progress } from '../types'
2
3export const PROGRESS_MARKER = '::progress::'
4
5const PROGRESS_LINE = /::progress::(?:(\d+)\/(\d+)|(\d+(?:\.\d+)?)%|\?)(?:\s+(.*))?$/
6
7const LINE_BREAK = '\n'
8
9export const SCRIPT_RULE = `# Progress reporting
10When you write a script or command that can run longer than about 30 seconds and you run it in the background, make it print progress lines to stderr, at most once per second:
11- \`${PROGRESS_MARKER}<done>/<total> <label>\` when the total is known, e.g. \`${PROGRESS_MARKER}37/120 Migrating users\`
12- \`${PROGRESS_MARKER}<percent>% <label>\` when only a percentage is known
13- \`${PROGRESS_MARKER}? <label>\` when neither is
14If you find more or fewer steps than planned, report the new total in the next line.
15The user watches these as a live progress bar.`
16
17export const AGENT_RULE = `---
18Progress reporting: you run in the background, and the user watches a live progress bar fed by your messages. Each time you start a new step of your work, begin a message with one line \`${PROGRESS_MARKER}<done>/<total> <label>\` (or \`<percent>%\`, or \`?\` when you cannot estimate), e.g. \`${PROGRESS_MARKER}3/8 Reviewing the auth module\`. One line per step, no more. If you find more or fewer steps than planned, report the new total in the next line. Long scripts you run in the background should print the same lines to stderr, at most once per second.`
19
20export function parseProgressLine(line: string): Progress | null {
21  const match = PROGRESS_LINE.exec(line.trimEnd())
22
23  if (match === null) {
24    return null
25  }
26
27  const [, done, total, percent, rawLabel] = match
28  const label = (rawLabel ?? '').trim()
29
30  if (done !== undefined && total !== undefined) {
31    const totalCount = Number(total)
32
33    return totalCount > 0 ? { kind: 'fraction', done: Number(done), total: totalCount, label } : null
34  }
35
36  if (percent !== undefined) {
37    return { kind: 'percent', percent: Number(percent), label }
38  }
39
40  return { kind: 'unknown', label }
41}
42
43export function lastProgress(text: string): Progress | null {
44  const lines = text.split(LINE_BREAK)
45
46  for (let i = lines.length - 1; i >= 0; i -= 1) {
47    const progress = parseProgressLine(lines[i] ?? '')
48
49    if (progress !== null) {
50      return progress
51    }
52  }
53
54  return null
55}
56
57/**
58 * Scans the complete lines of a chunk read from an output file: the last
59 * progress among them, and how many bytes they span (the next read's start).
60 */
61export function scanChunk(chunk: string): { progress: Progress | null; consumedBytes: number } {
62  const end = chunk.lastIndexOf(LINE_BREAK)
63
64  if (end === -1) {
65    return { progress: null, consumedBytes: 0 }
66  }
67
68  const complete = chunk.slice(0, end + 1)
69
70  return {
71    progress: lastProgress(complete),
72    consumedBytes: new TextEncoder().encode(complete).length,
73  }
74}
75
hooks/tasks.ts 185 lines
1import type { Progress, TaskRow, TaskStatus } from '../types'
2
3import { lastProgress } from './protocol'
4
5/** How long a finished row stays in the band. */
6export const LINGER_MS = 5000
7
8const FAILED_STATUSES = new Set(['failed', 'killed', 'error'])
9const ACTIVE_STATUSES = new Set(['running', 'pending'])
10
11const NOTIFICATION = /<task-notification>([\s\S]*?)<\/task-notification>/g
12const NOTIFICATION_ID = /<task-id>([^<]+)<\/task-id>/
13const NOTIFICATION_STATUS = /<status>([^<]+)<\/status>/
14const OUTPUT_FILE = /Output is being written to: (\S+\.output)/
15
16const FILE_ARGS = ['file_path', 'notebook_path', 'path']
17const TEXT_ARGS = ['description', 'command', 'pattern', 'url', 'query']
18
19type AgentStatus = { id: string; status: string }
20
21function endStatus(status: string): TaskStatus {
22  return FAILED_STATUSES.has(status) ? 'failed' : 'done'
23}
24
25/** Applies `change` to the rows it returns a new row for; the same list when none changed. */
26function patch(tasks: TaskRow[], change: (task: TaskRow) => TaskRow | null): TaskRow[] {
27  let isChanged = false
28  const next = tasks.map(task => {
29    const changed = change(task)
30
31    if (changed === null) {
32      return task
33    }
34
35    isChanged = true
36
37    return changed
38  })
39
40  return isChanged ? next : tasks
41}
42
43function end(task: TaskRow, status: string, now: number): TaskRow {
44  return { ...task, status: endStatus(status), endedAt: now }
45}
46
47export function addTask(tasks: TaskRow[], task: TaskRow): TaskRow[] {
48  return tasks.some(t => t.id === task.id)
49    ? tasks.map(t => (t.id === task.id ? task : t))
50    : [...tasks, task]
51}
52
53export function recordActivity(tasks: TaskRow[], id: string, action: string): TaskRow[] {
54  return patch(tasks, task =>
55    task.id === id ? { ...task, toolCalls: task.toolCalls + 1, lastAction: action } : null,
56  )
57}
58
59export function setProgress(tasks: TaskRow[], id: string, progress: Progress): TaskRow[] {
60  return patch(tasks, task => (task.id === id ? { ...task, progress } : null))
61}
62
63export function finishTask(tasks: TaskRow[], id: string, status: string, now: number): TaskRow[] {
64  return patch(tasks, task =>
65    task.id === id && task.status === 'running' ? end(task, status, now) : null,
66  )
67}
68
69export function pruneFinished(tasks: TaskRow[], now: number): TaskRow[] {
70  const kept = tasks.filter(task => task.endedAt === undefined || now - task.endedAt < LINGER_MS)
71
72  return kept.length === tasks.length ? tasks : kept
73}
74
75/** Finishes the agent rows `$.agent.list()` no longer reports as active. */
76export function applyAgentStatuses(
77  tasks: TaskRow[],
78  agents: readonly AgentStatus[],
79  now: number,
80): TaskRow[] {
81  const statusById = new Map(agents.map(agent => [agent.id, agent.status]))
82
83  return patch(tasks, task => {
84    const status = statusById.get(task.id)
85    const isOver = task.kind === 'agent' && task.status === 'running' && status !== undefined
86
87    return isOver && !ACTIVE_STATUSES.has(status) ? end(task, status, now) : null
88  })
89}
90
91/** Finishes the running shells missing from the engine's in-flight list. */
92export function settleShells(
93  tasks: TaskRow[],
94  inFlightIds: readonly string[],
95  now: number,
96): TaskRow[] {
97  const inFlight = new Set(inFlightIds)
98
99  return patch(tasks, task =>
100    task.kind === 'shell' && task.status === 'running' && !inFlight.has(task.id)
101      ? end(task, 'completed', now)
102      : null,
103  )
104}
105
106export function parseTaskNotifications(text: string): AgentStatus[] {
107  return [...text.matchAll(NOTIFICATION)].flatMap(([, body = '']) => {
108    const id = NOTIFICATION_ID.exec(body)?.[1]
109    const status = NOTIFICATION_STATUS.exec(body)?.[1]
110
111    return id !== undefined && status !== undefined ? [{ id, status }] : []
112  })
113}
114
115/**
116 * What one appended conversation row changes: an agent's own progress line,
117 * or a background task's completion notice.
118 */
119export function applyAppendedRow(
120  tasks: TaskRow[],
121  appended: { type: string; agentId?: string; text: string },
122  now: number,
123): TaskRow[] {
124  let next = tasks
125  const { agentId } = appended
126
127  if (agentId !== undefined && appended.type === 'assistant') {
128    const progress = lastProgress(appended.text)
129
130    if (progress !== null) {
131      next = setProgress(next, agentId, progress)
132    }
133  }
134
135  for (const { id, status } of parseTaskNotifications(appended.text)) {
136    next = finishTask(next, id, status, now)
137  }
138
139  return next
140}
141
142export function shellOutputFile(text: string): string | undefined {
143  return OUTPUT_FILE.exec(text)?.[1]
144}
145
146export function describeAction(tool: string, input: Readonly<Record<string, unknown>>): string {
147  for (const key of FILE_ARGS) {
148    const value = input[key]
149
150    if (typeof value === 'string' && value !== '') {
151      return `${tool} ${value.split('/').pop()}`
152    }
153  }
154
155  for (const key of TEXT_ARGS) {
156    const value = input[key]
157
158    if (typeof value === 'string' && value !== '') {
159      return `${tool} ${value.split('\n')[0]}`
160    }
161  }
162
163  return tool
164}
165
166export type ShellRead = { id: string; consumedBytes: number; progress: Progress | null }
167
168export function applyShellRead(tasks: TaskRow[], shellRead: ShellRead): TaskRow[] {
169  const { id, consumedBytes, progress } = shellRead
170
171  if (consumedBytes === 0 && progress === null) {
172    return tasks
173  }
174
175  return patch(tasks, task =>
176    task.id === id
177      ? {
178          ...task,
179          readOffset: (task.readOffset ?? 0) + consumedBytes,
180          progress: progress ?? task.progress,
181        }
182      : null,
183  )
184}
185
types/index.d.ts 31 lines
1export type Progress =
2  | { kind: 'fraction'; done: number; total: number; label: string }
3  | { kind: 'percent'; percent: number; label: string }
4  | { kind: 'unknown'; label: string }
5
6export type TaskKind = 'shell' | 'agent'
7
8export type TaskStatus = 'running' | 'done' | 'failed'
9
10export type TaskRow = {
11  id: string
12  kind: TaskKind
13  name: string
14  startedAt: number
15  status: TaskStatus
16  endedAt?: number
17  /** A shell's output file, which the mod tails for progress lines. */
18  outputFile?: string
19  /** Bytes of `outputFile` already scanned. */
20  readOffset?: number
21  progress?: Progress
22  toolCalls: number
23  lastAction?: string
24}
25
26declare module 'claude-code' {
27  interface PluginState {
28    'task-progress': { tasks: TaskRow[]; now: number }
29  }
30}
31