dispatch-board 的搭檔:worker session 把回報寫進結果檔後以一行結束,由 mod 在 turn 結束時把新增內容與最終回覆轉送給指揮站(mission 檔寫了 station 才啟用;需 Claude Code ≥ 2.1.289)

dispatch-board 的搭檔,裝在 worker 那一側。多個 session 分工時,worker 做完通常要自己 SendMessage 把回報全文送給指揮站,然後再結束 turn——同一份回報生成兩次,SendMessage 之後還要多跑一次收尾 request。
裝了 dispatch-relay 之後,worker 只要把回報寫進結果檔、以一行結束 turn;mod 在 turn 結束時讀結果檔,把新增或變更的內容連同最終回覆原文送給指揮站。
↪ dispatch-relay:結果檔全文+最終回覆(1834 字)已送達指揮站 shop-lead
/plugin install dispatch-relay --marketplace Hangghost/learning-hacker-claude-mod
裝在 worker 那一側(通常跟 dispatch-board 一起裝)。
~/.claude/missions/*.json),在 mission 層加上 station:指揮站 session 的名稱。--name 開:claude --bg --name shop-lead、claude --bg --name shop-api。名字要和 claude agents 列出的一樣(不分大小寫);沒命名的 session 對不到節點,指揮站沒命名則送不到。.mission-result.md),以一行結束 turn。開始前要注意:
claude --bg 之前,先在該目錄跑一次 claude、接受信任提示。否則 CLI 會回 Workspace not trusted…,session 開不起來。--worktree <名> 開的 worker:它的專案根目錄是 <repo>/.claude/worktrees/<名>,result_file 相對的是這個容器根目錄,不是 repo 根。{
"mission_id": "shop-redesign",
"station": "shop-lead",
"nodes": [
{ "id": "api", "session": "shop-api", "done": "…", "result_file": "reports/api.md" },
{ "id": "web", "session": "shop-web", "done": "…" }
]
}
| 欄位 | 必填 | 說明 |
|---|---|---|
station | 否 | 指揮站 session 的名稱。沒寫就不啟用:只用 dispatch-board 的 mission 不受影響 |
nodes[].result_file | 否 | 結果檔,相對於 worker 的專案根目錄(--worktree 開的 worker 是 <repo>/.claude/worktrees/<名>);預設 .mission-result.md。不能是絕對路徑,不能含 .. |
其餘欄位(mission_id、nodes[].id、nodes[].session、done…)與 dispatch-board 相同,見 dispatch-board 的 README。
$.session.id() 取得本 session 的 id。claude agents --json 找出這個 id 的名稱。nodes[].session 等於這個名稱(不分大小寫)的節點。恰好一個節點、且該 mission 有 station,才啟用。查無、多個節點都指到自己、mission 沒有 station、station 就是自己、名冊不可用,一律不啟用並在日誌(claude --debug)寫一行原因。未啟用時零行為:不改 system prompt、不放行任何送件、不送任何訊息。
mission 目錄裡沒有任何 mission 寫了 station 時,mod 連名冊都不讀——沒在用它的 session 不付這個成本。
啟動時還沒對到的話,每個新 prompt 會再試一次。啟用後,每次要送件前都重新判定一次:mission 檔搬到 done/、改派、或 session 改名就停用;指揮站重開換了 session id 也跟得上(每次送件前用名冊把 station 名稱解析成 session id)。
SendMessage 時,最終回覆附帶送出,不設長度門檻(一行的「要不要 push?」也要送到)。worker 已自送時不附最終回覆,但結果檔變更照送。SendMessage。需要問指揮站時照常 SendMessage,或寫在最終回覆。 [dispatch-relay] mission=<mission_id> node=<node_id> parts=<part>[,<part>…] chars=<本文總字數>
--- <part> ---
<該部分原文>
part 是 result-full、result-append、final-reply 之一;chars 是各部分原文長度加總。
station、同名多筆(不猜是哪一個)、或送件失敗:mod 先把內容記為已處理,再排一個 prompt 請 worker 自己 SendMessage。prompt 裡給的收件位址依序是:mission 檔的 station 名稱(SendMessage 直接收名稱)、上次成功送達時的位址,最後才是最近一則來訊的 from 位址——而且僅限該則來自指揮站。worker 可能收過其他 session 的訊息,「回覆最近一則」會把回報送給第三方。完成與否仍以 mission 檔的完成條件為準:轉送的訊息只是門鈴。
dispatch-relay 會把檔案內容送到另一個 session。 送什麼、送給誰、何時不送:
~、.. 一律拒絕;讀檔前解析符號連結,指到根目錄外的不讀也不送。超過 256 KB 不送。station 名稱,在 claude agents --json 名冊上恰好一筆對到的 session。查無或多筆都不送(改請 worker 自己決定)。station 是自己時不啟用。station 時;turn 不是正常答覆結束時。~/.claude/missions/。可在 /plugin 的設定改 missions_dir,但只認 ~/.claude/settings.json 裡的值;專案設定給的值不採用。missions_dir 時,沿用 dispatch-board 的 missions_dir。優先序是 dispatch-relay 自己的 → dispatch-board 的 → 預設;dispatch-board 的值同樣只認 ~/.claude/settings.json。沒裝 dispatch-board 時不受影響。tool.check 只放行自己發出的 SendMessage;其他來源(包括 worker 自己的 SendMessage)一律交給原本的流程,不攔截、不改寫、不放行。$.store)、不執行 mission 檔的完成條件、不連網、不呼叫模型。claude plugin validate ./plugins/dispatch-relay
結果(v0.1.0):
hooks: session.start, prompt.submit, prompt.compose, turn.start, tool.call{tool=SendMessage},
session.send, tool.check{tool=SendMessage}, turn.complete
calls: $.env.get, $.fs.exists, $.fs.list, $.fs.read, $.fs.stat, $.process.run,
$.prompt.submit, $.session.cwd, $.session.id, $.session.root, $.session.send,
$.settings.read, $.store.delete, $.store.get, $.store.set, $.ui.log
env reads: HOME
env writes: nothing
$.process.run:只跑 claude agents --json$.session.send:唯一的送件點,送給名冊解析出的指揮站$.prompt.submit:唯一的降級點,送不到時請 worker 自己 SendMessage$.fs.*:只讀 mission 目錄、檢查安全邊界、讀結果檔;沒有寫檔turn.complete、$.session.send、tool.check 的 next.origin.plugin、$.store、$.prompt.submit)。--name)並出現在 claude agents 名冊上;沒命名的前景 session 對不到節點。station 時它只讀 mission 目錄,不做別的事。tool.check 看到的收件者是引擎解析後的位址,對不回 session id,所以只驗證寄件 mod 是 dispatch-relay(日誌會寫一次)。claude --plugin-dir ./plugins/dispatch-relay # 單次載入
claude plugin test ./plugins/dispatch-relay # 跑測試
dispatch-relay is the worker-side companion to dispatch-board. Instead of a worker writing its report and then sending the whole thing again with SendMessage, the worker writes the report to a result file (default .mission-result.md in its project root) and ends the turn with one line; at the end of each turn the mod sends what changed in that file, plus the worker's final reply, to the commanding session.
It is opt-in per mission: add "station": "<commander session name>" to a dispatch-board mission file. A session activates only if claude agents --json maps its own session id ($.session.id()) to a name that exactly one node's session matches (case-insensitive) in a mission that has a station. The station name is resolved to a session id from the roster before every send; no match or duplicate names means no send — the worker is asked to send it itself instead (once per content; no loops). Sessions with no stationed mission do nothing, not even read the roster.
Security: the mod sends file contents to another session. It sends only the matched node's result file (relative to the project root, no absolute paths or .., symlinks resolved and kept inside the root, 256 KB cap) and the final reply, only to the one roster session named by station. Mission files are read under the same rules as dispatch-board: a user-level directory under your home, outside the session's project and any git work tree; a custom missions_dir is honoured only from ~/.claude/settings.json, and when dispatch-relay has none of its own it reuses dispatch-board's (from the same file). Both sessions must be started with --name; run claude once in a new directory to accept the trust prompt before the first claude --bg there; for a --worktree <name> worker, result_file is relative to <repo>/.claude/worktrees/<name>. When forwarding fails, the worker is pointed at the station name first, and at the latest from address only if that message came from the station. In tool.check it allows only its own SendMessage; everything else passes through untouched. It writes no files and runs no completion checks. Requires Claude Code v2.1.289+.
hooks/register.ts 386 lines1// dispatch-relay:worker session 的回報由 mod 在 turn 結束時轉送給指揮站。
2//
3// 作用面(全部):
4// - prompt.compose 尾端加一段說明(只在啟用時)。
5// - turn.complete 讀結果檔(預設 `<session 根目錄>/.mission-result.md`),內容有變更才轉送;
6// worker 本 turn 沒自送時附帶最終回覆。
7// - tool.check 只放行「本 mod 自己發出」的送件;worker 自己的 SendMessage 原樣通過。
8// - session.send 只觀察本 mod 自己的送件,記下送達時引擎使用的位址(供降級 prompt 引用),原樣放行。
9// - 送件失敗時請 worker 自己 SendMessage(先記為已處理,同一內容至多降級一次;降級排不進去則回滾,下次 turn 重送)。
10// 不做:不寫任何檔案(日誌走 ui.log、狀態走 store)、不寫結果檔、不執行 mission 檔的完成條件。
11//
12// 啟用判定:`$.session.id()` → `claude agents --json` 反查本 session 的名稱 → mission 目錄裡恰好一個節點的
13// `session` 是這個名稱,且該 mission 有 `station` 欄位(opt-in)。mission 目錄的安全邊界與 dispatch-board 相同。
14
15import type { Register } from 'claude-code'
16
17import {
18 DEFAULT_DIR,
19 MAX_FILE_BYTES,
20 PLUGIN,
21 ROSTER_TIMEOUT_MS,
22 SECTION_ID,
23 buildMessage,
24 degradeFailedLine,
25 degradePrompt,
26 degradedLine,
27 deliveredLine,
28 dirRefusal,
29 expandDir,
30 failedAgainLine,
31 findIdentity,
32 gitProbePaths,
33 inactiveLog,
34 injectionText,
35 isMissionFileName,
36 isUnder,
37 ownName,
38 parseMission,
39 parseRoster,
40 pickDir,
41 resolveStation,
42 sendCheckVerdict,
43 truncate,
44 trySubmit,
45} from './logic'
46import type { Built, Identity, RelayMission, Resolution, Roster } from './logic'
47
48// ── 模組狀態(熱重載即重來;持久狀態只放 $.store)─────────────────────────────
49let dirSetting = DEFAULT_DIR
50let identity: Identity | null = null // 啟用後的節點身份;每個答覆 turn 結束時重新判定
51let lastStationId = '' // 最近一次解析到的指揮站 session id:tool.check 比對用
52let lastDelivered = '' // 最近一次成功送達時引擎實際使用的收件位址(uds:…),降級 prompt 引用;綁行程,不進 $.store
53let lastDeliveredStation = '' // 取得 lastDelivered 的那次送件,送往的指揮站 id;與目前 id 不同時不引用該位址
54// forward() 送件期間的指揮站 id,供 session.send 觀察 hook 與位址一起記下。
55// 前提:forward() 不並行——它只在主迴圈的 turn.complete 被呼叫,同一 session 的主迴圈 turn 不重疊。
56let sendingTo = ''
57let selfSent = false // 本 turn worker 是否自己呼叫過 SendMessage(turn.start 重置)
58let pendingDegrade = false // 本 mod 剛排入一個降級 prompt,等它的 turn.start
59let inDegradedTurn = false // 目前這個 turn 是本 mod 排入的降級 turn
60let lastInactiveLog = '' // 未啟用的原因只在改變時出聲,避免每個 prompt 重複同一行
61let laxRecipientLogged = false
62
63function log($: any, text: string): void {
64 try {
65 $.ui.log(text)
66 } catch {}
67}
68
69function logInactive($: any, text: string): void {
70 if (text === lastInactiveLog) return
71 lastInactiveLog = text
72 log($, text)
73}
74
75// ── mission 目錄(安全邊界與 dispatch-board 相同)────────────────────────────
76
77async function realPathOf($: any, path: string): Promise<string | undefined> {
78 const st = await $.fs.stat(path, { resolve: true }).catch(() => undefined)
79 return st?.realPath
80}
81
82type Located = { kind: 'ok'; realDir: string } | { kind: 'missing' } | { 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 // 只讀使用者層級設定:本 mod 自訂值的來源檢查,以及沿用 dispatch-board 的 missions_dir。
89 const userSettings = await $.settings.read({ source: 'user' }).catch(() => ({}))
90 const picked = pickDir(dirSetting, userSettings ?? {})
91 if (!picked.ok) return { kind: 'refused', error: picked.error }
92 const expanded = expandDir(picked.dir, home)
93 if (!expanded.ok) return { kind: 'refused', error: expanded.error }
94 const realHome = await realPathOf($, home)
95 if (!realHome) return { kind: 'refused', error: `家目錄 ${home} 解析不了,不讀任何 mission 檔` }
96 const dirStat = await $.fs.stat(expanded.path, { resolve: true }).catch(() => undefined)
97 if (!dirStat) return { kind: 'missing' }
98 if (dirStat.kind !== 'dir' || !dirStat.realPath) return { kind: 'refused', error: `${expanded.path} 不是目錄` }
99 const realDir: string = dirStat.realPath
100 const projectDirs: string[] = []
101 for (const p of [await $.session.root(), await $.session.cwd()]) projectDirs.push((await realPathOf($, p)) ?? p)
102 const gitHits: string[] = []
103 for (const p of gitProbePaths(realDir, realHome)) if (await $.fs.exists(p)) gitHits.push(p)
104 const refused = dirRefusal({ realHome, realDir, projectDirs, gitHits })
105 if (refused) return { kind: 'refused', error: refused }
106 return { kind: 'ok', realDir }
107}
108
109/** 讀目錄第一層的 *.json;經符號連結指到目錄外、太大、格式錯、mission_id 重複的檔一律略過(dispatch-board 會列出原因)。 */
110async function loadMissions($: any, realDir: string): Promise<RelayMission[]> {
111 const out: RelayMission[] = []
112 const entries = await $.fs.list(realDir)
113 const names = entries
114 .filter((e: any) => e.kind !== 'dir' && isMissionFileName(e.name))
115 .map((e: any) => String(e.name))
116 .sort()
117 const ids = new Set<string>()
118 for (const name of names) {
119 const st = await $.fs.stat(`${realDir}/${name}`, { resolve: true }).catch(() => undefined)
120 if (!st?.realPath || !isUnder(st.realPath, realDir) || st.kind !== 'file' || st.size > MAX_FILE_BYTES) continue
121 let text: string
122 try {
123 text = await $.fs.read(st.realPath)
124 } catch {
125 continue
126 }
127 const parsed = parseMission(text, name)
128 if (!parsed.ok || ids.has(parsed.value.missionId)) continue
129 ids.add(parsed.value.missionId)
130 out.push(parsed.value)
131 }
132 return out
133}
134
135async function fetchRoster($: any): Promise<Roster> {
136 try {
137 const r = await $.process.run(['claude', 'agents', '--json'], { timeoutMs: ROSTER_TIMEOUT_MS })
138 return parseRoster(r.exitCode, r.stdout, r.stderr)
139 } catch (err) {
140 return { available: false, note: `claude agents --json 跑不起來:${truncate(String(err), 80)}` }
141 }
142}
143
144/**
145 * 身份判定;never throw。沒有任何 mission 選用 relay(沒有 `station`)時不讀名冊、不啟動任何程式:
146 * 本 mod 經 marketplace 安裝會在每個 session 載入,沒用到它的 session 不該付這個成本。
147 * `full`:已啟用的 worker 重新判定時一律讀名冊,停用訊息才能分辨「節點還在但 mission 拿掉了 station」與「沒有節點」。
148 */
149async function resolve($: any, full = false): Promise<{ res: Resolution; roster: Roster | null }> {
150 try {
151 const where = await locate($)
152 if (where.kind === 'refused') return { res: { kind: 'unavailable', reason: where.error }, roster: null }
153 if (where.kind === 'missing') return { res: { kind: 'no-missions' }, roster: null }
154 const missions = await loadMissions($, where.realDir)
155 if (missions.length === 0) return { res: { kind: 'no-missions' }, roster: null }
156 if (!full && !missions.some(m => m.station)) return { res: { kind: 'no-relay' }, roster: null }
157 let sessionId = ''
158 try {
159 sessionId = String((await $.session.id()) ?? '')
160 } catch {}
161 if (!sessionId) return { res: { kind: 'unavailable', reason: '取不到本 session 的 id' }, roster: null }
162 const roster = await fetchRoster($)
163 if (!roster.available) return { res: { kind: 'unavailable', reason: `名冊不可用(${roster.note})` }, roster }
164 const name = ownName(roster.entries, sessionId)
165 if (!name) return { res: { kind: 'unavailable', reason: '名冊上找不到本 session 的名稱(用 --name 命名的 session 才能對到節點)' }, roster }
166 return { res: findIdentity(missions, name), roster }
167 } catch (err) {
168 return { res: { kind: 'unavailable', reason: `判定身份時出錯:${truncate(String(err), 120)}` }, roster: null }
169 }
170}
171
172/** 啟用嘗試:尚未啟用時才查。 */
173async function tryActivate($: any): Promise<void> {
174 if (identity !== null) return
175 const { res } = await resolve($)
176 if (res.kind === 'match') {
177 identity = res.identity
178 lastInactiveLog = ''
179 log($, `已啟用:mission ${identity.missionId} 節點 ${identity.nodeId},回報轉送給 ${identity.station}`)
180 return
181 }
182 logInactive($, inactiveLog(res))
183}
184
185// ── 轉送與降級 ──────────────────────────────────────────────────────────────
186
187/** 唯一的送件點:把訊息送給指揮站(每次送件前由名冊把名稱解析成 session id,不猜)。 */
188async function forward($: any, built: Built, roster: Roster | null, station: string): Promise<{ ok: boolean; reason: string }> {
189 const to = resolveStation(roster ?? { available: false, note: '沒有名冊' }, station)
190 if (!to.ok) return { ok: false, reason: to.reason }
191 lastStationId = to.sessionId
192 sendingTo = to.sessionId
193 try {
194 const sent: any = await $.session.send({ to: { sessionId: to.sessionId }, text: built.text })
195 return sent && sent.isDelivered ? { ok: true, reason: '' } : { ok: false, reason: String(sent?.reason ?? '未送達') }
196 } catch (err) {
197 return { ok: false, reason: String(err) }
198 } finally {
199 sendingTo = ''
200 }
201}
202
203/**
204 * 唯一的降級點:排一個 turn 請 worker 自己 SendMessage。呼叫端 SHALL 已先把內容記為已處理。
205 * 排入呼叫拋例外,或 resolve 為 `{ drop }`,皆為失敗——後者若當成功,內容會停在「已處理」卻沒有人送。
206 */
207async function degrade($: any, id: Identity, built: Built, reason: string): Promise<{ ok: boolean; reason: string }> {
208 pendingDegrade = true
209 // 上次成功送達的位址只在「送往的正是目前這個指揮站 id」時才給:id 改變代表指揮站重開,舊位址綁的是舊行程。
210 const usable = lastDelivered && lastDeliveredStation === lastStationId ? lastDelivered : ''
211 const text = degradePrompt(built, reason, id.resultFile, { station: id.station, lastDelivered: usable })
212 const failure = await trySubmit(() => $.prompt.submit({ text }))
213 if (failure === null) return { ok: true, reason: '' }
214 pendingDegrade = false
215 log($, `降級 prompt 排入失敗:${truncate(failure, 120)}`)
216 return { ok: false, reason: failure }
217}
218
219/** 讀結果檔;不存在回 null。解析符號連結後不在 session 根目錄底下的一律不讀——它的內容會被送出去。 */
220async function readResult($: any, root: string, file: string): Promise<string | null> {
221 try {
222 const realRoot = (await realPathOf($, root)) ?? root
223 const st = await $.fs.stat(`${root}/${file}`, { resolve: true }).catch(() => undefined)
224 if (!st) return null
225 if (!st.realPath || !isUnder(st.realPath, realRoot)) {
226 log($, `結果檔 ${file} 解析後不在 session 根目錄底下,不讀也不送`)
227 return null
228 }
229 if (st.kind !== 'file') return null
230 if (st.size > MAX_FILE_BYTES) {
231 log($, `結果檔 ${file} 超過 ${MAX_FILE_BYTES / 1024} KB,不送`)
232 return null
233 }
234 return String(await $.fs.read(st.realPath))
235 } catch {
236 return null
237 }
238}
239
240function sentKey(root: string, file: string): string {
241 return `relay:sent:${root}/${file}`
242}
243
244async function readSent($: any, key: string): Promise<string | undefined> {
245 try {
246 const v = await $.store.get(key)
247 return typeof v === 'string' ? v : undefined
248 } catch {
249 return undefined
250 }
251}
252
253async function markSent($: any, key: string, content: string): Promise<void> {
254 try {
255 await $.store.set(key, content)
256 } catch (err) {
257 log($, `無法記錄已處理的結果檔內容(下次會重送):${truncate(String(err), 120)}`)
258 }
259}
260
261/**
262 * 降級排入失敗時把「已處理」恢復為轉送前的值;轉送前沒有值就刪鍵(缺失視為從未處理,下次送全文)。
263 * 回傳是否恢復成功:失敗時內容仍停在已處理、不會重送,畫面須照實說。
264 */
265async function restoreSent($: any, key: string, prev: string | undefined): Promise<boolean> {
266 try {
267 if (prev === undefined) await $.store.delete(key)
268 else await $.store.set(key, prev)
269 return true
270 } catch (err) {
271 log($, `無法回滾已處理狀態(這份內容不會自動重送):${truncate(String(err), 120)}`)
272 return false
273 }
274}
275
276export const register: Register = (on, options) => {
277 const configured = (options as Record<string, unknown> | undefined)?.missions_dir
278 dirSetting = typeof configured === 'string' && configured.trim() ? configured : DEFAULT_DIR
279
280 on('session.start', async ($, e, next) => {
281 await tryActivate($)
282 return next(e)
283 })
284
285 // 啟動時 mission 檔可能還沒寫好、session 可能還沒命名:每個新 prompt 在尚未啟用時重試。
286 // 本 mod 自己排入的降級 prompt 不觸發重試(能降級代表已啟用)。
287 on('prompt.submit', async ($, e, next) => {
288 const own = (e as any).origin?.kind === 'plugin' && (e as any).origin?.name === PLUGIN
289 if (identity === null && !own) await tryActivate($)
290 return next(e)
291 })
292
293 on('prompt.compose', async ($, e, next) => {
294 const r = await next(e)
295 if (identity === null) return r
296 return { ...r, sections: [...r.sections, { id: SECTION_ID, text: injectionText(identity), scope: 'session' as const }] }
297 })
298
299 on('turn.start', async ($, e, next) => {
300 selfSent = false
301 inDegradedTurn = pendingDegrade
302 pendingDegrade = false
303 return next(e)
304 })
305
306 // 只觀察、原樣放行:記下 worker 本 turn 是否自己送過件。
307 on('tool.call', { tool: 'SendMessage' }, async ($, e, next) => {
308 if (identity !== null && (e as any).agentId === undefined) selfSent = true
309 return next(e)
310 })
311
312 // 只觀察本 mod 自己的送件、原樣放行:送達時記下引擎實際使用的收件位址,供降級 prompt 引用。
313 // origin 形狀是 { kind: 'plugin', name }(與 tool.check 的 next.origin.plugin 不同)。
314 on('session.send', async ($, e, next) => {
315 const origin = (e as any).origin
316 if (!(origin?.kind === 'plugin' && origin?.name === PLUGIN)) return next(e)
317 const r: any = await next(e)
318 if (r?.isDelivered && typeof (e as any).to === 'string' && (e as any).to) {
319 lastDelivered = (e as any).to
320 lastDeliveredStation = sendingTo
321 }
322 return r
323 })
324
325 // 只放行本 mod 自己的送件(auto mode 的分類器會拒絕 plugin 的送件)。其他來源一律交給下一個 hook。
326 on('tool.check', { tool: 'SendMessage' }, async ($, e, next) => {
327 const origin = (next as any).origin
328 const { verdict, lax } = sendCheckVerdict(identity !== null, origin?.plugin, (e as any).input, lastStationId)
329 if (verdict === 'pass') return next(e)
330 if (lax && !laxRecipientLogged) {
331 laxRecipientLogged = true
332 log($, '放行送件時無法驗證收件者(事件的收件者是引擎解析後的位址,不是 session id):只驗證寄件 mod 為 dispatch-relay')
333 }
334 return { decision: 'allow' as const, reason: 'dispatch-relay 轉送回報給指揮站' }
335 })
336
337 on('turn.complete', async ($, e, next) => {
338 const r = await next(e)
339 if (e.agentId !== undefined) return r
340 const wasDegraded = inDegradedTurn
341 inDegradedTurn = false
342 if (identity === null || e.reason !== 'answer') return r
343
344 // 每次送件前重新判定:mission 檔被搬走或改派、session 改名時停用;指揮站重開換 id 也跟得上。
345 const { res, roster } = await resolve($, true)
346 if (res.kind !== 'match') {
347 identity = null
348 logInactive($, `停用(${inactiveLog(res)})`)
349 return r
350 }
351 const id = res.identity
352 identity = id
353
354 let root = ''
355 try {
356 root = await $.session.root()
357 } catch {
358 return r
359 }
360 const key = sentKey(root, id.resultFile)
361 const content = await readResult($, root, id.resultFile)
362 const prev = await readSent($, key)
363 const built = buildMessage(id, prev, content, e.answer, selfSent)
364 if (built.parts.length === 0) return r
365
366 const sent = await forward($, built, roster, id.station)
367 if (sent.ok) {
368 if (built.nextSent !== null) await markSent($, key, built.nextSent)
369 return { ...r, text: deliveredLine(built, id.station) }
370 }
371 // 失敗:先把內容記為已處理,才排降級 turn——順序顛倒時,worker 在補記之前就自送並結束 turn 會被重送。
372 if (built.nextSent !== null) await markSent($, key, built.nextSent)
373 if (wasDegraded) {
374 log($, `降級 turn 內轉送再度失敗:${truncate(sent.reason, 120)}`)
375 return { ...r, text: failedAgainLine(sent.reason) }
376 }
377 const queued = await degrade($, id, built, sent.reason)
378 if (queued.ok) return { ...r, text: degradedLine(sent.reason) }
379 // 降級沒排進去:沒有人會送出這份內容。回滾為轉送前的狀態,讓下一個以答覆結束的 turn 重送(偏向重複而非漏送);
380 // 不自行排任何 prompt,所以不構成迴圈。
381 const result = built.nextSent === null ? 'none' : (await restoreSent($, key, prev)) ? 'resend' : 'lost'
382 const finalReply = built.parts.includes('final-reply')
383 return { ...r, text: degradeFailedLine(sent.reason, queued.reason, { result, finalReply }) }
384 })
385}
386hooks/logic.ts 469 lines1// dispatch-relay 純函式:mission 檔解析、目錄安全邊界、名冊解析、身份判定、訊息組裝、降級與注入文字。
2// 不碰 $,供 register.ts 與測試共用。
3// 結果檔判定一律比對內容,不看任何工具呼叫的種類或時機(結果檔可能經 Write、Edit、`cat >>` 或 `mv` 寫入)。
4// 目錄安全邊界與名冊解析和 dispatch-board 同一套規則,刻意複製而不跨 plugin import:兩個 mod 必須能各自安裝。
5
6import type { Identity, Part, Resolution } from '../types'
7
8export type { Identity, Part, Resolution }
9
10export const PLUGIN = 'dispatch-relay'
11export const DEFAULT_DIR = '~/.claude/missions'
12export const DEFAULT_RESULT_FILE = '.mission-result.md'
13export const ROSTER_TIMEOUT_MS = 10000
14export const MAX_FILE_BYTES = 256 * 1024
15export const MAX_NODES = 50
16export const SECTION_ID = 'dispatch-relay:relay'
17
18export function truncate(s: string, max: number): string {
19 const one = s.replace(/\s+/g, ' ').trim()
20 return one.length <= max ? one : `${one.slice(0, max - 1)}…`
21}
22
23// ── 目錄與安全邊界(與 dispatch-board 相同)──────────────────────────────────
24// relay 會把 mission 檔指定的結果檔內容送給另一個 session,所以 mission 檔只從使用者層級的目錄讀:
25// 家目錄底下、不在任何 git 工作樹內、也不在本 session 的專案目錄內。
26
27/** 設定值 → 絕對路徑;只收 `~`、`~/…` 或絕對路徑。 */
28export function expandDir(raw: string, home: string): { ok: true; path: string } | { ok: false; error: string } {
29 const text = raw.trim()
30 if (!text) return { ok: false, error: 'missions_dir 是空的' }
31 if (text.includes('\0')) return { ok: false, error: 'missions_dir 含不合法字元' }
32 if (text === '~') return { ok: true, path: home }
33 if (text.startsWith('~/')) return { ok: true, path: `${home}/${text.slice(2)}` }
34 if (text.startsWith('/')) return { ok: true, path: text }
35 return { ok: false, error: `missions_dir 須為 ~/… 或絕對路徑(收到 ${truncate(text, 60)})` }
36}
37
38export function isUnder(child: string, parent: string): boolean {
39 const p = parent.replace(/\/+$/, '')
40 return child.startsWith(`${p}/`)
41}
42
43export function isSameOrUnder(child: string, parent: string): boolean {
44 return child === parent.replace(/\/+$/, '') || isUnder(child, parent)
45}
46
47/** realDir 往上到(不含)realHome 的每一層:檢查 `.git` 用。 */
48export function gitProbePaths(realDir: string, realHome: string): string[] {
49 const out: string[] = []
50 let p = realDir.replace(/\/+$/, '')
51 while (isUnder(p, realHome)) {
52 out.push(`${p}/.git`)
53 p = p.slice(0, p.lastIndexOf('/'))
54 }
55 return out
56}
57
58export type DirFacts = {
59 realHome: string
60 realDir: string
61 /** 本 session 的專案根與工作目錄(已解析) */
62 projectDirs: readonly string[]
63 /** gitProbePaths 裡存在的那些 */
64 gitHits: readonly string[]
65}
66
67/** 安全邊界判定;回 null=可讀,否則回拒絕原因。 */
68export function dirRefusal(f: DirFacts): string | null {
69 if (!isUnder(f.realDir, f.realHome)) return `mission 目錄 ${f.realDir} 不在家目錄底下,拒讀`
70 for (const p of f.projectDirs) {
71 if (isSameOrUnder(f.realDir, p)) return `mission 目錄 ${f.realDir} 在本 session 的專案目錄內,拒讀`
72 }
73 if (f.gitHits.length) return `mission 目錄 ${f.realDir} 在 git 工作樹內(${f.gitHits[0]}),拒讀`
74 return null
75}
76
77/**
78 * 自訂的 missions_dir 必須來自使用者層級設定(~/.claude/settings.json)。
79 * 專案的 .claude/settings.json 也能寫 pluginConfigs;不檢查來源的話,clone 一個陌生 repo
80 * 就能把目錄指到別處、讓 relay 讀對方寫的 mission 檔。預設值不需要來源。
81 */
82export function configRefusal(configured: string, userSettings: Readonly<Record<string, unknown>>): string | null {
83 if (configured.trim() === DEFAULT_DIR) return null
84 if (userDirOption(userSettings, PLUGIN) === configured) return null
85 return 'missions_dir 只接受使用者層級設定(~/.claude/settings.json);其他來源的值不採用'
86}
87
88export const BOARD_PLUGIN = 'dispatch-board'
89
90/** 使用者層級設定裡某個 plugin(`<name>` 或 `<name>@<marketplace>`)的 missions_dir;沒設回 undefined。 */
91export function userDirOption(userSettings: Readonly<Record<string, unknown>>, plugin: string): string | undefined {
92 const configs = userSettings.pluginConfigs
93 if (!configs || typeof configs !== 'object') return undefined
94 for (const [key, entry] of Object.entries(configs as Record<string, unknown>)) {
95 if (key !== plugin && !key.startsWith(`${plugin}@`)) continue
96 const v = (entry as { options?: Record<string, unknown> } | null)?.options?.missions_dir
97 if (typeof v === 'string' && v.trim()) return v
98 }
99 return undefined
100}
101
102/**
103 * 要用的 mission 目錄設定。優先序:本 mod 自己的 missions_dir → dispatch-board 的 missions_dir → 預設。
104 * 兩者都只認使用者層級設定:`configured` 是引擎給本 mod 的值(可能來自專案設定,所以非預設值要過 configRefusal);
105 * dispatch-board 的值直接從 `userSettings`(只讀 ~/.claude/settings.json)取,專案設定裡的 board 鍵看不到、不採用。
106 * 自訂目錄因此只要在 dispatch-board 設一次;沒裝 dispatch-board(設定裡沒有它的鍵)時行為不變。
107 */
108export function pickDir(
109 configured: string,
110 userSettings: Readonly<Record<string, unknown>>,
111): { ok: true; dir: string } | { ok: false; error: string } {
112 if (configured.trim() !== DEFAULT_DIR) {
113 const refused = configRefusal(configured, userSettings)
114 return refused ? { ok: false, error: refused } : { ok: true, dir: configured }
115 }
116 if (userDirOption(userSettings, PLUGIN) !== undefined) return { ok: true, dir: DEFAULT_DIR }
117 return { ok: true, dir: userDirOption(userSettings, BOARD_PLUGIN) ?? DEFAULT_DIR }
118}
119
120/** mission 檔候選:目錄第一層的 `*.json`(子目錄不讀,`done/` 可放收斂完的檔) */
121export function isMissionFileName(name: string): boolean {
122 return name.endsWith('.json') && !name.startsWith('.') && name.length > '.json'.length
123}
124
125// ── mission 檔(relay 只讀它用得到的欄位)─────────────────────────────────────
126
127export type RelayNode = { id: string; session: string; resultFile: string }
128export type RelayMission = { missionId: string; station: string; nodes: RelayNode[] }
129
130const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/
131
132function optString(v: unknown, field: string, max: number): string {
133 if (v === undefined || v === null) return ''
134 if (typeof v !== 'string') throw new Error(`${field} 必須是字串`)
135 if (v.length > max) throw new Error(`${field} 超過 ${max} 字`)
136 return v.trim()
137}
138
139/**
140 * 結果檔的相對路徑:拒絕絕對路徑、`~`、反斜線與任何 `.`/`..` 段——relay 會把它的內容送出去,
141 * 路徑只能落在 session 根目錄底下。實際讀檔前另以解析後的路徑再確認一次(防符號連結)。
142 */
143export function checkResultFile(raw: string): { ok: true; path: string } | { ok: false; error: string } {
144 const text = raw.trim()
145 if (!text) return { ok: true, path: DEFAULT_RESULT_FILE }
146 if (text.includes('\0') || text.includes('\\')) return { ok: false, error: 'result_file 含不合法字元' }
147 if (text.startsWith('/') || text.startsWith('~')) return { ok: false, error: 'result_file 必須是相對於 session 根目錄的路徑' }
148 const segs = text.split('/')
149 if (segs.some(s => s === '' || s === '.' || s === '..')) return { ok: false, error: 'result_file 不能含空段、. 或 ..' }
150 return { ok: true, path: text }
151}
152
153/** mission 檔文字 → relay 用得到的欄位或原因。mission_id 省略時取檔名(去掉 .json)。 */
154export function parseMission(text: string, fileName: string): { ok: true; value: RelayMission } | { ok: false; reason: string } {
155 let raw: unknown
156 try {
157 raw = JSON.parse(text)
158 } catch {
159 return { ok: false, reason: '不是合法 JSON' }
160 }
161 if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, reason: '最外層必須是 JSON 物件' }
162 const r = raw as Record<string, unknown>
163 try {
164 const missionId = optString(r.mission_id, 'mission_id', 80) || fileName.replace(/\.json$/, '')
165 if (!ID_RE.test(missionId)) throw new Error(`mission_id「${truncate(missionId, 40)}」只能用英數、_ . -`)
166 const station = optString(r.station, 'station', 200)
167 if (!Array.isArray(r.nodes) || r.nodes.length === 0) throw new Error('nodes 必須是非空陣列')
168 if (r.nodes.length > MAX_NODES) throw new Error(`nodes 超過 ${MAX_NODES} 個`)
169 const nodes: RelayNode[] = []
170 const ids = new Set<string>()
171 r.nodes.forEach((n: unknown, i: number) => {
172 if (n === null || typeof n !== 'object' || Array.isArray(n)) throw new Error(`nodes[${i}] 必須是物件`)
173 const o = n as Record<string, unknown>
174 const id = optString(o.id, `nodes[${i}].id`, 80)
175 if (!ID_RE.test(id)) throw new Error(`nodes[${i}].id 缺少或含不合法字元(只能用英數、_ . -)`)
176 if (ids.has(id)) throw new Error(`節點 id「${id}」重複`)
177 ids.add(id)
178 const file = checkResultFile(optString(o.result_file, `${id}.result_file`, 200))
179 if (!file.ok) throw new Error(`${id}.${file.error}`)
180 nodes.push({ id, session: optString(o.session, `${id}.session`, 200), resultFile: file.path })
181 })
182 return { ok: true, value: { missionId, station, nodes } }
183 } catch (err) {
184 return { ok: false, reason: err instanceof Error ? err.message : String(err) }
185 }
186}
187
188// ── session 名冊 ────────────────────────────────────────────────────────────
189
190export type RosterEntry = { name: string; sessionId: string }
191export type Roster = { available: true; entries: RosterEntry[] } | { available: false; note: string }
192
193/** `claude agents --json` 的結果 → 名冊。任何非預期都算不可用,不當成「名冊是空的」。 */
194export function parseRoster(exitCode: number, stdout: string, stderr: string): Roster {
195 if (exitCode !== 0) {
196 const line = stderr.trim().split('\n').pop() ?? ''
197 return { available: false, note: `claude agents --json exit ${exitCode}${line ? `:${truncate(line, 80)}` : ''}` }
198 }
199 let raw: unknown
200 try {
201 raw = JSON.parse(stdout)
202 } catch {
203 return { available: false, note: 'claude agents --json 輸出不是 JSON' }
204 }
205 if (!Array.isArray(raw)) return { available: false, note: 'claude agents --json 輸出不是陣列' }
206 const entries: RosterEntry[] = []
207 for (const e of raw) {
208 if (!e || typeof e !== 'object') continue
209 const o = e as Record<string, unknown>
210 entries.push({
211 name: typeof o.name === 'string' ? o.name : '',
212 sessionId: typeof o.sessionId === 'string' ? o.sessionId : '',
213 })
214 }
215 return { available: true, entries }
216}
217
218export function normalizeName(name: string): string {
219 return name.trim().toLowerCase()
220}
221
222/** 名冊上本 session 的名稱;查無或沒名稱回 ''。 */
223export function ownName(roster: RosterEntry[], sessionId: string): string {
224 for (const e of roster) if (e.sessionId === sessionId && e.name.trim()) return e.name.trim()
225 return ''
226}
227
228/** 指揮站名稱 → session id。查無、同名多筆都不猜。 */
229export function resolveStation(
230 roster: Roster,
231 station: string,
232): { ok: true; sessionId: string } | { ok: false; reason: string } {
233 if (!roster.available) return { ok: false, reason: `名冊不可用(${roster.note})` }
234 const key = normalizeName(station)
235 const ids = [...new Set(roster.entries.filter(e => normalizeName(e.name) === key && e.sessionId).map(e => e.sessionId))]
236 if (ids.length === 0) return { ok: false, reason: `名冊上沒有名為 ${station} 的 session` }
237 if (ids.length > 1) return { ok: false, reason: `名冊上有 ${ids.length} 個名為 ${station} 的 session,不猜` }
238 return { ok: true, sessionId: ids[0] as string }
239}
240
241// ── 身份判定 ────────────────────────────────────────────────────────────────
242
243/**
244 * 本 session 的名稱 → 它在 mission 目錄裡負責的節點。名稱比對不分大小寫。
245 * 恰好一個節點才算數:零個是「不是 worker」、多個是「不知道是哪一個」,都不啟用——錯認身份會把
246 * 別人的結果檔送出去。對到的 mission 沒有 `station`=沒有選用 relay,同樣不啟用。
247 */
248export function findIdentity(missions: readonly RelayMission[], name: string): Resolution {
249 if (!name) return { kind: 'none', name }
250 const key = normalizeName(name)
251 const hits: { m: RelayMission; n: RelayNode }[] = []
252 for (const m of missions) for (const n of m.nodes) if (n.session && normalizeName(n.session) === key) hits.push({ m, n })
253 if (hits.length === 0) return { kind: 'none', name }
254 if (hits.length > 1) return { kind: 'ambiguous', candidates: hits.map(h => `${h.m.missionId}/${h.n.id}`) }
255 const { m, n } = hits[0] as { m: RelayMission; n: RelayNode }
256 if (!m.station) return { kind: 'no-station', missionId: m.missionId, nodeId: n.id }
257 if (normalizeName(m.station) === key) return { kind: 'self-station', missionId: m.missionId, nodeId: n.id }
258 return { kind: 'match', identity: { missionId: m.missionId, nodeId: n.id, station: m.station, resultFile: n.resultFile } }
259}
260
261/** 未啟用時的日誌一行。 */
262export function inactiveLog(r: Exclude<Resolution, { kind: 'match' }>): string {
263 switch (r.kind) {
264 case 'unavailable':
265 return `未啟用:${r.reason}`
266 case 'no-missions':
267 return '未啟用:mission 目錄不存在或沒有可讀的 mission 檔'
268 case 'no-relay':
269 return '未啟用:mission 目錄裡沒有任何 mission 寫 station(沒有選用 dispatch-relay)'
270 case 'none':
271 return `未啟用:mission 目錄裡沒有指派給本 session${r.name ? `(${r.name})` : ''}的節點`
272 case 'ambiguous':
273 return `未啟用:多重匹配,候選 ${r.candidates.join('、')},不任選其一`
274 case 'no-station':
275 return `未啟用:找到指派給本 session 的節點 ${r.missionId}/${r.nodeId},但該 mission 沒有 station 欄位(沒有選用 dispatch-relay),照原本方式回報`
276 case 'self-station':
277 return `未啟用:mission ${r.missionId} 的 station 就是本 session,不轉送給自己`
278 }
279}
280
281// ── 訊息組裝 ────────────────────────────────────────────────────────────────
282
283export type Built = {
284 /** 空陣列=沒有東西要送。 */
285 parts: Part[]
286 /** 含固定首行的完整訊息;parts 為空時為 ''。 */
287 text: string
288 /** 本文總字數(各部分原文長度加總,不含首行與段落標題)。 */
289 chars: number
290 /** 送出(或交給 worker 自送)後要記為「已處理」的結果檔內容;沒有結果檔部分時為 null。 */
291 nextSent: string | null
292}
293
294/**
295 * 組出本 turn 要轉送的訊息。
296 *
297 * - 結果檔:`content` 非 null、非空白、且與 `prev` 不同才轉送;以 `prev` 為前綴且 `prev` 非空時只送追加段,
298 * 否則送全文。`prev` 缺失(狀態被清空)視為從未處理 → 全文:偏向重複而非漏送。
299 * - 最終回覆:非空、且 worker 本 turn 沒自送時附帶為 `final-reply`,**不設長度或行數門檻**——
300 * worker 結束 turn 只有回報與提問兩種原因,兩者都要送達;以「超過一行」為門檻會吞掉單行提問。
301 * - 自送時不附最終回覆,但結果檔變更仍轉送(結果檔是回報正本,自送的可能只是提問)。
302 */
303export function buildMessage(
304 ids: { missionId: string; nodeId: string },
305 prev: string | undefined,
306 content: string | null,
307 answer: string,
308 selfSent: boolean,
309): Built {
310 const before = prev ?? ''
311 const sections: { part: Part; body: string }[] = []
312 let nextSent: string | null = null
313 if (content !== null && content !== before && content.trim() !== '') {
314 if (before !== '' && content.startsWith(before)) {
315 sections.push({ part: 'result-append', body: content.slice(before.length) })
316 } else {
317 sections.push({ part: 'result-full', body: content })
318 }
319 nextSent = content
320 }
321 if (!selfSent && answer.trim() !== '') sections.push({ part: 'final-reply', body: answer.trim() })
322 if (sections.length === 0) return { parts: [], text: '', chars: 0, nextSent: null }
323 const chars = sections.reduce((n, s) => n + s.body.length, 0)
324 const head = `[dispatch-relay] mission=${ids.missionId} node=${ids.nodeId} parts=${sections.map(s => s.part).join(',')} chars=${chars}`
325 const text = [head, ...sections.map(s => `--- ${s.part} ---\n${s.body}`)].join('\n\n')
326 return { parts: sections.map(s => s.part), text, chars, nextSent }
327}
328
329// ── 畫面一行、降級 prompt、注入段 ───────────────────────────────────────────
330
331const PART_LABEL: Record<Part, string> = {
332 'result-full': '結果檔全文',
333 'result-append': '結果檔追加段',
334 'final-reply': '最終回覆',
335}
336
337export function partsLabel(parts: readonly Part[]): string {
338 return parts.map(p => PART_LABEL[p]).join('+')
339}
340
341export function deliveredLine(built: Built, station: string): string {
342 return `↪ dispatch-relay:${partsLabel(built.parts)}(${built.chars} 字)已送達指揮站 ${station}`
343}
344
345export function degradedLine(reason: string): string {
346 return `⚠ dispatch-relay:轉送失敗(${truncate(reason, 80)}),已請 worker 自行 SendMessage`
347}
348
349/**
350 * `$.prompt.submit` 正常 resolve 時的結果 → 排入失敗的原因;**進入了回 `null`**。
351 * `{ drop }` 是「沒進入 session、也沒拋例外」的那一型:漏掉它,內容會停在已處理卻沒有人送。
352 * 有 `drop` 鍵就算失敗,不看它的型別。以 `null` 而非空字串表示成功:空字串是合法的失敗原因。
353 */
354export function submitFailure(result: unknown): string | null {
355 if (result === null || typeof result !== 'object' || !('drop' in result)) return null
356 const drop = (result as { drop?: unknown }).drop
357 return `被擋下:${typeof drop === 'string' && drop ? drop : '(未附原因)'}`
358}
359
360/**
361 * 執行一次排入並把結局歸成失敗原因;**進入了回 `null`**。拋例外(含同步拋出)與 resolve 為 `{ drop }` 皆為失敗。
362 * 排入呼叫由參數傳入,讓例外路徑可測(測試引擎的 prompt.submit 做不出「拋例外」)。
363 */
364export async function trySubmit(submit: () => Promise<unknown>): Promise<string | null> {
365 try {
366 return submitFailure(await submit())
367 } catch (err) {
368 return String(err) || '排入時拋出例外(無訊息)'
369 }
370}
371
372/**
373 * 送件失敗且降級 prompt 也沒排進去:沒有人會送出這份內容,畫面不得暗示有人接手。
374 *
375 * - `result`:`resend`=已回滾為未處理、下一個以答覆結束的 turn 會重送;`lost`=回滾本身失敗,不會重送;
376 * `none`=本次沒有結果檔部分。
377 * - `finalReply`:本次是否附帶最終回覆。最終回覆沒有比對狀態可回滾,排入失敗即不會重送,SHALL 明說。
378 */
379export function degradeFailedLine(
380 sendReason: string,
381 submitReason: string,
382 outcome: { result: 'resend' | 'lost' | 'none'; finalReply: boolean },
383): string {
384 const parts: string[] = []
385 if (outcome.result === 'resend') parts.push('結果檔內容維持未送出,下次 turn 結束時重送')
386 if (outcome.result === 'lost') parts.push('結果檔內容無法回滾為未處理,不會重送')
387 if (outcome.finalReply) parts.push('本則最終回覆不會重送')
388 if (outcome.result !== 'resend' || outcome.finalReply) parts.push('請指揮站以完成條件判定進度')
389 return `⚠ dispatch-relay:轉送失敗(${truncate(sendReason, 80)}),也無法請 worker 自行送出(${truncate(submitReason, 80)});${parts.join(';')}`
390}
391
392export function failedAgainLine(reason: string): string {
393 return `⚠ dispatch-relay:降級 turn 內轉送再度失敗(${truncate(reason, 80)}),不再請 worker 自送;請指揮站以完成條件判定進度`
394}
395
396/** 降級 prompt 可引用的收件位址。`lastDelivered`:最近一次成功送達時引擎實際使用的位址。 */
397export type DegradeAddress = { station: string; lastDelivered?: string }
398
399/**
400 * 降級 prompt:載明失敗原因;要求 worker 自己 SendMessage 全文;不要求落成另一個檔案;告訴 worker 送去哪裡。
401 * 位址依可靠度排序:mission 檔的 station 名稱 → 上次成功送達時的位址 → 最近一則來訊的 `from` 位址(僅當來自指揮站)。
402 * from 放最後:worker 可能收過其他 session 的訊息,「最近一則」不一定是指揮站,回錯人會把回報送給第三方。
403 * 各位址候選各占一行,model 不必從連成一串的句子裡切出位址。
404 */
405export function degradePrompt(built: Built, reason: string, resultFile: string, addr: DegradeAddress): string {
406 const what: string[] = []
407 if (built.parts.some(p => p === 'result-full' || p === 'result-append')) {
408 what.push(`${resultFile} 的內容(${built.parts.includes('result-append') ? '本次新增的部分' : '全文'})`)
409 }
410 if (built.parts.includes('final-reply')) what.push('你剛才那則最終回覆')
411 const lines = [
412 `dispatch-relay 沒能把回報轉送給指揮站(${reason})。`,
413 `請改用 SendMessage,把${what.join('與')}原文放進訊息本體送給指揮站。`,
414 `收件者:${addr.station}(mission 檔記錄的指揮站名稱,SendMessage 的 to 直接填這個名稱)。`,
415 ]
416 if (addr.lastDelivered) {
417 lines.push(`名稱送不到時,改用上次成功送達指揮站時使用的位址 ${addr.lastDelivered}(指揮站若已重開可能失效)。`)
418 }
419 lines.push(
420 `以上都送不到時,才回覆最近一則送給你的訊息的 from 位址——僅當該則來自指揮站 ${addr.station};來自其他 session 的訊息不算,不要回給它。`,
421 )
422 lines.push('不要為此另外寫檔。')
423 return lines.join('\n')
424}
425
426/** `prompt.compose` 注入段。刻意只說「寫進結果檔、以一行結束、不要另外 SendMessage」,不提降級細節。 */
427export function injectionText(id: Identity): string {
428 return [
429 `本 session 是 mission ${id.missionId} 節點 ${id.nodeId} 的 worker,掛了 dispatch-relay(回報轉送)。`,
430 `每個 turn 結束時,dispatch-relay 會把專案根目錄 ${id.resultFile} 新增或變更的內容、以及你的最終回覆,原文送給指揮站 ${id.station}。`,
431 `因此:回報寫進 ${id.resultFile} 後直接以一行結束本 turn;不要為同一份回報另外呼叫 SendMessage,也不要為回報再讀取或驗證結果檔。`,
432 '需要問指揮站、被擋住或需要它決策時,照常用 SendMessage,或把問題寫在最終回覆,同樣會送達。',
433 '節點是否完成仍以 mission 檔的完成條件為準。',
434 ].join('')
435}
436
437// ── tool.check 的收件者判斷 ─────────────────────────────────────────────────
438
439const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
440
441/**
442 * `SendMessage` 的 `tool.check` 輸入裡的收件者 vs 指揮站 id。
443 * plugin 以 `{ sessionId }` 送件時,事件的收件者通常是引擎解析後的位址(`uds:…`),無從對回 session id:
444 * 此時是 `unverifiable`,退回只驗證寄件 mod。收件者明確是另一個 session id 時是 `mismatch`。
445 */
446export function classifyRecipient(input: unknown, station: string): 'match' | 'mismatch' | 'unverifiable' {
447 const to = input && typeof input === 'object' ? (input as any).to ?? (input as any).recipient : undefined
448 if (typeof to !== 'string' || to === '') return 'unverifiable'
449 if (station && to === station) return 'match'
450 return UUID_RE.test(to) ? 'mismatch' : 'unverifiable'
451}
452
453/**
454 * `tool.check { tool: 'SendMessage' }` 的判定:只放行「由本 mod 發出、且收件者不是別的 session」的送件。
455 * 其餘一律 `pass`(交給下一個 hook/引擎原本的流程)——worker 自己的 SendMessage 不被攔截、改寫或拒絕。
456 * `lax` 為真代表收件者無法驗證、只驗證了寄件 mod,呼叫端 SHALL 出聲。
457 */
458export function sendCheckVerdict(
459 active: boolean,
460 originPlugin: string | undefined,
461 input: unknown,
462 station: string,
463): { verdict: 'allow' | 'pass'; lax: boolean } {
464 if (!active || originPlugin !== PLUGIN) return { verdict: 'pass', lax: false }
465 const recipient = classifyRecipient(input, station)
466 if (recipient === 'mismatch') return { verdict: 'pass', lax: false }
467 return { verdict: 'allow', lax: recipient === 'unverifiable' }
468}
469types/index.d.ts 30 lines1// dispatch-relay 的型別契約:身份判定的結果與轉送訊息的 part 值域。
2// 本 mod 不使用 `$.state`(日誌走 `$.ui.log`、持久狀態走 `$.store`),故無 PluginState。
3
4/** 轉送訊息首行 `parts=` 的值域。改動屬 BREAKING(指揮站端若要解析訊息,以此為接縫)。 */
5export type Part = 'result-full' | 'result-append' | 'final-reply'
6
7/** 已啟用 session 的節點身份。`station` 是 mission 檔寫的名稱;session id 每次送件前由名冊解析,不快取。 */
8export type Identity = { missionId: string; nodeId: string; station: string; resultFile: string }
9
10/**
11 * 身份判定的結果。
12 * - match:恰好一個節點的 `session` 是本 session 的名稱,且該 mission 有 `station`
13 * - no-missions:mission 目錄不存在或沒有可讀的 mission 檔
14 * - no-relay:沒有任何 mission 寫 station(不讀名冊就能確定不啟用)
15 * - none:有選用 relay 的 mission,但沒有節點指派給本 session(`name` 是本 session 的名稱)
16 * - ambiguous:多個節點都指派給本 session
17 * - no-station:唯一匹配,但 mission 沒有 `station`(沒有選用 relay)
18 * - self-station:唯一匹配,但 `station` 就是本 session
19 * - unavailable:mission 目錄被拒讀、名冊不可用、取不到 session id 等
20 */
21export type Resolution =
22 | { kind: 'match'; identity: Identity }
23 | { kind: 'no-missions' }
24 | { kind: 'no-relay' }
25 | { kind: 'none'; name: string }
26 | { kind: 'ambiguous'; candidates: string[] }
27 | { kind: 'no-station'; missionId: string; nodeId: string }
28 | { kind: 'self-station'; missionId: string; nodeId: string }
29 | { kind: 'unavailable'; reason: string }
30