Live progress bars above the prompt for background shells and subagents

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

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.
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.
tail on background tasks' output files while they run.npm test) show only name and elapsed time.ctrl+x ctrl+a.claude --plugin-dir ./task-progress # load with hot reload
claude plugin test ./task-progress
claude plugin validate ./task-progresshooks/register.tsx 263 lines1import { 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}
263hooks/format.ts 91 lines1import 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}
91hooks/protocol.ts 75 lines1import 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}
75hooks/tasks.ts 185 lines1import 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}
185types/index.d.ts 31 lines1export 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