SLOPSHOPPER

opsx-board

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

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-05davidho27941/cockpit/plugins/opsx-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · opsx-board
│ ┃ opsx-board ✕ › fix the failing auth test and add an audit log call │ ┃ OpenSpec · no opsx flow running (/opsx:prop… │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ──────────── ⎿ Read 6 lines │ ┃ Sub agents · none yet ⏺ Update(src/auth.ts) │ ┃ ● running (0) ⎿ Added 2 lines, removed 1 line │ ┃ No events yet ⏺ Bash(bun test) │ ┃ [ Clear finished ][ Close ] ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /opsx-board │ ⎿ opsx-board: opsx-board pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · opsx-board
OpenSpec · no opsx flow running (/opsx:propose or /opsx:app… ──────────────────────────────────────────────────────── Sub agents · none yet ● running (0) No events yet [ Clear finished ][ Close ]
README

opsx-board

A board for OpenSpec spec-driven development in Claude Code. Three things in one pane, one band line and the status line:

  1. Which phase you are in: type /opsx:propose, /opsx:apply, /opsx:archive, … and the band shows the phase and the change name.
  2. Which task of tasks.md is current: tracks 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.
  3. What every sub agent is doing: name, type, model/effort, tokens, model requests, tool calls, last action and elapsed time, grouped into running / waiting / done.
⧉ 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 ]

Usage

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.

CommandEffect
/opsx-boardToggle the pane
/opsx-board clearDrop finished sub agents (running and waiting ones stay)
/opsx-board offHide the band, pane and status line; events are still recorded. /opsx-board resumes
/opsx-board demoShow 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.

Language

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.

How the phase is detected

SourceWhat it gives
/opsx:<kind> [change] commandphase = <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 Bashthe change name
openspec new change X in Bashphase propose, change X
a successful openspec archive in Bashback 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.

Task reporting: the mcp__opsx-board__task tool

The 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:

  1. Rule injection (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.
  2. Direct edits: when the model still edits tasks.md itself, one tick passes; several ticks at once get a one-line reminder attached to the tool result (the model sees it, you do not).
  3. Strict mode (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.

Sub agent columns

ColumnSource
name, typeagent.spawn's description and subagentType
modelthe model the spawn resolved to; afterwards whatever the API reports
effortthe effort of the agent's first model request (turn.step)
tokensthe agent's model requests summed (input + output + cache read + cache write); on the second line ↑ is the input side, ↓ the output
step / tools / lastmodel requests, tool calls, the last tool call's summary
running / waitingsince the spawn; "waiting" comes from $.agent.list() (plan approval, background work)
endturn.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.

Settings

In /plugin's configure page or under pluginConfigs in ~/.claude/settings.json:

FieldDefaultMeaning
languageautoauto, en, zh-TW or ja (see Language above)
strictfalseRefuse an Edit/Write that ticks several tasks at once
inject_instructionstrueAdd the reporting rule to the apply skill and the apply-phase system prompt
auto_opentrueOpen the pane when apply starts or the first sub agent spawns
band_styleboxHow 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)

Safety boundary

  • One kind of write 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.
  • No external commands (not even the openspec CLI; the phase comes from events alone), no network, no model calls.
  • The only text added to prompts is the fixed task-reporting rule, which inject_instructions turns off.
  • The only files read are tasks.md and the directory listing of openspec/changes/. Environment reads: LC_ALL, LC_MESSAGES, LANG (for language: auto).

What it does before you install it

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.

Limits

  • Without a tool report, the current task is a guess: the first undone task.
  • Agents that run remotely (cloud) produce no model-request events locally, so their tokens are not seen; agents a Workflow spawns have tokens but are not listed by $.agent.list(), so "waiting" is not seen for them.
  • claude -p starts no timer, so "running N s" does not advance there.
  • A tasks.md item must look like - [ ] 1.2 text (the number is digits and dots); other shapes are not tasks.

Development

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.

Source 5 files
hooks/register.tsx 645 lines
1// 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}
645
hooks/demo.ts 134 lines
1// 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)
134
hooks/i18n.ts 242 lines
1// 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}
242
hooks/logic.ts 503 lines
1// 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}
503
types/index.d.ts 92 lines
1// 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