SLOPSHOPPER

task-progress

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

newbandrowsguardcommandprompt
v0.2.1MITupdated 2026-10-06andras-gyarmati/claude-task-progress
A shopper browsing a rack in a slop shop
README

task-progress

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:

  • every background command the thread starts (bg rows with running time, green or red when it ends);
  • a yellow warning when a turn has had no tool call start or end for 3 minutes, or one tool has run that long.

/clip saves the image on the clipboard (a screenshot) to ~/.local/state/clipboard-shots/ and tells the model to read it.

Source 2 files
hooks/register.tsx 210 lines
1import { 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}
210
types/index.d.ts 33 lines
1export 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