SLOPSHOPPER

agent-progress

Animated per-agent progress bars (main agent, subagents, cheap-worker workers) driven by their task lists

newpanespinnerguardcommandprompt
v0.1.0no licenseupdated 2026-10-10ianwilson97/claude-mods/agent-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-progress
│ ┃ Agent progress ✕ › fix the failing auth test and add an audit log call │ ┃ [All] Workers │ ┃ ⏺ Read(src/auth.ts) │ ┃ THIS SESSION ⎿ Read 6 lines │ ┃ ✻ Claude ⏺ Update(src/auth.ts) │ ┃ ███ 9 steps 0:00 ⎿ Added 2 lines, removed 1 line │ ┃ idle ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ WORKERS ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ No cheap workers running (tmux session │ ┃ cw). ✻ Worked for 42s · done 4:20 PM │ ┃ stalled = no update for 3m · finished rows │ ┃ hide after 10m › /progress │ ⎿ agent-progress: Agent progress open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent progress
[All] Workers THIS SESSION ✻ Claude ███ 9 steps 0:00 idle WORKERS No cheap workers running (tmux session cw). stalled = no update for 3m · finished rows hide after 10m
README

ianwilson-mods

Claude Code mods (function-hook plugins). Each folder is one plugin; .claude-plugin/marketplace.json makes this repo a marketplace.

ModWhat it does
cheap-worker/worker spins up Claude Code workers from a picker; /workers lists, messages and closes them. Team mode lets workers find each other (ListAgents) and talk (SendMessage).
agent-progress/progress: animated progress bar per agent (main, subagents, workers) from their task lists.
agent-map/map: project file tree; red 🔨 = an agent is editing it, green ✓ = done, ready for review.
context-barContext-window breakdown above the prompt.
usage-meter5-hour / weekly usage in the footer.
quick-settings⚙ footer button for model and effort.
skins/skins: colour themes (Tokyo Night, Dracula, Gruvbox, …).
cw-pane, driver-seat-guard, evidence-bandFor the driver-seat workflow; skip them elsewhere.

Install

/plugin marketplace add ianwilson97/claude-mods
/plugin install cheap-worker@ianwilson-mods
/plugin install agent-progress@ianwilson-mods
/plugin install agent-map@ianwilson-mods

On the machine where this folder is the live copy (loaded through CLAUDE_CODE_PLUGIN_DIRS), don't also install from the marketplace: each mod would load twice.

cheap-worker: two switches

Set per machine in /config (or pluginConfigs.cheap-worker.options in ~/.claude/settings.json):

OptionValues
provideropenrouter (default)Any OpenRouter model; key read from $CW_OPENROUTER_API_KEY (export it in ~/.zshenv). Workers skip user settings, hooks and plugins.
bedrockClaude only (Sonnet / Opus / Haiku). Workers keep your user settings, so the Bedrock env there (CLAUDE_CODE_USE_BEDROCK, AWS_REGION, AWS_PROFILE, …) applies. No call to openrouter.ai.
launchpanes (default)All workers tiled in tmux session cw: tmux new -A -s cw.
tabsEach worker gets a tmux session of its own and opens in a background tab: a cmux tab when Claude Code runs inside cmux, else a Ghostty tab (focus stays where you were).

Work laptop: provider = bedrock, launch = tabs.

Reaching a worker: crew

The mod installs crew into ~/.local/bin (that folder must be on your PATH; it never overwrites a different crew).

crew            # list running workers, pick one by number
crew ab12c      # attach to cw-ab12c

If Ghostty can't be scripted (macOS asks once to let Claude control Ghostty; a managed Mac may refuse), the worker still runs: press CMD+T and run crew. Closing a tab only detaches; the worker keeps running until you close it from /workers.

Needs: tmux; for auto-opened tabs, cmux or Ghostty 1.3+.

First run on a new machine: checks

  1. /plugin lists the mods as enabled. Managed settings can block marketplaces or plugins; if so, nothing loads.
  2. /worker, launch one with task say hi; a Ghostty tab opens (or the toast says CMD+T, then crew …).
  3. Turn on team, launch two workers, then from /workers send one a message: messaging is Claude Code's own (ListAgents / SendMessage) and works under Bedrock unless policy turns it off.
Source 3 files
hooks/register.tsx 399 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Board, Kind, Track } from '../types'
5import {
6  FRAME_MS, KEEP_MS, META_FORMAT, SLIDE_MS, STALL_MS, TASK_TOOLS, TONE, TRACK,
7  applyTask, barOf, clockDur, encode, fillAt, frame, fromTranscript, hex, isMoving, parseMeta, projectDir, summary, svgBar, textBar, vendorOf,
8} from './progress'
9
10const PANE = 'agent-progress'
11const W = 24 // bar width in terminal cells
12const MODE_WORD: Record<string, string> = { acceptEdits: 'edits', auto: 'auto', default: 'ask' }
13const MODE_COLOR: Record<string, string> = { acceptEdits: '#57ab5a', auto: '#539bf5', default: '#e0823d' }
14const NUDGE =
15  'When a request takes three or more distinct steps, keep a task list (TaskCreate/TaskUpdate, or TodoWrite): ' +
16  'mark each step in_progress as you start it and completed as soon as it is done. The user watches a progress bar built from it.'
17
18const DEFAULTS: Board = { tracks: [], isNudgeOn: true, isMotionOn: true, view: 'all' }
19const board = atom({ plugin: 'agent-progress', key: 'board' } as const, DEFAULTS)
20
21// Module-local (not $.state): timers and caches start over on reload, which is fine.
22let frameTimer: Timer | null = null
23let isPaneOpen = false
24const denied = new Set<string>() // Raster keys whose blit was refused: static until the pane reopens
25const transcripts = new Map<string, string>()
26const svgs = new Map<string, string>()
27let pixels: Record<string, (string | null)[][]> | null = null
28
29const blank = (id: string, kind: Kind, name: string, now: number): Track => ({
30  id, kind, name, model: '', mode: '', items: [], steps: 0, startedAt: now, updatedAt: now,
31  endedAt: null, outcome: null, isBusy: kind !== 'main', from: 0, changedAt: 0,
32})
33const fractionOf = (t: Track) => barOf(t, 0, true).to ?? 0
34
35/**
36 * Rewrites one track (creating it when `fn` gets undefined); null leaves the board alone.
37 * When the bar's fraction moves, stamps where it slid from and when, then starts the frame clock.
38 */
39async function change($: EngineInterface, id: string, fn: (t: Track | undefined, now: number) => Track | null) {
40  const now = await $.clock.now()
41  const b = await read($, board)
42  const old = b.tracks.find(t => t.id === id)
43  const nxt = fn(old, now)
44  if (!nxt || (old && JSON.stringify(old) === JSON.stringify(nxt))) return
45  const before = old ? fractionOf(old) : 0
46  const moved = fractionOf(nxt) !== before
47  const t = moved ? { ...nxt, from: before, changedAt: now } : nxt
48  await update($, board, x => ({ ...x, tracks: x.tracks.some(y => y.id === id) ? x.tracks.map(y => (y.id === id ? t : y)) : [...x.tracks, t] }))
49  if (moved || t.isBusy) kick($)
50}
51
52/** Starts the ~30fps frame clock unless it's running or motion is off. */
53function kick($: EngineInterface) {
54  if (frameTimer) return
55  frameTimer = $.clock.every(FRAME_MS, () => void tick($))
56}
57
58/** One frame: blit the pane's moving bars, redraw the footer while it slides, stop when all is still. */
59async function tick($: EngineInterface) {
60  const b = await read($, board)
61  const now = await $.clock.now()
62  const isFooterSliding = b.isMotionOn && b.tracks.some(t => now - t.changedAt < SLIDE_MS)
63  let isBlitting = false
64  if (isPaneOpen && b.isMotionOn) {
65    for (const t of b.tracks) {
66      const key = `bar-${t.id}`
67      const bar = barOf(t, now, true)
68      if (denied.has(key) || !isMoving(bar, now) || (t.endedAt !== null && now - t.endedAt > KEEP_MS)) continue
69      const r = await $.ui.blit({ requestId: PANE, key, cells: encode(frame(bar, now, W)) }).catch(() => ({ deny: 'threw' }))
70      if ('deny' in r) denied.add(key)
71      else isBlitting = true
72    }
73  }
74  if (isFooterSliding) $.ui.invalidate('ui.render')
75  if (!isFooterSliding && !isBlitting) {
76    frameTimer?.cancel()
77    frameTimer = null
78  }
79}
80
81async function openPane($: EngineInterface, view?: Board['view']) {
82  if (view) await update($, board, b => ({ ...b, view }))
83  isPaneOpen = true
84  denied.clear()
85  await $.ui.open({ id: PANE, title: 'Agent progress' })
86  void refresh($)
87  kick($)
88}
89
90async function run($: EngineInterface, argv: string[]) {
91  const r = await $.process.run(argv).catch(() => null)
92  return r?.exitCode === 0 ? r.stdout : ''
93}
94
95/** Every 5s: subagents from $.agent.list, workers from tmux + the tail of their transcripts. */
96async function refresh($: EngineInterface) {
97  const home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
98  if (pixels === null && home) {
99    const raw = await $.fs.read(`${$.plugin.root}/assets/providers-px.json`).catch(() => null)
100    try {
101      pixels = typeof raw === 'string' ? JSON.parse(raw) : {}
102    } catch {
103      pixels = {}
104    }
105  }
106
107  const agents = await $.agent.list().catch(() => [])
108  const seen = new Set<string>()
109  for (const a of agents) {
110    const id = `s:${a.id}`
111    seen.add(id)
112    const isEnded = a.status === 'completed' || a.status === 'failed' || a.status === 'killed'
113    await change($, id, (t, now) => {
114      if (t?.endedAt != null) return null
115      const base = t ?? blank(id, 'sub', a.description || a.name || a.type, now)
116      return {
117        ...base,
118        name: a.description || a.name || a.type,
119        model: a.type,
120        isBusy: !isEnded && a.status !== 'idle',
121        ...(isEnded && { endedAt: now, outcome: a.status === 'completed' ? ('done' as const) : ('failed' as const) }),
122      }
123    })
124  }
125
126  const panes = parseMeta(await run($, ['tmux', 'list-panes', '-a', '-F', META_FORMAT]))
127  for (const p of panes) {
128    const id = `w:${p.name}`
129    seen.add(id)
130    let file = transcripts.get(p.name)
131    if (!file && home && p.cwd) {
132      const hit = await run($, ['grep', '-rl', '--include=*.jsonl', '-e', `"agentName":"${p.name}"`, projectDir(home, p.cwd)])
133      file = hit.split('\n').find(Boolean)
134      if (file) transcripts.set(p.name, file)
135    }
136    // ponytail: last 2MB only; a TaskCreate older than that is lost (its updates then ignored)
137    const r = fromTranscript(file ? await run($, ['tail', '-c', '2000000', file]) : '')
138    await change($, id, (t, now) => ({
139      ...(t ?? blank(id, 'worker', p.name, p.start ?? now)),
140      model: p.model,
141      mode: p.mode,
142      items: r.items,
143      steps: r.steps,
144      updatedAt: r.lastAt ?? t?.updatedAt ?? now,
145      isBusy: true,
146      endedAt: null,
147      outcome: null,
148    }))
149  }
150
151  // Gone from both lists: finished. Then drop what finished over 10 minutes ago.
152  const now = await $.clock.now()
153  for (const t of (await read($, board)).tracks) {
154    if (t.kind !== 'main' && t.endedAt === null && !seen.has(t.id)) await change($, t.id, x => (x ? { ...x, endedAt: now, outcome: 'done', isBusy: false } : null))
155  }
156  const b = await read($, board)
157  if (b.tracks.some(t => t.endedAt !== null && now - t.endedAt > KEEP_MS))
158    await update($, board, x => ({ ...x, tracks: x.tracks.filter(t => t.endedAt === null || now - t.endedAt <= KEEP_MS) }))
159}
160
161export const register: Register = on => {
162  on('session.start', async ($, e, next) => {
163    await $.command.register({ name: 'progress', description: 'Agent progress bars: /progress [workers|all] · motion on|off · nudge on|off' })
164    const motion = await $.store.get('motion').catch(() => undefined)
165    const nudge = await $.store.get('nudge').catch(() => undefined)
166    await update($, board, b => ({
167      ...DEFAULTS,
168      ...b,
169      ...(typeof motion === 'boolean' && { isMotionOn: motion }),
170      ...(typeof nudge === 'boolean' && { isNudgeOn: nudge }),
171    }))
172    $.clock.every(5000, () => void refresh($))
173    void refresh($)
174    return next(e)
175  })
176
177  on('command.run', { command: 'progress' }, async ($, e) => {
178    const [word, arg] = e.args.trim().split(/\s+/)
179    if (word === 'motion' || word === 'nudge') {
180      const isOn = arg !== 'off'
181      await $.store.set(word, isOn)
182      await update($, board, b => ({ ...b, ...(word === 'motion' ? { isMotionOn: isOn } : { isNudgeOn: isOn }) }))
183      return { text: `agent-progress: ${word} ${isOn ? 'on' : 'off'}` }
184    }
185    await openPane($, word === 'workers' ? 'workers' : 'all')
186    return { text: 'Agent progress open.' }
187  })
188
189  on('ui.close', async ($, e, next) => {
190    if (e.id === PANE) isPaneOpen = false
191    return next(e)
192  }).catch(($, e, next) => next(e)) // progress is cosmetic: fail open
193
194  on('prompt.compose', async ($, e, next) => {
195    const composed = await next(e)
196    if (!(await read($, board)).isNudgeOn) return composed
197    return { sections: [...composed.sections, { id: 'agent-progress:tasks', text: NUDGE, scope: 'session' as const }] }
198  })
199
200  // The main agent: busy from the prompt to the turn's end; a finished list clears for the next one.
201  on('prompt.submit', async ($, e, next) => {
202    const r = await next(e)
203    await change($, 'main', (t, now) => {
204      const base = t ?? blank('main', 'main', 'Claude', now)
205      const { done, total } = summary(base.items)
206      const isFresh = total === 0 || done === total
207      return { ...base, isBusy: true, updatedAt: now, ...(isFresh && { items: [], steps: 0, startedAt: now }) }
208    })
209    return r
210  }).catch(($, e, next) => next(e)) // progress is cosmetic: fail open
211
212  on('turn.complete', async ($, e, next) => {
213    const r = await next(e)
214    await change($, 'main', t => (t ? { ...t, isBusy: false } : null))
215    return r
216  })
217
218  // Every answered tool call is a step; task tools also move the bar. A subagent's call carries its agentId.
219  on('tool.call', async ($, e, next) => {
220    const r = await next(e)
221    if ('deny' in r && r.deny) return r
222    try {
223      const id = e.agentId ? `s:${e.agentId}` : 'main'
224      const isTask = (TASK_TOOLS as readonly string[]).includes(e.tool) && !('isError' in r && r.isError)
225      await change($, id, (t, now) => {
226        const base = t ?? blank(id, id === 'main' ? 'main' : 'sub', id === 'main' ? 'Claude' : 'subagent', now)
227        const items = isTask ? (applyTask(base.items, e.tool, e, 'result' in r ? r.result : null) ?? base.items) : base.items
228        return { ...base, items, steps: base.steps + 1, updatedAt: now, ...(id !== 'main' && { isBusy: true }) }
229      })
230    } catch {
231      // progress is cosmetic: never let it break a tool call
232    }
233    return r
234  }).catch(($, e, next) => next(e)) // progress is cosmetic: fail open
235
236  // ── footer chip ─────────────────────────────────────────────────────────
237  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
238    const below = await next(e)
239    const { Box, Button, Text } = $.ui.resolve(e)
240    const b = await read($, board)
241    const now = await $.clock.now()
242    const live = b.tracks.filter(t => t.endedAt === null)
243    const lead = live.find(t => t.id === 'main' && t.items.length > 0) ?? live.find(t => t.items.length > 0 && t.isBusy)
244    const others = live.filter(t => t !== lead && t.isBusy && t.kind !== 'main').length
245    if (!lead && others === 0) return below
246    let chip = null
247    if (lead) {
248      const bar = barOf(lead, now, b.isMotionOn)
249      const { done, total } = summary(lead.items)
250      const parts = textBar(fillAt(bar, now), 6)
251      chip = (
252        <Text key="progress-bar">
253          <Text color={hex(TONE[bar.tone])} backgroundColor={hex(TRACK)}>{parts.full + parts.part}</Text>
254          <Text backgroundColor={hex(TRACK)}>{parts.empty}</Text>
255          <Text color={hex(TONE[bar.tone])}>{` ${done}/${total}`}</Text>
256        </Text>
257      )
258    }
259    return (
260      <Box justifyContent="space-between">
261        <Box flexGrow={1}>{below}</Box>
262        <Box key="progress-chip" columnGap={1} marginLeft={2}>
263          {chip}
264          <Button
265            key="progress-open"
266            plain
267            label={others > 0 ? `+${others} agent${others > 1 ? 's' : ''}` : 'progress'}
268            hover={{ color: hex(TONE.blue), bold: true }}
269            onPress={() => openPane($)}
270          />
271        </Box>
272      </Box>
273    )
274  })
275
276  // ── pane ────────────────────────────────────────────────────────────────
277  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
278    const els = $.ui.resolve(e)
279    const { Box, Button, Text } = els
280    const Raster = e.surface === 'terminal' && 'Raster' in els ? els.Raster : null
281    const Svg = e.surface !== 'terminal' && 'Svg' in els ? els.Svg : null
282    const b = await read($, board)
283    const now = await $.clock.now()
284    const home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
285    const shown = b.tracks.filter(t => t.endedAt === null || now - t.endedAt <= KEEP_MS)
286    const session = b.view === 'workers' ? [] : shown.filter(t => t.kind !== 'worker')
287    const workers = shown.filter(t => t.kind === 'worker').sort((x, y) => Number(x.endedAt !== null) - Number(y.endedAt !== null))
288
289    for (const t of workers) {
290      const v = vendorOf(t.model)
291      if (Svg && v && !svgs.has(v)) {
292        const s = await $.fs.read(`${$.plugin.root}/assets/providers-svg/${v}.svg`).catch(() => null)
293        svgs.set(v, typeof s === 'string' ? s : '')
294      }
295    }
296
297    const avatar = (t: Track) => {
298      if (t.kind === 'main') return <Text key={`av-${t.id}`} color="#d97757">✻</Text>
299      if (t.kind === 'sub') return <Text key={`av-${t.id}`} color="#b083f0">◇</Text>
300      const v = vendorOf(t.model)
301      const grid = Raster ? pixels?.[v] : undefined
302      if (grid)
303        return (
304          <Box key={`av-${t.id}`} flexDirection="column" width={8}>
305            {[0, 2, 4, 6].map(y => (
306              <Text key={`av-${t.id}-${y}`}>
307                {grid[y]!.map((top, x) => {
308                  const bot = grid[y + 1]![x] ?? null
309                  return top && bot ? (
310                    <Text key={`px-${x}`} color={top} backgroundColor={bot}>▀</Text>
311                  ) : top ? (
312                    <Text key={`px-${x}`} color={top}>▀</Text>
313                  ) : bot ? (
314                    <Text key={`px-${x}`} color={bot}>▄</Text>
315                  ) : (
316                    ' '
317                  )
318                })}
319              </Text>
320            ))}
321          </Box>
322        )
323      const svg = svgs.get(v)
324      if (Svg && svg) return <Svg key={`av-${t.id}`} source={svg} width={28} height={28} alt={v} />
325      return <Text key={`av-${t.id}`} bold color="#768390">{(v[0] ?? '?').toUpperCase()}</Text>
326    }
327
328    const row = (t: Track) => {
329      const bar = barOf(t, now, b.isMotionOn)
330      const { done, total, current } = summary(t.items)
331      const isStalled = bar.tone === 'yellow'
332      const label =
333        t.outcome === 'failed' ? '✗' : t.endedAt !== null || (total > 0 && done === total) ? `✓ ${total ? `${done}/${total}` : ''}` : total ? `${done}/${total}` : `${t.steps} steps`
334      const elapsed = clockDur((t.endedAt ?? now) - t.startedAt)
335      const step = t.endedAt !== null ? (t.outcome === 'failed' ? 'failed' : 'finished') : isStalled ? `stalled · no update for ${clockDur(now - t.updatedAt)}` : current ?? (t.isBusy ? 'working…' : 'idle')
336      const graphic = Raster ? (
337        <Raster key={`bar-${t.id}`} columns={W} rows={1} cells={encode(frame(bar, now, W))} />
338      ) : Svg ? (
339        <Svg key={`bar-${t.id}`} source={svgBar(bar, now)} width={200} height={8} alt={label} isInteractive={e.surface !== 'mobile' && b.isMotionOn} />
340      ) : (
341        <Text key={`bar-${t.id}`} color={hex(TONE[bar.tone])}>{textBar(bar.to ?? 0, 12).full || '·'}</Text>
342      )
343      return (
344        <Box key={`row-${t.id}`} columnGap={1} marginBottom={1}>
345          {avatar(t)}
346          <Box key={`body-${t.id}`} flexDirection="column">
347            <Box key={`head-${t.id}`} columnGap={1}>
348              <Text bold>{t.name}</Text>
349              {t.kind === 'worker' && t.mode ? <Text color={MODE_COLOR[t.mode] ?? '#768390'}>{MODE_WORD[t.mode] ?? t.mode}</Text> : null}
350              {t.model ? <Text dimColor>{t.model}</Text> : null}
351            </Box>
352            <Box key={`line-${t.id}`} columnGap={1} alignItems="center">
353              {graphic}
354              <Text color={hex(TONE[bar.tone])}>{label}</Text>
355              <Text dimColor>{elapsed}</Text>
356            </Box>
357            <Text dimColor>{step}</Text>
358          </Box>
359        </Box>
360      )
361    }
362
363    const tab = (v: Board['view'], text: string) => (
364      <Button
365        key={`tab-${v}`}
366        plain
367        label={b.view === v ? `[${text}]` : text}
368        hover={{ bold: true }}
369        onPress={async () => {
370          denied.clear()
371          await update($, board, x => ({ ...x, view: v }))
372          kick($)
373        }}
374      />
375    )
376    const staleMins = Math.round(STALL_MS / 60_000)
377    return (
378      <Box flexDirection="column" paddingX={1}>
379        <Box key="tabs" columnGap={2} marginBottom={1}>
380          {tab('all', 'All')}
381          {tab('workers', 'Workers')}
382          <Text dimColor>{b.isMotionOn ? '' : 'motion off'}</Text>
383        </Box>
384        {b.view === 'all' ? (
385          <Box key="sec-session" flexDirection="column">
386            <Text bold color="#768390">THIS SESSION</Text>
387            {session.length ? session.map(row) : <Text dimColor>No task list yet. Bars appear once an agent keeps one.</Text>}
388          </Box>
389        ) : null}
390        <Box key="sec-workers" flexDirection="column" marginTop={1}>
391          <Text bold color="#768390">WORKERS</Text>
392          {workers.length ? workers.map(row) : <Text dimColor>No cheap workers running (tmux session cw).</Text>}
393        </Box>
394        <Text dimColor>{`stalled = no update for ${staleMins}m · finished rows hide after 10m`}</Text>
395      </Box>
396    )
397  })
398}
399
hooks/progress.ts 265 lines
1import type { Item, Status, Track } from '../types'
2
3export const STALL_MS = 3 * 60_000
4export const KEEP_MS = 10 * 60_000
5export const SLIDE_MS = 600
6export const TICK_MS = 300
7export const PULSE_MS = 400
8export const SHIMMER_MS = 1500
9export const FRAME_MS = 33
10
11// ── task lists ────────────────────────────────────────────────────────────
12
13const isStatus = (s: unknown): s is Status => s === 'pending' || s === 'in_progress' || s === 'completed'
14
15/**
16 * The task list after one TodoWrite / TaskCreate / TaskUpdate call, or null when the
17 * call doesn't touch it. TodoWrite sends the whole list; TaskCreate's id comes from its
18 * result; a TaskCreate after a fully completed list starts a new one.
19 */
20export function applyTask(items: readonly Item[], tool: string, input: unknown, result: unknown): Item[] | null {
21  const i = (input ?? {}) as Record<string, unknown>
22  if (tool === 'TodoWrite') {
23    const todos = Array.isArray(i.todos) ? (i.todos as Record<string, unknown>[]) : []
24    return todos.map((t, n) => ({
25      id: String(n),
26      title: String(t.content ?? ''),
27      active: String(t.activeForm ?? t.content ?? ''),
28      status: isStatus(t.status) ? t.status : 'pending',
29    }))
30  }
31  if (tool === 'TaskCreate') {
32    const id = ((result ?? {}) as { task?: { id?: unknown } }).task?.id
33    if (typeof id !== 'string' && typeof id !== 'number') return null
34    const subject = String(i.subject ?? '')
35    const item: Item = { id: String(id), title: subject, active: String(i.activeForm ?? subject), status: 'pending' }
36    const isFresh = items.length > 0 && items.every(x => x.status === 'completed')
37    return [...(isFresh ? [] : items.filter(x => x.id !== item.id)), item]
38  }
39  if (tool === 'TaskUpdate') {
40    const id = String(i.taskId ?? '')
41    if (!items.some(x => x.id === id)) return null
42    if (i.status === 'deleted') return items.filter(x => x.id !== id)
43    return items.map(x =>
44      x.id !== id
45        ? x
46        : {
47            ...x,
48            ...(isStatus(i.status) && { status: i.status }),
49            ...(typeof i.subject === 'string' && { title: i.subject }),
50            ...(typeof i.activeForm === 'string' && { active: i.activeForm }),
51          },
52    )
53  }
54  return null
55}
56
57export const TASK_TOOLS = ['TodoWrite', 'TaskCreate', 'TaskUpdate'] as const
58
59/** done ÷ total and the in-progress step; `total` 0 means no list (indeterminate). */
60export function summary(items: readonly Item[]): { done: number; total: number; current: string | null } {
61  const done = items.filter(x => x.status === 'completed').length
62  const cur = items.find(x => x.status === 'in_progress')
63  return { done, total: items.length, current: cur ? cur.active || cur.title : null }
64}
65
66/**
67 * A worker's task list from (the tail of) its transcript: tool_use rows on assistant
68 * messages, their results on the following user rows. A tail may start mid-line (skipped)
69 * and miss a TaskCreate (its later TaskUpdates are then ignored).
70 */
71export function fromTranscript(jsonl: string): { items: Item[]; steps: number; lastAt: number | null } {
72  let items: Item[] = []
73  let steps = 0
74  let lastAt: number | null = null
75  const pending = new Map<string, { name: string; input: unknown }>()
76  for (const line of jsonl.split('\n')) {
77    if (!line.startsWith('{')) continue
78    let row: Record<string, unknown>
79    try {
80      row = JSON.parse(line)
81    } catch {
82      continue
83    }
84    const at = typeof row.timestamp === 'string' ? Date.parse(row.timestamp) : NaN
85    if (!Number.isNaN(at)) lastAt = at
86    const content = (row.message as { content?: unknown } | undefined)?.content
87    if (!Array.isArray(content)) continue
88    for (const c of content as Record<string, unknown>[]) {
89      if (c.type === 'tool_use' && typeof c.name === 'string') {
90        steps++
91        if (c.name === 'TodoWrite') items = applyTask(items, c.name, c.input, null) ?? items
92        else if (c.name === 'TaskCreate' || c.name === 'TaskUpdate') pending.set(String(c.id), { name: c.name, input: c.input })
93      } else if (c.type === 'tool_result') {
94        const use = pending.get(String(c.tool_use_id))
95        if (!use) continue
96        pending.delete(String(c.tool_use_id))
97        items = applyTask(items, use.name, use.input, row.toolUseResult) ?? items
98      }
99    }
100  }
101  return { items, steps, lastAt }
102}
103
104// ── copied from cheap-worker/hooks/worker.ts (plugins can't import each other) ──
105
106export const projectDir = (home: string, cwd: string) => `${home}/.claude/projects/${cwd.replace(/[/.]/g, '-')}`
107export const vendorOf = (model: string) => model.split('/')[0]?.replace(/^~/, '') ?? ''
108export const META_FORMAT = '#{pane_id}\t#{@cw}\t#{@cw_model}\t#{@cw_mode}\t#{@cw_start}\t#{@cw_cwd}\t#{@cw_task}'
109export type PaneMeta = { pane: string; name: string; model: string; mode: string; start: number | null; cwd: string }
110export function parseMeta(stdout: string): PaneMeta[] {
111  return stdout
112    .split('\n')
113    .map(l => l.split('\t'))
114    .filter(([pane, name]) => pane?.startsWith('%') && name)
115    .map(([pane, name, model, mode, start, cwd]) => ({
116      pane: pane!,
117      name: name!,
118      model: model ?? '',
119      mode: mode ?? '',
120      start: start && /^\d+$/.test(start) ? Number(start) : null,
121      cwd: cwd ?? '',
122    }))
123}
124export function clockDur(ms: number): string {
125  const s = Math.max(0, Math.floor(ms / 1000))
126  const h = Math.floor(s / 3600)
127  const mm = Math.floor((s % 3600) / 60)
128  const ss = String(s % 60).padStart(2, '0')
129  return h > 0 ? `${h}:${String(mm).padStart(2, '0')}:${ss}` : `${mm}:${ss}`
130}
131
132// ── what a bar shows ──────────────────────────────────────────────────────
133
134export type Tone = 'blue' | 'green' | 'yellow' | 'red'
135export const TONE: Record<Tone, number> = { blue: 0x539bf5, green: 0x57ab5a, yellow: 0xc69026, red: 0xe5534b }
136export const TRACK = 0x2d333b
137export const hex = (c: number) => `#${c.toString(16).padStart(6, '0')}`
138
139export type Bar = {
140  /** fraction before the last change */
141  from: number
142  /** fraction now; null = no task list (indeterminate) */
143  to: number | null
144  changedAt: number
145  tone: Tone
146  /** working right now: head, shimmer, bounce */
147  isLive: boolean
148  isMotionOn: boolean
149}
150
151export function barOf(t: Track, now: number, isMotionOn: boolean): Bar {
152  const { done, total } = summary(t.items)
153  const isStalled = t.endedAt === null && t.isBusy && now - t.updatedAt > STALL_MS
154  const isDone = t.outcome === 'done' || (total > 0 && done === total)
155  const tone: Tone = t.outcome === 'failed' ? 'red' : isDone ? 'green' : isStalled ? 'yellow' : 'blue'
156  const to = t.endedAt !== null ? (t.outcome === 'failed' ? (total ? done / total : null) : 1) : total ? done / total : null
157  return { from: t.from, to, changedAt: t.changedAt, tone, isLive: t.isBusy && !isStalled && t.endedAt === null && !isDone, isMotionOn }
158}
159
160/** Still frames once the slide, tick and pulse are over and nothing is working. */
161export const isMoving = (b: Bar, now: number) => b.isMotionOn && (b.isLive || now - b.changedAt < SLIDE_MS + PULSE_MS)
162
163const ease = (x: number) => 1 - (1 - x) ** 3
164const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉'] as const
165
166/** The fill at `now`: sliding from `from` to `to` over SLIDE_MS. */
167export function fillAt(b: Bar, now: number): number {
168  if (b.to === null) return 0
169  const dt = now - b.changedAt
170  if (!b.isMotionOn || dt >= SLIDE_MS) return b.to
171  return b.from + (b.to - b.from) * ease(Math.max(0, dt) / SLIDE_MS)
172}
173
174/** Whole cells and the eighth-block of the partial one, for `fill` of `width` cells. */
175export function cellsOf(fill: number, width: number): { full: number; eighth: number } {
176  const cells = Math.min(1, Math.max(0, fill)) * width
177  let full = Math.floor(cells)
178  let eighth = Math.round((cells - full) * 8)
179  if (eighth === 8) (full++, (eighth = 0))
180  return { full, eighth }
181}
182
183/** Left edge of the bouncing segment: 0 → width-seg → 0, one cell per 50ms. */
184export function bounceX(now: number, width: number, seg = 3): number {
185  const span = Math.max(1, width - seg)
186  const pos = Math.floor(now / 50) % (2 * span)
187  return pos <= span ? pos : 2 * span - pos
188}
189
190const lighten = (c: number, k: number) => {
191  const ch = (s: number) => Math.round(((c >> s) & 0xff) + (255 - ((c >> s) & 0xff)) * Math.min(1, Math.max(0, k)))
192  return (ch(16) << 16) | (ch(8) << 8) | ch(0)
193}
194
195/** One frame: `width` cells as flat [codePoint, fg, bg] triplets. Pure, so it's testable. */
196export function frame(b: Bar, now: number, width: number): number[] {
197  const out: number[] = []
198  const fill = TONE[b.tone]
199  const cell = (ch: string, fg: number, bg: number) => out.push(ch.codePointAt(0)!, fg, bg)
200  if (b.to === null) {
201    const x0 = b.isLive && b.isMotionOn ? bounceX(now, width) : 0
202    for (let x = 0; x < width; x++) cell(x >= x0 && x < x0 + 3 ? '█' : ' ', fill, TRACK)
203    return out
204  }
205  const dt = now - b.changedAt
206  const motion = b.isMotionOn
207  const { full, eighth } = cellsOf(fillAt(b, now), width)
208  const tickFrom = Math.floor(Math.min(b.from, b.to) * width)
209  const isTick = motion && b.to > b.from && dt >= SLIDE_MS && dt < SLIDE_MS + TICK_MS
210  const pulse = motion && b.tone === 'green' && b.to === 1 && dt >= SLIDE_MS && dt < SLIDE_MS + PULSE_MS ? Math.sin((Math.PI * (dt - SLIDE_MS)) / PULSE_MS) * 0.5 : 0
211  const isSettled = !motion || dt >= SLIDE_MS
212  const shimmer = b.isLive && motion && isSettled ? ((now % SHIMMER_MS) / SHIMMER_MS) * (full + 6) - 3 : null
213  for (let x = 0; x < width; x++) {
214    if (x < full) {
215      let c = isTick && x >= tickFrom ? TONE.green : fill
216      if (pulse) c = lighten(c, pulse)
217      if (shimmer !== null) {
218        const d = Math.abs(x - shimmer)
219        if (d < 1.5) c = lighten(c, 0.3 * (1 - d / 1.5))
220      }
221      if (b.isLive && motion && isSettled && x === full - 1) c = lighten(c, 0.35)
222      cell('█', c, TRACK)
223    } else if (x === full && eighth > 0) cell(EIGHTHS[eighth]!, fill, TRACK)
224    else cell(' ', fill, TRACK)
225  }
226  return out
227}
228
229/** RasterProps.cells: little-endian u32s, padded base64. */
230export function encode(words: readonly number[]): string {
231  const bytes = new Uint8Array(new Uint32Array(words).buffer)
232  let s = ''
233  for (const b of bytes) s += String.fromCharCode(b)
234  return btoa(s)
235}
236
237/** The footer's text bar: whole cells, the partial eighth, the empty rest. */
238export function textBar(fill: number, width: number): { full: string; part: string; empty: string } {
239  const { full, eighth } = cellsOf(fill, width)
240  return { full: '█'.repeat(full), part: EIGHTHS[eighth]!, empty: ' '.repeat(Math.max(0, width - full - (eighth ? 1 : 0))) }
241}
242
243/** Desktop/vscode: the same bar as SVG; SMIL does the slide, shimmer and bounce. */
244export function svgBar(b: Bar, now: number, w = 200, h = 8): string {
245  const fill = hex(TONE[b.tone])
246  const r = h / 2
247  const animate = b.isMotionOn
248  const head = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><rect width="${w}" height="${h}" rx="${r}" fill="${hex(TRACK)}"/>`
249  if (b.to === null) {
250    const seg = w / 6
251    const move = animate && b.isLive ? `<animate attributeName="x" values="0;${w - seg};0" dur="1.6s" repeatCount="indefinite"/>` : ''
252    return `${head}<rect width="${seg}" height="${h}" rx="${r}" fill="${fill}">${move}</rect></svg>`
253  }
254  const sliding = animate && now - b.changedAt < SLIDE_MS
255  const to = b.to * w
256  const slide = sliding
257    ? `<animate attributeName="width" from="${b.from * w}" to="${to}" dur="${SLIDE_MS / 1000}s" fill="freeze" calcMode="spline" keyTimes="0;1" keySplines="0.33 1 0.68 1"/>`
258    : ''
259  const shimmer =
260    animate && b.isLive
261      ? `<defs><linearGradient id="g"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="0.5" stop-color="#fff" stop-opacity="0.35"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><clipPath id="c"><rect width="${to}" height="${h}" rx="${r}"/></clipPath></defs><rect clip-path="url(#c)" x="${-w / 4}" width="${w / 4}" height="${h}" fill="url(#g)"><animate attributeName="x" from="${-w / 4}" to="${to}" dur="${SHIMMER_MS / 1000}s" repeatCount="indefinite"/></rect>`
262      : ''
263  return `${head}<rect width="${to}" height="${h}" rx="${r}" fill="${fill}">${slide}</rect>${shimmer}</svg>`
264}
265
types/index.d.ts 35 lines
1export type Status = 'pending' | 'in_progress' | 'completed'
2export type Item = { id: string; title: string; active: string; status: Status }
3export type Kind = 'main' | 'sub' | 'worker'
4export type Track = {
5  id: string
6  kind: Kind
7  name: string
8  model: string
9  mode: string
10  items: Item[]
11  /** tool calls seen: the indeterminate bar's label */
12  steps: number
13  startedAt: number
14  /** last sign of life (a tool call, a transcript row): stalled after 3 min */
15  updatedAt: number
16  endedAt: number | null
17  outcome: 'done' | 'failed' | null
18  isBusy: boolean
19  /** animation: the fraction before the last change, and when it changed */
20  from: number
21  changedAt: number
22}
23export type Board = {
24  tracks: Track[]
25  isNudgeOn: boolean
26  isMotionOn: boolean
27  view: 'all' | 'workers'
28}
29
30declare module 'claude-code' {
31  interface PluginState {
32    'agent-progress': { board: Board }
33  }
34}
35