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

Claude Code mods (function-hook plugins). Each folder is one plugin; .claude-plugin/marketplace.json makes this repo a marketplace.
| Mod | What 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-bar | Context-window breakdown above the prompt. |
usage-meter | 5-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-band | For the driver-seat workflow; skip them elsewhere. |
/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.
Set per machine in /config (or pluginConfigs.cheap-worker.options in ~/.claude/settings.json):
| Option | Values | |
|---|---|---|
provider | openrouter (default) | Any OpenRouter model; key read from $CW_OPENROUTER_API_KEY (export it in ~/.zshenv). Workers skip user settings, hooks and plugins. |
bedrock | Claude 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. | |
launch | panes (default) | All workers tiled in tmux session cw: tmux new -A -s cw. |
tabs | Each 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.
crewThe 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+.
/plugin lists the mods as enabled. Managed settings can block marketplaces or plugins; if so, nothing loads./worker, launch one with task say hi; a Ghostty tab opens (or the toast says CMD+T, then crew …)./workers send one a message: messaging is Claude Code's own (ListAgents / SendMessage) and works under Bedrock unless policy turns it off.hooks/register.tsx 399 lines1import { 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}
399hooks/progress.ts 265 lines1import 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}
265types/index.d.ts 35 lines1export 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