SLOPSHOPPER

dispatch-board

多個 Claude Code session 分工時的派工看板:讀你寫的 mission 檔,顯示誰完成、誰在跑、誰在等你,狀態轉換時跳 toast

newpanebandcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dispatch-board
│ ┃ dispatch-board ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ 家目錄 /Users/dev 解析不了,不讀任何 missio… │ dispatch-board │ │ ┃ 尚無成功的快照 ⏺ Read(src/auth.ts) │ dispatch-board:家目錄 /Users/dev 解析不了,不讀任何 │ │ ┃ 尚未成功更新 ⎿ Read 6 lines │ mission 檔 │ │ ┃ [ 立即更新 ][ 關閉 ] ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /dispatch │ ⎿ dispatch-board: dispatch-board 面板已開啟。 │ │ ⧉ dispatch-board:家目錄 /Users/dev 解析不了,不讀任何 mission 檔 ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⧉ dispatch-board:家目錄 /Users/dev 解析不了,不讀任何 mission 檔 ⟨Claude Code's own drawing⟩
Pane · dispatch-board
家目錄 /Users/dev 解析不了,不讀任何 mission 檔 尚無成功的快照 尚未成功更新 [ 立即更新 ][ 關閉 ]
README

dispatch-board

同時開好幾個 Claude Code session 分工時用的派工看板。你寫一份 mission 檔,列出有哪些節點、各由哪個 session 負責、怎樣算做完;看板在終端機顯示誰完成、誰在跑、誰在等你,狀態一轉換就跳 toast。

⧉ shop-redesign 1/3 · 1 需要你 · 4s 前

/dispatch 打開側邊面板:

mission 目錄 ~/.claude/missions
需要你(1)
shop-redesign  ▓▓▓░░░░░░░ 1/3  商店改版
  ✓ api 後端 API · 閒置 shop-api
  ⏸ web 前端頁面 · 等你 3m · blocked shop-web
  ○ e2e 端對端測試 · 等依賴 web · 不在名冊 shop-e2e
上次更新:2s 前
[ 立即更新 ][ 關閉 ]

用法

  1. 建立 ~/.claude/missions/,放一份 mission 檔(*.json,格式見下)。
  2. 照 mission 檔裡寫的名字開 session,例如 claude --bg --name shop-api(名字要和 claude agents 列出的一樣,不分大小寫)。
  3. 在任何一個 session 打 /dispatch 開面板。
指令作用
/dispatch開關面板
/dispatch off本 session 停止讀 mission、停止執行完成條件;再打 /dispatch 恢復
/dispatch demo用假資料試看(不讀目錄、不執行任何指令);再打一次結束

也可以用環境變數 DISPATCH_BOARD_DEMO=1 啟動 demo 模式,方便錄影。

mission 檔

一個 JSON 檔一個 mission,放在 mission 目錄的第一層(子目錄不讀,所以做完的檔可以搬到 done/)。

{
  "mission_id": "shop-redesign",
  "title": "商店改版",
  "nodes": [
    {
      "id": "api",
      "title": "後端 API",
      "session": "shop-api",
      "done": "git -C ~/code/shop log --oneline origin/feature/api | grep -q '\\[api-done\\]'"
    },
    {
      "id": "web",
      "title": "前端頁面",
      "session": "shop-web",
      "done": "test -f ~/code/shop/web/dist/index.html"
    },
    {
      "id": "e2e",
      "title": "端對端測試",
      "session": "shop-e2e",
      "done": "grep -q 'ALL PASSED' ~/code/shop/e2e-report.txt",
      "depends_on": ["api", "web"]
    }
  ]
}
欄位必填說明
mission_id否英數、_ . -;省略時用檔名
title否顯示用
nodes[].id是英數、_ . -,同一份 mission 內不重複
nodes[].title否顯示用
nodes[].session否負責的 session 名稱;省略=不對名冊
nodes[].done是完成條件:一行 shell 指令,退出碼 0=完成
nodes[].depends_on否依賴的節點 id

examples/shop-redesign.json 是同一份範例,可以複製過去改。

完成條件怎麼求值

  • 用 /bin/sh -c 執行,工作目錄是家目錄,每條 5 秒逾時,最多同時跑 4 條。
  • 退出碼 0=完成;其他退出碼=還沒完成。
  • 逾時、指令跑不起來(shell 回 126/127)=無法判定,顯示 ✗ 並歸到「需要你」,不會畫成「還在跑」。壞掉的條件如果被當成還沒完成,你會一直等下去。
  • 面板開著時每 5 秒求值一次,關著時每 30 秒一次。目錄裡沒有 mission 檔時只列目錄,不執行任何指令、不讀名冊。
  • 完成條件不會被快取:條件之後又不成立(例如分支被刪),節點會回到未完成。

建議用「工作產出」當完成條件(commit 訊息、檔案存在、測試報告),而不是 session 自己說做完了。

session 狀態

來自 claude agents --json:

顯示意思
工作中/閒置/blockedsession 在名冊上;blocked 代表它在等你回覆
已結束看板啟動後看過它,現在名冊上沒有了
不在名冊名冊上沒有,也沒看過(還沒開,或在看板啟動前就結束了)
名冊不可用claude agents --json 跑不起來或輸出看不懂;不會顯示成已結束

面板分組

  • 需要你:有 session blocked、有條件無法判定、或 session 已結束但條件沒達成
  • 跑著:其餘還沒完成的
  • 做完待收斂:所有節點都完成。附一顆按鈕,把「把 mission 檔搬到 done/」的指令複製到剪貼簿;mod 自己不搬檔

狀態轉換時跳 toast:節點完成、條件變成無法判定、session 開始等你、session 結束但沒完成、整份 mission 完成、新出現讀不了的 mission 檔。看板啟動後的第一份快照只作基準,不跳 toast。

安全邊界

mission 檔裡的完成條件是 shell 指令,看板會以你的身分執行它。 所以 mission 檔只從使用者層級的目錄讀,絕不讀專案目錄或 repo 裡的檔案,否則 clone 一個陌生 repo 就等於執行對方寫的指令。具體規則:

  • 預設目錄 ~/.claude/missions/。可以在 /plugin 的設定改 missions_dir(~/… 或絕對路徑),但只認 ~/.claude/settings.json 裡的值;專案的 .claude/settings.json、settings.local.json 或 --settings 給的值一律不採用。
  • 目錄解析符號連結後必須在家目錄底下(不能是家目錄本身),不能在本 session 的專案目錄內,也不能在任何 git 工作樹內(從該目錄往上到家目錄之間任一層有 .git 就拒讀)。
  • 個別 mission 檔經符號連結指到目錄外的,拒讀並列在「讀不了的 mission 檔」。
  • 讀不到家目錄(HOME 未設定)就整個停用。
  • 任何一條不符合都直接拒讀並在 band 顯示原因,不會退而求其次改讀別處。

除此之外 mod 是唯讀的:不寫檔、不送 prompt、不碰其他 session。

安裝前看看它會做什麼

claude plugin validate ./plugins/dispatch-board

結果(v0.1.0):

hooks: session.start, command.run{command=dispatch}, ui.close,
       ui.render{component=AbovePrompt}, ui.render{component=Pane, requestId=dispatch-board}
calls: $.clock.every, $.clock.now, $.command.register, $.env.get,
       $.fs.exists, $.fs.list, $.fs.read, $.fs.stat,
       $.process.run, $.session.cwd, $.session.root, $.settings.read,
       $.state.get, $.state.set, $.ui.close, $.ui.copy, $.ui.open,
       $.ui.resolve, $.ui.toast
env reads: DISPATCH_BOARD_DEMO, HOME
env writes: nothing
  • $.process.run:執行 mission 檔的完成條件(/bin/sh -c),以及 claude agents --json
  • $.fs.*:只讀 mission 目錄與檢查安全邊界(解析路徑、找 .git);沒有寫檔
  • $.settings.read:只讀使用者層級設定,確認自訂的 missions_dir 從哪裡來
  • 不連網、不呼叫模型

限制

  • 只在終端機與 Claude Desktop 的 Code 分頁畫;claude -p 不輪詢。
  • 每個開著的互動 session 各自輪詢;session 開得多,完成條件就會被多個 session 重複執行,條件請保持便宜、沒有副作用。
  • 完成條件依賴 /bin/sh,Windows 未測試。

開發

claude --plugin-dir ./plugins/dispatch-board   # 單次載入
claude plugin test ./plugins/dispatch-board     # 跑測試

English

dispatch-board is a dispatch board for running several Claude Code sessions in parallel. You write a mission file (~/.claude/missions/*.json) listing nodes, the session responsible for each, and a completion check (a shell command; exit 0 means done). The board shows a one-line band above the prompt and a /dispatch side pane grouped into needs you / running / done, ready to wrap up, and toasts on state transitions. Session state comes from claude agents --json; an unreadable roster is shown as "roster unavailable", never as "ended". A check that times out (5 s) or cannot run is shown as undetermined and grouped under needs you, never as still running. /dispatch demo shows fake data without reading anything or running any command.

Security: completion checks are executed as you, so mission files are read only from a user-level directory: under your home directory, outside the session's project, outside any git work tree, with symlinks resolved. A custom missions_dir is honoured only from ~/.claude/settings.json. Anything else is refused with the reason shown. The mod writes no files, sends no prompts, makes no network requests and calls no model.

Source 4 files
hooks/register.tsx 420 lines
1// dispatch-board:多個 Claude Code session 分工時的派工看板。
2//
3// - 讀使用者層級目錄(預設 ~/.claude/missions/)裡的 mission 檔;每個節點有負責的 session 名稱與
4//   完成條件(shell 指令,退出碼 0=完成)。
5// - 安全邊界:完成條件會被執行,所以目錄必須在家目錄底下、不在 git 工作樹內、不在本 session 的
6//   專案目錄內;自訂目錄只認使用者層級設定。檔案經符號連結指到目錄外一律拒讀。
7// - session 狀態來自 `claude agents --json`(不解析文字輸出)。
8// - 計時器每 5 秒一拍;面板開著每 5 秒、關著每 30 秒更新一次。目錄裡沒有 mission 檔時只列目錄,
9//   不執行任何指令。狀態轉換時跳 toast(第一份快照只作基準)。
10// - 唯讀:不寫檔、不送 prompt、不碰其他 session;「複製收斂指令」只寫剪貼簿。
11
12import { atom, read, update } from 'claude-code'
13import type { Register } from 'claude-code'
14
15import type { BadFile, BoardMission, BoardSnapshot, NodeEval, Roster } from './logic'
16import { DEMO_SNAPSHOTS } from './demo'
17import {
18  BAND_POLL_MS,
19  DEFAULT_DIR,
20  DONE_TIMEOUT_MS,
21  EVAL_CONCURRENCY,
22  MAX_FILE_BYTES,
23  ROSTER_TIMEOUT_MS,
24  TICK_MS,
25  TOAST_MS,
26  bandText,
27  classifyExit,
28  configRefusal,
29  diffToasts,
30  dirRefusal,
31  expandDir,
32  gitProbePaths,
33  isMissionFileName,
34  isUnder,
35  mapPool,
36  missionNode,
37  nextBlockedSince,
38  nextSeen,
39  paneModel,
40  parseMission,
41  parseRoster,
42  pollDue,
43  rejectionReason,
44  sessionView,
45  tildify,
46  truncate,
47} from './logic'
48import type { MissionSpec, PaneRow } from './logic'
49
50const PANE = 'dispatch-board'
51
52const isPaneOpen = atom({ plugin: 'dispatch-board', key: 'isPaneOpen' } as const, false)
53const isPaused = atom({ plugin: 'dispatch-board', key: 'isPaused' } as const, false)
54const isDemo = atom({ plugin: 'dispatch-board', key: 'isDemo' } as const, false)
55const snapshot = atom({ plugin: 'dispatch-board', key: 'snapshot' } as const, null)
56const lastOkAt = atom({ plugin: 'dispatch-board', key: 'lastOkAt' } as const, null)
57const failure = atom({ plugin: 'dispatch-board', key: 'failure' } as const, null)
58const blockedSince = atom({ plugin: 'dispatch-board', key: 'blockedSince' } as const, {})
59const seenSessions = atom({ plugin: 'dispatch-board', key: 'seenSessions' } as const, {})
60const tickAt = atom({ plugin: 'dispatch-board', key: 'tickAt' } as const, 0)
61
62// 模組層狀態:熱重載時歸零(計時器隨之重建)。
63let dirSetting = DEFAULT_DIR
64let inFlight = false
65let lastAttemptAt: number | null = null
66let demoIndex = 0
67
68function toast($: any, text: string): void {
69  try {
70    $.ui.toast(text, { timeoutMs: TOAST_MS })
71  } catch {}
72}
73
74async function realPathOf($: any, path: string): Promise<string | undefined> {
75  const st = await $.fs.stat(path, { resolve: true }).catch(() => undefined)
76  return st?.realPath
77}
78
79type Located =
80  | { kind: 'ok'; home: string; realDir: string; display: string }
81  | { kind: 'missing'; display: string }
82  | { kind: 'refused'; error: string }
83
84/** 找出 mission 目錄並檢查安全邊界。任何一步不確定都拒讀,不退而求其次。 */
85async function locate($: any): Promise<Located> {
86  const home = await $.env.get('HOME')
87  if (!home || !home.startsWith('/')) return { kind: 'refused', error: '讀不到家目錄(HOME 未設定),不讀任何 mission 檔' }
88  if (dirSetting.trim() !== DEFAULT_DIR) {
89    const userSettings = await $.settings.read({ source: 'user' }).catch(() => ({}))
90    const refused = configRefusal(dirSetting, userSettings)
91    if (refused) return { kind: 'refused', error: refused }
92  }
93  const expanded = expandDir(dirSetting, home)
94  if (!expanded.ok) return { kind: 'refused', error: expanded.error }
95  const display = tildify(expanded.path, home)
96  const realHome = await realPathOf($, home)
97  if (!realHome) return { kind: 'refused', error: `家目錄 ${home} 解析不了,不讀任何 mission 檔` }
98  const dirStat = await $.fs.stat(expanded.path, { resolve: true }).catch(() => undefined)
99  if (!dirStat) return { kind: 'missing', display }
100  if (dirStat.kind !== 'dir' || !dirStat.realPath) return { kind: 'refused', error: `${display} 不是目錄` }
101  const realDir: string = dirStat.realPath
102  const projectDirs: string[] = []
103  for (const p of [await $.session.root(), await $.session.cwd()]) projectDirs.push((await realPathOf($, p)) ?? p)
104  const gitHits: string[] = []
105  for (const p of gitProbePaths(realDir, realHome)) if (await $.fs.exists(p)) gitHits.push(p)
106  const refused = dirRefusal({ realHome, realDir, projectDirs, gitHits })
107  if (refused) return { kind: 'refused', error: refused }
108  return { kind: 'ok', home: realHome, realDir, display }
109}
110
111type Loaded = { specs: { file: string; spec: MissionSpec }[]; bad: BadFile[] }
112
113/** 讀目錄第一層的 *.json;經符號連結指到目錄外、太大、格式錯的檔列為讀不了,附原因。 */
114async function loadMissions($: any, realDir: string): Promise<Loaded> {
115  const out: Loaded = { specs: [], bad: [] }
116  const entries = await $.fs.list(realDir)
117  const names = entries
118    .filter((e: any) => e.kind !== 'dir' && isMissionFileName(e.name))
119    .map((e: any) => String(e.name))
120    .sort()
121  const ids = new Set<string>()
122  for (const name of names) {
123    const path = `${realDir}/${name}`
124    const st = await $.fs.stat(path, { resolve: true }).catch(() => undefined)
125    if (!st?.realPath || !isUnder(st.realPath, realDir)) {
126      out.bad.push({ file: path, reason: '經符號連結指到 mission 目錄外,拒讀' })
127      continue
128    }
129    if (st.kind !== 'file') continue
130    if (st.size > MAX_FILE_BYTES) {
131      out.bad.push({ file: path, reason: `檔案超過 ${MAX_FILE_BYTES / 1024} KB` })
132      continue
133    }
134    let text: string
135    try {
136      text = await $.fs.read(st.realPath)
137    } catch (err) {
138      out.bad.push({ file: path, reason: `讀檔失敗:${truncate(String(err), 60)}` })
139      continue
140    }
141    const parsed = parseMission(text, name)
142    if (!parsed.ok) {
143      out.bad.push({ file: path, reason: parsed.reason })
144      continue
145    }
146    if (ids.has(parsed.value.mission_id)) {
147      out.bad.push({ file: path, reason: `mission_id「${parsed.value.mission_id}」和另一個檔重複` })
148      continue
149    }
150    ids.add(parsed.value.mission_id)
151    out.specs.push({ file: st.realPath, spec: parsed.value })
152  }
153  return out
154}
155
156async function fetchRoster($: any): Promise<Roster> {
157  try {
158    const r = await $.process.run(['claude', 'agents', '--json'], { timeoutMs: ROSTER_TIMEOUT_MS })
159    return parseRoster(r.exitCode, r.stdout, r.stderr)
160  } catch (err) {
161    return { available: false, note: `claude agents --json 跑不起來:${truncate(String(err), 80)}` }
162  }
163}
164
165/** 一條完成條件:`sh -c`,cwd 為家目錄,5 秒逾時;跑不起來或逾時=無法判定。 */
166async function evaluate($: any, cmd: string, cwd: string): Promise<NodeEval> {
167  const startedAt: number = await $.clock.now()
168  try {
169    const r = await $.process.run(['/bin/sh', '-c', cmd], { cwd, timeoutMs: DONE_TIMEOUT_MS })
170    return classifyExit(r.exitCode, r.stderr)
171  } catch (err) {
172    return { kind: 'unknown', reason: rejectionReason(err, (await $.clock.now()) - startedAt) }
173  }
174}
175
176type Fetched = { ok: true; value: BoardSnapshot; roster: Roster; names: string[] } | { ok: false; error: string }
177
178async function fetchBoard($: any): Promise<Fetched> {
179  if (await read($, isDemo)) {
180    const value = DEMO_SNAPSHOTS[Math.min(demoIndex, DEMO_SNAPSHOTS.length - 1)] as BoardSnapshot
181    demoIndex += 1
182    return { ok: true, value, roster: { available: false, note: 'demo' }, names: [] }
183  }
184  const loc = await locate($)
185  if (loc.kind === 'refused') return { ok: false, error: loc.error }
186  const empty = { dir: loc.display, roster: { available: true, note: '' }, missions: [], bad: [] }
187  if (loc.kind === 'missing') return { ok: true, value: empty, roster: { available: true, entries: [] }, names: [] }
188  let loaded: Loaded
189  try {
190    loaded = await loadMissions($, loc.realDir)
191  } catch (err) {
192    return { ok: false, error: `讀不了 mission 目錄 ${loc.display}:${truncate(String(err), 80)}` }
193  }
194  // 沒有 mission 就不叫名冊、不執行任何指令
195  if (!loaded.specs.length) return { ok: true, value: { ...empty, bad: loaded.bad }, roster: { available: true, entries: [] }, names: [] }
196  const roster = await fetchRoster($)
197  const seen = await read($, seenSessions)
198  const jobs = loaded.specs.flatMap(({ spec }) => spec.nodes.map(n => n.done))
199  const evals = await mapPool(jobs, EVAL_CONCURRENCY, cmd => evaluate($, cmd, loc.home))
200  let i = 0
201  const missions: BoardMission[] = loaded.specs.map(({ file, spec }) => ({
202    mission_id: spec.mission_id,
203    title: spec.title,
204    file,
205    nodes: spec.nodes.map(n => missionNode(n, evals[i++] as NodeEval, sessionView(n.session, roster, seen))),
206  }))
207  const names = loaded.specs.flatMap(({ spec }) => spec.nodes.map(n => n.session))
208  return {
209    ok: true,
210    value: { dir: loc.display, roster: roster.available ? { available: true, note: '' } : roster, missions, bad: loaded.bad },
211    roster,
212    names,
213  }
214}
215
216async function applyResult($: any, fetched: Fetched, now: number): Promise<void> {
217  if (!fetched.ok) {
218    const prevFailure = await read($, failure)
219    await update($, failure, () => fetched.error)
220    if (prevFailure !== fetched.error) toast($, `dispatch-board:${fetched.error}`)
221    return
222  }
223  const prev = await read($, snapshot)
224  const toasts = diffToasts(prev, fetched.value)
225  const since = await read($, blockedSince)
226  const seen = await read($, seenSessions)
227  await update($, seenSessions, () => nextSeen(seen, fetched.roster, fetched.names, now))
228  await update($, blockedSince, () => nextBlockedSince(since, fetched.value, now))
229  await update($, snapshot, () => fetched.value)
230  await update($, lastOkAt, () => now)
231  await update($, failure, () => null)
232  for (const t of toasts) toast($, t)
233}
234
235async function poll($: any): Promise<void> {
236  if (inFlight) return
237  inFlight = true
238  try {
239    lastAttemptAt = await $.clock.now()
240    const fetched = await fetchBoard($)
241    // 輪詢期間被 /dispatch off 停掉時,結果不再套用
242    if (await read($, isPaused)) return
243    await applyResult($, fetched, await $.clock.now())
244  } finally {
245    inFlight = false
246  }
247}
248
249async function onTick($: any): Promise<void> {
250  if (await read($, isPaused)) return
251  const now = await $.clock.now()
252  const s = await read($, snapshot)
253  // 只在 band 有東西時重繪「N 秒前」,沒有 mission 的 session 不必每拍重畫
254  if ((s && (s.missions.length || s.bad.length)) || (await read($, failure)) !== null) await update($, tickAt, () => now)
255  if (pollDue(now, lastAttemptAt, await read($, isPaneOpen))) await poll($)
256}
257
258async function resetBaseline($: any): Promise<void> {
259  await update($, snapshot, () => null)
260  await update($, blockedSince, () => ({}))
261  await update($, failure, () => null)
262  await update($, lastOkAt, () => null)
263  lastAttemptAt = null
264}
265
266async function onSessionStart($: any, e: any, next: any) {
267  const out = await next(e)
268  await $.command.register({ name: 'dispatch', description: '派工看板:開關面板;/dispatch off 停止、/dispatch demo 用假資料試看' })
269  let envDemo = false
270  try {
271    envDemo = (await $.env.get('DISPATCH_BOARD_DEMO')) === '1'
272  } catch {}
273  if (envDemo && !(await read($, isDemo))) {
274    await update($, isDemo, () => true)
275    await resetBaseline($)
276  }
277  // claude -p 與 SDK 沒有人看,不輪詢
278  if (e.isInteractive) {
279    $.clock.every(TICK_MS, () => {
280      void onTick($).catch(() => {})
281    })
282    void onTick($).catch(() => {})
283  }
284  return out
285}
286
287async function openPane($: any): Promise<void> {
288  await update($, isPaneOpen, () => true)
289  await $.ui.open({ id: PANE, title: 'dispatch-board' })
290  lastAttemptAt = null
291  void poll($).catch(() => {})
292}
293
294async function onDispatchCommand($: any, e: any) {
295  const arg = String(e.args ?? '').trim()
296  if (arg === 'off') {
297    await update($, isPaused, () => true)
298    await resetBaseline($)
299    if (await read($, isPaneOpen)) await $.ui.close({ id: PANE }).catch(() => {})
300    return { text: 'dispatch-board 已停止(本 session 不再讀 mission、不再執行完成條件)。/dispatch 重新開始。' }
301  }
302  if (arg === 'demo') {
303    const next = !(await read($, isDemo))
304    await update($, isDemo, () => next)
305    await update($, isPaused, () => false)
306    demoIndex = 0
307    await resetBaseline($)
308    if (next && !(await read($, isPaneOpen))) await openPane($)
309    else void poll($).catch(() => {})
310    return { text: next ? 'dispatch-board demo 模式:假資料,每次更新往下一格。再打 /dispatch demo 結束。' : 'dispatch-board 已離開 demo 模式。' }
311  }
312  if (arg !== '') return { text: '用法:/dispatch(開關面板)、/dispatch off(停止)、/dispatch demo(假資料試看)' }
313  if (await read($, isPaused)) {
314    await update($, isPaused, () => false)
315    await resetBaseline($)
316  }
317  if (await read($, isPaneOpen)) {
318    await $.ui.close({ id: PANE }).catch(() => {})
319    return { text: `dispatch-board 面板已關閉(仍每 ${BAND_POLL_MS / 1000} 秒更新 band;/dispatch off 停止)。` }
320  }
321  await openPane($)
322  return { text: 'dispatch-board 面板已開啟。' }
323}
324
325async function onPaneClose($: any, e: any, next: any) {
326  if (e.id === PANE) await update($, isPaneOpen, () => false)
327  return next(e)
328}
329
330async function onAbovePrompt($: any, e: any, next: any) {
331  if ((await read($, isPaused)) || e.props?.hasSurvey) return next(e)
332  await read($, tickAt) // 訂閱:每拍重繪,讓「N 秒前」跟著走
333  const fail = await read($, failure)
334  const text = bandText(await read($, snapshot), fail, await read($, lastOkAt), await $.clock.now())
335  if (text === null) return next(e)
336  const { Box, Text } = $.ui.resolve(e)
337  // AbovePrompt 是一條 hook 鏈:不呼叫 next 的 hook 會吃掉下層 plugin 的 band。
338  // 一律先畫自己那一行,再接上下層的結果。
339  const below = await next(e)
340  return (
341    <Box flexDirection="column">
342      <Text wrap="truncate-end" dimColor={fail === null} color={fail !== null ? 'yellow' : undefined}>
343        {text}
344      </Text>
345      {below}
346    </Box>
347  )
348}
349
350async function onPane($: any, e: any) {
351  const { Box, Text, Button } = $.ui.resolve(e)
352  await read($, tickAt)
353  const s = await read($, snapshot)
354  const fail = await read($, failure)
355  const since = await read($, blockedSince)
356  const okAt = await read($, lastOkAt)
357  const demo = await read($, isDemo)
358  const now = await $.clock.now()
359
360  const paneRow = (row: PaneRow, i: number) => {
361    const text = (
362      <Text
363        key={`t${i}`}
364        wrap="truncate-end"
365        bold={row.tone === 'bold' || undefined}
366        dimColor={row.tone === 'dim' || undefined}
367        color={row.tone === 'warn' ? 'yellow' : undefined}
368      >
369        {row.text}
370      </Text>
371    )
372    if (!row.copy) return text
373    const { key, text: cmd } = row.copy
374    // 只寫剪貼簿:mod 不搬檔
375    return (
376      <Box key={`row:${key}`} flexDirection="column">
377        {text}
378        <Button key={key} label="複製搬移指令" dimColor onPress={(p: any) => void $.ui.copy({ text: cmd, surface: p.surface }).catch(() => {})} />
379      </Box>
380    )
381  }
382
383  const rows: any[] = []
384  if (demo) rows.push(<Text key="demo" color="cyan">demo 模式:假資料(/dispatch demo 結束)</Text>)
385  if (fail !== null) rows.push(<Text key="fail" color="yellow" wrap="truncate-end">{fail}</Text>)
386  if (s === null) rows.push(<Text key="loading" dimColor>{fail === null ? '讀取中…' : '尚無成功的快照'}</Text>)
387  else paneModel(s, since, now).forEach((row, i) => rows.push(paneRow(row, i)))
388  rows.push(
389    <Text key="footer" dimColor wrap="truncate-end">
390      {okAt === null ? '尚未成功更新' : `上次更新:${Math.max(0, Math.round((now - okAt) / 1000))}s 前`}
391    </Text>,
392  )
393  return (
394    <Box flexDirection="column">
395      {rows}
396      <Box>
397        <Button
398          key="refresh"
399          label="立即更新"
400          onPress={() => {
401            lastAttemptAt = null
402            void poll($).catch(() => {})
403          }}
404        />
405        <Button key="close" label="關閉" onPress={() => $.ui.close({ id: PANE }).catch(() => {})} />
406      </Box>
407    </Box>
408  )
409}
410
411export const register: Register = (on, options) => {
412  const configured = (options as Record<string, unknown> | undefined)?.missions_dir
413  dirSetting = typeof configured === 'string' && configured.trim() ? configured : DEFAULT_DIR
414  on('session.start', onSessionStart)
415  on('command.run', { command: 'dispatch' }, onDispatchCommand)
416  on('ui.close', onPaneClose)
417  on('ui.render', { component: 'AbovePrompt' }, onAbovePrompt)
418  on('ui.render', { component: 'Pane', requestId: PANE }, onPane)
419}
420
hooks/logic.ts 550 lines
1// dispatch-board 純函式:mission 檔解析、路徑安全檢查、名冊對照、快照比對、band 與面板版面。
2// 不碰 $,供 register.tsx 與測試共用。
3
4import type { BadFile, BoardMission, BoardNode, BoardSnapshot, NodeEval, SessionView } from '../types'
5
6export type { BadFile, BoardMission, BoardNode, BoardSnapshot, NodeEval, SessionView }
7
8export const PLUGIN = 'dispatch-board'
9export const DEFAULT_DIR = '~/.claude/missions'
10export const TICK_MS = 5000
11export const PANE_POLL_MS = 5000
12export const BAND_POLL_MS = 30000
13export const DONE_TIMEOUT_MS = 5000
14export const ROSTER_TIMEOUT_MS = 10000
15export const TOAST_MS = 8000
16/** 同時求值的完成條件數上限 */
17export const EVAL_CONCURRENCY = 4
18export const MAX_FILE_BYTES = 256 * 1024
19export const MAX_NODES = 50
20
21// ── 目錄與安全邊界 ─────────────────────────────────────────────────────────
22// 完成條件是 shell 指令、mod 會執行它,所以 mission 檔只從使用者層級的目錄讀:
23// 家目錄底下、不在任何 git 工作樹內、也不在本 session 的專案目錄內。
24
25/** 設定值 → 絕對路徑;只收 `~`、`~/…` 或絕對路徑。 */
26export function expandDir(raw: string, home: string): { ok: true; path: string } | { ok: false; error: string } {
27  const text = raw.trim()
28  if (!text) return { ok: false, error: 'missions_dir 是空的' }
29  if (text.includes('\0')) return { ok: false, error: 'missions_dir 含不合法字元' }
30  if (text === '~') return { ok: true, path: home }
31  if (text.startsWith('~/')) return { ok: true, path: `${home}/${text.slice(2)}` }
32  if (text.startsWith('/')) return { ok: true, path: text }
33  return { ok: false, error: `missions_dir 須為 ~/… 或絕對路徑(收到 ${truncate(text, 60)})` }
34}
35
36export function isUnder(child: string, parent: string): boolean {
37  const p = parent.replace(/\/+$/, '')
38  return child.startsWith(`${p}/`)
39}
40
41export function isSameOrUnder(child: string, parent: string): boolean {
42  return child === parent.replace(/\/+$/, '') || isUnder(child, parent)
43}
44
45/** realDir 往上到(不含)realHome 的每一層:檢查 `.git` 用。 */
46export function gitProbePaths(realDir: string, realHome: string): string[] {
47  const out: string[] = []
48  let p = realDir.replace(/\/+$/, '')
49  while (isUnder(p, realHome)) {
50    out.push(`${p}/.git`)
51    p = p.slice(0, p.lastIndexOf('/'))
52  }
53  return out
54}
55
56export type DirFacts = {
57  realHome: string
58  realDir: string
59  /** 本 session 的專案根與工作目錄(已解析) */
60  projectDirs: readonly string[]
61  /** gitProbePaths 裡存在的那些 */
62  gitHits: readonly string[]
63}
64
65/** 安全邊界判定;回 null=可讀,否則回拒絕原因。 */
66export function dirRefusal(f: DirFacts): string | null {
67  if (!isUnder(f.realDir, f.realHome)) return `mission 目錄 ${f.realDir} 不在家目錄底下,拒讀`
68  for (const p of f.projectDirs) {
69    if (isSameOrUnder(f.realDir, p)) return `mission 目錄 ${f.realDir} 在本 session 的專案目錄內,拒讀`
70  }
71  if (f.gitHits.length) return `mission 目錄 ${f.realDir} 在 git 工作樹內(${f.gitHits[0]}),拒讀`
72  return null
73}
74
75/**
76 * 自訂的 missions_dir 必須來自使用者層級設定(~/.claude/settings.json)。
77 * 專案的 .claude/settings.json 也能寫 pluginConfigs;不檢查來源的話,clone 一個陌生 repo
78 * 就能把目錄指到別處。預設值不需要來源。
79 */
80export function configRefusal(configured: string, userSettings: Readonly<Record<string, unknown>>): string | null {
81  if (configured.trim() === DEFAULT_DIR) return null
82  const configs = userSettings.pluginConfigs
83  if (configs && typeof configs === 'object') {
84    for (const [key, entry] of Object.entries(configs as Record<string, unknown>)) {
85      if (key !== PLUGIN && !key.startsWith(`${PLUGIN}@`)) continue
86      const options = (entry as { options?: Record<string, unknown> } | null)?.options
87      if (options && options.missions_dir === configured) return null
88    }
89  }
90  return 'missions_dir 只接受使用者層級設定(~/.claude/settings.json);其他來源的值不採用'
91}
92
93/** 家目錄縮寫成 ~,顯示用。 */
94export function tildify(path: string, home: string): string {
95  if (path === home) return '~'
96  return isUnder(path, home) ? `~${path.slice(home.replace(/\/+$/, '').length)}` : path
97}
98
99export function baseName(path: string): string {
100  return path.slice(path.lastIndexOf('/') + 1)
101}
102
103/** mission 檔候選:目錄第一層的 `*.json`(子目錄不讀,`done/` 可放收斂完的檔) */
104export function isMissionFileName(name: string): boolean {
105  return name.endsWith('.json') && !name.startsWith('.') && name.length > '.json'.length
106}
107
108// ── mission 檔 ──────────────────────────────────────────────────────────────
109
110export type MissionNode = { id: string; title: string; session: string; done: string; depends_on: string[] }
111export type MissionSpec = { mission_id: string; title: string; nodes: MissionNode[] }
112
113const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/
114
115function optString(v: unknown, field: string, max: number): string {
116  if (v === undefined || v === null) return ''
117  if (typeof v !== 'string') throw new Error(`${field} 必須是字串`)
118  if (v.length > max) throw new Error(`${field} 超過 ${max} 字`)
119  return v.trim()
120}
121
122/** mission 檔文字 → 規格或原因。mission_id 省略時取檔名(去掉 .json)。 */
123export function parseMission(text: string, fileName: string): { ok: true; value: MissionSpec } | { ok: false; reason: string } {
124  let raw: unknown
125  try {
126    raw = JSON.parse(text)
127  } catch {
128    return { ok: false, reason: '不是合法 JSON' }
129  }
130  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, reason: '最外層必須是 JSON 物件' }
131  const r = raw as Record<string, unknown>
132  try {
133    const mission_id = optString(r.mission_id, 'mission_id', 80) || fileName.replace(/\.json$/, '')
134    if (!ID_RE.test(mission_id)) throw new Error(`mission_id「${truncate(mission_id, 40)}」只能用英數、_ . -`)
135    const title = optString(r.title, 'title', 200)
136    if (!Array.isArray(r.nodes) || r.nodes.length === 0) throw new Error('nodes 必須是非空陣列')
137    if (r.nodes.length > MAX_NODES) throw new Error(`nodes 超過 ${MAX_NODES} 個`)
138    const nodes: MissionNode[] = []
139    const ids = new Set<string>()
140    r.nodes.forEach((n: unknown, i: number) => {
141      if (n === null || typeof n !== 'object' || Array.isArray(n)) throw new Error(`nodes[${i}] 必須是物件`)
142      const o = n as Record<string, unknown>
143      const id = optString(o.id, `nodes[${i}].id`, 80)
144      if (!ID_RE.test(id)) throw new Error(`nodes[${i}].id 缺少或含不合法字元(只能用英數、_ . -)`)
145      if (ids.has(id)) throw new Error(`節點 id「${id}」重複`)
146      ids.add(id)
147      const done = optString(o.done, `${id}.done`, 2000)
148      if (!done) throw new Error(`${id}.done 缺少(完成條件指令)`)
149      const deps = o.depends_on ?? []
150      if (!Array.isArray(deps) || deps.some(d => typeof d !== 'string')) throw new Error(`${id}.depends_on 必須是字串陣列`)
151      nodes.push({
152        id,
153        title: optString(o.title, `${id}.title`, 200),
154        session: optString(o.session, `${id}.session`, 200),
155        done,
156        depends_on: (deps as string[]).map(d => d.trim()),
157      })
158    })
159    for (const n of nodes) {
160      for (const d of n.depends_on) {
161        if (d === n.id) throw new Error(`${n.id} 依賴自己`)
162        if (!ids.has(d)) throw new Error(`${n.id} 依賴不存在的節點「${truncate(d, 40)}」`)
163      }
164    }
165    return { ok: true, value: { mission_id, title, nodes } }
166  } catch (err) {
167    return { ok: false, reason: err instanceof Error ? err.message : String(err) }
168  }
169}
170
171// ── 完成條件求值 ────────────────────────────────────────────────────────────
172
173/** `sh -c` 的退出碼 → 求值結果。126/127 是 shell 自己的「不能執行/找不到指令」,算無法判定。 */
174export function classifyExit(exitCode: number, stderr: string): NodeEval {
175  if (exitCode === 0) return { kind: 'done' }
176  if (exitCode === 126 || exitCode === 127) {
177    const line = stderr.trim().split('\n').pop() ?? ''
178    return { kind: 'unknown', reason: `指令跑不起來(exit ${exitCode}${line ? `:${truncate(line, 80)}` : ''})` }
179  }
180  return { kind: 'open', exitCode }
181}
182
183/**
184 * `$.process.run` reject 的原因。逾時的錯誤訊息不保證寫明是逾時,所以用經過時間判定:
185 * 跑到逾時上限附近才 reject 就算逾時,否則是指令跑不起來。
186 */
187export function rejectionReason(err: unknown, elapsedMs: number): string {
188  if (elapsedMs >= DONE_TIMEOUT_MS - 500) return `逾時 ${DONE_TIMEOUT_MS / 1000}s`
189  const text = (err instanceof Error ? err.message : String(err)).replace(/^(HooksError:\s*)?dispatch-board:\s*/, '')
190  return `跑不起來:${truncate(text, 60)}`
191}
192
193/** 以固定併發數依序處理,保留輸入順序。 */
194export async function mapPool<T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
195  const out = new Array<R>(items.length)
196  let next = 0
197  const worker = async () => {
198    while (next < items.length) {
199      const i = next++
200      out[i] = await fn(items[i] as T)
201    }
202  }
203  await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker))
204  return out
205}
206
207// ── session 名冊 ────────────────────────────────────────────────────────────
208
209export type RosterEntry = { name: string; state: string; status: string; startedAt: number | null }
210export type Roster = { available: true; entries: RosterEntry[] } | { available: false; note: string }
211
212/** `claude agents --json` 的結果 → 名冊。任何非預期都算不可用,不當成「名冊是空的」。 */
213export function parseRoster(exitCode: number, stdout: string, stderr: string): Roster {
214  if (exitCode !== 0) {
215    const line = stderr.trim().split('\n').pop() ?? ''
216    return { available: false, note: `claude agents --json exit ${exitCode}${line ? `:${truncate(line, 80)}` : ''}` }
217  }
218  let raw: unknown
219  try {
220    raw = JSON.parse(stdout)
221  } catch {
222    return { available: false, note: 'claude agents --json 輸出不是 JSON' }
223  }
224  if (!Array.isArray(raw)) return { available: false, note: 'claude agents --json 輸出不是陣列' }
225  const entries: RosterEntry[] = []
226  for (const e of raw) {
227    if (!e || typeof e !== 'object') continue
228    const o = e as Record<string, unknown>
229    entries.push({
230      name: typeof o.name === 'string' ? o.name : '',
231      state: typeof o.state === 'string' ? o.state : '',
232      status: typeof o.status === 'string' ? o.status : '',
233      startedAt: typeof o.startedAt === 'number' ? o.startedAt : null,
234    })
235  }
236  return { available: true, entries }
237}
238
239export function normalizeName(name: string): string {
240  return name.trim().toLowerCase()
241}
242
243function liveState(e: RosterEntry): 'working' | 'idle' | 'blocked' {
244  if (e.state === 'blocked') return 'blocked'
245  if (e.status === 'busy' || e.state === 'working') return 'working'
246  return 'idle'
247}
248
249const LIVE_RANK = { blocked: 0, working: 1, idle: 2 } as const
250
251/** 節點的 session 名稱 → 名冊上的樣子。同名多筆時取最需要注意的(blocked>working>idle)。 */
252export function sessionView(name: string, roster: Roster, seen: Readonly<Record<string, number>>): SessionView {
253  if (!name) return { kind: 'none' }
254  if (!roster.available) return { kind: 'unavailable' }
255  const key = normalizeName(name)
256  let best: SessionView | null = null
257  for (const e of roster.entries) {
258    if (normalizeName(e.name) !== key) continue
259    const v = { kind: 'live' as const, state: liveState(e), startedAt: e.startedAt }
260    if (best === null || (best.kind === 'live' && LIVE_RANK[v.state] < LIVE_RANK[best.state])) best = v
261  }
262  if (best) return best
263  return seen[key] !== undefined ? { kind: 'gone' } : { kind: 'absent' }
264}
265
266/** 記下這次在名冊上看到的 session(只記 mission 用到的名稱)。名冊不可用時原樣保留。 */
267export function nextSeen(
268  prev: Readonly<Record<string, number>>,
269  roster: Roster,
270  names: readonly string[],
271  now: number,
272): Record<string, number> {
273  const out: Record<string, number> = {}
274  const wanted = new Set(names.filter(Boolean).map(normalizeName))
275  for (const [k, v] of Object.entries(prev)) if (wanted.has(k)) out[k] = v
276  if (!roster.available) return out
277  for (const e of roster.entries) {
278    const k = normalizeName(e.name)
279    if (wanted.has(k)) out[k] = now
280  }
281  return out
282}
283
284export function missionNode(spec: MissionNode, ev: NodeEval, live: SessionView): BoardNode {
285  return { ...spec, eval: ev, live }
286}
287
288// ── 狀態判定 ────────────────────────────────────────────────────────────────
289
290export function isDone(n: BoardNode): boolean {
291  return n.eval.kind === 'done'
292}
293
294export function isUnknown(n: BoardNode): boolean {
295  return n.eval.kind === 'unknown'
296}
297
298/** 節點在等你:還沒完成,且負責的 session blocked。 */
299export function waitingOnUser(n: BoardNode): boolean {
300  return !isDone(n) && n.live.kind === 'live' && n.live.state === 'blocked'
301}
302
303/** session 已結束但完成條件沒達成:多半是 session 半途掛了或忘了收尾。 */
304export function endedUndone(n: BoardNode): boolean {
305  return !isDone(n) && n.live.kind === 'gone'
306}
307
308export function nodeNeedsYou(n: BoardNode): boolean {
309  return waitingOnUser(n) || isUnknown(n) || endedUndone(n)
310}
311
312export function depsPending(m: BoardMission, n: BoardNode): string[] {
313  const byId = new Map(m.nodes.map(x => [x.id, x]))
314  return n.depends_on.filter(d => {
315    const dep = byId.get(d)
316    return !dep || !isDone(dep)
317  })
318}
319
320export function progressOf(m: BoardMission): { done: number; total: number } {
321  return { done: m.nodes.filter(isDone).length, total: m.nodes.length }
322}
323
324export function missionDone(m: BoardMission): boolean {
325  return m.nodes.length > 0 && m.nodes.every(isDone)
326}
327
328export function nodeKey(missionId: string, nodeId: string): string {
329  return `${missionId}/${nodeId}`
330}
331
332/**
333 * 「需要你」的件數:blocked 的 session(跨 mission 去重,你只需要去回一個 session)
334 * +無法判定的節點+session 已結束但沒完成的節點+讀不了的 mission 檔。
335 */
336export function needYou(s: BoardSnapshot): number {
337  const blocked = new Set<string>()
338  let n = s.bad.length
339  for (const m of s.missions) {
340    for (const node of m.nodes) {
341      if (waitingOnUser(node)) blocked.add(normalizeName(node.session))
342      else if (isUnknown(node) || endedUndone(node)) n += 1
343    }
344  }
345  return n + blocked.size
346}
347
348export type Group = 'need' | 'running' | 'converge'
349export const GROUP_LABEL: Record<Group, string> = { need: '需要你', running: '跑著', converge: '做完待收斂' }
350const GROUP_ORDER: readonly Group[] = ['need', 'running', 'converge']
351
352export function groupOf(m: BoardMission): Group {
353  if (m.nodes.some(nodeNeedsYou)) return 'need'
354  return missionDone(m) ? 'converge' : 'running'
355}
356
357/** 最急:需要你的 mission 優先,其次跑著的,再依 mission_id。 */
358export function mostUrgent(missions: readonly BoardMission[]): BoardMission | null {
359  const rank = (m: BoardMission) => GROUP_ORDER.indexOf(groupOf(m))
360  return [...missions].sort((a, b) => rank(a) - rank(b) || a.mission_id.localeCompare(b.mission_id))[0] ?? null
361}
362
363// ── 時間與文字 ──────────────────────────────────────────────────────────────
364
365export function duration(ms: number): string {
366  const s = Math.max(0, Math.floor(ms / 1000))
367  if (s < 60) return `${s}s`
368  if (s < 3600) return `${Math.floor(s / 60)}m`
369  return `${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60)}m`
370}
371
372export function truncate(s: string, max: number): string {
373  const one = s.replace(/\s+/g, ' ').trim()
374  return one.length <= max ? one : `${one.slice(0, max - 1)}…`
375}
376
377/** shell 單引號字串(給「複製收斂指令」用;mod 自己不執行它)。 */
378export function shellQuote(s: string): string {
379  return `'${s.replace(/'/g, `'\\''`)}'`
380}
381
382// ── band ───────────────────────────────────────────────────────────────────
383
384/** band 一行;回 null=不佔行(沒有 mission、沒有讀不了的檔、也沒有失敗)。 */
385export function bandText(s: BoardSnapshot | null, failure: string | null, lastOkAt: number | null, now: number): string | null {
386  if (failure !== null) return `⧉ dispatch-board:${failure}`
387  if (s === null || (s.missions.length === 0 && s.bad.length === 0)) return null
388  const ago = lastOkAt === null ? '' : ` · ${duration(now - lastOkAt)} 前`
389  const need = needYou(s)
390  if (s.missions.length === 0) return `⧉ ${s.bad.length} 份 mission 檔讀不了 · /dispatch 看原因${ago}`
391  if (s.missions.length === 1) {
392    const m = s.missions[0] as BoardMission
393    const p = progressOf(m)
394    return `⧉ ${m.mission_id} ${p.done}/${p.total} · ${need} 需要你${ago}`
395  }
396  let done = 0
397  let total = 0
398  for (const m of s.missions) {
399    const p = progressOf(m)
400    done += p.done
401    total += p.total
402  }
403  const urgent = mostUrgent(s.missions)
404  return `⧉ ${s.missions.length} missions ${done}/${total} · ${need} 需要你 · 最急 ${urgent?.mission_id ?? '—'}${ago}`
405}
406
407// ── toast ──────────────────────────────────────────────────────────────────
408
409/** 與上一份快照比對:只在狀態轉換時跳。prev 為 null(第一份)只作基準。 */
410export function diffToasts(prev: BoardSnapshot | null, next: BoardSnapshot): string[] {
411  if (prev === null) return []
412  const out: string[] = []
413  const prevMissions = new Map(prev.missions.map(m => [m.mission_id, m]))
414  for (const m of next.missions) {
415    const pm = prevMissions.get(m.mission_id)
416    if (!pm) continue
417    const prevNodes = new Map(pm.nodes.map(n => [n.id, n]))
418    for (const n of m.nodes) {
419      const pn = prevNodes.get(n.id)
420      if (!pn) continue
421      const key = nodeKey(m.mission_id, n.id)
422      if (!isDone(pn) && isDone(n)) out.push(`${key} 完成`)
423      if (!isUnknown(pn) && n.eval.kind === 'unknown') out.push(`${key} 完成條件無法判定:${truncate(n.eval.reason, 80)}`)
424      if (!waitingOnUser(pn) && waitingOnUser(n)) out.push(`${key} 在等你(${n.session})`)
425      if (!endedUndone(pn) && endedUndone(n)) out.push(`${key} 的 session 已結束,但完成條件沒達成`)
426    }
427    if (!missionDone(pm) && missionDone(m)) out.push(`${m.mission_id} 全部完成,待收斂`)
428  }
429  const prevBad = new Set(prev.bad.map(b => b.file))
430  for (const b of next.bad) if (!prevBad.has(b.file)) out.push(`${baseName(b.file)} 讀不了:${truncate(b.reason, 80)}`)
431  return out
432}
433
434/** blocked 起始時間只能由快照比對得知。 */
435export function nextBlockedSince(prev: Readonly<Record<string, number>>, s: BoardSnapshot, now: number): Record<string, number> {
436  const out: Record<string, number> = {}
437  for (const m of s.missions) {
438    for (const n of m.nodes) {
439      if (!waitingOnUser(n)) continue
440      const key = nodeKey(m.mission_id, n.id)
441      out[key] = prev[key] ?? now
442    }
443  }
444  return out
445}
446
447// ── 面板 ───────────────────────────────────────────────────────────────────
448
449const BAR_MAX_CELLS = 10
450
451export function progressBar(m: BoardMission): string {
452  const { done, total } = progressOf(m)
453  if (total === 0) return '0/0'
454  const cells = Math.min(total, BAR_MAX_CELLS)
455  const filled = Math.round((done / total) * cells)
456  return `${'▓'.repeat(filled)}${'░'.repeat(cells - filled)} ${done}/${total}`
457}
458
459/** 節點符號:✓ 完成、✗ 無法判定、⏸ 等你、▶ session 活著、○ 其餘。 */
460export function nodeSymbol(n: BoardNode): string {
461  if (isDone(n)) return '✓'
462  if (isUnknown(n)) return '✗'
463  if (waitingOnUser(n)) return '⏸'
464  return n.live.kind === 'live' ? '▶' : '○'
465}
466
467/** session 欄:狀態詞在前、名稱在後,窄面板截斷時先吃掉名稱。 */
468export function sessionText(n: BoardNode): string {
469  const v = n.live
470  if (v.kind === 'none') return ''
471  const status =
472    v.kind === 'live'
473      ? { working: '工作中', idle: '閒置', blocked: 'blocked' }[v.state]
474      : v.kind === 'gone'
475        ? '已結束'
476        : v.kind === 'absent'
477          ? '不在名冊'
478          : '名冊不可用'
479  return `${status} ${n.session}`
480}
481
482export function waitText(missionId: string, n: BoardNode, blockedSince: Readonly<Record<string, number>>, now: number): string {
483  if (isDone(n)) return ''
484  const since = blockedSince[nodeKey(missionId, n.id)]
485  if (waitingOnUser(n) && since !== undefined) return `等你 ${duration(now - since)}`
486  if (n.live.kind === 'live' && n.live.state === 'working' && n.live.startedAt !== null) return `跑了 ${duration(now - n.live.startedAt)}`
487  return ''
488}
489
490export function nodeLine(m: BoardMission, n: BoardNode, blockedSince: Readonly<Record<string, number>>, now: number): string {
491  const pending = isDone(n) ? [] : depsPending(m, n)
492  const parts = [
493    `${nodeSymbol(n)} ${n.id}${n.title ? ` ${n.title}` : ''}`,
494    waitText(m.mission_id, n, blockedSince, now),
495    pending.length ? `等依賴 ${pending.join(',')}` : '',
496    endedUndone(n) ? '已結束但沒完成' : '',
497    sessionText(n),
498  ]
499  return parts.filter(Boolean).join(' · ')
500}
501
502export type PaneTone = 'plain' | 'bold' | 'dim' | 'warn'
503
504/** 面板的一列;`copy` 表示附一顆把該文字放上剪貼簿的按鈕。每列只佔一行(截斷,不換行)。 */
505export type PaneRow = { text: string; tone: PaneTone; copy?: { key: string; text: string } }
506
507export function archiveCommand(file: string): string {
508  const dir = file.slice(0, file.lastIndexOf('/'))
509  return `mkdir -p ${shellQuote(`${dir}/done`)} && mv ${shellQuote(file)} ${shellQuote(`${dir}/done/`)}`
510}
511
512/** 快照 → 面板各列(不含讀取中、失敗與頁尾,那些屬於 register 的輪詢狀態)。 */
513export function paneModel(s: BoardSnapshot, blockedSince: Readonly<Record<string, number>>, now: number): PaneRow[] {
514  const rows: PaneRow[] = []
515  rows.push({ text: `mission 目錄 ${s.dir}`, tone: 'dim' })
516  if (!s.roster.available) rows.push({ text: `session 名冊不可用:${s.roster.note}`, tone: 'warn' })
517  if (s.missions.length === 0 && s.bad.length === 0) {
518    rows.push({ text: '沒有 mission。在 mission 目錄放一個 .json 檔就會出現(格式見 README)', tone: 'dim' })
519  }
520  for (const g of GROUP_ORDER) {
521    const ms = s.missions.filter(m => groupOf(m) === g)
522    if (!ms.length) continue
523    rows.push({ text: `${GROUP_LABEL[g]}(${ms.length})`, tone: 'bold' })
524    for (const m of ms) {
525      rows.push({ text: `${m.mission_id}  ${progressBar(m)}${m.title ? `  ${m.title}` : ''}`, tone: 'plain' })
526      for (const n of m.nodes) {
527        const tone: PaneTone = isDone(n) ? 'dim' : nodeNeedsYou(n) ? 'warn' : 'plain'
528        rows.push({ text: `  ${nodeLine(m, n, blockedSince, now)}`, tone })
529        if (n.eval.kind === 'unknown') rows.push({ text: `      條件 ${truncate(n.done, 70)} → ${truncate(n.eval.reason, 60)}`, tone: 'warn' })
530      }
531      if (g === 'converge') {
532        const cmd = archiveCommand(m.file)
533        rows.push({ text: `  收斂後把 mission 檔移到 done/,看板就不再求值`, tone: 'dim', copy: { key: `copy-archive:${m.mission_id}`, text: cmd } })
534      }
535    }
536  }
537  if (s.bad.length) {
538    rows.push({ text: `讀不了的 mission 檔(${s.bad.length})`, tone: 'bold' })
539    for (const b of s.bad) rows.push({ text: `  ${baseName(b.file)} · ${truncate(b.reason, 100)}`, tone: 'warn' })
540  }
541  return rows
542}
543
544export function pollDue(now: number, lastAttemptAt: number | null, paneOpen: boolean): boolean {
545  if (lastAttemptAt === null) return true
546  const period = paneOpen ? PANE_POLL_MS : BAND_POLL_MS
547  // 計時器每 TICK_MS 觸發,留半個 tick 的寬限,避免 30 秒週期因抖動拖成 35 秒
548  return now - lastAttemptAt >= period - TICK_MS / 2
549}
550
hooks/demo.ts 82 lines
1// demo 模式的假資料:錄影與還沒有 mission 時試看用。不讀目錄、不執行任何指令。
2// 每次輪詢往下一格,停在最後一格;格與格之間的差異會跳出對應的 toast。
3
4import type { BoardMission, BoardNode, BoardSnapshot, NodeEval, SessionView } from '../types'
5
6const T0 = Date.UTC(2026, 9, 4, 1, 0, 0)
7
8const done: NodeEval = { kind: 'done' }
9const open: NodeEval = { kind: 'open', exitCode: 1 }
10const working = (minutesAgo: number): SessionView => ({ kind: 'live', state: 'working', startedAt: T0 - minutesAgo * 60000 })
11const idle: SessionView = { kind: 'live', state: 'idle', startedAt: T0 }
12const blocked: SessionView = { kind: 'live', state: 'blocked', startedAt: T0 }
13
14function node(id: string, title: string, session: string, ev: NodeEval, live: SessionView, depends_on: string[] = []): BoardNode {
15  return { id, title, session, done: `git -C ~/code/shop log --oneline main | grep -q '\\[${id}-done\\]'`, depends_on, eval: ev, live }
16}
17
18function launch(nodes: BoardNode[]): BoardMission {
19  return { mission_id: 'shop-redesign', title: '商店改版', file: '/home/demo/.claude/missions/shop-redesign.json', nodes }
20}
21
22const docsDone: BoardMission = {
23  mission_id: 'docs-cleanup',
24  title: '文件整理',
25  file: '/home/demo/.claude/missions/docs-cleanup.json',
26  nodes: [
27    node('api-docs', 'API 文件', 'docs-api', done, idle),
28    node('guide', '使用指南', 'docs-guide', done, { kind: 'gone' }),
29  ],
30}
31
32const base = { dir: '~/.claude/missions', roster: { available: true, note: '' }, bad: [] }
33
34export const DEMO_SNAPSHOTS: readonly BoardSnapshot[] = [
35  {
36    ...base,
37    missions: [
38      launch([
39        node('api', '後端 API', 'shop-api', open, working(12)),
40        node('web', '前端頁面', 'shop-web', open, blocked),
41        node('e2e', '端對端測試', 'shop-e2e', open, { kind: 'absent' }, ['api', 'web']),
42      ]),
43      docsDone,
44    ],
45  },
46  {
47    ...base,
48    missions: [
49      launch([
50        node('api', '後端 API', 'shop-api', done, idle),
51        node('web', '前端頁面', 'shop-web', open, working(3)),
52        node('e2e', '端對端測試', 'shop-e2e', open, { kind: 'absent' }, ['api', 'web']),
53      ]),
54      docsDone,
55    ],
56  },
57  {
58    ...base,
59    missions: [
60      launch([
61        node('api', '後端 API', 'shop-api', done, idle),
62        node('web', '前端頁面', 'shop-web', done, idle),
63        node('e2e', '端對端測試', 'shop-e2e', { kind: 'unknown', reason: '逾時 5s' }, working(1), ['api', 'web']),
64      ]),
65      docsDone,
66    ],
67    bad: [{ file: '/home/demo/.claude/missions/draft.json', reason: 'e2e.done 缺少(完成條件指令)' }],
68  },
69  {
70    ...base,
71    missions: [
72      launch([
73        node('api', '後端 API', 'shop-api', done, idle),
74        node('web', '前端頁面', 'shop-web', done, idle),
75        node('e2e', '端對端測試', 'shop-e2e', done, idle, ['api', 'web']),
76      ]),
77      docsDone,
78    ],
79    bad: [{ file: '/home/demo/.claude/missions/draft.json', reason: 'e2e.done 缺少(完成條件指令)' }],
80  },
81]
82
types/index.d.ts 77 lines
1// dispatch-board 的資料型別與 $.state 契約。
2// 一份 mission 檔 → BoardMission;每個節點附上「完成條件求值」與「session 名冊對照」的結果。
3
4/** 完成條件求值:退出碼 0=完成;指令跑不起來或逾時=無法判定(不併入「還沒完成」) */
5export type NodeEval =
6  | { kind: 'done' }
7  | { kind: 'open'; exitCode: number }
8  | { kind: 'unknown'; reason: string }
9
10/**
11 * 節點負責的 session 在名冊上的樣子。
12 * - live:在 `claude agents --json` 名冊上(工作中/閒置/blocked)
13 * - gone:本 session 之前看過它活著,現在名冊上沒有=已結束
14 * - absent:名冊上沒有,也沒看過(還沒開,或在看板啟動前就結束了)
15 * - unavailable:名冊讀不到;SHALL NOT 畫成已結束
16 * - none:節點沒指定 session
17 */
18export type SessionView =
19  | { kind: 'live'; state: 'working' | 'idle' | 'blocked'; startedAt: number | null }
20  | { kind: 'gone' }
21  | { kind: 'absent' }
22  | { kind: 'unavailable' }
23  | { kind: 'none' }
24
25export type BoardNode = {
26  id: string
27  title: string
28  /** session 名稱;空字串=沒指定 */
29  session: string
30  /** 完成條件(shell 指令) */
31  done: string
32  depends_on: string[]
33  eval: NodeEval
34  live: SessionView
35}
36
37export type BoardMission = {
38  mission_id: string
39  title: string
40  /** mission 檔的絕對路徑(已解析符號連結) */
41  file: string
42  nodes: BoardNode[]
43}
44
45/** 讀不了的 mission 檔:檔名+原因 */
46export type BadFile = { file: string; reason: string }
47
48export type BoardSnapshot = {
49  /** mission 目錄(顯示用,家目錄縮寫成 ~) */
50  dir: string
51  roster: { available: boolean; note: string }
52  missions: BoardMission[]
53  bad: BadFile[]
54}
55
56declare module 'claude-code' {
57  interface PluginState {
58    'dispatch-board': {
59      isPaneOpen: boolean
60      /** /dispatch off 之後為 true:本 session 不再輪詢 */
61      isPaused: boolean
62      /** demo 模式:用假資料畫面,不讀 mission 目錄、不執行任何指令 */
63      isDemo: boolean
64      snapshot: BoardSnapshot | null
65      lastOkAt: number | null
66      /** 目錄被拒或讀不了的原因;null=正常 */
67      failure: string | null
68      /** 節點鍵 → 開始 blocked 的時間(名冊沒有這個時間,只能靠快照比對) */
69      blockedSince: Record<string, number>
70      /** session 名稱(正規化)→ 最近一次在名冊上看到它的時間 */
71      seenSessions: Record<string, number>
72      /** 每個計時器 tick 寫入一次,只為讓「N 秒前」重繪 */
73      tickAt: number
74    }
75  }
76}
77