SLOPSHOPPER

savvy-progress

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

newpanebandcommandtimeragents
v0.1.0no licenseupdated 2026-10-06srepollock/dotfiles/.claude/skills/savvy-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · savvy-progress
│ ┃ savvy-progress ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Cost │ ⏺ Read(src/auth.ts) │ ┃ │ ≈$0.42 │ ⎿ Read 6 lines │ ┃ ╰──────────────────────────────────────────╯ ⏺ Update(src/auth.ts) │ ┃ No subagents yet. ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /savvy-progress │ ⎿ savvy-progress: Agents panel off. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · savvy-progress
╭──────────────────────────────────────────────────────────╮ │ Cost │ │ ≈$0.42 │ ╰──────────────────────────────────────────────────────────╯ No subagents yet.
README

savvy-progress

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.

What it does

  • Band above the prompt (appears once the first subagent spawns): 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.
  • Agents panel (pane titled "Agents"), opened automatically on the first spawn when visible:
  • tiles for session cost, total tokens and elapsed time
  • Running section, then a collapsible Completed section (button toggles it; failed agents are counted separately)
  • one card per agent: order and description, a status mark, a weight label (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 bar
  • Agents are tracked from agent.spawn, turn.step and turn.complete. A one-second timer reconciles agents that ended without a completion event (killed, stopped) while any are running.
  • Agents it never saw spawn (for example, spawned before a reload) are ignored.
  • Visibility is persisted in the plugin store and restored on the next session.

Install

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.

Usage

CommandEffect
/savvy-progressToggle 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.

Configuration

None. The only persisted setting is the on/off state set by /savvy-progress.

Development

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.

Requirements

Claude Code with the mod (function-hook plugin) API; built and tested against Claude Code 2.1.291.

Source 3 files
hooks/register.tsx 312 lines
1import { 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}
312
hooks/agents.ts 169 lines
1import 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}
169
types/index.d.ts 35 lines
1export 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