Live progress of a superpowers run: a band above the prompt, a side pane and notifications.

A Claude Code mod that shows the progress of a superpowers run: the current stage (brainstorming, plan, execution, final review, finishing), the plan's task, the implementation and review loop, and what is waiting for an answer from you.
Superpowers 7.0.0 is required: the mod recognises the scripts, the subagent dispatches and the ledger as that version writes them.
⠋ exec SDD ▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱ 7/15 · Task 7: Ledger parser · review · fix 2/5 · 42m
Before execution, the line gives the stage and its phase (● brainstorming · conversation, ● brainstorming · builder check, ● plan · drafting). While the run works, its mark is a spinner where the surface can animate one (terminal and desktop). When the run waits for an answer, the line becomes one highlighted block, WAITING · <reason> . When the width runs short, the band drops the elapsed time first, then the task title, then the bar. Outside a superpowers run, the band shows nothing. Its colours follow your Claude Code theme.
The mod follows the run from Claude Code's events: tool calls (superpowers scripts, writes of the spec and the plan, subagent dispatches) and their results, turn ends, and the skills launched through the Skill tool. For the phases those events do not show (conversation, drafting and review of the spec or the plan, RED/GREEN/refactor), the model reports them through a tool the mod exposes, report_phase. It is guided by a short instruction placed at the start of the session and of each subagent (about 150 tokens each) and by the tool's description. For RED/GREEN the mod also infers them from the plan's run commands: the first file written in a task means red, the first run of a plan command after a write means green. The mod does not change the skills' text. Limits:
green means a plan command was run, not that it passed;refactor comes from the model only.A skill launched by typing /superpowers:… is seen through the command.run hook; one the model launches is seen through its Skill tool call. The skill.prompt hook is no longer observed: the security plugin built into Claude Code bypasses it for user-level plugins, and it carries no agent id.
In a Claude Code session, at a terminal's prompt:
/plugin install superpowers-statusbar --marketplace AdrienGras/superpowers-statusbar
Answer y to add the marketplace, then pick the scope (user by default).
To start a session with the mod loaded from a local copy of the repository:
claude --plugin-dir /path/to/superpowers-statusbar
Checks:
claude plugin test .
npm run typecheck
claude plugin validate .
The SP_STATUSBAR_TRACE environment variable, when it names a file, makes the mod write one JSON line per recognised action to it.
claude plugin test .: unit tests, on synthetic runs (tests/fixtures/).npm run contract -- <superpowers folder>: checks that the markers the mod recognises exist in a superpowers version.npm run local: runs the real plans and ledgers dropped in fixtures-local/ (gitignored) through the parser.npm run e2e -- <superpowers folder> [sdd|direct|all] [npm|bats|all]: local e2e with a real claude -p. The second argument picks the mode, all by default. The last argument picks the fixture: e2e/fixture (a small Node library tested with node --test, npm) or e2e/fixture-bats (a small bash library tested with bats, bats); the default is all, both fixtures. The script copies the fixture (with its 3-task plan) to a temporary folder and has the plan executed there in SDD mode (subagent-driven-development), in inline mode (executing-plans) or both, with the mod and the given superpowers version loaded through --plugin-dir. The run is isolated from your configuration (--setting-sources project,local): none of your hooks, installed plugins or user CLAUDE.md; only the mod and the tested version are loaded. The run does not get every permission: acceptEdits (writes in the run's folder only) and a limited tool list (reading files, skills, subagents, the mod's tool, the scripts of the tested superpowers version, node --test, bats, git, npm test, a few shell text commands such as cat, grep or ls, and removing the plan's workspace); other Bash commands are refused. This list is not a security boundary: npm test and node --test run code the model writes, and git can act on other repositories and run commands through aliases. It guides a cooperative model, nothing more: treat the run as unsandboxed, and only launch it on a machine where that is acceptable. The script then checks the mod's trace. Errors: the stages exec then final review, the 3 tasks done, a review of each task in SDD, a verification of each task inline, RED then GREEN for each done task, reported by the model or observed by the mod (in both modes), and, if the run reached finishing, its menu last. Warnings, which describe the flow the model chose and do not fail the run: task <n>: red/green not reported by the model (the model did not report them itself), finishing never reached, and, in SDD, no dispatch seen at agent.spawn (only the tool.call fallback). It prints E2E <mode>: OK (with any warnings) or the list of problems, and copies each trace, with the matching stream, to fixtures-local/e2e/<version>-<fixture>-<mode>-<stamp>.{trace,stream}.jsonl. The run costs tokens (model sonnet) and takes several minutes per mode; it needs claude and jq, plus bats, since the default runs the bats fixture too. npm run e2e -- ~/.claude/plugins/cache/superpowers-marketplace/superpowers/7.0.0 all
With no third argument this runs both modes on both fixtures, so four runs.
/sp-progress/sp-progress opens the pane, or closes it if it is open. Where the surface cannot show the pane, the command answers "Progress pane cannot be shown here." and the pane is not counted as open.
The pane also opens by itself when the plan's execution starts, but only where it sits beside the transcript (fullscreen terminal). Elsewhere, it waits for the command.
notifications optionThe notifications option (on by default) turns the mod's notifications off when disabled. It is set at installation.
Notifications go through your usual channel, the one Claude Code's preferredNotifChannel setting names (/config, Local notifications).
Claude Code sends its own notification when a task ends or a permission is waiting. During a run, it can double the mod's. According to Claude Code's documentation at the time of writing:
preferredNotifChannel set to "notifications_disabled" turns off Claude Code's notifications, but also the mod's, which go through the same channel. If you only want to keep one of the two, keep Claude Code's and disable the mod's notifications option.Notification hooks in your settings run in addition to the built-in notification, without replacing it: they cannot suppress it. They also receive the mod's notifications (plugin_notification field), which lets you, if you notify through your own hooks, handle the mod's and Claude Code's differently (the matcher works on the notification type, for example idle_prompt or permission_prompt).hooks/register.tsx 220 lines1// Two rules of the engine's scan of this module, which refuses to load it otherwise; band, pane and rebuild follow them:
2// - `$` is followed only into functions declared in this file, never across an import: helpers in other files get
3// what `portOf($)` builds (EnginePort), or are hooks passed to `on` by reference.
4// - `read`, `update` and `$.state.*` take only an atom or reference written in the file that uses it, never an
5// imported one: each such file declares its own `atom({ plugin: 'superpowers-statusbar', key: 'run' } as const, null)`.
6// Likewise `$.env.get` takes a literal variable name.
7
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, PluginOptions, Register } from 'claude-code'
10
11import type { ObservedEvent, RunState } from '../types'
12
13import { REPORT_TOOL } from '../src/instrument.ts'
14import { messagesToEvents, stopAgents } from '../src/rebuild.ts'
15import { isDockingSurface, renderBand } from './band.tsx'
16import { callEvents, spawnEvent, toolEvent } from './normalize.ts'
17import { PANE_ID, PANE_TITLE, renderPane } from './pane.tsx'
18import { observe, resetTrace } from './runtime.ts'
19import { onSpinnerFault } from './spin.ts'
20import type { EnginePort } from './runtime.ts'
21
22/**
23 * The run the status bar shows; `null` until a superpowers skill starts one. Declared here: the engine reads
24 * state only through an atom or reference written in the file that hooks it, never one imported.
25 */
26const runAtom = atom({ plugin: 'superpowers-statusbar', key: 'run' } as const, null)
27
28/** Whether the progress pane is open, as `/sp-progress`, the opening at exec and the person's close leave it. */
29const paneOpenAtom = atom({ plugin: 'superpowers-statusbar', key: 'isPaneOpen' } as const, false)
30
31const REPORT = 'mcp__superpowers-statusbar__report_phase'
32const REPORTED = { result: 'ok' } as const
33
34/** What `observe` reaches of the engine; `$` itself cannot cross into another file. */
35function portOf($: EngineInterface): EnginePort {
36 return {
37 read: () => read($, runAtom),
38 update: change => update($, runAtom, change),
39 notify: (text, options) => $.ui.notify(text, options),
40 tracePath: () => $.env.get('SP_STATUSBAR_TRACE'),
41 readFile: path => $.fs.read(path),
42 writeFile: (path, text) => $.fs.write(path, text),
43 entered: stage => {
44 if (stage === 'exec' && isDockingSurface()) void openPane($).catch(() => {})
45 },
46 }
47}
48
49/**
50 * Opens the progress pane and tells whether the engine placed it. A pane it left undrawn (a terminal too narrow:
51 * 144 columns unasked, 110 once asked) is closed again and not counted open.
52 */
53async function openPane($: EngineInterface): Promise<boolean> {
54 const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
55 if (!opened.isPlaced) {
56 await $.ui.close({ id: PANE_ID })
57 await update($, paneOpenAtom, () => false)
58 return false
59 }
60 await update($, paneOpenAtom, () => true)
61 return true
62}
63
64const TICK_MS = 60_000
65/** A timer silent for longer than this is taken as ended (the engine ends an interval whose period it refused). */
66const TICK_STALE_MS = 3 * TICK_MS
67
68/** The timer this copy of the module runs (a hot reload cancels it with the module it ran in), and when it last ticked or began. */
69let timer: { cancel: () => void } | undefined
70let lastTick = 0
71
72/**
73 * Keeps, once per copy of the module, a timer that redraws the band and the pane every minute while a run is
74 * underway, so their elapsed time and estimate move on when no observation writes the run. A timer that has not
75 * ticked for more than 3 minutes has ended: it is cancelled and started again.
76 */
77function tickEveryMinute($: EngineInterface, now: number) {
78 if (timer) {
79 if (now - lastTick <= TICK_STALE_MS) return
80 try { timer.cancel() } catch {}
81 timer = undefined
82 }
83 lastTick = now
84 try {
85 timer = $.clock.every(TICK_MS, () => {
86 void $.clock.now().then(at => { lastTick = at }, () => {})
87 void read($, runAtom).then(run => {
88 if (run && run.stage !== 'done') $.ui.invalidate('ui.render')
89 }, () => {})
90 })
91 } catch {
92 timer = undefined
93 }
94}
95
96/** Observes, in order, the events `events` builds for the current time; neither the clock nor observing fails the hook. */
97async function seen($: EngineInterface, events: (at: number) => ObservedEvent | ObservedEvent[], options: PluginOptions) {
98 let at: number
99 try {
100 at = await $.clock.now()
101 } catch {
102 return
103 }
104 tickEveryMinute($, at)
105 for (const e of [events(at)].flat()) await observe(portOf($), e, options)
106}
107
108/**
109 * Rebuilds the run from the session transcript when the session starts with a transcript and no run (a resume).
110 * A run already stored (reload, compaction) is kept, and an empty transcript adopts nothing. The events go
111 * through `observe` as live ones do, files served by `$.fs.read`, but on a local run, apart from the stored one and
112 * silent: no notification, trace or pane. Only when the whole replay succeeded is the run stored, once, and only
113 * if none was stored meanwhile; then no agent is left running (`stopAgents`), so the next main turn end can wait.
114 * Failing to read or replay the transcript leaves the run as it is.
115 */
116async function rebuildFromTranscript($: EngineInterface, options: PluginOptions) {
117 try {
118 if ((await read($, runAtom)) !== null) return
119 const messages = await $.session.messages()
120 if (!Array.isArray(messages) || messages.length === 0) return
121 const events = messagesToEvents(messages)
122 let rebuilt: RunState | null = null
123 const port: EnginePort = {
124 read: async () => rebuilt,
125 update: async change => { rebuilt = change(rebuilt) },
126 notify: async () => {},
127 tracePath: async () => undefined,
128 readFile: path => $.fs.read(path),
129 writeFile: async () => {},
130 entered: () => {},
131 }
132 for (const e of events) await observe(port, e, { ...options, notifications: false })
133 const run = rebuilt as RunState | null
134 if (run) await update($, runAtom, s => s ?? stopAgents(run))
135 } catch {}
136}
137
138export const register: Register = (on, options) => {
139 on('session.start', async ($, e, next) => {
140 await Promise.all([
141 $.tool.register(REPORT_TOOL),
142 $.command.register({ name: 'sp-progress', description: 'Open or close the superpowers progress pane' }),
143 ])
144 await rebuildFromTranscript($, options)
145 try { tickEveryMinute($, await $.clock.now()) } catch {}
146 return next(e)
147 }).catch(($, e, next) => next(e))
148
149 on('command.run', { command: 'sp-progress' }, async $ => {
150 if (await read($, paneOpenAtom)) {
151 await $.ui.close({ id: PANE_ID })
152 await update($, paneOpenAtom, () => false)
153 return { text: 'Progress pane closed.' }
154 }
155 return { text: await openPane($) ? 'Progress pane opened.' : 'Progress pane cannot be shown here.' }
156 }).catch(() => ({ text: 'Progress pane unavailable.' }))
157
158 // A skill typed as `/superpowers:…` reaches `command.run`; the security plugin bypasses `skill.prompt`, which has no agentId.
159 on('command.run', async ($, e, next) => {
160 if (e.command !== 'sp-progress') await seen($, at => ({ kind: 'command', command: e.command, at }), options)
161 return next(e)
162 }).catch(($, e, next) => next(e))
163
164 on('ui.close', { id: PANE_ID }, async ($, e, next) => {
165 const closed = await next(e)
166 await update($, paneOpenAtom, () => false)
167 return closed
168 }).catch(($, e, next) => next(e))
169
170 on('ui.render', { component: 'AbovePrompt' }, renderBand).catch(($, e, next) => next(e))
171
172 on('ui.render', { component: 'Pane', requestId: PANE_ID }, renderPane)
173
174 // A spinner `Client` that failed is left out from then on: the engine draws its site again, with a still mark.
175 on('ui.fault', ($, e, next) => {
176 onSpinnerFault(e)
177 return next(e)
178 }).catch(($, e, next) => next(e))
179
180 on('tool.call', { tool: REPORT }, async ($, e) => {
181 await seen($, at => toolEvent(e, REPORTED, at), options)
182 return REPORTED
183 }).catch(() => REPORTED)
184
185 on('tool.call', async ($, e, next) => {
186 if (e.tool === REPORT) return next(e)
187 const ran = await next(e)
188 await seen($, at => callEvents(e, ran, at), options)
189 return ran
190 }).catch(($, e, next) => next(e))
191
192 on('agent.spawn', async ($, e, next) => {
193 const r = await next(e)
194 // Only the model's own Agent tool starts a superpowers dispatch: another plugin's `$.agent.spawn` is not one.
195 if (next.origin.plugin !== 'engine') return r
196 await seen($, at => spawnEvent(e, r, at) ?? [], options)
197 return r
198 }).catch(($, e, next) => next(e))
199
200 on('turn.start', async ($, e, next) => {
201 const started = await next(e)
202 await seen($, at => ({ kind: 'turn-start', at }), options)
203 return started
204 }).catch(($, e, next) => next(e))
205
206 on('turn.complete', async ($, e, next) => {
207 const completed = await next(e)
208 await seen($, at => ({ kind: 'turn-end', ...(e.agentId ? { agentId: e.agentId } : {}), answer: e.answer, at }), options)
209 return completed
210 }).catch(($, e, next) => next(e))
211
212 on('session.end', async ($, e, next) => {
213 if (e.reason === 'clear') {
214 resetTrace()
215 await update($, runAtom, () => null)
216 }
217 return next(e)
218 }).catch(($, e, next) => next(e))
219}
220src/instrument.ts 69 lines1import type { ReportedPhase } from '../types'
2
3type ReportingSkill = 'brainstorming' | 'writing-plans' | 'test-driven-development'
4
5/** The phases each skill reports, in the order the skill goes through them. */
6const PHASES_BY_SKILL: Record<ReportingSkill, readonly ReportedPhase[]> = {
7 'brainstorming': ['conversation', 'design-writing', 'builder-check', 'awaiting-approval'],
8 'writing-plans': ['drafting', 'self-review'],
9 'test-driven-development': ['red', 'green', 'refactor'],
10}
11
12/** When the model reports each phase, as the tool description words it. */
13const WHEN: Record<ReportedPhase, string> = {
14 'conversation': 'you are asking questions or playing understanding back',
15 'design-writing': 'you are writing the design document',
16 'builder-check': 'the builder check subagent is reading the document',
17 'awaiting-approval': 'the document is handed over for approval',
18 'drafting': 'you are writing the plan',
19 'self-review': 'you are running the self-review checklist',
20 'red': 'you start writing a failing test',
21 'green': 'you start writing the code that makes it pass',
22 'refactor': 'you are cleaning up with the tests green',
23}
24
25/** Every phase the tool accepts; the detector accepts the same set. */
26export const ALL_PHASES: readonly ReportedPhase[] = Object.values(PHASES_BY_SKILL).flat()
27
28const phaseLines = (skill: ReportingSkill) => PHASES_BY_SKILL[skill].map(p => `- \`${p}\`: ${WHEN[p]}`).join('\n')
29
30/**
31 * The reporting instructions, which the model reads in the tool list on every request and in every subagent:
32 * Claude Code's built-in security plugin skips plugins' `skill.prompt` hooks, so the skills' text cannot carry them.
33 */
34const DESCRIPTION = [
35 'Reports the current superpowers phase to the status bar your human partner watches. Call it only while following '
36 + 'superpowers\' brainstorming, writing-plans or test-driven-development skill: once each time you move into one of '
37 + 'the phases below, then carry on. It is a signal only: it changes nothing about how you follow the skill, and you '
38 + 'report nothing else. This includes executing a plan yourself (superpowers\' executing-plans): report red, green '
39 + 'and refactor for each task as you go, as an implementer subagent does.',
40 ...(Object.keys(PHASES_BY_SKILL) as ReportingSkill[]).map(skill => `${skill}:\n${phaseLines(skill)}`),
41].join('\n\n')
42
43/** The tool the model calls to report a phase, listed to it as `mcp__superpowers-statusbar__report_phase`. */
44export const REPORT_TOOL: { name: 'report_phase'; description: string; inputSchema: Record<string, unknown>; isDeferred: false } = {
45 name: 'report_phase',
46 description: DESCRIPTION,
47 inputSchema: {
48 type: 'object',
49 properties: { phase: { type: 'string', enum: ALL_PHASES } },
50 required: ['phase'],
51 },
52 isDeferred: false,
53}
54
55/**
56 * The same instruction, put in context at session and subagent start by the `SessionStart` and `SubagentStart`
57 * command hooks of `hooks/hooks.json`: Sonnet follows it far better there than from the tool description alone.
58 */
59export const STARTUP_CONTEXT =
60 'Status bar reporting (superpowers-statusbar plugin): whenever you follow superpowers test-driven-development, '
61 + 'including when you execute a plan yourself with superpowers:executing-plans and when you implement a task as a '
62 + 'subagent, call the mcp__superpowers-statusbar__report_phase tool each time you move into a TDD phase: red when '
63 + 'you start writing a failing test, green when you start writing the code that makes it pass, refactor when you '
64 + 'clean up with the tests green. It is a signal only; it changes nothing about how you work.'
65
66/** What a `SessionStart` or `SubagentStart` command hook prints on stdout. */
67export const startupHookOutput = (event: 'SessionStart' | 'SubagentStart'): string =>
68 JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: STARTUP_CONTEXT } })
69src/rebuild.ts 53 lines1import type { ObservedEvent, RunState } from '../types'
2import { skillOfCall } from './skill-call.ts'
3
4/** The part of a `SessionMessage` the rebuild reads; structural, so `src/` imports nothing from `'claude-code'`. */
5export type TranscriptMessage = {
6 role: 'user' | 'assistant'
7 text: string
8 toolUses: readonly {
9 tool: string
10 input: Record<string, unknown>
11 result?: unknown
12 text?: string
13 isError?: true
14 agentId?: string
15 }[]
16}
17
18/**
19 * The events a session transcript shows the detectors, in order, all at time 0 (unknown). A `Skill` call that
20 * did not fail also counts as that skill starting, placed before the call itself: the rule `hooks/normalize.ts`
21 * applies live. The transcript keeps no turn boundaries, so no `turn-end` is emitted.
22 */
23export function messagesToEvents(messages: readonly TranscriptMessage[]): ObservedEvent[] {
24 const events: ObservedEvent[] = []
25 for (const m of messages) {
26 for (const u of m.toolUses) {
27 const skill = skillOfCall(u, 0)
28 if (skill) events.push(skill)
29 events.push({
30 kind: 'tool',
31 tool: u.tool,
32 input: u.input,
33 ...(u.result !== undefined ? { result: u.result } : {}),
34 ...(u.text !== undefined ? { text: u.text } : {}),
35 ...(u.isError ? { isError: true } : {}),
36 ...(u.agentId ? { agentId: u.agentId } : {}),
37 at: 0,
38 })
39 }
40 }
41 return events
42}
43
44/**
45 * The run as a resume leaves it: no agent still running. Background agents finish through task notifications the
46 * transcript does not replay as turn ends, and none survives the session that launched it.
47 */
48export function stopAgents(s: RunState): RunState {
49 const running = Object.entries(s.agents).filter(([, r]) => r.isRunning)
50 if (running.length === 0) return s
51 return { ...s, agents: { ...s.agents, ...Object.fromEntries(running.map(([id, r]) => [id, { ...r, isRunning: false }])) } }
52}
53hooks/band.tsx 52 lines1// Follows the engine's two rules spelled out at the top of register.tsx: this hook is passed to `on` by reference,
2// and reads the run through an atom declared here.
3
4import { atom, read } from 'claude-code'
5import type { MatchedHook, TextProps } from 'claude-code'
6
7import { bandSegments } from '../src/view.ts'
8import type { Tone } from '../src/view.ts'
9import { canSpin } from './spin.ts'
10
11const runAtom = atom({ plugin: 'superpowers-statusbar', key: 'run' } as const, null)
12
13/** The theme's colours for each tone, so the band follows the person's theme. */
14export const TONES: Record<Tone, TextProps> = {
15 normal: {},
16 subtle: { color: 'subtle' },
17 ok: { color: 'success' },
18 active: { color: 'warning' },
19 wait: { backgroundColor: 'warning', color: 'inverseText' },
20}
21
22/** What the band last read of `e.viewport.isFullscreen`: whether this session's surface docks a pane. */
23let isDockingSeen: boolean | undefined
24
25/** True when the last band drawn was on a surface that docks a pane beside the transcript. */
26export const isDockingSurface = () => isDockingSeen === true
27
28/**
29 * Draws the run as one line above the prompt on the terminal and the desktop, sized to `e.props.bodyColumns`, its
30 * running mark a spinner (`./spinner.tsx`) where the surface animates one.
31 * Leaves the band to the engine without a run, while a survey holds it, and on the surfaces it does not draw on.
32 */
33export const renderBand: MatchedHook<'ui.render', { component: 'AbovePrompt' }> = async ($, e, next) => {
34 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
35 if (e.viewport?.isFullscreen !== undefined) isDockingSeen = e.viewport.isFullscreen
36 if (e.props.hasSurvey) return next(e)
37 const run = await read($, runAtom)
38 if (run === null) return next(e)
39 const columns = e.props.bodyColumns
40 const segments = bandSegments(run, await $.clock.now(), columns)
41 const table = $.ui.resolve(e)
42 const { Box, Text } = table
43 const Client = 'Client' in table && canSpin(e.surface) ? table.Client : undefined
44 return (
45 <Box flexDirection="row" width={columns} height={1}>
46 {segments.map(g => g.isSpinner && Client
47 ? <Client key="spinner" module="./spinner.tsx" props={null} width={1} />
48 : <Text {...TONES[g.tone]} {...(g.isBold ? { bold: true } : {})}>{g.text}</Text>)}
49 </Box>
50 )
51}
52hooks/normalize.ts 40 lines1import type { AgentSpawnInput, AgentSpawnResult, ToolCallInput, ToolCallResult } from 'claude-code'
2
3import type { ObservedEvent } from '../types'
4import { skillOfCall } from '../src/skill-call.ts'
5
6/** Keys `tool.call` carries beside the tool's own arguments. */
7const ENVELOPE: ReadonlySet<string> = new Set(['tool', 'tool_use_id', 'agentId', 'requestMeta', 'consent'])
8
9/** A finished tool call as the detectors read it: the tool's arguments as `input`, then how it ended. */
10export function toolEvent(e: ToolCallInput, ran: ToolCallResult, at: number): Extract<ObservedEvent, { kind: 'tool' }> {
11 const input = Object.fromEntries(Object.entries(e).filter(([k]) => !ENVELOPE.has(k)))
12 const outcome = ran.deny !== undefined
13 ? { text: ran.deny, isError: true, isDenied: true as const }
14 : {
15 ...(ran.result !== undefined ? { result: ran.result } : {}),
16 ...(ran.text !== undefined ? { text: ran.text } : {}),
17 ...(ran.isError ? { isError: true } : {}),
18 }
19 return { kind: 'tool', tool: e.tool, input, ...outcome, ...(e.agentId ? { agentId: e.agentId } : {}), at }
20}
21
22/**
23 * What a finished tool call shows the detectors: a `Skill` call that ran also counts as that skill starting,
24 * observed before the call itself: Claude Code's security plugin skips plugins' `skill.prompt` hooks, which would say it.
25 */
26export function callEvents(e: ToolCallInput, ran: ToolCallResult, at: number): ObservedEvent[] {
27 const call = toolEvent(e, ran, at)
28 const skill = skillOfCall(call, at)
29 return skill ? [skill, call] : [call]
30}
31
32/**
33 * A subagent the engine started, as the detectors read it. Nothing for a spawn that gave no `agentId` (refused or
34 * answered without starting one), nor for a workflow script's agent or a teammate: neither is a superpowers dispatch.
35 */
36export function spawnEvent(e: AgentSpawnInput, r: AgentSpawnResult, at: number): Extract<ObservedEvent, { kind: 'spawn' }> | undefined {
37 if (!r.agentId || e.workflow || e.isTeammate) return undefined
38 return { kind: 'spawn', agentId: r.agentId, description: e.description, prompt: e.prompt, ...(e.parentAgentId ? { parentAgentId: e.parentAgentId } : {}), at }
39}
40hooks/pane.tsx 104 lines1// Follows the engine's two rules spelled out at the top of register.tsx: this hook is passed to `on` by reference,
2// and reads the run through an atom declared here.
3
4import { atom, read } from 'claude-code'
5import type { MatchedHook, RenderChildren, TextProps } from 'claude-code'
6
7import type { Decision } from '../types'
8import { paneModel } from '../src/view.ts'
9import type { Mark } from '../src/view.ts'
10import { TONES } from './band.tsx'
11import { canSpin } from './spin.ts'
12
13/** The pane's id, for `$.ui.open`, `$.ui.close` and the `ui.render` matcher. */
14export const PANE_ID = 'sp-progress'
15export const PANE_TITLE = 'superpowers'
16
17const runAtom = atom({ plugin: 'superpowers-statusbar', key: 'run' } as const, null)
18
19const MARKS: Record<Mark, string> = { done: '✓', active: '●', todo: '○', skipped: '–' }
20/** A line's colour by where it stands: done, under way, not started (default text), skipped. */
21const STATUS_TONES: Record<Mark, TextProps> = { done: TONES.ok, active: TONES.active, todo: TONES.normal, skipped: TONES.subtle }
22const DECISION_LABELS: Record<Decision['kind'], string> = { ruling: 'Ruling', minor: 'minor', parked: 'parked' }
23/** Where a milestone sits under its stage's label: past the mark and its space. */
24const MILESTONE_INDENT = ' '
25
26const decisionTag = (d: Decision) => `${DECISION_LABELS[d.kind]}${d.task ? ` · Task ${d.task}` : ''} · `
27
28/**
29 * Draws the run in the pane, in five blocks (stages, tasks, current task, decisions, estimate), each left out
30 * when it has nothing to show; the task list fits the surface's height. One line per stage, task and phase of the
31 * current task: its mark and label in the colour of where it stands, details in `subtle`, a finished one's duration
32 * on the right edge; what is under way turns a spinner (`./spinner.tsx`) where the surface animates one, unless the
33 * run waits on the person.
34 */
35export const renderPane: MatchedHook<'ui.render', { component: 'Pane', requestId: 'sp-progress' }> = async ($, e) => {
36 const table = $.ui.resolve(e)
37 const { Box, Text } = table
38 const Client = 'Client' in table && canSpin(e.surface) ? table.Client : undefined
39 const run = await read($, runAtom)
40 if (run === null) return <Box><Text {...TONES.subtle}>No superpowers run yet.</Text></Box>
41 const columns = e.props.bodyColumns
42 const m = paneModel(run, await $.clock.now(), e.viewport?.rows ?? 24)
43
44 // While the run waits on the person nothing turns: what is under way keeps a still mark.
45 const mark = (status: Mark, spinnerKey: string) => status === 'active' && Client && !run.waiting
46 ? <Client key={spinnerKey} module="./spinner.tsx" props={null} width={1} />
47 : <Text {...STATUS_TONES[status]}>{MARKS[status]}</Text>
48 /** One line: mark, label, `details` in subtle after it, `right` (a duration) against the right edge. */
49 const line = (status: Mark, spinnerKey: string, label: string, details: string[], right?: string) => (
50 <Box flexDirection="row" width={columns} justifyContent="space-between">
51 <Box flexDirection="row" flexShrink={1}>
52 {mark(status, spinnerKey)}
53 <Text {...STATUS_TONES[status]} wrap="truncate-end">{` ${label}`}</Text>
54 {details.map(d => <Text {...TONES.subtle} wrap="truncate-end">{` · ${d}`}</Text>)}
55 </Box>
56 {right !== undefined && <Box flexShrink={0}><Text {...TONES.subtle}>{` ${right}`}</Text></Box>}
57 </Box>
58 )
59 const block = (title: string, lines: RenderChildren[]) => (
60 <Box flexDirection="column" marginTop={1}>
61 <Text bold>{title}</Text>
62 {lines}
63 </Box>
64 )
65
66 const stages = m.stages.flatMap(g => [
67 line(g.status, 'stage-spinner', g.label, g.detail ? [g.detail] : [], g.duration),
68 ...g.milestones.map(label => <Text {...TONES.subtle}>{`${MILESTONE_INDENT}${label}`}</Text>),
69 ])
70 const tasks = m.tasks.map(t =>
71 line(t.status, `task-spinner-${t.n}`, `${t.n}. ${t.title}`, t.fixRounds > 0 ? [`fix ${t.fixRounds}`] : [], t.duration))
72 if (m.hiddenTasks > 0) tasks.push(<Text {...TONES.subtle}>{`(+${m.hiddenTasks})`}</Text>)
73 const current = m.current && [
74 <Text>{`Task ${m.current.n} · ${m.current.title}`}</Text>,
75 ...m.current.trail.map(p => line(p.status, 'phase-spinner', p.label, p.verdict ? [p.verdict] : [])),
76 ]
77 const decisions = m.decisions.map(d => (
78 <Box flexDirection="row" width={columns}>
79 <Box flexShrink={0}><Text {...TONES.subtle}>{decisionTag(d)}</Text></Box>
80 <Text>{d.text}</Text>
81 </Box>
82 ))
83 // The latest decisions are shown: the earlier ones left out are counted above them.
84 if (m.hiddenDecisions > 0) decisions.unshift(<Text {...TONES.subtle}>{`(+${m.hiddenDecisions})`}</Text>)
85 const eta = m.eta && (
86 <Box flexDirection="row">
87 {m.eta.split(' · ').flatMap((part, i) => [...(i > 0 ? [<Text {...TONES.subtle}> · </Text>] : []), <Text>{part}</Text>])}
88 </Box>
89 )
90 return (
91 <Box flexDirection="column" width={columns}>
92 <Box flexDirection="row">
93 <Text bold>{m.title}</Text>
94 {m.elapsed && <Text {...TONES.subtle}>{` · ${m.elapsed}`}</Text>}
95 </Box>
96 {block('STAGES', stages)}
97 {tasks.length > 0 && block('TASKS', tasks)}
98 {current && block('CURRENT TASK', current)}
99 {decisions.length > 0 && block('DECISIONS & SET ASIDE', decisions)}
100 {eta && block('ESTIMATE', [eta])}
101 </Box>
102 )
103}
104hooks/runtime.ts 130 lines1import type { PluginOptions } from 'claude-code'
2
3import type { FileRequest, ObservedEvent, RunState, Stage, TraceEntry } from '../types'
4import { detect } from '../src/detectors/index.ts'
5import { reduce } from '../src/reduce.ts'
6import { notificationFor } from '../src/notify.ts'
7import { traceEntry } from '../src/trace.ts'
8
9/**
10 * What observing needs from the engine. The engine only lets `$` be passed to functions of the file that
11 * hooks it, so `register.tsx` builds this from `$` (`portOf`) and hands it here.
12 */
13export type EnginePort = {
14 /** The stored run (`read($, runAtom)`). */
15 read: () => Promise<RunState | null>
16 /** `update($, runAtom, change)`: `change` may run again on a version miss. */
17 update: (change: (s: RunState | null) => RunState | null) => Promise<unknown>
18 notify: (text: string, options: { title: string }) => Promise<unknown>
19 /** The trace file the environment names (`SP_STATUSBAR_TRACE`), if any. */
20 tracePath: () => Promise<string | undefined>
21 /** Rejects when the file is missing. */
22 readFile: (path: string) => Promise<string>
23 writeFile: (path: string, text: string) => Promise<void>
24 /** Told, once the step is written, that the run entered `stage`; never awaited. */
25 entered: (stage: Stage) => void
26}
27
28/** What one event did to the run: the state before and after, a trace entry per action, the files to read. */
29type Step = { prev: RunState | null; next: RunState | null; entries: TraceEntry[]; reads: FileRequest[] }
30
31function step(prev: RunState | null, e: ObservedEvent): Step {
32 const d = detect(e, prev)
33 let next = prev
34 const entries: TraceEntry[] = []
35 for (const a of d.actions) {
36 const before = next
37 next = reduce(next, a)
38 entries.push({ ...traceEntry(a, next, before), via: e.kind })
39 }
40 return { prev, next, entries, reads: d.reads }
41}
42
43/**
44 * Applies `e` to the run against its latest state. An event that changes nothing is settled on the value read;
45 * one that does goes through `update`, whose function may run again on a version miss: the step kept is the one
46 * computed from the value actually replaced.
47 */
48async function apply(port: EnginePort, e: ObservedEvent): Promise<Step> {
49 const seen = step(await port.read(), e)
50 if (seen.entries.length === 0) return seen
51 const written: { step?: Step } = {}
52 await port.update(s => (written.step = step(s, e)).next)
53 return written.step ?? seen
54}
55
56/** Trace lines of this copy of the module since the run began (the file is rewritten whole each time). */
57const traceLines: string[] = []
58let inFlight: Promise<void> | undefined
59let queued: Promise<void> | undefined
60
61/** Forgets the lines so far: a new run (after `/clear`) starts a new trace. */
62export function resetTrace(): void {
63 traceLines.length = 0
64}
65
66/**
67 * Writes every line so far. Writes land in order and coalesce: while one is in flight, the lines that come meanwhile
68 * wait for one more write, which carries them all.
69 */
70function writeTrace(port: EnginePort, path: string): Promise<void> {
71 if (queued) return queued
72 if (inFlight) {
73 queued = inFlight.catch(() => {}).then(() => {
74 queued = undefined
75 return writeTrace(port, path)
76 })
77 return queued
78 }
79 const write = (async () => port.writeFile(path, `${traceLines.join('\n')}\n`))()
80 inFlight = write.finally(() => { inFlight = undefined })
81 return inFlight
82}
83
84async function trace(port: EnginePort, entries: readonly TraceEntry[]) {
85 if (entries.length === 0) return
86 const path = await port.tracePath()
87 if (!path) return
88 traceLines.push(...entries.map(entry => JSON.stringify(entry)))
89 await writeTrace(port, path)
90}
91
92/** Swallows a side effect's failure: observing never fails the hook that observes. */
93const quietly = (work: Promise<unknown>) => work.then(() => {}, () => {})
94
95/**
96 * Feeds one observed event to the run: detection and reduction on the stored state, `port.entered` when the run
97 * changed stage, a notification when the run enters a wait worth one (and `options.notifications` is on), a trace
98 * line per action when `SP_STATUSBAR_TRACE` names a file, then each file the detectors asked for, read through the
99 * engine and fed back as a `file` event (a missing file is skipped). Never rejects.
100 */
101export async function observe(port: EnginePort, e: ObservedEvent, options: PluginOptions): Promise<void> {
102 let done: Step
103 try {
104 done = await apply(port, e)
105 } catch {
106 return
107 }
108 if (done.next && done.next.stage !== done.prev?.stage) {
109 try {
110 port.entered(done.next.stage)
111 } catch {}
112 }
113 // Started first, awaited last: a notification slow or throwing must not hold back the trace and the reads.
114 const text = notificationFor(done.prev, done.next)
115 const notified = text !== undefined && options.notifications === true
116 ? quietly((async () => port.notify(text, { title: 'superpowers' }))())
117 : undefined
118 await quietly(trace(port, done.entries))
119 for (const r of done.reads) {
120 let content: string
121 try {
122 content = await port.readFile(r.path)
123 } catch {
124 continue
125 }
126 await observe(port, { kind: 'file', role: r.role, path: r.path, text: content, at: e.at }, options)
127 }
128 await notified
129}
130hooks/spin.ts 25 lines1import type { RenderSurface } from 'claude-code'
2
3/** The surfaces where a spinner `Client` failed in this copy of the module (`ui.fault`). */
4const faulted = new Set<RenderSurface>()
5
6/** Records that a spinner failed on `surface`: the band and the pane draw a still `●` there from now on. */
7export function spinnerFailed(surface: RenderSurface): void {
8 faulted.add(surface)
9}
10
11/** Forgets every fault recorded: for tests, so one test's fault does not reach the next. */
12export function resetSpinnerFaults(): void {
13 faulted.clear()
14}
15
16/** Whether `surface` animates the spinner: terminal and desktop draw a `Client`, unless one failed there. */
17export function canSpin(surface: RenderSurface): surface is 'terminal' | 'desktop' {
18 return (surface === 'terminal' || surface === 'desktop') && !faulted.has(surface)
19}
20
21/** What `ui.fault` tells of a failed `Client`: only the spinner's own module fails the spinner. */
22export function onSpinnerFault(e: { surface: RenderSurface; module: string }): void {
23 if (e.module.endsWith('spinner.tsx')) spinnerFailed(e.surface)
24}
25src/skill-call.ts 15 lines1import type { ObservedEvent } from '../types'
2
3/**
4 * The skill starting that a finished `Skill` call shows, or nothing: a call that failed, or that names no skill,
5 * starts none. The call's `agentId` (a subagent made it) travels with the event.
6 */
7export function skillOfCall(
8 call: { tool: string; input: Record<string, unknown>; isError?: boolean; agentId?: string },
9 at: number,
10): Extract<ObservedEvent, { kind: 'skill' }> | undefined {
11 const skill = call.tool === 'Skill' && !call.isError ? call.input.skill : undefined
12 if (typeof skill !== 'string') return undefined
13 return { kind: 'skill', skill, ...(call.agentId ? { agentId: call.agentId } : {}), at }
14}
15src/view.ts 371 lines1import type { ReportedPhase, RunState, Stage, TaskEvent, TaskPhase, TaskState, Waiting } from '../types'
2import { currentTask } from './selectors.ts'
3import { waitText } from './notify.ts'
4
5/**
6 * What a piece of text means, which the render hooks turn into the theme's colours: `ok` done, `active` under way,
7 * `subtle` secondary (durations, separators, verdicts, tags), `wait` the highlighted waiting block.
8 */
9export type Tone = 'normal' | 'subtle' | 'ok' | 'active' | 'wait'
10/** `isSpinner`: the mark of something running, which a surface that can animates in place of its text. */
11export type Segment = { text: string; tone: Tone; isBold?: boolean; isSpinner?: boolean }
12
13/** Where a stage, task or task phase stands. */
14export type Mark = 'done' | 'active' | 'todo' | 'skipped'
15
16export type PaneModel = {
17 title: string
18 elapsed?: string
19 /** `duration` once the stage is finished and its span known; `milestones` under brainstorm and plan only. */
20 stages: { label: string; status: Mark; detail?: string; duration?: string; milestones: string[] }[]
21 tasks: { n: string; title: string; status: TaskState['status']; duration?: string; fixRounds: number }[]
22 hiddenTasks: number
23 /** The current task's phases passed (`done`), under way (`active`) and still expected (`todo`), in order. */
24 current?: { n: string; title: string; trail: { label: string; status: Exclude<Mark, 'skipped'>; verdict?: string }[] }
25 /** The latest decisions, as many as the rows left allow (at least MIN_DECISION_ROWS); `hiddenDecisions` earlier ones. */
26 decisions: RunState['decisions']
27 hiddenDecisions: number
28 eta?: string
29}
30
31const BAR_CELLS = 15
32const MAX_FIX_ROUNDS = 5
33const STAGES: readonly Stage[] = ['brainstorm', 'plan', 'exec', 'final-review', 'finishing', 'done']
34/** Rows the pane spends beside the task list, before milestones and the current task's trail. */
35const PANE_CHROME_ROWS = 14
36const MIN_TASK_ROWS = 3
37/** Rows the decisions block spends beside its lines: the gap above it and its title. */
38const DECISION_CHROME_ROWS = 2
39const MIN_DECISION_ROWS = 3
40
41const REPORTED_LABELS: Record<ReportedPhase, string> = {
42 conversation: 'conversation',
43 'design-writing': 'writing the spec',
44 'builder-check': 'builder check',
45 'awaiting-approval': 'spec awaiting approval',
46 drafting: 'drafting',
47 'self-review': 'plan self-review',
48 red: 'RED',
49 green: 'GREEN',
50 refactor: 'refactor',
51}
52
53// --- formatting ---
54
55export function formatDuration(ms: number): string {
56 const minutes = Math.max(0, Math.floor(ms / 60000))
57 if (minutes < 60) return `${minutes}m`
58 return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}`
59}
60
61export function formatClock(at: number): string {
62 const d = new Date(at)
63 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
64}
65
66export function phaseLabel(e: TaskEvent): string {
67 switch (e.phase) {
68 case 'brief': return 'brief'
69 case 'writing': return 'implementing'
70 case 'review': return 'review'
71 case 'fix': return `fix ${e.round ?? '?'}/${MAX_FIX_ROUNDS}`
72 case 're-review': return `re-review ${e.round ?? '?'}`
73 case 'red': return 'RED'
74 case 'green': return 'GREEN'
75 case 'refactor': return 'refactor'
76 case 'verify': return 'verify'
77 case 'done': return 'done'
78 case 'unknown': return '?'
79 }
80}
81
82export function stageLabel(s: RunState): string {
83 switch (s.stage) {
84 case 'brainstorm': return 'brainstorming'
85 case 'plan': return 'plan'
86 case 'exec': return s.mode === 'sdd' ? 'exec SDD' : s.mode === 'direct' ? 'exec inline' : 'exec'
87 case 'final-review': return s.finalReview?.phase === 'fixes' ? 'final review · fixes' : 'final review'
88 case 'finishing': return 'finishing'
89 case 'done': return 'done'
90 }
91}
92
93const planSlug = (planPath: string) =>
94 (planPath.split('/').pop() ?? '').replace(/\.md$/, '').replace(/^\d{4}-\d{2}-\d{2}-/, '')
95
96// --- estimate ---
97
98export function estimate(s: RunState, now: number): { avgMs: number; remainingMs: number; finishAt: number } | undefined {
99 const spans = s.tasks.flatMap(t =>
100 t.status === 'done' && t.startedAt && t.doneAt ? [t.doneAt - t.startedAt] : [])
101 const remaining = s.tasks.filter(t => t.status !== 'done').length
102 if (!s.isPlanLoaded || spans.length === 0 || remaining === 0) return undefined
103 const avgMs = spans.reduce((a, b) => a + b, 0) / spans.length
104 const remainingMs = avgMs * remaining
105 return { avgMs, remainingMs, finishAt: now + remainingMs }
106}
107
108// --- band ---
109
110const sep = (text: string): Segment => ({ text, tone: 'subtle' })
111/** The band's lead while the run works. */
112const RUNNING: Segment = { text: '●', tone: 'active', isSpinner: true }
113const elapsedOf = (s: RunState, now: number) => (s.startedAt ? formatDuration(now - s.startedAt) : undefined)
114
115function doneBand(s: RunState, now: number): Segment[] {
116 const fixRounds = s.tasks.reduce((n, t) => n + t.fixRounds, 0)
117 const aside = s.decisions.filter(d => d.kind === 'parked' || d.kind === 'minor').length
118 const end = s.finishedAt ?? now
119 const parts = [`${s.tasks.length} tasks`, `${fixRounds} fix rounds`, `${aside} set aside`]
120 const elapsed = s.startedAt ? formatDuration(end - s.startedAt) : undefined
121 const tail: Segment[] = elapsed ? [sep(' · '), { text: elapsed, tone: 'subtle' }] : []
122 return [{ text: '✓ done', tone: 'ok', isBold: true }, ...parts.flatMap(p => [sep(' · '), { text: p, tone: 'normal' as const }]), ...tail]
123}
124
125type Parts = { isElapsed: boolean; isTitle: boolean; isBar: boolean }
126
127function counterOf(s: RunState): number {
128 const done = s.tasks.filter(t => t.status === 'done').length
129 return Math.min(s.tasks.length, done + (currentTask(s) ? 1 : 0))
130}
131
132/** How many tasks the run has: `?` until a plan was read, the tasks then being only those already started. */
133const totalOf = (s: RunState) => (s.isPlanLoaded ? String(s.tasks.length) : '?')
134
135function execBand(s: RunState, now: number, parts: Parts): Segment[] {
136 const total = s.tasks.length
137 const done = s.tasks.filter(t => t.status === 'done').length
138 const cur = currentTask(s)
139 const filled = total ? Math.floor((done / total) * BAR_CELLS) : 0
140 const counter = counterOf(s)
141
142 const head: Segment[] = [
143 RUNNING, sep(' '),
144 { text: stageLabel(s), tone: 'normal' }, sep(' '),
145 ]
146 const bar: Segment[] = !parts.isBar || !s.isPlanLoaded ? [] : [
147 { text: '▰'.repeat(filled), tone: 'ok' },
148 { text: '▱'.repeat(BAR_CELLS - filled), tone: 'normal' },
149 sep(' '),
150 ]
151 const tail: Segment[] = [{ text: `${counter}/${totalOf(s)}`, tone: 'normal', isBold: true }]
152 const more: Segment[][] = []
153 if (cur) {
154 const last = cur.trail[cur.trail.length - 1]
155 more.push([
156 { text: `Task ${cur.n}`, tone: 'normal' },
157 ...(parts.isTitle ? [{ text: `: ${cur.title}`, tone: 'normal' as const }] : []),
158 ])
159 more.push([{ text: last ? phaseLabel(last) : '?', tone: 'normal' }])
160 if (cur.fixRounds > 0 && last?.phase !== 'fix') {
161 more.push([{ text: `fix ${cur.fixRounds}/${MAX_FIX_ROUNDS}`, tone: 'normal' }])
162 }
163 }
164 const elapsed = parts.isElapsed ? elapsedOf(s, now) : undefined
165 if (elapsed) more.push([{ text: elapsed, tone: 'subtle' }])
166 return [...head, ...bar, ...tail, ...more.flatMap(m => [sep(' · '), ...m])]
167}
168
169/** The band's wording of a reported phase: the pane's, but the plan's self-review reads `self-review` there. */
170const BAND_LABELS: Record<ReportedPhase, string> = { ...REPORTED_LABELS, 'self-review': 'self-review' }
171
172const length = (segs: Segment[]) => segs.reduce((n, g) => n + g.text.length, 0)
173
174/** The part of the band after the stage: the plan's task count then the reported phase, brainstorm's phase. */
175function phaseOf(s: RunState): string | undefined {
176 const parts = [
177 ...(s.stage === 'plan' && s.tasks.length > 0 ? [`${s.tasks.length} tasks`] : []),
178 ...(s.stagePhase ? [BAND_LABELS[s.stagePhase]] : []),
179 ]
180 return parts.length > 0 ? parts.join(' · ') : undefined
181}
182
183/** Stage, phase, elapsed time; the elapsed time goes first, then the phase, when the line is longer than `columns`. */
184function simpleBand(s: RunState, now: number, columns: number): Segment[] {
185 const phase = phaseOf(s)
186 const elapsed = s.stage !== 'brainstorm' && s.stage !== 'plan' ? elapsedOf(s, now) : undefined
187 let band: Segment[] = []
188 for (const parts of [{ isPhase: true, isElapsed: true }, { isPhase: true, isElapsed: false }, { isPhase: false, isElapsed: false }]) {
189 band = [RUNNING, sep(' '), { text: stageLabel(s), tone: 'normal' }]
190 if (phase && parts.isPhase) band.push(sep(' · '), { text: phase, tone: 'normal' })
191 if (elapsed && parts.isElapsed) band.push(sep(' · '), { text: elapsed, tone: 'subtle' })
192 if (length(band) <= columns) break
193 }
194 return band
195}
196
197/** Drops, in order: elapsed time, task title, bar. Marker, stage and counter always stay. */
198function fitExecBand(s: RunState, now: number, columns: number): Segment[] {
199 const attempts: Parts[] = [
200 { isElapsed: true, isTitle: true, isBar: true },
201 { isElapsed: false, isTitle: true, isBar: true },
202 { isElapsed: false, isTitle: false, isBar: true },
203 { isElapsed: false, isTitle: false, isBar: false },
204 ]
205 let band: Segment[] = []
206 for (const parts of attempts) {
207 band = execBand(s, now, parts)
208 if (length(band) <= columns) break
209 }
210 return band
211}
212
213/** `segs` cut to `columns` cells. */
214function cutTo(segs: Segment[], columns: number): Segment[] {
215 let left = Math.max(0, columns)
216 return segs.flatMap(g => {
217 const text = g.text.slice(0, left)
218 left -= text.length
219 return text ? [{ ...g, text }] : []
220 })
221}
222
223/** The waiting block's text around the reason: ` WAITING · <reason> `, all of it highlighted. */
224const WAIT_BADGE = 'WAITING'
225const WAIT_CHROME = ` ${WAIT_BADGE} · `.length
226
227/**
228 * One highlighted block, ` WAITING · <reason> `, its spaces included, the reason cut with `…` to fit; in exec, the
229 * counter follows outside the block when the whole reason and the counter fit.
230 */
231function waitingBand(s: RunState, w: Waiting, columns: number): Segment[] {
232 const message = waitText(s, w)
233 const room = columns - WAIT_CHROME
234 if (room < 1) return cutTo([
235 { text: ' ', tone: 'wait' }, { text: WAIT_BADGE, tone: 'wait', isBold: true }, { text: ` · ${message} `, tone: 'wait' },
236 ], columns)
237 const shown = message.length <= room ? message : `${message.slice(0, room - 1)}…`
238 const out: Segment[] = [
239 { text: ' ', tone: 'wait' },
240 { text: WAIT_BADGE, tone: 'wait', isBold: true },
241 { text: ` · ${shown} `, tone: 'wait' },
242 ]
243 const counter = `${counterOf(s)}/${totalOf(s)}`
244 if (s.stage === 'exec' && shown === message && WAIT_CHROME + message.length + 3 + counter.length <= columns) {
245 out.push(sep(' · '), { text: counter, tone: 'normal', isBold: true })
246 }
247 return out
248}
249
250/** Every band is cut to `columns` as a last resort, after its own drop order, so none is ever wider. */
251export function bandSegments(s: RunState, now: number, columns: number): Segment[] {
252 return cutTo(bandOf(s, now, columns), columns)
253}
254
255function bandOf(s: RunState, now: number, columns: number): Segment[] {
256 if (s.stage === 'done') return doneBand(s, now)
257 if (s.waiting) return waitingBand(s, s.waiting, columns)
258 return s.stage === 'exec' ? fitExecBand(s, now, columns) : simpleBand(s, now, columns)
259}
260
261// --- pane ---
262
263/** The phases a task is expected to go through, by mode; fix and re-review rounds are shown as they happen. */
264const SDD_PHASES: readonly TaskPhase[] = ['brief', 'writing', 'review', 'done']
265const INLINE_PHASES: readonly TaskPhase[] = ['brief', 'red', 'green', 'refactor', 'verify', 'done']
266/** Where a phase outside the SDD sequence stands in it: TDD steps inside implementing, fix rounds inside review. */
267const SDD_PLACE: Partial<Record<TaskPhase, TaskPhase>> = { red: 'writing', green: 'writing', refactor: 'writing', fix: 'review', 're-review': 'review' }
268const MILESTONE_STAGES: ReadonlySet<Stage> = new Set<Stage>(['brainstorm', 'plan'])
269
270/** The tasks shown: `taskRows` of them (at least MIN_TASK_ROWS) around `centre`. */
271function visibleWindow(total: number, taskRows: number, centre: number): { start: number; end: number } {
272 const size = Math.min(total, Math.max(MIN_TASK_ROWS, taskRows))
273 const start = Math.max(0, Math.min(total - size, centre - Math.floor(size / 2)))
274 return { start, end: start + size }
275}
276
277/** How long `stage` lasted: from its start to the start of the next stage the run entered; unknown when either is. */
278function stageDuration(s: RunState, i: number): string | undefined {
279 const starts = s.stageStartedAt ?? {}
280 const from = starts[STAGES[i]!]
281 const to = STAGES.slice(i + 1).map(stage => starts[stage]).find(at => at !== undefined)
282 return from && to ? formatDuration(to - from) : undefined
283}
284
285function stagesOf(s: RunState): PaneModel['stages'] {
286 const cur = STAGES.indexOf(s.stage)
287 const done = s.tasks.filter(t => t.status === 'done').length
288 return STAGES.map((stage, i) => {
289 const label = stageLabel({ ...s, stage })
290 const milestones = MILESTONE_STAGES.has(stage) ? s.milestones.filter(m => m.stage === stage).map(m => m.label) : []
291 if (i > cur) return { label, status: 'todo' as const, milestones }
292 if (stage === 'done' || (i === cur && s.stage === 'done')) return { label, status: 'done' as const, milestones }
293 if (i === cur) {
294 const detail = stage === 'exec' ? `${done}/${totalOf(s)}`
295 : s.stagePhase ? REPORTED_LABELS[s.stagePhase] : undefined
296 return { label, status: 'active' as const, milestones, ...(detail ? { detail } : {}) }
297 }
298 // A stage behind the current one that the run never entered was bypassed, whatever its position: no final review
299 // happened when the run went from exec straight to finishing.
300 const isEntered = s.milestones.some(m => m.stage === stage) || s.stageStartedAt?.[stage] !== undefined
301 if (!isEntered) return { label, status: 'skipped' as const, milestones }
302 const duration = stageDuration(s, i)
303 return { label, status: 'done' as const, milestones, ...(duration ? { duration } : {}) }
304 })
305}
306
307/** The phases still expected after `trail`, by the furthest point of the mode's sequence it reached. */
308function expectedAfter(trail: readonly TaskEvent[], mode: RunState['mode']): TaskPhase[] {
309 const sequence = mode === 'direct' ? INLINE_PHASES : SDD_PHASES
310 const place = (phase: TaskPhase) => sequence.indexOf(mode === 'direct' ? phase : SDD_PLACE[phase] ?? phase)
311 const reached = trail.reduce((furthest, e) => Math.max(furthest, place(e.phase)), -1)
312 return sequence.slice(reached + 1)
313}
314
315function currentOf(s: RunState, t: TaskState): NonNullable<PaneModel['current']> {
316 const last = t.trail.length - 1
317 const passed = t.trail.map((e, i) => ({
318 label: phaseLabel(e), status: i === last ? 'active' as const : 'done' as const, ...(e.verdict ? { verdict: e.verdict } : {}),
319 }))
320 const expected = expectedAfter(t.trail, s.mode).map(phase => ({ label: phaseLabel({ phase, at: 0 }), status: 'todo' as const }))
321 return { n: t.n, title: t.title, trail: [...passed, ...expected] }
322}
323
324/**
325 * The rows the decisions block takes at least: its title, MIN_DECISION_ROWS lines, and the `(+N)` line when there are
326 * more; none without decisions.
327 */
328function decisionReserve(count: number): number {
329 if (count === 0) return 0
330 return DECISION_CHROME_ROWS + Math.min(count, MIN_DECISION_ROWS) + (count > MIN_DECISION_ROWS ? 1 : 0)
331}
332
333/** How many of `count` decisions the `room` rows left show, keeping one for `(+N)` when some stay hidden. */
334function shownDecisions(count: number, room: number): number {
335 if (count <= Math.max(MIN_DECISION_ROWS, room)) return count
336 return Math.max(MIN_DECISION_ROWS, room - 1)
337}
338
339/**
340 * The pane's content for `rows` rows: the task list folds around the current task in the rows left once the
341 * decisions keep their least; the decisions then show their latest in what the tasks leave, so the estimate below
342 * stays in view.
343 */
344export function paneModel(s: RunState, now: number, rows: number): PaneModel {
345 const cur = currentTask(s)
346 const current = cur ? currentOf(s, cur) : undefined
347 const stages = stagesOf(s)
348 const curIndex = cur ? s.tasks.indexOf(cur) : Math.max(0, s.tasks.findIndex(t => t.status !== 'done'))
349 const extraRows = stages.reduce((n, g) => n + g.milestones.length, 0) + (current?.trail.length ?? 0)
350 const free = rows - PANE_CHROME_ROWS - extraRows
351 const { start, end } = visibleWindow(s.tasks.length, free - decisionReserve(s.decisions.length), curIndex)
352 const decisionRoom = free - (end - start) - (s.decisions.length > 0 ? DECISION_CHROME_ROWS : 0)
353 const decisionCount = shownDecisions(s.decisions.length, decisionRoom)
354 const est = estimate(s, now)
355 const elapsed = elapsedOf(s, now)
356 return {
357 title: s.planPath ? `superpowers · ${planSlug(s.planPath)}` : 'superpowers',
358 ...(elapsed ? { elapsed } : {}),
359 stages,
360 tasks: s.tasks.slice(start, end).map(t => ({
361 n: t.n, title: t.title, status: t.status, fixRounds: t.fixRounds,
362 ...(t.status === 'done' && t.startedAt && t.doneAt ? { duration: formatDuration(t.doneAt - t.startedAt) } : {}),
363 })),
364 hiddenTasks: s.tasks.length - (end - start),
365 ...(current ? { current } : {}),
366 decisions: s.decisions.slice(s.decisions.length - decisionCount),
367 hiddenDecisions: s.decisions.length - decisionCount,
368 ...(est ? { eta: `avg ${formatDuration(est.avgMs)}/task · ~${formatDuration(est.remainingMs)} left · ETA ${formatClock(est.finishAt)}` } : {}),
369 }
370}
371hooks/spinner.tsx 24 lines1// A surface module, drawn by the band's and the pane's `Client`s: it runs on the surface's own frame clock, so the
2// spinner turns without the plugin's tree being drawn again.
3
4import type { ClientModule } from 'claude-code'
5
6const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
7const FRAME_MS = 100
8
9/** A braille spinner in the theme's `warning` colour, one cell wide; its state is the frame shown. */
10const Spinner: ClientModule<null, number> = (_props, surface) => {
11 if (surface.state === undefined) {
12 let frame = 0
13 surface.every(FRAME_MS, () => {
14 frame = (frame + 1) % FRAMES.length
15 surface.setState(frame)
16 })
17 surface.setState(frame)
18 }
19 const { Text } = surface.elements
20 return <Text color="warning">{FRAMES[surface.state ?? 0]}</Text>
21}
22
23export default Spinner
24src/detectors/index.ts 35 lines1import type { ObservedEvent, RunState } from '../../types'
2import { reduce } from '../reduce.ts'
3import { detect } from './sp-7.0.0.ts'
4
5export { detect, MARKERS } from './sp-7.0.0.ts'
6export type { Marker } from './sp-7.0.0.ts'
7
8/**
9 * Rebuilds a run from observed events. Each event's file reads are served from `files`
10 * right after it, as `file` events stamped with its time; paths missing from `files` are skipped.
11 * An array lists the contents successive reads of that path see; its last entry repeats.
12 */
13export function replay(events: ObservedEvent[], files: Record<string, string | readonly string[]> = {}): RunState | null {
14 let s: RunState | null = null
15 const readCounts = new Map<string, number>()
16 const contentOf = (path: string): string | undefined => {
17 if (!Object.hasOwn(files, path)) return undefined
18 const entry = files[path]!
19 if (typeof entry === 'string') return entry
20 const k = readCounts.get(path) ?? 0
21 readCounts.set(path, k + 1)
22 return entry[Math.min(k, entry.length - 1)]
23 }
24 const apply = (e: ObservedEvent) => {
25 const d = detect(e, s)
26 for (const a of d.actions) s = reduce(s, a)
27 for (const r of d.reads) {
28 const text = contentOf(r.path)
29 if (text !== undefined) apply({ kind: 'file', role: r.role, path: r.path, text, at: e.at })
30 }
31 }
32 for (const e of events) apply(e)
33 return s
34}
35