Agent Supervisor:監控這個 session 的子代理、回報的任務與依賴、可能的阻塞與下一步建議;/supervisor 開關面板

Claude Code 的 mod:在 Claude Code 裡用一個面板監控這個 session 的子代理、代理回報的任務與依賴、可能的阻塞,以及下一步建議。不是獨立程式,不需要另外開 CLI、伺服器或資料庫。
a: 代理 t: 任務 l: 事件 r: 更新 q: 關閉
● Supervisor RUNNING
委派 agent:做前端畫面
AGENTS
◐ 做前端畫面 BLOCKED
執行:讀取 schema.json · 1分12秒
● 定義 API schema RUNNING
執行:echo schema第5次 · 1分20秒
TASKS
Completed: 0 / 2 · Progress: 0%
[>] T1 定義 API schema · 定義 API schema
[!] T2 做前端畫面 · 做前端畫面(等 T1)
ATTENTION
⚠ 做前端畫面 被 定義 API schema 擋住
等 API schema
SUGGESTED ACTION
→ 先完成「定義 API schema」(定義 API schema),做前端畫面 才能繼續
EVENTS
17:26:43 做前端畫面 開始
17:26:50 做前端畫面 回報在等:等 API schema
這個 mod 在 my-mods 這個本機 marketplace 裡:
claude plugin install agent-supervisor@my-mods
在 Claude Code 裡執行 /reload-plugins 就會載入。停用:claude plugin disable agent-supervisor@my-mods(之後再 /reload-plugins),面板、指令與回報工具都會一起拿掉。
| 操作 | 作用 |
|---|---|
/supervisor | 開關面板。不會自己跳出來,只在你打指令時開 |
/supervisor agents、tasks、logs | 直接開那一頁 |
/supervisor focus | 把鍵盤交給面板,之後可以按下面的快捷鍵 |
a、t、l、o | 切到代理、任務、事件頁,o 回總覽(面板要先拿到鍵盤:/supervisor focus、ctrl+x tab 或用滑鼠點) |
r | 立刻跟 Claude Code 的代理清單對帳並重畫 |
q | 關閉面板 |
| Esc | 鍵盤回到輸入框 |
面板沒開而有代理在跑時,輸入框下方會釘一行 Supervisor ●2 · /supervisor。
面板的狀態也會寫成一份 JSON,給外部 App(例如 Splex 終端機)顯示同樣的內容:
~/.claude/agent-supervisor/sessions/<session id>.json,每個 session 一份。"ended": true(mod 沒有刪檔的 API)。pid 是 Claude Code 本身的程序,外部 App 用它比對是哪個終端機窗格;取不到時是 null,改用 cwd 比對。waiting 的 isMain 為 true 表示主對話在等:子代理不能直接問使用者,只有這種等待代表在等你(Splex 只對這種跳 WAIT 提醒)。格式(version 有不相容的改動時加一,欄位見 hooks/snapshot.ts 的 SupervisorSnapshot):
{
"version": 1, "sessionId": "…", "pid": 4242, "cwd": "/Users/me/app", "updatedAt": 1792233300000, "ended": false,
"main": { "isRunning": true, "activity": "委派", "activityAt": 1792233290000 },
"agents": [{ "id": "a1", "name": "定義 API schema", "status": "RUNNING", "isInferred": false, "activity": "讀取 schema.json",
"detail": null, "failureReason": null, "startedAt": 1792233220000, "endedAt": null, "toolCount": 3,
"activityAt": 1792233280000 }],
"tasks": [{ "id": "T2", "title": "做前端畫面", "status": "pending", "owner": "做前端畫面", "blockedBy": ["T1"] }],
"progress": { "completed": 0, "total": 2, "percent": 0 },
"waiting": [{ "reporter": "做前端畫面", "isMain": false, "text": "等 API schema", "at": 1792233250000 }],
"attention": [{ "subject": "做前端畫面", "dependsOn": "定義 API schema", "reason": "…", "source": "dependency", "certainty": "confirmed", "taskId": "T2" }],
"suggestions": ["先完成「定義 API schema」…"],
"events": [{ "at": 1792233250000, "kind": "waiting", "text": "做前端畫面 回報在等:等 API schema" }]
}
面板只顯示真的觀察到或回報過的資料,不知道的寫 Unknown,不編造進度。
| 資訊 | 來源 |
|---|---|
| 代理的開始、結束、失敗原因 | Claude Code 的事件(agent.spawn、turn.complete) |
| 最近活動、工具次數、連續失敗 | 子代理的每次請求與工具呼叫(turn.step、tool.call) |
| IDLE、等待背景工作 | Claude Code 的代理清單($.agent.list()),有代理沒結束時每 3 秒對一次,不呼叫模型 |
| 任務、依賴、在等什麼 | 代理用回報工具回報;也會讀 Claude Code 原生的 TaskCreate、TaskUpdate、TodoWrite(開了 CLAUDE_CODE_ENABLE_TASKS 才有) |
| 建議 | 規則式,不呼叫模型 |
| 狀態 | 意思 |
|---|---|
| RUNNING | 正在跑 |
| IDLE | 這一輪結束,等訊息喚醒或等自己的背景工作 |
| BLOCKED | 有確認的阻塞(見下面) |
| COMPLETED、FAILED | 結束;FAILED 會寫原因(已中斷、API 錯誤、模型拒絕、已停止) |
| UNKNOWN? | 清單上找不到又沒收到結束事件 |
狀態後面有 ? 的是推測的,其他都由事件或 Claude Code 確認。開面板前就在跑的代理,開始時間寫 Unknown。
mod 註冊 mcp__agent-supervisor__report,讓主對話與子代理回報:
tasks:任務的 id、title(新任務必填,繁體中文)、status(pending、in_progress、completed)、blockedBy(要先完成的任務 id)、owner(main,或子代理的任務說明、名字;預設是回報者)waitingFor:在等什麼;空字串表示不再等待主對話的 system prompt 會加一段說明:分派子代理前先登記計畫與依賴。子代理的任務尾端附一段說明:有多個步驟或卡住時才回報,不要每個工具呼叫都回報。這會讓代理稍微改變做法、多用一點 token;workflow 的代理不附說明。
| 等級 | 條件 |
|---|---|
確認(⚠,代理顯示 BLOCKED,任務標 [!]) | 已經在做的任務還有沒完成的依賴;還沒結束的代理或主對話自己回報在等什麼 |
可能(? POSSIBLE BLOCKER,不改狀態) | 依賴沒有人回報過的任務;擋路任務的負責人已結束卻沒標完成;還在跑的代理連續 3 次工具失敗 |
不算阻塞:跑很久、一陣子沒有新事件、Claude Code 清單上的 waiting、還沒開始的任務在等依賴(只寫「(等 T1)」)。
name 參數,代理多半用任務說明當標題。/clear 會清掉。claude plugin test agent-supervisor # 在 my-mods 根目錄執行
claude plugin validate agent-supervisor
bash scripts/check-contracts.sh # 改過 types/index.d.ts 後執行
| 檔案 | 內容 |
|---|---|
hooks/register.tsx | 掛事件、指令、回報工具、計時器、狀態列與面板 |
hooks/agents.ts | 代理狀態:派出、活動、工具結果、結束、跟清單對帳 |
hooks/tasks.ts | 回報格式檢查、任務更新、原生任務工具、等待回報 |
hooks/blockers.ts | 阻塞規則與建議 |
hooks/events.ts | 事件串 |
hooks/view.ts、names.ts、text.ts | 面板版面、名稱、顯示寬度與時間格式 |
agents.ts 與部分版面改寫自已刪除的 agent-panel。
hooks/register.tsx 573 lines1// Agent Supervisor:監控這個 session 的子代理、回報的任務與依賴、可能的阻塞與下一步建議。
2// 只用 /supervisor 手動開關面板(使用者選的,不自動跳出);面板沒開而有代理在跑時,輸入框下方釘一行狀態
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register, Timer } from 'claude-code'
5
6import type { AgentRecord, MainLoop, SupervisorEvent, SupervisorView, TaskRecord, WaitingReports } from '../types'
7import { addSpawn, adoptListed, capAgents, describeTool, finishAgent, hasLive, reconcile, recordActivity, recordToolOutcome, toolOutcomeOf } from './agents'
8import type { FinishReason, ListedAgent, ToolOutcome } from './agents'
9import { appendEvents, taskEvents, waitingEvents } from './events'
10import { buildSnapshot, snapshotPath, snapshotSignature } from './snapshot'
11import type { SnapshotIdentity } from './snapshot'
12import { applyNativeTool, applyReport, parseReport, resolvePendingOwners, setWaiting } from './tasks'
13import { viewRows } from './view'
14import type { Span } from './view'
15
16const PANE_ID = 'agent-supervisor'
17const PANE_TITLE = 'Agent Supervisor'
18// 停靠在右邊時要求的寬度;放在輸入框上方時 Claude Code 會忽略它
19const PANE_COLUMNS = 44
20// 計時器每秒一拍:面板開著時更新經過時間;每 3 拍跟 Claude Code 的代理清單對帳一次(不呼叫模型)
21const TICK_MS = 1000
22const POLL_EVERY_TICKS = 3
23const THINKING = '思考中'
24const USAGE = '用法:/supervisor 開關面板;/supervisor agents、tasks、logs 直接開那一頁;/supervisor focus 把鍵盤交給面板(之後按 A、T、L、O 切頁,R 更新,Q 關閉)。'
25// /supervisor 的參數對到分頁;logs 跟面板上的 L 一致
26const VIEW_ARGUMENTS: Record<string, SupervisorView> = { overview: 'overview', agents: 'agents', tasks: 'tasks', logs: 'events', events: 'events' }
27// 工具列:鍵盤要先把焦點交給面板才按得到;Claude Code 會在按鈕前面畫出快捷鍵(a: 代理),標籤不再寫字母;目前這一頁的按鈕不畫
28const VIEW_BUTTONS: { view: SupervisorView; hotkey: string; label: string }[] = [
29 { view: 'overview', hotkey: 'o', label: '總覽' },
30 { view: 'agents', hotkey: 'a', label: '代理' },
31 { view: 'tasks', hotkey: 't', label: '任務' },
32 { view: 'events', hotkey: 'l', label: '事件' },
33]
34// 停靠在右邊時總覽多列最近事件;放在輸入框上方時精簡,免得把 band 擠掉
35const DOCKED_LAYOUT = { maxAgents: 6, maxTasks: 6, eventCount: 3 }
36const INLINE_LAYOUT = { maxAgents: 3, maxTasks: 3, eventCount: 0, compact: true }
37// 回報工具:代理與主對話回報任務、依賴與在等什麼(使用者選的「最小回報工具」)
38const REPORT_TOOL_NAME = 'report'
39const REPORT_TOOL = 'mcp__agent-supervisor__report'
40const REPORT_ACTIVITY = '回報任務'
41// Claude Code 原生的任務工具;使用者開了 CLAUDE_CODE_ENABLE_TASKS 才會出現,有呼叫就一併記下
42const NATIVE_TASK_TOOLS = new Set(['TaskCreate', 'TaskUpdate', 'TodoWrite'])
43// 看面板的人讀的語言;任務標題用它
44const READER_LANGUAGE = 'Traditional Chinese (Taiwan usage)'
45const REPORT_SCHEMA = {
46 type: 'object',
47 properties: {
48 tasks: {
49 type: 'array',
50 description: 'Tasks to create or update, matched by id. Only the fields you pass change.',
51 items: {
52 type: 'object',
53 properties: {
54 id: { type: 'string', description: 'A short stable id, e.g. "T1".' },
55 title: { type: 'string', description: `Required for a new task: a short phrase in ${READER_LANGUAGE}.` },
56 status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] },
57 blockedBy: { type: 'array', items: { type: 'string' }, description: 'Ids of tasks that must finish first.' },
58 owner: { type: 'string', description: 'Who does it: "main", or the subagent\'s description or name. Defaults to you.' },
59 },
60 required: ['id'],
61 },
62 },
63 waitingFor: { type: 'string', description: `What you cannot continue without, in ${READER_LANGUAGE}; an empty string when you can continue again.` },
64 },
65}
66const REPORT_TOOL_DESCRIPTION =
67 'Report tasks, their dependencies and what you are waiting on to the Agent Supervisor panel the person watches. ' +
68 'Report only real progress: never invent tasks or completion. ' +
69 `Task titles and waitingFor are short phrases in ${READER_LANGUAGE}; keep code identifiers as they are.`
70// 主對話:分派子代理時先登記計畫,面板才看得到依賴
71const MAIN_INSTRUCTIONS = [
72 '# Agent Supervisor',
73 `When you split work across subagents, first register the plan with the ${REPORT_TOOL} tool: one task per piece of work with a short id, a title in ${READER_LANGUAGE}, the owner (the description you will give the subagent in the Agent call) and blockedBy for tasks that need another task's result.`,
74 'Mark tasks completed as results come back. Skip it for a single subagent or trivial work.',
75].join('\n')
76// 子代理:只在有多個步驟或卡住時回報,不要每個工具呼叫都回報
77const SUBAGENT_NOTE = [
78 '# Agent Supervisor',
79 `The person follows your progress in a supervisor panel. Use the ${REPORT_TOOL} tool only when it helps them:`,
80 `- if your task has several distinct steps, report them once as tasks (short ids, titles in ${READER_LANGUAGE}) and update their status as you go;`,
81 '- if you cannot continue until something else is done (another agent\'s output, a decision), report waitingFor with what you need, and blockedBy with the task id if you know it; report an empty waitingFor when you can continue.',
82 'Do not report every tool call.',
83].join('\n')
84const IDLE_MAIN: MainLoop = { isRunning: false, activity: '', activityAt: null }
85// 剛載入時不知道輸入框下方是什麼(熱重載前可能留著舊的狀態列),用它讓第一次同步一定送出
86const STATUS_UNKNOWN = Symbol('status-unknown')
87// 狀態改變後等這麼久才寫快照,連續的改變合併成一次寫入
88const SNAPSHOT_DELAY_MS = 500
89// 版本衝突時最多重試幾次,跟 claude-code 的 update 一樣
90const WRITE_ATTEMPTS = 64
91
92// 狀態由 host 保存,熱重載後仍在
93const viewAtom = atom({ plugin: 'agent-supervisor', key: 'view' } as const, 'overview' as SupervisorView)
94const agentsAtom = atom({ plugin: 'agent-supervisor', key: 'agents' } as const, [] as AgentRecord[])
95const mainAtom = atom({ plugin: 'agent-supervisor', key: 'mainLoop' } as const, IDLE_MAIN)
96const tasksAtom = atom({ plugin: 'agent-supervisor', key: 'tasks' } as const, [] as TaskRecord[])
97const waitingAtom = atom({ plugin: 'agent-supervisor', key: 'waiting' } as const, {} as WaitingReports)
98const eventsAtom = atom({ plugin: 'agent-supervisor', key: 'events' } as const, [] as SupervisorEvent[])
99// 只有面板讀,所以每秒的更新只讓面板重畫
100const tickAtom = atom({ plugin: 'agent-supervisor', key: 'tick' } as const, 0)
101// 沒有變化就不寫的寫入直接用 $.state.get/set:plugin 與 key 要是字面值,host 才掃得到
102const AGENTS_REF = { plugin: 'agent-supervisor', key: 'agents' } as const
103
104// 下面是模組自己的變數,熱重載後重來
105let timer: Timer | undefined
106let tickCount = 0
107// 上一次送給 $.ui.status 的文字;相同就不再送,每送一次輸入框下方就重畫一次
108let shownStatus: string | undefined | typeof STATUS_UNKNOWN = STATUS_UNKNOWN
109// 快照:排定中的寫入、上一次寫出的內容(相同就不寫)、這個 session 的身分與檔案路徑(session 開始時重取)
110let snapshotTimer: Timer | undefined
111let lastSnapshotSignature: string | undefined
112let snapshotTarget: { identity: SnapshotIdentity; path: string } | null | undefined
113
114// 更新代理清單;沒有變化就不寫(每次寫入都讓面板重畫),版本衝突時重讀再試
115async function changeAgents($: EngineInterface, change: (agents: AgentRecord[]) => AgentRecord[]): Promise<AgentRecord[]> {
116 for (let attempt = 0; attempt < WRITE_ATTEMPTS; attempt++) {
117 const held = await $.state.get(AGENTS_REF)
118 const current = held.value ?? []
119 const next = capAgents(change(current))
120 if (next === current) return current
121 if ((await $.state.set(AGENTS_REF, next, { ifVersion: held.version })).isSet) {
122 markSnapshotDirty($)
123 return next
124 }
125 }
126 throw new Error('changeAgents: 每次讀到之後都被別人搶先寫入,試到上限仍沒寫進去')
127}
128
129async function isPanelOpen($: EngineInterface): Promise<boolean> {
130 const panes = await $.ui.panes().catch(() => [])
131 return panes.some(pane => pane.id === PANE_ID)
132}
133
134async function togglePanel($: EngineInterface): Promise<{ text: string }> {
135 if (await isPanelOpen($)) {
136 await closePanel($)
137 return { text: '已關閉 Agent Supervisor 面板。' }
138 }
139 await openPanel($, false)
140 return { text: '已開啟 Agent Supervisor 面板。' }
141}
142
143// 使用者打指令開的窗格任何寬度都放得下:夠寬時停靠右邊,不然放在輸入框上方。
144// focus 只是請求:輸入框有字、正在對話框裡時不會給
145async function openPanel($: EngineInterface, focus: boolean): Promise<void> {
146 await $.ui.open({ id: PANE_ID, title: PANE_TITLE, columns: PANE_COLUMNS, ...(focus && { focus: true }) })
147 await syncTimerAndStatus($)
148}
149
150async function closePanel($: EngineInterface): Promise<void> {
151 await $.ui.close({ id: PANE_ID }).catch(() => {})
152 await syncTimerAndStatus($)
153}
154
155async function runSupervisorCommand($: EngineInterface, args: string): Promise<{ text: string }> {
156 const action = args.trim().toLowerCase()
157 if (action === '') return togglePanel($)
158 if (action === 'focus') {
159 await openPanel($, true)
160 return { text: '已要求把鍵盤交給 Agent Supervisor 面板:A、T、L、O 切頁,R 更新,Q 關閉,Esc 回到輸入框。' }
161 }
162 const view = VIEW_ARGUMENTS[action]
163 if (view === undefined) return { text: USAGE }
164 await showView($, view)
165 await openPanel($, false)
166 return { text: '已開啟 Agent Supervisor 面板。' }
167}
168
169async function showView($: EngineInterface, view: SupervisorView): Promise<void> {
170 if ((await read($, viewAtom)) !== view) await update($, viewAtom, () => view)
171}
172
173// R:立刻跟 Claude Code 的代理清單對帳並重畫
174async function refreshNow($: EngineInterface): Promise<void> {
175 await pollAgentList($).catch(() => {})
176 await update($, tickAtom, frame => frame + 1)
177 await syncTimerAndStatus($)
178}
179
180async function recordEvents($: EngineInterface, added: readonly SupervisorEvent[]): Promise<void> {
181 // 任務與等待原因改變時一定伴隨事件,從這裡標記快照要更新
182 markSnapshotDirty($)
183 if (added.length === 0) return
184 await update($, eventsAtom, events => appendEvents(events, added))
185}
186
187// 有還沒結束的代理時跑計時器、沒有就停;面板沒開時在輸入框下方釘一行狀態,開了或都結束了就清掉
188async function syncTimerAndStatus($: EngineInterface): Promise<void> {
189 markSnapshotDirty($)
190 try {
191 const [agents, isOpen] = await Promise.all([read($, agentsAtom), isPanelOpen($)])
192 const liveCount = agents.filter(agent => agent.phase === 'running' || agent.phase === 'idle').length
193 showStatus($, liveCount > 0 && !isOpen ? `Supervisor ●${liveCount} · /supervisor` : undefined)
194 if (liveCount === 0) stopTimer()
195 else if (timer === undefined) timer = $.clock.every(TICK_MS, () => void onTick($))
196 } catch {
197 // 略過,下一個事件或下一拍再同步
198 }
199}
200
201async function onTick($: EngineInterface): Promise<void> {
202 tickCount += 1
203 try {
204 if (tickCount % POLL_EVERY_TICKS === 0) await pollAgentList($)
205 if (await isPanelOpen($)) await update($, tickAtom, frame => frame + 1)
206 await syncTimerAndStatus($)
207 } catch {
208 // 略過這一拍
209 }
210}
211
212function stopTimer() {
213 timer?.cancel()
214 timer = undefined
215}
216
217function showStatus($: EngineInterface, text: string | undefined) {
218 if (text === shownStatus) return
219 $.ui.status(text)
220 shownStatus = text
221}
222
223// session 結束後畫面上是什麼不確定,記錄回到「不知道」,下一個 session 的第一次同步一定送出
224function clearStatus($: EngineInterface) {
225 try {
226 $.ui.status(undefined)
227 } catch {
228 // 略過
229 }
230 shownStatus = STATUS_UNKNOWN
231}
232
233async function pollAgentList($: EngineInterface): Promise<void> {
234 const listed = (await $.agent.list()) as ListedAgent[]
235 await changeAgents($, agents => reconcile(agents, listed))
236}
237
238// 結束事件:不認得的 agentId 先確認清單上有沒有,有才補上(Claude Code 自己的 fork 也帶 agentId,但不在清單上)
239async function recordFinish($: EngineInterface, agentId: string, reason: FinishReason) {
240 const endedAt = await $.clock.now()
241 const known = (await read($, agentsAtom)).some(agent => agent.id === agentId)
242 const listed = known ? [] : ((await $.agent.list().catch(() => [])) as ListedAgent[])
243 const entry = listed.find(agent => agent.id === agentId && agent.teammateId === undefined)
244 if (!known && entry === undefined) return
245 const before = (await read($, agentsAtom)).find(agent => agent.id === agentId)
246 const agents = await changeAgents($, current => finishAgent(entry === undefined ? current : adoptListed(current, entry), agentId, reason, endedAt))
247 const after = agents.find(agent => agent.id === agentId)
248 // 重複的結束事件不再記一筆
249 if (after !== undefined && after.phase !== before?.phase) {
250 const event: SupervisorEvent =
251 after.phase === 'completed'
252 ? { at: endedAt, agentId, kind: 'complete', text: '完成' }
253 : { at: endedAt, agentId, kind: 'fail', text: `失敗:${after.failureReason ?? '失敗'}` }
254 await recordEvents($, [event])
255 }
256 // 結束了就不再等什麼,免得留著舊的等待被當成阻塞
257 await update($, waitingAtom, waiting => setWaiting(waiting, agentId, '', endedAt))
258 await syncTimerAndStatus($)
259}
260
261async function recordAgentActivity($: EngineInterface, agentId: string, activity: string, isToolCall: boolean) {
262 const at = await $.clock.now()
263 await changeAgents($, agents => recordActivity(agents, agentId, { activity, at, isToolCall }))
264}
265
266async function recordMainActivity($: EngineInterface, activity: string) {
267 const at = await $.clock.now()
268 await update($, mainAtom, main => ({ ...main, activity, activityAt: at }))
269 markSnapshotDirty($)
270}
271
272// MARK: 快照(給外部 App 讀,見 snapshot.ts)
273
274function markSnapshotDirty($: EngineInterface) {
275 if (snapshotTimer !== undefined) return
276 snapshotTimer = $.clock.after(SNAPSHOT_DELAY_MS, () => {
277 snapshotTimer = undefined
278 void writeSnapshot($)
279 })
280}
281
282// session 的身分只取一次:id、目錄、HOME,以及 Claude Code 的 pid(shell 的父程序就是 Claude Code)
283async function resolveSnapshotTarget($: EngineInterface): Promise<{ identity: SnapshotIdentity; path: string } | null> {
284 if (snapshotTarget !== undefined) return snapshotTarget
285 const [sessionId, cwd, home] = await Promise.all([$.session.id(), $.session.cwd(), $.env.get('HOME')])
286 const path = home === undefined ? null : snapshotPath(home, sessionId)
287 if (path === null) {
288 snapshotTarget = null
289 return null
290 }
291 let pid: number | null = null
292 try {
293 const parent = Number.parseInt((await $.process.run(['/bin/sh', '-c', 'echo $PPID'], { timeoutMs: 5_000 })).stdout.trim(), 10)
294 if (Number.isInteger(parent) && parent > 1) pid = parent
295 } catch {
296 // 取不到 pid 時外部 App 改用目錄比對
297 }
298 snapshotTarget = { identity: { sessionId, pid, cwd }, path }
299 return snapshotTarget
300}
301
302// ended:session 結束時一定要寫(內容沒變也寫),讓外部 App 知道這份快照不會再更新
303async function writeSnapshot($: EngineInterface, ended = false): Promise<void> {
304 try {
305 const target = await resolveSnapshotTarget($)
306 if (target === null) return
307 const [agents, mainLoop, tasks, waiting, events, now] = await Promise.all([
308 read($, agentsAtom), read($, mainAtom), read($, tasksAtom), read($, waitingAtom), read($, eventsAtom), $.clock.now(),
309 ])
310 const snapshot = buildSnapshot({ agents, mainLoop, tasks, waiting, events }, target.identity, now, ended)
311 const signature = snapshotSignature(snapshot)
312 if (!ended && signature === lastSnapshotSignature) return
313 await $.fs.write(target.path, JSON.stringify(snapshot))
314 lastSnapshotSignature = signature
315 } catch {
316 // 寫不進去就略過,下一次狀態改變再試;面板不受影響
317 }
318}
319
320// 工具結果:累計連續失敗;失敗或被拒絕的記一筆事件,成功的不記(太多)
321async function recordToolResult($: EngineInterface, agentId: string, toolName: string, outcome: ToolOutcome): Promise<void> {
322 await changeAgents($, agents => recordToolOutcome(agents, agentId, outcome))
323 if (outcome.outcome === 'ok') return
324 const at = await $.clock.now()
325 const text = `${outcome.outcome === 'denied' ? '工具被拒絕' : '工具失敗'}:${outcome.errorLine ?? toolName}`
326 await recordEvents($, [{ at, agentId, kind: 'tool-error', text }])
327}
328
329function activityOf(tool: string, input: unknown): string {
330 if (tool === 'SubagentHandback') return '交回報告'
331 if (tool === REPORT_TOOL) return REPORT_ACTIVITY
332 return describeTool(tool, input)
333}
334
335// 回報工具:檢查格式、更新任務與等待原因,回覆用繁體中文。自己回答、不呼叫 next,這個工具沒有實際執行的東西
336async function answerReport($: EngineInterface, input: unknown, reporter: string): Promise<{ result: string } | { deny: string }> {
337 const parsed = parseReport(input)
338 if ('error' in parsed) return { deny: parsed.error }
339 const { report } = parsed
340 const [agents, at] = await Promise.all([read($, agentsAtom), $.clock.now()])
341 const context = { reporter, agents, at }
342 const added: SupervisorEvent[] = []
343 if (report.tasks.length > 0) {
344 const checked = applyReport(await read($, tasksAtom), report, context)
345 if ('error' in checked) return { deny: checked.error }
346 // 再套用一次:讀到之後若有別人寫入,update 會重讀再算;事件取最後真正寫入的那次
347 let changes: SupervisorEvent[] = []
348 await update($, tasksAtom, tasks => {
349 const applied = applyReport(tasks, report, context)
350 if ('error' in applied) return tasks
351 changes = taskEvents(tasks, applied.tasks, at)
352 return applied.tasks
353 })
354 added.push(...changes)
355 }
356 if (report.waitingFor !== null) {
357 const waitingFor = report.waitingFor
358 let changes: SupervisorEvent[] = []
359 await update($, waitingAtom, waiting => {
360 const next = setWaiting(waiting, reporter, waitingFor, at)
361 changes = waitingEvents(waiting, next, at)
362 return next
363 })
364 added.push(...changes)
365 }
366 await recordEvents($, added)
367 return { result: reportReply(report.tasks.length, report.waitingFor !== null) }
368}
369
370function reportReply(taskCount: number, hasWaiting: boolean): string {
371 if (taskCount > 0 && hasWaiting) return `已記錄 ${taskCount} 項任務與等待原因。`
372 if (taskCount > 0) return `已記錄 ${taskCount} 項任務。`
373 return '已記錄等待原因。'
374}
375
376// 原生任務工具的結果:被拒絕的不記;工具本身的輸出在 tool.call 結果的 result 欄位
377async function recordNativeTask($: EngineInterface, tool: string, input: unknown, result: unknown, reporter: string) {
378 const answered = (typeof result === 'object' && result !== null ? result : {}) as { deny?: unknown; result?: unknown }
379 if (answered.deny !== undefined) return
380 const [agents, at] = await Promise.all([read($, agentsAtom), $.clock.now()])
381 let changes: SupervisorEvent[] = []
382 await update($, tasksAtom, tasks => {
383 const next = applyNativeTool(tasks, { tool, input, result: answered.result }, { reporter, agents, at })
384 changes = taskEvents(tasks, next, at)
385 return next
386 })
387 await recordEvents($, changes)
388}
389
390function withSubagentNote(prompt: string): string {
391 return `${prompt.trimEnd()}\n\n${SUBAGENT_NOTE}`
392}
393
394export const register: Register = on => {
395 on('session.start', async ($, e, next) => {
396 const started = await next(e)
397 // 新的 session(含 /clear 之後)重新取身分,快照寫到新的檔案
398 snapshotTarget = undefined
399 lastSnapshotSignature = undefined
400 // 名稱已被佔用時會被拒絕,不影響其他功能
401 // immediate:主對話還在工作時打 /supervisor 也立刻執行;處理時只開關窗格、讀 state,不依賴這一輪的狀態
402 await $.command
403 .register({ name: 'supervisor', description: '開關 Agent Supervisor 面板:子代理、任務、阻塞與建議', argumentHint: '[agents|tasks|logs|focus]', immediate: true })
404 .catch(() => {})
405 // 不放在 ToolSearch 後面:子代理不用先搜尋就能回報
406 await $.tool.register({ name: REPORT_TOOL_NAME, description: REPORT_TOOL_DESCRIPTION, inputSchema: REPORT_SCHEMA, isDeferred: false }).catch(() => {})
407 // 熱重載或開 session 前就在跑的代理從清單補上,計時器與狀態列也接回來
408 await pollAgentList($).catch(() => {})
409 await syncTimerAndStatus($)
410 return started
411 })
412
413 on('session.end', async ($, e, next) => {
414 stopTimer()
415 clearStatus($)
416 snapshotTimer?.cancel()
417 snapshotTimer = undefined
418 await writeSnapshot($, true)
419 if (e.reason === 'clear') {
420 await update($, agentsAtom, () => []).catch(() => {})
421 await update($, mainAtom, () => IDLE_MAIN).catch(() => {})
422 await update($, tasksAtom, () => []).catch(() => {})
423 await update($, waitingAtom, () => ({})).catch(() => {})
424 await update($, eventsAtom, () => []).catch(() => {})
425 }
426 return next(e)
427 })
428
429 on('command.run', { command: 'supervisor' }, async ($, e) => runSupervisorCommand($, e.args))
430
431 on('prompt.compose', async ($, e, next) => {
432 const composed = await next(e)
433 if (!e.tools.includes(REPORT_TOOL)) return composed
434 return { sections: [...composed.sections, { id: 'agent-supervisor:report', text: MAIN_INSTRUCTIONS, scope: 'session' }] }
435 })
436
437 // subagent 的執行不會發 turn.start,只有主對話會
438 on('turn.start', async ($, e, next) => {
439 await update($, mainAtom, () => ({ isRunning: true, activity: '', activityAt: null })).catch(() => {})
440 return next(e)
441 })
442
443 on('agent.spawn', async ($, e, next) => {
444 // workflow 的代理照原樣:它們有自己的輸出格式
445 const result = await next(e.workflow ? e : { ...e, prompt: withSubagentNote(e.prompt) })
446 // 被擋下、沒有本機執行迴圈(workflow 的遠端子代理)、在其他窗格跑的 teammate:追蹤不到,不列入
447 if (result.deny !== undefined || result.agentId === undefined || result.teammateId !== undefined) return result
448 try {
449 const startedAt = await $.clock.now()
450 const facts = {
451 id: result.agentId,
452 name: e.name ?? null,
453 agentType: e.subagentType,
454 description: e.description,
455 parentId: e.parentAgentId ?? null,
456 startedAt,
457 }
458 const isNew = !(await read($, agentsAtom)).some(agent => agent.id === facts.id)
459 const agents = await changeAgents($, current => addSpawn(current, facts))
460 // 派出前就登記的計畫,負責人寫的是名字或任務說明,現在對得到這個代理了
461 await update($, tasksAtom, tasks => resolvePendingOwners(tasks, agents))
462 if (isNew) await recordEvents($, [{ at: startedAt, agentId: facts.id, kind: 'spawn', text: '開始' }])
463 await syncTimerAndStatus($)
464 } catch {
465 // 只觀察:出錯就略過,不影響子代理本身
466 }
467 return result
468 })
469
470 on('turn.step', async function* ($, e, next) {
471 try {
472 if (e.agentId !== undefined) await recordAgentActivity($, e.agentId, THINKING, false)
473 else await recordMainActivity($, THINKING)
474 } catch {
475 // 略過
476 }
477 return yield* next(e)
478 })
479
480 on('tool.call', async ($, e, next) => {
481 const agentId = e.agentId
482 const reporter = agentId ?? 'main'
483 // 回報工具不在內建工具的型別清單裡,當成一般字串比較
484 const toolName: string = e.tool
485 try {
486 const activity = activityOf(toolName, e)
487 if (agentId !== undefined) await recordAgentActivity($, agentId, activity, true)
488 else await recordMainActivity($, activity)
489 } catch {
490 // 略過
491 }
492 if (toolName === REPORT_TOOL) return answerReport($, e, reporter)
493 if (agentId === undefined) {
494 const result = await next(e)
495 if (NATIVE_TASK_TOOLS.has(toolName)) await recordNativeTask($, toolName, e, result, reporter).catch(() => {})
496 return result
497 }
498 let result: Awaited<ReturnType<typeof next>>
499 try {
500 result = await next(e)
501 } catch (error) {
502 // 工具執行時拋例外:記成失敗後照樣往外丟
503 const message = error instanceof Error ? error.message : String(error)
504 await recordToolResult($, agentId, toolName, { outcome: 'error', errorLine: message }).catch(() => {})
505 throw error
506 }
507 await recordToolResult($, agentId, toolName, toolOutcomeOf(result)).catch(() => {})
508 if (NATIVE_TASK_TOOLS.has(toolName)) await recordNativeTask($, toolName, e, result, reporter).catch(() => {})
509 return result
510 })
511
512 on('turn.complete', async ($, e, next) => {
513 try {
514 if (e.agentId !== undefined) await recordFinish($, e.agentId, e.reason)
515 else await update($, mainAtom, main => ({ ...main, isRunning: false }))
516 } catch {
517 // 略過
518 }
519 return next(e)
520 })
521
522 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
523 const { Box, Text, Button } = $.ui.resolve(e)
524 // 一列畫成一個 Text,各段顏色是裡面的 Text;放不下就截斷,不折行
525 const spanRow = (spans: Span[]) => (
526 <Text wrap="truncate-end">
527 {spans.map(span => (
528 <Text color={span.color} dimColor={span.isDim} bold={span.isBold}>
529 {span.text}
530 </Text>
531 ))}
532 </Text>
533 )
534 // 畫面丟例外時 Claude Code 會卸載窗格、要再打 /supervisor 才回來;這裡接住,畫一行提示
535 try {
536 const [agents, mainLoop, tasks, waiting, events, view] = await Promise.all([
537 read($, agentsAtom),
538 read($, mainAtom),
539 read($, tasksAtom),
540 read($, waitingAtom),
541 read($, eventsAtom),
542 read($, viewAtom),
543 read($, tickAtom),
544 ])
545 const layout = e.props.placement === 'inline' ? INLINE_LAYOUT : DOCKED_LAYOUT
546 const rows = viewRows(view, { agents, mainLoop, tasks, waiting, events }, { ...layout, columns: e.props.bodyColumns, now: await $.clock.now() })
547 // 工具列:每個按鈕一個字母快捷鍵(面板拿到鍵盤時才按得到);目前這一頁的不畫
548 const toolbar = (
549 <Box flexDirection="row" columnGap={1}>
550 {VIEW_BUTTONS.filter(button => button.view !== view).map(button => (
551 <Button key={`view-${button.view}`} hotkey={button.hotkey} plain label={button.label} onPress={() => showView($, button.view)} />
552 ))}
553 <Button key="refresh" hotkey="r" plain label="更新" onPress={() => refreshNow($)} />
554 <Button key="close" hotkey="q" plain label="關閉" onPress={() => closePanel($)} />
555 </Box>
556 )
557 return (
558 <Box flexDirection="column">
559 {toolbar}
560 {rows.map(spanRow)}
561 </Box>
562 )
563 } catch (error) {
564 const reason = error instanceof Error ? error.message : String(error)
565 return (
566 <Text color="error" wrap="truncate-end">
567 {`面板暫時畫不出來:${reason}`}
568 </Text>
569 )
570 }
571 })
572}
573hooks/agents.ts 209 lines1// 子代理的狀態:派出、活動、工具結果、結束,以及跟 Claude Code 的代理清單($.agent.list())對帳。
2// 全部是純函式,回傳新陣列、不改傳入的;沒有變化時回傳同一個陣列,呼叫端就不必寫入(改寫自已刪除的 agent-panel)
3import type { AgentPhase, AgentRecord, StatusSource } from '../types'
4
5export type SpawnFacts = {
6 id: string
7 name: string | null
8 agentType: string
9 description: string
10 parentId: string | null
11 startedAt: number
12}
13export type FinishReason = 'answer' | 'aborted' | 'error' | 'refusal'
14export type ListedAgent = { id: string; description: string; type: string; status: string; parentId?: string; teammateId?: string; name?: string }
15export type ToolOutcome = { outcome: 'ok' | 'error' | 'denied'; errorLine: string | null }
16type Activity = { activity: string; at: number; isToolCall: boolean }
17
18const MAX_AGENTS = 30
19
20const FAILURE_REASONS: Record<Exclude<FinishReason, 'answer'>, string> = {
21 aborted: '已中斷',
22 error: 'API 錯誤',
23 refusal: '模型拒絕',
24}
25
26// $.agent.list() 的狀態對到面板的狀態。waiting 是在等自己的背景工作、計畫核准或(背景子代理)等 Agent 呼叫,
27// 不是異常,所以當成 idle 加註,不當成阻塞
28const LISTED_PHASES: Record<string, { phase: AgentPhase; detail: string | null; failureReason: string | null }> = {
29 pending: { phase: 'running', detail: null, failureReason: null },
30 running: { phase: 'running', detail: null, failureReason: null },
31 idle: { phase: 'idle', detail: null, failureReason: null },
32 waiting: { phase: 'idle', detail: '等待背景工作或核准', failureReason: null },
33 completed: { phase: 'completed', detail: null, failureReason: null },
34 failed: { phase: 'failed', detail: null, failureReason: '失敗' },
35 killed: { phase: 'failed', detail: null, failureReason: '已停止' },
36}
37// 清單上這些狀態的代理還沒結束,補進面板;已結束的不補(不知道它做了什麼)
38const LIVE_LISTED = new Set(['pending', 'running', 'waiting', 'idle'])
39
40export function isFinished(agent: AgentRecord): boolean {
41 return agent.phase === 'completed' || agent.phase === 'failed'
42}
43
44export function hasLive(agents: readonly AgentRecord[]): boolean {
45 return agents.some(agent => agent.phase === 'running' || agent.phase === 'idle')
46}
47
48// 開面板前就在跑、從清單補上的不知道開始時間
49type RecordFacts = Omit<SpawnFacts, 'startedAt'> & { startedAt: number | null }
50
51function newRecord(facts: RecordFacts, phase: AgentPhase, source: StatusSource): AgentRecord {
52 return {
53 ...facts,
54 phase,
55 source,
56 detail: null,
57 failureReason: null,
58 endedAt: null,
59 activity: '',
60 activityAt: null,
61 toolCount: 0,
62 toolErrorStreak: 0,
63 lastToolError: null,
64 }
65}
66
67function updateAgent(agents: AgentRecord[], id: string, change: (agent: AgentRecord) => AgentRecord): AgentRecord[] {
68 const index = agents.findIndex(agent => agent.id === id)
69 if (index < 0) return agents
70 const changed = change(agents[index]!)
71 if (changed === agents[index]) return agents
72 const next = agents.slice()
73 next[index] = changed
74 return next
75}
76
77// 派出事件。已經有紀錄(結束事件先到、先從清單補上)時只補上名稱與說明,不動狀態
78export function addSpawn(agents: AgentRecord[], facts: SpawnFacts): AgentRecord[] {
79 const existing = agents.find(agent => agent.id === facts.id)
80 if (existing === undefined) return [...agents, newRecord(facts, 'running', 'event')]
81 const isSame =
82 existing.name === facts.name &&
83 existing.agentType === facts.agentType &&
84 existing.description === facts.description &&
85 existing.parentId === facts.parentId &&
86 existing.startedAt !== null
87 if (isSame) return agents
88 return updateAgent(agents, facts.id, agent => ({
89 ...agent,
90 name: facts.name,
91 agentType: facts.agentType,
92 description: facts.description,
93 parentId: facts.parentId,
94 startedAt: agent.startedAt ?? facts.startedAt,
95 }))
96}
97
98// 從清單補上一個還沒記錄的代理(開面板前就在跑,或結束事件比派出先到)
99export function adoptListed(agents: AgentRecord[], listed: ListedAgent): AgentRecord[] {
100 if (agents.some(agent => agent.id === listed.id)) return agents
101 const mapped = LISTED_PHASES[listed.status] ?? { phase: 'unknown' as const, detail: null, failureReason: null }
102 const facts = { id: listed.id, name: listed.name ?? null, agentType: listed.type, description: listed.description, parentId: listed.parentId ?? null, startedAt: null }
103 return [...agents, { ...newRecord(facts, mapped.phase, 'engine'), detail: mapped.detail, failureReason: mapped.failureReason }]
104}
105
106// 思考或工具呼叫:證明它正在跑。已結束的不動(結束後才到的事件)
107export function recordActivity(agents: AgentRecord[], id: string, activity: Activity): AgentRecord[] {
108 return updateAgent(agents, id, agent =>
109 isFinished(agent)
110 ? agent
111 : {
112 ...agent,
113 phase: 'running',
114 source: 'event',
115 detail: null,
116 activity: activity.activity,
117 activityAt: activity.at,
118 toolCount: agent.toolCount + (activity.isToolCall ? 1 : 0),
119 },
120 )
121}
122
123export function recordToolOutcome(agents: AgentRecord[], id: string, result: ToolOutcome): AgentRecord[] {
124 return updateAgent(agents, id, agent => {
125 if (result.outcome === 'ok') {
126 return agent.toolErrorStreak === 0 && agent.lastToolError === null ? agent : { ...agent, toolErrorStreak: 0, lastToolError: null }
127 }
128 return { ...agent, toolErrorStreak: agent.toolErrorStreak + 1, lastToolError: result.errorLine }
129 })
130}
131
132// 結束事件。重複的以第一次為準
133export function finishAgent(agents: AgentRecord[], id: string, reason: FinishReason, endedAt: number): AgentRecord[] {
134 return updateAgent(agents, id, agent => {
135 if (isFinished(agent) && agent.source === 'event') return agent
136 return reason === 'answer'
137 ? { ...agent, phase: 'completed', source: 'event', detail: null, failureReason: null, endedAt }
138 : { ...agent, phase: 'failed', source: 'event', detail: null, failureReason: FAILURE_REASONS[reason], endedAt }
139 })
140}
141
142// 跟 Claude Code 的代理清單對帳:還沒結束的照清單更新狀態;清單上找不到的標成推測的 unknown;
143// 清單上還沒記錄、還沒結束的補上。事件已確認的結束不被清單蓋掉
144export function reconcile(agents: AgentRecord[], listed: readonly ListedAgent[]): AgentRecord[] {
145 const byId = new Map(listed.map(agent => [agent.id, agent]))
146 let next = agents
147 for (const agent of agents) {
148 if (isFinished(agent) && agent.source === 'event') continue
149 const entry = byId.get(agent.id)
150 const mapped = entry === undefined ? null : LISTED_PHASES[entry.status]
151 next = updateAgent(next, agent.id, current => {
152 if (mapped === null || mapped === undefined) {
153 return current.phase === 'unknown' ? current : { ...current, phase: 'unknown', source: 'inferred', detail: null }
154 }
155 const isSame = current.phase === mapped.phase && current.detail === mapped.detail && current.failureReason === mapped.failureReason
156 return isSame ? current : { ...current, ...mapped, source: 'engine' }
157 })
158 }
159 for (const entry of listed) {
160 if (entry.teammateId !== undefined || !LIVE_LISTED.has(entry.status)) continue
161 next = adoptListed(next, entry)
162 }
163 return next
164}
165
166// 最多留 MAX_AGENTS 個:先丟最早結束的,還沒結束的保留
167export function capAgents(agents: AgentRecord[]): AgentRecord[] {
168 if (agents.length <= MAX_AGENTS) return agents
169 const finished = agents
170 .filter(isFinished)
171 .sort((left, right) => (left.endedAt ?? left.startedAt ?? 0) - (right.endedAt ?? right.startedAt ?? 0))
172 const dropped = new Set(finished.slice(0, agents.length - MAX_AGENTS).map(agent => agent.id))
173 return agents.filter(agent => !dropped.has(agent.id))
174}
175
176// 工具結果:被拒絕、出錯或成功;錯誤取第一行有字的,去掉 <tool_use_error> 標籤
177export function toolOutcomeOf(result: unknown): ToolOutcome {
178 const record = (typeof result === 'object' && result !== null ? result : {}) as { deny?: unknown; isError?: unknown; text?: unknown }
179 if (typeof record.deny === 'string') return { outcome: 'denied', errorLine: firstLine(record.deny) }
180 if (record.isError === true) return { outcome: 'error', errorLine: typeof record.text === 'string' ? firstLine(record.text) : null }
181 return { outcome: 'ok', errorLine: null }
182}
183
184function firstLine(text: string): string | null {
185 const line = text.replace(/<\/?tool_use_error>/g, '').split('\n').map(part => part.trim()).find(part => part !== '')
186 return line === undefined ? null : line.slice(0, 120)
187}
188
189// 一句話說出代理在做什麼(跟已刪除的 agent-panel、clean-view 用同一套說法)
190export function describeTool(tool: string, input: unknown): string {
191 const args = (typeof input === 'object' && input !== null ? input : {}) as Record<string, unknown>
192 const text = (key: string) => (typeof args[key] === 'string' ? (args[key] as string) : '')
193 const file = (path: string) => path.split('/').slice(-2).join('/')
194 if (tool === 'Bash') return `執行:${text('description') || text('command').slice(0, 80)}`
195 if (tool === 'Read') return `讀取 ${file(text('file_path'))}`
196 if (tool === 'Write') return `寫入 ${file(text('file_path'))}`
197 if (tool === 'Edit') return `編輯 ${file(text('file_path'))}`
198 if (tool === 'NotebookEdit') return `編輯 ${file(text('notebook_path'))}`
199 if (tool === 'Grep' || tool === 'Glob') return `搜尋 "${text('pattern').slice(0, 40)}"`
200 if (tool === 'WebSearch') return `搜尋網路「${text('query').slice(0, 40)}」`
201 if (tool === 'WebFetch') return `讀取網頁 ${text('url').replace(/^[a-z]+:\/\//i, '').split(/[/?#]/)[0]}`
202 if (tool === 'Agent') return `委派 agent:${text('description')}`
203 if (tool === 'Skill') return `使用 skill:${text('skill')}`
204 if (tool === 'AskUserQuestion') return '詢問你問題'
205 if (tool === 'ToolSearch') return '載入工具'
206 if (tool.startsWith('mcp__')) return `使用 ${tool.split('__').slice(1).join(' ')}`
207 return `使用 ${tool}`
208}
209hooks/events.ts 40 lines1// 事件串:代理開始、結束與失敗、工具失敗、任務新增與狀態改變、回報在等與不再等待。
2// 成功的工具呼叫太多,不記。只留最近 MAX_EVENTS 筆,記憶體不會一直長
3import type { SupervisorEvent, TaskRecord, TaskStatus, WaitingReports } from '../types'
4
5const MAX_EVENTS = 50
6const STATUS_TEXT: Record<TaskStatus, string> = { pending: '改回未開始', in_progress: '開始進行', completed: '完成' }
7
8export function appendEvents(events: SupervisorEvent[], added: readonly SupervisorEvent[]): SupervisorEvent[] {
9 if (added.length === 0) return events
10 return [...events, ...added].slice(-MAX_EVENTS)
11}
12
13// 比對回報前後的任務:新增、狀態改變、移除各一筆,記在負責人底下
14export function taskEvents(before: readonly TaskRecord[], after: readonly TaskRecord[], at: number): SupervisorEvent[] {
15 const previous = new Map(before.map(task => [task.id, task]))
16 const current = new Set(after.map(task => task.id))
17 const changes: SupervisorEvent[] = []
18 for (const task of after) {
19 const old = previous.get(task.id)
20 if (old === undefined) changes.push({ at, agentId: task.owner, kind: 'task', text: `新增任務 ${task.id} ${task.title}` })
21 else if (old.status !== task.status) changes.push({ at, agentId: task.owner, kind: 'task', text: `${task.id} ${task.title} ${STATUS_TEXT[task.status]}` })
22 }
23 for (const task of before) {
24 if (!current.has(task.id)) changes.push({ at, agentId: task.owner, kind: 'task', text: `移除任務 ${task.id} ${task.title}` })
25 }
26 return changes
27}
28
29// 比對前後的等待回報:回報在等(或改了原因)、不再等待
30export function waitingEvents(before: WaitingReports, after: WaitingReports, at: number): SupervisorEvent[] {
31 const changes: SupervisorEvent[] = []
32 for (const [reporter, report] of Object.entries(after)) {
33 if (before[reporter]?.text !== report.text) changes.push({ at, agentId: reporter, kind: 'waiting', text: `回報在等:${report.text}` })
34 }
35 for (const reporter of Object.keys(before)) {
36 if (!(reporter in after)) changes.push({ at, agentId: reporter, kind: 'resolved', text: '不再等待' })
37 }
38 return changes
39}
40hooks/snapshot.ts 130 lines1// 給外部 App(例如 Splex 終端機)讀的 session 快照:面板上的代理、任務、阻塞、建議與最近事件,寫成一份 JSON
2// 純函式,不碰 engine;寫檔與節流在 register.tsx
3import type { AgentRecord, MainLoop, SupervisorEvent, TaskRecord, WaitingReports } from '../types'
4import { blockedSubjects, detectBlockers, suggestActions } from './blockers'
5import { agentName, ownerName } from './names'
6import { taskProgress } from './tasks'
7import { agentStatus, sortAgents } from './view'
8import type { StatusLabel } from './view'
9
10// 格式有不相容的改動時加一;讀的一方看到不認得的版本就不顯示
11export const SNAPSHOT_VERSION = 1
12// 事件只帶最近幾筆,外部 App 只顯示最近 3 筆
13const SNAPSHOT_EVENT_COUNT = 10
14
15export type SnapshotIdentity = {
16 sessionId: string
17 // Claude Code 本身的 pid,外部 App 用它比對是哪個終端機窗格;取不到是 null
18 pid: number | null
19 cwd: string
20}
21
22export type SnapshotState = {
23 agents: readonly AgentRecord[]
24 mainLoop: MainLoop
25 tasks: readonly TaskRecord[]
26 waiting: WaitingReports
27 events: readonly SupervisorEvent[]
28}
29
30export type SupervisorSnapshot = {
31 version: number
32 sessionId: string
33 pid: number | null
34 cwd: string
35 // 毫秒;session 結束時 ended 為 true(沒有刪檔的 API,改用這個欄位表示)
36 updatedAt: number
37 ended: boolean
38 // activityAt:最近一次活動的時間(毫秒),外部 App 用來算閒置多久;不知道是 null
39 main: { isRunning: boolean; activity: string; activityAt: number | null }
40 agents: {
41 id: string
42 name: string
43 // 跟面板一樣的狀態字:RUNNING/IDLE/BLOCKED/COMPLETED/FAILED/UNKNOWN;isInferred 是推測的
44 status: StatusLabel
45 isInferred: boolean
46 activity: string
47 detail: string | null
48 failureReason: string | null
49 startedAt: number | null
50 endedAt: number | null
51 toolCount: number
52 activityAt: number | null
53 }[]
54 tasks: { id: string; title: string; status: TaskRecord['status']; owner: string; blockedBy: string[] }[]
55 progress: { completed: number; total: number; percent: number } | null
56 // 代理或主對話回報「在等什麼」;reporter 已換成顯示名稱。isMain 是主對話回報的:
57 // 子代理不能直接問使用者,只有主對話的等待代表在等使用者(Splex 依此決定要不要提醒)
58 waiting: { reporter: string; isMain: boolean; text: string; at: number }[]
59 // 面板 ATTENTION 區的阻塞:被擋住的一方、擋路的一方(顯示名稱)、原因;possible 是推測的
60 attention: { subject: string; dependsOn: string | null; reason: string; source: string; certainty: 'confirmed' | 'possible'; taskId: string | null }[]
61 suggestions: string[]
62 events: { at: number; kind: SupervisorEvent['kind']; text: string }[]
63}
64
65// session id 只收英數、底線與 -,避免組出奇怪的路徑
66export function snapshotPath(home: string, sessionId: string): string | null {
67 if (!/^[A-Za-z0-9_-]+$/.test(sessionId)) return null
68 return `${home}/.claude/agent-supervisor/sessions/${sessionId}.json`
69}
70
71export function buildSnapshot(state: SnapshotState, identity: SnapshotIdentity, now: number, ended = false): SupervisorSnapshot {
72 const model = { agents: state.agents, tasks: state.tasks, waiting: state.waiting }
73 const blockers = detectBlockers(model)
74 const blocked = blockedSubjects(blockers)
75 return {
76 version: SNAPSHOT_VERSION,
77 sessionId: identity.sessionId,
78 pid: identity.pid,
79 cwd: identity.cwd,
80 updatedAt: now,
81 ended,
82 main: { isRunning: state.mainLoop.isRunning, activity: state.mainLoop.activity, activityAt: state.mainLoop.activityAt },
83 agents: sortAgents(state.agents, blocked).map(agent => {
84 const status = agentStatus(agent, blocked.has(agent.id))
85 return {
86 id: agent.id,
87 name: agentName(agent),
88 status: status.label,
89 isInferred: status.isInferred,
90 activity: agent.activity,
91 detail: agent.detail,
92 failureReason: agent.failureReason,
93 startedAt: agent.startedAt,
94 endedAt: agent.endedAt,
95 toolCount: agent.toolCount,
96 activityAt: agent.activityAt,
97 }
98 }),
99 tasks: state.tasks.map(task => ({
100 id: task.id,
101 title: task.title,
102 status: task.status,
103 owner: ownerName(task.owner, state.agents),
104 blockedBy: [...task.blockedBy],
105 })),
106 progress: taskProgress(state.tasks),
107 waiting: Object.entries(state.waiting).map(([reporter, report]) => ({
108 reporter: ownerName(reporter, state.agents),
109 isMain: reporter === 'main',
110 text: report.text,
111 at: report.at,
112 })),
113 attention: blockers.map(blocker => ({
114 subject: ownerName(blocker.subject, state.agents),
115 dependsOn: blocker.dependsOn === null ? null : ownerName(blocker.dependsOn, state.agents),
116 reason: blocker.reason,
117 source: blocker.source,
118 certainty: blocker.certainty,
119 taskId: blocker.taskId,
120 })),
121 suggestions: suggestActions(model, blockers),
122 events: state.events.slice(-SNAPSHOT_EVENT_COUNT).map(event => ({ at: event.at, kind: event.kind, text: event.text })),
123 }
124}
125
126// 比較內容是否改變時不算 updatedAt,否則每次都算有變
127export function snapshotSignature(snapshot: SupervisorSnapshot): string {
128 return JSON.stringify({ ...snapshot, updatedAt: 0 })
129}
130hooks/tasks.ts 164 lines1// 任務:代理用 report 工具回報的,以及 Claude Code 原生任務工具(TaskCreate/TaskUpdate/TodoWrite)留下的。
2// 只記錄回報過的,不自行編造任務或完成狀態。全部是純函式,沒有變化時回傳同一個陣列
3import type { AgentRecord, TaskRecord, TaskStatus, WaitingReports } from '../types'
4
5export type ReportedTask = { id: string; title?: string; status?: TaskStatus; blockedBy?: string[]; owner?: string }
6// waitingFor:null 是這次沒回報;空字串是不再等待
7export type Report = { tasks: ReportedTask[]; waitingFor: string | null }
8// reporter:回報者,主對話是 'main',代理是 agentId
9export type ReportContext = { reporter: string; agents: readonly AgentRecord[]; at: number }
10export type NativeToolCall = { tool: string; input: unknown; result: unknown }
11
12const MAX_TASKS = 50
13const MAX_TITLE_LENGTH = 80
14const STATUSES: readonly TaskStatus[] = ['pending', 'in_progress', 'completed']
15const MAIN_OWNER = 'main'
16const MAIN_ALIASES = new Set(['main', 'supervisor'])
17const NATIVE_PREFIX = '#'
18
19const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
20const isStringArray = (value: unknown): value is string[] => Array.isArray(value) && value.every(item => typeof item === 'string')
21
22// 工具輸入的格式檢查;錯誤訊息回給呼叫的模型,用繁體中文
23export function parseReport(input: unknown): { report: Report } | { error: string } {
24 if (!isRecord(input)) return { error: '參數必須是物件。' }
25 const { tasks = [], waitingFor } = input
26 if (!Array.isArray(tasks)) return { error: 'tasks 必須是陣列。' }
27 if (waitingFor !== undefined && typeof waitingFor !== 'string') return { error: 'waitingFor 必須是字串;不再等待時傳空字串。' }
28 if (tasks.length === 0 && waitingFor === undefined) return { error: '至少要回報 tasks 或 waitingFor 其中一項。' }
29 const parsed: ReportedTask[] = []
30 for (const item of tasks) {
31 if (!isRecord(item) || typeof item.id !== 'string' || item.id.trim() === '') return { error: '每個任務都要有 id(字串)。' }
32 if (item.title !== undefined && typeof item.title !== 'string') return { error: `任務 ${item.id} 的 title 必須是字串。` }
33 if (item.status !== undefined && !STATUSES.includes(item.status as TaskStatus)) {
34 return { error: `任務 ${item.id} 的 status 只能是 pending、in_progress 或 completed。` }
35 }
36 if (item.blockedBy !== undefined && !isStringArray(item.blockedBy)) return { error: `任務 ${item.id} 的 blockedBy 必須是任務 id 的陣列。` }
37 if (item.owner !== undefined && typeof item.owner !== 'string') return { error: `任務 ${item.id} 的 owner 必須是字串。` }
38 parsed.push({
39 id: item.id.trim(),
40 ...(item.title !== undefined && { title: item.title }),
41 ...(item.status !== undefined && { status: item.status as TaskStatus }),
42 ...(item.blockedBy !== undefined && { blockedBy: item.blockedBy }),
43 ...(item.owner !== undefined && { owner: item.owner }),
44 })
45 }
46 return { report: { tasks: parsed, waitingFor: waitingFor ?? null } }
47}
48
49// 負責人:main、supervisor 是主對話;代理的 id、名字、任務說明(不分大小寫)對到那個代理;對不到的原樣保留
50export function resolveOwner(owner: string, agents: readonly AgentRecord[]): string {
51 const wanted = owner.trim().toLowerCase()
52 if (MAIN_ALIASES.has(wanted)) return MAIN_OWNER
53 const matched = agents.find(agent => [agent.id, agent.name ?? '', agent.description].some(key => key.toLowerCase() === wanted))
54 return matched?.id ?? owner.trim()
55}
56
57export function applyReport(tasks: TaskRecord[], report: Report, context: ReportContext): { tasks: TaskRecord[] } | { error: string } {
58 let next = tasks
59 for (const reported of report.tasks) {
60 if (reported.blockedBy?.includes(reported.id)) return { error: `任務 ${reported.id} 不能依賴自己。` }
61 const existing = next.find(task => task.id === reported.id)
62 if (existing === undefined && (reported.title === undefined || reported.title.trim() === '')) {
63 return { error: `新任務 ${reported.id} 要有 title。` }
64 }
65 const owner = reported.owner === undefined ? existing?.owner ?? context.reporter : resolveOwner(reported.owner, context.agents)
66 const record: TaskRecord = {
67 id: reported.id,
68 title: (reported.title?.trim() || existing?.title || '').slice(0, MAX_TITLE_LENGTH),
69 status: reported.status ?? existing?.status ?? 'pending',
70 owner,
71 blockedBy: reported.blockedBy ?? existing?.blockedBy ?? [],
72 source: existing?.source ?? 'report',
73 updatedAt: context.at,
74 }
75 next = existing === undefined ? [...next, record] : next.map(task => (task.id === record.id ? record : task))
76 }
77 return { tasks: capTasks(next) }
78}
79
80// Claude Code 原生任務工具的呼叫(使用者開了 CLAUDE_CODE_ENABLE_TASKS 才會有):從輸入與結果還原任務。
81// 結果失敗或不是任務工具時原樣回傳
82export function applyNativeTool(tasks: TaskRecord[], call: NativeToolCall, context: ReportContext): TaskRecord[] {
83 if (!isRecord(call.input) || (isRecord(call.result) && call.result.deny !== undefined)) return tasks
84 const input = call.input
85 if (call.tool === 'TaskCreate') {
86 const created = isRecord(call.result) && isRecord(call.result.task) ? call.result.task : null
87 if (created === null || typeof created.id !== 'string') return tasks
88 const title = typeof created.subject === 'string' ? created.subject : typeof input.subject === 'string' ? input.subject : ''
89 const record: TaskRecord = { id: NATIVE_PREFIX + created.id, title, status: 'pending', owner: context.reporter, blockedBy: [], source: 'native', updatedAt: context.at }
90 return capTasks([...tasks.filter(task => task.id !== record.id), record])
91 }
92 if (call.tool === 'TaskUpdate' && typeof input.taskId === 'string') {
93 const id = NATIVE_PREFIX + input.taskId
94 if (input.status === 'deleted') return tasks.filter(task => task.id !== id)
95 return tasks.map(task => {
96 if (task.id !== id) return task
97 const addBlockedBy = isStringArray(input.addBlockedBy) ? input.addBlockedBy.map(blocker => NATIVE_PREFIX + blocker) : []
98 return {
99 ...task,
100 title: typeof input.subject === 'string' ? input.subject : task.title,
101 status: STATUSES.includes(input.status as TaskStatus) ? (input.status as TaskStatus) : task.status,
102 owner: typeof input.owner === 'string' ? resolveOwner(input.owner, context.agents) : task.owner,
103 blockedBy: [...new Set([...task.blockedBy, ...addBlockedBy])],
104 updatedAt: context.at,
105 }
106 })
107 }
108 if (call.tool === 'TodoWrite' && Array.isArray(input.todos)) {
109 const prefix = `${context.reporter}:todo:`
110 const todos = input.todos.filter(isRecord).map((todo, index): TaskRecord => ({
111 id: `${prefix}${index + 1}`,
112 title: typeof todo.content === 'string' ? todo.content.slice(0, MAX_TITLE_LENGTH) : '',
113 status: STATUSES.includes(todo.status as TaskStatus) ? (todo.status as TaskStatus) : 'pending',
114 owner: context.reporter,
115 blockedBy: [],
116 source: 'native',
117 updatedAt: context.at,
118 }))
119 return capTasks([...tasks.filter(task => !task.id.startsWith(prefix)), ...todos])
120 }
121 return tasks
122}
123
124// 完成幾項、總共幾項、百分比;沒有任何回報的任務時是 null(畫面寫 Unknown)
125export function taskProgress(tasks: readonly TaskRecord[]): { completed: number; total: number; percent: number } | null {
126 if (tasks.length === 0) return null
127 const completed = tasks.filter(task => task.status === 'completed').length
128 return { completed, total: tasks.length, percent: Math.round((completed / tasks.length) * 100) }
129}
130
131// 最多留 MAX_TASKS 項:先丟最早更新的已完成任務
132export function capTasks(tasks: TaskRecord[]): TaskRecord[] {
133 if (tasks.length <= MAX_TASKS) return tasks
134 const completed = tasks.filter(task => task.status === 'completed').sort((left, right) => left.updatedAt - right.updatedAt)
135 const dropped = new Set(completed.slice(0, tasks.length - MAX_TASKS).map(task => task.id))
136 return tasks.filter(task => !dropped.has(task.id))
137}
138
139// 回報者在等什麼:空字串是不再等待;內容一樣時回傳同一個物件
140export function setWaiting(waiting: WaitingReports, reporter: string, text: string, at: number): WaitingReports {
141 const trimmed = text.trim()
142 if (trimmed === '') {
143 if (!(reporter in waiting)) return waiting
144 const { [reporter]: _cleared, ...rest } = waiting
145 return rest
146 }
147 if (waiting[reporter]?.text === trimmed) return waiting
148 return { ...waiting, [reporter]: { text: trimmed, at } }
149}
150
151// 主對話常在派出子代理前就登記計畫,那時負責人只能用名字或任務說明寫;派出後再對一次,換成代理的 id,
152// 阻塞偵測才連得到那個代理。主對話與已對上的不動;沒有變化時回傳同一個陣列
153export function resolvePendingOwners(tasks: TaskRecord[], agents: readonly AgentRecord[]): TaskRecord[] {
154 let changed = false
155 const next = tasks.map(task => {
156 if (task.owner === MAIN_OWNER || agents.some(agent => agent.id === task.owner)) return task
157 const owner = resolveOwner(task.owner, agents)
158 if (owner === task.owner) return task
159 changed = true
160 return { ...task, owner }
161 })
162 return changed ? next : tasks
163}
164hooks/view.ts 286 lines1// 面板的版面:把狀態組成一列一列的文字片段(Span),register.tsx 只負責畫出來。
2// 每一列都依顯示寬度截斷在面板寬度內,窄的 cmux 分割窗格也不會折行
3import type { AgentPhase, AgentRecord, MainLoop, SupervisorEvent, SupervisorView, TaskRecord, TaskStatus, WaitingReports } from '../types'
4import { blockedSubjects, detectBlockers, suggestActions } from './blockers'
5import type { Blocker } from './blockers'
6import { agentName, ownerName } from './names'
7import { taskProgress } from './tasks'
8import { displayWidth, fitToWidth, formatClock, formatDuration, padToWidth } from './text'
9
10export type Span = { text: string; color?: string; isDim?: boolean; isBold?: boolean }
11export type Row = Span[]
12export type StatusLabel = 'RUNNING' | 'IDLE' | 'BLOCKED' | 'COMPLETED' | 'FAILED' | 'UNKNOWN'
13// isInferred:推測的狀態(不是事件或 Claude Code 確認的),畫面上加「?」
14export type DisplayStatus = { label: StatusLabel; isInferred: boolean }
15export type OverviewModel = {
16 agents: readonly AgentRecord[]
17 mainLoop: MainLoop
18 tasks: readonly TaskRecord[]
19 waiting: WaitingReports
20 events: readonly SupervisorEvent[]
21}
22// eventCount:總覽列最近幾筆事件,0 是不畫事件區(放在輸入框上方的精簡版)
23// compact:放在輸入框上方的精簡版,每個代理一列、區塊之間不空行、阻塞一條一列
24export type ViewOptions = { columns: number; now: number; maxAgents?: number; maxTasks?: number; eventCount?: number; compact?: boolean }
25
26type StatusStyle = { mark: string; color?: string; isDim?: boolean }
27
28const STATUS_STYLES: Record<StatusLabel, StatusStyle> = {
29 RUNNING: { mark: '●', color: 'suggestion' },
30 IDLE: { mark: '○', isDim: true },
31 BLOCKED: { mark: '◐', color: 'warning' },
32 COMPLETED: { mark: '✓', color: 'success' },
33 FAILED: { mark: '✗', color: 'error' },
34 UNKNOWN: { mark: '?', isDim: true },
35}
36const PHASE_LABELS: Record<AgentPhase, StatusLabel> = {
37 running: 'RUNNING',
38 idle: 'IDLE',
39 completed: 'COMPLETED',
40 failed: 'FAILED',
41 unknown: 'UNKNOWN',
42}
43// 需要注意的排前面,已結束的排後面
44const DISPLAY_ORDER: Record<StatusLabel, number> = { BLOCKED: 0, RUNNING: 1, IDLE: 2, UNKNOWN: 3, FAILED: 4, COMPLETED: 5 }
45const INFERRED_MARK = '?'
46const INDENT = ' '
47const UNKNOWN_TEXT = 'Unknown'
48const DEFAULT_MAX_AGENTS = 6
49const DEFAULT_MAX_TASKS = 6
50const TASK_MARKS: Record<TaskStatus, string> = { completed: '[x]', in_progress: '[>]', pending: '[ ]' }
51const STUCK_MARK = '[!]'
52// 進行中的排前面,已完成的排後面
53const TASK_ORDER: Record<TaskStatus, number> = { in_progress: 0, pending: 1, completed: 2 }
54const NO_TASKS = 'Unknown:還沒有代理回報任務'
55// 第二列放得下說明的最少欄數;更窄時不放經過時間
56const MIN_DETAIL_COLUMNS = 6
57const EMPTY_HINT = '這個 session 還沒有派出子代理'
58const INFERRED_LEGEND = '? 表示推測,其他狀態由事件或 Claude Code 確認'
59
60export function agentStatus(agent: AgentRecord, isBlocked = false): DisplayStatus {
61 if (isBlocked && (agent.phase === 'running' || agent.phase === 'idle')) return { label: 'BLOCKED', isInferred: false }
62 return { label: PHASE_LABELS[agent.phase], isInferred: agent.source === 'inferred' }
63}
64
65export function overviewRows(model: OverviewModel, options: ViewOptions): Row[] {
66 const blockers = detectBlockers(model)
67 const blocked = blockedSubjects(blockers)
68 const isCompact = options.compact === true
69 // 區塊之間空一列;精簡版不空
70 const gap = (): Row[] => (isCompact ? [] : [blankRow()])
71 const main = mainRows(model.mainLoop, blocked.has('main'), options.columns)
72 const rows: Row[] = isCompact ? main.slice(0, 1) : [...main]
73 // 什麼都還沒有:只寫一行說明,不畫空的區塊
74 if (model.agents.length === 0 && model.tasks.length === 0) {
75 rows.push(...gap(), [{ text: EMPTY_HINT, isDim: true }])
76 return fitRows(rows, options.columns)
77 }
78 const sorted = sortAgents(model.agents, blocked)
79 const shown = sorted.slice(0, options.maxAgents ?? DEFAULT_MAX_AGENTS)
80 rows.push(...gap(), sectionRow('AGENTS'))
81 if (shown.length === 0) rows.push([{ text: INDENT + EMPTY_HINT, isDim: true }])
82 for (const agent of shown) {
83 const agentLines = agentRows(agent, agentStatus(agent, blocked.has(agent.id)), options)
84 rows.push(...(isCompact ? agentLines.slice(0, 1) : agentLines))
85 }
86 const hiddenCount = sorted.length - shown.length
87 if (hiddenCount > 0) rows.push([{ text: `${INDENT}還有 ${hiddenCount} 個代理`, isDim: true }])
88 rows.push(...gap(), ...tasksRows(model, blockers, options))
89 if (blockers.length > 0) rows.push(...gap(), ...attentionRows(blockers, model, isCompact))
90 const suggestions = suggestActions(model, blockers)
91 if (suggestions.length > 0) rows.push(...gap(), sectionRow('SUGGESTED ACTION'), ...suggestions.map(text => [{ text: `→ ${text}` }]))
92 const eventCount = options.eventCount ?? 0
93 if (eventCount > 0 && model.events.length > 0) {
94 rows.push(...gap(), sectionRow('EVENTS'), ...model.events.slice(-eventCount).map(event => eventRow(event, model.agents)))
95 }
96 if (shown.some(agent => agentStatus(agent).isInferred)) rows.push(...gap(), [{ text: INFERRED_LEGEND, isDim: true }])
97 return fitRows(rows, options.columns)
98}
99
100// 任務區:完成數與百分比只算回報過的任務;每項寫狀態、id、標題、負責人,等別的任務的註明
101export function tasksRows(model: OverviewModel, blockers: readonly Blocker[], options: ViewOptions): Row[] {
102 const progress = taskProgress(model.tasks)
103 if (progress === null) return [sectionRow('TASKS'), [{ text: INDENT + NO_TASKS, isDim: true }]]
104 const sorted = model.tasks
105 .map((task, index) => ({ task, index }))
106 .sort((left, right) => TASK_ORDER[left.task.status] - TASK_ORDER[right.task.status] || left.index - right.index)
107 .map(entry => entry.task)
108 const shown = sorted.slice(0, options.maxTasks ?? DEFAULT_MAX_TASKS)
109 // 確認卡住的任務標 [!]
110 const stuckTaskIds = new Set(blockers.filter(blocker => blocker.certainty === 'confirmed' && blocker.taskId !== null).map(blocker => blocker.taskId))
111 const rows: Row[] = [
112 sectionRow('TASKS'),
113 [{ text: `${INDENT}Completed: ${progress.completed} / ${progress.total} · Progress: ${progress.percent}%`, isDim: true }],
114 ...shown.map(task => taskRow(task, model, stuckTaskIds.has(task.id))),
115 ]
116 const hiddenCount = sorted.length - shown.length
117 if (hiddenCount > 0) rows.push([{ text: `${INDENT}還有 ${hiddenCount} 項任務`, isDim: true }])
118 return rows
119}
120
121function taskRow(task: TaskRecord, model: OverviewModel, isStuck: boolean): Row {
122 const pending = task.blockedBy.filter(id => model.tasks.find(other => other.id === id)?.status !== 'completed')
123 const waitingNote = task.status !== 'completed' && pending.length > 0 ? `(等 ${pending.join('、')})` : ''
124 return [
125 isStuck ? { text: `${INDENT}${STUCK_MARK} `, color: 'warning' } : { text: `${INDENT}${TASK_MARKS[task.status]} `, isDim: task.status === 'completed' },
126 { text: `${task.id} ${task.title}`, isDim: task.status === 'completed', isBold: task.status === 'in_progress' },
127 { text: ` · ${ownerName(task.owner, model.agents)}${waitingNote}`, isDim: true },
128 ]
129}
130
131function mainRows(mainLoop: MainLoop, isBlocked: boolean, columns: number): Row[] {
132 const status: DisplayStatus = { label: isBlocked ? 'BLOCKED' : mainLoop.isRunning ? 'RUNNING' : 'IDLE', isInferred: false }
133 const detail = mainLoop.isRunning ? mainLoop.activity || '處理中' : '等你下指令'
134 return [titleRow('Supervisor', status, columns), detailRow(detail, null, columns)]
135}
136
137function agentRows(agent: AgentRecord, status: DisplayStatus, options: ViewOptions): Row[] {
138 return [titleRow(agentName(agent), status, options.columns), detailRow(agentDetail(agent), elapsedText(agent, options.now), options.columns)]
139}
140
141// 第二列:有名字時寫任務說明(標題已經是名字);沒名字時標題就是任務,改寫最近的活動,還沒有活動寫類型。
142// 失敗的先寫原因
143function agentDetail(agent: AgentRecord): string {
144 const detail = agent.name !== null ? agent.description || UNKNOWN_TEXT : agent.activity || agent.agentType
145 return agent.phase === 'failed' ? `${agent.failureReason ?? '失敗'}:${detail}` : detail
146}
147
148function elapsedText(agent: AgentRecord, now: number): string {
149 if (agent.startedAt === null) return UNKNOWN_TEXT
150 const isLive = agent.phase === 'running' || agent.phase === 'idle'
151 const end = isLive ? now : agent.endedAt
152 return end === null ? UNKNOWN_TEXT : formatDuration(end - agent.startedAt)
153}
154
155export function sortAgents(agents: readonly AgentRecord[], blocked: ReadonlySet<string> = new Set()): AgentRecord[] {
156 const order = (agent: AgentRecord) => DISPLAY_ORDER[agentStatus(agent, blocked.has(agent.id)).label]
157 return agents
158 .map((agent, index) => ({ agent, index }))
159 .sort((left, right) => order(left.agent) - order(right.agent) || left.index - right.index)
160 .map(entry => entry.agent)
161}
162
163// 依目前的分頁畫:總覽,或只看代理、任務、事件
164export function viewRows(view: SupervisorView, model: OverviewModel, options: ViewOptions): Row[] {
165 if (view === 'agents') return agentsViewRows(model, options)
166 if (view === 'tasks') return tasksViewRows(model, options)
167 if (view === 'events') return eventsViewRows(model, options)
168 return overviewRows(model, options)
169}
170
171// 代理頁:全部代理,每個多寫 id、派出者、工具次數與最近活動,失敗的寫原因
172function agentsViewRows(model: OverviewModel, options: ViewOptions): Row[] {
173 const blockers = detectBlockers(model)
174 const blocked = blockedSubjects(blockers)
175 const rows: Row[] = [...mainRows(model.mainLoop, blocked.has('main'), options.columns), blankRow(), sectionRow(`AGENTS(${model.agents.length})`)]
176 if (model.agents.length === 0) rows.push([{ text: INDENT + EMPTY_HINT, isDim: true }])
177 for (const agent of sortAgents(model.agents, blocked)) {
178 const status = agentStatus(agent, blocked.has(agent.id))
179 const parent = agent.parentId === null ? '主對話派出' : `由 ${ownerName(agent.parentId, model.agents)} 派出`
180 const activity = agent.activity === '' ? '' : ` · 最近:${agent.activity}`
181 rows.push(
182 titleRow(agentName(agent), status, options.columns),
183 [{ text: `${INDENT}任務:${agent.description || UNKNOWN_TEXT}`, isDim: true }],
184 detailRow(`id ${agent.id} · ${parent} · ${agent.toolCount} 次工具${activity}`, elapsedText(agent, options.now), options.columns),
185 )
186 if (agent.detail !== null) rows.push([{ text: `${INDENT}${agent.detail}`, isDim: true }])
187 if (agent.phase === 'failed') rows.push([{ text: `${INDENT}原因:${agent.failureReason ?? '失敗'}`, color: 'error' }])
188 }
189 if (model.agents.some(agent => agentStatus(agent).isInferred)) rows.push(blankRow(), [{ text: INFERRED_LEGEND, isDim: true }])
190 return fitRows(rows, options.columns)
191}
192
193// 任務頁:全部任務與阻塞
194function tasksViewRows(model: OverviewModel, options: ViewOptions): Row[] {
195 const blockers = detectBlockers(model)
196 const rows: Row[] = [...tasksRows(model, blockers, { ...options, maxTasks: Number.POSITIVE_INFINITY })]
197 if (blockers.length > 0) rows.push(blankRow(), ...attentionRows(blockers, model))
198 return fitRows(rows, options.columns)
199}
200
201// 事件頁:全部事件(最多 50 筆),新的在上
202function eventsViewRows(model: OverviewModel, options: ViewOptions): Row[] {
203 const rows: Row[] = [sectionRow(`EVENTS(${model.events.length})`)]
204 if (model.events.length === 0) rows.push([{ text: `${INDENT}${UNKNOWN_TEXT}:還沒有事件`, isDim: true }])
205 rows.push(...[...model.events].reverse().map(event => eventRow(event, model.agents)))
206 return fitRows(rows, options.columns)
207}
208
209const EVENT_COLORS: Partial<Record<SupervisorEvent['kind'], string>> = { fail: 'error', 'tool-error': 'error', waiting: 'warning', complete: 'success' }
210
211function eventRow(event: SupervisorEvent, agents: readonly AgentRecord[]): Row {
212 return [
213 { text: `${formatClock(event.at)} `, isDim: true },
214 { text: `${ownerName(event.agentId ?? 'main', agents)} `, isBold: true },
215 { text: event.text, color: EVENT_COLORS[event.kind] },
216 ]
217}
218
219// 阻塞區:每一條兩列,第一列誰被誰擋住(可能的標 POSSIBLE BLOCKER),第二列原因;精簡版併成一列
220function attentionRows(blockers: readonly Blocker[], model: OverviewModel, isCompact = false): Row[] {
221 const name = (subject: string) => ownerName(subject, model.agents)
222 return [
223 sectionRow('ATTENTION'),
224 ...blockers.flatMap((blocker): Row[] => {
225 const title =
226 blocker.certainty === 'possible'
227 ? { text: `? POSSIBLE BLOCKER:${name(blocker.subject)}`, color: 'warning', isDim: true }
228 : { text: `⚠ ${name(blocker.subject)}${blocker.dependsOn === null ? '' : ` 被 ${name(blocker.dependsOn)} 擋住`}`, color: 'warning', isBold: true }
229 if (isCompact) return [[title, { text: `:${blocker.reason}`, isDim: true }]]
230 return [[title], [{ text: INDENT + blocker.reason, isDim: true }]]
231 }),
232 ]
233}
234
235// 第一列:狀態符號、名稱,狀態字靠右
236function titleRow(name: string, status: DisplayStatus, columns: number): Row {
237 const style = STATUS_STYLES[status.label]
238 const statusText = status.label + (status.isInferred ? INFERRED_MARK : '')
239 // 符號一格、空一格、名稱、至少空一格、狀態字
240 const nameColumns = Math.max(1, columns - displayWidth(statusText) - 3)
241 return [
242 { text: `${style.mark} `, color: style.color, isDim: style.isDim },
243 { text: padToWidth(fitToWidth(name, nameColumns), nameColumns + 1), isBold: true },
244 { text: statusText, color: style.color, isDim: style.isDim === true || status.isInferred },
245 ]
246}
247
248// 第二列:縮排的說明,後面接經過時間;太窄時只留說明
249function detailRow(text: string, trailing: string | null, columns: number): Row {
250 const suffix = trailing === null ? '' : ` · ${trailing}`
251 const room = columns - INDENT.length - displayWidth(suffix)
252 if (room < MIN_DETAIL_COLUMNS) return [{ text: INDENT + fitToWidth(text, columns - INDENT.length), isDim: true }]
253 return [{ text: INDENT + fitToWidth(text, room) + suffix, isDim: true }]
254}
255
256export function sectionRow(title: string): Row {
257 return [{ text: title, isBold: true, isDim: true }]
258}
259
260// 空一列放一個空白字元:空字串的 Text 可能畫不出高度
261export function blankRow(): Row {
262 return [{ text: ' ' }]
263}
264
265// 最後一道保險:整列超過寬度時,從超過的那一段截斷,後面的片段丟掉
266export function fitRows(rows: Row[], columns: number): Row[] {
267 return rows.map(row => fitRow(row, columns))
268}
269
270function fitRow(row: Row, columns: number): Row {
271 const fitted: Row = []
272 let used = 0
273 for (const span of row) {
274 const width = displayWidth(span.text)
275 if (used + width <= columns) {
276 fitted.push(span)
277 used += width
278 continue
279 }
280 const text = fitToWidth(span.text, columns - used)
281 if (text !== '') fitted.push({ ...span, text })
282 break
283 }
284 return fitted
285}
286hooks/blockers.ts 165 lines1// 阻塞偵測與下一步建議,全部是規則,不呼叫模型。
2// 確認的阻塞只來自明確的依賴與代理自己的回報;其他只能標成可能(POSSIBLE BLOCKER)。
3// 跑很久、沒有新事件、Claude Code 清單上的 waiting、還沒開始的任務在等依賴,都不算阻塞
4import type { AgentRecord, TaskRecord, WaitingReports } from '../types'
5import { isFinished } from './agents'
6import { agentName, ownerName } from './names'
7
8export type BlockerSource = 'dependency' | 'report' | 'missing-task' | 'stale-owner' | 'tool-errors'
9export type Blocker = {
10 certainty: 'confirmed' | 'possible'
11 source: BlockerSource
12 // 被擋住的一方:主對話是 'main',代理是 agentId(任務負責人對不到代理時是原樣的名字)
13 subject: string
14 // 擋路的一方(知道的話)
15 dependsOn: string | null
16 reason: string
17 // 相關的任務:被擋的任務;stale-owner 是擋路的任務
18 taskId: string | null
19 // 擋路的任務(知道的話),建議用它寫「先完成哪一項」
20 blockingTaskId: string | null
21}
22export type SupervisionModel = { agents: readonly AgentRecord[]; tasks: readonly TaskRecord[]; waiting: WaitingReports }
23
24const TOOL_ERROR_THRESHOLD = 3
25const MAX_SUGGESTIONS = 2
26
27function isLive(subject: string, agents: readonly AgentRecord[]): boolean {
28 if (subject === 'main') return true
29 const agent = agents.find(candidate => candidate.id === subject)
30 return agent !== undefined && (agent.phase === 'running' || agent.phase === 'idle')
31}
32
33export function detectBlockers(model: SupervisionModel): Blocker[] {
34 const reported = waitingBlockers(model)
35 const reportedSubjects = new Set(reported.map(blocker => blocker.subject))
36 // 同一方自己回報了在等什麼,就不再列依賴推出來的那一條(回報的原因比較準)
37 const dependencies = dependencyBlockers(model).filter(blocker => blocker.certainty === 'possible' || !reportedSubjects.has(blocker.subject))
38 const all = [...reported, ...dependencies, ...toolErrorBlockers(model)]
39 return [...all.filter(blocker => blocker.certainty === 'confirmed'), ...all.filter(blocker => blocker.certainty === 'possible')]
40}
41
42function dependencyBlockers({ agents, tasks }: SupervisionModel): Blocker[] {
43 const byId = new Map(tasks.map(task => [task.id, task]))
44 const blockers: Blocker[] = []
45 const seenStale = new Set<string>()
46 for (const task of tasks) {
47 if (task.status === 'completed') continue
48 for (const dependencyId of task.blockedBy) {
49 const dependency = byId.get(dependencyId)
50 if (dependency === undefined) {
51 blockers.push({
52 certainty: 'possible',
53 source: 'missing-task',
54 subject: task.owner,
55 dependsOn: null,
56 reason: `「${task.title}」依賴的任務 ${dependencyId} 沒有人回報過`,
57 taskId: task.id,
58 blockingTaskId: null,
59 })
60 continue
61 }
62 if (dependency.status === 'completed') continue
63 const dependencyOwner = agents.find(agent => agent.id === dependency.owner)
64 if (dependencyOwner !== undefined && isFinished(dependencyOwner)) {
65 const key = `${task.owner}:${dependency.id}`
66 if (seenStale.has(key)) continue
67 seenStale.add(key)
68 blockers.push({
69 certainty: 'possible',
70 source: 'stale-owner',
71 subject: task.owner,
72 dependsOn: dependency.owner,
73 reason: `「${dependency.title}」的負責人已結束,但任務沒標成完成`,
74 taskId: dependency.id,
75 blockingTaskId: dependency.id,
76 })
77 continue
78 }
79 // 還沒開始的任務在等依賴是正常的先後順序;已經在做卻還有沒完成的依賴才是卡住
80 if (task.status !== 'in_progress') continue
81 blockers.push({
82 certainty: 'confirmed',
83 source: 'dependency',
84 subject: task.owner,
85 dependsOn: dependency.owner,
86 reason: `「${task.title}」要等「${dependency.title}」`,
87 taskId: task.id,
88 blockingTaskId: dependency.id,
89 })
90 }
91 }
92 return blockers
93}
94
95// 還沒結束的代理(或主對話)自己回報在等什麼;它的任務有沒完成的依賴時,指向那個依賴的負責人
96function waitingBlockers({ agents, tasks, waiting }: SupervisionModel): Blocker[] {
97 return Object.entries(waiting)
98 .filter(([reporter]) => isLive(reporter, agents))
99 .map(([reporter, report]) => {
100 const blockedTask = tasks.find(task => task.owner === reporter && task.status !== 'completed' && unmetDependency(task, tasks) !== undefined)
101 const dependency = blockedTask === undefined ? undefined : unmetDependency(blockedTask, tasks)
102 return {
103 certainty: 'confirmed' as const,
104 source: 'report' as const,
105 subject: reporter,
106 dependsOn: dependency === undefined || dependency.owner === reporter ? null : dependency.owner,
107 reason: report.text,
108 taskId: blockedTask?.id ?? null,
109 blockingTaskId: dependency?.id ?? null,
110 }
111 })
112}
113
114function unmetDependency(task: TaskRecord, tasks: readonly TaskRecord[]): TaskRecord | undefined {
115 return task.blockedBy.map(id => tasks.find(other => other.id === id)).find(other => other !== undefined && other.status !== 'completed')
116}
117
118function toolErrorBlockers({ agents }: SupervisionModel): Blocker[] {
119 return agents
120 .filter(agent => agent.phase === 'running' && agent.toolErrorStreak >= TOOL_ERROR_THRESHOLD)
121 .map(agent => ({
122 certainty: 'possible' as const,
123 source: 'tool-errors' as const,
124 subject: agent.id,
125 dependsOn: null,
126 reason: `連續 ${agent.toolErrorStreak} 次工具失敗${agent.lastToolError === null ? '' : `:${agent.lastToolError}`}`,
127 taskId: null,
128 blockingTaskId: null,
129 }))
130}
131
132// 確認被擋住的一方:面板上這些代理(或主對話)顯示 BLOCKED;可能的阻塞不改狀態
133export function blockedSubjects(blockers: readonly Blocker[]): Set<string> {
134 return new Set(blockers.filter(blocker => blocker.certainty === 'confirmed').map(blocker => blocker.subject))
135}
136
137// 規則式的下一步建議,依重要性排,最多兩則;沒有足夠資訊時不給
138export function suggestActions(model: SupervisionModel, blockers: readonly Blocker[]): string[] {
139 const name = (subject: string) => ownerName(subject, model.agents)
140 const taskOf = (id: string | null) => (id === null ? undefined : model.tasks.find(task => task.id === id))
141 const suggestions: string[] = []
142 for (const blocker of blockers) {
143 const blocking = taskOf(blocker.blockingTaskId)
144 if (blocker.source === 'stale-owner' && blocking !== undefined) {
145 suggestions.push(`「${blocking.title}」的負責人 ${name(blocking.owner)} 已結束:確認是否完成,或改派`)
146 } else if ((blocker.source === 'dependency' || blocker.source === 'report') && blocking !== undefined) {
147 suggestions.push(`先完成「${blocking.title}」(${name(blocking.owner)}),${name(blocker.subject)} 才能繼續`)
148 } else if (blocker.source === 'report') {
149 suggestions.push(`處理 ${name(blocker.subject)} 在等的事:${blocker.reason}`)
150 }
151 }
152 for (const agent of model.agents) {
153 if (agent.phase === 'failed') suggestions.push(`查看 ${agentName(agent)} 失敗的原因(${agent.failureReason ?? '失敗'}),決定要不要重派`)
154 }
155 for (const blocker of blockers) {
156 if (blocker.source === 'tool-errors') suggestions.push(`看一下 ${name(blocker.subject)} 的工具錯誤:${blocker.reason}`)
157 }
158 const isAllDone =
159 model.tasks.length > 0 &&
160 model.tasks.every(task => task.status === 'completed') &&
161 !model.agents.some(agent => agent.phase === 'running' || agent.phase === 'idle')
162 if (isAllDone) suggestions.push('全部任務完成:整理結果')
163 return [...new Set(suggestions)].slice(0, MAX_SUGGESTIONS)
164}
165hooks/names.ts 15 lines1// 面板與建議裡怎麼稱呼代理與負責人
2import type { AgentRecord } from '../types'
3
4// 標題:Agent({ name }) 給的名字;這個環境的 Agent 工具通常沒有 name,就用任務說明,都沒有才用類型
5export function agentName(agent: AgentRecord): string {
6 return agent.name ?? (agent.description || agent.agentType)
7}
8
9// 負責人顯示名稱:主對話是 Supervisor,代理用面板上的標題,對不到的原樣寫
10export function ownerName(owner: string, agents: readonly AgentRecord[]): string {
11 if (owner === 'main') return 'Supervisor'
12 const agent = agents.find(candidate => candidate.id === owner)
13 return agent === undefined ? owner : agentName(agent)
14}
15hooks/text.ts 49 lines1// 東亞寬字元、全形符號與 emoji 在終端機佔兩格;範圍用 \u 跳脫,避免存檔時字元被正規化成別的碼位
2const WIDE_CHAR = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{1F300}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u
3
4function charWidth(char: string) {
5 return WIDE_CHAR.test(char) ? 2 : 1
6}
7
8export function displayWidth(text: string): number {
9 let width = 0
10 for (const char of text) width += charWidth(char)
11 return width
12}
13
14// 超過寬度時依顯示寬度截斷,補「…」;中文一個字算兩格
15export function fitToWidth(text: string, maxColumns: number): string {
16 if (maxColumns <= 0) return ''
17 if (displayWidth(text) <= maxColumns) return text
18 let kept = ''
19 let width = 0
20 for (const char of text) {
21 if (width + charWidth(char) > maxColumns - 1) break
22 kept += char
23 width += charWidth(char)
24 }
25 return `${kept}…`
26}
27
28// 補空白到指定的顯示寬度;已經超過就原樣回傳
29export function padToWidth(text: string, columns: number): string {
30 return text + ' '.repeat(Math.max(0, columns - displayWidth(text)))
31}
32
33// 經過時間:「45秒」「2分05秒」「1小時02分」,跟 clean-view 的寫法一樣
34export function formatDuration(ms: number): string {
35 const totalSeconds = Math.max(0, Math.floor(ms / 1000))
36 const hours = Math.floor(totalSeconds / 3600)
37 const minutes = Math.floor((totalSeconds % 3600) / 60)
38 const seconds = totalSeconds % 60
39 if (hours > 0) return `${hours}小時${String(minutes).padStart(2, '0')}分`
40 if (minutes > 0) return `${minutes}分${String(seconds).padStart(2, '0')}秒`
41 return `${seconds}秒`
42}
43
44// 事件的時間:本地時間的 時:分:秒
45export function formatClock(at: number): string {
46 const date = new Date(at)
47 return [date.getHours(), date.getMinutes(), date.getSeconds()].map(part => String(part).padStart(2, '0')).join(':')
48}
49types/index.d.ts 84 lines1// 面板目前顯示的頁面:總覽,或只看代理、任務、事件
2export type SupervisorView = 'overview' | 'agents' | 'tasks' | 'events'
3
4// running:正在跑一輪;idle:這一輪結束、等訊息喚醒或等背景工作;unknown:清單上找不到又沒收到結束事件
5export type AgentPhase = 'running' | 'idle' | 'completed' | 'failed' | 'unknown'
6
7// 狀態從哪裡來:event 是派出、工具呼叫、結束等事件直接確認;engine 是 Claude Code 的代理清單($.agent.list())回報;
8// inferred 是推測,畫面上要跟確認的分開標示
9export type StatusSource = 'event' | 'engine' | 'inferred'
10
11export type AgentRecord = {
12 id: string
13 // Agent({ name }) 給的名字;沒給是 null
14 name: string | null
15 // 派出時的 subagentType(general-purpose、Explore…)
16 agentType: string
17 // 派出時 Agent 工具的 description:這個代理的任務
18 description: string
19 // 由哪個代理派出;主對話派的是 null
20 parentId: string | null
21 phase: AgentPhase
22 source: StatusSource
23 // Claude Code 細分的狀態說明,例如 waiting 時的「等待背景工作或核准」
24 detail: string | null
25 // 已中斷、API 錯誤、模型拒絕、已停止
26 failureReason: string | null
27 // 開面板前就在跑、從清單補上的不知道開始時間,是 null
28 startedAt: number | null
29 endedAt: number | null
30 // 最近一次可觀察到的活動,例如「讀取 src/app.ts」「思考中」
31 activity: string
32 activityAt: number | null
33 toolCount: number
34 // 連續失敗或被拒絕的工具呼叫次數,成功一次就歸零
35 toolErrorStreak: number
36 lastToolError: string | null
37}
38
39// 主對話(畫面上的 Supervisor 那一列)
40export type MainLoop = {
41 isRunning: boolean
42 activity: string
43 activityAt: number | null
44}
45
46export type TaskStatus = 'pending' | 'in_progress' | 'completed'
47
48// 代理回報(report 工具)或 Claude Code 原生任務工具(TaskCreate/TaskUpdate/TodoWrite)留下的任務;不自行編造
49export type TaskRecord = {
50 // 回報的 id;原生 TaskCreate 的 id 加「#」,TodoWrite 的是「回報者:todo:序號」
51 id: string
52 title: string
53 status: TaskStatus
54 // 負責人:主對話是 'main',代理是 agentId;回報的名字對不到任何代理時原樣保留
55 owner: string
56 // 要先完成的任務 id
57 blockedBy: string[]
58 source: 'report' | 'native'
59 updatedAt: number
60}
61
62// 代理或主對話回報「在等什麼」(report 工具的 waitingFor);鍵是回報者:'main' 或 agentId
63export type WaitingReports = Record<string, { text: string; at: number }>
64
65export type EventKind = 'spawn' | 'complete' | 'fail' | 'tool-error' | 'task' | 'waiting' | 'resolved'
66
67// 事件串的一筆:時間、哪個代理(主對話是 'main')、種類、一句話說明。只留最近 50 筆
68export type SupervisorEvent = { at: number; agentId: string | null; kind: EventKind; text: string }
69
70declare module 'claude-code' {
71 interface PluginState {
72 'agent-supervisor': {
73 view: SupervisorView
74 agents: AgentRecord[]
75 mainLoop: MainLoop
76 tasks: TaskRecord[]
77 waiting: WaitingReports
78 events: SupervisorEvent[]
79 // 計時器每一拍加一,只有面板讀,用來更新經過時間
80 tick: number
81 }
82 }
83}
84