A progress bar above the prompt and a live agents panel. Shows any subagents.

A Claude Code mod that shows what your subagents are doing: a one-line progress band above the prompt and a dockable "Agents" panel with per-agent cards. Useful when you fan work out to several subagents and want cost, tokens and status at a glance.
Agents ████░░ 2/3 done · 1 running · ≈$1.20 · 84k tok · 4:12. Green cells are completed, red failed, yellow light-shade running. It yields to the feedback survey.light, medium, careful, heavy, or default, derived from the agent's effort), model name (for example Opus 5.5), effort, subagent type, context percentage, tokens, elapsed time and a context baragent.spawn, turn.step and turn.complete. A one-second timer reconciles agents that ended without a completion event (killed, stopped) while any are running.1. From the marketplace
/plugin install savvy-progress --marketplace srepollock/dotfiles
Answer y to add the marketplace, then pick a scope (user scope = every session). Hooks-only mods are active immediately.
2. Via the dotfiles repo
Clone srepollock/dotfiles and run .dotfile_scripts/claude_sync pull. It copies .claude/skills/savvy-progress/ into ~/.claude/skills/, where a plugin folder auto-loads in every new session.
3. Ad hoc
claude --plugin-dir /path/to/savvy-progress
Or list the absolute path in CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json.
| Command | Effect |
|---|---|
/savvy-progress | Toggle the band and panel |
/savvy-progress on (or open) | Show and open the panel |
/savvy-progress off (or close) | Hide the band and close the panel |
The command replies Agents panel on. or Agents panel off.
None. The only persisted setting is the on/off state set by /savvy-progress.
claude plugin validate <folder>
claude plugin test <folder>
Files are hot-reloaded when the mod is loaded from ~/.claude/skills/ or --plugin-dir in an interactive session.
Claude Code with the mod (function-hook plugin) API; built and tested against Claude Code 2.1.291.
hooks/register.tsx 312 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import {
5 addAgent,
6 byPhase,
7 cellsFor,
8 finishAgent,
9 formatCost,
10 formatDuration,
11 formatTokens,
12 hasRunning,
13 modelName,
14 percentOf,
15 progressCells,
16 recordStep,
17 settledPhase,
18 totalTokens,
19 weightOf,
20} from './agents'
21import type { AgentRow, SessionTotals } from '../types'
22
23const PLUGIN = 'savvy-progress'
24const PANE = 'savvy-progress'
25const PANE_TITLE = 'Agents'
26const STORE_KEY = 'isVisible'
27const TICK_MS = 1_000
28const MIN_BAR = 10
29const MAX_BAR = 40
30const DEFAULT_WINDOW = 200_000
31
32const agents = atom({ plugin: 'savvy-progress', key: 'agents' } as const, {})
33const totals = atom({ plugin: 'savvy-progress', key: 'totals' } as const, null)
34const sessionTokens = atom({ plugin: 'savvy-progress', key: 'sessionTokens' } as const, 0)
35const now = atom({ plugin: 'savvy-progress', key: 'now' } as const, 0)
36const isVisible = atom({ plugin: 'savvy-progress', key: 'isVisible' } as const, true)
37const isCompletedCollapsed = atom({ plugin: 'savvy-progress', key: 'isCompletedCollapsed' } as const, false)
38
39async function refreshTotals($: EngineInterface): Promise<void> {
40 const usage = await $.session.usage()
41 const next: SessionTotals = {
42 costUsd: usage.cost?.usd ?? null,
43 startedAt: usage.startedAt,
44 contextWindow: usage.context.window || DEFAULT_WINDOW,
45 }
46 const at = await $.clock.now()
47 await update($, totals, () => next)
48 await update($, now, () => at)
49}
50
51/** Catches agents that ended without a `turn.complete` of their own (killed, stopped). */
52async function reconcile($: EngineInterface): Promise<void> {
53 const live = await $.agent.list()
54 const at = await $.clock.now()
55 await update($, agents, current => {
56 let result = current
57 for (const info of live) {
58 const phase = settledPhase(info.status)
59 if (phase) result = finishAgent(result, info.id, phase, at)
60 }
61 return result
62 })
63}
64
65function parseToggle(args: string, current: boolean): boolean {
66 const word = args.trim().toLowerCase()
67 if (word === 'on' || word === 'open') return true
68 if (word === 'off' || word === 'close') return false
69 return !current
70}
71
72export const register: Register = on => {
73 on('session.start', async ($, e, next) => {
74 const result = await next(e)
75
76 await $.command.register({
77 name: PLUGIN,
78 description: 'Toggle the agents progress bar and panel',
79 argumentHint: '[on|off]',
80 immediate: true,
81 })
82
83 const stored = await $.store.get(STORE_KEY)
84 if (typeof stored === 'boolean') {
85 await update($, isVisible, () => stored)
86 }
87 await refreshTotals($)
88
89 $.clock.every(TICK_MS, () => {
90 void (async () => {
91 if (!hasRunning(await read($, agents))) return
92 await reconcile($)
93 await refreshTotals($)
94 })().catch(() => undefined)
95 })
96
97 return result
98 })
99
100 on('command.run', { command: PLUGIN }, async ($, e) => {
101 const shouldShow = parseToggle(e.args, await read($, isVisible))
102 await update($, isVisible, () => shouldShow)
103 await $.store.set(STORE_KEY, shouldShow)
104
105 if (shouldShow) {
106 await refreshTotals($)
107 await $.ui.open({ id: PANE, title: PANE_TITLE })
108 } else if ((await $.ui.panes()).some(pane => pane.id === PANE)) {
109 await $.ui.close({ id: PANE })
110 }
111
112 return { text: `Agents panel ${shouldShow ? 'on' : 'off'}.` }
113 })
114
115 on('agent.spawn', async ($, e, next) => {
116 const result = await next(e)
117 if (!result.agentId) return result
118
119 const startedAt = await $.clock.now()
120 const isFirst = Object.keys(await read($, agents)).length === 0
121 await update($, agents, current =>
122 addAgent(current, {
123 id: result.agentId!,
124 description: e.description || e.subagentType,
125 type: e.subagentType,
126 model: result.model,
127 startedAt,
128 }),
129 )
130 await update($, now, () => startedAt)
131
132 if (isFirst && (await read($, isVisible))) {
133 void $.ui.open({ id: PANE, title: PANE_TITLE })
134 }
135
136 return result
137 })
138
139 on('turn.step', async function* ($, e, next) {
140 const result = yield* next(e)
141 const usage = result.usage
142 if (usage) {
143 await update($, sessionTokens, n => n + totalTokens(usage))
144 }
145 if (e.agentId) {
146 const id = e.agentId
147 const effort = typeof e.effort === 'string' ? e.effort : null
148 await update($, agents, current => recordStep(current, id, { model: e.model, effort, usage }))
149 }
150
151 return result
152 })
153
154 on('turn.complete', async ($, e, next) => {
155 const result = await next(e)
156 const at = await $.clock.now()
157
158 if (e.agentId) {
159 const id = e.agentId
160 const phase = e.reason === 'answer' ? 'completed' : 'failed'
161 await update($, agents, current => finishAgent(current, id, phase, at))
162 }
163 await refreshTotals($)
164
165 return result
166 })
167
168 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
169 const rows = await read($, agents)
170 if (e.props.hasSurvey || Object.keys(rows).length === 0 || !(await read($, isVisible))) {
171 return next(e)
172 }
173
174 const { Box, Text } = $.ui.resolve(e)
175 const beneath = await next(e)
176 const groups = byPhase(rows)
177 const sums = await read($, totals)
178 const tokens = await read($, sessionTokens)
179 const at = await read($, now)
180
181 const done = groups.completed.length + groups.failed.length
182 const total = done + groups.running.length
183 const elapsed = sums ? formatDuration(at - sums.startedAt) : ''
184 const summary = [
185 ` ${done}/${total} done`,
186 groups.running.length > 0 ? `${groups.running.length} running` : null,
187 formatCost(sums?.costUsd ?? null),
188 `${formatTokens(tokens)} tok`,
189 elapsed,
190 ]
191 .filter(Boolean)
192 .join(' · ')
193 const width = Math.min(MAX_BAR, Math.max(MIN_BAR, e.props.bodyColumns - summary.length - 8))
194 const cells = progressCells(
195 { completed: groups.completed.length, failed: groups.failed.length, running: groups.running.length },
196 width,
197 )
198
199 return (
200 <Box flexDirection="column">
201 {beneath}
202 <Box key="savvy-band">
203 <Text bold>Agents </Text>
204 <Text>
205 <Text color="success">{'█'.repeat(cells.completed)}</Text>
206 <Text color="error">{'█'.repeat(cells.failed)}</Text>
207 <Text color="warning">{'░'.repeat(cells.running)}</Text>
208 </Text>
209 <Text dimColor>{summary}</Text>
210 </Box>
211 </Box>
212 )
213 })
214
215 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
216 const { Box, Text, Button } = $.ui.resolve(e)
217 const rows = await read($, agents)
218 const groups = byPhase(rows)
219 const sums = await read($, totals)
220 const tokens = await read($, sessionTokens)
221 const at = await read($, now)
222 const isCollapsed = await read($, isCompletedCollapsed)
223 const window = sums?.contextWindow ?? DEFAULT_WINDOW
224 const barWidth = Math.max(MIN_BAR, Math.min(MAX_BAR, e.props.bodyColumns - 4))
225 const finished = [...groups.completed, ...groups.failed].sort((a, b) => a.order - b.order)
226
227 const tile = (key: string, label: string, value: string) => (
228 <Box key={key} flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1} flexGrow={1}>
229 <Text dimColor>{label}</Text>
230 <Text bold>{value}</Text>
231 </Box>
232 )
233
234 const card = (row: AgentRow) => {
235 const weight = weightOf(row.effort)
236 const percent = percentOf(row.contextTokens, window)
237 const filled = cellsFor(percent, barWidth)
238 const elapsed = formatDuration((row.endedAt ?? at) - row.startedAt)
239 const mark =
240 row.phase === 'running' ? <Text color="error">●</Text>
241 : row.phase === 'completed' ? <Text color="success">✓</Text>
242 : <Text color="error">✗</Text>
243
244 return (
245 <Box key={`agent-${row.id}`} flexDirection="column" marginTop={1}>
246 <Box justifyContent="space-between">
247 <Text bold wrap="truncate-end">
248 {row.order}. {row.description}
249 </Text>
250 {mark}
251 </Box>
252 <Text>
253 <Text color={weight.color}>{weight.label}</Text>
254 <Text dimColor>
255 {' '}
256 {modelName(row.model)}
257 {row.effort ? ` · ${row.effort}` : ''} · {row.type}
258 </Text>
259 </Text>
260 <Text dimColor>
261 ctx {percent}% · {formatTokens(row.tokens)} · {elapsed}
262 </Text>
263 <Text>
264 <Text color={weight.color}>{'━'.repeat(filled)}</Text>
265 <Text color="subtle">{'━'.repeat(barWidth - filled)}</Text>
266 </Text>
267 </Box>
268 )
269 }
270
271 return (
272 <Box flexDirection="column">
273 <Box key="tiles" columnGap={1}>
274 {tile('cost', 'Cost', formatCost(sums?.costUsd ?? null))}
275 {tile('tokens', 'Tokens', formatTokens(tokens))}
276 {tile('time', 'Time', sums ? formatDuration(at - sums.startedAt) : '—')}
277 </Box>
278
279 {Object.keys(rows).length === 0 && (
280 <Text key="empty" dimColor>
281 No subagents yet.
282 </Text>
283 )}
284
285 {groups.running.length > 0 && (
286 <Box key="running" flexDirection="column" marginTop={1}>
287 <Text dimColor>Running · {groups.running.length}</Text>
288 {groups.running.map(card)}
289 </Box>
290 )}
291
292 {finished.length > 0 && (
293 <Box key="finished" flexDirection="column" marginTop={1}>
294 <Box columnGap={1}>
295 <Button
296 key="toggle-finished"
297 label={isCollapsed ? '▸' : '▾'}
298 onPress={() => update($, isCompletedCollapsed, value => !value)}
299 />
300 <Text dimColor>
301 Completed · {groups.completed.length}
302 {groups.failed.length > 0 ? ` · failed ${groups.failed.length}` : ''}
303 </Text>
304 </Box>
305 {!isCollapsed && finished.map(card)}
306 </Box>
307 )}
308 </Box>
309 )
310 })
311}
312hooks/agents.ts 169 lines1import type { AgentStatus, ModelUsage, ThemeKey } from 'claude-code'
2
3import type { AgentRow, Phase } from '../types'
4
5export type Agents = Record<string, AgentRow>
6
7export type Weight = { label: string; color: ThemeKey }
8
9const WEIGHTS: Record<string, Weight> = {
10 max: { label: 'heavy', color: 'error' },
11 xhigh: { label: 'heavy', color: 'error' },
12 high: { label: 'careful', color: 'warning' },
13 medium: { label: 'medium', color: 'suggestion' },
14 low: { label: 'light', color: 'success' },
15}
16
17const UNKNOWN_WEIGHT: Weight = { label: 'default', color: 'inactive' }
18
19export function weightOf(effort: string | null): Weight {
20 return (effort && WEIGHTS[effort]) || UNKNOWN_WEIGHT
21}
22
23export function totalTokens(usage: ModelUsage): number {
24 return (
25 usage.input_tokens +
26 usage.output_tokens +
27 usage.cache_read_input_tokens +
28 usage.cache_creation_input_tokens
29 )
30}
31
32export function contextTokens(usage: ModelUsage): number {
33 return usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
34}
35
36export type Spawned = {
37 id: string
38 description: string
39 type: string
40 model: string
41 startedAt: number
42}
43
44export function addAgent(agents: Agents, spawned: Spawned): Agents {
45 const order = Object.keys(agents).length + 1
46 return {
47 ...agents,
48 [spawned.id]: {
49 ...spawned,
50 effort: null,
51 phase: 'running',
52 endedAt: null,
53 tokens: 0,
54 contextTokens: 0,
55 order,
56 },
57 }
58}
59
60export type Step = { model: string; effort: string | null; usage: ModelUsage | null }
61
62/**
63 * A step from an agent this mod never saw spawn (spawned before a reload, or
64 * by a plugin whose spawns skip our hook) is ignored rather than invented.
65 */
66export function recordStep(agents: Agents, id: string, step: Step): Agents {
67 const row = agents[id]
68 if (!row) return agents
69
70 return {
71 ...agents,
72 [id]: {
73 ...row,
74 model: step.model,
75 effort: step.effort ?? row.effort,
76 phase: 'running',
77 endedAt: null,
78 tokens: row.tokens + (step.usage ? totalTokens(step.usage) : 0),
79 contextTokens: step.usage ? contextTokens(step.usage) : row.contextTokens,
80 },
81 }
82}
83
84export function finishAgent(agents: Agents, id: string, phase: Exclude<Phase, 'running'>, at: number): Agents {
85 const row = agents[id]
86 if (!row || row.phase !== 'running') return agents
87
88 return { ...agents, [id]: { ...row, phase, endedAt: at } }
89}
90
91/** Maps the engine's status to ours; undefined while it is still live. */
92export function settledPhase(status: AgentStatus): Exclude<Phase, 'running'> | undefined {
93 if (status === 'completed' || status === 'idle') return 'completed'
94 if (status === 'failed' || status === 'killed') return 'failed'
95 return undefined
96}
97
98export function byPhase(agents: Agents): Record<Phase, AgentRow[]> {
99 const sorted = Object.values(agents).sort((a, b) => a.order - b.order)
100 return {
101 running: sorted.filter(row => row.phase === 'running'),
102 completed: sorted.filter(row => row.phase === 'completed'),
103 failed: sorted.filter(row => row.phase === 'failed'),
104 }
105}
106
107export function hasRunning(agents: Agents): boolean {
108 return Object.values(agents).some(row => row.phase === 'running')
109}
110
111/** `claude-opus-5-5` → `Opus 5.5`, `claude-haiku-4-5-20251001` → `Haiku 4.5`. */
112export function modelName(model: string): string {
113 const bare = model
114 .replace(/\[.*\]$/, '')
115 .replace(/^claude-/, '')
116 .replace(/-\d{8}$/, '')
117 const [family, ...version] = bare.split('-')
118 if (!family) return model
119
120 const name = family.charAt(0).toUpperCase() + family.slice(1)
121 const digits = version.filter(part => /^\d+$/.test(part))
122 return digits.length > 0 ? `${name} ${digits.join('.')}` : name
123}
124
125export function formatTokens(tokens: number): string {
126 if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
127 if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`
128 return String(tokens)
129}
130
131export function formatCost(usd: number | null): string {
132 if (usd === null) return '—'
133 return usd >= 10 ? `≈$${usd.toFixed(1)}` : `≈$${usd.toFixed(2)}`
134}
135
136export function formatDuration(ms: number): string {
137 const seconds = Math.max(0, Math.floor(ms / 1000))
138 const h = Math.floor(seconds / 3600)
139 const m = Math.floor((seconds % 3600) / 60)
140 const s = String(seconds % 60).padStart(2, '0')
141 return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`
142}
143
144export function percentOf(part: number, whole: number): number {
145 if (whole <= 0) return 0
146 return Math.min(100, Math.round((part / whole) * 100))
147}
148
149export function cellsFor(percent: number, width: number): number {
150 return Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)
151}
152
153/**
154 * Splits the band's bar into finished (completed, failed) cells and the
155 * still-running remainder; the three always sum to the width.
156 */
157export function progressCells(
158 counts: { completed: number; running: number; failed: number },
159 width: number,
160): { completed: number; failed: number; running: number } {
161 const total = counts.completed + counts.running + counts.failed
162 if (total === 0 || width <= 0) return { completed: 0, failed: 0, running: Math.max(0, width) }
163
164 const completed = Math.floor((counts.completed / total) * width)
165 const failed = Math.floor((counts.failed / total) * width)
166 const running = counts.running > 0 ? width - completed - failed : 0
167 return { completed: width - failed - running, failed, running }
168}
169types/index.d.ts 35 lines1export type Phase = 'running' | 'completed' | 'failed'
2
3export type AgentRow = {
4 id: string
5 description: string
6 type: string
7 model: string
8 effort: string | null
9 phase: Phase
10 startedAt: number
11 endedAt: number | null
12 tokens: number
13 contextTokens: number
14 order: number
15}
16
17export type SessionTotals = {
18 costUsd: number | null
19 startedAt: number
20 contextWindow: number
21}
22
23declare module 'claude-code' {
24 interface PluginState {
25 'savvy-progress': {
26 agents: Record<string, AgentRow>
27 totals: SessionTotals | null
28 sessionTokens: number
29 now: number
30 isVisible: boolean
31 isCompletedCollapsed: boolean
32 }
33 }
34}
35