Shows Superpowers progress, context fill and running agents inside Claude Code.

SuperMod is a Claude Code mod that shows, inside the session, how far a Superpowers run has come, how full the main context window is, and what the subagents are doing. It draws a one-line band above the prompt and a detail pane on demand. It only reads and observes: it never steers the run, never sends text to the model and never writes a file.
The band above the prompt is always one line. On a wide terminal it reads:
englische-ansicht █████░░░░░░░ 44% Task 5/9 · Implementer ctx ███░░░░░ 38% Agents 2 ~25–40 min [ Details ] [ Hide ]
and at 80 columns:
englische-ansicht ███░░░ 44% Task 5/9 ctx ██░░░░ 38% [ Details ] [ Hide ]
From left to right: the run's name (the plan's file name without its date, or the spec's title before a plan exists), the overall progress bar and percent, the current task with its station, the context bar and percent, the number of running agents, the remaining-time range, and two buttons. With no run, when the band shows only for the context fill, the row starts with SuperMod instead. The context figure is green below the warning threshold, yellow from it and red from the critical threshold. A crossing upward raises one toast for the person only; nothing reaches the model. With fewer columns the bars shrink first, then the band drops, in this order, the time range, the agent count, the task station and the run's name. No band row is wider than the terminal.
The band shows while a Superpowers run is active in the session, or while the context is at or above the warning threshold. Otherwise it stays out of the way. [Hide] hides it until the next run starts or /supermod show is run.
The pane (/supermod, or [Details]) has these sections:
done/total bar and the current task with its station./context-style breakdown by category when available.showCost option is off.In a terminal session:
/plugin install supermod --marketplace siller/supermod
Answer y to add the marketplace and choose the user scope. Desktop sessions pick up a user-scope install.
| Surface | Band and pane | /supermod status |
|---|---|---|
| Terminal, including an editor's integrated terminal | yes | yes |
| Desktop app, Code tab | yes | yes |
| VS Code extension, chat panel | no | yes |
claude -p, Agent SDK | no | yes |
| Cloud sessions | no | yes |
Where nothing is drawn the hooks still run, and /supermod status prints the band's content as text. VS Code users who want the band and the pane run claude in the editor's integrated terminal.
/supermod command| Command | Effect |
|---|---|
/supermod | Opens the pane. |
/supermod hide | Hides the band until the next run starts or /supermod show. |
/supermod show | Shows the band again. |
/supermod status | Replies with the band's content as text, on every surface. |
/supermod use <plan-or-ledger-path> | Follows that run instead of the newest one; a relative path is resolved against the session's project root. |
/supermod use auto | Goes back to following the newest run. |
| Option | Default | Meaning |
|---|---|---|
language | en | Language of the texts: en or de. |
warnPercent | 70 | Context fill in percent at which the band turns yellow and a toast appears (1 to 99). |
criticalPercent | 85 | Context fill in percent at which the band turns red and a toast appears (1 to 100). |
showCost | true | Show the cost section of the pane. |
reviewMinutes | 10 | Minutes of review allowance added to the remaining-time estimate (0 to 240). |
Set them in /config, or under pluginConfigs in the Claude Code settings.
SuperMod reads only these files, all inside the repository of the session (its project root, the main checkout above it when the root is a worktree, and the worktree folders .claude/worktrees/*, .worktrees/* and worktrees/* of that main checkout):
docs/superpowers/plans/*.mddocs/superpowers/specs/*-design.md.superpowers/sdd/*/progress.md.superpowers/sdd/*/plan-pathIt lists the directories that hold them, the file names inside each .superpowers/sdd/<plan>/ workspace (brief, report and diff files are never opened, only their names and times are used), and the three worktree folders. It stats .git to tell a main checkout from a linked worktree. A path whose real location leaves those roots, such as a symlink out of the repository, is refused. Every file is stat'ed before it is read, and a file of the current run whose modification time has not changed is not read again; the mod's state keeps only what it parsed from a file (titles of at most 80 characters, task numbers, ledger stations), never the file's text.
To learn which run the session works on, the tool.call hook looks at each tool call's file_path, path, command and prompt inputs for paths of the shapes above. It keeps those paths (the last 100, each at most 512 characters) in its state, refreshes the run shortly after the call without holding up the tool's result, and never runs or opens anything a tool input names. The tool call itself passes on unchanged.
Paths are handled as POSIX paths. On Windows SuperMod finds no run; the context fill, the agents and the cost still work.
claude plugin validate --strict . reports these hooks and calls for the mod:
./register.tsx hooks: session.start, command.run{command=supermod}, ui.render{component=Pane, requestId=supermod}, ui.render{component=AbovePrompt}, skill.prompt, tool.call, turn.step, turn.complete, agent.spawn, session.compact
./register.tsx calls: $.agent.list (via refreshUsage), $.clock.after (via refreshRunSoon), $.clock.every (via startTimers), $.clock.now, $.command.register, $.fs.list (via fsOf), $.fs.read (via fsOf), $.fs.stat (via fsOf), $.session.root, $.session.surfaces, $.session.usage (via refreshBreakdown, refreshContext), $.state.get, $.state.set, $.ui.open, $.ui.panes (via refreshUsage), $.ui.resolve, $.ui.toast (via refreshContext)
The validator prints a few more lines for the mod: the state it declares, reads and writes (supermod.run, supermod.context, supermod.agents, supermod.models, supermod.ui), that it answers its own command command.run{command=supermod}, and gating hook without .catch for tool.call, agent.spawn and session.compact. Those three hooks only observe: each passes its event on unchanged, and should one of them fail, the engine skips it and carries on with the event as if SuperMod were not installed, so they need no .catch.
Compare them with your own run. SuperMod never writes files, starts processes, reads environment variables or settings, or uses the network. scripts/check-calls.sh fails the CI build if the validator lists any call outside the allow list.
Cost figures are estimates at list price (hooks/prices.ts), except the session total, which is the engine's own.
SuperMod understands the conventions of Superpowers 7.0.0: the workspace .superpowers/sdd/<plan-basename>/ with the marker file plan-path and the ledger progress.md, plans in docs/superpowers/plans/ with a Spec: header, and specs in docs/superpowers/specs/*-design.md. Where the format differs it shows "unknown" or no run rather than guessing.
Station credit for the current task comes from the ledger lines and from the names of the files in the workspace. In subagent-driven execution the brief, the implementer, the review and the fix rounds each count as a station. In native (inline) execution Superpowers writes no briefs or reports, so a task goes from 0 to 85 (fix round, if any) to 100 when its complete line appears.
Both Superpowers execution skills delete the workspace after the final review. SuperMod keeps the last ledger snapshot in its own state, so a finished run stays visible at 100 percent until the next run starts or the band is hidden with [Hide].
The maintainer checks these by hand (they need real sessions, so CI cannot):
/supermod status replies with text.warnPercent./compact: progress stays, the remaining-time estimate waits.claude --plugin-dir . # run Claude Code with the mod loaded
claude plugin test # unit and mount tests
claude plugin validate --strict . # manifest, hooks and calls
bash scripts/check-calls.sh . # fails on any call outside the allow list
The CI workflow (.github/workflows/check.yml) runs validate and the call check. claude plugin test runs there with continue-on-error, because signing in on CI is not verified yet.
MIT. See LICENSE.
hooks/register.tsx 359 lines1// SuperMod's hooks: observe the engine's events into `$.state` and keep the
2// run and the usage fresh on timers while something is visible (spec §6).
3// Parsing and rules live in the pure modules; every `$` call is written out in
4// full here, in a hook or in a top-level function that receives `$` (spec §7).
5// Every observing hook passes its event on unchanged; only `command.run` for
6// `supermod` answers for itself.
7
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register, Timer, TurnUsage } from 'claude-code'
10import type { AgentsState, ContextState, ModelsState, RunFiles, RunState, StoredBreakdown, UiOptions, UiState } from '../types'
11import { bandModel, planBandRow, renderBand } from './band'
12import { applyFiles, DEFAULT_OPTIONS, finishAgent, isFound, isNewRun, newRow, optionsOf, pathsIn, planKey, reviveAgent, syncAgents, withReferenced, withSnapshot, ZERO } from './glue'
13import { t } from './i18n'
14import { paneModel, planPane, renderPane, statusText } from './pane'
15import { discoverRun, discoverWorktreeRoots, resolveRoots } from './scan'
16import type { Fs } from './scan'
17import { addModel, addUsage, crossing, pushReading, tone, upsertAgent } from './usage'
18import type { Reading } from './usage'
19import { isBandVisible } from './visibility'
20
21const USAGE_EVERY_MS = 3_000
22const FILES_EVERY_MS = 30_000
23const BREAKDOWN_EVERY_MS = 30_000
24/** A tool call's run refresh waits this long; calls within it share the one refresh. */
25const TOOL_REFRESH_AFTER_MS = 2_000
26const MAX_AGE_MS = 7 * 86_400_000
27const FINISHING = 'superpowers:finishing-a-development-branch'
28const SPEC_FILE = /(?:^|\/)docs\/superpowers\/specs\/[^/]+-design\.md$/
29
30const runAtom = atom({ plugin: 'supermod', key: 'run' } as const, {
31 files: null, snapshot: null, skillsSeen: [], sessionSpec: null, pinned: null, lastLedger: null, completions: [],
32 startedAt: null, startedWithCompleted: false, finishingTurnCompleted: false, referenced: [],
33} as RunState)
34const contextAtom = atom({ plugin: 'supermod', key: 'context' } as const, { ring: [], compactions: 0, lastCompactionAt: null, lastTone: 'success', breakdown: null } as ContextState)
35const agentsAtom = atom({ plugin: 'supermod', key: 'agents' } as const, { rows: [] } as AgentsState)
36const modelsAtom = atom({ plugin: 'supermod', key: 'models' } as const, { totals: {}, engineUsd: null } as ModelsState)
37const uiAtom = atom({ plugin: 'supermod', key: 'ui' } as const, { isHidden: false, isPaneOpen: false, surfaces: [], options: DEFAULT_OPTIONS } as UiState)
38
39/** The running timers; a module reload drops them, the state stays. */
40let timers: { usage: Timer; files: Timer } | null = null
41/** The run refresh a tool call asked for, until it starts. */
42let toolRefresh: Timer | null = null
43
44/** Swallows a failure while observing: the event goes on whatever the mod's bookkeeping did. */
45async function observe(work: () => Promise<unknown>): Promise<void> {
46 try {
47 await work()
48 } catch {
49 // Observing never breaks the event it observes.
50 }
51}
52
53// ── Adapters, refreshes and timers (the only places `$` is called) ────────
54
55function fsOf($: EngineInterface): Fs {
56 return {
57 read: p => $.fs.read(p),
58 list: p => $.fs.list(p),
59 stat: p => $.fs.stat(p, { resolve: true }).then(s => s, () => null),
60 }
61}
62
63/**
64 * Finds the current run in the search roots and stores files, snapshot and
65 * completions; files unchanged since the last refresh are not read again, and
66 * nothing is written when nothing changed. A new run ends a `[Hide]`.
67 */
68async function refreshRun($: EngineInterface): Promise<void> {
69 const fs = fsOf($)
70 const cur = await read($, runAtom)
71 const roots = await resolveRoots(fs, await $.session.root())
72 // The worktree folders of the main checkout, whether the session sits in it or in one of its worktrees.
73 const main = roots[1] ?? roots[0]
74 if (main !== undefined) for (const r of await discoverWorktreeRoots(fs, main)) if (!roots.includes(r)) roots.push(r)
75 const now = await $.clock.now()
76 const opts = { pinned: cur.pinned, sessionSpec: cur.sessionSpec, now, maxAgeMs: MAX_AGE_MS, prior: cur.files }
77 let found: RunFiles | null = await discoverRun(fs, roots, opts)
78 // The newest-plan route counts only for a plan a tool call named in this session.
79 const key = found.planPath === null ? null : planKey(found.planPath)
80 if (cur.pinned === null && found.ledgerPath === null && found.planPath !== null && !cur.referenced.some(p => key !== null && planKey(p) === key)) found = null
81 if (found !== null && !isFound(found)) found = null
82 // Once the workspace is gone, keep following the run's plan rather than falling back to its spec.
83 const prev = cur.files
84 const isLost = found === null || (found.ledgerPath === null && found.planPath === null && (found.specPath === null || found.specPath === prev?.specPath))
85 if (prev?.planPath && isLost) {
86 const again = await discoverRun(fs, roots, { ...opts, pinned: prev.planPath })
87 if (again.planPath === prev.planPath) found = again
88 }
89 if (applyFiles(cur, found, now) === cur) return
90 if (isNewRun(cur, found) && (await read($, uiAtom)).isHidden) await update($, uiAtom, u => ({ ...u, isHidden: false }))
91 await update($, runAtom, c => applyFiles(c, found, now))
92}
93
94/** One run refresh shortly after a tool call named a run path, outside the call: the result never waits for it, a burst of calls shares it. */
95function refreshRunSoon($: EngineInterface, o: UiOptions): void {
96 if (toolRefresh !== null) return
97 toolRefresh = $.clock.after(TOOL_REFRESH_AFTER_MS, () => {
98 toolRefresh = null
99 void tick($, o, () => refreshRun($))
100 })
101}
102
103/** Reads the main context; a new reading goes into the ring and may raise one toast. */
104async function refreshContext($: EngineInterface, o: UiOptions): Promise<void> {
105 const usage = await $.session.usage()
106 const now = await $.clock.now()
107 const reading: Reading = { at: now, percent: usage.context.percent ?? null, tokens: usage.context.tokens ?? null, window: usage.context.window }
108 const engineUsd = usage.cost?.usd ?? null
109 const models = await read($, modelsAtom)
110 if (models.engineUsd !== engineUsd) await update($, modelsAtom, m => ({ ...m, engineUsd }))
111 const last = (await read($, contextAtom)).ring.at(-1)
112 if (last && last.percent === reading.percent && last.tokens === reading.tokens && last.window === reading.window) return
113 let raised = null as 'warn' | 'critical' | null
114 await update($, contextAtom, c => {
115 raised = crossing(c.ring.at(-1)?.percent ?? null, reading.percent, o.warnPercent, o.criticalPercent)
116 return { ...c, ring: pushReading(c.ring, reading), lastTone: tone(reading.percent, o.warnPercent, o.criticalPercent) }
117 })
118 if (raised !== null && reading.percent !== null) {
119 $.ui.toast(t(o.language, raised === 'warn' ? 'toast.warn' : 'toast.critical').replace('{p}', String(reading.percent)))
120 }
121}
122
123/** The `/context`-style rows while the pane is open, at most every 30 s; dropped once it closes. */
124async function refreshBreakdown($: EngineInterface): Promise<void> {
125 const isPaneOpen = (await read($, uiAtom)).isPaneOpen
126 const cur = (await read($, contextAtom)).breakdown ?? null
127 if (!isPaneOpen) {
128 if (cur !== null) await update($, contextAtom, c => ({ ...c, breakdown: null }))
129 return
130 }
131 const now = await $.clock.now()
132 if (cur !== null && now - cur.at < BREAKDOWN_EVERY_MS) return
133 const bd = (await $.session.usage({ breakdown: 'summary' })).context.breakdown
134 // None without a bound session: an empty one still waits out the 30 s.
135 const breakdown: StoredBreakdown = {
136 at: now,
137 maxTokens: bd?.maxTokens ?? 0,
138 categories: (bd?.categories ?? []).map(c => ({ name: c.name, tokens: c.tokens, kind: c.kind })),
139 }
140 await update($, contextAtom, c => ({ ...c, breakdown }))
141}
142
143/** Whether the pane is open, the context (and its breakdown while open), and the engine's agent statuses; a failed part skips no other. */
144async function refreshUsage($: EngineInterface, o: UiOptions): Promise<void> {
145 await observe(async () => {
146 const isPaneOpen = (await $.ui.panes()).some(p => p.id === 'supermod' && p.isShown)
147 if ((await read($, uiAtom)).isPaneOpen !== isPaneOpen) await update($, uiAtom, u => ({ ...u, isPaneOpen }))
148 })
149 await observe(() => refreshContext($, o))
150 await observe(() => refreshBreakdown($))
151 await observe(async () => {
152 const now = await $.clock.now()
153 const listed = await $.agent.list()
154 const rows = (await read($, agentsAtom)).rows
155 if (syncAgents(rows, listed, now) !== rows) await update($, agentsAtom, a => ({ rows: syncAgents(a.rows, listed, now) }))
156 })
157}
158
159/** Usage, run, then the timers, each on its own so one failure skips none of the others. */
160async function refreshAll($: EngineInterface, o: UiOptions): Promise<void> {
161 await observe(() => refreshUsage($, o))
162 await observe(() => refreshRun($))
163 await observe(() => syncTimers($, o))
164}
165
166/** Adds one model response's usage to its model's totals and, for a subagent, to its row. */
167async function recordStep($: EngineInterface, agentId: string | null, u: TurnUsage): Promise<void> {
168 await update($, modelsAtom, m => ({ ...m, totals: addModel(m.totals, u.model, addUsage(ZERO, u)) }))
169 if (agentId === null) return
170 const now = await $.clock.now()
171 const lastWindow = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
172 await update($, agentsAtom, a => {
173 const row = reviveAgent(a.rows.find(r => r.id === agentId) ?? newRow(agentId, now))
174 return { rows: upsertAgent(a.rows, { ...row, model: u.model, usage: addUsage(row.usage, u), lastWindow, steps: row.steps + 1 }) }
175 })
176}
177
178function startTimers($: EngineInterface, o: UiOptions): void {
179 if (timers !== null) return
180 timers = {
181 usage: $.clock.every(USAGE_EVERY_MS, () => void tick($, o, () => refreshUsage($, o))),
182 files: $.clock.every(FILES_EVERY_MS, () => void tick($, o, () => refreshRun($))),
183 }
184}
185
186function stopTimers(): void {
187 timers?.usage.cancel()
188 timers?.files.cancel()
189 timers = null
190}
191
192async function tick($: EngineInterface, o: UiOptions, work: () => Promise<void>): Promise<void> {
193 await observe(work)
194 await observe(() => syncTimers($, o))
195}
196
197/** Timers run only while the band is visible or the pane is open. */
198async function syncTimers($: EngineInterface, o: UiOptions): Promise<void> {
199 const ui = await read($, uiAtom)
200 const visible = isBandVisible({ run: await read($, runAtom), context: await read($, contextAtom), ui }, o)
201 if (visible || ui.isPaneOpen) startTimers($, o)
202 else stopTimers()
203}
204
205// ── Hooks ─────────────────────────────────────────────────────────────────
206
207export const register: Register = (on, options) => {
208 const o = optionsOf(options)
209
210 on('session.start', async ($, e, next) => {
211 await $.command.register({
212 name: 'supermod',
213 description: 'Show Superpowers progress, context fill and agents',
214 })
215 await observe(async () => {
216 const surfaces = await $.session.surfaces()
217 await update($, uiAtom, u => ({ ...u, surfaces: [...surfaces] }))
218 })
219 await observe(() => update($, uiAtom, u => ({ ...u, options: o })))
220 await refreshAll($, o)
221 return next(e)
222 })
223
224 // `/supermod`: no argument opens the pane; hide, show, status, use <path>, use auto.
225 on('command.run', { command: 'supermod' }, async ($, e) => {
226 const [verb = '', ...rest] = e.args.trim().split(/\s+/)
227 const arg = rest.join(' ')
228 if (verb === 'hide' || verb === 'show') {
229 await update($, uiAtom, u => ({ ...u, isHidden: verb === 'hide' }))
230 await observe(() => syncTimers($, o))
231 return { text: t(o.language, verb === 'hide' ? 'hidden' : 'shown') }
232 }
233 if (verb === 'status') {
234 const state = { run: await read($, runAtom), context: await read($, contextAtom), agents: await read($, agentsAtom), ui: await read($, uiAtom) }
235 return { text: statusText(state, o) }
236 }
237 if (verb === 'use' && arg !== '') {
238 const pinned = arg === 'auto' ? null : arg.startsWith('/') ? arg : `${(await $.session.root()).replace(/\/+$/, '')}/${arg.replace(/^\.\//, '')}`
239 await update($, runAtom, c => ({ ...c, pinned }))
240 await observe(() => refreshRun($))
241 await observe(() => syncTimers($, o))
242 return { text: pinned === null ? t(o.language, 'unpinned') : t(o.language, 'pinned').replace('{p}', pinned) }
243 }
244 const opened = await $.ui.open({ id: 'supermod', title: 'SuperMod' })
245 await update($, uiAtom, u => ({ ...u, isPaneOpen: true }))
246 await observe(() => syncTimers($, o))
247 return { text: t(o.language, opened.isPlaced ? 'paneOpened' : 'surfaceFallback') }
248 })
249
250 // The pane reads state and the clock only; the command and the timers write.
251 on('ui.render', { component: 'Pane', requestId: 'supermod' }, async ($, e) => {
252 const state = {
253 run: await read($, runAtom), context: await read($, contextAtom), agents: await read($, agentsAtom), models: await read($, modelsAtom), ui: await read($, uiAtom),
254 }
255 const model = paneModel(state, o, await $.clock.now())
256 return renderPane($.ui.resolve(e), planPane(model, e.props.bodyColumns, e.props.scroll.bodyRows))
257 })
258
259 // The band reads state only; its buttons write from their own presses.
260 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
261 if (e.props.hasSurvey) return next(e)
262 const state = { run: await read($, runAtom), context: await read($, contextAtom), agents: await read($, agentsAtom), ui: await read($, uiAtom) }
263 const model = bandModel(state)
264 if (model === null) return next(e)
265 return renderBand($.ui.resolve(e), planBandRow(model, e.props.bodyColumns), {
266 onDetails: async () => {
267 await $.ui.open({ id: 'supermod', title: 'SuperMod' })
268 await update($, uiAtom, u => ({ ...u, isPaneOpen: true }))
269 await observe(() => syncTimers($, o))
270 },
271 onHide: async () => {
272 await update($, uiAtom, u => ({ ...u, isHidden: true }))
273 await observe(() => syncTimers($, o))
274 },
275 })
276 })
277
278 on('skill.prompt', async ($, e, next) => {
279 const r = await next(e)
280 if (e.skill.startsWith('superpowers:')) {
281 await observe(() => update($, runAtom, c => (c.skillsSeen.includes(e.skill) ? c : withSnapshot({ ...c, skillsSeen: [...c.skillsSeen, e.skill] }))))
282 await observe(() => syncTimers($, o))
283 }
284 return r
285 })
286
287 on('tool.call', async ($, e, next) => {
288 const input = e as unknown as Record<string, unknown>
289 const paths = pathsIn(input)
290 const r = await next(e)
291 if (paths.length > 0) {
292 const file = input.file_path
293 const spec = (e.tool === 'Write' || e.tool === 'Edit') && typeof file === 'string' && SPEC_FILE.test(file) ? file : null
294 await observe(async () => {
295 const c = await read($, runAtom)
296 if (withReferenced(c, paths, spec) !== c) await update($, runAtom, r => withReferenced(r, paths, spec))
297 })
298 await observe(async () => refreshRunSoon($, o))
299 }
300 return r
301 })
302
303 on('turn.step', async function* ($, e, next) {
304 const r = yield* next(e)
305 const usage = r.usage
306 if (usage) await observe(() => recordStep($, e.agentId ?? null, usage))
307 if (e.agentId === undefined) await observe(() => refreshContext($, o))
308 return r
309 })
310
311 on('turn.complete', async ($, e, next) => {
312 const r = await next(e)
313 if (e.agentId === undefined) {
314 // Written only when it changes: `update` writes whatever its function returns.
315 await observe(async () => {
316 const c = await read($, runAtom)
317 if (c.skillsSeen.includes(FINISHING) && !c.finishingTurnCompleted) await update($, runAtom, r => withSnapshot({ ...r, finishingTurnCompleted: true }))
318 })
319 } else {
320 // A subagent's answer is its own turn.complete: its row is done, whatever the engine still lists.
321 const id = e.agentId
322 await observe(async () => {
323 const now = await $.clock.now()
324 const rows = (await read($, agentsAtom)).rows
325 if (finishAgent(rows, id, now) !== rows) await update($, agentsAtom, a => ({ rows: finishAgent(a.rows, id, now) }))
326 })
327 }
328 await refreshAll($, o)
329 return r
330 })
331
332 on('agent.spawn', async ($, e, next) => {
333 const r = await next(e)
334 const id = r.agentId
335 if (id !== undefined) {
336 await observe(async () => {
337 const now = await $.clock.now()
338 await update($, agentsAtom, a => {
339 const row = a.rows.find(x => x.id === id) ?? newRow(id, now)
340 return { rows: upsertAgent(a.rows, { ...row, type: e.subagentType, description: e.description, model: row.model ?? r.model }) }
341 })
342 })
343 }
344 return r
345 })
346
347 on('session.compact', async ($, e, next) => {
348 const r = await next(e)
349 const skipped = 'skip' in r && typeof r.skip === 'string'
350 if (e.agentId === undefined && e.trigger !== 'precompute' && !skipped) {
351 await observe(async () => {
352 const now = await $.clock.now()
353 await update($, contextAtom, c => ({ ...c, compactions: c.compactions + 1, lastCompactionAt: now }))
354 })
355 }
356 return r
357 })
358}
359hooks/band.tsx 171 lines1// The band above the prompt (spec §4.1): a model from the state, one row
2// planned against the band's width, and the tree drawn from that plan. Pure:
3// no `$` here; register.tsx resolves the surface's elements and builds the
4// handlers with its own calls. JSX compiles to the global `h`; no name here shadows it.
5
6import type { ElementTable, RenderElement } from 'claude-code'
7import type { AgentsState, ContextState, RunState, UiState } from '../types'
8import { estimateRange } from './eta'
9import { bar, fit, pct } from './format'
10import { t } from './i18n'
11import type { LabelKey, Lang } from './i18n'
12import { slugFromFileName } from './progress'
13import { tone } from './usage'
14import { isBandVisible, isRunActive } from './visibility'
15
16export type BandModel = {
17 title: string | null
18 percent: number | null
19 task: string | null
20 station: string | null
21 ctxPercent: number | null
22 ctxTone: 'success' | 'warning' | 'error'
23 agentsRunning: number
24 eta: string | null
25 lang: Lang
26}
27export type BandState = { run: RunState; context: ContextState; agents: AgentsState; ui: UiState }
28export type Segment = { text: string; tone?: string; bold?: boolean; dim?: boolean }
29/** The planned row; `lang` names the buttons. */
30export type BandRow = { segments: Segment[]; lang: Lang }
31export type BandHandlers = { onDetails: () => unknown; onHide: () => unknown }
32
33const MINUTE_MS = 60_000
34const SEP = ' '
35/** The title is truncated down to this many cells before it is dropped. */
36const TITLE_MIN = 8
37const LIVE = new Set(['running', 'pending'])
38/** What goes first when the row is too wide (spec §4.1). */
39const DROPS = ['eta', 'agents', 'station', 'title'] as const
40
41type GroupId = 'lead' | 'title' | 'progress' | 'task' | (typeof DROPS)[number] | 'ctx'
42/** Segments that stay or go together, `sep` in front of them unless they lead the row. */
43type Group = { id: GroupId; sep: string; parts: Segment[] }
44
45/** The band's content, or null when it yields (spec §4.1 visibility). */
46export function bandModel(s: BandState): BandModel | null {
47 return isBandVisible(s, s.ui.options) ? bandModelOf(s) : null
48}
49
50/** The band's content whether the band is visible or not (the status text answers either way). */
51export function bandModelOf(s: BandState): BandModel {
52 const o = s.ui.options
53 const lang = o.language
54 const ctxPercent = s.context.ring.at(-1)?.percent ?? null
55 const base = { ctxPercent, ctxTone: tone(ctxPercent, o.warnPercent, o.criticalPercent), lang }
56 const agentsRunning = s.agents.rows.filter(r => LIVE.has(r.status)).length
57 const snap = isRunActive(s.run) ? s.run.snapshot : null
58 if (snap === null) return { ...base, title: null, percent: null, task: null, station: null, agentsRunning, eta: null }
59 const plan = s.run.files?.planPath ?? null
60 const cur = snap.currentTask
61 const range = estimateRange(s.run.completions, s.run.startedWithCompleted ? null : s.run.startedAt, snap.total - snap.completed, o.reviewMinutes)
62 return {
63 ...base,
64 title: plan !== null ? slugFromFileName(plan.slice(plan.lastIndexOf('/') + 1)) : snap.title || null,
65 percent: snap.percent,
66 task: cur === null ? null : `${t(lang, 'task')} ${cur.n}/${snap.total}`,
67 station: cur === null || cur.station === 'none' ? null : t(lang, `station.${cur.station}` as LabelKey),
68 agentsRunning,
69 eta: range === null ? null : `~${Math.round(range.lowMs / MINUTE_MS)}–${Math.round(range.highMs / MINUTE_MS)} ${t(lang, 'min')}`,
70 }
71}
72
73/** Cells the two buttons take on the terminal (`[ label ]` each) with a space before each. */
74export function buttonCells(lang: Lang): number {
75 return 1 + t(lang, 'details').length + 4 + 1 + t(lang, 'hide').length + 4
76}
77
78function groupsOf(m: BandModel, isWide: boolean): Group[] {
79 const g: Group[] = []
80 const label = (key: LabelKey): Segment => ({ text: t(m.lang, key) + ' ', dim: true })
81 if (m.title !== null) g.push({ id: 'title', sep: SEP, parts: [{ text: m.title }] })
82 else g.push({ id: 'lead', sep: SEP, parts: [{ text: t(m.lang, 'title'), dim: true }] })
83 if (m.percent !== null) g.push({ id: 'progress', sep: SEP, parts: [{ text: bar(m.percent, isWide ? 12 : 6) }, { text: ' ' + pct(m.percent), bold: true }] })
84 if (m.task !== null) g.push({ id: 'task', sep: SEP, parts: [{ text: m.task }] })
85 if (m.task !== null && m.station !== null) g.push({ id: 'station', sep: ' · ', parts: [{ text: m.station, dim: true }] })
86 g.push({
87 id: 'ctx',
88 sep: SEP,
89 parts: [label('ctx'), { text: bar(m.ctxPercent, isWide ? 8 : 6), tone: m.ctxTone }, { text: ' ' + pct(m.ctxPercent), tone: m.ctxTone, bold: true }],
90 })
91 if (m.percent !== null) g.push({ id: 'agents', sep: SEP, parts: [label('agents'), { text: String(m.agentsRunning) }] })
92 if (m.eta !== null) g.push({ id: 'eta', sep: SEP, parts: [{ text: m.eta }] })
93 return g
94}
95
96/** Cells the groups take, the title counted as `titleCells`. */
97function widthOf(groups: Group[], titleCells: number): number {
98 return groups.reduce((sum, g, i) => {
99 const sep = i === 0 ? 0 : g.sep.length
100 return sum + sep + (g.id === 'title' ? titleCells : g.parts.reduce((s, p) => s + p.text.length, 0))
101 }, 0)
102}
103
104const titleOf = (groups: Group[]) => groups.find(g => g.id === 'title')?.parts[0].text ?? ''
105
106/** The row's segments, separators folded into the text, ending before the buttons. */
107function flatten(groups: Group[], titleCells: number): Segment[] {
108 return groups.flatMap((g, i) => {
109 const parts = g.id === 'title' ? [{ ...g.parts[0], text: fit(g.parts[0].text, titleCells) }] : g.parts
110 return i === 0 ? parts : [{ ...parts[0], text: g.sep + parts[0].text }, ...parts.slice(1)]
111 })
112}
113
114/** Cuts the row at `budget` cells, the last segment ending in an ellipsis. */
115export function clip(segments: Segment[], budget: number): Segment[] {
116 const out: Segment[] = []
117 let left = Math.max(0, budget)
118 for (const s of segments) {
119 if (left === 0) break
120 if (s.text.length <= left) {
121 out.push(s)
122 left -= s.text.length
123 } else {
124 out.push({ ...s, text: fit(s.text, left) })
125 left = 0
126 }
127 }
128 return out
129}
130
131/**
132 * The band's row for `columns` cells beside the buttons: the full row with
133 * 12/8-cell bars, else with 6/6-cell bars, then dropping time range, agents,
134 * station, title in that order until it fits; whatever is left is cut.
135 */
136export function planBandRow(m: BandModel, columns: number): BandRow {
137 const budget = columns - buttonCells(m.lang)
138 const fits = (gs: Group[]) => widthOf(gs, Math.min(TITLE_MIN, titleOf(gs).length)) <= budget
139 const wide = groupsOf(m, true)
140 let groups = fits(wide) ? wide : groupsOf(m, false)
141 for (const id of DROPS) {
142 if (fits(groups)) break
143 groups = groups.filter(g => g.id !== id)
144 }
145 const title = titleOf(groups)
146 const titleCells = Math.min(title.length, budget - widthOf(groups, 0))
147 return { segments: clip(flatten(groups, titleCells), budget), lang: m.lang }
148}
149
150/** A segment's Text props: its tone, `subtle` for a label, bold for a value; no prop left undefined. */
151export function styleOf(s: Segment): { color?: string; bold?: boolean } {
152 const color = s.tone ?? (s.dim ? 'subtle' : null)
153 return { ...(color !== null ? { color } : {}), ...(s.bold ? { bold: true } : {}) }
154}
155
156/** The band: one row of the planned segments, then `[Details]` and `[Hide]`. */
157export function renderBand(els: ElementTable, row: BandRow, handlers: BandHandlers): RenderElement {
158 const { Box, Button, Text } = els
159 return (
160 <Box flexDirection="row">
161 {row.segments.map(s => (
162 <Text {...styleOf(s)}>{s.text}</Text>
163 ))}
164 <Box flexDirection="row" marginLeft={1} gap={1}>
165 <Button key="details" label={t(row.lang, 'details')} onPress={handlers.onDetails} />
166 <Button key="hide" label={t(row.lang, 'hide')} onPress={handlers.onHide} />
167 </Box>
168 </Box>
169 )
170}
171hooks/glue.ts 154 lines1// The pure glue between the engine's events and the state: options, run-file
2// bookkeeping, agent rows and the run snapshot. No `$` here; register.tsx
3// calls these from its hooks, refreshes and timers.
4
5import type { PluginOptions } from 'claude-code'
6import type { RunFiles, RunState, StoredLedger, StoredPlan, UiOptions } from '../types'
7import { recordCompletions } from './eta'
8import { computeRun } from './progress'
9import type { Ledger, Plan, RunSnapshot } from './progress'
10import { upsertAgent } from './usage'
11import type { AgentRow, Usage } from './usage'
12
13const FINISHED = new Set(['completed', 'failed', 'killed'])
14export const ZERO: Usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
15/** What ends a path inside a tool input: whitespace, quotes, brackets and shell operators. */
16const DELIMITERS = /[\s'"`<>()|;&]+/
17/** The part every run path holds: workspace, ledger, marker, plan or spec. */
18const RUN_PART = /\.superpowers\/sdd\/|docs\/superpowers\/(?:plans|specs)\//
19/** Input scanned per field, and the longest run path kept. */
20const SCAN_CAP = 100_000
21const PATH_CAP = 512
22/** Run paths kept from this session's tool calls. */
23const REFERENCED_CAP = 100
24export const DEFAULT_OPTIONS: UiOptions = { language: 'en', warnPercent: 70, criticalPercent: 85, showCost: true, reviewMinutes: 10 }
25
26export function optionsOf(o: PluginOptions): UiOptions {
27 const num = (v: unknown, d: number) => (typeof v === 'number' && Number.isFinite(v) ? v : d)
28 return {
29 language: o.language === 'de' ? 'de' : 'en',
30 warnPercent: num(o.warnPercent, DEFAULT_OPTIONS.warnPercent),
31 criticalPercent: num(o.criticalPercent, DEFAULT_OPTIONS.criticalPercent),
32 showCost: typeof o.showCost === 'boolean' ? o.showCost : DEFAULT_OPTIONS.showCost,
33 reviewMinutes: num(o.reviewMinutes, DEFAULT_OPTIONS.reviewMinutes),
34 }
35}
36
37export const loadLedger = (s: StoredLedger): Ledger => ({ planPath: s.planPath, tasks: new Map(s.tasks.map(x => [x.n, x])) })
38export const loadPlan = (p: StoredPlan): Plan => ({ title: p.title, specPath: p.specPath, taskCount: p.tasks.length, taskTitles: new Map(p.tasks.map(x => [x.n, x.title])) })
39export const isFound = (f: RunFiles) => f.ledgerPath !== null || f.planPath !== null || f.specPath !== null || f.workspaceExists
40const same = (a: string | null | undefined, b: string | null | undefined) => a != null && a === b
41export const sameRun = (a: RunFiles, b: RunFiles) =>
42 same(a.planPath, b.planPath) || same(a.specPath, b.specPath) || same(a.ledgerPath, b.ledgerPath) || same(a.workspaceDir, b.workspaceDir)
43/** Whether a discovery found another run than the state holds (or the first one). */
44export const isNewRun = (c: RunState, found: RunFiles | null) => found !== null && (c.files === null || !sameRun(c.files, found))
45export const planKey = (p: string) => (p.includes('docs/superpowers/plans/') ? p.slice(p.lastIndexOf('docs/superpowers/plans/')) : null)
46
47/**
48 * Run paths named in a tool call's file path, path, command or prompt: in each
49 * token between delimiters that holds a run part, from just after the last `=`
50 * before that part to the token's end. Linear in the input: the part is found
51 * by plain search, never by a pattern that backtracks over the token; a path
52 * longer than 512 characters is dropped.
53 */
54export function pathsIn(e: Record<string, unknown>): string[] {
55 const out = new Set<string>()
56 for (const key of ['file_path', 'path', 'command', 'prompt']) {
57 const v = e[key]
58 if (typeof v !== 'string') continue
59 for (const token of v.slice(0, SCAN_CAP).split(DELIMITERS)) {
60 if (!token.includes('superpowers/')) continue
61 const at = token.search(RUN_PART)
62 if (at < 0) continue
63 const path = token.slice(token.lastIndexOf('=', at) + 1)
64 if (path.length > PATH_CAP) continue
65 out.add(path.replace(/^\.\//, '').replace(/[.,:;]+$/, ''))
66 }
67 }
68 return [...out]
69}
70
71/** The state with the paths a tool call named moved to the end (the last 100 kept) and the spec it wrote; `c` itself when nothing changes. */
72export function withReferenced(c: RunState, paths: string[], spec: string | null): RunState {
73 const referenced = [...c.referenced.filter(p => !paths.includes(p)), ...paths].slice(-REFERENCED_CAP)
74 const sessionSpec = spec ?? c.sessionSpec
75 const isSame = sessionSpec === c.sessionSpec && referenced.length === c.referenced.length && referenced.every((p, i) => p === c.referenced[i])
76 return isSame ? c : { ...c, referenced, sessionSpec }
77}
78
79export const newRow = (id: string, now: number): AgentRow => ({
80 id, type: '', description: '', status: 'running', model: null, startedAt: now, endedAt: null, usage: ZERO, lastWindow: 0, steps: 0,
81})
82
83/** Rows with the engine's statuses applied, and rows for agents the mod has not seen spawn. */
84export function syncAgents(rows: AgentRow[], listed: readonly { id: string; type: string; description: string; status: string }[], now: number): AgentRow[] {
85 let next = rows
86 for (const a of listed) {
87 const row = next.find(r => r.id === a.id) ?? { ...newRow(a.id, now), type: a.type, description: a.description }
88 // A closed row stays closed while the engine still lists its agent as live: only a new step of the agent revives it.
89 if (row.endedAt !== null && !FINISHED.has(a.status)) continue
90 if (row.status === a.status && next.includes(row)) continue
91 const endedAt = FINISHED.has(a.status) ? row.endedAt ?? now : row.endedAt
92 next = upsertAgent(next, { ...row, status: a.status, endedAt })
93 }
94 // The engine drops an agent's entry once its task is over: a row it no longer lists is finished.
95 // Only rows older than `now` (taken before the list call) count; one spawned meanwhile is not missing.
96 const ids = new Set(listed.map(a => a.id))
97 for (const row of next) if (!ids.has(row.id) && row.startedAt < now) next = finishAgent(next, row.id, now)
98 return next
99}
100
101/** The row as a live one again: a closed agent that steps is working. */
102export function reviveAgent(row: AgentRow): AgentRow {
103 return row.endedAt === null && !FINISHED.has(row.status) ? row : { ...row, status: 'running', endedAt: null }
104}
105
106/** The row closed as `completed` at `now`; the same rows when the id is unknown or the row is finished already. */
107export function finishAgent(rows: AgentRow[], id: string, now: number): AgentRow[] {
108 const row = rows.find(r => r.id === id)
109 if (row === undefined || row.endedAt !== null || FINISHED.has(row.status)) return rows
110 return upsertAgent(rows, { ...row, status: 'completed', endedAt: now })
111}
112
113/** The run as the band and pane draw it, from the stored files and what this session saw. */
114export function snapshotOf(r: RunState): RunSnapshot | null {
115 const f = r.files
116 if (f === null && r.skillsSeen.length === 0) return null
117 return computeRun({
118 skillsSeen: new Set(r.skillsSeen),
119 specExists: f?.specPath != null,
120 planExists: f?.planPath != null,
121 ledger: f?.ledger != null ? loadLedger(f.ledger) : null,
122 workspaceExists: f?.workspaceExists ?? false,
123 files: f?.workspaceFiles ?? [],
124 plan: f?.plan != null ? loadPlan(f.plan) : null,
125 finishingTurnCompleted: r.finishingTurnCompleted,
126 lastLedger: r.lastLedger === null ? null : loadLedger(r.lastLedger),
127 ...(f?.specTitle != null ? { specTitle: f.specTitle } : {}),
128 })
129}
130
131export const withSnapshot = (r: RunState): RunState => ({ ...r, snapshot: snapshotOf(r) })
132
133/**
134 * The run state after a discovery (`found` null: nothing found, the last files
135 * stay): a new run resets the run's clock, a ledger adds completions. Returns
136 * `c` itself when nothing changed, so the caller can skip the write and the
137 * band is not drawn again (spec §4.1).
138 */
139export function applyFiles(c: RunState, found: RunFiles | null, now: number): RunState {
140 const files = found ?? c.files
141 let next: RunState = { ...c, files }
142 if (files !== null) {
143 const isNew = isNewRun(c, found)
144 if (isNew) next = { ...next, startedAt: now, completions: [], lastLedger: null, startedWithCompleted: false }
145 if (files.ledger != null) {
146 const done = files.ledger.tasks.filter(x => x.complete).map(x => x.n)
147 next = { ...next, lastLedger: files.ledger, completions: recordCompletions(next.completions, done, now) }
148 if (isNew) next.startedWithCompleted = done.length > 0
149 }
150 }
151 next = withSnapshot(next)
152 return JSON.stringify(next) === JSON.stringify(c) ? c : next
153}
154hooks/i18n.ts 132 lines1export type Lang = 'en' | 'de'
2
3const en = {
4 'title': 'SuperMod',
5 'context': 'Context',
6 'agents': 'Agents',
7 'tokens': 'Tokens',
8 'cost': 'Cost',
9 'estimate': 'estimate',
10 'eta': 'ETA',
11 'notEnoughData': 'not enough data',
12 'noRun': 'no run detected',
13 'step.brainstorming': 'Brainstorming',
14 'step.spec': 'Spec',
15 'step.plan': 'Plan',
16 'step.tasks': 'Tasks',
17 'step.review': 'Review',
18 'step.finish': 'Finish',
19 'task': 'Task',
20 'ctx': 'ctx',
21 'min': 'min',
22 'station.brief': 'Brief',
23 'station.implementer': 'Implementer',
24 'station.review': 'Review',
25 'station.fix': 'Fix',
26 'station.complete': 'Complete',
27 'details': 'Details',
28 'hide': 'Hide',
29 'show': 'Show',
30 'paneOpened': 'SuperMod pane opened',
31 'hidden': 'SuperMod hidden',
32 'shown': 'SuperMod shown',
33 'pinned': 'Following {p}',
34 'unpinned': 'Following the newest run',
35 'toast.warn': 'Context at {p}%: consider finishing the task in a fresh subagent or compacting',
36 'toast.critical': 'Context at {p}%: finish the task in a fresh subagent or compact now',
37 'lastCompaction': 'Last compaction',
38 'none': 'none',
39 'started': 'Started',
40 'elapsed': 'Elapsed',
41 'threshold': 'Threshold',
42 'model': 'Model',
43 'status': 'Status',
44 'in': 'in',
45 'out': 'out',
46 'window': 'window',
47 'approx': 'approx.',
48 'costEngine': 'session total from the engine',
49 'costEstimate': 'estimate at list price',
50 'surfaceFallback': 'This surface does not draw panes; use /supermod status',
51 'more': '+{n} more',
52 'perTask': 'per task',
53 'cacheHit': 'cache hit',
54 'cacheRead': 'cache read',
55 'cacheWrite': 'cache write',
56 'agent.running': 'running',
57 'agent.pending': 'pending',
58 'agent.completed': 'done',
59 'agent.failed': 'failed',
60 'agent.killed': 'stopped',
61} as const
62
63export type LabelKey = keyof typeof en
64
65const de: Record<LabelKey, string> = {
66 'title': 'SuperMod',
67 'context': 'Kontext',
68 'agents': 'Agenten',
69 'tokens': 'Tokens',
70 'cost': 'Kosten',
71 'estimate': 'Schätzung',
72 'eta': 'Restzeit',
73 'notEnoughData': 'zu wenig Daten',
74 'noRun': 'kein Lauf erkannt',
75 'step.brainstorming': 'Brainstorming',
76 'step.spec': 'Spec',
77 'step.plan': 'Plan',
78 'step.tasks': 'Aufgaben',
79 'step.review': 'Review',
80 'step.finish': 'Abschluss',
81 'task': 'Aufgabe',
82 'ctx': 'ctx',
83 'min': 'min',
84 'station.brief': 'Auftrag',
85 'station.implementer': 'Umsetzung',
86 'station.review': 'Review',
87 'station.fix': 'Korrektur',
88 'station.complete': 'Fertig',
89 'details': 'Details',
90 'hide': 'Ausblenden',
91 'show': 'Einblenden',
92 'paneOpened': 'SuperMod-Bereich geöffnet',
93 'hidden': 'SuperMod ausgeblendet',
94 'shown': 'SuperMod eingeblendet',
95 'pinned': 'Folge {p}',
96 'unpinned': 'Folge dem neuesten Lauf',
97 'toast.warn': 'Kontext bei {p}%: Aufgabe in frischem Subagenten beenden oder kompaktieren',
98 'toast.critical': 'Kontext bei {p}%: Aufgabe jetzt in frischem Subagenten beenden oder kompaktieren',
99 'lastCompaction': 'Letzte Kompaktierung',
100 'none': 'keine',
101 'started': 'Gestartet',
102 'elapsed': 'Laufzeit',
103 'threshold': 'Schwelle',
104 'model': 'Modell',
105 'status': 'Status',
106 'in': 'ein',
107 'out': 'aus',
108 'window': 'Fenster',
109 'approx': 'ca.',
110 'costEngine': 'Sitzungssumme der Engine',
111 'costEstimate': 'Schätzung zum Listenpreis',
112 'surfaceFallback': 'Diese Oberfläche zeichnet keine Bereiche; /supermod status nutzen',
113 'more': '+{n} weitere',
114 'perTask': 'je Aufgabe',
115 'cacheHit': 'Cache-Treffer',
116 'cacheRead': 'Cache gelesen',
117 'cacheWrite': 'Cache geschrieben',
118 'agent.running': 'läuft',
119 'agent.pending': 'wartet',
120 'agent.completed': 'fertig',
121 'agent.failed': 'Fehler',
122 'agent.killed': 'gestoppt',
123}
124
125const LABELS: Record<Lang, Record<LabelKey, string>> = { en, de }
126
127export const LABEL_KEYS = Object.keys(en) as LabelKey[]
128
129export function t(lang: Lang, key: LabelKey): string {
130 return LABELS[lang][key]
131}
132hooks/pane.tsx 305 lines1// The detail pane (spec §4.2) and the one-line status (§4.3): a model from the
2// state, rows planned against the pane's width, and the tree drawn from that
3// plan. Pure: no `$` here; register.tsx reads the state, the clock and the
4// surface's elements with its own calls. JSX compiles to the global `h`.
5
6import type { ElementTable, RenderElement } from 'claude-code'
7import type { AgentRow, ModelsState, RunSnapshot, UiOptions } from '../types'
8import { bandModelOf, clip, planBandRow, styleOf } from './band'
9import type { BandState, Segment } from './band'
10import { estimateRange, medianMs, taskDurations } from './eta'
11import { bar, dur, hm, k, pct, spark, usd } from './format'
12import { t } from './i18n'
13import type { LabelKey, Lang } from './i18n'
14import { slugFromFileName } from './progress'
15import { agentContextPercent, estimateUsd, tone } from './usage'
16import { isRunActive } from './visibility'
17
18export type PaneState = BandState & { models: ModelsState }
19
20type Tone = 'success' | 'warning' | 'error'
21
22export type PaneAgent = {
23 mark: string
24 status: string
25 type: string
26 model: string
27 ctxPercent: number | null
28 tokensIn: number
29 tokensOut: number
30 elapsedMs: number
31 isFinished: boolean
32}
33
34export type PaneModel = {
35 lang: Lang
36 run: {
37 title: string
38 path: string | null
39 percent: number
40 startedAt: number | null
41 elapsedMs: number | null
42 steps: RunSnapshot['steps']
43 current: { n: number; title: string; station: string | null } | null
44 eta: { lowMs: number; highMs: number; perTaskMs: number } | null
45 } | null
46 context: {
47 percent: number | null
48 tokens: number | null
49 window: number | null
50 tone: Tone
51 warn: number
52 critical: number
53 spark: string
54 compactions: number
55 lastCompactionAt: number | null
56 breakdown: { name: string; percent: number }[]
57 }
58 /** Newest first. */
59 agents: PaneAgent[]
60 running: number
61 /** Null when the `showCost` option is off. */
62 cost: {
63 usd: number
64 isEngine: boolean
65 models: { name: string; input: number; output: number; cacheRead: number; cacheWrite: number; usd: number }[]
66 cacheHit: number | null
67 } | null
68}
69
70const MINUTE_MS = 60_000
71const BREAKDOWN_ROWS = 6
72/** Rows the pane keeps for everything but the agent list. */
73const NON_AGENT_ROWS = 20
74const FINISHED = new Set(['completed', 'failed', 'killed'])
75const LIVE = new Set(['running', 'pending'])
76const MARKS: Record<string, string> = { running: '●', pending: '○', completed: '✓', failed: '✗', killed: '✗' }
77const STEP_MARKS = { done: '✓', active: '▶', pending: '·' } as const
78const MODEL_NAMES: Record<string, string> = {
79 'claude-opus-5-5': 'Opus 5.5',
80 'claude-sonnet-5-5': 'Sonnet 5.5',
81 'claude-haiku-5-5': 'Haiku 5.5',
82 'fable': 'Fable',
83}
84/** Cells of an agent row besides its type: mark, status, model, ctx, tokens, elapsed and the gaps. */
85const AGENT_FIXED = 2 + 8 + 1 + 1 + 10 + 1 + 4 + 1 + 8 + 1 + 6
86const ZERO_USAGE = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
87
88/** `claude-sonnet-5-5` → `Sonnet 5.5`; an unknown id without its `claude-` prefix. */
89export function modelShort(id: string | null): string {
90 if (id === null) return '–'
91 return MODEL_NAMES[id] ?? id.replace(/^claude-/, '')
92}
93
94const statusLabel = (lang: Lang, status: string) =>
95 status in MARKS ? t(lang, `agent.${status}` as LabelKey) : status
96
97function agentOf(r: AgentRow, lang: Lang, window: number, now: number): PaneAgent {
98 const isFinished = r.endedAt !== null || FINISHED.has(r.status)
99 return {
100 mark: MARKS[r.status] ?? (isFinished ? '✓' : '·'),
101 status: statusLabel(lang, r.status),
102 type: r.type || r.description || r.id,
103 model: modelShort(r.model),
104 ctxPercent: agentContextPercent(r, window, null),
105 tokensIn: r.usage.input + r.usage.cacheRead + r.usage.cacheWrite,
106 tokensOut: r.usage.output,
107 elapsedMs: (r.endedAt ?? now) - r.startedAt,
108 isFinished,
109 }
110}
111
112/** The pane's content from the state; `options` are the mod's, `now` the engine's clock. */
113export function paneModel(s: PaneState, o: UiOptions, now: number): PaneModel {
114 const lang = o.language
115 const last = s.context.ring.at(-1)
116 const ctxPercent = last?.percent ?? null
117 const bd = s.context.breakdown ?? null
118 const context: PaneModel['context'] = {
119 percent: ctxPercent,
120 tokens: last?.tokens ?? null,
121 window: last?.window ?? null,
122 tone: tone(ctxPercent, o.warnPercent, o.criticalPercent),
123 warn: o.warnPercent,
124 critical: o.criticalPercent,
125 spark: spark(s.context.ring.map(r => r.percent)),
126 compactions: s.context.compactions,
127 lastCompactionAt: s.context.lastCompactionAt,
128 breakdown: bd === null || bd.maxTokens <= 0
129 ? []
130 : bd.categories.filter(c => c.kind !== 'deferred').slice(0, BREAKDOWN_ROWS).map(c => ({ name: c.name, percent: Math.round((c.tokens / bd.maxTokens) * 100) })),
131 }
132
133 const indexed = s.agents.rows.map((r, i) => ({ r, i }))
134 indexed.sort((a, b) => b.r.startedAt - a.r.startedAt || b.i - a.i)
135 const agents = indexed.map(({ r }) => agentOf(r, lang, last?.window ?? 0, now))
136 const running = s.agents.rows.filter(r => LIVE.has(r.status)).length
137
138 let cost: PaneModel['cost'] = null
139 if (o.showCost) {
140 const entries = Object.entries(s.models.totals)
141 const sum = entries.reduce((a, [, u]) => ({ input: a.input + u.input, output: a.output + u.output, cacheRead: a.cacheRead + u.cacheRead, cacheWrite: a.cacheWrite + u.cacheWrite }), ZERO_USAGE)
142 const seen = sum.input + sum.cacheRead + sum.cacheWrite
143 cost = {
144 usd: s.models.engineUsd ?? estimateUsd(s.models.totals),
145 isEngine: s.models.engineUsd !== null,
146 models: entries.map(([id, u]) => ({
147 name: modelShort(id), input: u.input, output: u.output, cacheRead: u.cacheRead, cacheWrite: u.cacheWrite, usd: estimateUsd({ [id]: u }),
148 })),
149 cacheHit: seen > 0 ? Math.round((sum.cacheRead / seen) * 100) : null,
150 }
151 }
152
153 const snap = isRunActive(s.run) ? s.run.snapshot : null
154 if (snap === null) return { lang, run: null, context, agents, running, cost }
155 const f = s.run.files
156 const abs = f?.planPath ?? f?.specPath ?? f?.ledgerPath ?? null
157 const path = abs !== null && f?.root && abs.startsWith(f.root + '/') ? abs.slice(f.root.length + 1) : abs
158 const since = s.run.startedWithCompleted ? null : s.run.startedAt
159 const range = estimateRange(s.run.completions, since, snap.total - snap.completed, o.reviewMinutes)
160 const cur = snap.currentTask
161 return {
162 lang,
163 run: {
164 title: snap.title || (abs !== null ? slugFromFileName(abs.slice(abs.lastIndexOf('/') + 1)) : t(lang, 'title')),
165 path,
166 percent: snap.percent,
167 startedAt: s.run.startedAt,
168 elapsedMs: s.run.startedAt === null ? null : now - s.run.startedAt,
169 steps: snap.steps,
170 current: cur === null ? null : { n: cur.n, title: cur.title, station: cur.station === 'none' ? null : t(lang, `station.${cur.station}` as LabelKey) },
171 eta: range === null ? null : { ...range, perTaskMs: medianMs(taskDurations(s.run.completions, since)) },
172 },
173 context,
174 agents,
175 running,
176 cost,
177 }
178}
179
180const minutes = (ms: number) => Math.round(ms / MINUTE_MS)
181
182/** The pane's rows for `columns` cells, the agent list capped to `rows - 20`; no row wider than `columns`. */
183export function planPane(m: PaneModel, columns: number, rows: number): Segment[][] {
184 const L = (key: LabelKey) => t(m.lang, key)
185 const out: Segment[][] = []
186 const blank = () => out.push([{ text: ' ' }])
187 const barCells = Math.max(4, Math.min(24, columns - 6))
188
189 // 1. Progress overall
190 if (m.run === null) {
191 out.push([{ text: L('title'), bold: true }, { text: ': ' + L('noRun'), dim: true }])
192 } else {
193 const r = m.run
194 out.push([{ text: r.title, bold: true }])
195 if (r.path !== null) out.push([{ text: r.path, dim: true }])
196 out.push([{ text: bar(r.percent, barCells) }, { text: ' ' + pct(r.percent), bold: true }])
197 if (r.startedAt !== null && r.elapsedMs !== null) {
198 out.push([{ text: `${L('started')} ${hm(r.startedAt)} · ${L('elapsed')} ${dur(r.elapsedMs)}`, dim: true }])
199 }
200 blank()
201
202 // 2. Per step
203 for (const step of r.steps) {
204 const name = L(`step.${step.id}` as LabelKey)
205 const head: Segment = { text: `${STEP_MARKS[step.status]} ${name}`, bold: step.status === 'active', dim: step.status === 'pending' }
206 if (step.id === 'tasks' && step.total !== undefined && step.total > 0) {
207 const done = step.done ?? 0
208 out.push([{ ...head, text: head.text.padEnd(16) }, { text: bar((done / step.total) * 100, 10) }, { text: ` ${done}/${step.total}` }])
209 if (r.current !== null) {
210 const station = r.current.station === null ? '' : ` · ${r.current.station}`
211 out.push([{ text: ` ${L('task')} ${r.current.n}: ${r.current.title}${station}`, dim: true }])
212 }
213 } else {
214 out.push([head])
215 }
216 }
217 }
218 blank()
219
220 // 3. Context window
221 const c = m.context
222 const tokens = c.window === null ? '' : ` ${k(c.tokens)} / ${k(c.window)} ${L('window')}`
223 out.push([
224 { text: L('context') + ' ', dim: true },
225 { text: bar(c.percent, Math.min(12, barCells)), tone: c.tone },
226 { text: ' ' + pct(c.percent), tone: c.tone, bold: true },
227 { text: tokens, dim: true },
228 ])
229 out.push([{ text: `${L('threshold')} ${c.warn}% / ${c.critical}%`, dim: true }])
230 if (c.spark.length > 0) out.push([{ text: c.spark, tone: c.tone }])
231 const compaction = c.lastCompactionAt === null ? L('none') : `${hm(c.lastCompactionAt)} (${c.compactions})`
232 out.push([{ text: `${L('lastCompaction')} ${compaction}`, dim: true }])
233 const nameCells = Math.max(4, Math.min(22, columns - 6))
234 for (const b of c.breakdown) out.push([{ text: ` ${b.name.padEnd(nameCells).slice(0, nameCells)} ${pct(b.percent).padStart(4)}` }])
235 blank()
236
237 // 4. Agents, newest first
238 out.push([{ text: L('agents'), bold: true }, { text: ` · ${m.running} ${L('agent.running')} · ${L('context')} ${L('approx')}`, dim: true }])
239 const cap = Math.max(1, rows - NON_AGENT_ROWS)
240 const typeCells = Math.max(4, columns - AGENT_FIXED)
241 for (const a of m.agents.slice(0, cap)) {
242 const type = a.type.length > typeCells ? a.type.slice(0, typeCells - 1) + '…' : a.type.padEnd(typeCells)
243 const text = [
244 `${a.mark} ${a.status.padEnd(8).slice(0, 8)} ${type}`,
245 a.model.padEnd(10).slice(0, 10),
246 pct(a.ctxPercent).padStart(4),
247 `${k(a.tokensIn)}/${k(a.tokensOut)}`.padStart(8),
248 dur(a.elapsedMs).padStart(6),
249 ].join(' ')
250 out.push([{ text, dim: a.isFinished }])
251 }
252 if (m.agents.length > cap) out.push([{ text: L('more').replace('{n}', String(m.agents.length - cap)), dim: true }])
253
254 // 5. Tokens and cost
255 if (m.cost !== null) {
256 blank()
257 const cost = m.cost
258 out.push([{ text: `${L('tokens')} · ${L('cost')}`, bold: true }])
259 out.push([{ text: (cost.isEngine ? '' : '~') + usd(cost.usd), bold: true }, { text: ' ' + L(cost.isEngine ? 'costEngine' : 'costEstimate'), dim: true }])
260 for (const x of cost.models) {
261 out.push([{ text: `${x.name.padEnd(11)}${k(x.input)} ${L('in')} · ${k(x.output)} ${L('out')} · ~${usd(x.usd)}` }])
262 out.push([{ text: `${''.padEnd(11)}${k(x.cacheRead)} ${L('cacheRead')} · ${k(x.cacheWrite)} ${L('cacheWrite')}`, dim: true }])
263 }
264 const hit = cost.cacheHit === null ? '' : `${L('cacheHit')} ${pct(cost.cacheHit)} · `
265 out.push([{ text: `${hit}~ ${L('costEstimate')}`, dim: true }])
266 }
267
268 // 6. Remaining time
269 if (m.run !== null) {
270 blank()
271 const e = m.run.eta
272 const value = e === null
273 ? L('notEnoughData')
274 : `~${minutes(e.lowMs)}–${minutes(e.highMs)} ${L('min')} · ${Math.max(1, minutes(e.perTaskMs))} ${L('min')} ${L('perTask')}`
275 out.push([{ text: L('eta') + ' ', dim: true }, { text: value }])
276 }
277
278 return out.map(row => clip(row, columns))
279}
280
281/** The pane: one Box per planned row, each a run of Texts. */
282export function renderPane(els: ElementTable, rows: Segment[][]): RenderElement {
283 const { Box, Text } = els
284 return (
285 <Box flexDirection="column">
286 {rows.map((row, i) => (
287 <Box key={`row-${i}`} flexDirection="row">
288 {row.map(s => (
289 <Text {...styleOf(s)}>{s.text}</Text>
290 ))}
291 </Box>
292 ))}
293 </Box>
294 )
295}
296
297/** The band's content as one line of text, prefixed `SuperMod`, whether the band is hidden or not. */
298export function statusText(s: BandState, o: UiOptions): string {
299 const m = bandModelOf({ ...s, ui: { ...s.ui, isHidden: false, options: o } })
300 const L = (key: LabelKey) => t(o.language, key)
301 // No run: the status still names the context and the agent count, from the model rather than the row.
302 if (m.percent === null) return `${L('title')}: ${L('noRun')} ${L('ctx')} ${pct(m.ctxPercent)} ${L('agents')} ${m.agentsRunning}`
303 return `${L('title')} ${planBandRow(m, 10_000).segments.map(x => x.text).join('')}`
304}
305hooks/scan.ts 427 lines1// ── Search roots, path guard and run discovery ────────────────────────────
2// Decides which files the mod may ever look at (spec §5.1, §7). Pure apart
3// from the Fs port that register.tsx fills. Every list and read, and every stat
4// but two, goes through `guard`: the path must lie inside a search root, match
5// one of the allowed shapes below for the access asked for, exist, and its
6// realPath must be present and match the same shape inside a search root (a
7// symlink out, or an answer without realPath, is refused).
8// The three documented exceptions are the raw stats in `resolveRoots`: the
9// project root itself (to learn its realPath), `<project root>/.git` (a file
10// marks a linked worktree) and `<main checkout>/.git` (to confirm the main
11// checkout is one); all are stat only, never read or listed.
12// Paths are POSIX strings; `..` that climbs above `/` is refused.
13// Each file is parsed as it is read and only the parsed value is kept (the
14// state never holds a file's text); given the run as last found, a file whose
15// stat answers the same mtime as when it was read is not read again (§5.1).
16
17import type { RunFiles, StoredLedger, StoredPlan } from '../types'
18import { parseLedger, parsePlan, specTitle } from './progress'
19import type { Ledger, Plan } from './progress'
20
21export type FsKind = 'file' | 'dir' | 'other'
22export type FsEntry = { name: string; kind: FsKind; mtimeMs: number }
23export type FsStat = { kind: FsKind; mtimeMs: number; realPath?: string }
24export type Fs = {
25 read(path: string): Promise<string>
26 list(path: string): Promise<FsEntry[]>
27 /**
28 * Resolves null when the path is missing. Must be called with resolution on
29 * (the engine's `stat(path, { resolve: true })`) so the answer carries
30 * `realPath`; an answer without a string `realPath` is treated as leading nowhere.
31 */
32 stat(path: string): Promise<FsStat | null>
33}
34
35export type DiscoverOptions = {
36 pinned: string | null
37 sessionSpec: string | null
38 now: number
39 maxAgeMs: number
40 /** The run as last found: a file it read whose mtime is unchanged keeps its parsed value, unread. */
41 prior?: RunFiles | null
42}
43
44const PLAN_HEADINGS_ONLY_ABOVE = 1_048_576
45/** Titles kept for the band and pane (§7: at most 80 characters of any title). */
46const TITLE_CAP = 80
47/** A path named inside a file (marker, ledger header, Spec line) longer than this is ignored. */
48const PATH_CAP = 1_024
49const WORKTREE_FOLDERS = [['.claude', 'worktrees'], ['.worktrees'], ['worktrees']]
50
51// ── Paths ─────────────────────────────────────────────────────────────────
52
53/** Absolute path without `.`, `..`, empty segments or a trailing slash; null when relative or climbing above `/`. */
54function normalize(path: string): string | null {
55 if (!path.startsWith('/')) return null
56 const out: string[] = []
57 for (const seg of path.split('/')) {
58 if (seg === '' || seg === '.') continue
59 if (seg === '..') {
60 if (out.length === 0) return null
61 out.pop()
62 } else out.push(seg)
63 }
64 return '/' + out.join('/')
65}
66
67function join(root: string, ...parts: string[]): string {
68 return [root, ...parts].join('/')
69}
70
71function dirname(path: string): string {
72 return path.slice(0, path.lastIndexOf('/')) || '/'
73}
74
75function basename(path: string): string {
76 return path.slice(path.lastIndexOf('/') + 1)
77}
78
79/** A listing name that stays one segment when joined to its folder. */
80function isPlainName(name: unknown): name is string {
81 return typeof name === 'string' && name !== '' && name !== '.' && name !== '..' && !name.includes('/')
82}
83
84/** Normalized absolute roots; `/` is never a root. */
85function cleanRoots(roots: string[]): string[] {
86 const out: string[] = []
87 for (const r of roots) {
88 const n = normalize(r)
89 if (n !== null && n !== '/' && !out.includes(n)) out.push(n)
90 }
91 return out
92}
93
94function relSegments(path: string, root: string): string[] | null {
95 if (path === root) return []
96 return path.startsWith(root + '/') ? path.slice(root.length + 1).split('/') : null
97}
98
99export function isInsideRoots(realPath: string, roots: string[]): boolean {
100 const path = normalize(realPath)
101 return path !== null && cleanRoots(roots).some(r => relSegments(path, r) !== null)
102}
103
104/**
105 * The project root, plus the main checkout when the root is itself a worktree
106 * (`…/.claude/worktrees/<n>`, `…/.worktrees/<n>`, `…/worktrees/<n>`; the last such segment wins).
107 */
108export function searchRoots(projectRoot: string): string[] {
109 const root = normalize(projectRoot)
110 if (root === null) return []
111 const segs = root.split('/').slice(1)
112 for (let i = segs.length - 2; i >= 0; i--) {
113 let cut = -1
114 if (segs[i] === '.worktrees') cut = i
115 else if (segs[i] === 'worktrees') cut = i > 0 && segs[i - 1] === '.claude' ? i - 1 : i
116 if (cut < 0) continue
117 const main = '/' + segs.slice(0, cut).join('/')
118 return main === '/' ? [root] : [root, main]
119 }
120 return [root]
121}
122
123/**
124 * The search roots for a session: the project root resolved through `stat`
125 * (lexical when the stat fails or has no realPath), plus the main checkout from
126 * `searchRoots` when the root is a linked worktree (`<root>/.git` is a file)
127 * and `<main>/.git` exists. A clone of its own inside a `worktrees` folder (its
128 * `.git` a folder) never adds the folder above it. `/` is never a root.
129 */
130export async function resolveRoots(fs: Fs, projectRoot: string): Promise<string[]> {
131 const lexical = normalize(projectRoot)
132 if (lexical === null) return []
133 const stat = await attempt(() => fs.stat(lexical), null)
134 const real = stat && typeof stat.realPath === 'string' ? normalize(stat.realPath) : null
135 const root = real ?? lexical
136 if (root === '/') return []
137 const main = searchRoots(root)[1]
138 if (main === undefined) return [root]
139 const own = await attempt(() => fs.stat(join(root, '.git')), null)
140 if (!own || own.kind !== 'file') return [root]
141 const git = await attempt(() => fs.stat(join(main, '.git')), null)
142 return git ? [root, main] : [root]
143}
144
145// ── Allowed shapes and the guard ──────────────────────────────────────────
146
147type Shape = 'plans' | 'specs' | 'sdd' | 'workspace' | 'plan' | 'spec' | 'ledger' | 'marker' | 'worktrees'
148
149/** What a path relative to a root is allowed to be, if anything. */
150function shapeOf(s: string[]): Shape | null {
151 const docs = s.length >= 3 && s[0] === 'docs' && s[1] === 'superpowers'
152 const sdd = s.length >= 2 && s[0] === '.superpowers' && s[1] === 'sdd'
153 if (docs && s.length === 3 && s[2] === 'plans') return 'plans'
154 if (docs && s.length === 3 && s[2] === 'specs') return 'specs'
155 if (docs && s.length === 4 && s[2] === 'plans' && s[3].endsWith('.md') && s[3].length > 3) return 'plan'
156 if (docs && s.length === 4 && s[2] === 'specs' && s[3].endsWith('-design.md') && s[3].length > 10) return 'spec'
157 if (sdd && s.length === 2) return 'sdd'
158 if (sdd && s.length === 3) return 'workspace'
159 if (sdd && s.length === 4 && s[3] === 'progress.md') return 'ledger'
160 if (sdd && s.length === 4 && s[3] === 'plan-path') return 'marker'
161 if (WORKTREE_FOLDERS.some(f => f.length === s.length && f.every((x, i) => x === s[i]))) return 'worktrees'
162 return null
163}
164
165/** The root under which `path` has `shape`, or null. */
166function rootFor(path: string, roots: string[], shape: Shape): string | null {
167 for (const r of roots) {
168 const s = relSegments(path, r)
169 if (s !== null && shapeOf(s) === shape) return r
170 }
171 return null
172}
173
174/** The first root under which `path` has any allowed shape. */
175function locate(path: string, roots: string[]): { root: string; shape: Shape } | null {
176 for (const r of roots) {
177 const s = relSegments(path, r)
178 const shape = s === null ? null : shapeOf(s)
179 if (shape !== null) return { root: r, shape }
180 }
181 return null
182}
183
184async function attempt<T>(fn: () => Promise<T>, fallback: T): Promise<T> {
185 try {
186 return await fn()
187 } catch {
188 return fallback
189 }
190}
191
192type Guard = { fs: Fs; roots: string[]; prior: RunFiles | null }
193type Admitted = { path: string; root: string; stat: FsStat }
194
195/** The gate to the Fs: shape inside a root, exists, realPath present and of the same shape inside a root. */
196async function guard(g: Guard, path: string, shape: Shape): Promise<Admitted | null> {
197 const norm = normalize(path)
198 if (norm === null) return null
199 const root = rootFor(norm, g.roots, shape)
200 if (root === null) return null
201 const stat = await attempt(() => g.fs.stat(norm), null)
202 if (!stat) return null
203 if (typeof stat.realPath !== 'string') return null
204 const real = normalize(stat.realPath)
205 if (real === null || rootFor(real, g.roots, shape) === null) return null
206 return { path: norm, root, stat }
207}
208
209/** Entries of an admitted folder; null when refused or missing. */
210async function listDir(g: Guard, path: string, shape: Shape): Promise<{ path: string; entries: FsEntry[] } | null> {
211 const ok = await guard(g, path, shape)
212 if (!ok || ok.stat.kind !== 'dir') return null
213 const entries = await attempt(() => g.fs.list(ok.path), [] as FsEntry[])
214 return { path: ok.path, entries: Array.isArray(entries) ? entries.filter(e => e !== null && typeof e === 'object' && isPlainName(e.name)) : [] }
215}
216
217type Loaded = { path: string; root: string; mtimeMs: number; text: string | null; prior: RunFiles | null }
218
219/**
220 * An admitted file; null when refused or missing. When the prior run read this
221 * same file (`pathOf` names it there) at the mtime the stat answers now, that
222 * run is handed back as `prior` and nothing is read; otherwise the text, null
223 * when the read fails.
224 */
225async function load(g: Guard, path: string, shape: Shape, pathOf: (r: RunFiles) => string | null): Promise<Loaded | null> {
226 const ok = await guard(g, path, shape)
227 if (!ok || ok.stat.kind !== 'file') return null
228 const at = { path: ok.path, root: ok.root, mtimeMs: ok.stat.mtimeMs }
229 const p = g.prior
230 if (p !== null && pathOf(p) === ok.path && p.stamps.some(s => s.path === ok.path && s.mtimeMs === at.mtimeMs)) return { ...at, text: null, prior: p }
231 const text = await attempt(() => g.fs.read(ok.path), null)
232 return { ...at, text: typeof text === 'string' ? text : null, prior: null }
233}
234
235/** The loaded file's value, the prior run's when unchanged; a file read or kept is stamped with its mtime. */
236function parsed<T>(run: RunFiles, f: Loaded, valueOf: (r: RunFiles) => T | null, parse: (text: string) => T | null): T | null {
237 const value = f.prior !== null ? valueOf(f.prior) : f.text !== null ? parse(f.text) : null
238 if ((f.prior !== null || f.text !== null) && Number.isFinite(f.mtimeMs)) run.stamps.push({ path: f.path, mtimeMs: f.mtimeMs })
239 return value
240}
241
242const capTitle = (t: string) => (t.length > TITLE_CAP ? t.slice(0, TITLE_CAP - 1) + '…' : t)
243const capPath = (p: string | null) => (p !== null && p.length <= PATH_CAP ? p : null)
244
245const storeLedger = (l: Ledger): StoredLedger => ({ planPath: capPath(l.planPath), tasks: [...l.tasks.values()] })
246
247const storePlan = (p: Plan): StoredPlan => ({
248 title: capTitle(p.title),
249 specPath: capPath(p.specPath),
250 tasks: [...p.taskTitles].map(([n, title]) => ({ n, title: capTitle(title) })),
251})
252
253/** The prior run when it carries stamps (state written by an older version does not). */
254const usable = (prior: RunFiles | null | undefined): RunFiles | null =>
255 prior !== null && typeof prior === 'object' && Array.isArray(prior.stamps) ? prior : null
256
257// ── Run assembly ──────────────────────────────────────────────────────────
258
259const emptyRun = (): RunFiles => ({
260 root: null, ledgerPath: null, ledger: null, marker: null, planPath: null, plan: null,
261 specPath: null, specTitle: null, workspaceDir: null, workspaceFiles: [], workspaceExists: false, stamps: [],
262})
263
264const foundAnything = (r: RunFiles) => r.ledgerPath !== null || r.planPath !== null || r.specPath !== null || r.workspaceExists
265
266/** A path from a marker, Spec line or option: absolute as given, relative against `base`. */
267function resolveAgainst(base: string, ref: string): string | null {
268 return normalize(ref.startsWith('/') ? ref : join(base, ref))
269}
270
271/** A plan over 1 MiB keeps only headings, fence lines (so code stays code) and the Spec line. */
272function trimPlan(text: string): string {
273 if (text.length <= PLAN_HEADINGS_ONLY_ABOVE) return text
274 return text
275 .split(/\r?\n/)
276 .filter(l => l.startsWith('#') || l.startsWith('**Spec:**') || l.trimStart().startsWith('```'))
277 .join('\n')
278}
279
280async function withPlan(g: Guard, run: RunFiles, planPath: string): Promise<void> {
281 const plan = await load(g, planPath, 'plan', r => r.planPath)
282 if (!plan) return
283 run.planPath = plan.path
284 run.plan = parsed(run, plan, r => r.plan, text => storePlan(parsePlan(trimPlan(text), basename(plan.path))))
285 const ref = run.plan?.specPath ?? null
286 if (ref === null) return
287 // Relative to the plan's folder first (a Markdown link), then to its root.
288 for (const base of [dirname(plan.path), plan.root]) {
289 const specPath = resolveAgainst(base, ref)
290 if (specPath !== null) await withSpec(g, run, specPath)
291 if (run.specPath !== null) return
292 }
293}
294
295async function withSpec(g: Guard, run: RunFiles, specPath: string): Promise<void> {
296 const spec = await load(g, specPath, 'spec', r => r.specPath)
297 if (!spec) return
298 run.specPath = spec.path
299 run.specTitle = parsed(run, spec, r => r.specTitle, text => capTitle(specTitle(text, basename(spec.path))))
300}
301
302async function withWorkspace(g: Guard, run: RunFiles, dir: string): Promise<void> {
303 const ws = await listDir(g, dir, 'workspace')
304 if (!ws) return
305 run.workspaceDir = ws.path
306 run.workspaceExists = true
307 run.workspaceFiles = ws.entries.filter(e => e.kind === 'file').map(e => ({ name: e.name, mtimeMs: e.mtimeMs }))
308 const ledger = await load(g, join(ws.path, 'progress.md'), 'ledger', r => r.ledgerPath)
309 if (ledger) {
310 run.ledgerPath = ledger.path
311 run.ledger = parsed(run, ledger, r => r.ledger, text => storeLedger(parseLedger(text)))
312 }
313}
314
315/** Ledger, marker, plan and spec of one workspace; the marker names the plan, the ledger header is the fallback. */
316async function runFromWorkspace(g: Guard, root: string, dir: string): Promise<RunFiles> {
317 const run = { ...emptyRun(), root }
318 await withWorkspace(g, run, dir)
319 if (!run.workspaceExists) return run
320 const marker = await load(g, join(dir, 'plan-path'), 'marker', r => (r.workspaceDir === null ? null : join(r.workspaceDir, 'plan-path')))
321 if (marker) run.marker = parsed(run, marker, r => r.marker, text => capPath(text.split(/\r?\n/)[0].trim() || null))
322 const ref = run.marker ?? run.ledger?.planPath ?? null
323 const planPath = ref ? resolveAgainst(root, ref) : null
324 if (planPath !== null) await withPlan(g, run, planPath)
325 return run
326}
327
328/** A plan, its spec, and its workspace `.superpowers/sdd/<plan basename>/` when present. */
329async function runFromPlan(g: Guard, root: string, planPath: string): Promise<RunFiles> {
330 const run = { ...emptyRun(), root }
331 await withPlan(g, run, planPath)
332 if (run.planPath === null) return run
333 await withWorkspace(g, run, join(root, '.superpowers', 'sdd', basename(run.planPath).slice(0, -'.md'.length)))
334 return run
335}
336
337async function runFromSpec(g: Guard, root: string, specPath: string): Promise<RunFiles> {
338 const run = { ...emptyRun(), root }
339 await withSpec(g, run, specPath)
340 return run
341}
342
343/** The run a pinned path names: a workspace (or its ledger or marker), a plan, or a spec. */
344async function runFromPinned(g: Guard, pinned: string): Promise<RunFiles | null> {
345 const path = resolveAgainst(g.roots[0], pinned)
346 const at = path === null ? null : locate(path, g.roots)
347 if (path === null || at === null) return null
348 if (at.shape === 'workspace') return runFromWorkspace(g, at.root, path)
349 if (at.shape === 'ledger' || at.shape === 'marker') return runFromWorkspace(g, at.root, dirname(path))
350 if (at.shape === 'plan') return runFromPlan(g, at.root, path)
351 if (at.shape === 'spec') return runFromSpec(g, at.root, path)
352 return null
353}
354
355type Candidate = { root: string; path: string; mtimeMs: number }
356
357const newest = (cs: Candidate[]): Candidate | null => cs.reduce<Candidate | null>((best, c) => (best === null || c.mtimeMs > best.mtimeMs ? c : best), null)
358
359async function freshLedgers(g: Guard, fresh: (mtimeMs: number) => boolean): Promise<Candidate[]> {
360 const out: Candidate[] = []
361 for (const root of g.roots) {
362 const sdd = await listDir(g, join(root, '.superpowers', 'sdd'), 'sdd')
363 if (!sdd) continue
364 for (const e of sdd.entries) {
365 if (e.kind !== 'dir') continue
366 const ledger = await guard(g, join(sdd.path, e.name, 'progress.md'), 'ledger')
367 if (ledger && ledger.stat.kind === 'file' && fresh(ledger.stat.mtimeMs)) {
368 out.push({ root, path: join(sdd.path, e.name), mtimeMs: ledger.stat.mtimeMs })
369 }
370 }
371 }
372 return out
373}
374
375async function freshPlans(g: Guard, fresh: (mtimeMs: number) => boolean): Promise<Candidate[]> {
376 const out: Candidate[] = []
377 for (const root of g.roots) {
378 const plans = await listDir(g, join(root, 'docs', 'superpowers', 'plans'), 'plans')
379 if (!plans) continue
380 for (const e of plans.entries) {
381 if (e.kind === 'file' && e.name.endsWith('.md') && fresh(e.mtimeMs)) out.push({ root, path: join(plans.path, e.name), mtimeMs: e.mtimeMs })
382 }
383 }
384 return out
385}
386
387/**
388 * The current run: pinned path → newest ledger in the age window → spec written
389 * this session → newest plan in the age window → nothing. Never throws.
390 */
391export async function discoverRun(fs: Fs, roots: string[], opts: DiscoverOptions): Promise<RunFiles> {
392 const g: Guard = { fs, roots: cleanRoots(roots), prior: usable(opts.prior) }
393 if (g.roots.length === 0) return emptyRun()
394 const fresh = (mtimeMs: number) => opts.now - mtimeMs <= opts.maxAgeMs
395
396 if (opts.pinned) {
397 const run = await runFromPinned(g, opts.pinned)
398 if (run && foundAnything(run)) return run
399 }
400 const ledger = newest(await freshLedgers(g, fresh))
401 if (ledger) return runFromWorkspace(g, ledger.root, ledger.path)
402 if (opts.sessionSpec) {
403 const path = resolveAgainst(g.roots[0], opts.sessionSpec)
404 const root = path === null ? null : rootFor(path, g.roots, 'spec')
405 if (path !== null && root !== null) {
406 const run = await runFromSpec(g, root, path)
407 if (foundAnything(run)) return run
408 }
409 }
410 const plan = newest(await freshPlans(g, fresh))
411 if (plan) return runFromPlan(g, plan.root, plan.path)
412 return emptyRun()
413}
414
415/** Folders under the main checkout's worktree folders (names only), as further search roots. */
416export async function discoverWorktreeRoots(fs: Fs, mainRoot: string): Promise<string[]> {
417 const g: Guard = { fs, roots: cleanRoots([mainRoot]), prior: null }
418 if (g.roots.length === 0) return []
419 const out: string[] = []
420 for (const folder of WORKTREE_FOLDERS) {
421 const listed = await listDir(g, join(g.roots[0], ...folder), 'worktrees')
422 if (!listed) continue
423 for (const e of listed.entries) if (e.kind === 'dir') out.push(join(listed.path, e.name))
424 }
425 return out
426}
427hooks/usage.ts 104 lines1import { priceFor } from './prices'
2
3export type Reading = { at: number; percent: number | null; tokens: number | null; window: number }
4export type Usage = { input: number; output: number; cacheRead: number; cacheWrite: number }
5export type ApiUsage = {
6 input_tokens: number
7 output_tokens: number
8 cache_read_input_tokens: number
9 cache_creation_input_tokens: number
10}
11export type AgentRow = {
12 id: string
13 type: string
14 description: string
15 status: string
16 model: string | null
17 startedAt: number
18 endedAt: number | null
19 usage: Usage
20 lastWindow: number
21 steps: number
22}
23export type ModelTotals = Record<string, Usage & { steps: number }>
24
25const FINISHED = new Set(['completed', 'failed', 'killed'])
26const isFinished = (r: AgentRow): boolean => r.endedAt !== null || FINISHED.has(r.status)
27const LARGE_WINDOW = 1_000_000
28
29export function pushReading(ring: Reading[], r: Reading, cap = 30): Reading[] {
30 const next = [...ring, r]
31 return next.length > cap ? next.slice(next.length - cap) : next
32}
33
34export function addUsage(a: Usage, u: ApiUsage): Usage {
35 return {
36 input: a.input + u.input_tokens,
37 output: a.output + u.output_tokens,
38 cacheRead: a.cacheRead + u.cache_read_input_tokens,
39 cacheWrite: a.cacheWrite + u.cache_creation_input_tokens,
40 }
41}
42
43export function upsertAgent(rows: AgentRow[], row: AgentRow, cap = 200): AgentRow[] {
44 const at = rows.findIndex(r => r.id === row.id)
45 if (at >= 0) {
46 const next = rows.slice()
47 next[at] = row
48 return next
49 }
50 const next = [...rows, row]
51 let over = next.length - cap
52 if (over <= 0) return next
53 const kept = next.filter((r, i) => {
54 if (over > 0 && i < next.length - 1 && isFinished(r)) {
55 over--
56 return false
57 }
58 return true
59 })
60 return over > 0 ? kept.slice(over) : kept
61}
62
63export function agentContextPercent(row: AgentRow, sessionWindow: number, sessionModel: string | null): number | null {
64 if (!row.lastWindow) return null
65 const window = row.model === null || row.model === sessionModel ? sessionWindow : LARGE_WINDOW
66 return Math.min(100, Math.round((row.lastWindow / window) * 100))
67}
68
69export function addModel(t: ModelTotals, model: string, u: Usage): ModelTotals {
70 const cur = t[model] ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, steps: 0 }
71 return {
72 ...t,
73 [model]: {
74 input: cur.input + u.input,
75 output: cur.output + u.output,
76 cacheRead: cur.cacheRead + u.cacheRead,
77 cacheWrite: cur.cacheWrite + u.cacheWrite,
78 steps: cur.steps + 1,
79 },
80 }
81}
82
83export function estimateUsd(t: ModelTotals): number {
84 let usd = 0
85 for (const [model, u] of Object.entries(t)) {
86 const p = priceFor(model)
87 usd += (u.input * p.input + u.output * p.output + u.cacheRead * p.cacheRead + u.cacheWrite * p.cacheWrite) / 1_000_000
88 }
89 return Math.round(usd * 100) / 100
90}
91
92export function crossing(prev: number | null, next: number | null, warn: number, crit: number): 'warn' | 'critical' | null {
93 if (next === null) return null
94 const p = prev ?? 0
95 if (next >= crit && p < crit) return 'critical'
96 if (next >= warn && p < warn) return 'warn'
97 return null
98}
99
100export function tone(percent: number | null, warn: number, crit: number): 'success' | 'warning' | 'error' {
101 if (percent === null || percent < warn) return 'success'
102 return percent >= crit ? 'error' : 'warning'
103}
104hooks/visibility.ts 19 lines1// When the band shows (spec §4.1). Pure; the band and the timers share it.
2
3import type { ContextState, RunState, UiOptions, UiState } from '../types'
4
5export type VisibilityState = { run: RunState; context: ContextState; ui: Pick<UiState, 'isHidden'> }
6
7/** A Superpowers skill was seen this session, or a path was referenced and a run found. */
8export function isRunActive(run: RunState): boolean {
9 return run.skillsSeen.some(s => s.startsWith('superpowers:')) || (run.referenced.length > 0 && run.files !== null)
10}
11
12/** The band shows while a run is active or the main context is at the warning threshold, unless hidden. */
13export function isBandVisible(state: VisibilityState, options: Pick<UiOptions, 'warnPercent'>): boolean {
14 if (state.ui.isHidden) return false
15 const last = state.context.ring[state.context.ring.length - 1]
16 const isHigh = last !== undefined && last.percent !== null && last.percent >= options.warnPercent
17 return isRunActive(state.run) || isHigh
18}
19hooks/eta.ts 41 lines1export type Completion = { n: number; at: number }
2
3const LOW_FACTOR = 0.7
4const HIGH_FACTOR = 1.5
5
6export function recordCompletions(prev: Completion[], completedNow: number[], at: number): Completion[] {
7 const known = new Set(prev.map(c => c.n))
8 const added = [...new Set(completedNow)].filter(n => !known.has(n)).sort((a, b) => a - b)
9 return [...prev, ...added.map(n => ({ n, at }))]
10}
11
12export function medianMs(durations: number[]): number {
13 if (durations.length === 0) return 0
14 const s = [...durations].sort((a, b) => a - b)
15 const mid = s.length >> 1
16 return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2
17}
18
19/** The positive gaps between completions in time order, the first measured from the run's start when known. */
20export function taskDurations(c: Completion[], runStartedAt: number | null): number[] {
21 const sorted = [...c].sort((a, b) => a.at - b.at)
22 const durations: number[] = []
23 if (sorted.length > 0 && typeof runStartedAt === 'number' && runStartedAt < sorted[0].at) durations.push(sorted[0].at - runStartedAt)
24 for (let i = 1; i < sorted.length; i++) durations.push(sorted[i].at - sorted[i - 1].at)
25 return durations.filter(d => d > 0)
26}
27
28export function estimateRange(
29 c: Completion[],
30 runStartedAt: number | null,
31 openTasks: number,
32 reviewMinutes: number,
33): { lowMs: number; highMs: number } | null {
34 if (c.length < 2) return null
35 const positive = taskDurations(c, runStartedAt)
36 const median = medianMs(positive)
37 if (positive.length === 0 || median <= 0) return null
38 const total = median * Math.max(0, openTasks) + reviewMinutes * 60_000
39 return { lowMs: Math.round(total * LOW_FACTOR), highMs: Math.round(total * HIGH_FACTOR) }
40}
41hooks/format.ts 61 lines1const DASH = '–'
2
3/** Compact token count: 42, 381k, 1.21M, 12M; an en dash when unknown. */
4export function k(n: number | null | undefined): string {
5 if (n === null || n === undefined || Number.isNaN(n)) return DASH
6 if (n < 1000) return String(Math.round(n))
7 if (n < 999_500) return `${Math.round(n / 1000)}k`
8 const m = n / 1_000_000
9 return m < 10 ? `${m.toFixed(2).replace(/\.?0+$/, '')}M` : `${Math.round(m)}M`
10}
11
12/** Text bar of `width` cells, filled by percent (clamped to 0..100); null is empty. */
13export function bar(percent: number | null, width: number): string {
14 const cells = Math.max(0, Math.floor(width))
15 const clamped = percent === null || Number.isNaN(percent) ? 0 : Math.min(100, Math.max(0, percent))
16 const filled = Math.round((clamped / 100) * cells)
17 return '█'.repeat(filled) + '░'.repeat(cells - filled)
18}
19
20/** Exactly `width` characters: longer text ends in an ellipsis, shorter text is padded right. */
21export function fit(text: string, width: number): string {
22 const cells = Math.max(0, Math.floor(width))
23 if (cells === 0) return ''
24 if (text.length > cells) return text.slice(0, cells - 1) + '…'
25 return text.padEnd(cells, ' ')
26}
27
28/** Percent label: 38%, or –% when unknown. */
29export function pct(n: number | null): string {
30 return n === null || Number.isNaN(n) ? `${DASH}%` : `${Math.round(n)}%`
31}
32
33/** A duration as m:ss, or h:mm:ss from an hour on; below zero counts as zero. */
34export function dur(ms: number): string {
35 const s = Math.max(0, Math.floor(ms / 1000))
36 const two = (n: number) => String(n).padStart(2, '0')
37 const h = Math.floor(s / 3600)
38 const m = Math.floor((s % 3600) / 60)
39 return h > 0 ? `${h}:${two(m)}:${two(s % 60)}` : `${m}:${two(s % 60)}`
40}
41
42/** Local wall-clock time as HH:MM. */
43export function hm(ms: number): string {
44 const d = new Date(ms)
45 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
46}
47
48const SPARK = '▁▂▃▄▅▆▇█'
49
50/** One glyph per percent (clamped to 0..100), a space for an unknown reading. */
51export function spark(values: (number | null)[]): string {
52 return values
53 .map(v => (v === null || Number.isNaN(v) ? ' ' : SPARK[Math.min(SPARK.length - 1, Math.floor((Math.min(100, Math.max(0, v)) / 100) * SPARK.length))]))
54 .join('')
55}
56
57/** US dollars with cents: $3.10. */
58export function usd(n: number): string {
59 return `$${n.toFixed(2)}`
60}
61hooks/progress.ts 180 lines1// ── Ledger ────────────────────────────────────────────────────────────────
2// Parser for the Superpowers 7.0.0 SDD ledger. Only `complete` and `fix round`
3// directly after `Task N:` are stations; parked, minor and Ruling lines are not.
4
5export type TaskLedger = { n: number; complete: boolean; fixRound: number | null }
6export type Ledger = { planPath: string | null; tasks: Map<number, TaskLedger> }
7
8const LEDGER_HEADER = /^# SDD ledger [—-] plan: (.+)$/m
9const LEDGER_TASK = /^Task (\d+): (?:complete\b|fix round (\d+)\/\d+)/gm
10
11/** Reads the plan path and the per-task stations from the ledger text. */
12export function parseLedger(text: string): Ledger {
13 const header = LEDGER_HEADER.exec(text)
14 const tasks = new Map<number, TaskLedger>()
15 for (const m of text.matchAll(LEDGER_TASK)) {
16 const n = Number(m[1])
17 const task = tasks.get(n) ?? { n, complete: false, fixRound: null }
18 if (m[2] === undefined) task.complete = true
19 else task.fixRound = Number(m[2])
20 tasks.set(n, task)
21 }
22 return { planPath: header ? header[1].trim() : null, tasks }
23}
24
25// ── Plan and spec ─────────────────────────────────────────────────────────
26// Parsers for Superpowers 7.0.0 plan and spec files. Lines inside fenced code
27// blocks are never headings, so example snippets in a plan do not count.
28
29export type Plan = { title: string; specPath: string | null; taskCount: number; taskTitles: Map<number, string> }
30
31const PLAN_TASK = /^#{2,3} Task (\d+)(?::\s*(.*))?$/
32const PLAN_SPEC = /^\*\*Spec:\*\*\s*(\S.*)$/
33const SPEC_LINK = /^\[[^\]]*\]\(([^)\s]+)\)/
34const FENCE = /^\s*```/
35
36/** The lines outside fenced code blocks. */
37function proseLines(text: string): string[] {
38 const out: string[] = []
39 let fenced = false
40 for (const line of text.split(/\r?\n/)) {
41 if (FENCE.test(line)) fenced = !fenced
42 else if (!fenced) out.push(line)
43 }
44 return out
45}
46
47function firstH1(lines: string[]): string | null {
48 const line = lines.find(l => l.startsWith('# '))
49 return line === undefined ? null : line.slice(2).trim()
50}
51
52/** `2026-10-07-englische-ansicht-design.md` → `englische-ansicht`. */
53export function slugFromFileName(name: string): string {
54 return name.replace(/^\d{4}-\d{2}-\d{2}-/, '').replace(/\.md$/, '').replace(/-design$/, '')
55}
56
57/** The path in a Spec line: `[text](path)` gives the path, otherwise the first word minus `[]` or backticks. */
58function specPathFrom(rest: string): string {
59 const link = SPEC_LINK.exec(rest)
60 if (link) return link[1]
61 return rest.split(/\s/)[0].replace(/^[[`]+|[\]`]+$/g, '')
62}
63
64/** Reads title, spec path and the numbered task headings from a plan. */
65export function parsePlan(text: string, fileName: string): Plan {
66 const lines = proseLines(text)
67 const taskTitles = new Map<number, string>()
68 let specPath: string | null = null
69 for (const line of lines) {
70 const task = PLAN_TASK.exec(line)
71 if (task) taskTitles.set(Number(task[1]), (task[2] ?? '').trim())
72 const spec = PLAN_SPEC.exec(line)
73 if (spec && specPath === null) specPath = specPathFrom(spec[1])
74 }
75 return { title: firstH1(lines) ?? slugFromFileName(fileName), specPath, taskCount: taskTitles.size, taskTitles }
76}
77
78/** The spec's first heading, or the slug of its file name. */
79export function specTitle(text: string, fileName: string): string {
80 return firstH1(proseLines(text)) ?? slugFromFileName(fileName)
81}
82
83// ── Stations and the run model ────────────────────────────────────────────
84// Turns ledger, plan and workspace evidence (names and mtimes only) into the
85// snapshot the band and pane draw. Weights and credits are constants (spec §5.1).
86
87export type Station = 'none' | 'brief' | 'implementer' | 'review' | 'fix' | 'complete'
88export type WorkspaceFile = { name: string; mtimeMs: number }
89export type StepId = 'brainstorming' | 'spec' | 'plan' | 'tasks' | 'review' | 'finish'
90export type StepState = { id: StepId; status: 'done' | 'active' | 'pending'; done?: number; total?: number }
91export type RunEvidence = {
92 skillsSeen: Set<string>
93 specExists: boolean
94 planExists: boolean
95 ledger: Ledger | null
96 workspaceExists: boolean
97 files: WorkspaceFile[]
98 plan: Plan | null
99 finishingTurnCompleted: boolean
100 lastLedger: Ledger | null
101 /** Title of the spec written in this session; names the run until a plan takes over. */
102 specTitle?: string
103}
104export type RunSnapshot = {
105 title: string
106 percent: number
107 steps: StepState[]
108 currentTask: { n: number; title: string; station: Station } | null
109 completed: number
110 total: number
111}
112
113export const STEP_WEIGHTS: Record<StepId, number> = { brainstorming: 5, spec: 10, plan: 10, tasks: 60, review: 10, finish: 5 }
114export const STATION_CREDIT: Record<Station, number> = { none: 0, brief: 15, implementer: 40, review: 70, fix: 85, complete: 100 }
115
116const STEP_ORDER: StepId[] = ['brainstorming', 'spec', 'plan', 'tasks', 'review', 'finish']
117const SKILL_BRAINSTORMING = 'superpowers:brainstorming'
118const SKILL_PLANS = 'superpowers:writing-plans'
119const SKILL_FINISHING = 'superpowers:finishing-a-development-branch'
120const SKILLS_EXECUTING = ['superpowers:executing-plans', 'superpowers:subagent-driven-development']
121
122export function creditOf(station: Station): number {
123 return STATION_CREDIT[station]
124}
125
126/** The highest station task `n` has reached; ledger lines beat workspace files. */
127export function stationOf(n: number, ledger: Ledger, files: WorkspaceFile[]): Station {
128 const line = ledger.tasks.get(n)
129 if (line?.complete) return 'complete'
130 if (line && line.fixRound !== null) return 'fix'
131 if (files.some(f => f.name === `task-${n}-report.md`)) return 'review'
132 const briefs = files.filter(f => /^task-\d+-brief\.md$/.test(f.name))
133 const own = briefs.find(f => f.name === `task-${n}-brief.md`)
134 if (!own) return 'none'
135 return own.mtimeMs >= Math.max(...briefs.map(f => f.mtimeMs)) ? 'implementer' : 'brief'
136}
137
138/** Step states, percent, current task and counts for one run. */
139export function computeRun(ev: RunEvidence): RunSnapshot {
140 const ledger = ev.ledger ?? (ev.workspaceExists ? null : ev.lastLedger)
141 const seen = (s: string) => ev.skillsSeen.has(s)
142 const finishing = seen(SKILL_FINISHING)
143 const numbers = ev.plan ? [...ev.plan.taskTitles.keys()].sort((a, b) => a - b) : []
144 const open = numbers.filter(n => !ledger?.tasks.get(n)?.complete)
145 const allComplete = numbers.length > 0 && open.length === 0
146 const tasksActive = ledger !== null && !allComplete
147
148 const raw: Record<StepId, 'done' | 'active' | 'pending'> = {
149 brainstorming: ev.specExists ? 'done' : seen(SKILL_BRAINSTORMING) ? 'active' : 'pending',
150 spec: ev.specExists ? 'done' : 'pending',
151 plan: ev.planExists && (ledger !== null || SKILLS_EXECUTING.some(seen)) ? 'done' : seen(SKILL_PLANS) && ledger === null ? 'active' : 'pending',
152 tasks: allComplete ? 'done' : tasksActive ? 'active' : 'pending',
153 review: (!ev.workspaceExists && allComplete) || finishing ? 'done' : allComplete ? 'active' : 'pending',
154 finish: finishing && ev.finishingTurnCompleted ? 'done' : finishing ? 'active' : 'pending',
155 }
156 // A step that is done or active makes every earlier step done.
157 let later = false
158 for (const id of [...STEP_ORDER].reverse()) {
159 if (later && raw[id] !== 'done') raw[id] = 'done'
160 if (raw[id] !== 'pending') later = true
161 }
162
163 const completed = numbers.length - open.length
164 const total = numbers.length
165 const stationNow = (n: number): Station => (ledger ? stationOf(n, ledger, ev.files) : 'none')
166 const currentN = raw.tasks === 'active' && open.length > 0 ? open[0] : null
167 const credit = currentN === null ? 0 : creditOf(stationNow(currentN))
168
169 let percent = 0
170 for (const id of STEP_ORDER) {
171 const w = STEP_WEIGHTS[id]
172 if (raw[id] === 'done') percent += w
173 else if (raw[id] === 'active') percent += id === 'tasks' ? (total === 0 ? 0 : (w * (completed + credit / 100)) / total) : w / 2
174 }
175
176 const steps: StepState[] = STEP_ORDER.map(id => (id === 'tasks' ? { id, status: raw[id], done: completed, total } : { id, status: raw[id] }))
177 const currentTask = currentN === null ? null : { n: currentN, title: ev.plan?.taskTitles.get(currentN) ?? '', station: stationNow(currentN) }
178 return { title: ev.plan?.title ?? ev.specTitle ?? '', percent: Math.round(percent), steps, currentTask, completed, total }
179}
180hooks/prices.ts 26 lines1// USD per million tokens. Estimates only; first matching row wins.
2export type Price = { input: number; output: number; cacheRead: number; cacheWrite: number }
3
4export const PRICES_AS_OF = '2026-10-10'
5
6const row = (input: number, output: number, cacheRead: number, cacheWrite: number): Price => ({
7 input, output, cacheRead, cacheWrite,
8})
9
10const TABLE: [RegExp, Price][] = [
11 [/opus-5-5/, row(4, 20, 0.2, 5)],
12 [/sonnet-5-5/, row(2, 10, 0.2, 2.5)],
13 [/haiku-5-5/, row(0.1, 0.5, 0.01, 0.125)],
14 [/fable|mythos/, row(10, 50, 0.25, 12.5)],
15 [/opus/, row(5, 25, 0.5, 6.25)],
16 [/sonnet/, row(2, 10, 0.2, 2.5)],
17 [/haiku/, row(1, 5, 0.1, 1.25)],
18]
19
20const FALLBACK: Price = row(4, 20, 0.2, 5)
21
22export function priceFor(model: string): Price {
23 for (const [re, price] of TABLE) if (re.test(model)) return { ...price }
24 return { ...FALLBACK }
25}
26