SLOPSHOPPER

savvy-progress

Progress bar above the prompt and a live subagent pane with pixel crab mascots, model, tokens and estimated cost.

newpanebandguardcommandtoast
v1.0.0no licenseupdated 2026-10-09Fraktalis/claude-mods/plugins/savvy-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · savvy-progress
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ No subagents yet this session. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ 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 │ ⎿ savvy-progress: Savvy Progress pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
No subagents yet this session.
README

claude-mods

My Claude Code mods, as a plugin marketplace (alex-mods). One folder per mod under plugins/.

ModWhat it does
savvy-progressEmber progress bar above the prompt for a batch of subagents, and an Agents pane: crab mascot per agent, live status, per-agent progress bar, model, tokens, estimated cost, elapsed time.
file-treeFiles pane with the project tree: files Claude reads (blue), modifies (orange) or creates (purple) light up with a shimmer, and turn green once committed, in every repo they live in.
tool-skinsReskins the transcript's tool rows: an icon and a palette colour per tool (dracula skin from kevinvn1709/vscode-dracula-color-theme), the row's result still drawn by Claude Code.
cache-taxPrompt-cache state in the status line; stops a message that would re-read the whole conversation on a cold cache and shows what it costs; /keepwarm keeps the cache warm while you are away.

Install on this machine (reads straight from this folder)

claude plugin marketplace add E:/WS/claude-mods
claude plugin install savvy-progress@alex-mods --scope user
claude plugin install file-tree@alex-mods --scope user
claude plugin install cache-tax@alex-mods --scope user
claude plugin install tool-skins@alex-mods --scope user

Installed this way, Claude Code reads the plugin from this folder, so an edit here reaches a session with /reload-plugins (no reinstall, no version bump).

Install from GitHub (another machine)

/plugin install savvy-progress --marketplace Fraktalis/claude-mods

Then update later with claude plugin update savvy-progress@alex-mods after pushing a new version.

Working on a mod

claude plugin validate plugins/savvy-progress
claude plugin test plugins/savvy-progress

Bump version in the mod's .claude-plugin/plugin.json for each release that GitHub installs should pick up.

savvy-progress

  • /savvy opens the Agents pane; /savvy-skin <type|*> <crab|developer|designer|researcher|architect|ghost> picks a mascot.
  • Registers a progress tool (mcp__savvy-progress__progress); each subagent is asked to report done/total/step with it. The main loop can also call it, with title to name the batch.
  • Cost is estimated from each request's token usage at the model's list price (table in hooks/register.tsx, PRICES); edit it when prices change.

file-tree

  • /tree opens the Files pane (it also opens by itself on the first file Claude edits or creates); /tree-clear forgets what was touched.
  • States: ◦ read, ● modified, + new, ✓ committed. Reads never downgrade a state; editing a committed file makes it modified again.
  • A successful git commit run by Claude re-checks git status in every repository holding a modified or new file; commits made in a terminal are picked up on the next prompt.
  • Folders leading to touched files open by themselves; node_modules, .git and the like are listed dimmed and stay closed. Touched only hides the rest. Files outside the session's folder are listed under Elsewhere.

cache-tax

  • Status line: 🔥 cache 42m while warm, 🧊 cache cold · next msg ≈ $2.40 once the TTL has passed since the main conversation's last request.
  • On a cold cache, a message whose context is above the threshold (20k tokens by default) is stopped once with the cold and warm cost; the text is put back in the box, and Enter again within 2 min sends it.
  • Cost: cold re-writes the whole context to the cache (2x input price for a 1h TTL, 1.25x for 5m); warm reads it at the cache-read price. Prices in hooks/register.ts (PRICES), same table as savvy-progress.
  • /cache shows the details; /keepwarm on|off sends a one-line ping ~1m30 before expiry, at most maxPings times in a row (never while Claude works or while you type). Each ping costs a cache read and a one-word reply.
  • Options (plugin config): ttl (1h or 5m), minTokens, maxPings. The TTL is not detected: switch it to 5m in usage overage.

tool-skins

  • Redraws each tool row's header (ToolUse): icon + tool name in its palette colour, the call's argument (last 3 path segments, or the one-line command), … running, ✗ error, Interrupted. The result under the row (ToolResult: diffs, Bash output) is still Claude Code's own.
  • /skin lists skins, /skin <name> switches, /skin off hands the rows back; the choice is kept across sessions. dracula is on by default.
  • dracula comes from themes/dracula-color-theme.json of github.com/kevinvn1709/vscode-dracula-color-theme, a Darcula-style palette despite its name: keyword #CC7832 → Bash, function #FFC66D → Edit, string #6A8759 → Write, number #6897BB → Read, type #4EC9B0 → Grep/Glob, property #9876AA → Agent, info #6796e6 → web tools, debug #b267e6 → MCP tools, on #212122.
  • A new skin is one more entry in THEMES (hooks/register.tsx). The name claude-skins is reserved for Anthropic, hence tool-skins.
Source 7 files
hooks/register.tsx 602 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, StateDollar } from 'claude-code'
3
4import type { AgentRow, AgentStatus, Batch, Report, Task, TaskStatus } from '../types'
5
6const PANE = 'savvy-agents'
7const PROGRESS_TOOL = 'mcp__savvy-progress__progress'
8
9const tasks = atom({ plugin: 'savvy-progress', key: 'tasks' } as const, {})
10const agents = atom({ plugin: 'savvy-progress', key: 'agents' } as const, {})
11const skins = atom({ plugin: 'savvy-progress', key: 'skins' } as const, {})
12const batch = atom({ plugin: 'savvy-progress', key: 'batch' } as const, null)
13const mainReport = atom({ plugin: 'savvy-progress', key: 'mainReport' } as const, null)
14const finishedCollapsed = atom({ plugin: 'savvy-progress', key: 'finishedCollapsed' } as const, false)
15const lastPrompt = atom({ plugin: 'savvy-progress', key: 'lastPrompt' } as const, '')
16
17// ---------- pure helpers (tested in savvy.test.ts) ----------
18
19export type Usage = {
20  input_tokens: number
21  output_tokens: number
22  cache_read_input_tokens: number
23  cache_creation_input_tokens: number
24}
25
26/** $ per million tokens: input, output, cache read. Cache writes bill at 1.25x input. */
27export type Price = { input: number; output: number; cacheRead: number }
28
29// ponytail: first-party list prices (claude-api skill table, 2026-10-06); edit here when prices change
30const PRICES: [RegExp, Price][] = [
31  [/fable|mythos/, { input: 10, output: 50, cacheRead: 0.25 }],
32  [/opus-5-5/, { input: 4, output: 20, cacheRead: 0.2 }],
33  [/opus/, { input: 5, output: 25, cacheRead: 0.5 }],
34  [/sonnet-5/, { input: 2, output: 10, cacheRead: 0.2 }],
35  [/sonnet/, { input: 3, output: 15, cacheRead: 0.3 }],
36  [/haiku-5/, { input: 0.1, output: 0.5, cacheRead: 0.01 }],
37  [/haiku/, { input: 1, output: 5, cacheRead: 0.1 }],
38]
39
40export const priceOf = (model: string): Price => PRICES.find(([re]) => re.test(model))?.[1] ?? PRICES[1]![1]
41
42/** What one request cost, in dollars, at the answering model's list price. */
43export const costUsd = (u: Usage, model: string) => {
44  const p = priceOf(model)
45  return (
46    (u.input_tokens * p.input +
47      u.cache_creation_input_tokens * p.input * 1.25 +
48      u.cache_read_input_tokens * p.cacheRead +
49      u.output_tokens * p.output) /
50    1_000_000
51  )
52}
53
54/** Tokens the request added: new input, cache writes and output; cache re-reads are not new. */
55export const newTokens = (u: Usage) => u.input_tokens + u.cache_creation_input_tokens + u.output_tokens
56
57export const promptTokens = (u: Usage) => u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
58
59export const tally = (list: Task[]) => ({
60  done: list.filter(t => t.status === 'completed').length,
61  total: list.length,
62  active: list.find(t => t.status === 'in_progress')?.subject,
63})
64
65export const bar = (done: number, total: number, width = 20) => {
66  const n = total ? Math.round((done / total) * width) : 0
67  return '█'.repeat(n) + '░'.repeat(width - n)
68}
69
70export const shortModel = (m: string) =>
71  (m || '?')
72    .replace(/^claude-/, '')
73    .replace(/-\d{8}$/, '')
74    .replace(/^(\w+)-(\d+)-(\d+)$/, (_, n: string, a: string, b: string) => `${n[0]!.toUpperCase()}${n.slice(1)} ${a}.${b}`)
75
76export const money = (usd: number) => (usd < 0.01 ? '≈<$0.01' : `≈$${usd.toFixed(2)}`)
77export const kTokens = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`)
78export const clock = (ms: number) => {
79  const s = Math.max(0, Math.round(ms / 1000))
80  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
81}
82
83/** Where an agent stands, 0..1, or null when nothing measurable was reported. */
84export const fraction = (a: Pick<AgentRow, 'status' | 'report'>, own?: { done: number; total: number }) => {
85  if (a.status === 'done' || a.status === 'failed') return 1
86  if (a.report && a.report.total > 0) return Math.min(1, a.report.done / a.report.total)
87  if (own && own.total > 0) return own.done / own.total
88  return null
89}
90
91export const titleOf = (prompt: string) => {
92  const first = prompt.replace(/^\/\S+\s*/, '').split(/[.:\n!?]/)[0]?.trim() ?? ''
93  if (!first) return 'Agents'
94  return first.length > 28 ? `${first.slice(0, 27)}…` : first
95}
96
97// ---------- pixel crabs ----------
98// 10x8 grid: two hat rows + six body rows. b body, e eye, h hat.
99
100export type Skin = { body: number; eye: number; hat?: number; hatRows?: [string, string]; role: string; color: string }
101
102export const SKINS = {
103  crab: { body: 0xd97757, eye: 0x1a1a1a, role: 'crab', color: '#d97757' },
104  developer: { body: 0xd97757, eye: 0x1a1a1a, hat: 0x3fb950, hatRows: ['...hhhh...', '..hhhhhhhh'], role: 'builder', color: '#3fb950' },
105  designer: { body: 0xd97757, eye: 0x1a1a1a, hat: 0xf2f2f2, hatRows: ['..hhhhhh..', '...hhhh...'], role: 'designer', color: '#4c9aff' },
106  researcher: { body: 0xd97757, eye: 0x1a1a1a, hat: 0xb07d48, hatRows: ['...hhhh...', '.hhhhhhhh.'], role: 'scout', color: '#e3a33b' },
107  architect: { body: 0xd97757, eye: 0x1a1a1a, hat: 0xf2c94c, hatRows: ['..hhhhhh..', '.hhhhhhhh.'], role: 'planner', color: '#f2c94c' },
108  ghost: { body: 0x8b949e, eye: 0x30363d, role: 'failed', color: '#f85149' },
109} satisfies Record<string, Skin>
110
111const skinOf = (name: string): Skin => (SKINS as Record<string, Skin>)[name] ?? SKINS.crab
112
113// ponytail: default skin per built-in agent type; /savvy-skin overrides
114const TYPE_SKIN: Record<string, string> = { Explore: 'researcher', Plan: 'architect', 'general-purpose': 'developer' }
115
116const BODY = ['.bbbbbbbb.', '.bebbbbeb.', 'bbbbbbbbbb', '.bbbbbbbb.', '.bbbbbbbb.', '.b.b..b.b.']
117
118export const pixels = (skin: Skin): (number | null)[][] =>
119  [...(skin.hatRows ?? ['..........', '..........']), ...BODY].map(r =>
120    [...r].map(c => (c === 'b' ? skin.body : c === 'e' ? skin.eye : c === 'h' ? skin.hat ?? null : null)),
121  )
122
123const DEFAULT = 0x01000000
124
125export const rasterCells = (px: (number | null)[][]) => {
126  const words: number[] = []
127  for (let y = 0; y < px.length; y += 2) {
128    const row = px[y] ?? []
129    for (let x = 0; x < row.length; x++) {
130      const top = row[x] ?? null
131      const bottom = px[y + 1]?.[x] ?? null
132      if (top === null && bottom === null) words.push(0x20, DEFAULT, DEFAULT)
133      else if (top === null) words.push(0x2584, bottom ?? DEFAULT, DEFAULT)
134      else words.push(0x2580, top, bottom ?? DEFAULT)
135    }
136  }
137  // toBase64 is in the mod runtime, not in the es2023 lib typings
138  return (new Uint8Array(Uint32Array.from(words).buffer) as Uint8Array & { toBase64(): string }).toBase64()
139}
140
141const hex = (c: number) => '#' + c.toString(16).padStart(6, '0')
142
143export const svgCrab = (px: (number | null)[][], opts: { scale?: number; bob?: boolean; dim?: boolean } = {}) => {
144  const s = opts.scale ?? 3
145  const rects = px
146    .flatMap((row, y) =>
147      row.map((c, x) => (c === null ? '' : `<rect x="${x * s}" y="${y * s + s}" width="${s}" height="${s}" fill="${hex(c)}"/>`)),
148    )
149    .join('')
150  const bob = opts.bob
151    ? `<animateTransform attributeName="transform" type="translate" values="0 0;0 -${s};0 0" dur="0.7s" repeatCount="indefinite"/>`
152    : ''
153  return (
154    `<svg xmlns="http://www.w3.org/2000/svg" width="${10 * s}" height="${9 * s}" viewBox="0 0 ${10 * s} ${9 * s}" shape-rendering="crispEdges">` +
155    `<g opacity="${opts.dim ? 0.45 : 1}">${rects}${bob}</g></svg>`
156  )
157}
158
159// ---------- status glyphs (fallback where a surface has no Client) ----------
160
161export const STATUS = {
162  waiting: { glyph: '◌', color: 'subtle' },
163  running: { glyph: '●', color: '#e8743b' },
164  done: { glyph: '✓', color: 'success' },
165  failed: { glyph: '✗', color: 'error' },
166} satisfies Record<AgentStatus, { glyph: string; color: string }>
167
168const BAR_COLOR: Record<AgentStatus, string | null> = { waiting: null, running: null, done: '#3fb950', failed: '#f85149' }
169
170// ---------- state helpers ----------
171
172function setTasks($: StateDollar, owner: string, fn: (t: Record<string, Task>) => Record<string, Task>) {
173  return update($, tasks, all => ({ ...all, [owner]: fn(all[owner] ?? {}) }))
174}
175
176function patchAgent($: StateDollar, id: string, fn: (a: AgentRow) => Partial<AgentRow>) {
177  return update($, agents, all => (all[id] ? { ...all, [id]: { ...all[id], ...fn(all[id]) } } : all))
178}
179
180const isLive = (a: AgentRow) => a.status === 'running' || a.status === 'waiting'
181
182const NOTE =
183  `\n\n[Progress reporting] If the tool ${PROGRESS_TOOL} is available, call it once at the start with ` +
184  `total = the number of steps you plan and done = 0, then after each step with the new done count and ` +
185  `a 2-4 word step label. One call per step, no more.`
186
187/** Marks the batch ended (and toasts once) when none of its agents is live. */
188async function closeBatchIfDone($: EngineInterface, now: number) {
189  const all = await read($, agents)
190  const b = await read($, batch)
191  if (!b || b.endedAt !== undefined || !b.ids.every(id => !all[id] || !isLive(all[id]!))) return
192  await update($, batch, cur => (cur ? { ...cur, endedAt: now } : cur))
193  if (!b.hidden) $.ui.toast(`🦀 ${b.title}: ${b.ids.length} agent${b.ids.length > 1 ? 's' : ''} finished`)
194}
195
196// ---------- hooks ----------
197
198export const register: Register = on => {
199  // a subagent can finish before agent.spawn has written its row: keep the outcome until the row lands
200  const early = new Map<string, { status: AgentStatus; endedAt: number }>()
201
202  on('session.start', async ($, e, next) => {
203    await $.command.register({ name: 'savvy', description: 'Open the Savvy Progress agents pane' })
204    await $.command.register({
205      name: 'savvy-skin',
206      description: 'Set a crab skin for an agent type: /savvy-skin <type|*> <skin>',
207      argumentHint: `<type|*> <${Object.keys(SKINS).join('|')}>`,
208    })
209    await $.tool.register({
210      name: 'progress',
211      description:
212        'Report progress on your current multi-step job so the person sees a live progress bar. ' +
213        'Call with total steps and done = 0 at the start, then after each step. Optional title names the whole job (main loop only).',
214      inputSchema: {
215        type: 'object',
216        properties: {
217          done: { type: 'integer', minimum: 0 },
218          total: { type: 'integer', minimum: 1 },
219          step: { type: 'string', description: '2-4 word label of the current step' },
220          title: { type: 'string', description: 'Short name of the whole job' },
221        },
222        required: ['done', 'total'],
223      },
224      isDeferred: false,
225    })
226    const saved = (await $.store.get('skins')) as Record<string, string> | undefined
227    if (saved) await update($, skins, () => saved)
228    return next(e)
229  })
230
231  on('command.run', { command: 'savvy' }, async $ => {
232    await $.ui.open({ id: PANE, title: 'Agents' })
233    return { text: 'Savvy Progress pane opened.' }
234  })
235
236  on('command.run', { command: 'savvy-skin' }, async ($, e) => {
237    const [type, skin] = e.args.trim().split(/\s+/)
238    if (!type || !skin || !(SKINS as Record<string, Skin>)[skin]) {
239      return { text: `Usage: /savvy-skin <type|*> <skin>. Skins: ${Object.keys(SKINS).join(', ')}` }
240    }
241    const map = { ...(await read($, skins)), [type]: skin }
242    await update($, skins, () => map)
243    await $.store.set('skins', map)
244    return { text: `Agents of type ${type} now wear the ${skin} skin.` }
245  })
246
247  on('prompt.submit', async ($, e, next) => {
248    if (e.text.trim()) await update($, lastPrompt, () => e.text)
249    return next(e)
250  })
251
252  on('tool.call', async ($, e, next) => {
253    const owner = e.agentId ?? 'main'
254
255    if ((e.tool as string) === PROGRESS_TOOL) {
256      const input = e as unknown as { done?: number; total?: number; step?: string; title?: string }
257      const report: Report = { done: Math.max(0, input.done ?? 0), total: Math.max(1, input.total ?? 1), step: input.step }
258      if (e.agentId) {
259        await patchAgent($, e.agentId, a => ({ report, tools: a.tools + 1 }))
260      } else {
261        await update($, mainReport, () => report)
262        if (input.title) await update($, batch, b => (b ? { ...b, title: input.title! } : b))
263      }
264      return { result: 'Progress noted.' }
265    }
266
267    if (e.agentId) void patchAgent($, e.agentId, a => ({ tools: a.tools + 1 }))
268
269    if (e.tool === 'TodoWrite') {
270      await setTasks($, owner, () =>
271        Object.fromEntries(e.todos.map((t, i) => [`todo${i}`, { subject: t.activeForm || t.content, status: t.status }])),
272      )
273      return next(e)
274    }
275    if (e.tool === 'TaskUpdate' && e.status) {
276      const status = e.status
277      await setTasks($, owner, list => {
278        const { [e.taskId]: old, ...rest } = list
279        if (status === 'deleted') return rest
280        if (!old) return list
281        return { ...list, [e.taskId]: { subject: e.activeForm ?? e.subject ?? old.subject, status: status as TaskStatus } }
282      })
283      return next(e)
284    }
285    if (e.tool === 'TaskCreate') {
286      const subject = e.activeForm ?? e.subject
287      const ran = await next(e)
288      const id = (ran as { result?: { task?: { id?: string } } }).result?.task?.id
289      if (id) await setTasks($, owner, list => ({ ...list, [id]: { subject, status: 'pending' } }))
290      return ran
291    }
292    return next(e)
293  })
294
295  on('agent.spawn', async ($, e, next) => {
296    const ran = await next(e.workflow ? e : { ...e, prompt: e.prompt + NOTE })
297    if (!ran.agentId) return ran
298    const now = await $.clock.now()
299    const row: AgentRow = {
300      id: ran.agentId,
301      description: e.description,
302      type: e.subagentType,
303      model: ran.model,
304      status: 'waiting',
305      steps: 0,
306      tools: 0,
307      usd: 0,
308      tokens: 0,
309      ctx: 0,
310      startedAt: now,
311    }
312    const prompt = await read($, lastPrompt)
313    // one atomic update: parallel spawns all land in the same batch (update retries on a version miss)
314    let isFresh = false
315    let nextBatch: Batch = { id: now, title: titleOf(prompt), startedAt: now, ids: [row.id], hidden: false }
316    await update($, batch, cur => {
317      isFresh = !cur || cur.endedAt !== undefined
318      nextBatch = isFresh
319        ? { id: now, title: titleOf(prompt), startedAt: now, ids: [row.id], hidden: false }
320        : { id: cur!.id, title: cur!.title, startedAt: cur!.startedAt, ids: [...cur!.ids, row.id], hidden: false }
321      return nextBatch
322    })
323    if (isFresh) await update($, mainReport, () => null)
324    await update($, agents, list => {
325      // ponytail: keep this batch's agents + the 6 most recent older finished ones
326      const older = Object.values(list)
327        .filter(a => !nextBatch.ids.includes(a.id) && !isLive(a))
328        .sort((a, b) => b.startedAt - a.startedAt)
329        .slice(6)
330        .map(a => a.id)
331      const kept = Object.fromEntries(Object.entries(list).filter(([id]) => !older.includes(id)))
332      return { ...kept, [row.id]: row }
333    })
334    const done = early.get(row.id)
335    if (done) {
336      early.delete(row.id)
337      await patchAgent($, row.id, () => done)
338      await closeBatchIfDone($, done.endedAt)
339    }
340    void $.ui.open({ id: PANE, title: 'Agents' })
341    return ran
342  })
343
344  on('turn.step', async function* ($, e, next) {
345    if (e.agentId) {
346      const effort = e.effort === undefined ? undefined : String(e.effort)
347      const id = e.agentId
348      // a step after an early finish means the agent was resumed: forget the stored outcome
349      early.delete(id)
350      let resumed = false
351      // a step means the agent is working: waiting -> running, and a finished agent that is resumed goes back to Running
352      await update($, agents, all => {
353        const a = all[id]
354        if (!a) return all
355        if (a.status === 'running') return effort && effort !== a.effort ? { ...all, [id]: { ...a, effort } } : all
356        resumed = a.status === 'done' || a.status === 'failed'
357        const { endedAt: _ended, ...rest } = a
358        const back: AgentRow = { ...rest, status: 'running', effort: effort ?? a.effort }
359        return { ...all, [id]: back }
360      })
361      if (resumed) {
362        await update($, batch, cur => {
363          if (!cur || !cur.ids.includes(id) || cur.endedAt === undefined) return cur
364          const { endedAt: _e, ...open } = cur
365          return open
366        })
367      }
368    }
369    const r = yield* next(e)
370    if (r.usage) {
371      if (e.agentId) {
372        const model = r.usage.model || e.model
373        const usd = costUsd(r.usage, model)
374        const t = newTokens(r.usage)
375        const ctx = promptTokens(r.usage)
376        await patchAgent($, e.agentId, a => ({ steps: a.steps + 1, usd: (a.usd ?? 0) + usd, tokens: a.tokens + t, ctx, model: model || a.model }))
377      }
378    }
379    return r
380  })
381
382  on('turn.complete', async ($, e, next) => {
383    if (e.agentId) {
384      const now = await $.clock.now()
385      const status: AgentStatus = e.reason === 'answer' ? 'done' : 'failed'
386      if (!(await read($, agents))[e.agentId]) early.set(e.agentId, { status, endedAt: now })
387      await patchAgent($, e.agentId, a => ({
388        status,
389        endedAt: now,
390        report: a.report && e.reason === 'answer' ? { ...a.report, done: a.report.total } : a.report,
391      }))
392      // the row may have landed meanwhile and taken the outcome above: then nothing waits in `early`
393      if ((await read($, agents))[e.agentId]) early.delete(e.agentId)
394      await closeBatchIfDone($, now)
395    }
396    return next(e)
397  })
398
399  // ---------- band above the prompt ----------
400
401  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
402    const b = await read($, batch)
403    if (e.props.hasSurvey || !b || b.hidden) return next(e)
404    const all = await read($, agents)
405    const list = b.ids.map(id => all[id]).filter((a): a is AgentRow => !!a)
406    const live = list.filter(isLive)
407    // ponytail: the band stays up after a batch ends until dismissed, so the result is seen
408    const ownTasks = await read($, tasks)
409    const skinMap = await read($, skins)
410    const mr = await read($, mainReport)
411
412    const segs = list.map(a => {
413      const own = tally(Object.values(ownTasks[a.id] ?? {}))
414      const skin = skinOf(skinMap[a.type] ?? skinMap['*'] ?? TYPE_SKIN[a.type] ?? 'crab')
415      return { frac: fraction(a, own), color: BAR_COLOR[a.status] ?? skin.color, running: a.status === 'running' }
416    })
417    const finished = list.length - live.length
418    const known = segs.map(s => s.frac ?? 0)
419    const frac = mr ? mr.done / mr.total : known.reduce((s, f) => s + f, 0) / Math.max(1, list.length)
420    const activeStep = live.map(a => a.report?.step).find(Boolean)
421    const label = mr ? `${mr.step ?? 'Step'} ${mr.done}/${mr.total}` : `${activeStep ?? (live.length ? 'Working' : 'Done')} ${finished}/${list.length}`
422    const pct = `${Math.round(frac * 100)}%`
423    const overall: AgentStatus = live.length ? (live.some(a => a.status === 'running') ? 'running' : 'waiting') : list.some(a => a.status === 'failed') ? 'failed' : 'done'
424    const hide = () => update($, batch, cur => (cur ? { ...cur, hidden: true } : cur))
425    const openPane = () => void $.ui.open({ id: PANE, title: 'Agents' })
426
427    // ponytail: animation lives in Clients (surface-side, kept across redraws), so redraws never restart it
428    if (e.surface === 'terminal' || e.surface === 'desktop') {
429      const { Box, Text, Button, Client } = $.ui.resolve(e)
430      const barW = Math.max(16, Math.min(44, (e.props.bodyColumns ?? 80) - b.title.length - 26))
431      return (
432        <Box flexDirection="row" gap={1} alignItems="center">
433          <Client key="band-dot" module="./dot.tsx" props={{ status: overall }} />
434          <Button key="agents" plain label={b.title} onPress={openPane} />
435          <Client
436            key="band-bar"
437            module="./globalbar.tsx"
438            props={{ frac, label: `${label} · ${pct}`, live: live.length > 0, failed: overall === 'failed', width: barW }}
439            width={barW}
440          />
441          <Text color="#d97757">🦀</Text>
442          <Text>×{list.length}</Text>
443          <Button key="hide" plain label="✕" role="dismiss" onPress={hide} />
444        </Box>
445      )
446    }
447    const { Text } = $.ui.resolve(e)
448    return (
449      <Text>
450        {STATUS[overall].glyph} {b.title} · {label} · {pct}
451      </Text>
452    )
453  })
454
455  // ---------- agents pane ----------
456
457  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
458    const { Box, Text, Button } = $.ui.resolve(e)
459    const all = await read($, agents)
460    const list = Object.values(all)
461    if (list.length === 0) return <Text dimColor>No subagents yet this session.</Text>
462
463    const b = await read($, batch)
464    const allTasks = await read($, tasks)
465    const skinMap = await read($, skins)
466    const collapsed = await read($, finishedCollapsed)
467    const usage = await $.session.usage()
468    const window = usage.context?.window || 200_000
469    const now = await $.clock.now()
470    const cols = e.props.bodyColumns ?? 40
471    const costOf = (a: AgentRow) => a.usd ?? 0
472
473    const inBatch = b ? list.filter(a => b.ids.includes(a.id)) : list
474    const statCost = inBatch.reduce((s, a) => s + costOf(a), 0)
475    const statTokens = inBatch.reduce((s, a) => s + a.tokens, 0)
476    const statTime = b ? (b.endedAt ?? now) - b.startedAt : 0
477
478    const live = list.filter(isLive).sort((x, y) => x.startedAt - y.startedAt)
479    const finished = list.filter(a => !isLive(a)).sort((x, y) => (y.endedAt ?? 0) - (x.endedAt ?? 0))
480
481
482    const mascot = (a: AgentRow, skin: Skin) => {
483      const px = pixels(a.status === 'failed' ? SKINS.ghost : skin)
484      if (e.surface === 'terminal') {
485        const { Raster } = $.ui.resolve(e)
486        return <Raster key={`crab-${a.id}`} columns={10} rows={4} cells={rasterCells(px)} />
487      }
488      const { Svg } = $.ui.resolve(e)
489      // static image: no sandboxed frame, nothing to restart on redraw
490      return <Svg source={svgCrab(px, { dim: a.status === 'waiting' })} alt={`${skin.role} crab`} width={30} height={27} />
491    }
492
493    const statusMark = (a: AgentRow) => {
494      if (e.surface === 'terminal' || e.surface === 'desktop') {
495        const { Client } = $.ui.resolve(e)
496        return <Client key={`dot-${a.id}`} module="./dot.tsx" props={{ status: a.status }} />
497      }
498      return <Text color={STATUS[a.status].color}>{STATUS[a.status].glyph}</Text>
499    }
500
501    const timer = (key: string, elapsed: number, running: boolean, bold?: boolean) => {
502      if (e.surface === 'terminal' || e.surface === 'desktop') {
503        const { Client } = $.ui.resolve(e)
504        return <Client key={key} module="./timer.tsx" props={{ elapsed, running, bold: !!bold, dim: !bold }} />
505      }
506      return (
507        <Text bold={bold} dimColor={!bold}>
508          {clock(elapsed)}
509        </Text>
510      )
511    }
512
513    const progressBar = (a: AgentRow, frac: number | null, color: string) => {
514      if (e.surface === 'terminal' || e.surface === 'desktop') {
515        const { Client } = $.ui.resolve(e)
516        // ponytail: pane width minus the crab (10) and gaps; a fixed width keeps the Client's size stable
517        const width = Math.max(8, Math.min(60, cols - 13))
518        return <Client key={`bar-${a.id}`} module="./agentbar.tsx" props={{ frac, color, status: a.status, width }} width={width} />
519      }
520      const w = Math.max(8, Math.min(28, cols - 14))
521      return <Text color={color}>{frac === null ? '━'.repeat(w) : bar(Math.round(frac * w), w, w)}</Text>
522    }
523
524    const row = (a: AgentRow) => {
525      const skin = skinOf(skinMap[a.type] ?? skinMap['*'] ?? TYPE_SKIN[a.type] ?? 'crab')
526      const own = tally(Object.values(allTasks[a.id] ?? {}))
527      const frac = fraction(a, own)
528      const color = BAR_COLOR[a.status] ?? skin.color
529      const count = a.report ? `${Math.min(a.report.done, a.report.total)}/${a.report.total}` : own.total ? `${own.done}/${own.total}` : `${a.steps} steps`
530      const step = a.report?.step ?? own.active ?? (a.status === 'waiting' ? 'Waiting' : a.status === 'running' ? `${a.tools} tools` : '')
531      const ctxPct = a.ctx ? `ctx ${Math.max(1, Math.round((a.ctx / window) * 100))}% · ` : ''
532      return (
533        <Box key={a.id} flexDirection="row" gap={1} marginBottom={1}>
534          {mascot(a, skin)}
535          <Box flexDirection="column" flexGrow={1} flexShrink={1}>
536            <Box flexDirection="row" justifyContent="space-between" gap={1}>
537              <Text bold dimColor={a.status === 'waiting'} wrap="truncate-end">
538                {a.description}
539              </Text>
540              {statusMark(a)}
541            </Box>
542            <Text wrap="truncate-end">
543              <Text color={skin.color}>{skin.role}</Text>
544              <Text dimColor>
545                {' '}
546                {shortModel(a.model)}
547                {a.effort ? ` · ${a.effort}` : ''}
548              </Text>
549            </Text>
550            <Box flexDirection={cols < 56 ? 'column' : 'row'} justifyContent="space-between" columnGap={1}>
551              <Text wrap="truncate-end">
552                {count}
553                {step ? <Text dimColor> · {step}</Text> : null}
554              </Text>
555              <Box flexDirection="row" gap={1}>
556                <Text dimColor wrap="truncate-end">
557                  {ctxPct}
558                  {kTokens(a.tokens)} {money(costOf(a))}
559                </Text>
560                {timer(`t-${a.id}`, (a.endedAt ?? now) - a.startedAt, isLive(a))}
561              </Box>
562            </Box>
563            {progressBar(a, frac, color)}
564          </Box>
565        </Box>
566      )
567    }
568
569    const stat = (label: string, value: string | ReturnType<typeof timer>) => (
570      <Box key={label} flexDirection="column" flexGrow={1} borderStyle="round" borderColor="subtle" paddingX={1}>
571        <Text dimColor>{label}</Text>
572        {typeof value === 'string' ? <Text bold>{value}</Text> : value}
573      </Box>
574    )
575
576    const toggle = () => update($, finishedCollapsed, v => !v)
577
578    return (
579      <Box flexDirection="column" gap={1}>
580        <Text bold>{b?.title ?? 'Agents'}</Text>
581        <Box flexDirection="row" gap={1}>
582          {stat('Cost', money(statCost))}
583          {stat('Tokens', kTokens(statTokens))}
584          {stat('Time', timer('stat-time', statTime, live.length > 0, true))}
585        </Box>
586        {live.length > 0 && (
587          <Box flexDirection="column">
588            <Text dimColor>Running · {live.length}</Text>
589            {live.map(row)}
590          </Box>
591        )}
592        {finished.length > 0 && (
593          <Box flexDirection="column">
594            <Button key="finished" plain label={`${collapsed ? '▸' : '▾'} Finished · ${finished.length}`} onPress={toggle} />
595            {!collapsed && finished.map(row)}
596          </Box>
597        )}
598      </Box>
599    )
600  })
601}
602
hooks/dot.tsx 39 lines
1import type { ClientModule } from 'claude-code'
2
3import { mix, ORANGE } from './fx'
4
5type Status = 'waiting' | 'running' | 'done' | 'failed'
6type Props = { status: Status }
7type Ref = { t: number; status: Status }
8type State = { r: Ref }
9
10/** Status mark: pulsing ● running, breathing ◌ waiting, ✓ done, ✗ failed. */
11const Dot: ClientModule<Props, State> = (props, surface) => {
12  const { Text } = surface.elements
13  let st = surface.state
14  if (!st) {
15    const r: Ref = { t: 0, status: props.status }
16    st = { r }
17    surface.setState(st)
18    surface.every(120, () => {
19      r.t += 1
20      if (r.status === 'running' || r.status === 'waiting') surface.setState({ r })
21    })
22  }
23  const r = st.r
24  r.status = props.status
25  if (r.status === 'running') {
26    const k = 0.5 + 0.5 * Math.sin(r.t / 2)
27    return <Text color={mix('#5a2a12', ORANGE, 0.35 + 0.65 * k)}>●</Text>
28  }
29  if (r.status === 'waiting') {
30    // same glyph every frame (a rotating glyph changes width in a proportional font)
31    const k = 0.5 + 0.5 * Math.sin(r.t / 3)
32    return <Text color={mix('#484f58', '#8b949e', k)}>◌</Text>
33  }
34  if (r.status === 'done') return <Text color="#3fb950">✓</Text>
35  return <Text color="#f85149">✗</Text>
36}
37
38export default Dot
39
hooks/globalbar.tsx 101 lines
1import type { ClientModule } from 'claude-code'
2
3import { ease, mix, ORANGE, TRACK } from './fx'
4
5type Props = { frac: number; label: string; live: boolean; failed: boolean; width: number }
6type Ref = { target: number; shown: number; t: number; live: boolean; failed: boolean }
7type State = { r: Ref }
8
9const EMBER_BG = '#3b1a0a'
10const EMBER_HI = '#ffb070'
11// figure space: as wide as a digit in any font, so blank cells match the label's cells
12const FILL = ' '
13
14// ponytail: fixed embers (offset, speed, size); add more if the field looks sparse on wide bars
15const EMBERS = [
16  [0.05, 0.11, 1.6],
17  [0.27, 0.07, 2.2],
18  [0.48, 0.13, 1.3],
19  [0.66, 0.09, 1.9],
20  [0.83, 0.06, 2.6],
21  [0.94, 0.12, 1.4],
22] as const
23
24/**
25 * The global bar. Every cell is the same character (a space) and only its
26 * background colour changes, so the bar never changes width. The filled part
27 * glows orange and embers drift right through it; the fill eases toward its target.
28 */
29const GlobalBar: ClientModule<Props, State> = (props, surface) => {
30  const { Box, Text } = surface.elements
31  let st = surface.state
32  if (!st) {
33    const r: Ref = { target: props.frac, shown: 0, t: 0, live: props.live, failed: props.failed }
34    st = { r }
35    surface.setState(st)
36    surface.every(80, () => {
37      const settled = r.shown === r.target
38      r.shown = ease(r.shown, r.target, 0.06)
39      r.t += 1
40      if (r.live || !settled) surface.setState({ r })
41    })
42  }
43  const r = st.r
44  r.target = Math.max(0, Math.min(1, props.frac))
45  r.live = props.live
46  r.failed = props.failed
47
48  const w = Math.max(10, props.width)
49  const filled = r.shown * w
50  const full = Math.floor(filled)
51  const edge = filled - full
52  // split by code point, capped to the bar, so an emoji in a step label never breaks the cell count
53  const label = [...` ${props.label} `].slice(0, Math.max(10, props.width))
54  const ls = Math.max(0, Math.floor((w - label.length) / 2))
55  const accent = r.failed ? '#f85149' : ORANGE
56  const sec = r.t * 0.08
57
58  const glow = (i: number) => {
59    // a slow breathing base plus soft embers drifting right inside the filled span
60    let g = 0.25 + 0.1 * Math.sin(sec * 1.3 + i * 0.45)
61    if (r.live && full > 0) {
62      for (const [off, speed, size] of EMBERS) {
63        const x = (off * full + sec * speed * full) % Math.max(1, full)
64        const d = Math.abs(i - x)
65        g += Math.max(0, 1 - d / size) * 0.75
66      }
67    }
68    return Math.min(1, g)
69  }
70
71  const cells = []
72  for (let i = 0; i < w; i++) {
73    const li = i - ls
74    let bg: string
75    if (i < full) bg = mix(mix(EMBER_BG, accent, 0.55), EMBER_HI, glow(i))
76    else if (i === full && edge > 0) bg = mix(TRACK, mix(EMBER_BG, accent, 0.55), edge)
77    else bg = TRACK
78    if (li >= 0 && li < label.length) {
79      // the label sits on the bar itself, its cells only a little darker for contrast
80      cells.push(
81        <Text key={`c${i}`} color="#ffffff" backgroundColor={mix(bg, '#000000', 0.3)} bold>
82          {label[li]}
83        </Text>,
84      )
85      continue
86    }
87    cells.push(
88      <Text key={`c${i}`} backgroundColor={bg}>
89        {FILL}
90      </Text>,
91    )
92  }
93  return (
94    <Box flexDirection="row" flexShrink={0}>
95      {cells}
96    </Box>
97  )
98}
99
100export default GlobalBar
101
hooks/timer.tsx 40 lines
1import type { ClientModule } from 'claude-code'
2
3type Props = { elapsed: number; running: boolean; bold?: boolean; dim?: boolean }
4type Ref = { base: number; elapsed: number; running: boolean }
5type State = { r: Ref }
6
7const fmt = (ms: number) => {
8  const s = Math.max(0, Math.round(ms / 1000))
9  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
10}
11
12/** An elapsed-time label that ticks on the surface's own clock, so the hooks never redraw for it. */
13const Timer: ClientModule<Props, State> = (props, surface) => {
14  const { Text } = surface.elements
15  let st = surface.state
16  if (!st) {
17    const r: Ref = { base: Date.now() - props.elapsed, elapsed: props.elapsed, running: props.running }
18    st = { r }
19    surface.setState(st)
20    surface.every(1000, () => {
21      if (r.running) surface.setState({ r })
22    })
23  }
24  const r = st.r
25  if (props.elapsed !== r.elapsed) {
26    // a redraw brought a fresher reading from the hooks: rebase on it
27    r.elapsed = props.elapsed
28    r.base = Date.now() - props.elapsed
29  }
30  r.running = props.running
31  const ms = r.running ? Date.now() - r.base : r.elapsed
32  return (
33    <Text bold={props.bold} dimColor={props.dim}>
34      {fmt(ms)}
35    </Text>
36  )
37}
38
39export default Timer
40
hooks/agentbar.tsx 76 lines
1import type { ClientModule } from 'claude-code'
2
3import { ease, mix, TRACK } from './fx'
4
5type Status = 'waiting' | 'running' | 'done' | 'failed'
6type Props = { frac: number | null; color: string; status: Status; width: number }
7type Ref = { target: number | null; shown: number; t: number; status: Status; flash: number; color: string }
8type State = { r: Ref }
9
10/**
11 * One agent's bar: the fill eases toward its fraction; unknown progress shows a
12 * comet sweeping the track; finishing sweeps to full and flashes once.
13 */
14const AgentBar: ClientModule<Props, State> = (props, surface) => {
15  const { Box, Text } = surface.elements
16  let st = surface.state
17  if (!st) {
18    const r: Ref = { target: props.frac, shown: props.frac ?? 0, t: 0, status: props.status, flash: 0, color: props.color }
19    st = { r }
20    surface.setState(st)
21    surface.every(50, () => {
22      const goal = r.target ?? 0
23      const moving = r.target !== null && r.shown !== goal
24      if (r.target !== null) r.shown = ease(r.shown, goal, 0.1)
25      r.flash = Math.max(0, r.flash - 0.06)
26      r.t += 1
27      if (moving || r.flash > 0 || (r.target === null && r.status === 'running')) surface.setState({ r })
28    })
29  }
30  const r = st.r
31  if (props.status !== r.status && (props.status === 'done' || props.status === 'failed')) r.flash = 1
32  r.status = props.status
33  r.target = props.frac
34  r.color = props.color
35
36  // the width comes from the hooks, never from the region: drawing to the region's size fed back into it
37  const w = Math.max(6, props.width)
38  const base = r.flash > 0 ? mix(r.color, '#ffffff', r.flash * 0.7) : r.color
39  const cells = []
40
41  if (r.target === null) {
42    // comet: a bright head with a fading tail, sweeping while running
43    const head = r.status === 'running' ? ((r.t * 0.7) % (w + 8)) - 4 : -99
44    for (let i = 0; i < w; i++) {
45      const d = head - i
46      const b = d >= 0 && d < 6 ? 1 - d / 6 : d < 0 && d > -1.5 ? 0.6 : 0
47      cells.push(
48        <Text key={`c${i}`} color={b > 0 ? mix(TRACK, base, b) : TRACK}>
49          ━
50        </Text>,
51      )
52    }
53  } else {
54    const filled = r.shown * w
55    const full = Math.floor(filled)
56    const edge = filled - full
57    for (let i = 0; i < w; i++) {
58      // one glyph everywhere, only colours move: the bar never changes width
59      const sheen = i < full && r.status === 'running' ? Math.max(0, 1 - Math.abs(((r.t * 0.5) % (w + 10)) - 5 - i) / 3) * 0.35 : 0
60      const color = i < full ? mix(base, '#ffffff', sheen) : i === full ? mix(TRACK, base, edge) : TRACK
61      cells.push(
62        <Text key={`c${i}`} color={color}>
63          ━
64        </Text>,
65      )
66    }
67  }
68  return (
69    <Box flexDirection="row" flexShrink={0}>
70      {cells}
71    </Box>
72  )
73}
74
75export default AgentBar
76
hooks/fx.ts 32 lines
1// Shared by the surface modules (Clients). Plain functions, no `$`.
2
3const rgb = (c: string) => {
4  const n = parseInt(c.slice(1, 7), 16)
5  return [(n >> 16) & 255, (n >> 8) & 255, n & 255] as const
6}
7
8/** a→b by t (0..1), both '#rrggbb'. */
9export const mix = (a: string, b: string, t: number) => {
10  const k = Math.max(0, Math.min(1, t))
11  const [r1, g1, b1] = rgb(a)
12  const [r2, g2, b2] = rgb(b)
13  const h = (x: number, y: number) => Math.round(x + (y - x) * k).toString(16).padStart(2, '0')
14  return `#${h(r1, r2)}${h(g1, g2)}${h(b1, b2)}`
15}
16
17/** Deterministic noise in 0..1 for a cell and a time bucket. */
18export const noise = (x: number, y: number) => {
19  let h = (x * 374761393 + y * 668265263) | 0
20  h = Math.imul(h ^ (h >>> 13), 1274126177)
21  return ((h ^ (h >>> 16)) >>> 0) / 4294967295
22}
23
24/** Ease `shown` toward `target`; snaps when close. */
25export const ease = (shown: number, target: number, k = 0.09) => {
26  const next = shown + (target - shown) * k
27  return Math.abs(target - next) < 0.002 ? target : next
28}
29
30export const TRACK = '#3d444d'
31export const ORANGE = '#e8743b'
32
types/index.d.ts 46 lines
1export type TaskStatus = 'pending' | 'in_progress' | 'completed'
2export type Task = { subject: string; status: TaskStatus }
3
4/** What a loop reported through the progress tool. */
5export type Report = { done: number; total: number; step?: string }
6
7export type AgentStatus = 'waiting' | 'running' | 'done' | 'failed'
8
9export type AgentRow = {
10  id: string
11  description: string
12  type: string
13  model: string
14  effort?: string
15  status: AgentStatus
16  steps: number
17  tools: number
18  /** Dollars at the model's list price, summed over the agent's requests. */
19  usd: number
20  tokens: number
21  /** Prompt size of the last step, for the ctx % figure. */
22  ctx: number
23  startedAt: number
24  endedAt?: number
25  report?: Report
26}
27
28export type Batch = { id: number; title: string; startedAt: number; endedAt?: number; ids: string[]; hidden: boolean }
29
30declare module 'claude-code' {
31  interface PluginState {
32    'savvy-progress': {
33      /** Task lists by owner loop: 'main' or a subagent id, then by task id. */
34      tasks: Record<string, Record<string, Task>>
35      agents: Record<string, AgentRow>
36      /** Skin name by agent type ('*' is the fallback). */
37      skins: Record<string, string>
38      batch: Batch | null
39      mainReport: Report | null
40      finishedCollapsed: boolean
41      /** The main loop's last prompt, the batch title when nothing names one. */
42      lastPrompt: string
43    }
44  }
45}
46