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

同時開好幾個 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 前
[ 立即更新 ][ 關閉 ]
~/.claude/missions/,放一份 mission 檔(*.json,格式見下)。claude --bg --name shop-api(名字要和 claude agents 列出的一樣,不分大小寫)。/dispatch 開面板。| 指令 | 作用 |
|---|---|
/dispatch | 開關面板 |
/dispatch off | 本 session 停止讀 mission、停止執行完成條件;再打 /dispatch 恢復 |
/dispatch demo | 用假資料試看(不讀目錄、不執行任何指令);再打一次結束 |
也可以用環境變數 DISPATCH_BOARD_DEMO=1 啟動 demo 模式,方便錄影。
一個 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 條。建議用「工作產出」當完成條件(commit 訊息、檔案存在、測試報告),而不是 session 自己說做完了。
來自 claude agents --json:
| 顯示 | 意思 |
|---|---|
| 工作中/閒置/blocked | session 在名冊上;blocked 代表它在等你回覆 |
| 已結束 | 看板啟動後看過它,現在名冊上沒有了 |
| 不在名冊 | 名冊上沒有,也沒看過(還沒開,或在看板啟動前就結束了) |
| 名冊不可用 | claude agents --json 跑不起來或輸出看不懂;不會顯示成已結束 |
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 給的值一律不採用。.git 就拒讀)。HOME 未設定)就整個停用。除此之外 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 -p 不輪詢。/bin/sh,Windows 未測試。claude --plugin-dir ./plugins/dispatch-board # 單次載入
claude plugin test ./plugins/dispatch-board # 跑測試
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.
hooks/register.tsx 420 lines1// 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}
420hooks/logic.ts 550 lines1// 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}
550hooks/demo.ts 82 lines1// 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]
82types/index.d.ts 77 lines1// 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