Side pane for OpenSpec: active change, artifact and task progress, spend per OPSX step, next-step buttons

A Claude Code mod for OpenSpec users: a side pane showing the change you are working on, its progress, and what each OPSX step has cost.
┌ OpenSpec ──────────────────────────┐
│ add-dark-mode-toggle │
│ ● proposal ● specs ● design ● tasks│
│ ████████░░░░░░░░ 6/14 tasks │
│ Next: 2.3 Persist theme choice │
│ │
│ Spend on this change │
│ propose 38k tok $0.41 │
│ apply ▸ 620k tok $2.95 │
│ Total 658k tok $3.36 │
│ │
│ [ Apply ] 1 other │
└────────────────────────────────────┘
/opsx:<step> <change>, or picked with /opsx-change <change>; otherwise the most recently modified change in progress./opsx:<step>, every turn's tokens and cost count toward that step until the next /opsx: command or /clear. Totals are kept per project and change across sessions.The pane opens with the session in any project with an OpenSpec root. Reads state through the openspec CLI (1.9 or later).
/plugin install openspec-pane --marketplace kk5190/claude-code-mods
MIT. See LICENSE.
hooks/register.tsx 282 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ChangeDetail, Current, Ledger, Project } from '../types'
5import { availableSteps, firstUnchecked, nextSteps, parseList, parseStatus, pickActive } from './openspec'
6import { addSpend, changeTotal, parseOpsx, usageTokens } from './spend'
7
8const PANE = 'openspec-pane'
9const TITLE = 'OpenSpec'
10const POLL_MS = 10_000
11const CLI_TIMEOUT_MS = 5000
12const WRITING_TOOLS = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit']
13const MISSING_CLI = 'openspec CLI not found — npm i -g @fission-ai/openspec'
14
15const project = atom({ plugin: 'openspec-pane', key: 'project' } as const, null)
16const detail = atom({ plugin: 'openspec-pane', key: 'detail' } as const, null)
17const active = atom({ plugin: 'openspec-pane', key: 'active' } as const, null)
18const current = atom({ plugin: 'openspec-pane', key: 'current' } as const, null)
19const ledger = atom({ plugin: 'openspec-pane', key: 'ledger' } as const, {})
20const error = atom({ plugin: 'openspec-pane', key: 'error' } as const, null)
21const showOthers = atom({ plugin: 'openspec-pane', key: 'showOthers' } as const, false)
22
23const k = (n: number) => (n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(1)}M` : `${Math.round(n / 1000)}k`)
24
25// Cost is a session running total; a reload starts this over so its first reading only sets the baseline.
26let lastUsd: number | null = null
27
28// Each refresh takes a number; an older, slower run stops writing once a newer one has started.
29let generation = 0
30let poll: { cancel: () => void } | null = null
31
32const openspec = async ($: EngineInterface, args: string[]) => {
33 try {
34 return await $.process.run(['openspec', ...args], { timeoutMs: CLI_TIMEOUT_MS })
35 } catch (err) {
36 const msg = err instanceof Error ? err.message : String(err)
37 if (/time(d)?[ -]?out/i.test(msg)) throw new Error('openspec timed out')
38 if (/ENOENT|not found|no such file|cannot start|could not start/i.test(msg)) throw new Error(MISSING_CLI)
39 throw new Error(`openspec failed: ${msg.split('\n')[0]}`)
40 }
41}
42
43// The `/opsx:` commands installed in the project; null when the folder is missing (e.g. skills-only setups).
44const installedCommands = async ($: EngineInterface, root: string): Promise<string[] | null> => {
45 try {
46 const entries = await $.fs.list(`${root}/.claude/commands/opsx`)
47 return entries.filter(e => e.name.endsWith('.md')).map(e => e.name.slice(0, -3))
48 } catch {
49 return null
50 }
51}
52
53const refresh = async ($: EngineInterface): Promise<Project | null> => {
54 const gen = ++generation
55 const stale = () => gen !== generation
56 try {
57 const listed = await openspec($, ['list', '--json'])
58 if (stale()) return read($, project)
59 let found: Project | null
60 try {
61 found = parseList(listed.stdout)
62 } catch {
63 throw new Error(`openspec list failed: ${(listed.stderr || listed.stdout).split('\n')[0]}`)
64 }
65 const before = await read($, project)
66 if (stale()) return read($, project)
67 await update($, project, () => found)
68 if (found === null) {
69 await update($, detail, () => null)
70 await update($, error, () => null)
71 return null
72 }
73 if (before?.root !== found.root) {
74 const saved = ((await $.store.get(`ledger:${found.root}`)) as Ledger | undefined) ?? {}
75 if (stale()) return read($, project)
76 await update($, ledger, () => saved)
77 // A step from another project must not keep spending here.
78 await update($, current, () => null)
79 }
80 const chosen = ((await $.store.get(`active:${found.root}`)) as string | undefined) ?? null
81 if (stale()) return read($, project)
82 const name = pickActive(found.changes, chosen)
83 await update($, active, () => name)
84 let next: ChangeDetail | null = null
85 if (name !== null) {
86 const ran = await openspec($, ['status', '--change', name, '--json'])
87 if (stale()) return read($, project)
88 let status: ReturnType<typeof parseStatus>
89 try {
90 if (ran.exitCode !== 0) throw new Error('non-zero exit')
91 status = parseStatus(ran.stdout)
92 } catch {
93 throw new Error(`openspec status failed: ${(ran.stderr || ran.stdout).split('\n')[0]}`)
94 }
95 const tasks = status.tasksPath ? await $.fs.read(status.tasksPath) : ''
96 if (stale()) return read($, project)
97 const commands = await installedCommands($, found.root)
98 if (stale()) return read($, project)
99 next = { name, artifacts: status.artifacts, nextTask: firstUnchecked(String(tasks)), commands }
100 }
101 await update($, detail, () => next)
102 await update($, error, () => null)
103 return found
104 } catch (err) {
105 if (stale()) return read($, project)
106 await update($, error, () => (err instanceof Error ? err.message : String(err)))
107 return read($, project)
108 }
109}
110
111const choose = async ($: EngineInterface, name: string) => {
112 const p = await read($, project)
113 if (!p?.changes.some(c => c.name === name)) return false
114 await $.store.set(`active:${p.root}`, name)
115 await update($, showOthers, () => false)
116 await refresh($)
117 return true
118}
119
120// `/opsx:<step> [change]` from the prompt or as a slash command: switch change if it exists, start the step.
121const startStep = async ($: EngineInterface, text: string) => {
122 const opsx = parseOpsx(text)
123 if (!opsx) return
124 if (opsx.change) await choose($, opsx.change)
125 const name = await read($, active)
126 await update($, current, () => (name ? ({ change: name, step: opsx.step } satisfies Current) : null))
127}
128
129const spend = async ($: EngineInterface, add: { tokens?: number; usd?: number }) => {
130 const cur = await read($, current)
131 const p = await read($, project)
132 if (!cur || !p) return
133 // The store is shared by every session in this project: add to what it holds now, not to our copy.
134 const stored = ((await $.store.get(`ledger:${p.root}`)) as Ledger | undefined) ?? {}
135 const next = addSpend(stored, cur, add)
136 await $.store.set(`ledger:${p.root}`, next)
137 await update($, ledger, () => next)
138}
139
140export const register: Register = on => {
141 on('session.start', async ($, e, next) => {
142 await $.command.register({ name: 'opsx-change', description: 'Show the OpenSpec pane, or switch the active change' })
143 void refresh($)
144 .then(p => p && $.ui.open({ id: PANE, title: TITLE }))
145 .catch(() => {})
146 poll?.cancel()
147 // Poll while the pane is placed and a project is known or the last refresh failed; a repo with no OpenSpec stays quiet.
148 poll = $.clock.every(POLL_MS, () => {
149 void (async () => {
150 const placed = (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced)
151 if (placed && ((await read($, project)) || (await read($, error)) !== null)) await refresh($)
152 })().catch(() => {})
153 })
154 return next(e)
155 })
156
157 on('command.run', { command: 'opsx-change' }, async ($, e) => {
158 const p = await refresh($)
159 if (!p) {
160 const problem = await read($, error)
161 if (problem === null) return { text: 'No OpenSpec project here' }
162 await $.ui.open({ id: PANE, title: TITLE, focus: true })
163 return { text: problem }
164 }
165 const name = e.args.trim()
166 if (name && !(await choose($, name))) return { text: `No change named ${name}` }
167 if (!name) await update($, showOthers, () => true)
168 await $.ui.open({ id: PANE, title: TITLE, focus: true })
169 return { text: name ? `Active change: ${name}` : 'OpenSpec pane opened.' }
170 })
171
172 on('command.run', async ($, e, next) => {
173 if (e.command.startsWith('opsx:')) await startStep($, `/${e.command} ${e.args}`)
174 return next(e)
175 })
176
177 on('prompt.submit', async ($, e, next) => {
178 await startStep($, e.text)
179 return next(e)
180 })
181
182 on('turn.complete', async ($, e, next) => {
183 if (e.usage) await spend($, { tokens: usageTokens(e.usage) })
184 await refresh($)
185 return next(e)
186 })
187
188 on('session.measure', async ($, e, next) => {
189 const usd = e.cost?.usd
190 if (usd !== undefined) {
191 if (lastUsd !== null && usd > lastUsd) await spend($, { usd: usd - lastUsd })
192 lastUsd = usd
193 }
194 return next(e)
195 })
196
197 on('session.end', async ($, e, next) => {
198 await update($, current, () => null)
199 // /clear and a resume end the session without a session.start after: keep polling for those.
200 if (e.reason !== 'clear' && e.reason !== 'resume') {
201 poll?.cancel()
202 poll = null
203 }
204 lastUsd = null
205 return next(e)
206 })
207
208 on('tool.call', async ($, e, next) => {
209 const ran = await next(e)
210 const path = (e as { file_path?: unknown }).file_path
211 if (WRITING_TOOLS.includes(e.tool) && typeof path === 'string' && /(^|\/)openspec\//.test(path)) refresh($).catch(() => {})
212 return ran
213 })
214
215 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
216 const { Box, Text, Button } = $.ui.resolve(e)
217 const p = await read($, project)
218 const d = await read($, detail)
219 const cur = await read($, current)
220 const spent = await read($, ledger)
221 const problem = await read($, error)
222 const expanded = await read($, showOthers)
223 const width = Math.max(20, e.props.bodyColumns - 2)
224 const cut = (s: string) => (s.length > width ? `${s.slice(0, width - 1)}…` : s)
225
226 const change = d && p?.changes.find(c => c.name === d.name)
227 const others = (p?.changes ?? []).filter(c => c.status !== 'complete' && c.name !== d?.name)
228 const steps = spent[d?.name ?? ''] ?? {}
229 const total = changeTotal(steps)
230 const filled = change && change.totalTasks > 0 ? Math.min(width, Math.max(0, Math.round((change.completedTasks / change.totalTasks) * width))) : 0
231 const row = (label: string, s: { tokens: number; usd: number }) =>
232 `${label.padEnd(11)}${`${k(s.tokens)} tok`.padStart(10)}${`$${s.usd.toFixed(2)}`.padStart(9)}`
233
234 return (
235 <Box flexDirection="column" paddingX={1}>
236 {problem !== null && <Text color="error">{problem}</Text>}
237 {!change && problem === null && <Text dimColor>No change in progress</Text>}
238 {change && d && (
239 <Box flexDirection="column">
240 <Text bold>{cut(d.name)}</Text>
241 <Text>{cut(d.artifacts.map(a => `${a.present ? '●' : '○'} ${a.id}`).join(' '))}</Text>
242 <Box flexDirection="row">
243 <Text color="success">{'█'.repeat(filled)}</Text>
244 <Text dimColor>{'░'.repeat(width - filled)}</Text>
245 </Box>
246 <Text dimColor>{`${change.completedTasks}/${change.totalTasks} tasks`}</Text>
247 {d.nextTask && <Text>{cut(`Next: ${d.nextTask}`)}</Text>}
248
249 {Object.keys(steps).length > 0 && (
250 <Box flexDirection="column" marginTop={1}>
251 <Text dimColor>Spend on this change</Text>
252 {Object.entries(steps).map(([step, s]) => (
253 <Text key={`spend-${step}`} color={cur?.change === d.name && cur.step === step ? 'claude' : undefined}>
254 {row(cur?.change === d.name && cur.step === step ? `${step} ▸` : step, s)}
255 </Text>
256 ))}
257 <Text bold>{row('Total', total)}</Text>
258 </Box>
259 )}
260
261 <Box flexDirection="row" gap={1} marginTop={1}>
262 {availableSteps(nextSteps(change, d.artifacts), d.commands).map(step => (
263 <Button
264 key={`step-${step}`}
265 label={step[0]!.toUpperCase() + step.slice(1)}
266 onPress={() => $.prompt.fill({ text: `/opsx:${step} ${d.name}`, mode: 'replace' })}
267 />
268 ))}
269 {others.length > 0 && (
270 <Button key="others" plain label={`${others.length} other`} onPress={() => update($, showOthers, v => !v)} />
271 )}
272 </Box>
273 </Box>
274 )}
275 {expanded && others.map(c => (
276 <Button key={`pick-${c.name}`} plain label={cut(`${c.name} ${c.completedTasks}/${c.totalTasks}`)} onPress={() => choose($, c.name)} />
277 ))}
278 </Box>
279 )
280 })
281}
282hooks/openspec.ts 41 lines1import type { Artifact, ChangeSummary, Project, Step } from '../types'
2
3type ListJson = { root: { path: string } | null; changes: ChangeSummary[] }
4type StatusJson = { artifactPaths?: Record<string, { existingOutputPaths?: string[] }> }
5
6/** `openspec list --json` → the project, or null when no OpenSpec root was found. */
7export const parseList = (stdout: string): Project | null => {
8 const json = JSON.parse(stdout) as ListJson
9 return json.root ? { root: json.root.path, changes: json.changes } : null
10}
11
12/** `openspec status --change <name> --json` → artifacts in schema order, and the tasks file if it exists. */
13export const parseStatus = (stdout: string): { artifacts: Artifact[]; tasksPath: string | null } => {
14 const paths = (JSON.parse(stdout) as StatusJson).artifactPaths ?? {}
15 const artifacts = Object.entries(paths).map(([id, p]) => ({ id, present: (p.existingOutputPaths?.length ?? 0) > 0 }))
16 return { artifacts, tasksPath: paths.tasks?.existingOutputPaths?.[0] ?? null }
17}
18
19/** The text of the first `- [ ]` line in a tasks file. */
20export const firstUnchecked = (markdown: string): string | null =>
21 markdown.match(/^\s*-\s\[ \]\s+(.+?)\s*$/m)?.[1] ?? null
22
23/** The chosen change when it still exists, else the most recently modified change still in progress. */
24export const pickActive = (changes: ChangeSummary[], chosen: string | null): string | null => {
25 if (chosen && changes.some(c => c.name === chosen)) return chosen
26 const open = changes.filter(c => c.status !== 'complete')
27 open.sort((a, b) => b.lastModified.localeCompare(a.lastModified))
28 return open[0]?.name ?? null
29}
30
31/** Which OPSX steps make sense next for a change. */
32export const nextSteps = (change: ChangeSummary, artifacts: Artifact[]): Step[] => {
33 if (artifacts.some(a => !a.present)) return ['continue']
34 if (change.completedTasks < change.totalTasks) return ['apply']
35 return ['verify', 'archive']
36}
37
38/** The steps a project has `/opsx:` commands for; `null` (commands unknown) keeps every step. */
39export const availableSteps = (steps: Step[], commands: string[] | null): Step[] =>
40 commands === null ? steps : steps.filter(s => commands.includes(s))
41hooks/spend.ts 27 lines1import type { Current, Ledger, StepSpend } from '../types'
2
3/** `/opsx:<step> [change]` at the start of a prompt → its step and change. */
4export const parseOpsx = (text: string): { step: string; change: string | null } | null => {
5 const m = text.trim().match(/^\/opsx:([a-z][a-z-]*)(?:\s+(\S+))?/)
6 return m ? { step: m[1]!, change: m[2] ?? null } : null
7}
8
9export const usageTokens = (u: {
10 input_tokens: number
11 output_tokens: number
12 cache_read_input_tokens: number
13 cache_creation_input_tokens: number
14}) => u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
15
16/** Adds spend to the current change and step; spend with no current step is not attributed. */
17export const addSpend = (ledger: Ledger, current: Current, add: Partial<StepSpend>): Ledger => {
18 if (current === null) return ledger
19 const steps = ledger[current.change] ?? {}
20 const was = steps[current.step] ?? { tokens: 0, usd: 0 }
21 const now = { tokens: was.tokens + (add.tokens ?? 0), usd: was.usd + (add.usd ?? 0) }
22 return { ...ledger, [current.change]: { ...steps, [current.step]: now } }
23}
24
25export const changeTotal = (steps: Record<string, StepSpend> | undefined): StepSpend =>
26 Object.values(steps ?? {}).reduce((t, s) => ({ tokens: t.tokens + s.tokens, usd: t.usd + s.usd }), { tokens: 0, usd: 0 })
27types/index.d.ts 38 lines1export type ChangeSummary = {
2 name: string
3 completedTasks: number
4 totalTasks: number
5 lastModified: string
6 status: string
7}
8
9export type Project = { root: string; changes: ChangeSummary[] }
10
11export type Artifact = { id: string; present: boolean }
12
13/** `commands`: the `/opsx:` commands the project has installed, or null when unknown. */
14export type ChangeDetail = { name: string; artifacts: Artifact[]; nextTask: string | null; commands: string[] | null }
15
16export type Step = 'continue' | 'apply' | 'verify' | 'archive'
17
18export type StepSpend = { tokens: number; usd: number }
19
20/** change name → OPSX step → spend */
21export type Ledger = Record<string, Record<string, StepSpend>>
22
23export type Current = { change: string; step: string } | null
24
25declare module 'claude-code' {
26 interface PluginState {
27 'openspec-pane': {
28 project: Project | null
29 detail: ChangeDetail | null
30 active: string | null
31 current: Current
32 ledger: Ledger
33 error: string | null
34 showOthers: boolean
35 }
36 }
37}
38