Agenti, pipeline Superpowers, piano e sessioni in un pannello fisso

Mod di Claude Code che tiene un pannello fisso con tutto quello che sta succedendo nella sessione: agenti, pipeline Superpowers, piano e altre sessioni. Sola osservazione: non modifica mai gli eventi (unica eccezione, il suffisso dello spinner).
Svg (in Desktop) dei subagent lanciati: ● Explore mentre lavora, ✓ a fine lavoro. Lo spinner mostra · N agenti./clear.Spec: <file>, Piano 7/12, Fase 4/6). Si possono fissare a mano con /overclock pin.⚠ se due sessioni scrivono lo stesso file entro 5 minuti.I tre casi di sessione, mostrati con colore e simbolo (leggibile anche senza colori):
| Caso | Quando | Banda |
|---|---|---|
| A | stesso repo, worktree diversi | verde ⎇ |
| B | repo diversi | blu ▣ |
| C | stesso repo e stesso branch, più sessioni vive | arancione ⇄ (ha la precedenza su A) |
Il pannello si apre da solo solo con terminale di almeno 144 colonne.
Al prompt di una sessione nel terminale:
/plugin install overclock --marketplace rh363/overclock
Rispondi y per aggiungere il marketplace, poi scegli lo scope (user è il primo). Da shell:
claude plugin install overclock --marketplace rh363/overclock
Nell'app desktop, dove /plugin non funziona: clicca + accanto al box del prompt, poi Plugins, Add marketplace con rh363/overclock, poi Add plugin. Un mod installato dal terminale a livello user si carica anche nelle sessioni della tab Code.
Terminale:
claude --plugin-dir /percorso/di/overclock
Desktop: imposta CLAUDE_CODE_PLUGIN_DIRS nel blocco env di ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/percorso/di/overclock" } }
/overclock senza argomenti apre il pannello se è chiuso e lo chiude se è aperto.
| Comando | Effetto | |||
|---|---|---|---|---|
/overclock | apre/chiude il pannello | |||
| `/overclock agents\ | pipeline\ | plan\ | sessions` | apre il pannello su quella tab |
/overclock pin spec <path> | fissa la spec (path relativo al worktree) | |||
/overclock pin plan <path> | fissa il piano | |||
/overclock unpin | torna al rilevamento automatico | |||
/overclock phase <0-6> | imposta la fase a mano | |||
/overclock reset | azzera la pipeline |
Usa: $.fs.read/list/exists, $.store, $.state, $.ui, $.command, $.prompt.fill, $.session, $.agent.list, $.clock. Non usa: $.http, $.process, $.env, $.model, $.fs.write, tool.check.
La mappa skill → fase (o badge) sta in src/core/phases.ts (DEFAULT_PHASE_MAP), ad esempio:
'superpowers:brainstorming': { phase: 1 },
'superpowers:test-driven-development': { badge: 'TDD' },
In alternativa, senza toccare il codice, l'opzione phaseMapPath (file JSON, path assoluto o relativo alla cartella della sessione) si unisce alla mappa predefinita: le sue voci si aggiungono a quelle predefinite e, a parità di chiave, le sostituiscono. Stesso formato: { "plugin:skill": { "phase": 1-6 } | { "badge": "TDD" | "Debug" | "Verifica" } }.
skill.prompt non dice da quale agente arriva: Overclock la riconosce dalla chiamata Skill del subagente in corso). Una skill precaricata nel subagente dal suo frontmatter non passa dal tool Skill e conta ancora come skill della sessione.claude plugin validate .
claude plugin test .
npx -y -p typescript@5.6.3 tsc -p .
Da fare a mano (terminale largo almeno 144 colonne):
claude --plugin-dir <path>, chiedi di lanciare un subagent Explore. Atteso: il pannello si apre da solo, il grafo mostra ● Explore che poi diventa ✓; la banda mostra ▣ overclock (o ⎇) e Spec: 2026-10-19-overclock-design.md; lo spinner mostra · 1 agente.git worktree add ../overclock-wt -b test/oc, apri una seconda sessione lì. Atteso: entro 15 secondi la banda diventa verde ⎇ e la tab Sessioni (4) mostra due righe sotto overclock. Alla fine git worktree remove ../overclock-wt && git branch -D test/oc./clear. Nella prima sessione lancia /clear. Atteso: la tab Pipeline mostra ancora la fase di prima.CLAUDE_CODE_PLUGIN_DIRS al blocco env di ~/.claude/settings.json, apri una sessione nella tab Code. Atteso: la tab Agenti disegna il grafo Svg.hooks/register.tsx 151 lines1import type { EngineInterface, Register } from 'claude-code'
2import {
3 carryOverOnClear, configure, dispatchAgent, endSession, guardRender, handleCommand, initSession, logError, maybeAutoOpen, onPaneClosed,
4 inSubagentSkill, onSessionResumed, onSkill, onToolCall, onWrite, refreshGit, takeClearedId, writtenPath,
5} from '../src/app/runtime'
6import { renderBand, renderSpinner } from '../src/ui/band'
7import { renderPane } from '../src/ui/pane'
8
9const GIT_MOVE = /\bgit\s+(checkout|switch|worktree)\b/
10
11// Overclock only observes: a failure of its own must never reach the event chain.
12async function safe($: EngineInterface, work: () => Promise<void>): Promise<void> {
13 try {
14 await work()
15 } catch (err) {
16 logError($, err)
17 }
18}
19
20// Events that can gate (deny, block): if a hook of ours ever fails, the event goes on as if
21// we were not there. `next` is replay-safe inside a catch.
22const passThrough = <E, R>(_$: unknown, e: E, next: (e: E) => R): R => next(e)
23
24export const register: Register = (on, options) => {
25 configure(options)
26
27 on('session.start', async ($, e, next) => {
28 const r = await next(e)
29 await safe($, async () => {
30 await $.command.register({
31 name: 'overclock',
32 description: 'Pannello di Overclock: agenti, pipeline, piano, sessioni',
33 argumentHint: '[agents|pipeline|plan|sessions|pin|unpin|phase|reset]',
34 immediate: true,
35 })
36 await initSession($)
37 })
38 return r
39 })
40
41 on('agent.spawn', async ($, e, next) => {
42 const r = await next(e)
43 const id = r.agentId
44 if (id) {
45 await safe($, async () => {
46 const at = await $.clock.now()
47 await dispatchAgent(
48 $,
49 e.resume
50 ? { kind: 'agent.resumed', id, at }
51 : {
52 kind: 'agent.started', id, at, parentId: e.parentAgentId, toolUseId: e.tool_use_id,
53 type: e.subagentType, description: e.description, background: e.background,
54 },
55 )
56 await maybeAutoOpen($)
57 })
58 }
59 return r
60 }).catch(passThrough)
61
62 on('turn.complete', async ($, e, next) => {
63 const r = await next(e)
64 await safe($, async () => {
65 const usage = e.usage
66 if (e.agentId) {
67 await dispatchAgent($, { kind: 'agent.ended', id: e.agentId, reason: e.reason, usage, at: await $.clock.now() })
68 } else {
69 await dispatchAgent($, { kind: 'main.turn', usage })
70 }
71 })
72 return r
73 })
74
75 on('turn.start', async ($, e, next) => {
76 const r = await next(e)
77 if (!e.agentId) await safe($, () => refreshGit($))
78 return r
79 })
80
81 on('tool.call', async ($, e, next) => {
82 const r = e.tool === 'Skill' && e.agentId && typeof e.skill === 'string'
83 ? await inSubagentSkill(e.skill, () => next(e))
84 : await next(e)
85 await safe($, async () => {
86 await onToolCall($, e.agentId, e.tool)
87 const ok = r.deny === undefined && !r.isError
88 const written = ok ? writtenPath(e) : undefined
89 if (written !== undefined) await onWrite($, written)
90 const moved =
91 (e.tool === 'Bash' && typeof e.command === 'string' && GIT_MOVE.test(e.command)) ||
92 e.tool === 'EnterWorktree' ||
93 e.tool === 'ExitWorktree'
94 // A subagent's cwd is not the session's: only the main session moves branch or worktree.
95 if (moved && !e.agentId) await refreshGit($)
96 })
97 return r
98 }).catch(passThrough)
99
100 on('skill.prompt', async ($, e, next) => {
101 const r = await next(e)
102 // onSkill opens the pane itself when the phase changes.
103 await safe($, () => onSkill($, e.skill, e.pluginName))
104 return r
105 })
106
107 on('session.end', async ($, e, next) => {
108 await safe($, () => endSession($, e.sessionId, e.reason))
109 return next(e)
110 })
111
112 on('classic.SessionStart', async ($, e, next) => {
113 const r = await next(e)
114 // An event since the /clear may already have done the carry-over (see ensureInit).
115 if (e.source === 'clear') {
116 const oldId = takeClearedId()
117 if (oldId !== undefined) await safe($, () => carryOverOnClear($, oldId))
118 } else if (e.source !== 'startup') {
119 await safe($, () => onSessionResumed($))
120 }
121 return r
122 }).catch(passThrough)
123
124 on('command.run', { command: 'overclock' }, async ($, e) => handleCommand($, e.args))
125
126 // Observe-only: keeps paneOpen true to the pane's actual state.
127 on('ui.close', async ($, e, next) => {
128 await safe($, () => onPaneClosed($, e.id))
129 return next(e)
130 }).catch(passThrough)
131
132 // A render failure of ours falls back to the engine's own band and spinner (see guardRender).
133 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => guardRender($, e, next, (n) => renderBand($, e, n)))
134
135 on('ui.render', { component: 'Spinner' }, async ($, e, next) => guardRender($, e, next, (n) => renderSpinner($, e, n)))
136
137 on('ui.render', { component: 'Pane', requestId: 'overclock' }, async ($, e, next) => {
138 try {
139 return await renderPane($, e)
140 } catch (err) {
141 logError($, err)
142 try {
143 const { Text } = $.ui.resolve(e)
144 return <Text>Overclock: errore di disegno</Text>
145 } catch {
146 return next(e)
147 }
148 }
149 })
150}
151src/app/runtime.ts 479 lines1// Inbound adapter: turns engine events into core reductions, then publishes the result to
2// $.state (what the UI reads) and $.store (what other sessions read).
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, PluginOptions, Timer } from 'claude-code'
5import { reduceAgents, emptyTree } from '../core/agents'
6import { parseCommand } from '../core/commands'
7import type { Tab } from '../core/commands'
8import { DEFAULT_PHASE_MAP, PHASE_NAMES, parsePhaseMap, skillKey } from '../core/phases'
9import { applySkill, emptyPipeline, isEmptyPipeline, resetPipeline, setPhase } from '../core/pipeline'
10import { PLAN_DIR, SPEC_DIR, classifyDocPath, countCheckboxes, pickLatest } from '../core/plan'
11import { isLive, pruneWrites } from '../core/sessions'
12import { basename, joinPath, relativeTo } from '../core/paths'
13import type { AgentEvent, BadgeName, PhaseMap, PipelineState, SessionEntry } from '../core/types'
14import type { FilePort } from '../ports'
15import { storePort } from '../adapters/store'
16import { filePort, resolveGitInfo } from '../adapters/files'
17
18// One atom per key of PluginState['overclock']. The host's module scan only accepts state
19// sources declared in the calling file, so every module that reads state declares its own.
20const agentsAtom = atom({ plugin: 'overclock', key: 'agents' } as const, emptyTree())
21const pipelineAtom = atom({ plugin: 'overclock', key: 'pipeline' } as const, null)
22const selfAtom = atom({ plugin: 'overclock', key: 'self' } as const, null)
23const othersAtom = atom({ plugin: 'overclock', key: 'others' } as const, [])
24const planTextAtom = atom({ plugin: 'overclock', key: 'planText' } as const, '')
25const autoOpenedAtom = atom({ plugin: 'overclock', key: 'autoOpened' } as const, false)
26const toastShownAtom = atom({ plugin: 'overclock', key: 'toastShown' } as const, false)
27const paneOpenAtom = atom({ plugin: 'overclock', key: 'paneOpen' } as const, false)
28const tabAtom = atom({ plugin: 'overclock', key: 'tab' } as const, 'agents')
29
30export const HEARTBEAT_MS = 15_000
31const PANE = { id: 'overclock', title: 'Overclock' }
32const TOAST = 'Overclock: /overclock per aprire il pannello'
33// AgentInfo statuses of a loop that has ended.
34const ENDED = new Set(['completed', 'failed', 'killed'])
35
36// Module variables hold only what may be lost on hot reload: options, caches, the timer.
37let phaseMapPath = ''
38let phaseMap: PhaseMap | undefined
39let timer: Timer | undefined
40// Id of the session a /clear just ended, until its pipeline is carried over.
41let clearedId: string | undefined
42// Id of the last session that ended (any reason): a later init under another id is a new
43// conversation, whose agents start from empty.
44let endedId: string | undefined
45// Skill tool calls in flight in a subagent's loop, by the name the call gave: skill.prompt
46// carries no agentId, but it fires while the Skill call that expands it is in flight.
47const subagentSkills = new Map<string, number>()
48
49/** Runs a subagent's Skill call, marking its skill as a subagent's while it is in flight. */
50export async function inSubagentSkill<R>(skill: string, call: () => Promise<R>): Promise<R> {
51 subagentSkills.set(skill, (subagentSkills.get(skill) ?? 0) + 1)
52 try {
53 return await call()
54 } finally {
55 const n = (subagentSkills.get(skill) ?? 1) - 1
56 if (n > 0) subagentSkills.set(skill, n)
57 else subagentSkills.delete(skill)
58 }
59}
60
61/** The id a /clear ended, if its carry-over is still pending; clears it. */
62export function takeClearedId(): string | undefined {
63 const id = clearedId
64 clearedId = undefined
65 return id
66}
67
68/** Called by register() on every (re)load with the plugin's options. */
69export function configure(options: PluginOptions): void {
70 const p = options.phaseMapPath
71 phaseMapPath = typeof p === 'string' ? p.trim() : ''
72 phaseMap = undefined
73}
74
75async function loadPhaseMap($: EngineInterface): Promise<PhaseMap> {
76 if (phaseMap) return phaseMap
77 if (phaseMapPath === '') return (phaseMap = DEFAULT_PHASE_MAP)
78 const path = phaseMapPath.startsWith('/') ? phaseMapPath : joinPath(await $.session.cwd(), phaseMapPath)
79 const text = await filePort($).read(path)
80 const parsed = text === undefined ? null : parsePhaseMap(text)
81 if (parsed === null) {
82 $.ui.log(`overclock: mappa fasi non valida in ${path}, uso quella predefinita`)
83 return (phaseMap = DEFAULT_PHASE_MAP)
84 }
85 return (phaseMap = { ...DEFAULT_PHASE_MAP, ...parsed })
86}
87
88function summary(p: PipelineState | null) {
89 const badges: BadgeName[] = []
90 for (const b of p?.current.badges ?? []) if (!badges.includes(b.name)) badges.push(b.name)
91 return { phase: p?.current.phase ?? 0, badges, planDone: p?.plan.done ?? 0, planTotal: p?.plan.total ?? 0 }
92}
93
94/** Recomputes self from the atoms (plus `change`), stamps the heartbeat and persists it. */
95async function publishSelf(
96 $: EngineInterface,
97 change: (s: SessionEntry) => SessionEntry = (s) => s,
98 opts: { activity?: boolean } = {},
99): Promise<void> {
100 const now = await $.clock.now()
101 const tree = await read($, agentsAtom)
102 const p = await read($, pipelineAtom)
103 const self = await update($, selfAtom, (cur) => {
104 if (!cur) return cur
105 const s = change(cur)
106 return {
107 ...s,
108 ...summary(p),
109 agents: tree.nodes,
110 recentWrites: pruneWrites(s.recentWrites, now),
111 lastActivity: opts.activity ? now : s.lastActivity,
112 heartbeat: now,
113 }
114 })
115 if (self) await storePort($).putSession(self)
116}
117
118/** An empty pipeline is not persisted, unless it replaces one already stored. */
119async function savePipeline($: EngineInterface, p: PipelineState): Promise<void> {
120 await update($, pipelineAtom, () => p)
121 const id = await $.session.id()
122 const sp = storePort($)
123 if (isEmptyPipeline(p) && (await sp.getPipeline(id)) === undefined) return
124 await sp.putPipeline(id, p)
125}
126
127const absolute = (root: string, path: string) => (path.startsWith('/') ? path : joinPath(root, path))
128
129/** Reads the plan file into planText and its checkbox counts into the PlanRef. */
130async function loadPlan($: EngineInterface, fp: FilePort, root: string, p: PipelineState): Promise<PipelineState> {
131 const text = p.plan.planPath ? await fp.read(absolute(root, p.plan.planPath)) : undefined
132 await update($, planTextAtom, () => text ?? '')
133 if (text === undefined) return p
134 const { done, total } = countCheckboxes(text)
135 return { ...p, plan: { ...p.plan, done, total } }
136}
137
138/** Latest spec and plan under `root`, unless pinned; then the plan's progress. */
139async function detectDocs($: EngineInterface, fp: FilePort, root: string, p: PipelineState): Promise<PipelineState> {
140 const plan = { ...p.plan }
141 if (!plan.pinned.spec) {
142 const name = pickLatest(await fp.list(joinPath(root, SPEC_DIR)))
143 if (name) plan.specPath = `${SPEC_DIR}/${name}`
144 }
145 if (!plan.pinned.plan) {
146 const name = pickLatest(await fp.list(joinPath(root, PLAN_DIR)))
147 if (name) plan.planPath = `${PLAN_DIR}/${name}`
148 }
149 return loadPlan($, fp, root, { ...p, plan })
150}
151
152/** Reports a failure of Overclock's own work without letting it reach the event chain. */
153export function logError($: EngineInterface, err: unknown): void {
154 try {
155 $.ui.log(`overclock: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
156 } catch {
157 // nothing left to report to
158 }
159}
160
161/**
162 * Self for the current session id. Re-initialises when self is missing or belongs to another
163 * id (a /clear whose classic.SessionStart never came), carrying the pipeline over first.
164 */
165async function ensureInit($: EngineInterface): Promise<SessionEntry> {
166 const id = await $.session.id()
167 const self = await read($, selfAtom)
168 if (self && self.sessionId === id) return self
169 const oldId = takeClearedId()
170 if (oldId !== undefined && oldId !== id) await carryOverOnClear($, oldId)
171 else {
172 const prevId = self?.sessionId ?? endedId
173 if (prevId !== undefined && prevId !== id) await update($, agentsAtom, () => emptyTree())
174 await initSession($)
175 }
176 return (await read($, selfAtom))!
177}
178
179function startHeartbeat($: EngineInterface): void {
180 timer?.cancel()
181 timer = $.clock.every(HEARTBEAT_MS, () => {
182 void heartbeat($).catch((err: unknown) => logError($, err))
183 })
184}
185
186export function stopHeartbeat(): void {
187 timer?.cancel()
188 timer = undefined
189}
190
191export async function initSession($: EngineInterface): Promise<void> {
192 const id = await $.session.id()
193 const cwd = await $.session.cwd()
194 const now = await $.clock.now()
195 const fp = filePort($)
196 const git = await resolveGitInfo(fp, cwd)
197 const root = git?.worktreeRoot ?? cwd
198 await loadPhaseMap($)
199 const sp = storePort($)
200 await sp.prunePipelines(now, id)
201
202 let p = (await sp.getPipeline(id)) ?? emptyPipeline(now)
203 p = await detectDocs($, fp, root, p)
204 await savePipeline($, p)
205
206 const self: SessionEntry = {
207 v: 1, sessionId: id,
208 repoRoot: git?.repoRoot ?? null,
209 repoName: basename(git?.repoRoot ?? root),
210 worktreeRoot: root,
211 branch: git?.branch ?? null,
212 agents: [], recentWrites: [], ...summary(p),
213 lastActivity: now, heartbeat: now,
214 }
215 await update($, selfAtom, () => self)
216 await heartbeat($)
217 startHeartbeat($)
218}
219
220export async function dispatchAgent($: EngineInterface, ev: AgentEvent): Promise<void> {
221 await ensureInit($)
222 await update($, agentsAtom, (t) => reduceAgents(t, ev))
223 await publishSelf($, undefined, { activity: ev.kind !== 'agents.reconcile' })
224}
225
226/**
227 * tool.call: in memory only (the subagent's last tool, self's lastActivity); the heartbeat
228 * publishes it. Keeps the store off the tool's return path.
229 */
230export async function onToolCall($: EngineInterface, agentId: string | undefined, tool: string): Promise<void> {
231 await ensureInit($)
232 const now = await $.clock.now()
233 if (agentId) await update($, agentsAtom, (t) => reduceAgents(t, { kind: 'agent.tool', id: agentId, tool }))
234 await update($, selfAtom, (s) => (s ? { ...s, lastActivity: now } : s))
235}
236
237export async function onSkill($: EngineInterface, skill: string, pluginName?: string): Promise<void> {
238 await ensureInit($)
239 const now = await $.clock.now()
240 const map = await loadPhaseMap($)
241 const key = skillKey(skill, pluginName)
242 // A subagent's skill (requesting-code-review inside subagent-driven) is not the session's
243 // phase: only its badge counts.
244 const entry = Object.hasOwn(map, key) ? map[key] : undefined
245 if (entry && 'phase' in entry && (subagentSkills.has(key) || subagentSkills.has(skill))) return
246 const before = (await read($, pipelineAtom)) ?? emptyPipeline(now)
247 const after = applySkill(before, key, map, now)
248 if (after === before) return
249 await savePipeline($, after)
250 await publishSelf($, undefined, { activity: true })
251 if (after.current.phase !== before.current.phase) await maybeAutoOpen($)
252}
253
254// Tools that write a file, by the argument naming it. MultiEdit is not in every build's tool
255// table, so the name is matched as a string.
256const WRITE_ARG: Record<string, string> = { Write: 'file_path', Edit: 'file_path', MultiEdit: 'file_path', NotebookEdit: 'notebook_path' }
257
258/** The file a tool call writes, if it is a write tool's call with that path given. */
259export function writtenPath(call: { tool: string } & Record<string, unknown>): string | undefined {
260 const arg = Object.hasOwn(WRITE_ARG, call.tool) ? WRITE_ARG[call.tool] : undefined
261 const path = arg === undefined ? undefined : call[arg]
262 return typeof path === 'string' ? path : undefined
263}
264
265export async function onWrite($: EngineInterface, absPath: string): Promise<void> {
266 const self = await ensureInit($)
267 const now = await $.clock.now()
268 const root = self.worktreeRoot
269 const rel = relativeTo(root, absPath)
270 const path = rel ?? absPath
271 const kind = rel === null ? null : classifyDocPath(rel)
272 const p = await read($, pipelineAtom)
273 if (kind && p) {
274 const plan = { ...p.plan }
275 if (kind === 'spec' && !plan.pinned.spec) plan.specPath = path
276 if (kind === 'plan' && !plan.pinned.plan) plan.planPath = path
277 let next: PipelineState = { ...p, plan, updatedAt: now }
278 if (kind === 'plan' && plan.planPath === path) next = await loadPlan($, filePort($), root, next)
279 await savePipeline($, next)
280 }
281 await publishSelf(
282 $,
283 (s) => ({ ...s, recentWrites: [...s.recentWrites.filter((w) => w.path !== path), { path, at: now }] }),
284 { activity: true },
285 )
286}
287
288export async function refreshGit($: EngineInterface): Promise<void> {
289 const self = await ensureInit($)
290 const cwd = await $.session.cwd()
291 const fp = filePort($)
292 const git = await resolveGitInfo(fp, cwd)
293 const root = git?.worktreeRoot ?? cwd
294 const repoRoot = git?.repoRoot ?? null
295 const branch = git?.branch ?? null
296 if (root === self.worktreeRoot && repoRoot === self.repoRoot && branch === self.branch) return
297 if (root !== self.worktreeRoot) {
298 const p = await read($, pipelineAtom)
299 if (p) await savePipeline($, await detectDocs($, fp, root, p))
300 }
301 await publishSelf($, (s) => ({ ...s, repoRoot, repoName: basename(repoRoot ?? root), worktreeRoot: root, branch }))
302}
303
304export async function heartbeat($: EngineInterface): Promise<void> {
305 const self = await ensureInit($)
306 const now = await $.clock.now()
307 const live = await $.agent.list()
308 const alive = live.filter((a) => !ENDED.has(a.status))
309 const liveIds = alive.map((a) => a.id)
310 const teammateIds = alive.filter((a) => a.teammateId !== undefined).map((a) => a.id)
311 await update($, agentsAtom, (t) => reduceAgents(t, { kind: 'agents.reconcile', liveIds, teammateIds, at: now }))
312 const all = await storePort($).listSessions(now)
313 const others = all.filter((e) => e.sessionId !== self.sessionId && isLive(e, now))
314 await update($, othersAtom, () => others)
315 await publishSelf($)
316}
317
318export async function maybeAutoOpen($: EngineInterface): Promise<void> {
319 if (await read($, autoOpenedAtom)) return
320 await update($, autoOpenedAtom, () => true)
321 const r = await $.ui.open(PANE)
322 if (r.isPlaced) await update($, paneOpenAtom, () => true)
323 if (!r.isPlaced && !(await read($, toastShownAtom))) {
324 $.ui.toast(TOAST)
325 await update($, toastShownAtom, () => true)
326 }
327}
328
329/**
330 * session.end: drop this session's entry, stop the heartbeat that would rewrite it and forget
331 * self, so the next event re-initialises. After /clear, remember the id for the carry-over.
332 */
333export async function endSession($: EngineInterface, id: string, reason: string): Promise<void> {
334 stopHeartbeat()
335 endedId = id
336 if (reason === 'clear') clearedId = id
337 await update($, selfAtom, () => null)
338 await storePort($).deleteSession(id)
339}
340
341/** After /clear: move the pipeline to the new session id, reset agents, re-init. */
342export async function carryOverOnClear($: EngineInterface, oldId: string): Promise<void> {
343 const newId = await $.session.id()
344 const sp = storePort($)
345 if (newId !== oldId) {
346 const p = await sp.getPipeline(oldId)
347 if (p) await sp.putPipeline(newId, p)
348 await sp.deletePipeline(oldId)
349 }
350 await update($, agentsAtom, () => emptyTree())
351 await update($, selfAtom, () => null)
352 await initSession($)
353}
354
355/**
356 * classic.SessionStart other than startup or clear (a /resume in the same process): when
357 * session.end left self empty, start the new conversation from empty agents.
358 */
359export async function onSessionResumed($: EngineInterface): Promise<void> {
360 if (await read($, selfAtom)) return
361 await update($, agentsAtom, () => emptyTree())
362 await initSession($)
363}
364
365/** ui.close of our pane (by Esc, the close mark or /overclock): remembers it is closed. */
366export async function onPaneClosed($: EngineInterface, id: string): Promise<void> {
367 if (id === PANE.id) await update($, paneOpenAtom, () => false)
368}
369
370async function openPane($: EngineInterface): Promise<boolean> {
371 const r = await $.ui.open({ ...PANE, focus: true })
372 if (r.isPlaced) await update($, paneOpenAtom, () => true)
373 return r.isPlaced
374}
375
376const PIN_LABEL = { spec: 'Spec', plan: 'Piano' } as const
377
378async function pinDoc($: EngineInterface, target: 'spec' | 'plan', path: string): Promise<string> {
379 const self = await ensureInit($)
380 const root = self.worktreeRoot
381 const rel = path.startsWith('/') ? relativeTo(root, path) : path.replace(/^\.\//, '')
382 if (rel === null || rel === '' || rel.split('/').includes('..') || !(await filePort($).exists(joinPath(root, rel)))) {
383 return `File non trovato: ${path}`
384 }
385 const p = (await read($, pipelineAtom)) ?? emptyPipeline(await $.clock.now())
386 const plan = { ...p.plan, pinned: { ...p.plan.pinned, [target]: true } }
387 if (target === 'spec') plan.specPath = rel
388 else plan.planPath = rel
389 const next = await loadPlan($, filePort($), root, { ...p, plan, updatedAt: await $.clock.now() })
390 await savePipeline($, next)
391 await publishSelf($)
392 return `${PIN_LABEL[target]} fissato: ${rel}`
393}
394
395async function runCommand($: EngineInterface, args: string): Promise<string> {
396 const cmd = parseCommand(args)
397 switch (cmd.kind) {
398 case 'error':
399 return cmd.message
400 case 'toggle':
401 if (await read($, paneOpenAtom)) {
402 await $.ui.close(PANE)
403 await update($, paneOpenAtom, () => false)
404 return 'Pannello chiuso.'
405 }
406 await openPane($)
407 return 'Pannello aperto.'
408 case 'open':
409 await update($, tabAtom, () => cmd.tab as Tab)
410 await openPane($)
411 return `Pannello aperto su ${cmd.tab}.`
412 case 'pin':
413 return pinDoc($, cmd.target, cmd.path)
414 case 'unpin': {
415 const self = await ensureInit($)
416 const p = (await read($, pipelineAtom)) ?? emptyPipeline(await $.clock.now())
417 const cleared = { ...p, plan: { ...p.plan, pinned: { spec: false, plan: false } } }
418 await savePipeline($, await detectDocs($, filePort($), self.worktreeRoot, cleared))
419 await publishSelf($)
420 return 'Rilevamento automatico ripristinato.'
421 }
422 case 'phase': {
423 await ensureInit($)
424 const now = await $.clock.now()
425 const p = (await read($, pipelineAtom)) ?? emptyPipeline(now)
426 await savePipeline($, setPhase(p, cmd.phase, now))
427 await publishSelf($, undefined, { activity: true })
428 return `Fase impostata a ${cmd.phase} (${cmd.phase === 0 ? 'nessuna fase' : PHASE_NAMES[cmd.phase - 1]}).`
429 }
430 case 'reset': {
431 await ensureInit($)
432 const now = await $.clock.now()
433 const p = (await read($, pipelineAtom)) ?? emptyPipeline(now)
434 await savePipeline($, resetPipeline(p, now))
435 await publishSelf($, undefined, { activity: true })
436 return 'Pipeline azzerata.'
437 }
438 }
439}
440
441/** /overclock: never throws, so a failure cannot break the command chain. */
442export async function handleCommand($: EngineInterface, args: string): Promise<{ text: string }> {
443 try {
444 return { text: await runCommand($, args) }
445 } catch (err) {
446 logError($, err)
447 return { text: 'Overclock: errore nel comando' }
448 }
449}
450
451// The render gets `next` only: the host does not let `$` be passed to a function parameter.
452type Render<E, R> = (next: (e: E) => Promise<R>) => Promise<R>
453
454/**
455 * Runs a render of ours that calls `next` at most once. A failure of ours falls back to what
456 * the engine drew (`next` is not called twice); a failure of `next` itself is not ours to hide.
457 */
458export async function guardRender<E, R>($: EngineInterface, e: E, next: (e: E) => Promise<R>, render: Render<E, R>): Promise<R> {
459 let below: { value: R } | undefined
460 let nextFailed = false
461 const once = async (x: E): Promise<R> => {
462 try {
463 const value = await next(x)
464 below = { value }
465 return value
466 } catch (err) {
467 nextFailed = true
468 throw err
469 }
470 }
471 try {
472 return await render(once)
473 } catch (err) {
474 if (nextFailed) throw err
475 logError($, err)
476 return below ? below.value : next(e)
477 }
478}
479src/ui/band.tsx 42 lines1// Band above the prompt and spinner suffix. Both read state through atoms declared here:
2// the host only accepts state sources declared in the calling file.
3import { atom, read } from 'claude-code'
4import type { EngineInterface } from 'claude-code'
5import { emptyTree } from '../core/agents'
6import { PHASE_NAMES } from '../core/phases'
7import { ORANGE as WARNING_COLOR, bandView, caseDetail, spinnerSuffix } from './view-model'
8
9const agentsAtom = atom({ plugin: 'overclock', key: 'agents' } as const, emptyTree())
10const pipelineAtom = atom({ plugin: 'overclock', key: 'pipeline' } as const, null)
11const selfAtom = atom({ plugin: 'overclock', key: 'self' } as const, null)
12const othersAtom = atom({ plugin: 'overclock', key: 'others' } as const, [])
13
14export async function renderBand($: EngineInterface, e: any, next: (e: any) => any): Promise<any> {
15 if (e.props.hasSurvey) return next(e)
16 const [self, others, pipeline] = await Promise.all([read($, selfAtom), read($, othersAtom), read($, pipelineAtom)])
17 const below = await next(e)
18 if (!self) return below
19 const v = bandView({ self, others, pipeline }, await $.clock.now())
20 const detail = caseDetail(self, others)
21 const { Box, Text } = $.ui.resolve(e)
22 const segments = [
23 <Box key="case"><Text color={v.case.color} bold>{v.case.label}</Text></Box>,
24 detail !== '' && <Box key="detail"><Text dimColor>{detail}</Text></Box>,
25 v.specName !== undefined && <Box key="spec"><Text dimColor>{`Spec: ${v.specName}`}</Text></Box>,
26 v.planName !== undefined && <Box key="plan"><Text>{`Piano ${v.planDone}/${v.planTotal} (${v.planPercent}%)`}</Text></Box>,
27 v.phase > 0 && <Box key="phase"><Text>{`Fase ${v.phase}/${PHASE_NAMES.length}`}</Text></Box>,
28 v.warning !== undefined && <Box key="warning"><Text color={WARNING_COLOR}>{v.warning}</Text></Box>,
29 ].filter((x) => x !== false)
30 return (
31 <Box flexDirection="column">
32 {below}
33 <Box>{segments.flatMap((seg, i) => (i === 0 ? [seg] : [<Text key={`sep${i}`} dimColor>{' · '}</Text>, seg]))}</Box>
34 </Box>
35 )
36}
37
38export async function renderSpinner($: EngineInterface, e: any, next: (e: any) => any): Promise<any> {
39 const suffix = spinnerSuffix(await read($, agentsAtom), e.props.suffix)
40 return next(suffix === undefined ? e : { ...e, props: { ...e.props, suffix } })
41}
42src/ui/pane.tsx 140 lines1// The Overclock pane (errors propagate: the hook in register.tsx logs them and draws the fallback): four tabs over the agents, the pipeline, the plan and the sessions.
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface } from 'claude-code'
4import { logError } from '../app/runtime'
5import { emptyTree } from '../core/agents'
6import type { Tab } from '../core/commands'
7import { PHASE_NAMES } from '../core/phases'
8import type { SessionEntry } from '../core/types'
9import { GraphText } from './graph-text'
10import { agentGraphSvg } from './graph-svg'
11import { SessionsCards } from './sessions-cards'
12import { SessionsText } from './sessions-text'
13import { agentsView, caseStyle, pipelineView, sessionsView } from './view-model'
14
15// Atoms are declared here: the host refuses state sources declared in another file.
16const tabAtom = atom({ plugin: 'overclock', key: 'tab' } as const, 'agents')
17const agentsAtom = atom({ plugin: 'overclock', key: 'agents' } as const, emptyTree())
18const pipelineAtom = atom({ plugin: 'overclock', key: 'pipeline' } as const, null)
19const selfAtom = atom({ plugin: 'overclock', key: 'self' } as const, null)
20const othersAtom = atom({ plugin: 'overclock', key: 'others' } as const, [])
21const planTextAtom = atom({ plugin: 'overclock', key: 'planText' } as const, '')
22
23const TABS: { tab: Tab; key: string; label: string; hotkey: string }[] = [
24 { tab: 'agents', key: 'tab-agents', label: 'Agenti', hotkey: '1' },
25 { tab: 'pipeline', key: 'tab-pipeline', label: 'Pipeline', hotkey: '2' },
26 { tab: 'plan', key: 'tab-plan', label: 'Piano', hotkey: '3' },
27 { tab: 'sessions', key: 'tab-sessions', label: 'Sessioni', hotkey: '4' },
28]
29const PLAN_DIR_HINT = 'Nessun piano trovato in docs/superpowers/plans/'
30const PIPE_SYMBOL = { done: '✓', current: '●', todo: '○' } as const
31
32const hhmm = (at: number): string => {
33 const d = new Date(at)
34 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
35}
36
37function sessionLine(s: SessionEntry, self: SessionEntry | null): string {
38 const style = caseStyle(s, self ? [self] : [])
39 const symbol = style.symbol !== '' ? style.symbol : '▣'
40 const phase = s.phase > 0 ? `${PHASE_NAMES[s.phase - 1]} ${s.phase}/${PHASE_NAMES.length}` : `— ${s.phase}/${PHASE_NAMES.length}`
41 const running = s.agents.filter((a) => a.status === 'running').length
42 return `${symbol} ${s.branch ?? s.repoName} ${phase} ${running} ${running === 1 ? 'agente' : 'agenti'}`
43}
44
45export async function renderPane($: EngineInterface, e: any): Promise<any> {
46 const [tab, tree, pipeline, self, others, planText, now] = await Promise.all([
47 read($, tabAtom), read($, agentsAtom), read($, pipelineAtom), read($, selfAtom), read($, othersAtom),
48 read($, planTextAtom), $.clock.now(),
49 ])
50 const ui = $.ui.resolve(e) as any
51 const { Box, Text, Button, Markdown, Svg } = ui
52 // resolve() hands out an Svg stub even on the terminal, so the surface decides too.
53 const hasSvg = e.surface !== 'terminal' && 'Svg' in ui
54
55 const header = (
56 <Box key="tabs">
57 {TABS.map((t) => (
58 <Button
59 key={t.key}
60 label={t.label}
61 hotkey={t.hotkey}
62 dimColor={tab !== t.tab}
63 onPress={() => update($, tabAtom, () => t.tab)}
64 />
65 ))}
66 </Box>
67 )
68
69 let body: any
70 if (tab === 'pipeline') {
71 const v = pipelineView(pipeline ?? emptyPipelineLike())
72 body = (
73 <Box flexDirection="column">
74 {v.phases.map((p) => (
75 <Box key={`phase-${p.n}`}>
76 <Text dimColor={p.state === 'todo'} bold={p.state === 'current'}>
77 {`${PIPE_SYMBOL[p.state]} ${p.name}${p.enteredAt !== undefined ? ` ${hhmm(p.enteredAt)}` : ''}${p.skill ? ` ${p.skill}` : ''}`}
78 </Text>
79 </Box>
80 ))}
81 {v.badges.length > 0 && <Text>{v.badges.map((b) => `[${b}]`).join(' ')}</Text>}
82 {v.previousCycles > 0 && (
83 <Text dimColor>{`${v.previousCycles} ${v.previousCycles === 1 ? 'ciclo precedente' : 'cicli precedenti'}`}</Text>
84 )}
85 </Box>
86 )
87 } else if (tab === 'plan') {
88 const planPath = pipeline?.plan.planPath
89 body =
90 planPath === undefined || planText === '' ? (
91 <Text dimColor>{PLAN_DIR_HINT}</Text>
92 ) : (
93 <Box flexDirection="column">
94 <Box key="open-plan-row">
95 <Button
96 key="open-plan"
97 label="Apri"
98 onPress={() => void $.prompt.fill({ text: `@${planPath} `, mode: 'insert' }).catch((err: unknown) => logError($, err))}
99 />
100 </Box>
101 <Markdown text={planText} />
102 </Box>
103 )
104 } else if (tab === 'sessions') {
105 if (self === null) {
106 body = <Text dimColor>Nessuna sessione attiva</Text>
107 } else {
108 // The own agents come from the live tree, not from the last published entry.
109 const view = sessionsView({ ...(self as SessionEntry), agents: tree.nodes }, others as SessionEntry[], now, tree.mainTokens)
110 body = hasSvg ? SessionsCards({ view, now, Box, Text }) : SessionsText({ view, now, Box, Text })
111 }
112 } else {
113 const nodes = agentsView(tree, now)
114 const lines = (others as SessionEntry[]).map((s) => sessionLine(s, self))
115 body = (
116 <Box flexDirection="column">
117 {hasSvg ? <Svg source={agentGraphSvg(nodes, 560)} alt="Grafo degli agenti" /> : GraphText({ nodes, Box, Text })}
118 {lines.length > 0 ? (
119 <Box key="others" flexDirection="column">
120 {lines.map((l, i) => <Box key={`o${i}`}><Text>{l}</Text></Box>)}
121 </Box>
122 ) : null}
123 </Box>
124 )
125 }
126 return (
127 <Box flexDirection="column">
128 {header}
129 {body}
130 </Box>
131 )
132}
133
134function emptyPipelineLike() {
135 return {
136 current: { phase: 0 as const, history: [], badges: [] }, cycles: [],
137 plan: { pinned: { spec: false, plan: false }, done: 0, total: 0 }, updatedAt: 0,
138 }
139}
140src/core/agents.ts 94 lines1import type { AgentEvent, AgentNode, AgentStatus, AgentTree, Tokens, TurnReason, Usage } from './types'
2
3export const MAX_DONE_AGENTS = 50
4
5export function emptyTree(): AgentTree {
6 return { nodes: [], mainTokens: { input: 0, output: 0 } }
7}
8
9export function activeCount(tree: AgentTree): number {
10 return tree.nodes.filter((n) => n.status === 'running').length
11}
12
13const STATUS_BY_REASON: Record<TurnReason, AgentStatus> = {
14 answer: 'done',
15 error: 'failed',
16 refusal: 'failed',
17 aborted: 'aborted',
18}
19
20// input_tokens is only the uncached part: the cache reads and writes count as input too.
21function addUsage(t: Tokens, u?: Usage): Tokens {
22 if (!u) return t
23 const input = u.input_tokens + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
24 return { input: t.input + input, output: t.output + u.output_tokens }
25}
26
27// Keep every running node plus the MAX_DONE_AGENTS most recently ended ones,
28// preserving the original order.
29function prune(nodes: AgentNode[]): AgentNode[] {
30 const finished = nodes.filter((n) => n.status !== 'running')
31 if (finished.length <= MAX_DONE_AGENTS) return nodes
32 const keep = new Set(
33 finished
34 .map((n, i) => ({ n, i }))
35 .sort((a, b) => (b.n.endedAt ?? 0) - (a.n.endedAt ?? 0) || b.i - a.i)
36 .slice(0, MAX_DONE_AGENTS)
37 .map((x) => x.n),
38 )
39 return nodes.filter((n) => n.status === 'running' || keep.has(n))
40}
41
42function patch(tree: AgentTree, id: string, fn: (n: AgentNode) => AgentNode): AgentTree {
43 if (!tree.nodes.some((n) => n.id === id)) return tree
44 return { ...tree, nodes: tree.nodes.map((n) => (n.id === id ? fn(n) : n)) }
45}
46
47export function reduceAgents(tree: AgentTree, ev: AgentEvent): AgentTree {
48 switch (ev.kind) {
49 case 'agent.started': {
50 const node: AgentNode = {
51 id: ev.id,
52 parentId: ev.parentId ?? 'main',
53 toolUseId: ev.toolUseId,
54 type: ev.type,
55 description: ev.description,
56 background: ev.background,
57 status: 'running',
58 startedAt: ev.at,
59 tokens: { input: 0, output: 0 },
60 }
61 const exists = tree.nodes.some((n) => n.id === ev.id)
62 return { ...tree, nodes: exists ? tree.nodes.map((n) => (n.id === ev.id ? node : n)) : [...tree.nodes, node] }
63 }
64 case 'agent.resumed':
65 return patch(tree, ev.id, (n) => ({ ...n, status: 'running', endedAt: undefined }))
66 case 'agent.ended': {
67 const next = patch(tree, ev.id, (n) => ({
68 ...n,
69 status: STATUS_BY_REASON[ev.reason],
70 endedAt: ev.at,
71 tokens: addUsage(n.tokens, ev.usage),
72 }))
73 return next === tree ? tree : { ...next, nodes: prune(next.nodes) }
74 }
75 case 'agent.tool':
76 return patch(tree, ev.id, (n) => ({ ...n, lastTool: ev.tool }))
77 case 'main.turn':
78 return { ...tree, mainTokens: addUsage(tree.mainTokens, ev.usage) }
79 case 'agents.reconcile': {
80 // A running node the list no longer has is done; an ended teammate the roster still
81 // lists (idle between turns) is alive again.
82 const live = new Set(ev.liveIds)
83 const mates = new Set(ev.teammateIds ?? [])
84 const ended = (n: AgentNode) => n.status === 'running' && !live.has(n.id)
85 const revived = (n: AgentNode) => n.status !== 'running' && mates.has(n.id)
86 if (!tree.nodes.some((n) => ended(n) || revived(n))) return tree
87 const nodes = tree.nodes.map((n): AgentNode =>
88 ended(n) ? { ...n, status: 'done', endedAt: ev.at } : revived(n) ? { ...n, status: 'running', endedAt: undefined } : n,
89 )
90 return { ...tree, nodes: prune(nodes) }
91 }
92 }
93}
94src/core/commands.ts 31 lines1import type { Phase } from './types'
2
3export type Tab = 'agents' | 'pipeline' | 'plan' | 'sessions'
4export type OverclockCommand =
5 | { kind: 'toggle' } | { kind: 'open'; tab: Tab }
6 | { kind: 'pin'; target: 'spec' | 'plan'; path: string } | { kind: 'unpin' }
7 | { kind: 'phase'; phase: Phase } | { kind: 'reset' } | { kind: 'error'; message: string }
8
9const USAGE = 'Uso: /overclock [agents|pipeline|plan|sessions|pin spec|plan <path>|unpin|phase <0-6>|reset]'
10const TABS: Tab[] = ['agents', 'pipeline', 'plan', 'sessions']
11const fail = (): OverclockCommand => ({ kind: 'error', message: USAGE })
12
13// Parses the argument string of /overclock.
14export function parseCommand(args: string): OverclockCommand {
15 const s = args.trim()
16 if (s === '') return { kind: 'toggle' }
17 if ((TABS as string[]).includes(s)) return { kind: 'open', tab: s as Tab }
18 if (s === 'unpin') return { kind: 'unpin' }
19 if (s === 'reset') return { kind: 'reset' }
20 const m = /^(\S+)\s+(.+)$/.exec(s)
21 if (!m) return fail()
22 const head = m[1]!
23 const rest = m[2]!
24 if (head === 'pin') {
25 const p = /^(spec|plan)\s+(.+)$/.exec(rest)
26 return p ? { kind: 'pin', target: p[1]! as 'spec' | 'plan', path: p[2]!.trim() } : fail()
27 }
28 if (head === 'phase' && /^[0-6]$/.test(rest)) return { kind: 'phase', phase: Number(rest) as Phase }
29 return fail()
30}
31src/core/phases.ts 50 lines1import type { BadgeName, Phase, PhaseMap } from './types'
2
3// Index 0 is phase 1.
4export const PHASE_NAMES: readonly string[] = ['Brainstorming', 'Worktree', 'Piano', 'Esecuzione', 'Code review', 'Chiusura branch']
5
6export const DEFAULT_PHASE_MAP: PhaseMap = {
7 'superpowers:brainstorming': { phase: 1 },
8 'superpowers:using-git-worktrees': { phase: 2 },
9 'alex-dev-skills:git-worktrees': { phase: 2 },
10 'superpowers:writing-plans': { phase: 3 },
11 'superpowers:subagent-driven-development': { phase: 4 },
12 'superpowers:executing-plans': { phase: 4 },
13 'superpowers:requesting-code-review': { phase: 5 },
14 'superpowers:finishing-a-development-branch': { phase: 6 },
15 'superpowers:test-driven-development': { badge: 'TDD' },
16 'alex-dev-skills:tdd-prove-it': { badge: 'TDD' },
17 'superpowers:systematic-debugging': { badge: 'Debug' },
18 'alex-dev-skills:systematic-debugging': { badge: 'Debug' },
19 'superpowers:verification-before-completion': { badge: 'Verifica' },
20}
21
22export function skillKey(skill: string, pluginName?: string): string {
23 if (skill.includes(':')) return skill
24 return pluginName ? `${pluginName}:${skill}` : skill
25}
26
27const BADGES: readonly string[] = ['TDD', 'Debug', 'Verifica'] satisfies BadgeName[]
28
29// An own data property even for '__proto__', which plain assignment would turn into the prototype.
30function set(map: PhaseMap, key: string, v: PhaseMap[string]): void {
31 Object.defineProperty(map, key, { value: v, enumerable: true, writable: true, configurable: true })
32}
33
34export function parsePhaseMap(json: string): PhaseMap | null {
35 let raw: unknown
36 try { raw = JSON.parse(json) } catch { return null }
37 if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null
38 const out: PhaseMap = {}
39 for (const [key, v] of Object.entries(raw)) {
40 if (typeof v !== 'object' || v === null) return null
41 const o = v as { phase?: unknown; badge?: unknown }
42 if (typeof o.phase === 'number' && Number.isInteger(o.phase) && o.phase >= 1 && o.phase <= 6) {
43 set(out, key, { phase: o.phase as Phase })
44 } else if (typeof o.badge === 'string' && BADGES.includes(o.badge)) {
45 set(out, key, { badge: o.badge as BadgeName })
46 } else return null
47 }
48 return out
49}
50src/core/pipeline.ts 60 lines1import type { Phase, PhaseMap, PipelineCycle, PipelineState } from './types'
2
3export const MAX_CYCLES = 10
4export const MAX_HISTORY = 50
5
6const emptyCycle = (): PipelineCycle => ({ phase: 0, history: [], badges: [] })
7
8export function emptyPipeline(at: number): PipelineState {
9 return { current: emptyCycle(), cycles: [], plan: { pinned: { spec: false, plan: false }, done: 0, total: 0 }, updatedAt: at }
10}
11
12// Keeps the MAX_HISTORY most recent entries of a cycle's history.
13const capped = <T>(h: T[]): T[] => (h.length > MAX_HISTORY ? h.slice(-MAX_HISTORY) : h)
14
15/** Nothing worth persisting: no phase, history, badge, cycle, pin, spec or plan. */
16export function isEmptyPipeline(p: PipelineState): boolean {
17 const { current: c, plan } = p
18 return (
19 c.phase === 0 && c.history.length === 0 && c.badges.length === 0 && p.cycles.length === 0 &&
20 !plan.pinned.spec && !plan.pinned.plan && plan.specPath === undefined && plan.planPath === undefined
21 )
22}
23
24export function applySkill(p: PipelineState, key: string, map: PhaseMap, at: number): PipelineState {
25 // Own keys only: 'constructor' or 'toString' are not skills.
26 const entry = Object.hasOwn(map, key) ? map[key] : undefined
27 if (!entry) return p
28 const cur = p.current
29 if ('badge' in entry) {
30 if (cur.badges.some((b) => b.name === entry.badge)) return { ...p, updatedAt: at }
31 return { ...p, current: { ...cur, badges: [...cur.badges, { name: entry.badge, at }] }, updatedAt: at }
32 }
33 const phase = entry.phase
34 const hist = { phase, skill: key, at }
35 // A phase-1 skill after phase 2+ closes the cycle and starts a new one.
36 if (phase === 1 && cur.phase >= 2) {
37 const cycles = [...p.cycles, cur].slice(-MAX_CYCLES)
38 return { ...p, cycles, current: { phase: 1, history: [hist], badges: [] }, updatedAt: at }
39 }
40 if (phase > cur.phase) {
41 return { ...p, current: { phase, history: capped([...cur.history, hist]), badges: [] }, updatedAt: at }
42 }
43 // Never go back: record the skill only.
44 return { ...p, current: { ...cur, history: capped([...cur.history, hist]) }, updatedAt: at }
45}
46
47export function setPhase(p: PipelineState, phase: Phase, at: number): PipelineState {
48 const cur = p.current
49 const hist = { phase, skill: '/overclock phase', at }
50 return {
51 ...p,
52 current: { phase, history: capped([...cur.history, hist]), badges: phase === cur.phase ? cur.badges : [] },
53 updatedAt: at,
54 }
55}
56
57export function resetPipeline(p: PipelineState, at: number): PipelineState {
58 return { ...emptyPipeline(at), plan: p.plan }
59}
60src/core/plan.ts 50 lines1// Pure helpers for specs and plans: checkbox progress and file selection.
2
3export const SPEC_DIR = 'docs/superpowers/specs'
4export const PLAN_DIR = 'docs/superpowers/plans'
5
6const CHECKBOX = /^\s*[-*] \[( |x|X)\] /
7
8/** Count markdown task-list checkboxes: done (x/X) over total. */
9export function countCheckboxes(markdown: string): { done: number; total: number } {
10 let done = 0
11 let total = 0
12 for (const line of markdown.split('\n')) {
13 const m = CHECKBOX.exec(line)
14 if (!m) continue
15 total++
16 if (m[1] !== ' ') done++
17 }
18 return { done, total }
19}
20
21/** Name of the most recently modified `.md` file, ignoring directories. */
22export function pickLatest(
23 entries: { name: string; kind: string; mtimeMs: number }[],
24): string | undefined {
25 let best: { name: string; mtimeMs: number } | undefined
26 for (const e of entries) {
27 if (e.kind !== 'file' || !e.name.endsWith('.md')) continue
28 if (!best || e.mtimeMs > best.mtimeMs) best = e
29 }
30 return best?.name
31}
32
33/** Classify a repo-relative path (`/` separators); first level only. */
34export function classifyDocPath(relPath: string): 'spec' | 'plan' | null {
35 for (const [dir, kind] of [
36 [SPEC_DIR, 'spec'],
37 [PLAN_DIR, 'plan'],
38 ] as const) {
39 if (!relPath.startsWith(dir + '/')) continue
40 const rest = relPath.slice(dir.length + 1)
41 if (rest.length > 3 && !rest.includes('/') && rest.endsWith('.md')) return kind
42 }
43 return null
44}
45
46/** Rounded integer percentage; 0 when total is 0. */
47export function percent(done: number, total: number): number {
48 return total === 0 ? 0 : Math.round((done / total) * 100)
49}
50src/core/sessions.ts 118 lines1import type { SessionCase, SessionEntry } from './types'
2
3export const LIVE_MS = 60_000
4export const STALE_MS = 86_400_000
5export const SHARED_WRITE_MS = 300_000
6export const IDLE_MS = 600_000
7
8export function isLive(e: SessionEntry, now: number): boolean {
9 return now - e.heartbeat <= LIVE_MS
10}
11export function isStale(e: SessionEntry, now: number): boolean {
12 return now - e.heartbeat > STALE_MS
13}
14
15// A null repoRoot never equals anything, so out-of-git sessions are never "same repo".
16const sameRepo = (a: SessionEntry, b: SessionEntry) => a.repoRoot !== null && a.repoRoot === b.repoRoot
17const sameBranchOf = (a: SessionEntry, b: SessionEntry) => sameRepo(a, b) && a.branch === b.branch
18
19// `others` are already filtered to live sessions, without `self`.
20export function classify(
21 self: SessionEntry,
22 others: SessionEntry[],
23): { kind: SessionCase; sameBranch: number; worktrees: number; otherRepos: number } {
24 const sameBranch = others.filter((o) => sameBranchOf(self, o)).length
25 const worktrees = new Set([self, ...others.filter((o) => sameRepo(self, o))].map((e) => e.worktreeRoot)).size
26 const otherRepos = new Set(
27 others.filter((o) => !sameRepo(self, o)).map((o) => o.repoRoot ?? `wt:${o.worktreeRoot}`),
28 ).size
29 let kind: SessionCase = 'none'
30 if (others.length > 0) kind = sameBranch > 0 ? 'C' : worktrees > 1 ? 'A' : 'B'
31 return { kind, sameBranch, worktrees, otherRepos }
32}
33
34export function sharedWrites(
35 self: SessionEntry,
36 others: SessionEntry[],
37 now: number,
38): { path: string; sessionIds: string[]; at: number }[] {
39 const byPath = new Map<string, { ids: string[]; at: number }>()
40 for (const e of [self, ...others.filter((o) => sameBranchOf(self, o))]) {
41 const seen = new Set<string>()
42 for (const w of e.recentWrites) {
43 if (now - w.at > SHARED_WRITE_MS) continue
44 const cur = byPath.get(w.path) ?? { ids: [], at: 0 }
45 if (!seen.has(w.path)) {
46 cur.ids.push(e.sessionId)
47 seen.add(w.path)
48 }
49 cur.at = Math.max(cur.at, w.at)
50 byPath.set(w.path, cur)
51 }
52 }
53 const out: { path: string; sessionIds: string[]; at: number }[] = []
54 for (const [path, v] of byPath) if (v.ids.length >= 2) out.push({ path, sessionIds: v.ids, at: v.at })
55 return out
56}
57
58export function pruneWrites(writes: { path: string; at: number }[], now: number) {
59 return writes.filter((w) => now - w.at <= SHARED_WRITE_MS)
60}
61
62export type SessionGroup = {
63 repoName: string
64 repoRoot: string | null
65 kind: SessionCase
66 branches: { branch: string | null; sessions: (SessionEntry & { isSelf: boolean; isIdle: boolean })[] }[]
67}
68
69export function groupSessions(all: SessionEntry[], selfId: string, now: number): SessionGroup[] {
70 const kept = all.filter((e) => e.sessionId === selfId || isLive(e, now))
71 const self = kept.find((e) => e.sessionId === selfId)
72 const keyOf = (e: SessionEntry) => e.repoRoot ?? e.worktreeRoot
73 const groups = new Map<string, SessionEntry[]>()
74 for (const e of kept) {
75 const k = keyOf(e)
76 groups.set(k, [...(groups.get(k) ?? []), e])
77 }
78 const selfKey = self ? keyOf(self) : undefined
79 const selfBranch = self ? self.branch : undefined
80 const out: SessionGroup[] = []
81 for (const [key, members] of groups) {
82 const isSelfGroup = key === selfKey
83 let kind: SessionCase
84 if (isSelfGroup && self) {
85 kind = classify(self, kept.filter((e) => e !== self)).kind
86 } else {
87 const perBranch = new Map<string | null, number>()
88 for (const m of members) perBranch.set(m.branch, (perBranch.get(m.branch) ?? 0) + 1)
89 const shared = members[0]!.repoRoot !== null && [...perBranch.values()].some((n) => n >= 2)
90 const wts = new Set(members.map((m) => m.worktreeRoot)).size
91 kind = members[0]!.repoRoot === null ? 'B' : shared ? 'C' : wts >= 2 ? 'A' : 'B'
92 }
93 const names = [...new Set(members.map((m) => m.branch))].sort((a, b) => {
94 if (isSelfGroup && a !== b) {
95 if (a === selfBranch) return -1
96 if (b === selfBranch) return 1
97 }
98 return (a ?? '').localeCompare(b ?? '')
99 })
100 const branches = names.map((branch) => {
101 const ss = members
102 .filter((m) => m.branch === branch)
103 .map((m) => ({ ...m, isSelf: m.sessionId === selfId, isIdle: now - m.lastActivity > IDLE_MS }))
104 // Active first, idle last; Array.sort is stable.
105 ss.sort((a, b) => Number(a.isIdle) - Number(b.isIdle))
106 return { branch, sessions: ss }
107 })
108 out.push({ repoName: members[0]!.repoName, repoRoot: members[0]!.repoRoot, kind, branches })
109 }
110 out.sort((a, b) => {
111 const as = a.branches.some((br) => br.sessions.some((s) => s.isSelf))
112 const bs = b.branches.some((br) => br.sessions.some((s) => s.isSelf))
113 if (as !== bs) return as ? -1 : 1
114 return a.repoName.localeCompare(b.repoName)
115 })
116 return out
117}
118src/core/paths.ts 40 lines1// Pure POSIX-style path helpers: strings and '/' only, no Node.
2
3function trimTrailing(p: string): string {
4 let end = p.length
5 while (end > 1 && p[end - 1] === '/') end--
6 return p.slice(0, end)
7}
8
9export function joinPath(...parts: string[]): string {
10 let out = ''
11 for (const part of parts) {
12 if (part === '') continue
13 if (out === '') out = part
14 else out = trimTrailing(out).replace(/^\/$/, '') + '/' + part.replace(/^\/+/, '')
15 }
16 return out === '' ? '' : trimTrailing(out)
17}
18
19export function dirname(p: string): string {
20 const t = trimTrailing(p)
21 const i = t.lastIndexOf('/')
22 if (i < 0) return '.'
23 if (i === 0) return '/'
24 return t.slice(0, i)
25}
26
27export function basename(p: string): string {
28 const t = trimTrailing(p)
29 return t.slice(t.lastIndexOf('/') + 1)
30}
31
32/** Path of `p` relative to `root`, or null when `p` is outside `root`. */
33export function relativeTo(root: string, p: string): string | null {
34 const r = trimTrailing(root)
35 const t = trimTrailing(p)
36 if (t === r) return ''
37 const prefix = r === '/' ? '/' : r + '/'
38 return t.startsWith(prefix) ? t.slice(prefix.length) : null
39}
40src/core/types.ts 37 lines1// Shared types for the pure core. Flat list of exports.
2
3export type AgentStatus = 'running' | 'done' | 'failed' | 'aborted'
4export type Tokens = { input: number; output: number }
5export interface AgentNode {
6 id: string; parentId: string; toolUseId: string; type: string; description: string
7 background: boolean; status: AgentStatus; startedAt: number; endedAt?: number
8 tokens: Tokens; lastTool?: string
9}
10export interface AgentTree { nodes: AgentNode[]; mainTokens: Tokens }
11export type TurnReason = 'answer' | 'aborted' | 'refusal' | 'error'
12/** Token counts of a turn; the cache counts are optional and absent means 0. */
13export type Usage = { input_tokens: number; output_tokens: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number }
14export type AgentEvent =
15 | { kind: 'agent.started'; id: string; parentId?: string; toolUseId: string; type: string; description: string; background: boolean; at: number }
16 | { kind: 'agent.resumed'; id: string; at: number }
17 | { kind: 'agent.ended'; id: string; reason: TurnReason; usage?: Usage; at: number }
18 | { kind: 'agent.tool'; id: string; tool: string }
19 | { kind: 'main.turn'; usage?: Usage }
20 // teammateIds: the live ones that are teammates, whose loop outlives a turn.
21 | { kind: 'agents.reconcile'; liveIds: string[]; teammateIds?: string[]; at: number }
22
23export type Phase = 0 | 1 | 2 | 3 | 4 | 5 | 6
24export type BadgeName = 'TDD' | 'Debug' | 'Verifica'
25export interface PipelineCycle { phase: Phase; history: { phase: Phase; skill: string; at: number }[]; badges: { name: BadgeName; at: number }[] }
26export interface PlanRef { specPath?: string; planPath?: string; pinned: { spec: boolean; plan: boolean }; done: number; total: number }
27export interface PipelineState { current: PipelineCycle; cycles: PipelineCycle[]; plan: PlanRef; updatedAt: number }
28export type PhaseMap = Record<string, { phase: Phase } | { badge: BadgeName }>
29
30export interface SessionEntry {
31 v: 1; sessionId: string; repoRoot: string | null; repoName: string; worktreeRoot: string
32 branch: string | null; agents: AgentNode[]; recentWrites: { path: string; at: number }[]
33 phase: Phase; badges: BadgeName[]; planDone: number; planTotal: number
34 lastActivity: number; heartbeat: number
35}
36export type SessionCase = 'A' | 'B' | 'C' | 'none'
37