A live, colorful progress bar for whatever this session is working on: a band above the prompt, a full /progress pane, and a celebration every time a task lands

Mods that load in every Claude Code session: the terminal, the desktop app's Code tab, and cloud sessions.
| Mod | What it shows |
|---|---|
| repo-sentry | Business, repo, branch, behind or ahead of origin, dirty count. Warns on work sitting on main, a stale repo, or a staged secret. |
| progress-pulse | A live gradient progress band for the session's task list, a cheer on every finished task, /progress for the full view, /progress demo to see it run. |
| usage-meter | The 5-hour and weekly plan limits with reset times, context fill and session cost, right above the message box. /usage-meter hides or shows it. |
setup-mac.js (run once) installs every mod as a Claude Code plugin read straight from this folder, and adds a session-start hook that runs sync-mods.js --quiet. A mod added to the marketplace installs itself at the next session start; an edit to a mod takes effect at the next session start or /reload-plugins.cloud-setup.sh goes into each cloud environment's setup script. Every new container clones this repo and runs sync-mods.js.Put it in its own folder with .claude-plugin/plugin.json, add it to .claude-plugin/marketplace.json, check it with claude plugin validate <folder> and claude plugin test <folder>, commit and push.
mcp-diagnose.js, mcp-where.js, move-mcp-to-repo.js, mcp-set-secret.js, mcp-env-from-dotenv.js: MCP server helpers that never print secret values. servicetrade-mcp/: the read-only ServiceTrade MCP server.
hooks/register.tsx 466 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Agent, Celebration, Task } from '../types'
5import { bar, counts, duration, eta, fromTodos, gradient, milestonesCrossed, runs, textReport, today, upsert } from './logic'
6import { agentClock, agentsText, agentTasks, BAR_H, botSize, botSvg, pillWords, TRACK_H, compactTokens, currentBatch, finished, fraction, miniBarSvg, modelName, spawned, stepped, stepText, tier, trackSvg } from './agents'
7
8const tasksA = atom({ plugin: 'progress-pulse', key: 'tasks' } as const, [] as Task[])
9const startA = atom({ plugin: 'progress-pulse', key: 'sessionStart' } as const, 0)
10const cheerA = atom({ plugin: 'progress-pulse', key: 'celebration' } as const, null as Celebration | null)
11const projectA = atom({ plugin: 'progress-pulse', key: 'project' } as const, '')
12const hiddenA = atom({ plugin: 'progress-pulse', key: 'bandHidden' } as const, false)
13const agentsA = atom({ plugin: 'progress-pulse', key: 'agents' } as const, [] as Agent[])
14const paneOpenedA = atom({ plugin: 'progress-pulse', key: 'paneOpened' } as const, false)
15
16const usageTokens = (u: { input_tokens?: number; output_tokens?: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number } | undefined) =>
17 // Cache reads are left out: every request of a subagent re-reads its whole cached prompt, so
18 // counting them made three 9-second Haiku agents read as 1.3M tokens in the 0.2.0 pane.
19 u ? (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) : 0
20
21const PANE = 'progress-pulse'
22const CHEER_MS = 8000
23const QUIET_AFTER_DONE_MS = 15 * 60_000
24
25async function projectName($: EngineInterface): Promise<string> {
26 try {
27 const r = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { timeoutMs: 3000 })
28 if (r.exitCode === 0) return r.stdout.trim().split('/').filter(Boolean).pop() ?? ''
29 } catch { /* not a repo, or git missing */ }
30 const cwd = await $.session.cwd()
31 return cwd.split('/').filter(Boolean).pop() ?? 'this session'
32}
33
34// Everything that happens when the task list changes: store it, and cheer what just finished.
35async function apply($: EngineInterface, change: (prev: Task[], now: number) => Task[]): Promise<void> {
36 const now = await $.clock.now()
37 const stored = await read($, tasksA)
38 const isDemo = (t: Task) => t.id.startsWith('demo-')
39 const before = stored
40 let after = change(before, now)
41 if (after.some(t => !isDemo(t)) && after.some(isDemo)) after = after.filter(t => !isDemo(t))
42 await update($, tasksA, () => after)
43
44 const was = new Set(before.filter(t => t.status === 'completed').map(t => t.id))
45 const landed = after.filter(t => t.status === 'completed' && !was.has(t.id))
46 if (!landed.length) return
47
48 const b = counts(before), a = counts(after)
49 const all = a.total > 0 && a.done === a.total
50 const last = landed[landed.length - 1]!
51 const startedAt = await read($, startA)
52 const text = all
53 ? `ALL ${a.total} DONE in ${duration(now - (startedAt || now))}. Shipped.`
54 : `Done: ${last.subject} ${a.done} of ${a.total}`
55 await update($, cheerA, () => ({ text, big: all, until: now + CHEER_MS }))
56 $.clock.after(CHEER_MS + 50, () => {
57 void (async () => {
58 const t = await $.clock.now()
59 await update($, cheerA, c => (c && c.until <= t ? null : c))
60 })().catch(() => {})
61 })
62
63 for (const m of milestonesCrossed(b.total ? b.pct : 0, a.pct)) {
64 if (m < 100) $.ui.toast(`${m}% of the way there: ${a.done} of ${a.total} done`)
65 }
66 if (all) $.ui.toast(`Every task done: ${a.total} of ${a.total}. Nice work.`, { timeoutMs: 8000 })
67
68 // A running tally per project per day, kept across sessions, for the pane's Today section.
69 if (landed.every(isDemo)) return
70 try {
71 const project = (await read($, projectA)) || 'this session'
72 const key = `day:${today(now)}`
73 const tally = ((await $.store.get(key)) ?? {}) as Record<string, number>
74 tally[project] = (tally[project] ?? 0) + landed.filter(t => !isDemo(t)).length
75 await $.store.set(key, tally)
76 } catch { /* the tally is a nicety; never let it break a tool call */ }
77}
78
79// The Agents section: what the crew cost and how far each one has got.
80// eslint-disable-next-line @typescript-eslint/no-explicit-any
81type El = any
82async function crewSection($: EngineInterface, els: { Box: El; Text: El; Svg: El | null }, crew: Agent[], tasks: Task[], now: number) {
83 const { Box, Text, Svg } = els
84 let usd: number | null = null
85 try { const u = await $.session.usage(); usd = u.cost ? u.cost.usd : null } catch { /* no ledger on this host */ }
86 const tokens = crew.reduce((x, a) => x + a.tokens, 0)
87 const first = Math.min(...crew.map(a => a.startedAt))
88 const last = crew.some(a => a.status === 'running') ? now : Math.max(...crew.map(a => a.endedAt ?? now))
89 const running = crew.filter(a => a.status === 'running')
90 const ended = crew.filter(a => a.status !== 'running')
91 const planned = tasks.filter(t => t.status === 'pending')
92
93 const tile = (label: string, value: string, note: string) => (
94 <Box key={label} flexDirection="column" borderStyle="round" paddingX={1} flexGrow={1}>
95 <Text dimColor>{label}</Text>
96 <Text bold>{value}</Text>
97 <Text dimColor>{note}</Text>
98 </Box>
99 )
100 const row = (a: Agent) => {
101 const t = tier(a.model, a.type)
102 const live = a.status === 'running'
103 const step = a.tasksTotal ? `${a.tasksDone}/${a.tasksTotal} · ${a.stepLabel}` : a.stepLabel
104 const mark = a.status === 'done' ? <Text color="#2ec27e">{'✓'}</Text> : a.status === 'failed' ? <Text color="#e5484d">{'✕'}</Text> : <Text color={t.color}>{'●'}</Text>
105 return (
106 <Box key={a.id} flexDirection="row" columnGap={1} alignItems="center" marginBottom={1}>
107 {Svg ? <Svg source={botSvg(t.color, { cell: 3, bounce: live })} alt={`${t.label} agent`} {...botSize(3)} /> : null}
108 <Box flexDirection="column" flexGrow={1}>
109 <Text>
110 <Text bold>{a.name.slice(0, 40)}</Text>
111 <Text color={t.color}>{' ' + t.label}</Text>
112 <Text dimColor>{` ${modelName(a.model)} · ${a.type}`}</Text>
113 </Text>
114 <Text dimColor={!live}>{live ? step : a.status === 'done' ? 'Finished' : 'Stopped before finishing'}</Text>
115 {Svg
116 ? <Svg source={miniBarSvg(a.status === 'running' ? fraction(a) : 1, a.status === 'failed' ? '#e5484d' : t.color, 260)} alt="progress" width={260} height={BAR_H} />
117 : null}
118 <Text dimColor>{`${a.tokens ? compactTokens(a.tokens) + ' tokens · ' : ''}${agentClock((a.endedAt ?? now) - a.startedAt)}`}</Text>
119 </Box>
120 {mark}
121 </Box>
122 )
123 }
124 return (
125 <Box flexDirection="column">
126 <Text bold>{`Agents ${ended.length} of ${crew.length} finished`}</Text>
127 <Box flexDirection="row" columnGap={1}>
128 {tile('Cost', usd !== null ? `$${usd.toFixed(2)}` : 'n/a', 'whole session, API rates')}
129 {tile('Tokens', compactTokens(tokens), 'agents, excl. cache reads')}
130 {tile('Time', agentClock(last - first), 'this crew')}
131 </Box>
132 <Text> </Text>
133 {running.length ? <Text bold color="#8b7cf6">{`Running · ${running.length}`}</Text> : null}
134 {running.map(row)}
135 {ended.length ? <Text bold color="#2ec27e">{`Finished · ${ended.length}`}</Text> : null}
136 {ended.map(row)}
137 {planned.length ? <Text bold dimColor>{`Planned · ${planned.length}`}</Text> : null}
138 {planned.slice(0, 6).map(t => <Text key={t.id} dimColor>{'◷ ' + t.subject}</Text>)}
139 <Text> </Text>
140 </Box>
141 )
142}
143
144export const register: Register = on => {
145 on('session.start', async ($, e, next) => {
146 const r = await next(e)
147 if (!(await read($, startA))) {
148 const now = await $.clock.now()
149 await update($, startA, () => now)
150 }
151 const name = await projectName($)
152 await update($, projectA, () => name)
153 await $.command.register({ name: 'progress', description: 'Show live progress on what this session is working on' })
154 return r
155 })
156
157 on('command.run', { command: 'progress' }, async ($, e) => {
158 await update($, hiddenA, () => false)
159 if (e.args.trim() === 'demo') {
160 // A 12-second show of what a session looks like as it works through a list. Demo tasks
161 // carry a demo- id, are never tallied, and vanish the moment a real task arrives.
162 const steps = ['Read the brief', 'Pull the live numbers', 'Build the change', 'Run the test suite', 'Check it on real data', 'Ship it']
163 await apply($, (_prev, now) => steps.reduce((ts, s, i) => upsert(ts, `demo-${i}`, { subject: s }, now), [] as Task[]))
164 let i = 0
165 const tick = $.clock.every(1000, () => {
166 const k = Math.floor(i / 2), starting = i % 2 === 0
167 i++
168 if (k >= steps.length) { tick.cancel(); return }
169 void apply($, (prev, now) => upsert(prev, `demo-${k}`, starting
170 ? { status: 'in_progress', activeForm: steps[k] + '...' }
171 : { status: 'completed' }, now)).catch(() => {})
172 })
173 return { text: 'Running a 12 second demo in the band above the prompt.' }
174 }
175 try { await $.ui.open({ id: PANE, title: 'Progress' }) } catch { /* a screen with no panes: the text below still answers */ }
176 // Also answer in text: sessions whose screen does not draw mod bands or panes (a cloud
177 // session viewed from the app) still get the picture as a transcript line.
178 const tasks = await read($, tasksA)
179 const now = await $.clock.now()
180 const started = await read($, startA)
181 const crewText = agentsText(currentBatch(await read($, agentsA)), now)
182 const report = textReport(tasks, (await read($, projectA)) || 'this session', now - (started || now))
183 return { text: tasks.length || !crewText ? report + (crewText ? '\n' + crewText : '') : crewText }
184 })
185
186 // The session's own task list is the source of truth: every TaskCreate, TaskUpdate and
187 // TodoWrite the model makes passes through here, and only a call that succeeded is counted.
188 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
189 const r = await next(e)
190 const id = r.deny === undefined && !r.isError ? r.result?.task?.id : undefined
191 if (id && e.agentId) await update($, agentsA, l => agentTasks(l, String(e.agentId), { created: 1 }))
192 else if (id) await apply($, (prev, now) => upsert(prev, String(id), { subject: e.subject, activeForm: e.activeForm ?? null }, now))
193 return r
194 }).catch(($, e, next) => next(e))
195
196 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
197 const r = await next(e)
198 if (r.deny !== undefined || r.isError || r.result?.success === false) return r
199 if (e.agentId) {
200 if (e.status === 'completed') await update($, agentsA, l => agentTasks(l, String(e.agentId), { completed: 1 }))
201 return r
202 }
203 const id = String(e.taskId)
204 if (e.status === 'deleted') {
205 await apply($, prev => prev.filter(t => t.id !== id))
206 } else {
207 await apply($, (prev, now) => upsert(prev, id, {
208 ...(e.subject ? { subject: e.subject } : {}),
209 ...(e.activeForm ? { activeForm: e.activeForm } : {}),
210 ...(e.status && e.status !== 'deleted' ? { status: e.status } : {}),
211 }, now))
212 }
213 return r
214 }).catch(($, e, next) => next(e))
215
216 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
217 const r = await next(e)
218 if (r.deny === undefined && !r.isError && e.agentId) {
219 await update($, agentsA, l => agentTasks(l, String(e.agentId), { total: e.todos.length, done: e.todos.filter(t => t.status === 'completed').length }))
220 } else if (r.deny === undefined && !r.isError) await apply($, (prev, now) => fromTodos(prev, e.todos, now))
221 return r
222 }).catch(($, e, next) => next(e))
223
224 // The crew: every subagent the session starts (Agent tool, workflows, forks) gets a card.
225 on('agent.spawn', async ($, e, next) => {
226 const r = await next(e)
227 if (r.deny === undefined && r.agentId) {
228 const now = await $.clock.now()
229 await update($, agentsA, l => spawned(l, { id: r.agentId!, name: e.description || e.subagentType, type: e.subagentType, model: r.model || e.model || e.parentModel, background: e.background }, now))
230 if (!(await read($, paneOpenedA))) {
231 await update($, paneOpenedA, () => true)
232 try { await $.ui.open({ id: PANE, title: 'Progress' }) } catch { /* a screen with no panes */ }
233 }
234 }
235 return r
236 }).catch(($, e, next) => next(e))
237
238 // Each tool call an agent makes moves its card: a step counted and a few words on what it is doing.
239 on('tool.call', async ($, e, next) => {
240 const r = await next(e)
241 if (e.agentId) {
242 const label = stepText(e.tool, e as unknown as Record<string, unknown>)
243 await update($, agentsA, l => stepped(l, String(e.agentId), label))
244 }
245 return r
246 }).catch(($, e, next) => next(e))
247
248 // An agent's turn ending is the agent finishing: its tokens are what the API reported for it.
249 on('turn.complete', async ($, e, next) => {
250 const r = await next(e)
251 if (e.agentId) {
252 const now = await $.clock.now()
253 const ok = e.reason === 'answer'
254 await update($, agentsA, l => finished(l, String(e.agentId), ok, usageTokens(e.usage), now))
255 const batch = currentBatch(await read($, agentsA))
256 if (batch.length > 1 && batch.every(a => a.status !== 'running')) {
257 $.ui.toast(`Crew finished: ${batch.filter(a => a.status === 'done').length} of ${batch.length} agents done.`, { timeoutMs: 6000 })
258 }
259 }
260 return r
261 }).catch(($, e, next) => next(e))
262
263 // The band above the prompt: one glance, always there while there is work on the list.
264 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
265 const below = await next(e)
266 const tasks = await read($, tasksA)
267 const crew = currentBatch(await read($, agentsA))
268 if (e.props.hasSurvey || (!tasks.length && !crew.length) || (await read($, hiddenA))) return below
269 const now = await $.clock.now()
270 const c = counts(tasks)
271 const crewDone = crew.filter(a => a.status !== 'running').length
272 const lastDone = Math.max(0, ...tasks.map(t => t.doneAt ?? 0), ...crew.map(a => a.endedAt ?? 0))
273 const cheer = await read($, cheerA)
274 const cheering = cheer && cheer.until > now
275 const allDone = (tasks.length ? c.done === c.total : true) && crewDone === crew.length
276 if (allDone && now - lastDone > QUIET_AFTER_DONE_MS && !cheering) return below
277
278 const { Box, Text, Button } = $.ui.resolve(e)
279 const project = await read($, projectA)
280 const started = await read($, startA)
281
282 // Screens that draw vectors (the desktop app, VS Code, mobile): the animated pixel track,
283 // a pill riding its leading edge, and the crew count beside a bobbing bot.
284 if (e.surface !== 'terminal') {
285 const { Svg } = $.ui.resolve(e) as unknown as { Svg: El }
286 // Tasks and agents count together, so a crew still running never shows as 100%.
287 const crewRunning = crew.some(a => a.status === 'running')
288 const units = tasks.length + crew.length, unitsDone = c.done + crewDone
289 const pct = Math.round((unitsDone / Math.max(1, units)) * 100)
290 const done = pct >= 100 && allDone
291 const active = tasks.find(t => t.status === 'in_progress')
292 const word = active ? pillWords(active.activeForm || active.subject) : crewRunning ? 'Agents' : 'Next up'
293 const count = active || !crewRunning ? `${c.done}/${c.total}` : `${crewDone}/${crew.length}`
294 const label = done ? 'Done' : `${word} ${count}`
295 const title = (project || 'This session').slice(0, 28)
296 const dot = done ? '#2ec27e' : '#8b7cf6'
297 const running = crewRunning
298 const crewHat = (crew.find(a => a.status === 'running') ?? crew[crew.length - 1])
299 return (
300 <Box flexDirection="column">
301 <Box flexDirection="row" alignItems="center" columnGap={1}>
302 <Text color={dot}>{'●'}</Text>
303 <Text bold>{title}</Text>
304 <Svg source={trackSvg(pct, label, done ? 'done' : 'running', 300)} alt={`${title}: ${pct}% (${label})`} width={300} height={TRACK_H} />
305 <Text bold>{`${pct}%`}</Text>
306 {crew.length
307 ? <Box flexDirection="row" alignItems="center" columnGap={1}>
308 <Svg source={botSvg(done || !crewHat ? '#2ec27e' : tier(crewHat.model, crewHat.type).color, { cell: 2.4, bounce: running })} alt="crew" {...botSize(2.4)} />
309 <Text>{`×${crew.length}`}</Text>
310 </Box>
311 : null}
312 <Button key="hide" plain label="✕" onPress={() => update($, hiddenA, () => true)} />
313 </Box>
314 {cheering
315 ? <Text bold color={cheer!.big ? '#2ec27e' : 'success'}>{(cheer!.big ? '★ ' : '✦ ') + cheer!.text}</Text>
316 : active
317 ? <Text wrap="truncate-end">
318 <Text color={dot}>{'▶ '}</Text>
319 <Text>{active.activeForm || active.subject}</Text>
320 <Text dimColor>{(tasks.find(t => t.status === 'pending') ? ' next: ' + tasks.find(t => t.status === 'pending')!.subject : '') + (tasks.length ? ` ${c.done} of ${c.total}` : '')}</Text>
321 </Text>
322 : null}
323 {below}
324 </Box>
325 )
326 }
327
328 if (!tasks.length) {
329 return (
330 <Box flexDirection="column">
331 <Text>
332 <Text bold color="#8b7cf6">{'◆ '}</Text>
333 <Text bold>{(project || 'session') + ' '}</Text>
334 <Text>{`agents ${crewDone}/${crew.length} done`}</Text>
335 <Text dimColor>{' · ' + crew.filter(a => a.status === 'running').map(a => a.name).slice(0, 2).join(', ')}</Text>
336 </Text>
337 {below}
338 </Box>
339 )
340 }
341 // Size the bar to what is left of the line after the fixed text, so the band never wraps,
342 // including when the /progress pane is docked beside the transcript and narrows it.
343 const name = (project || 'session').slice(0, 24)
344 const tail = ` ${c.pct}% ${c.done} of ${c.total} · ${duration(now - (started || now))}`
345 const width = Math.max(6, Math.min(40, e.props.bodyColumns - (2 + name.length + 1) - tail.length - 7))
346 const active = tasks.find(t => t.status === 'in_progress')
347 const upNext = tasks.find(t => t.status === 'pending')
348
349 return (
350 <Box flexDirection="column">
351 <Box flexDirection="row" flexWrap="nowrap">
352 <Text wrap="truncate-end">
353 <Text bold color={gradient(c.pct / 100)}>{'◆ '}</Text>
354 <Text bold>{name + ' '}</Text>
355 {runs(bar(c, width)).map(r => (r.color ? <Text color={r.color}>{r.text}</Text> : <Text dimColor>{r.text}</Text>))}
356 <Text bold color={gradient(c.pct / 100)}>{` ${c.pct}%`}</Text>
357 <Text dimColor>{` ${c.done} of ${c.total} · ${duration(now - (started || now))}`}</Text>
358 </Text>
359 <Box marginLeft={2}>
360 <Button key="hide" plain label="hide" onPress={() => update($, hiddenA, () => true)} />
361 </Box>
362 </Box>
363 {cheering
364 ? <Text bold color={cheer!.big ? '#2ed573' : 'success'}>{(cheer!.big ? '★ ★ ★ ' : '✦ ') + cheer!.text + (cheer!.big ? ' ★ ★ ★' : ' ✦')}</Text>
365 : active
366 ? <Text>
367 <Text color={gradient(c.pct / 100)}>{'▶ '}</Text>
368 <Text>{active.activeForm || active.subject}</Text>
369 {upNext ? <Text dimColor>{' next: ' + upNext.subject}</Text> : null}
370 </Text>
371 : upNext ? <Text dimColor>{'○ up next: ' + upNext.subject}</Text> : null}
372 {below}
373 </Box>
374 )
375 })
376
377 // The full view: /progress opens it.
378 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
379 const { Box, Text } = $.ui.resolve(e)
380 const tasks = await read($, tasksA)
381 const project = await read($, projectA)
382 const started = await read($, startA)
383 const now = await $.clock.now()
384 const c = counts(tasks)
385 const cols = e.props.bodyColumns
386 const width = Math.max(10, Math.min(60, cols - 4))
387 const big = runs(bar(c, width))
388 const left = eta(tasks)
389 const doneTimes = tasks.filter(t => t.status === 'completed' && t.doneAt).map(t => (t.doneAt as number) - (t.startedAt ?? t.createdAt))
390 const avg = doneTimes.length ? doneTimes.reduce((x, y) => x + y, 0) / doneTimes.length : null
391
392 let tally: Record<string, number> = {}
393 try { tally = ((await $.store.get(`day:${today(now)}`)) ?? {}) as Record<string, number> } catch { /* empty */ }
394 const most = Math.max(1, ...Object.values(tally))
395 const barRow = (key: string) => (
396 <Box key={key} flexDirection="row">
397 {big.map(r => (r.color ? <Text color={r.color}>{r.text}</Text> : <Text dimColor>{r.text}</Text>))}
398 </Box>
399 )
400
401 const crew = currentBatch(await read($, agentsA))
402 const PaneSvg = e.surface !== 'terminal' ? ($.ui.resolve(e) as unknown as { Svg: El }).Svg : null
403 const crewView = crew.length ? await crewSection($, { Box, Text, Svg: PaneSvg }, crew, tasks, now) : null
404 // The task list's own bar: the band's dotted track where vectors draw. The block-character bar
405 // is for the terminal; on the desktop it wrapped into four green stripes nobody could read.
406 const taskBar = PaneSvg
407 ? <PaneSvg source={trackSvg(c.pct, c.done === c.total ? 'Done' : `${c.done}/${c.total}`, c.done === c.total ? 'done' : 'running', 300)} alt={`Tasks ${c.pct}%`} width={300} height={TRACK_H} />
408 : <Box flexDirection="column">{barRow('b1')}{barRow('b2')}</Box>
409
410 return (
411 <Box flexDirection="column">
412 <Text bold>{`${project || 'This session'}`}</Text>
413 <Text> </Text>
414 {crewView}
415 {crewView ? <Text bold>Session tasks</Text> : null}
416 {tasks.length === 0 && crew.length ? <Text dimColor>No task list in the main session yet.</Text> : tasks.length === 0
417 ? <Text dimColor>No task list yet. Progress appears as soon as the session breaks the work into tasks.</Text>
418 : <Box flexDirection="column">
419 {taskBar}
420 <Text> </Text>
421 <Text>
422 <Text bold color={gradient(c.pct / 100)}>{`${c.pct}%`}</Text>
423 <Text>{` ${c.done} of ${c.total} done`}</Text>
424 <Text dimColor>{c.active ? ` · ${c.active} in progress` : ''}</Text>
425 </Text>
426 <Text> </Text>
427 {tasks.map(t => {
428 const took = t.status === 'completed' && t.doneAt ? duration(t.doneAt - (t.startedAt ?? t.createdAt))
429 : t.status === 'in_progress' && t.startedAt ? `${duration(now - t.startedAt)} so far` : ''
430 return t.status === 'completed'
431 ? <Text><Text color="#2ed573">{'✓ '}</Text><Text dimColor>{t.subject}</Text><Text dimColor>{took ? ` ${took}` : ''}</Text></Text>
432 : t.status === 'in_progress'
433 ? <Text><Text bold color={gradient(c.pct / 100)}>{'▶ '}</Text><Text bold>{t.activeForm || t.subject}</Text><Text dimColor>{took ? ` ${took}` : ''}</Text></Text>
434 : <Text dimColor>{'○ ' + t.subject}</Text>
435 })}
436 <Text> </Text>
437 <Text dimColor>
438 {`Elapsed ${duration(now - (started || now))}`}
439 {avg !== null ? ` · ${duration(avg)} per task (measured)` : ''}
440 {left !== null && left > 0 ? ` · about ${duration(left)} left (estimate from that average)` : ''}
441 </Text>
442 </Box>}
443 {Object.keys(tally).length
444 ? <Box flexDirection="column">
445 <Text> </Text>
446 <Text bold>Today across projects</Text>
447 {Object.entries(tally).sort((x, y) => y[1] - x[1]).map(([name, n], i) => (
448 PaneSvg
449 ? <Box key={name} flexDirection="row" columnGap={1} alignItems="center">
450 <Text>{name.slice(0, 22)}</Text>
451 <PaneSvg source={miniBarSvg(n / most, gradient(n / most), 160)} alt={`${name}: ${n} done`} width={160} height={BAR_H} />
452 <Text bold>{`${n}`}</Text>
453 </Box>
454 : <Text>
455 <Text>{name.padEnd(22).slice(0, 22)}</Text>
456 <Text color={gradient(n / most)}>{'█'.repeat(Math.max(1, Math.round((n / most) * 24)))}</Text>
457 <Text bold>{` ${n}`}</Text>
458 </Text>
459 ))}
460 </Box>
461 : null}
462 </Box>
463 )
464 })
465}
466hooks/logic.ts 122 lines1import type { Task, TaskStatus } from '../types'
2
3// Orange to gold to green as the bar fills. At 100% the whole bar turns green.
4const STOPS: [number, number, number][] = [[0xff, 0x6b, 0x35], [0xff, 0xc1, 0x4d], [0x2e, 0xd5, 0x73]]
5const hex = (r: number, g: number, b: number) =>
6 '#' + [r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('')
7
8export function gradient(t: number): string {
9 const x = Math.min(1, Math.max(0, t)) * (STOPS.length - 1)
10 const i = Math.min(STOPS.length - 2, Math.floor(x))
11 const f = x - i
12 const a = STOPS[i]!, b = STOPS[i + 1]!
13 return hex(a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f)
14}
15
16export type Counts = { total: number; done: number; active: number; pending: number; pct: number }
17
18export function counts(tasks: Task[]): Counts {
19 const total = tasks.length
20 const done = tasks.filter(t => t.status === 'completed').length
21 const active = tasks.filter(t => t.status === 'in_progress').length
22 return { total, done, active, pending: total - done - active, pct: total ? Math.round((done / total) * 100) : 0 }
23}
24
25// One cell per entry: filled cells carry their gradient color; an in-progress task shows as a
26// half-lit cell after the filled run so the bar visibly moves as work starts, not only as it ends.
27export type Cell = { ch: string; color: string | null }
28
29export function bar(c: Counts, width: number): Cell[] {
30 const w = Math.max(4, width)
31 const filled = c.total ? Math.round((c.done / c.total) * w) : 0
32 const activeCells = c.total && c.active ? Math.max(1, Math.round((c.active / c.total) * w)) : 0
33 const cells: Cell[] = []
34 for (let i = 0; i < w; i++) {
35 if (i < filled) cells.push({ ch: '█', color: c.done === c.total ? '#2ed573' : gradient(i / Math.max(1, w - 1)) })
36 else if (i < filled + activeCells) cells.push({ ch: '▓', color: gradient(i / Math.max(1, w - 1)) })
37 else cells.push({ ch: '░', color: null })
38 }
39 return cells
40}
41
42// Collapse runs of the same color so a 40-cell bar is a handful of Text elements, not 40.
43export function runs(cells: Cell[]): { text: string; color: string | null }[] {
44 const out: { text: string; color: string | null }[] = []
45 for (const cell of cells) {
46 const last = out[out.length - 1]
47 if (last && last.color === cell.color) last.text += cell.ch
48 else out.push({ text: cell.ch, color: cell.color })
49 }
50 return out
51}
52
53export function duration(ms: number): string {
54 const s = Math.max(0, Math.round(ms / 1000))
55 if (s < 60) return `${s}s`
56 const m = Math.round(s / 60)
57 if (m < 60) return `${m}m`
58 return `${Math.floor(m / 60)}h ${m % 60}m`
59}
60
61// Estimate of time left: the average of the measured durations of tasks finished this session,
62// times what is left. Labelled an estimate wherever it is shown; null until two tasks have
63// finished with a measured duration, because one sample is not an average.
64export function eta(tasks: Task[]): number | null {
65 const measured = tasks
66 .filter(t => t.status === 'completed' && t.doneAt !== null && (t.startedAt ?? t.createdAt) !== null)
67 .map(t => (t.doneAt as number) - (t.startedAt ?? t.createdAt))
68 .filter(ms => ms > 0)
69 if (measured.length < 2) return null
70 const avg = measured.reduce((a, b) => a + b, 0) / measured.length
71 const left = tasks.filter(t => t.status !== 'completed').length
72 return left ? avg * left : 0
73}
74
75// The quarter marks a run of completions crossed, so each gets one cheer and only one.
76export function milestonesCrossed(before: number, after: number): number[] {
77 return [25, 50, 75, 100].filter(m => before < m && after >= m)
78}
79
80export function upsert(tasks: Task[], id: string, patch: Partial<Task> & { subject?: string }, now: number): Task[] {
81 const i = tasks.findIndex(t => t.id === id)
82 if (i === -1) {
83 return [...tasks, {
84 id, subject: patch.subject ?? `Task ${id}`, activeForm: patch.activeForm ?? null,
85 status: (patch.status as TaskStatus) ?? 'pending', createdAt: now,
86 startedAt: patch.status === 'in_progress' ? now : null, doneAt: patch.status === 'completed' ? now : null,
87 }]
88 }
89 const prev = tasks[i]!
90 const next: Task = { ...prev, ...patch }
91 if (patch.status === 'in_progress' && prev.status !== 'in_progress') next.startedAt = now
92 if (patch.status === 'completed' && prev.status !== 'completed') next.doneAt = now
93 if (patch.status && patch.status !== 'completed') next.doneAt = null
94 return tasks.map((t, j) => (j === i ? next : t))
95}
96
97// TodoWrite hands over the whole list every time. Match by content so a task keeps its timings
98// across rewrites; anything not in the new list is gone.
99export function fromTodos(prev: Task[], todos: { content: string; status: TaskStatus; activeForm?: string }[], now: number): Task[] {
100 return todos.map((todo, i) => {
101 const old = prev.find(t => t.subject === todo.content)
102 const base: Task = old ?? { id: `todo-${i}-${todo.content.slice(0, 24)}`, subject: todo.content, activeForm: todo.activeForm ?? null, status: 'pending', createdAt: now, startedAt: null, doneAt: null }
103 return upsert([base], base.id, { status: todo.status, activeForm: todo.activeForm ?? base.activeForm }, now)[0]!
104 })
105}
106
107export const today = (now: number) => new Date(now).toISOString().slice(0, 10)
108
109// The whole picture as plain text, for a screen that draws no bands or panes.
110export function textReport(tasks: Task[], project: string, elapsedMs: number): string {
111 if (!tasks.length) return `${project}: no task list yet. Progress appears once the session breaks the work into tasks.`
112 const c = counts(tasks)
113 const cells = bar(c, 20).map(x => x.ch).join('')
114 const lines = [`${project} ${cells} ${c.pct}% ${c.done} of ${c.total} done · ${duration(elapsedMs)}`]
115 for (const t of tasks) {
116 lines.push(t.status === 'completed' ? ` ✓ ${t.subject}` : t.status === 'in_progress' ? ` ▶ ${t.activeForm || t.subject}` : ` ○ ${t.subject}`)
117 }
118 const left = eta(tasks)
119 if (left) lines.push(` about ${duration(left)} left (estimate from the measured average)`)
120 return lines.join('\n')
121}
122hooks/agents.ts 180 lines1import type { Agent } from '../types'
2
3// What a model id reads as: claude-opus-5-5 -> Opus 5.5. Anything else is shown as given.
4export function modelName(id: string): string {
5 const m = /(opus|sonnet|haiku)-(\d+)(?:-(\d+))?/i.exec(id || '')
6 if (!m) return id || 'inherit'
7 return `${m[1]![0]!.toUpperCase()}${m[1]!.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
8}
9
10// The crew tier, read from the model the agent runs on: the bigger the model, the heavier the hat.
11export type Tier = { label: string; color: string }
12export function tier(model: string, type: string): Tier {
13 const m = (model || '').toLowerCase()
14 if (/explore|plan|review/i.test(type)) return { label: 'careful', color: '#e0a526' }
15 if (m.includes('opus')) return { label: 'heavy', color: '#ff6b35' }
16 if (m.includes('haiku')) return { label: 'light', color: '#2ec27e' }
17 return { label: 'medium', color: '#5b8def' }
18}
19
20export function spawned(list: Agent[], a: { id: string; name: string; type: string; model: string; background: boolean }, now: number): Agent[] {
21 if (list.some(x => x.id === a.id)) return list
22 return [...list, { ...a, status: 'running' as const, startedAt: now, endedAt: null, steps: 0, stepLabel: 'Starting', tokens: 0, tasksDone: 0, tasksTotal: 0 }].slice(-40)
23}
24
25export function stepped(list: Agent[], id: string, label: string): Agent[] {
26 return list.map(a => (a.id === id && a.status === 'running' ? { ...a, steps: a.steps + 1, stepLabel: label } : a))
27}
28
29export function agentTasks(list: Agent[], id: string, change: { created?: number; completed?: number; total?: number; done?: number }): Agent[] {
30 return list.map(a => {
31 if (a.id !== id) return a
32 const tasksTotal = change.total ?? a.tasksTotal + (change.created ?? 0)
33 const tasksDone = Math.min(tasksTotal, change.done ?? a.tasksDone + (change.completed ?? 0))
34 return { ...a, tasksTotal, tasksDone }
35 })
36}
37
38export function finished(list: Agent[], id: string, ok: boolean, tokens: number, now: number): Agent[] {
39 return list.map(a => (a.id === id
40 ? { ...a, status: (ok ? 'done' : 'failed') as Agent['status'], endedAt: now, tokens: a.tokens + tokens, tasksDone: ok ? a.tasksTotal : a.tasksDone, stepLabel: ok ? 'Done' : 'Stopped' }
41 : a))
42}
43
44// A tool call as a few words on the agent's card.
45export function stepText(tool: string, input: Record<string, unknown>): string {
46 if (tool === 'TaskUpdate' && typeof input.activeForm === 'string') return input.activeForm
47 if (tool === 'Bash' && typeof input.description === 'string') return input.description
48 if ((tool === 'Read' || tool === 'Edit' || tool === 'Write') && typeof input.file_path === 'string') {
49 return `${tool} ${String(input.file_path).split('/').pop()}`
50 }
51 if (tool === 'Grep' || tool === 'Glob') return 'Searching'
52 if (tool.startsWith('mcp__')) return tool.split('__').slice(1).join(' ').replace(/_/g, ' ')
53 return tool
54}
55
56// The batch: agents started within ten minutes of the newest, so yesterday's crew does not crowd today's.
57export function currentBatch(list: Agent[]): Agent[] {
58 if (!list.length) return []
59 const newest = Math.max(...list.map(a => a.startedAt))
60 return list.filter(a => newest - a.startedAt < 10 * 60_000 || a.status === 'running')
61}
62
63export function fraction(a: Agent): number | null {
64 if (a.status !== 'running') return 1
65 if (a.tasksTotal > 0) return a.tasksDone / a.tasksTotal
66 return null // running with no task list: drawn as an indeterminate shimmer
67}
68
69export const compactTokens = (n: number) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`)
70
71// ---------- Pixel art (original sprite: a small worker bot whose hat color says its tier) ----------
72
73const SPRITE = [
74 '...hhhh...',
75 '..hhhhhh..',
76 '.hhhhhhhh.',
77 '.bbbbbbbb.',
78 '.bebbbbeb.',
79 '.bbbbbbbb.',
80 '.bbmmmmbb.',
81 '..bbbbbb..',
82 '..b.bb.b..',
83]
84
85// Drawn sizes, so the Svg element gets an exact box: without width and height the desktop app
86// sizes the frame itself, which is how the 0.2.0 band ended up with large white boxes.
87export const botSize = (cell = 4) => ({ width: 10 * cell, height: 10 * cell })
88export const TRACK_H = 22
89export const BAR_H = 4
90
91// The pill's words: the start of what is happening, cut at a word so it reads as a phrase.
92export function pillWords(text: string, max = 18): string {
93 const t = text.replace(/\.\.\.$|…$/, '').trim()
94 if (t.length <= max) return t
95 const cut = t.slice(0, max)
96 const sp = cut.lastIndexOf(' ')
97 return (sp > 6 ? cut.slice(0, sp) : cut).replace(/[\s,;:]+$/, '') + '…'
98}
99
100export function botSvg(hat: string, opts: { cell?: number; bounce?: boolean } = {}): string {
101 const c = opts.cell ?? 4
102 const w = SPRITE[0]!.length * c, hgt = SPRITE.length * c
103 const fill: Record<string, string> = { h: hat, b: '#9fb3c8', e: '#1b2430', m: '#5c6f82' }
104 let rects = ''
105 SPRITE.forEach((row, y) => [...row].forEach((ch, x) => {
106 if (ch !== '.') rects += `<rect x="${x * c}" y="${y * c}" width="${c}" height="${c}" fill="${fill[ch]}"/>`
107 }))
108 const bob = opts.bounce
109 ? `<animateTransform attributeName="transform" type="translate" values="0 0;0 -${c / 2};0 0" dur="0.9s" repeatCount="indefinite"/>`
110 : ''
111 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${hgt + c}" viewBox="0 -${c} ${w} ${hgt + c}" shape-rendering="crispEdges"><g>${rects}${bob}</g></svg>`
112}
113
114const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
115
116// The band's track: a field of pixel dots that fills left to right, a shimmer running through the
117// filled part while work is in flight, and a pill riding the leading edge with what is happening.
118export function trackSvg(pct: number, label: string, state: 'running' | 'done', width = 300): string {
119 const h = 22, dot = 3, gap = 2, rows = 3
120 const cols = Math.floor((width - 8) / (dot + gap))
121 const filledCols = Math.round((Math.max(0, Math.min(100, pct)) / 100) * cols)
122 const color = state === 'done' ? '#2ec27e' : '#8b7cf6'
123 let dots = ''
124 for (let x = 0; x < cols; x++) {
125 for (let y = 0; y < rows; y++) {
126 const on = x < filledCols
127 const op = on ? 0.95 : ((x * 7 + y * 3) % 5 === 0 ? 0.35 : 0.15)
128 dots += `<rect x="${4 + x * (dot + gap)}" y="${5 + y * (dot + gap)}" width="${dot}" height="${dot}" fill="${on ? color : '#8a8f98'}" opacity="${op}"/>`
129 }
130 }
131 const pillText = esc(label).slice(0, 24)
132 const pillW = Math.max(46, pillText.length * 6.6 + 18)
133 const edge = 4 + filledCols * (dot + gap)
134 const pillX = state === 'done' ? width - pillW - 2 : Math.max(2, Math.min(width - pillW - 2, edge - pillW / 2))
135 const shimmer = state === 'running' && filledCols > 2
136 ? `<rect x="0" y="0" width="28" height="${h}" fill="url(#sh)"><animate attributeName="x" from="-28" to="${edge}" dur="1.6s" repeatCount="indefinite"/></rect>`
137 : ''
138 const pulse = state === 'running'
139 ? `<animate attributeName="opacity" values="1;0.75;1" dur="1.4s" repeatCount="indefinite"/>`
140 : ''
141 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}" viewBox="0 0 ${width} ${h}">` +
142 `<defs><linearGradient id="sh" x1="0" x2="1"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="0.5" stop-color="#fff" stop-opacity="0.55"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>` +
143 `<clipPath id="fill"><rect x="0" y="0" width="${edge}" height="${h}"/></clipPath></defs>` +
144 `<rect x="0" y="0" width="${width}" height="${h}" rx="${h / 2}" fill="#8a8f98" opacity="0.12"/>` +
145 dots + `<g clip-path="url(#fill)">${shimmer}</g>` +
146 `<g><rect x="${pillX}" y="1" width="${pillW}" height="${h - 2}" rx="${(h - 2) / 2}" fill="${color}"/>` +
147 `<text x="${pillX + pillW / 2}" y="${h / 2 + 4}" text-anchor="middle" font-family="-apple-system,Segoe UI,Helvetica,Arial,sans-serif" font-size="11.5" font-weight="600" fill="#fff">${pillText}</text>${pulse}</g></svg>`
148}
149
150// A thin per-agent bar: solid when it has a known fraction, a sliding stripe when it does not.
151export function miniBarSvg(frac: number | null, color: string, width = 260): string {
152 const h = 4
153 const base = `<rect x="0" y="0" width="${width}" height="${h}" rx="2" fill="#8a8f98" opacity="0.2"/>`
154 if (frac === null) {
155 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}" viewBox="0 0 ${width} ${h}">${base}<rect x="0" y="0" width="${width * 0.3}" height="${h}" rx="2" fill="${color}"><animate attributeName="x" from="-${width * 0.3}" to="${width}" dur="1.3s" repeatCount="indefinite"/></rect></svg>`
156 }
157 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}" viewBox="0 0 ${width} ${h}">${base}<rect x="0" y="0" width="${Math.round(width * Math.max(0, Math.min(1, frac)))}" height="${h}" rx="2" fill="${color}"/></svg>`
158}
159
160const clock = (ms: number) => {
161 const s = Math.max(0, Math.round(ms / 1000))
162 return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
163}
164export const agentClock = clock
165
166// One line per agent of the current crew, for sessions whose screen draws no panes.
167export function agentsText(crew: Agent[], now: number): string {
168 if (!crew.length) return ''
169 const done = crew.filter(a => a.status !== 'running').length
170 const lines = [`Agents ${done} of ${crew.length} finished`]
171 for (const a of crew) {
172 const mark = a.status === 'running' ? '●' : a.status === 'done' ? '✓' : '✕'
173 const t = tier(a.model, a.type)
174 const step = a.tasksTotal ? `${a.tasksDone}/${a.tasksTotal} · ${a.stepLabel}` : a.stepLabel
175 const took = clock((a.endedAt ?? now) - a.startedAt)
176 lines.push(` ${mark} ${a.name} (${t.label}, ${modelName(a.model)}) ${a.status === 'running' ? step : a.status === 'done' ? 'finished' : 'failed'} · ${a.tokens ? compactTokens(a.tokens) + ' tokens · ' : ''}${took}`)
177 }
178 return lines.join('\n')
179}
180types/index.d.ts 46 lines1export type TaskStatus = 'pending' | 'in_progress' | 'completed'
2
3export type Task = {
4 id: string
5 subject: string
6 activeForm: string | null
7 status: TaskStatus
8 createdAt: number
9 startedAt: number | null
10 doneAt: number | null
11}
12
13export type Celebration = { text: string; big: boolean; until: number }
14
15export type AgentStatus = 'running' | 'done' | 'failed'
16
17export type Agent = {
18 id: string
19 name: string
20 type: string
21 model: string
22 background: boolean
23 status: AgentStatus
24 startedAt: number
25 endedAt: number | null
26 steps: number
27 stepLabel: string
28 tokens: number
29 tasksDone: number
30 tasksTotal: number
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 'progress-pulse': {
36 tasks: Task[]
37 sessionStart: number
38 celebration: Celebration | null
39 project: string
40 bandHidden: boolean
41 agents: Agent[]
42 paneOpened: boolean
43 }
44 }
45}
46