Progress bars above the prompt for any long task that writes ~/.local/state/agent-progress/<id>.json

A Claude Code mod that draws a progress bar above the prompt for every long task that reports one, from any thread, script or agent.
Report with bin/progress (link it onto your PATH):
progress kernel 812 4500 "Pixel 4a kernel" "compiling"
progress kernel pct 40
progress kernel done
progress kernel clear
It writes ~/.local/state/agent-progress/<id>.json (label, current, total, percent, phase, state, updatedAt, all optional), so anything that can write a file can report. The mod polls that folder every 2 s, hides a running task silent for 15 minutes and a finished one after a minute, and passes the band on to other plugins' bands.
It also shows, with no reporting needed:
bg rows with running time, green or red when it ends);/clip saves the image on the clipboard (a screenshot) to ~/.local/state/clipboard-shots/ and tells the model to read it.
hooks/register.tsx 210 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Activity, BackgroundTask, Task } from '../types'
5
6// Any process reports a task by writing <DIR>/<id>.json:
7// { "label": "kernel build", "current": 812, "total": 4500, "percent": 18, "phase": "3/6 compiling",
8// "state": "running" | "done" | "failed", "updatedAt": <epoch ms> } every field optional.
9const DIR = '.local/state/agent-progress'
10const CLIP_DIR = '.local/state/clipboard-shots'
11const POLL_MS = 2000
12const TICK_MS = 5000
13// A running task that has not written for this long is assumed dead and hidden.
14const STALE_MS = 15 * 60e3
15// A finished task stays on screen this long.
16const DONE_SHOWN_MS = 60e3
17// A turn with no tool call starting or ending for this long reads as possibly stuck. A guess.
18const STALL_MS = 3 * 60e3
19const BAR_CELLS = 20
20// Wider than any terminal; the divider row clips it to the band's width.
21const DIVIDER = '─'.repeat(400)
22
23const tasks = atom({ plugin: 'task-progress', key: 'tasks' } as const, [])
24const background = atom({ plugin: 'task-progress', key: 'background' } as const, [])
25const activity = atom({ plugin: 'task-progress', key: 'activity' } as const, { isTurnRunning: false, lastAt: 0, runningTool: null, toolStartedAt: null })
26const tick = atom({ plugin: 'task-progress', key: 'tick' } as const, 0)
27
28const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : null)
29const str = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : null)
30
31const duration = (ms: number) => {
32 const s = Math.max(0, Math.round(ms / 1000))
33 return s >= 3600 ? `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
34 : s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `${s}s`
35}
36
37// First sighting of each task, for the ETA: percent and time.
38const firstSeen = new Map<string, { at: number; percent: number }>()
39
40async function poll($: any) {
41 const home = await $.env.get('HOME')
42 if (!home) return
43 const dir = `${home}/${DIR}`
44 const entries = await $.fs.list(dir).catch(() => [])
45 const now = await $.clock.now()
46 // Profiles share HOME, so every profile sees every file; show only this account's tasks and unstamped ones.
47 const account = await $.env.get('CLAUDE_CODE_ACCOUNT_UUID')
48 const found: Task[] = []
49 for (const entry of entries) {
50 if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
51 const id = entry.name.slice(0, -5)
52 const raw = await $.fs.read(`${dir}/${entry.name}`).catch(() => null)
53 if (raw === null) continue
54 let data: Record<string, unknown>
55 try { data = JSON.parse(raw as string) } catch { continue }
56 if (account && str(data.account) && data.account !== account) continue
57 const current = num(data.current)
58 const total = num(data.total)
59 const percent = num(data.percent) ?? (current !== null && total ? (current / total) * 100 : null)
60 const state = data.state === 'done' || data.state === 'failed' ? data.state : 'running'
61 const updatedAt = num(data.updatedAt) ?? entry.mtimeMs
62 const age = now - updatedAt
63 if (state === 'running' ? age > STALE_MS : age > DONE_SHOWN_MS) continue
64 let etaMs: number | null = null
65 if (percent !== null && state === 'running') {
66 const first = firstSeen.get(id)
67 if (!first || percent < first.percent) firstSeen.set(id, { at: now, percent })
68 else if (percent > first.percent) etaMs = ((now - first.at) / (percent - first.percent)) * (100 - percent)
69 }
70 found.push({ id, label: str(data.label) ?? id, percent, current, total, phase: str(data.phase), state, updatedAt, etaMs })
71 }
72 found.sort((a, b) => a.id.localeCompare(b.id))
73 const before = await read($, tasks)
74 if (JSON.stringify(before) !== JSON.stringify(found)) await update($, tasks, () => found)
75 const done = (await read($, background)).filter(t => t.endedAt === null || now - t.endedAt < DONE_SHOWN_MS)
76 if (done.length !== (await read($, background)).length) await update($, background, () => done)
77}
78
79// Redraws elapsed times while something time-based is on screen.
80async function advance($: any) {
81 const busy = (await read($, background)).some(t => t.endedAt === null) || (await read($, activity)).isTurnRunning
82 if (busy) await update($, tick, n => n + 1)
83}
84
85async function touch($: any, tool: string | null) {
86 const now = await $.clock.now()
87 await update($, activity, a => ({ ...a, lastAt: now, runningTool: tool, toolStartedAt: tool ? now : null }))
88}
89
90async function saveClipboard($: any) {
91 const home = await $.env.get('HOME')
92 const now = await $.clock.now()
93 const path = `${home}/${CLIP_DIR}/clip-${now}.png`
94 await $.fs.write(`${home}/${CLIP_DIR}/.keep`, '')
95 const script = [
96 `set f to open for access POSIX file "${path}" with write permission`,
97 'write (the clipboard as «class PNGf») to f',
98 'close access f',
99 ]
100 const run = $.process.spawn({ argv: ['/usr/bin/osascript', ...script.flatMap(line => ['-e', line])] })
101 for await (const _ of run) { /* drain */ }
102 const { code } = await run.result
103 return code === 0 ? path : null
104}
105
106export const register: Register = on => {
107 on('session.start', async ($, e, next) => {
108 const started = await next(e)
109 await $.command.register({ name: 'clip', description: 'Attach the image on the clipboard (a screenshot) to the conversation' })
110 await poll($)
111 $.clock.every(POLL_MS, () => { void poll($) })
112 $.clock.every(TICK_MS, () => { void advance($) })
113 return started
114 })
115
116 on('command.run', { command: 'clip' }, async $ => {
117 const path = await saveClipboard($)
118 return path
119 ? { text: `Screenshot from the clipboard saved to ${path}. Read it with the Read tool before answering.` }
120 : { text: 'The clipboard holds no image.' }
121 })
122
123 on('prompt.submit', async ($, e, next) => {
124 const now = await $.clock.now()
125 await update($, activity, () => ({ isTurnRunning: true, lastAt: now, runningTool: null, toolStartedAt: null }))
126 return next(e)
127 })
128
129 on('turn.complete', async ($, e, next) => {
130 await update($, activity, a => ({ ...a, isTurnRunning: false, runningTool: null, toolStartedAt: null }))
131 return next(e)
132 })
133
134 on('tool.call', async ($, e, next) => {
135 await touch($, e.tool)
136 const ran = await next(e)
137 await touch($, null)
138 const id = e.tool === 'Bash' ? (ran.result as any)?.backgroundTaskId : undefined
139 if (typeof id === 'string') {
140 const now = await $.clock.now()
141 const label = str((e as any).description) ?? String((e as any).command ?? 'background command').slice(0, 60)
142 await update($, background, list => [...list.filter(t => t.id !== id), { id, label, startedAt: now, endedAt: null, status: null }])
143 }
144 return ran
145 })
146
147 on('ui.render', { component: 'UserMessage', props: { origin: { kind: 'task-notification' } } }, async ($, e, next) => {
148 const task = e.props.task
149 if (task?.id) {
150 const list = await read($, background)
151 if (list.some(t => t.id === task.id && t.endedAt === null)) {
152 const now = await $.clock.now()
153 await update($, background, all => all.map(t => (t.id === task.id ? { ...t, endedAt: now, status: task.status ?? 'ended' } : t)))
154 }
155 }
156 return next(e)
157 })
158
159 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
160 const shown = await read($, tasks)
161 const bg = await read($, background)
162 const act = await read($, activity)
163 await read($, tick)
164 const below = await next(e)
165 const now = await $.clock.now()
166 const stalled = act.isTurnRunning && act.runningTool === null && act.lastAt > 0 && now - act.lastAt > STALL_MS
167 const longTool = act.isTurnRunning && act.runningTool !== null && act.toolStartedAt !== null && now - act.toolStartedAt > STALL_MS
168 if (e.props.hasSurvey || (shown.length === 0 && bg.length === 0 && !stalled && !longTool)) return below
169 const { Box, Text } = $.ui.resolve(e)
170
171 return (
172 <Box flexDirection="column">
173 {shown.map(t => {
174 const share = t.percent === null ? 0 : Math.min(1, Math.max(0, t.percent / 100))
175 const filled = Math.round(share * BAR_CELLS)
176 const color = t.state === 'failed' ? 'red' : t.state === 'done' ? 'green' : undefined
177 const count = t.current !== null ? (t.total !== null ? `${t.current}/${t.total}` : `${t.current}`) : null
178 const quiet = now - t.updatedAt > 60e3 && t.state === 'running' ? `quiet ${duration(now - t.updatedAt)}` : null
179 return (
180 <Box key={t.id} flexDirection="row" columnGap={1}>
181 <Text>{t.label}</Text>
182 <Text color={color}>{'█'.repeat(filled)}<Text dimColor>{'░'.repeat(BAR_CELLS - filled)}</Text></Text>
183 <Text color={color}>{t.state === 'done' ? 'done' : t.state === 'failed' ? 'failed' : t.percent === null ? '…' : `${Math.round(t.percent)}%`}</Text>
184 {t.phase ? <Text dimColor>{t.phase}</Text> : null}
185 {count ? <Text dimColor>{count}</Text> : null}
186 {t.etaMs !== null ? <Text dimColor>~{duration(t.etaMs)} left</Text> : null}
187 {quiet ? <Text color="yellow">{quiet}</Text> : null}
188 </Box>
189 )
190 })}
191 {bg.map(t => {
192 const failed = t.status !== null && t.status !== 'completed'
193 const color = t.endedAt === null ? undefined : failed ? 'red' : 'green'
194 return (
195 <Box key={`bg-${t.id}`} flexDirection="row" columnGap={1}>
196 <Text dimColor>bg</Text>
197 <Text>{t.label}</Text>
198 <Text color={color}>{t.endedAt === null ? `running ${duration(now - t.startedAt)}` : `${t.status} after ${duration(t.endedAt - t.startedAt)}`}</Text>
199 </Box>
200 )
201 })}
202 {stalled ? <Text color="yellow">no tool activity for {duration(now - act.lastAt)}, the agent may be stuck</Text> : null}
203 {longTool ? <Text color="yellow">{act.runningTool} running for {duration(now - act.toolStartedAt!)}</Text> : null}
204 {below ? <Box height={1} overflow="hidden"><Text dimColor>{DIVIDER}</Text></Box> : null}
205 {below}
206 </Box>
207 )
208 })
209}
210types/index.d.ts 33 lines1export type Task = {
2 id: string
3 label: string
4 percent: number | null
5 current: number | null
6 total: number | null
7 phase: string | null
8 state: 'running' | 'done' | 'failed'
9 updatedAt: number
10 etaMs: number | null
11}
12
13export type BackgroundTask = {
14 id: string
15 label: string
16 startedAt: number
17 endedAt: number | null
18 status: string | null
19}
20
21export type Activity = {
22 isTurnRunning: boolean
23 lastAt: number
24 runningTool: string | null
25 toolStartedAt: number | null
26}
27
28declare module 'claude-code' {
29 interface PluginState {
30 'task-progress': { tasks: Task[]; background: BackgroundTask[]; activity: Activity; tick: number }
31 }
32}
33