SLOPSHOPPER

supermod

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

newpanebandguardcommandtoast
v0.1.2MITupdated 2026-10-10siller/supermod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · supermod
│ ┃ SuperMod ✕ › fix the failing auth test and add an audit log call │ ┃ SuperMod: no run detected │ ┃ ⏺ Read(src/auth.ts) │ ┃ Context ██████░░░░░░ 49% 97k / 200k window ⎿ Read 6 lines │ ┃ Threshold 70% / 85% ⏺ Update(src/auth.ts) │ ┃ ▄ ⎿ Added 2 lines, removed 1 line │ ┃ Last compaction none ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Agents · 0 running · Context approx. │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Tokens · Cost │ ┃ $0.42 session total from the engine ✻ Worked for 42s · done 4:20 PM │ ┃ ~ estimate at list price │ › /supermod │ ⎿ supermod: SuperMod pane opened │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · SuperMod
SuperMod: no run detected Context ██████░░░░░░ 49% 97k / 200k window Threshold 70% / 85% ▄ Last compaction none Agents · 0 running · Context approx. Tokens · Cost $0.42 session total from the engine ~ estimate at list price
README

SuperMod

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.

What it shows

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:

  1. Progress overall: plan title and path, bar, percent, start time, elapsed time.
  2. Per step: Brainstorming, Spec, Plan, Tasks, Review, Finish, each done, active or pending; for Tasks the done/total bar and the current task with its station.
  3. Context window: bar, percent, tokens of the window, thresholds, a sparkline of the last 30 readings, the last compaction and how many so far, and the /context-style breakdown by category when available.
  4. Agents: one row per agent of the session, newest first, with status, type, model, approximate context percent, tokens in and out, and elapsed time.
  5. Tokens and cost: totals per model and the cost. The session total is the engine's own figure; the per-model split is an estimate at list price, not a bill. Hidden when the showCost option is off.
  6. Remaining time: the median duration of the tasks completed in this session times the open tasks, plus a review allowance, as a range. Shown after at least two completed tasks; before that it says "not enough data".

Install

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.

Where it draws

SurfaceBand and pane/supermod status
Terminal, including an editor's integrated terminalyesyes
Desktop app, Code tabyesyes
VS Code extension, chat panelnoyes
claude -p, Agent SDKnoyes
Cloud sessionsnoyes

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.

The /supermod command

CommandEffect
/supermodOpens the pane.
/supermod hideHides the band until the next run starts or /supermod show.
/supermod showShows the band again.
/supermod statusReplies 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 autoGoes back to following the newest run.

Options

OptionDefaultMeaning
languageenLanguage of the texts: en or de.
warnPercent70Context fill in percent at which the band turns yellow and a toast appears (1 to 99).
criticalPercent85Context fill in percent at which the band turns red and a toast appears (1 to 100).
showCosttrueShow the cost section of the pane.
reviewMinutes10Minutes of review allowance added to the remaining-time estimate (0 to 240).

Set them in /config, or under pluginConfigs in the Claude Code settings.

What it reads and calls

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/*.md
  • docs/superpowers/specs/*-design.md
  • .superpowers/sdd/*/progress.md
  • .superpowers/sdd/*/plan-path

It 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.

Superpowers compatibility

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].

Requirements

  • Claude Code v2.1.287 or later, for mods. Tested on 2.1.296.
  • The mods API is early access and may change; SuperMod may need updates when it does.

Before each release

The maintainer checks these by hand (they need real sessions, so CI cannot):

  • ☐ Terminal at 80 columns and at 120 columns: no band row wraps, the band degrades in the documented order.
  • ☐ Desktop app, Code tab: band and pane.
  • ☐ VS Code integrated terminal: band and pane.
  • ☐ VS Code chat panel: nothing drawn, /supermod status replies with text.
  • ☐ One real Superpowers run in subagent-driven mode: the band appears at brainstorming, the stations advance, a toast appears at a forced low warnPercent.
  • ☐ A real run in native execution mode: a task goes 0, then 100.
  • ☐ Resume after /compact: progress stays, the remaining-time estimate waits.

Development

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.

License

MIT. See LICENSE.

Source 13 files
hooks/register.tsx 359 lines
1// 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}
359
hooks/band.tsx 171 lines
1// 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}
171
hooks/glue.ts 154 lines
1// 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}
154
hooks/i18n.ts 132 lines
1export 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}
132
hooks/pane.tsx 305 lines
1// 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}
305
hooks/scan.ts 427 lines
1// ── 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}
427
hooks/usage.ts 104 lines
1import { 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}
104
hooks/visibility.ts 19 lines
1// 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}
19
hooks/eta.ts 41 lines
1export 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}
41
hooks/format.ts 61 lines
1const 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}
61
hooks/progress.ts 180 lines
1// ── 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}
180
hooks/prices.ts 26 lines
1// 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