OpenSpec board: which phase you are in (propose/apply/archive), which tasks.md task is current, and every sub agent's name, model/effort, tokens and progress

A board for OpenSpec spec-driven development in Claude Code. Three things in one pane, one band line and the status line:
/opsx:propose, /opsx:apply, /opsx:archive, … and the band shows the phase and the change name.openspec/changes/<change>/tasks.md and shows the current task, the next few and a progress bar. The model gets a task tool to report each task; on done the mod ticks [x] itself, so progress never waits for a batch edit at the end.⧉ opsx apply add-auth · 3.2 Implement token refresh · 5/9 · ⚇ 2 agents running · 1 done · 86k tok · 3s ago
/opsx-board opens the pane:
OpenSpec · apply · add-auth ▓▓▓▓▓▓░░░░ 5/9
▶ 3.2 Implement token refresh
○ 3.3 Refresh token rotation and… ○ 3.4 Add unit tests ○ 3.5 Docs and examples
Sub agents · 5 this session · 86k tok total
running (2)
▶ Search auth-related code · Explore · sonnet/medium · 12k tok · running 42s
step 4 · 7 tools · last Grep "refreshToken" · ↑11k ↓1.2k
▶ Write unit tests · general-purpose · opus/high · 31k tok · running 12s
step 2 · 3 tools · last Write auth.test.ts · ↑28k ↓2.6k
done (3)
✓ Check schema diff · Explore · haiku/low · 5.1k tok · 1m12s
✓ Tidy API docs · general-purpose · sonnet/medium · 22k tok · 2m40s
✗ Run e2e · general-purpose · opus/high · 15k tok · failed: error
Updated 2s ago current task reported by the model
[ Clear finished ][ Close ]
Set up OpenSpec's slash commands and skills in the project with openspec init, then work as usual; the mod only watches events and draws.
| Command | Effect |
|---|---|
/opsx-board | Toggle the pane |
/opsx-board clear | Drop finished sub agents (running and waiting ones stay) |
/opsx-board off | Hide the band, pane and status line; events are still recorded. /opsx-board resumes |
/opsx-board demo | Show fake data; run it again to leave |
The pane opens by itself when the apply phase starts or the first sub agent spawns (see settings). A pane nobody asked for is only drawn in a terminal at least 144 columns wide; narrower, it waits for your /opsx-board.
The UI, toasts, command output and the texts the model reads (tool description, reporting rule, reminders) come in English, Traditional Chinese and Japanese. The language setting picks one; auto (the default) reads LC_ALL, then LC_MESSAGES, then LANG: any zh* locale → zh-TW, ja* → ja, everything else (including C and POSIX) → en. Task titles, change names, agent descriptions and tool summaries are shown as they are.
| Source | What it gives |
|---|---|
/opsx:<kind> [change] command | phase = <kind>; the first argument is taken as the change name when it looks like one |
openspec-<kind> skill (triggered by the model or through the Skill tool) | the phase (openspec-apply-change → apply, and so on) |
openspec status/instructions/show/validate … --change X in Bash | the change name |
openspec new change X in Bash | phase propose, change X |
a successful openspec archive in Bash | back to idle, task list dropped |
reads and edits under openspec/changes/<change>/… | the change name (archive/ does not count) |
When only tasks.md has been read and no phase was entered, the band shows ⧉ opsx <change> · … without claiming apply; editing a checkbox in tasks.md directly counts as apply.
mcp__opsx-board__task toolThe mod registers a tool the model can call (Claude Code lists every plugin tool as mcp__<plugin>__<name>; OpenSpec itself is not an MCP server):
mcp__opsx-board__task { "task_id": "3.2", "status": "start" } → the pane shows "▶ 3.2 …" (reported by the model)
mcp__opsx-board__task { "task_id": "3.2", "status": "done" } → the mod rewrites that tasks.md line to [x] and answers with progress and the next task
The model no longer edits checkboxes itself, and the board's current task becomes a declared value instead of a guess. Three layers make the model actually do it:
inject_instructions, on by default): the reporting rule is appended to the openspec-apply-change skill text, and repeated as a session-scoped section of the system prompt while in the apply phase, so it survives compaction.strict, off by default): an edit that ticks several tasks at once is refused and the model is told to use the tool. The file is not touched.tasks.md stays the source of truth: if the model ignores the tool entirely, the board is only coarser, never wrong.
| Column | Source |
|---|---|
| name, type | agent.spawn's description and subagentType |
| model | the model the spawn resolved to; afterwards whatever the API reports |
| effort | the effort of the agent's first model request (turn.step) |
| tokens | the agent's model requests summed (input + output + cache read + cache write); on the second line ↑ is the input side, ↓ the output |
| step / tools / last | model requests, tool calls, the last tool call's summary |
| running / waiting | since the spawn; "waiting" comes from $.agent.list() (plan approval, background work) |
| end | turn.complete: answer → ✓, aborted → ■, anything else → ✗ with the reason |
A toast fires when a sub agent starts and when it ends (with tokens and duration). At most 50 are kept; past that, the earliest finished ones are dropped first.
In /plugin's configure page or under pluginConfigs in ~/.claude/settings.json:
| Field | Default | Meaning |
|---|---|---|
language | auto | auto, en, zh-TW or ja (see Language above) |
strict | false | Refuse an Edit/Write that ticks several tasks at once |
inject_instructions | true | Add the reporting rule to the apply skill and the apply-phase system prompt |
auto_open | true | Open the pane when apply starts or the first sub agent spawns |
band_style | box | How the band line is framed: box (its own rounded frame, stacked above the other mods' frames), rule (a thin line beneath it), plain (text only) |
openspec/changes/<change>/tasks.md under the session's project root, and only one line's [ ] becomes [x]. When the change or the file cannot be found, the model gets an error; nothing is guessed.openspec CLI; the phase comes from events alone), no network, no model calls.inject_instructions turns off.openspec/changes/. Environment reads: LC_ALL, LC_MESSAGES, LANG (for language: auto).claude plugin validate ./plugins/opsx-board
Result (v0.1.0):
hooks: session.start, command.run{command=opsx-board}, ui.close, command.run{command=/"^opsx[:/]"/},
skill.prompt{skill=/"^openspec-"/}, prompt.compose, tool.call{tool=/"^mcp__opsx-board__task$"/}, tool.call,
agent.spawn, turn.step, turn.complete, ui.render{component=AbovePrompt},
ui.render{component=Pane, requestId=opsx-board}
calls: $.agent.list, $.clock.every, $.clock.now, $.command.register, $.env.get, $.fs.list, $.fs.read,
$.fs.write, $.session.root, $.state.get, $.state.set, $.tool.register, $.ui.close, $.ui.open,
$.ui.resolve, $.ui.status, $.ui.toast
env reads: LANG, LC_ALL, LC_MESSAGES
env writes: nothing
The unmatched tool.call hook reads paths, Bash commands and sub agents' calls; it only changes a result on a multi-tick edit of tasks.md (a reminder, or a refusal in strict mode) and passes everything else through untouched.
$.agent.list(), so "waiting" is not seen for them.claude -p starts no timer, so "running N s" does not advance there.- [ ] 1.2 text (the number is digits and dots); other shapes are not tasks.claude --plugin-dir ./plugins/opsx-board # load once
claude plugin test ./plugins/opsx-board # run the tests
tsconfig.json depends on .claude-plugin/types/, the type declarations Claude Code writes when it loads the mod; they are not committed.
hooks/register.tsx 645 lines1// opsx-board: a board for the OpenSpec workflow and this session's sub agents.
2//
3// - Phase: read off /opsx:<kind> commands, openspec-<kind> skills, `openspec` commands in
4// Bash, and file paths under openspec/changes/<change>/, to tell propose / apply / archive apart.
5// - Tasks: tracks openspec/changes/<change>/tasks.md, shows the current task and progress;
6// gives the model an mcp__opsx-board__task tool to report start / done per task, and ticks
7// [x] itself on done. An Edit / Write that ticks several tasks at once gets a reminder
8// (or is refused in strict mode).
9// - Sub agents: agent.spawn / turn.step / tool.call / turn.complete build one row per agent:
10// name, type, model/effort, tokens, steps, last action and status.
11// - Language: every string a person or the model reads goes through hooks/i18n.ts; the
12// `language` setting picks en / zh-TW / ja, or `auto` reads LC_ALL / LC_MESSAGES / LANG.
13// - The only file it writes is one checkbox line of tasks.md. No network, no model calls,
14// no external commands.
15
16import { atom, read, update } from 'claude-code'
17import type { Register } from 'claude-code'
18
19import type { AgentRow, Phase, Tasks } from '../types'
20import { DEMO_AGENTS, DEMO_NOW, DEMO_PHASE, DEMO_TASKS } from './demo'
21import { DEFAULT_LANG, resolveLang, t } from './i18n'
22import type { Lang } from './i18n'
23import {
24 IDLE_PHASE,
25 IDLE_REDRAW_TICKS,
26 LIST_TICKS,
27 PANE,
28 TICK_MS,
29 TOAST_MS,
30 addUsage,
31 applyRule,
32 bandText,
33 changeFromPath,
34 currentTask,
35 detailOf,
36 endToast,
37 flippedDone,
38 hasSomething,
39 isTasksFile,
40 markDone,
41 newAgent,
42 nudgeText,
43 paneModel,
44 parseOpenspecBash,
45 parseTasks,
46 phaseFromCommand,
47 phaseFromSkill,
48 progressOf,
49 pruneAgents,
50 simulateEdit,
51 spawnToast,
52 statusFromReason,
53 statusText,
54 strictDenyText,
55 parseBandStyle,
56 tasksPathOf,
57 toolDescription,
58 truncate,
59} from './logic'
60import type { BandStyle, PaneRow } from './logic'
61
62const phase = atom({ plugin: 'opsx-board', key: 'phase' } as const, IDLE_PHASE)
63const tasks = atom({ plugin: 'opsx-board', key: 'tasks' } as const, null)
64const agents = atom({ plugin: 'opsx-board', key: 'agents' } as const, {})
65const isPaneOpen = atom({ plugin: 'opsx-board', key: 'isPaneOpen' } as const, false)
66const isPaused = atom({ plugin: 'opsx-board', key: 'isPaused' } as const, false)
67const isDemo = atom({ plugin: 'opsx-board', key: 'isDemo' } as const, false)
68const updatedAt = atom({ plugin: 'opsx-board', key: 'updatedAt' } as const, 0)
69const tickAt = atom({ plugin: 'opsx-board', key: 'tickAt' } as const, 0)
70const langState = atom({ plugin: 'opsx-board', key: 'lang' } as const, DEFAULT_LANG)
71
72// Settings (read in register; a hot reload re-reads them)
73let strict = false
74let inject = true
75let autoOpen = true
76let langOption: unknown = 'auto'
77let bandStyle: BandStyle = 'box'
78
79/**
80 * Frames this mod's band content per `band_style` and stacks the plugins beneath under it.
81 * `box`: a rounded frame (yellow when `isWarning`); `rule`: a dim line beneath, only when another
82 * plugin drew something below; `plain`: the bare text.
83 */
84function frameBand(ui: { Box: any; Text: any }, style: BandStyle, isWarning: boolean, bodyColumns: number | undefined, content: any, below: any) {
85 const { Box, Text } = ui
86 const hasBelow = below !== null && below !== undefined && (below as { type?: string }).type !== 'engine'
87 const own =
88 style === 'box' ? (
89 <Box key="frame" flexDirection="column" borderStyle="round" borderDimColor={isWarning ? undefined : true} borderColor={isWarning ? 'yellow' : undefined} paddingX={1}>
90 {content}
91 </Box>
92 ) : style === 'rule' ? (
93 <Box key="frame" flexDirection="column">
94 {content}
95 {hasBelow ? <Text key="rule" dimColor>{'─'.repeat(Math.max(8, Math.min(bodyColumns ?? 60, 200)))}</Text> : null}
96 </Box>
97 ) : (
98 <Box key="frame" flexDirection="column">
99 {content}
100 </Box>
101 )
102 return (
103 <Box flexDirection="column">
104 {own}
105 {below}
106 </Box>
107 )
108}
109// Module-level state: a hot reload resets it, which is fine
110let lang: Lang = DEFAULT_LANG
111let ticks = 0
112let autoOpened = false
113
114function toast($: any, text: string): void {
115 try {
116 $.ui.toast(text, { timeoutMs: TOAST_MS })
117 } catch {}
118}
119
120async function touch($: any): Promise<void> {
121 const now = await $.clock.now()
122 await update($, updatedAt, () => now)
123}
124
125async function refreshStatus($: any): Promise<void> {
126 if (await read($, isPaused)) return $.ui.status(undefined)
127 $.ui.status(statusText(await read($, phase), await read($, tasks)))
128}
129
130// ── Phase ──────────────────────────────────────────────────────────────────
131
132async function setPhase($: any, kind: string | null, change: string | null): Promise<void> {
133 const prev = await read($, phase)
134 const nextKind = kind ?? prev.kind
135 const nextChange = change ?? (nextKind === prev.kind ? prev.change : null) ?? prev.change
136 if (nextKind === prev.kind && nextChange === prev.change) return
137 const now = await $.clock.now()
138 const next: Phase = { kind: nextKind, change: nextChange, since: nextKind === prev.kind ? prev.since : now }
139 await update($, phase, () => next)
140 // A different change drops the old task list
141 const cur = await read($, tasks)
142 if (cur && nextChange && cur.change !== nextChange) await update($, tasks, () => null)
143 await update($, updatedAt, () => now)
144 await refreshStatus($)
145 if (nextKind === 'apply' && nextChange) await loadTasks($, nextChange).catch(() => {})
146 if (nextKind === 'apply' && autoOpen && !autoOpened) {
147 autoOpened = true
148 void openPane($, { asked: false }).catch(() => {})
149 }
150}
151
152async function clearPhase($: any): Promise<void> {
153 const now = await $.clock.now()
154 await update($, phase, () => IDLE_PHASE)
155 await update($, tasks, () => null)
156 await update($, updatedAt, () => now)
157 await refreshStatus($)
158}
159
160// ── tasks.md ───────────────────────────────────────────────────────────────
161
162async function applyTasksText($: any, file: string, change: string, text: string, keepCurrent = true): Promise<Tasks> {
163 const parsed = parseTasks(text)
164 const prev = await read($, tasks)
165 const now = await $.clock.now()
166 const next: Tasks = {
167 file,
168 change,
169 items: parsed.items,
170 sections: parsed.sections,
171 current: keepCurrent && prev && prev.file === file ? prev.current : null,
172 updatedAt: now,
173 }
174 await update($, tasks, () => next)
175 await update($, updatedAt, () => now)
176 await refreshStatus($)
177 return next
178}
179
180/** Reads a change's tasks.md; silently gives up when there is none */
181async function loadTasks($: any, change: string): Promise<Tasks | null> {
182 const file = tasksPathOf(await $.session.root(), change)
183 let text: string
184 try {
185 text = await $.fs.read(file)
186 } catch {
187 return null
188 }
189 return applyTasksText($, file, change, text)
190}
191
192/** The tasks.md to use: the tracked one, the current change's, or the only active change's */
193async function resolveTasksFile($: any): Promise<{ file: string; change: string } | null> {
194 const cur = await read($, tasks)
195 if (cur) return { file: cur.file, change: cur.change }
196 const p = await read($, phase)
197 if (p.change) return { file: tasksPathOf(await $.session.root(), p.change), change: p.change }
198 try {
199 const dir = `${await $.session.root()}/openspec/changes`
200 const names = (await $.fs.list(dir)).filter((e: any) => e.kind === 'dir' && e.name !== 'archive').map((e: any) => String(e.name))
201 if (names.length === 1 && names[0]) return { file: `${dir}/${names[0]}/tasks.md`, change: names[0] }
202 } catch {}
203 return null
204}
205
206// ── Sub agents ─────────────────────────────────────────────────────────────
207
208async function patchAgent($: any, id: string, fn: (a: AgentRow) => AgentRow): Promise<AgentRow | null> {
209 let out: AgentRow | null = null
210 await update($, agents, map => {
211 const a = map[id]
212 if (!a) return map
213 out = fn(a)
214 return { ...map, [id]: out }
215 })
216 return out
217}
218
219async function refreshWaiting($: any): Promise<void> {
220 const map = await read($, agents)
221 const live = Object.values(map).filter(a => a.status === 'running' || a.status === 'waiting')
222 if (!live.length) return
223 let list: any[]
224 try {
225 list = await $.agent.list()
226 } catch {
227 return
228 }
229 const byId = new Map(list.map((a: any) => [a.id, a.status]))
230 for (const a of live) {
231 const s = byId.get(a.id)
232 if (s === undefined) continue
233 const nextStatus = s === 'waiting' || s === 'idle' ? 'waiting' : s === 'running' || s === 'pending' ? 'running' : null
234 if (nextStatus && nextStatus !== a.status) await patchAgent($, a.id, x => ({ ...x, status: nextStatus }))
235 }
236}
237
238// ── Pane ───────────────────────────────────────────────────────────────────
239
240async function openPane($: any, { asked }: { asked: boolean }): Promise<boolean> {
241 const opened = await $.ui.open({ id: PANE, title: 'opsx-board' })
242 const placed = opened?.isPlaced !== false
243 if (placed || asked) await update($, isPaneOpen, () => true)
244 return placed
245}
246
247async function onTick($: any): Promise<void> {
248 if (await read($, isPaused)) return
249 ticks += 1
250 const open = await read($, isPaneOpen)
251 if (!(await read($, isDemo)) && ticks % LIST_TICKS === 0) await refreshWaiting($).catch(() => {})
252 if (!open && ticks % IDLE_REDRAW_TICKS !== 0) return
253 if (open || hasSomething(await read($, phase), await read($, tasks), await read($, agents))) {
254 const now = await $.clock.now()
255 await update($, tickAt, () => now)
256 }
257}
258
259// ── Hooks ──────────────────────────────────────────────────────────────────
260
261export const register: Register = (on, options) => {
262 const o = (options ?? {}) as Record<string, unknown>
263 strict = o.strict === true
264 inject = o.inject_instructions !== false
265 autoOpen = o.auto_open !== false
266 langOption = o.language
267 bandStyle = parseBandStyle(o.band_style)
268
269 on('session.start', async ($, e, next) => {
270 const out = await next(e)
271 lang = resolveLang(langOption, {
272 LC_ALL: await $.env.get('LC_ALL'),
273 LC_MESSAGES: await $.env.get('LC_MESSAGES'),
274 LANG: await $.env.get('LANG'),
275 })
276 await update($, langState, () => lang)
277 await $.command.register({
278 name: 'opsx-board',
279 description: t(lang, 'cmd.description'),
280 argumentHint: '[clear|off|demo]',
281 })
282 await $.tool.register({
283 name: 'task',
284 description: toolDescription(lang),
285 inputSchema: {
286 type: 'object',
287 properties: {
288 task_id: { type: 'string', description: t(lang, 'tool.arg.taskId') },
289 status: { type: 'string', enum: ['start', 'done'], description: t(lang, 'tool.arg.status') },
290 note: { type: 'string', description: t(lang, 'tool.arg.note') },
291 },
292 required: ['task_id', 'status'],
293 },
294 })
295 await refreshStatus($)
296 if (e.isInteractive) {
297 $.clock.every(TICK_MS, () => {
298 void onTick($).catch(() => {})
299 })
300 }
301 return out
302 })
303
304 on('command.run', { command: 'opsx-board' }, async ($, e) => {
305 const arg = String(e.args ?? '').trim()
306 if (arg === 'off') {
307 await update($, isPaused, () => true)
308 $.ui.status(undefined)
309 if (await read($, isPaneOpen)) await $.ui.close({ id: PANE }).catch(() => {})
310 return { text: t(lang, 'cmd.off') }
311 }
312 if (arg === 'clear') {
313 await update($, agents, map => Object.fromEntries(Object.entries(map).filter(([, a]) => a.status === 'running' || a.status === 'waiting')))
314 await touch($)
315 return { text: t(lang, 'cmd.cleared') }
316 }
317 if (arg === 'demo') {
318 const next = !(await read($, isDemo))
319 await update($, isDemo, () => next)
320 await update($, isPaused, () => false)
321 if (next && !(await read($, isPaneOpen))) await openPane($, { asked: true })
322 await touch($)
323 return { text: t(lang, next ? 'cmd.demoOn' : 'cmd.demoOff') }
324 }
325 if (arg !== '') return { text: t(lang, 'cmd.usage') }
326 if (await read($, isPaused)) {
327 await update($, isPaused, () => false)
328 await refreshStatus($)
329 }
330 if (await read($, isPaneOpen)) {
331 await $.ui.close({ id: PANE }).catch(() => {})
332 await update($, isPaneOpen, () => false)
333 return { text: t(lang, 'cmd.closed') }
334 }
335 const placed = await openPane($, { asked: true })
336 return { text: t(lang, placed ? 'cmd.opened' : 'cmd.openedNarrow') }
337 })
338
339 on('ui.close', async ($, e, next) => {
340 if (e.id === PANE) await update($, isPaneOpen, () => false)
341 return next(e)
342 })
343
344 // ── Phase: /opsx:<kind>, openspec-<kind> skill, the Skill tool ────────────
345
346 on('command.run', { command: /^opsx[:/]/ }, async ($, e, next) => {
347 const found = phaseFromCommand(e.command, e.args)
348 if (found) await setPhase($, found.kind, found.change)
349 return next(e)
350 })
351
352 on('skill.prompt', { skill: /^openspec-/ }, async ($, e, next) => {
353 const kind = phaseFromSkill(e.skill)
354 if (kind) await setPhase($, kind, null)
355 const out = await next(e)
356 if (inject && e.skill === 'openspec-apply-change') return { text: `${out.text}\n\n${applyRule(lang)}` }
357 return out
358 })
359
360 on('prompt.compose', async ($, e, next) => {
361 const out = await next(e)
362 if (!inject || (await read($, isPaused))) return out
363 if ((await read($, phase)).kind !== 'apply') return out
364 if (out.sections.some(s => s.id === 'opsx-board:apply')) return out
365 return { sections: [...out.sections, { id: 'opsx-board:apply', text: applyRule(lang), scope: 'session' as const }] }
366 })
367
368 // ── The task tool ─────────────────────────────────────────────────────────
369
370 // A RegExp matcher: the plugin's own tool is not in the built-in tool union the types declare
371 on('tool.call', { tool: /^mcp__opsx-board__task$/ }, async ($, e) => {
372 const input = e as unknown as { task_id?: unknown; status?: unknown; note?: unknown }
373 const id = String(input.task_id ?? '').trim()
374 const status = String(input.status ?? '').trim()
375 if (!id || (status !== 'start' && status !== 'done')) {
376 return { result: { ok: false, error: t(lang, 'tool.err.args') } }
377 }
378 const where = await resolveTasksFile($)
379 if (!where) return { result: { ok: false, error: t(lang, 'tool.err.noTasks') } }
380 let text: string
381 try {
382 text = await $.fs.read(where.file)
383 } catch {
384 return { result: { ok: false, error: t(lang, 'tool.err.read', { file: where.file }) } }
385 }
386 if ((await read($, phase)).kind === 'idle') await setPhase($, 'apply', where.change)
387 if (status === 'start') {
388 const list = await applyTasksText($, where.file, where.change, text, false)
389 const item = list.items.find(i => i.id === id)
390 if (!item) return { result: { ok: false, error: t(lang, 'tool.err.unknown', { id }), known: list.items.filter(i => !i.done).map(i => i.id) } }
391 await update($, tasks, cur => (cur ? { ...cur, current: id } : cur))
392 await refreshStatus($)
393 const p = progressOf(list.items)
394 return { result: { ok: true, task: `${item.id} ${item.title}`, progress: `${p.done}/${p.total}` } }
395 }
396 const marked = markDone(text, id)
397 if (!marked.found) {
398 const list = await applyTasksText($, where.file, where.change, text)
399 return { result: { ok: false, error: t(lang, 'tool.err.unknown', { id }), known: list.items.filter(i => !i.done).map(i => i.id) } }
400 }
401 if (marked.changed) await $.fs.write(where.file, marked.text)
402 const list = await applyTasksText($, where.file, where.change, marked.text, false)
403 const p = progressOf(list.items)
404 const nextItem = list.items.find(i => !i.done) ?? null
405 const done = list.items.find(i => i.id === id)
406 toast($, t(lang, 'toast.taskDone', { id, title: truncate(done?.title ?? '', 30), progress: `${p.done}/${p.total}` }))
407 return {
408 result: {
409 ok: true,
410 marked: marked.changed,
411 progress: `${p.done}/${p.total}`,
412 next: nextItem ? `${nextItem.id} ${nextItem.title}` : null,
413 all_done: p.done === p.total,
414 },
415 }
416 })
417
418 // ── Other tool calls: phase from paths / Bash, tasks.md tracking, agent counters ──
419
420 on('tool.call', async ($, e, next) => {
421 const tool = String(e.tool)
422 const anyE = e as unknown as Record<string, unknown>
423 const path = typeof anyE.file_path === 'string' ? anyE.file_path : null
424
425 // A sub agent's tool call: count it and note the last action
426 if (e.agentId) {
427 const a = (await read($, agents))[e.agentId]
428 if (a) await patchAgent($, e.agentId, x => ({ ...x, tools: x.tools + 1, lastTool: detailOf(tool, e) }))
429 }
430
431 if (tool === 'Skill') {
432 const kind = phaseFromSkill(String(anyE.skill ?? ''))
433 if (kind) await setPhase($, kind, null)
434 return next(e)
435 }
436
437 if (tool === 'Bash' && typeof anyE.command === 'string') {
438 const found = parseOpenspecBash(anyE.command)
439 if (found) {
440 if (found.sub === 'archive') {
441 const ran = await next(e)
442 if (ran.deny === undefined && ran.isError !== true) await clearPhase($)
443 return ran
444 }
445 if (found.sub === 'new change') await setPhase($, 'propose', found.change)
446 else if (found.change) await setPhase($, null, found.change)
447 }
448 return next(e)
449 }
450
451 if (path && changeFromPath(path)) {
452 const change = changeFromPath(path) as string
453 if (!isTasksFile(path)) {
454 await setPhase($, null, change)
455 return next(e)
456 }
457 // tasks.md
458 if (tool === 'Read') {
459 const ran = await next(e)
460 try {
461 await applyTasksText($, path, change, await $.fs.read(path))
462 } catch {}
463 return ran
464 }
465 if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit') {
466 let before = ''
467 try {
468 before = await $.fs.read(path)
469 } catch {}
470 const prevItems = parseTasks(before).items
471 let predicted = 0
472 const sim = simulateEdit(before, anyE as any)
473 if (sim !== null) predicted = flippedDone(prevItems, parseTasks(sim).items)
474 if (strict && predicted > 1) return { deny: strictDenyText(lang, predicted) }
475 const ran = await next(e)
476 if (ran.deny !== undefined || ran.isError === true) return ran
477 let after = sim ?? before
478 try {
479 after = await $.fs.read(path)
480 } catch {}
481 // Ticking checkboxes in tasks.md means apply; with no phase known yet, assume it
482 await setPhase($, (await read($, phase)).kind === 'idle' ? 'apply' : null, change)
483 const list = await applyTasksText($, path, change, after)
484 const flipped = flippedDone(prevItems, list.items)
485 if (flipped > 1) return { ...ran, context: [...(ran.context ?? []), nudgeText(lang, flipped)] }
486 return ran
487 }
488 }
489 return next(e)
490 })
491
492 // ── Sub agents ───────────────────────────────────────────────────────────
493
494 on('agent.spawn', async ($, e, next) => {
495 const r = await next(e)
496 if (r.deny !== undefined || !r.agentId) return r
497 const now = await $.clock.now()
498 const row = newAgent({
499 id: r.agentId,
500 description: e.description,
501 type: e.subagentType,
502 model: r.model,
503 parentId: e.parentAgentId,
504 background: e.background,
505 now,
506 })
507 await update($, agents, map => pruneAgents({ ...map, [row.id]: row }))
508 await update($, updatedAt, () => now)
509 if (!(await read($, isPaused))) toast($, spawnToast(lang, row))
510 if (autoOpen && !autoOpened && !(await read($, isPaused))) {
511 autoOpened = true
512 void openPane($, { asked: false }).catch(() => {})
513 }
514 return r
515 })
516
517 on('turn.step', async function* ($, e, next) {
518 const id = e.agentId
519 const known = id !== undefined && (await read($, agents))[id] !== undefined
520 if (known && id) {
521 await patchAgent($, id, a => ({
522 ...a,
523 effort: a.effort ?? (e.effort === undefined ? null : String(e.effort)),
524 model: a.model || e.model,
525 }))
526 }
527 const r = yield* next(e)
528 if (known && id) {
529 const first = r.toolUses[0]
530 await patchAgent($, id, a => {
531 const withUsage = addUsage(a, r.usage)
532 return {
533 ...withUsage,
534 steps: a.steps + 1,
535 model: r.usage?.model ?? a.model,
536 lastTool: first ? detailOf(first.name, first.input) : a.lastTool,
537 }
538 })
539 const now = await $.clock.now()
540 await update($, updatedAt, () => now)
541 }
542 return r
543 })
544
545 on('turn.complete', async ($, e, next) => {
546 const id = e.agentId
547 if (id === undefined) return next(e)
548 const a = (await read($, agents))[id]
549 if (!a) return next(e)
550 const now = await $.clock.now()
551 const status = statusFromReason(e.reason)
552 const reason = e.reason === 'refusal' ? `refusal${e.refusal.category ? ` (${e.refusal.category})` : ''}` : e.reason
553 const row = await patchAgent($, id, x => {
554 const withUsage = x.steps === 0 ? addUsage(x, e.usage) : x
555 return { ...withUsage, status, endedAt: now, failReason: status === 'completed' ? null : reason }
556 })
557 await update($, updatedAt, () => now)
558 if (row && !(await read($, isPaused))) toast($, endToast(lang, row))
559 return next(e)
560 })
561
562 // ── Drawing ──────────────────────────────────────────────────────────────
563
564 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
565 if ((await read($, isPaused)) || e.props.hasSurvey) return next(e)
566 await read($, tickAt)
567 const L = await read($, langState)
568 const demo = await read($, isDemo)
569 const now = demo ? DEMO_NOW : await $.clock.now()
570 const text = demo
571 ? bandText(L, DEMO_PHASE, DEMO_TASKS, DEMO_AGENTS, DEMO_NOW - 3000, now)
572 : bandText(L, await read($, phase), await read($, tasks), await read($, agents), await read($, updatedAt), now)
573 if (text === null) return next(e)
574 const ui = $.ui.resolve(e)
575 const { Text } = ui
576 // AbovePrompt is a chain: draw our line in its frame, then whatever the plugins beneath drew
577 const below = await next(e)
578 return frameBand(
579 ui,
580 bandStyle,
581 false,
582 e.props.bodyColumns,
583 <Text wrap="truncate-end" dimColor>
584 {text}
585 </Text>,
586 below,
587 )
588 })
589
590 on('ui.render', { component: 'Pane', requestId: 'opsx-board' }, async ($, e) => {
591 const { Box, Text, Button } = $.ui.resolve(e)
592 await read($, tickAt)
593 const L = await read($, langState)
594 const demo = await read($, isDemo)
595 const now = demo ? DEMO_NOW : await $.clock.now()
596 const rows: PaneRow[] = demo
597 ? paneModel(L, DEMO_PHASE, DEMO_TASKS, DEMO_AGENTS, now)
598 : paneModel(L, await read($, phase), await read($, tasks), await read($, agents), now)
599 const okAt = demo ? DEMO_NOW - 2000 : await read($, updatedAt)
600 const list = demo ? DEMO_TASKS : await read($, tasks)
601 const cur = currentTask(list)
602 const isDeclared = cur !== null && list !== null && list.current === cur.id
603 return (
604 <Box flexDirection="column">
605 {demo && (
606 <Text key="demo" color="cyan">
607 {t(L, 'pane.demo')}
608 </Text>
609 )}
610 {rows.map((row, i) =>
611 row.rule ? (
612 <Text key={`r${i}`} dimColor>
613 {'─'.repeat(Math.max(8, Math.min(e.props.bodyColumns ?? 40, 200)))}
614 </Text>
615 ) : (
616 <Text
617 key={`r${i}`}
618 wrap="truncate-end"
619 bold={row.tone === 'bold' || undefined}
620 dimColor={row.tone === 'dim' || undefined}
621 color={row.tone === 'warn' ? 'yellow' : row.tone === 'ok' ? 'green' : undefined}
622 >
623 {row.text}
624 </Text>
625 ),
626 )}
627 <Text key="footer" dimColor wrap="truncate-end">
628 {okAt > 0 ? t(L, 'pane.updated', { s: Math.max(0, Math.round((now - okAt) / 1000)) }) : t(L, 'pane.noEvents')}
629 {cur ? t(L, isDeclared ? 'pane.currentDeclared' : 'pane.currentInferred') : ''}
630 </Text>
631 <Box>
632 <Button
633 key="clear"
634 label={t(L, 'pane.clear')}
635 onPress={() =>
636 void update($, agents, map => Object.fromEntries(Object.entries(map).filter(([, a]) => a.status === 'running' || a.status === 'waiting'))).catch(() => {})
637 }
638 />
639 <Button key="close" label={t(L, 'pane.close')} role="dismiss" onPress={() => void $.ui.close({ id: PANE }).catch(() => {})} />
640 </Box>
641 </Box>
642 )
643 })
644}
645hooks/demo.ts 134 lines1// Demo-mode fake data: a way to see the board before there is an OpenSpec project or a sub agent.
2// Reads nothing, writes nothing. Task titles and agent descriptions are English and language-neutral;
3// the labels around them are localized through the UI language.
4
5import type { AgentRow, Phase, Tasks } from '../types'
6
7export const DEMO_NOW = Date.UTC(2026, 9, 4, 1, 0, 0)
8
9export const DEMO_PHASE: Phase = { kind: 'apply', change: 'add-auth', since: DEMO_NOW - 15 * 60000 }
10
11const item = (id: string, title: string, done: boolean, line: number, section: string) => ({ id, title, done, line, section })
12
13export const DEMO_TASKS: Tasks = {
14 file: '/home/demo/proj/openspec/changes/add-auth/tasks.md',
15 change: 'add-auth',
16 sections: [
17 { title: '1. Schema', line: 2 },
18 { title: '2. API', line: 6 },
19 { title: '3. Token', line: 10 },
20 ],
21 items: [
22 item('1.1', 'Add users and sessions tables', true, 3, '1. Schema'),
23 item('1.2', 'Add migration and rollback', true, 4, '1. Schema'),
24 item('2.1', 'Implement /login and /logout', true, 7, '2. API'),
25 item('2.2', 'Implement /me and permission checks', true, 8, '2. API'),
26 item('3.1', 'Issue access tokens', true, 11, '3. Token'),
27 item('3.2', 'Implement token refresh', false, 12, '3. Token'),
28 item('3.3', 'Refresh token rotation and revocation', false, 13, '3. Token'),
29 item('3.4', 'Add unit tests', false, 14, '3. Token'),
30 item('3.5', 'Docs and examples', false, 15, '3. Token'),
31 ],
32 current: '3.2',
33 updatedAt: DEMO_NOW - 3000,
34}
35
36function agent(partial: Partial<AgentRow> & Pick<AgentRow, 'id' | 'description' | 'type' | 'model' | 'status' | 'startedAt'>): AgentRow {
37 return {
38 effort: null,
39 endedAt: null,
40 input: 0,
41 output: 0,
42 cacheRead: 0,
43 cacheWrite: 0,
44 steps: 0,
45 tools: 0,
46 lastTool: '',
47 parentId: null,
48 background: false,
49 failReason: null,
50 ...partial,
51 }
52}
53
54export const DEMO_AGENTS: Record<string, AgentRow> = Object.fromEntries(
55 [
56 agent({
57 id: 'a1',
58 description: 'Search auth-related code',
59 type: 'Explore',
60 model: 'claude-sonnet-5-5',
61 effort: 'medium',
62 status: 'running',
63 startedAt: DEMO_NOW - 42000,
64 input: 1800,
65 output: 1200,
66 cacheRead: 9400,
67 steps: 4,
68 tools: 7,
69 lastTool: 'Grep "refreshToken"',
70 }),
71 agent({
72 id: 'a2',
73 description: 'Write unit tests',
74 type: 'general-purpose',
75 model: 'claude-opus-5-5',
76 effort: 'high',
77 status: 'running',
78 startedAt: DEMO_NOW - 12000,
79 input: 4000,
80 output: 2600,
81 cacheRead: 24000,
82 steps: 2,
83 tools: 3,
84 lastTool: 'Write auth.test.ts',
85 }),
86 agent({
87 id: 'a3',
88 description: 'Check schema diff',
89 type: 'Explore',
90 model: 'claude-haiku-4-5-20251001',
91 effort: 'low',
92 status: 'completed',
93 startedAt: DEMO_NOW - 10 * 60000,
94 endedAt: DEMO_NOW - 10 * 60000 + 72000,
95 input: 900,
96 output: 700,
97 cacheRead: 3500,
98 steps: 3,
99 tools: 5,
100 }),
101 agent({
102 id: 'a4',
103 description: 'Tidy API docs',
104 type: 'general-purpose',
105 model: 'claude-sonnet-5-5',
106 effort: 'medium',
107 status: 'completed',
108 startedAt: DEMO_NOW - 8 * 60000,
109 endedAt: DEMO_NOW - 8 * 60000 + 160000,
110 input: 3000,
111 output: 4000,
112 cacheRead: 15000,
113 steps: 6,
114 tools: 11,
115 }),
116 agent({
117 id: 'a5',
118 description: 'Run e2e',
119 type: 'general-purpose',
120 model: 'claude-opus-5-5',
121 effort: 'high',
122 status: 'failed',
123 startedAt: DEMO_NOW - 5 * 60000,
124 endedAt: DEMO_NOW - 5 * 60000 + 95000,
125 input: 2000,
126 output: 1000,
127 cacheRead: 12000,
128 steps: 5,
129 tools: 9,
130 failReason: 'error',
131 }),
132 ].map(a => [a.id, a]),
133)
134hooks/i18n.ts 242 lines1// opsx-board i18n: the UI language, how it is resolved, and every string a person
2// or the model reads, in English, Traditional Chinese and Japanese.
3// Pure: no `$`. Shared with logic.ts and the tests.
4
5export type Lang = 'en' | 'zh-TW' | 'ja'
6export const LANGS: readonly Lang[] = ['en', 'zh-TW', 'ja']
7export const DEFAULT_LANG: Lang = 'en'
8
9export type LangEnv = { LC_ALL?: string; LC_MESSAGES?: string; LANG?: string }
10
11/**
12 * Picks the language: an explicit option (`en`, `zh-TW`, `ja`) wins; `auto`,
13 * undefined or anything else reads LC_ALL, then LC_MESSAGES, then LANG.
14 * Any `zh*` locale maps to zh-TW (only Traditional is shipped), `ja*` to ja,
15 * everything else (including C, POSIX and empty) to en.
16 */
17export function resolveLang(option: unknown, env: LangEnv): Lang {
18 if (option === 'en' || option === 'zh-TW' || option === 'ja') return option
19 for (const raw of [env.LC_ALL, env.LC_MESSAGES, env.LANG]) {
20 const v = (raw ?? '').trim()
21 if (!v) continue
22 const low = v.toLowerCase()
23 if (low === 'c' || low === 'posix') return 'en'
24 if (low.startsWith('zh')) return 'zh-TW'
25 if (low.startsWith('ja')) return 'ja'
26 return 'en'
27 }
28 return DEFAULT_LANG
29}
30
31export type Params = Record<string, string | number>
32type Message = string | ((p: Params) => string)
33
34const en = {
35 // groups
36 'group.running': 'running',
37 'group.waiting': 'waiting',
38 'group.done': 'done',
39 // agent rows
40 'agent.ranFor': (p: Params) => `running ${p.d}`,
41 'agent.waitedFor': (p: Params) => `waiting ${p.d}`,
42 'agent.failed': (p: Params) => `failed: ${p.reason}`,
43 'agent.killed': (p: Params) => `stopped · ${p.d}`,
44 'agent.step': (p: Params) => `step ${p.n}`,
45 'agent.tools': (p: Params) => `${p.n} tools`,
46 'agent.last': (p: Params) => `last ${p.tool}`,
47 // toasts
48 'toast.spawn': (p: Params) => `▶ ${p.name} started (${p.type})`,
49 'toast.done': (p: Params) => `✓ ${p.name} done · ${p.tok} tok · ${p.d}`,
50 'toast.killed': (p: Params) => `■ ${p.name} stopped`,
51 'toast.failed': (p: Params) => `✗ ${p.name} failed: ${p.reason}`,
52 'toast.taskDone': (p: Params) => `✓ ${p.id} ${p.title} · ${p.progress}`,
53 // band
54 'band.ago': (p: Params) => `${p.d} ago`,
55 'band.running': (p: Params) => `⚇ ${p.n} agent${Number(p.n) === 1 ? '' : 's'} running`,
56 'band.waiting': (p: Params) => `${p.n} waiting`,
57 'band.done': (p: Params) => `${p.n} done`,
58 'band.doneOnly': (p: Params) => `⚇ ${p.n} agent${Number(p.n) === 1 ? '' : 's'} done`,
59 // pane
60 'pane.idle': 'OpenSpec · no opsx flow running (/opsx:propose or /opsx:apply shows the phase here)',
61 'pane.inferred': ' (inferred)',
62 'pane.allDone': '✓ All tasks done, ready for /opsx:archive',
63 'pane.noItems': ' No "- [ ]" items in tasks.md',
64 'pane.noTasksYet': ' tasks.md not read yet (it appears once the model reads or edits it)',
65 'pane.agentsHead': (p: Params) => `Sub agents · ${p.n} this session · ${p.tok} tok total`,
66 'pane.noAgents': 'Sub agents · none yet',
67 'pane.group': (p: Params) => `${p.label} (${p.n})`,
68 'pane.demo': 'demo mode: fake data (/opsx-board demo to leave)',
69 'pane.updated': (p: Params) => `Updated ${p.s}s ago`,
70 'pane.noEvents': 'No events yet',
71 'pane.currentDeclared': ' current task reported by the model',
72 'pane.currentInferred': ' current task inferred from the file',
73 'pane.clear': 'Clear finished',
74 'pane.close': 'Close',
75 // commands
76 'cmd.description': 'OpenSpec board: toggle the pane; /opsx-board clear drops finished agents, off pauses, demo shows fake data',
77 'cmd.off': 'opsx-board paused (band, pane and status line hidden; events are still recorded). /opsx-board to resume.',
78 'cmd.cleared': 'opsx-board: finished sub agents cleared.',
79 'cmd.demoOn': 'opsx-board demo mode: fake data. /opsx-board demo again to leave.',
80 'cmd.demoOff': 'opsx-board left demo mode.',
81 'cmd.usage': 'Usage: /opsx-board (toggle pane), /opsx-board clear (drop finished agents), /opsx-board off (pause), /opsx-board demo (fake data)',
82 'cmd.closed': 'opsx-board pane closed (band and status line stay; /opsx-board off pauses).',
83 'cmd.opened': 'opsx-board pane opened.',
84 'cmd.openedNarrow': 'opsx-board pane opened, but the terminal is too narrow to draw it; widen it and it appears.',
85 // tool (model-facing)
86 'tool.description':
87 'Report OpenSpec task progress (opsx-board). When implementing tasks from tasks.md: call with { task_id, status: "start" } before beginning a task and { task_id, status: "done" } right after finishing it. The tool ticks the checkbox in tasks.md for you; never edit the checkboxes yourself. task_id is the number in tasks.md, e.g. "3.2".',
88 'tool.arg.taskId': 'The task number in tasks.md, e.g. "3.2"',
89 'tool.arg.status': 'start = beginning this task; done = finished, tick it for me',
90 'tool.arg.note': 'Optional one-line note',
91 'tool.err.args': 'Both task_id and status (start|done) are required.',
92 'tool.err.noTasks': 'tasks.md not found: the current change is unknown. Run /opsx:apply <change> first, or read openspec/changes/<change>/tasks.md once.',
93 'tool.err.read': (p: Params) => `Cannot read ${p.file}`,
94 'tool.err.unknown': (p: Params) => `No task numbered ${p.id} in tasks.md.`,
95 'rule.apply':
96 '# opsx-board: task reporting rule\n' +
97 'When implementing the tasks.md of an OpenSpec change, call mcp__opsx-board__task {"task_id": "<number>", "status": "start"} before each task ' +
98 'and {"task_id": "<number>", "status": "done"} right after finishing it. One task at a time: do not finish several and report them together, ' +
99 'and never edit the [ ] / [x] checkboxes in tasks.md yourself (the tool ticks them).',
100 'rule.nudge': (p: Params) =>
101 `opsx-board: this edit marked ${p.n} tasks done at once. From now on call mcp__opsx-board__task {task_id, status:"done"} after each task so the board shows progress as it happens.`,
102 'rule.strictDeny': (p: Params) =>
103 `opsx-board (strict mode): this edit would mark ${p.n} tasks done at once and was refused. Use the mcp__opsx-board__task tool instead and report status:"done" after each task; it ticks tasks.md for you.`,
104} as const
105
106export type MessageKey = keyof typeof en
107export type Messages = Record<MessageKey, Message>
108
109const zhTW: Messages = {
110 'group.running': '跑著',
111 'group.waiting': '等待中',
112 'group.done': '做完',
113 'agent.ranFor': p => `跑了 ${p.d}`,
114 'agent.waitedFor': p => `等了 ${p.d}`,
115 'agent.failed': p => `失敗:${p.reason}`,
116 'agent.killed': p => `已停止 · ${p.d}`,
117 'agent.step': p => `step ${p.n}`,
118 'agent.tools': p => `${p.n} tools`,
119 'agent.last': p => `最近 ${p.tool}`,
120 'toast.spawn': p => `▶ ${p.name} 開始(${p.type})`,
121 'toast.done': p => `✓ ${p.name} 完成 · ${p.tok} tok · ${p.d}`,
122 'toast.killed': p => `■ ${p.name} 已停止`,
123 'toast.failed': p => `✗ ${p.name} 失敗:${p.reason}`,
124 'toast.taskDone': p => `✓ ${p.id} ${p.title} · ${p.progress}`,
125 'band.ago': p => `${p.d} 前`,
126 'band.running': p => `⚇ ${p.n} agent${Number(p.n) > 1 ? 's' : ''} 跑著`,
127 'band.waiting': p => `${p.n} 等待中`,
128 'band.done': p => `${p.n} 做完`,
129 'band.doneOnly': p => `⚇ ${p.n} agent${Number(p.n) > 1 ? 's' : ''} 做完`,
130 'pane.idle': 'OpenSpec · 沒在跑 opsx 流程(/opsx:propose、/opsx:apply 進入後會顯示階段)',
131 'pane.inferred': '(推斷)',
132 'pane.allDone': '✓ 全部任務完成,可以 /opsx:archive',
133 'pane.noItems': ' tasks.md 裡沒有 - [ ] 項目',
134 'pane.noTasksYet': ' 還沒讀到 tasks.md(模型讀或改它之後就會出現)',
135 'pane.agentsHead': p => `Sub agents · 本 session 共 ${p.n} 個 · 合計 ${p.tok} tok`,
136 'pane.noAgents': 'Sub agents · 還沒有 sub agent',
137 'pane.group': p => `${p.label}(${p.n})`,
138 'pane.demo': 'demo 模式:假資料(/opsx-board demo 結束)',
139 'pane.updated': p => `上次更新:${p.s}s 前`,
140 'pane.noEvents': '還沒有事件',
141 'pane.currentDeclared': ' 目前任務由模型回報',
142 'pane.currentInferred': ' 目前任務由檔案推斷',
143 'pane.clear': '清除已完成',
144 'pane.close': '關閉',
145 'cmd.description': 'OpenSpec 看板:開關面板;/opsx-board clear 清除做完的 agent、off 暫停、demo 假資料',
146 'cmd.off': 'opsx-board 已暫停(band、面板、狀態列都收起;事件仍照常記錄)。/opsx-board 恢復。',
147 'cmd.cleared': 'opsx-board:已清除做完的 sub agent。',
148 'cmd.demoOn': 'opsx-board demo 模式:假資料。再打 /opsx-board demo 結束。',
149 'cmd.demoOff': 'opsx-board 已離開 demo 模式。',
150 'cmd.usage': '用法:/opsx-board(開關面板)、/opsx-board clear(清除做完的 agent)、/opsx-board off(暫停)、/opsx-board demo(假資料)',
151 'cmd.closed': 'opsx-board 面板已關閉(band 與狀態列照常;/opsx-board off 暫停)。',
152 'cmd.opened': 'opsx-board 面板已開啟。',
153 'cmd.openedNarrow': 'opsx-board 面板已開啟,但終端機太窄還畫不出來;放寬就會出現。',
154 'tool.description':
155 'OpenSpec 任務進度回報(opsx-board)。實作 tasks.md 的任務時:開始一個任務前呼叫 { task_id, status: "start" },做完後立刻呼叫 { task_id, status: "done" },由本工具把 tasks.md 該行勾成 [x],不要自己編輯勾選框。task_id 是 tasks.md 裡的編號,例如 "3.2"。',
156 'tool.arg.taskId': 'tasks.md 裡的任務編號,例如 "3.2"',
157 'tool.arg.status': 'start=開始做這個任務;done=做完了,請替我勾選',
158 'tool.arg.note': '選填:一句話說明',
159 'tool.err.args': 'task_id 與 status(start|done)都要給。',
160 'tool.err.noTasks': '找不到 tasks.md:還不知道目前的 change。先用 /opsx:apply <change>,或讀一次 openspec/changes/<change>/tasks.md。',
161 'tool.err.read': p => `讀不到 ${p.file}`,
162 'tool.err.unknown': p => `tasks.md 裡沒有編號 ${p.id} 的任務。`,
163 'rule.apply':
164 '# opsx-board:任務回報規則\n' +
165 '實作 OpenSpec change 的 tasks.md 時,每個任務開始前呼叫 mcp__opsx-board__task {"task_id": "<編號>", "status": "start"},' +
166 '完成後立刻呼叫 {"task_id": "<編號>", "status": "done"};一次只做一個任務,不要把多個任務做完再一起回報,' +
167 '也不要直接編輯 tasks.md 的 [ ] / [x](工具會替你勾)。',
168 'rule.nudge': p => `opsx-board:這次編輯一次把 ${p.n} 個任務標成完成。之後請每完成一個任務就呼叫 mcp__opsx-board__task {task_id, status:"done"} 回報,看板才能即時顯示進度。`,
169 'rule.strictDeny': p => `opsx-board(嚴格模式):這次編輯會一次把 ${p.n} 個任務標成完成,已拒絕。請改用 mcp__opsx-board__task 工具,每完成一個任務回報一次 status:"done",工具會替你勾 tasks.md。`,
170}
171
172const ja: Messages = {
173 'group.running': '実行中',
174 'group.waiting': '待機中',
175 'group.done': '完了',
176 'agent.ranFor': p => `${p.d} 経過`,
177 'agent.waitedFor': p => `${p.d} 待機`,
178 'agent.failed': p => `失敗:${p.reason}`,
179 'agent.killed': p => `停止 · ${p.d}`,
180 'agent.step': p => `step ${p.n}`,
181 'agent.tools': p => `${p.n} tools`,
182 'agent.last': p => `直近 ${p.tool}`,
183 'toast.spawn': p => `▶ ${p.name} 開始(${p.type})`,
184 'toast.done': p => `✓ ${p.name} 完了 · ${p.tok} tok · ${p.d}`,
185 'toast.killed': p => `■ ${p.name} 停止`,
186 'toast.failed': p => `✗ ${p.name} 失敗:${p.reason}`,
187 'toast.taskDone': p => `✓ ${p.id} ${p.title} · ${p.progress}`,
188 'band.ago': p => `${p.d}前`,
189 'band.running': p => `⚇ ${p.n} agent${Number(p.n) > 1 ? 's' : ''} 実行中`,
190 'band.waiting': p => `${p.n} 待機中`,
191 'band.done': p => `${p.n} 完了`,
192 'band.doneOnly': p => `⚇ ${p.n} agent${Number(p.n) > 1 ? 's' : ''} 完了`,
193 'pane.idle': 'OpenSpec · opsx フローは動いていません(/opsx:propose や /opsx:apply で段階が表示されます)',
194 'pane.inferred': '(推定)',
195 'pane.allDone': '✓ 全タスク完了、/opsx:archive できます',
196 'pane.noItems': ' tasks.md に - [ ] の項目がありません',
197 'pane.noTasksYet': ' tasks.md をまだ読んでいません(モデルが読むか編集すると表示されます)',
198 'pane.agentsHead': p => `Sub agents · このセッションで ${p.n} 件 · 合計 ${p.tok} tok`,
199 'pane.noAgents': 'Sub agents · まだありません',
200 'pane.group': p => `${p.label}(${p.n})`,
201 'pane.demo': 'デモモード:ダミーデータ(/opsx-board demo で終了)',
202 'pane.updated': p => `最終更新:${p.s}秒前`,
203 'pane.noEvents': 'まだイベントがありません',
204 'pane.currentDeclared': ' 現在のタスクはモデルの報告',
205 'pane.currentInferred': ' 現在のタスクはファイルから推定',
206 'pane.clear': '完了分を消す',
207 'pane.close': '閉じる',
208 'cmd.description': 'OpenSpec ボード:ペインの開閉;/opsx-board clear で完了した agent を消去、off で一時停止、demo でダミーデータ',
209 'cmd.off': 'opsx-board を一時停止しました(バンド・ペイン・ステータス行を非表示;イベントは記録し続けます)。/opsx-board で再開。',
210 'cmd.cleared': 'opsx-board:完了した sub agent を消去しました。',
211 'cmd.demoOn': 'opsx-board デモモード:ダミーデータ。もう一度 /opsx-board demo で終了。',
212 'cmd.demoOff': 'opsx-board デモモードを終了しました。',
213 'cmd.usage': '使い方:/opsx-board(ペイン開閉)、/opsx-board clear(完了した agent を消去)、/opsx-board off(一時停止)、/opsx-board demo(ダミーデータ)',
214 'cmd.closed': 'opsx-board ペインを閉じました(バンドとステータス行はそのまま;/opsx-board off で一時停止)。',
215 'cmd.opened': 'opsx-board ペインを開きました。',
216 'cmd.openedNarrow': 'opsx-board ペインを開きましたが、端末幅が足りず描画できません。広げると表示されます。',
217 'tool.description':
218 'OpenSpec タスク進捗の報告(opsx-board)。tasks.md のタスクを実装するとき:タスク開始前に { task_id, status: "start" }、完了直後に { task_id, status: "done" } を呼ぶこと。このツールが tasks.md の該当行を [x] にする。チェックボックスを自分で編集しないこと。task_id は tasks.md の番号、例 "3.2"。',
219 'tool.arg.taskId': 'tasks.md のタスク番号、例 "3.2"',
220 'tool.arg.status': 'start=このタスクを開始;done=完了したのでチェックを付けて',
221 'tool.arg.note': '任意:一言メモ',
222 'tool.err.args': 'task_id と status(start|done)の両方が必要です。',
223 'tool.err.noTasks': 'tasks.md が見つかりません:現在の change が不明です。先に /opsx:apply <change> を実行するか、openspec/changes/<change>/tasks.md を一度読んでください。',
224 'tool.err.read': p => `${p.file} を読めません`,
225 'tool.err.unknown': p => `tasks.md に番号 ${p.id} のタスクはありません。`,
226 'rule.apply':
227 '# opsx-board:タスク報告ルール\n' +
228 'OpenSpec change の tasks.md を実装するときは、各タスクの開始前に mcp__opsx-board__task {"task_id": "<番号>", "status": "start"}、' +
229 '完了直後に {"task_id": "<番号>", "status": "done"} を呼ぶこと。一度に一つのタスクだけ扱い、複数をまとめて報告しないこと。' +
230 'tasks.md の [ ] / [x] を自分で編集しないこと(ツールがチェックを付ける)。',
231 'rule.nudge': p => `opsx-board:この編集で ${p.n} 件のタスクが一度に完了になりました。今後はタスクごとに mcp__opsx-board__task {task_id, status:"done"} で報告してください。ボードが進捗をリアルタイムに表示できます。`,
232 'rule.strictDeny': p => `opsx-board(厳格モード):この編集は ${p.n} 件のタスクを一度に完了にするため拒否しました。代わりに mcp__opsx-board__task ツールでタスクごとに status:"done" を報告してください。ツールが tasks.md にチェックを付けます。`,
233}
234
235export const MESSAGES: Record<Lang, Messages> = { en: en as Messages, 'zh-TW': zhTW, ja }
236
237/** Looks a message up in `lang`, falling back to English when the key is missing there. */
238export function t(lang: Lang, key: MessageKey, params: Params = {}): string {
239 const m = MESSAGES[lang]?.[key] ?? MESSAGES.en[key]
240 return typeof m === 'function' ? m(params) : m
241}
242hooks/logic.ts 503 lines1// opsx-board pure functions: tasks.md parsing and rewriting, phase detection, agent
2// bookkeeping, band / pane layout, toast text. No `$` here; shared with register.tsx
3// and the tests. Every function that produces text takes the UI language.
4
5import type { AgentRow, AgentStatusKind, Phase, TaskItem, TaskSection, Tasks } from '../types'
6import { t } from './i18n'
7import type { Lang } from './i18n'
8
9export type { AgentRow, AgentStatusKind, Phase, TaskItem, TaskSection, Tasks }
10
11export const PLUGIN = 'opsx-board'
12export const PANE = 'opsx-board'
13export const TOOL = 'mcp__opsx-board__task'
14export const TICK_MS = 1000
15/** Redraw every N ticks while the pane is closed */
16export const IDLE_REDRAW_TICKS = 5
17/** Ask $.agent.list() every N ticks to refresh the "waiting" group */
18export const LIST_TICKS = 5
19export const TOAST_MS = 6000
20export const MAX_AGENTS = 50
21export const NEXT_TASKS = 3
22
23export const IDLE_PHASE: Phase = { kind: 'idle', change: null, since: 0 }
24
25// ── Text helpers ───────────────────────────────────────────────────────────
26
27export function truncate(s: string, max: number): string {
28 const one = s.replace(/\s+/g, ' ').trim()
29 return one.length <= max ? one : `${one.slice(0, Math.max(0, max - 1))}…`
30}
31
32/** Token count: raw under 1000, then 12.4k / 12k / 1.2M */
33export function fmt(n: number): string {
34 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
35 if (n >= 10_000) return `${Math.round(n / 1000)}k`
36 if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
37 return `${Math.max(0, Math.round(n))}`
38}
39
40export function duration(ms: number): string {
41 const s = Math.max(0, Math.floor(ms / 1000))
42 if (s < 60) return `${s}s`
43 if (s < 3600) return `${Math.floor(s / 60)}m${s % 60 ? `${String(s % 60).padStart(2, '0')}s` : ''}`
44 return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
45}
46
47/** Shortens a model id to its family: claude-sonnet-5-5 → sonnet; unknown ids pass through */
48export function shortModel(model: string): string {
49 const m = /^(?:[a-z]+\.)?(?:anthropic\.)?claude-(fable|opus|sonnet|haiku)\b/i.exec(model)
50 if (m && m[1]) return m[1].toLowerCase()
51 const alias = /^(fable|opus|sonnet|haiku)$/i.exec(model.trim())
52 return alias && alias[1] ? alias[1].toLowerCase() : model
53}
54
55export function baseName(path: string): string {
56 return path.slice(path.lastIndexOf('/') + 1)
57}
58
59/** One-line summary of a tool call: Read logic.ts, Bash npm test, Grep "refreshToken" */
60export function detailOf(tool: string, input: unknown): string {
61 const name = tool.replace(/^mcp__[^_]+(?:_[^_]+)*__/, '')
62 const o = (input && typeof input === 'object' ? input : {}) as Record<string, unknown>
63 const raw = o.file_path ?? o.pattern ?? o.command ?? o.query ?? o.url ?? o.description ?? o.skill ?? o.task_id ?? ''
64 let s = String(raw).replace(/\s+/g, ' ').trim()
65 if (typeof o.file_path === 'string') s = baseName(s)
66 if (typeof o.pattern === 'string' || typeof o.query === 'string') s = `"${s}"`
67 return s ? `${name} ${truncate(s, 32)}` : name
68}
69
70// ── Phase detection ────────────────────────────────────────────────────────
71
72const SKILL_PHASE: Record<string, string> = {
73 'openspec-explore': 'explore',
74 'openspec-propose': 'propose',
75 'openspec-apply-change': 'apply',
76 'openspec-archive-change': 'archive',
77 'openspec-sync-specs': 'sync',
78 'openspec-continue-change': 'propose',
79 'openspec-new-change': 'propose',
80 'openspec-verify-change': 'verify',
81}
82
83/** openspec-<x> skill name → phase; null for a skill that is not OpenSpec's */
84export function phaseFromSkill(skill: string): string | null {
85 if (SKILL_PHASE[skill]) return SKILL_PHASE[skill] as string
86 if (skill.startsWith('openspec-')) return skill.slice('openspec-'.length).replace(/-change$/, '')
87 return null
88}
89
90/** /opsx:<kind> [change] → phase and change name; null for any other command */
91export function phaseFromCommand(command: string, args: string): { kind: string; change: string | null } | null {
92 const m = /^opsx[:/]([a-z0-9-]+)$/i.exec(command.trim())
93 if (!m || !m[1]) return null
94 const first = args.trim().split(/\s+/)[0] ?? ''
95 const change = first && !first.startsWith('-') && isChangeName(first) ? first : null
96 return { kind: m[1].toLowerCase(), change }
97}
98
99const CHANGE_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/
100
101export function isChangeName(s: string): boolean {
102 return CHANGE_RE.test(s) && s !== 'archive'
103}
104
105/** The <change> in a path under openspec/changes/<change>/… (archive/ does not count) */
106export function changeFromPath(path: string): string | null {
107 const m = /(?:^|\/)openspec\/changes\/([^/]+)\//.exec(path)
108 if (!m || !m[1] || m[1] === 'archive') return null
109 return m[1]
110}
111
112export function isTasksFile(path: string): boolean {
113 return /(?:^|\/)openspec\/changes\/(?!archive\/)[^/]+\/tasks\.md$/.test(path)
114}
115
116export function tasksPathOf(root: string, change: string): string {
117 return `${root.replace(/\/+$/, '')}/openspec/changes/${change}/tasks.md`
118}
119
120/** An `openspec` command in a Bash line: its subcommand and the --change (or positional) name */
121export function parseOpenspecBash(command: string): { sub: string; change: string | null } | null {
122 const m = /(?:^|[;&|]\s*|\n\s*)openspec\s+(status|instructions|new\s+change|archive|validate|show|list|change)\b([^\n;&|]*)/.exec(command)
123 if (!m || !m[1]) return null
124 const sub = m[1].replace(/\s+/g, ' ')
125 const rest = m[2] ?? ''
126 let change: string | null = null
127 const flag = /--change\s+(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(rest)
128 if (flag) change = flag[1] ?? flag[2] ?? flag[3] ?? null
129 else {
130 const pos = rest
131 .trim()
132 .split(/\s+/)
133 .filter(tok => tok && !tok.startsWith('-'))
134 const cand = sub === 'instructions' ? pos[1] : pos[0]
135 if (cand && isChangeName(cand)) change = cand
136 }
137 if (change !== null && !isChangeName(change)) change = null
138 return { sub, change }
139}
140
141// ── tasks.md ───────────────────────────────────────────────────────────────
142
143const ITEM_RE = /^\s*[-*]\s+\[( |x|X)\]\s+(\d+(?:\.\d+)*)\.?\s+(.*)$/
144const SECTION_RE = /^\s*#{2,3}\s+(.*?)\s*$/
145
146export function parseTasks(text: string): { items: TaskItem[]; sections: TaskSection[] } {
147 const items: TaskItem[] = []
148 const sections: TaskSection[] = []
149 let section = ''
150 text.split('\n').forEach((raw, line) => {
151 const h = SECTION_RE.exec(raw)
152 if (h && h[1] !== undefined) {
153 section = h[1]
154 sections.push({ title: section, line })
155 return
156 }
157 const m = ITEM_RE.exec(raw)
158 if (!m || m[2] === undefined) return
159 items.push({ id: m[2], title: (m[3] ?? '').trim(), done: m[1] !== ' ', line, section })
160 })
161 return { items, sections }
162}
163
164export function progressOf(items: readonly TaskItem[]): { done: number; total: number } {
165 return { done: items.filter(i => i.done).length, total: items.length }
166}
167
168/** The current task: the one the model declared, else the first undone one; null when all are done */
169export function currentTask(tasks: Tasks | null): TaskItem | null {
170 if (!tasks) return null
171 if (tasks.current !== null) {
172 const declared = tasks.items.find(i => i.id === tasks.current)
173 if (declared && !declared.done) return declared
174 }
175 return tasks.items.find(i => !i.done) ?? null
176}
177
178/** Up to n undone tasks after the current one */
179export function nextTasks(tasks: Tasks | null, n = NEXT_TASKS): TaskItem[] {
180 const cur = currentTask(tasks)
181 if (!tasks || !cur) return []
182 return tasks.items.filter(i => !i.done && i.id !== cur.id).slice(0, n)
183}
184
185/** Rewrites the `- [ ] <id>` line to `- [x]`; `changed` is false when not found or already done */
186export function markDone(text: string, id: string): { text: string; changed: boolean; found: boolean } {
187 const lines = text.split('\n')
188 let found = false
189 let changed = false
190 const out = lines.map(raw => {
191 const m = ITEM_RE.exec(raw)
192 if (!m || m[2] !== id) return raw
193 found = true
194 if (m[1] !== ' ') return raw
195 changed = true
196 return raw.replace(/\[ \]/, '[x]')
197 })
198 return { text: changed ? out.join('\n') : text, changed, found }
199}
200
201/** How many tasks this edit flipped from undone to done */
202export function flippedDone(prev: readonly TaskItem[], next: readonly TaskItem[]): number {
203 const before = new Map(prev.map(i => [i.id, i.done]))
204 return next.filter(i => i.done && before.get(i.id) === false).length
205}
206
207/** Simulates the file after an Edit / Write (for the strict check; the real content is re-read afterwards) */
208export function simulateEdit(
209 text: string,
210 e: { tool: string; old_string?: unknown; new_string?: unknown; replace_all?: unknown; content?: unknown },
211): string | null {
212 if (e.tool === 'Write') return typeof e.content === 'string' ? e.content : null
213 if (e.tool !== 'Edit' || typeof e.old_string !== 'string' || typeof e.new_string !== 'string') return null
214 if (!e.old_string) return null
215 return e.replace_all === true ? text.split(e.old_string).join(e.new_string) : text.replace(e.old_string, () => e.new_string as string)
216}
217
218// ── Agents ─────────────────────────────────────────────────────────────────
219
220export type AgentGroup = 'running' | 'waiting' | 'done'
221const GROUP_ORDER: readonly AgentGroup[] = ['running', 'waiting', 'done']
222/** Icon in front of each group header in the pane */
223export const GROUP_ICON: Record<AgentGroup, string> = { running: '●', waiting: '◐', done: '○' }
224
225export function groupLabel(lang: Lang, g: AgentGroup): string {
226 return t(lang, g === 'running' ? 'group.running' : g === 'waiting' ? 'group.waiting' : 'group.done')
227}
228
229export function groupOf(a: AgentRow): AgentGroup {
230 if (a.status === 'running') return 'running'
231 if (a.status === 'waiting') return 'waiting'
232 return 'done'
233}
234
235export function tokensOf(a: AgentRow): number {
236 return a.input + a.output + a.cacheRead + a.cacheWrite
237}
238
239export function agentSymbol(a: AgentRow): string {
240 switch (a.status) {
241 case 'running':
242 return '▶'
243 case 'waiting':
244 return '⏸'
245 case 'completed':
246 return '✓'
247 case 'killed':
248 return '■'
249 default:
250 return '✗'
251 }
252}
253
254export function statusFromReason(reason: string): AgentStatusKind {
255 if (reason === 'answer') return 'completed'
256 if (reason === 'aborted') return 'killed'
257 return 'failed'
258}
259
260/** Over the cap, drop the earliest finished agents first */
261export function pruneAgents(agents: Readonly<Record<string, AgentRow>>, max = MAX_AGENTS): Record<string, AgentRow> {
262 const rows = Object.values(agents)
263 if (rows.length <= max) return { ...agents }
264 const finished = rows.filter(a => groupOf(a) === 'done').sort((a, b) => (a.endedAt ?? 0) - (b.endedAt ?? 0))
265 const drop = new Set(finished.slice(0, rows.length - max).map(a => a.id))
266 const out: Record<string, AgentRow> = {}
267 for (const a of rows) if (!drop.has(a.id)) out[a.id] = a
268 return out
269}
270
271export function sortedAgents(agents: Readonly<Record<string, AgentRow>>): AgentRow[] {
272 return Object.values(agents).sort((a, b) => a.startedAt - b.startedAt)
273}
274
275export function agentLine(lang: Lang, a: AgentRow, now: number): string {
276 const model = `${shortModel(a.model)}${a.effort ? `/${a.effort}` : ''}`
277 const tail =
278 a.status === 'running'
279 ? t(lang, 'agent.ranFor', { d: duration(now - a.startedAt) })
280 : a.status === 'waiting'
281 ? t(lang, 'agent.waitedFor', { d: duration(now - a.startedAt) })
282 : a.status === 'failed'
283 ? t(lang, 'agent.failed', { reason: truncate(a.failReason ?? 'error', 40) })
284 : a.status === 'killed'
285 ? t(lang, 'agent.killed', { d: duration((a.endedAt ?? now) - a.startedAt) })
286 : duration((a.endedAt ?? now) - a.startedAt)
287 return [`${agentSymbol(a)} ${truncate(a.description || a.type, 40)}`, a.type, model, `${fmt(tokensOf(a))} tok`, tail].join(' · ')
288}
289
290export function agentDetail(lang: Lang, a: AgentRow): string {
291 const parts = [t(lang, 'agent.step', { n: a.steps }), t(lang, 'agent.tools', { n: a.tools })]
292 if (a.lastTool) parts.push(t(lang, 'agent.last', { tool: a.lastTool }))
293 if (a.input || a.output) parts.push(`↑${fmt(a.input + a.cacheRead + a.cacheWrite)} ↓${fmt(a.output)}`)
294 return parts.join(' · ')
295}
296
297export function spawnToast(lang: Lang, a: AgentRow): string {
298 return t(lang, 'toast.spawn', { name: truncate(a.description || a.type, 40), type: a.type })
299}
300
301export function endToast(lang: Lang, a: AgentRow): string {
302 const name = truncate(a.description || a.type, 40)
303 if (a.status === 'completed') return t(lang, 'toast.done', { name, tok: fmt(tokensOf(a)), d: duration((a.endedAt ?? a.startedAt) - a.startedAt) })
304 if (a.status === 'killed') return t(lang, 'toast.killed', { name })
305 return t(lang, 'toast.failed', { name, reason: truncate(a.failReason ?? 'error', 60) })
306}
307
308// ── Model-facing text ──────────────────────────────────────────────────────
309
310export function toolDescription(lang: Lang): string {
311 return t(lang, 'tool.description')
312}
313
314export function applyRule(lang: Lang): string {
315 return t(lang, 'rule.apply')
316}
317
318export function nudgeText(lang: Lang, n: number): string {
319 return t(lang, 'rule.nudge', { n })
320}
321
322export function strictDenyText(lang: Lang, n: number): string {
323 return t(lang, 'rule.strictDeny', { n })
324}
325
326// ── Band / status line / pane ──────────────────────────────────────────────
327
328export function phaseLabel(p: Phase): string {
329 return `opsx ${p.kind}${p.change ? ` ${p.change}` : ''}`
330}
331
332/** Head of the band and status line: the phase when known, else the change name tasks.md gave us */
333export function headLabel(phase: Phase, tasks: Tasks | null): string | null {
334 if (phase.kind !== 'idle') return phaseLabel(phase)
335 if (tasks) return `opsx ${tasks.change}`
336 return null
337}
338
339export function statusText(phase: Phase, tasks: Tasks | null): string | undefined {
340 const head = headLabel(phase, tasks)
341 if (head === null) return undefined
342 const parts = [head]
343 const cur = currentTask(tasks)
344 if (tasks) {
345 if (cur) parts.push(cur.id)
346 const p = progressOf(tasks.items)
347 parts.push(`${p.done}/${p.total}`)
348 }
349 return parts.join(' · ')
350}
351
352export function agentSummary(lang: Lang, agents: Readonly<Record<string, AgentRow>>): string | null {
353 const rows = Object.values(agents)
354 if (!rows.length) return null
355 const running = rows.filter(a => groupOf(a) === 'running').length
356 const waiting = rows.filter(a => groupOf(a) === 'waiting').length
357 const done = rows.length - running - waiting
358 const parts: string[] = []
359 if (running) parts.push(t(lang, 'band.running', { n: running }))
360 if (waiting) parts.push(t(lang, 'band.waiting', { n: waiting }))
361 if (done) parts.push(running || waiting ? t(lang, 'band.done', { n: done }) : t(lang, 'band.doneOnly', { n: done }))
362 parts.push(`${fmt(rows.reduce((n, a) => n + tokensOf(a), 0))} tok`)
363 return parts.join(' · ')
364}
365
366/** The one band line; null means it takes no row */
367export function bandText(
368 lang: Lang,
369 phase: Phase,
370 tasks: Tasks | null,
371 agents: Readonly<Record<string, AgentRow>>,
372 updatedAt: number,
373 now: number,
374): string | null {
375 const parts: string[] = []
376 const head = headLabel(phase, tasks)
377 if (head !== null) {
378 parts.push(`⧉ ${head}`)
379 const cur = currentTask(tasks)
380 if (cur) parts.push(`${cur.id} ${truncate(cur.title, 28)}`)
381 if (tasks) {
382 const p = progressOf(tasks.items)
383 parts.push(`${p.done}/${p.total}`)
384 }
385 }
386 const agentsText = agentSummary(lang, agents)
387 if (agentsText) parts.push(agentsText)
388 if (!parts.length) return null
389 if (updatedAt > 0) parts.push(t(lang, 'band.ago', { d: duration(now - updatedAt) }))
390 return parts.join(' · ')
391}
392
393const BAR_CELLS = 10
394
395export function progressBar(items: readonly TaskItem[]): string {
396 const { done, total } = progressOf(items)
397 if (total === 0) return '0/0'
398 const filled = Math.round((done / total) * BAR_CELLS)
399 return `${'▓'.repeat(filled)}${'░'.repeat(BAR_CELLS - filled)} ${done}/${total}`
400}
401
402export type PaneTone = 'plain' | 'bold' | 'dim' | 'warn' | 'ok'
403/** A pane row; `rule` marks a horizontal separator drawn across the pane's width instead of `text`. */
404export type PaneRow = { text: string; tone: PaneTone; rule?: true }
405
406/** The pane's rows (without the footer and buttons, which belong to register.tsx) */
407export function paneModel(lang: Lang, phase: Phase, tasks: Tasks | null, agents: Readonly<Record<string, AgentRow>>, now: number): PaneRow[] {
408 const rows: PaneRow[] = []
409 if (phase.kind === 'idle' && !tasks) {
410 rows.push({ text: t(lang, 'pane.idle'), tone: 'dim' })
411 } else {
412 const change = phase.change ?? tasks?.change ?? null
413 const head = `OpenSpec${phase.kind !== 'idle' ? ` · ${phase.kind}` : ''}${change ? ` · ${change}` : ''}`
414 rows.push({ text: tasks ? `${head} ${progressBar(tasks.items)}` : head, tone: 'bold' })
415 if (tasks) {
416 const cur = currentTask(tasks)
417 if (cur) {
418 rows.push({ text: `▶ ${cur.id} ${cur.title}${tasks.current === cur.id ? '' : t(lang, 'pane.inferred')}`, tone: 'plain' })
419 const nexts = nextTasks(tasks)
420 if (nexts.length) rows.push({ text: ` ${nexts.map(x => `○ ${x.id} ${truncate(x.title, 24)}`).join(' ')}`, tone: 'dim' })
421 } else if (tasks.items.length) rows.push({ text: t(lang, 'pane.allDone'), tone: 'ok' })
422 else rows.push({ text: t(lang, 'pane.noItems'), tone: 'dim' })
423 } else if (phase.kind === 'apply') rows.push({ text: t(lang, 'pane.noTasksYet'), tone: 'dim' })
424 }
425
426 const list = sortedAgents(agents)
427 const total = list.reduce((n, a) => n + tokensOf(a), 0)
428 rows.push({ text: '', tone: 'dim', rule: true })
429 rows.push({ text: list.length ? t(lang, 'pane.agentsHead', { n: list.length, tok: fmt(total) }) : t(lang, 'pane.noAgents'), tone: 'bold' })
430 for (const g of GROUP_ORDER) {
431 const inGroup = list.filter(a => groupOf(a) === g)
432 if (!inGroup.length && g !== 'running') continue
433 // Group header with an icon; the agents of the group are indented beneath it
434 rows.push({ text: `${GROUP_ICON[g]} ${t(lang, 'pane.group', { label: groupLabel(lang, g), n: inGroup.length })}`, tone: 'bold' })
435 for (const a of inGroup) {
436 const tone: PaneTone = a.status === 'failed' ? 'warn' : g === 'done' ? 'dim' : 'plain'
437 rows.push({ text: ` ${agentLine(lang, a, now)}`, tone })
438 if (g !== 'done') rows.push({ text: ` ${agentDetail(lang, a)}`, tone: 'dim' })
439 }
440 }
441 return rows
442}
443
444/** Whether a redraw is worth it while the pane is closed */
445export function hasSomething(phase: Phase, tasks: Tasks | null, agents: Readonly<Record<string, AgentRow>>): boolean {
446 return phase.kind !== 'idle' || tasks !== null || Object.keys(agents).length > 0
447}
448
449export function newAgent(input: {
450 id: string
451 description: string
452 type: string
453 model: string
454 parentId?: string
455 background: boolean
456 now: number
457}): AgentRow {
458 return {
459 id: input.id,
460 description: input.description,
461 type: input.type,
462 model: input.model,
463 effort: null,
464 status: 'running',
465 startedAt: input.now,
466 endedAt: null,
467 input: 0,
468 output: 0,
469 cacheRead: 0,
470 cacheWrite: 0,
471 steps: 0,
472 tools: 0,
473 lastTool: '',
474 parentId: input.parentId ?? null,
475 background: input.background,
476 failReason: null,
477 }
478}
479
480export type Usage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
481
482export function addUsage(a: AgentRow, u: Usage | null | undefined): AgentRow {
483 if (!u) return a
484 return {
485 ...a,
486 input: a.input + (u.input_tokens || 0),
487 output: a.output + (u.output_tokens || 0),
488 cacheRead: a.cacheRead + (u.cache_read_input_tokens || 0),
489 cacheWrite: a.cacheWrite + (u.cache_creation_input_tokens || 0),
490 }
491}
492
493
494// ── Band framing ───────────────────────────────────────────────────────────
495
496/** How the mod's line above the prompt is framed: a rounded box, a thin rule beneath, or bare text. */
497export type BandStyle = 'box' | 'rule' | 'plain'
498
499/** The `band_style` option; anything but `rule` or `plain` is the default box. */
500export function parseBandStyle(v: unknown): BandStyle {
501 return v === 'rule' || v === 'plain' ? v : 'box'
502}
503types/index.d.ts 92 lines1// opsx-board data types and the $.state contract.
2// Three parts: the OpenSpec phase, the task list of tasks.md, and this session's sub agents.
3
4/** OpenSpec phase: `idle` means no opsx flow is running; otherwise from /opsx:<kind> or an openspec-<kind> skill */
5export type Phase = {
6 kind: string
7 /** The change name (openspec/changes/<change>/); null while unknown */
8 change: string | null
9 /** When this phase was entered */
10 since: number
11}
12
13export type TaskItem = {
14 /** The number, e.g. 1.2 */
15 id: string
16 title: string
17 done: boolean
18 /** Line in tasks.md (0-based) */
19 line: number
20 /** The enclosing ## section title; empty when there is none */
21 section: string
22}
23
24export type TaskSection = { title: string; line: number }
25
26export type Tasks = {
27 /** Absolute path of tasks.md */
28 file: string
29 change: string
30 items: TaskItem[]
31 sections: TaskSection[]
32 /** The task the model declared as current through the task tool; null = infer the first undone one */
33 current: string | null
34 updatedAt: number
35}
36
37export type AgentStatusKind = 'running' | 'waiting' | 'completed' | 'failed' | 'killed'
38
39export type AgentRow = {
40 id: string
41 /** The Agent tool's description (a few words naming the task) */
42 description: string
43 /** Agent type: Explore, general-purpose, a plugin's agent, … */
44 type: string
45 /** Resolved model id */
46 model: string
47 /** Effort of the first turn.step; null until seen */
48 effort: string | null
49 status: AgentStatusKind
50 startedAt: number
51 endedAt: number | null
52 input: number
53 output: number
54 cacheRead: number
55 cacheWrite: number
56 /** Model requests (turn.step) */
57 steps: number
58 /** Tool calls */
59 tools: number
60 /** Summary of the last tool call, e.g. `Grep "refreshToken"` */
61 lastTool: string
62 parentId: string | null
63 background: boolean
64 /** Why it failed or was stopped */
65 failReason: string | null
66}
67
68/** UI language, as hooks/i18n.ts resolves it */
69export type UiLang = 'en' | 'zh-TW' | 'ja'
70
71declare module 'claude-code' {
72 interface PluginState {
73 'opsx-board': {
74 phase: Phase
75 tasks: Tasks | null
76 /** agentId → one row */
77 agents: Record<string, AgentRow>
78 isPaneOpen: boolean
79 /** True after /opsx-board off: band, pane and status line are hidden */
80 isPaused: boolean
81 /** Demo mode: draw fake data */
82 isDemo: boolean
83 /** Last time the state changed (the band's "N s ago") */
84 updatedAt: number
85 /** Written on every timer tick, only so "running N s" redraws */
86 tickAt: number
87 /** The resolved UI language */
88 lang: UiLang
89 }
90 }
91}
92