在 Claude Code 聊天面板即時顯示子 agent 狀態的看板(試作版)。

讓 Claude Code 像一個小團隊一樣分工:Opus 當主管負責想,Sonnet 和 Haiku 負責動手;對話變長時先寫交接筆記,清空後照筆記接著做。
A Claude Code plugin for tiered model delegation — Opus plans, Sonnet executes, Haiku retrieves and digests bulk output — plus context-length reminders with handoff notes so the main conversation stays short, and persistent, source-cited memory that accumulates across conversations. English documentation: README.en.md.
目的:用同樣的預算做更多事,而且長時間工作也不會因為對話越拉越長而變貴、變亂。
為什麼用它
/clear 或隔天開新對話,進度都會自動接回來。usage-stats 可以量出主對話 context 長度、每個 agent 用了多少 token、依 API 牌價估算的金額,也能輸出一份有每日圖表的 HTML 報表。agent-board 看板常駐在輸入框上方,即時列出每個子 agent 的類型、模型、狀態、經過時間、token 與估計金額。reply-detail 讓回覆預設只講現況、你要做的、需要你決定的事;要細節時用看板的「⇄ 回覆:精簡/詳細」切換鍵,切到詳細後每則都寫詳細版。用 Claude Code 一陣子,常會遇到兩件事:
這個 plugin 的做法像一位主管帶團隊:主管(Opus)聽你說、想清楚、決定派給誰;動手的工作交給組員(Sonnet、Haiku)在各自的桌上做完,只把結論交回來,主管的桌面因此保持乾淨。桌面快滿時,主管先寫好交接筆記,清掉桌面後照筆記繼續做。
適合:已經在用 Claude Code、常做多步驟工作(改程式、讀 log、查資料),想省錢或不想讓長對話失控的人。只偶爾問一兩句話的話,分工的好處不明顯。
在 Claude Code 裡輸入:
/plugin install smart-agent-route --marketplace Novie131/smart-agent-route
/reload-plugins
(Claude Code 2.1.275 以前:先 /plugin marketplace add Novie131/smart-agent-route,再 /plugin install smart-agent-route@smart-agent-route。)
裝完就能用。接著建議做兩件事:
/model opus。plugin 不會自動改。/smart-agent-route:route-setup,可以設定 context 狀態列、自動壓縮門檻、Haiku 版 Explore、claude-memory/ 記憶庫。它一次只問一項,每項都說明做什麼、影響、怎麼還原,可以隨時略過;寫 settings.json 前會備份,已有的值不覆蓋。沒做的話,session 開始時會提醒你(最多 3 次)。context 長度提醒需要 python3,沒有的話會安靜停用。不想用 marketplace,見其他安裝方式。
照平常講話就好,主對話會自己判斷要不要派工:
| 你說 | 誰處理 |
|---|---|
| 「幫我想清楚這該怎麼做」 | 主對話(Opus)自己想 |
| 「照剛才講的做」 | worker(Sonnet)動手,做完先驗證再回報 |
| 「看一下這 18 張截圖有沒有跑版」「這份 log 為什麼失敗」 | bulk(Haiku)讀完只回結論 |
| 「我之前有記過 X 嗎」 | librarian(Haiku)查記憶,附出處 |
| 「這個 API 新版怎麼設定」 | research skill 查外部資料,回答附來源,官方資料存進 claude-memory/web/ |
| 「交接一下」 | handoff skill 寫 handoff.md,之後可以 /clear |
想強制指定某個 agent,用 @agent-smart-agent-route:worker。
對話變長時(預設超過 10 萬 token)你會看到提醒,Claude 會先把目前進度寫進 handoff.md。之後不管是自動壓縮、/clear 還是隔天開新對話,交接筆記都會自動帶回來,不用重新解釋一次。
handoff。你 ──► 主對話(Opus) ← 大門。理解、判斷、決定派給誰
│
├──► worker(Sonnet) ← 方向已定、動手做的事
│ └► worker-hard ← worker 失敗後的升級
├──► bulk(Haiku) ← 大量讀取只要結論:圖片、長 log、網頁
├──► librarian(Haiku) ← 查知識庫。唯讀
└──► harvester(Sonnet) ← 挖歷史對話進知識庫。手動觸發
skill:handoff(交接筆記)、research(查外部資料並附來源)、usage-stats(量測 context 與子 agent 用量)、route-setup(一次性完整設定)
hook :session 開始帶入分工慣例與記憶;context 過長時提醒
| 元件 | 模型 | 做什麼 |
|---|---|---|
| worker | Sonnet,effort medium | 方向已定的實作:寫程式、修 bug、補測試。回報完成前必須先驗證 |
| worker-hard | Sonnet,effort high | 只在 worker 失敗後使用,派工時必須附上前次失敗報告 |
| bulk | Haiku | 大量讀取、只回結論:大量圖片、長 log、網頁、輸出很多的指令,以及機械式的批次修改 |
| librarian | Haiku,effort low | 查知識庫與記憶。只有 Read、Glob、Grep,什麼都改不了;回傳原文片段與來源,並說明搜過哪些詞、什麼沒找到 |
| harvester | Sonnet | 把歷史對話逐字稿挖進知識庫筆記。只在你明確要求時才跑,成本不低 |
| Explore(選裝) | Haiku | 內建 Explore 在主對話是 Opus 時也跑 Opus。plugin 蓋不掉它(只有使用者或專案層級、名為 Explore 的 agent 能覆蓋),所以用 route-setup 或 bash install.sh --with-explore 把 Haiku 版裝進 ~/.claude/agents/ |
handoff 寫 handoff.md;research 查外部資料並附來源;usage-stats 量測主對話 context 長度、各 agent 的派工次數、token 用量與估計金額(唯讀;加 --html 可輸出圖表報表);route-setup 是一次性完整設定。主對話怎麼分工,不靠你的 CLAUDE.md:SessionStart hook 會在每個 session 開始時把分工表帶進去(內容見 hooks/guide.md)。設計理由與取捨見 docs/spec.md。
路由發生在 Opus 之後,不是之前。 判斷意圖就得先讀懂訊息,而讀懂就等於規劃了,所以沒辦法在前面放一個便宜的路由器。Opus 永遠是入口,由它派工。
省下的錢來自四個地方,照影響大小排序:
effort 等級。 worker 用 medium,失敗才升級到 worker-hard(high);librarian 用 low。/clear 仍然最省。子 agent 收工時用固定的回報格式,主對話只看前兩行就能決定下一步:狀態行(完成、失敗、需要決策)與驗證行(通過、未驗證、失敗)。「完成」只能搭配「驗證通過」。遇到需要決策的岔路時,子 agent 會停下來回報有哪些選項與各自的影響,不會自己選一條路走完。
失敗升級依序:補背景後同一個 agent 重派 → bulk 失敗改 worker → worker 失敗改 worker-hard(附前次失敗報告)→ 仍失敗就停下來重新規劃或問你。不會無限升級。
CONTEXT_REMIND_TOKENS 調整。handoff.md(目前任務 / 已完成 / 下一步 / 未決問題)。autoCompactWindow 時,提醒只會告訴你約多少 k 會自動壓縮、壓縮後會帶回交接筆記;沒設才給可複製的指令:告一段落用 /clear、做到一半用 /compact 並指定重點、後半段是除錯雜訊就用 /rewind 局部摘要。/clear、新 session 開始時,SessionStart hook 自動帶入 handoff.md:專案有 claude-memory/ 就用它,否則用 transcript 旁 auto memory 目錄的 handoff.md(Claude Code 只會自動帶入 auto memory 的 MEMORY.md 索引,不會帶 handoff.md)。建議在 ~/.claude/settings.json 把自動壓縮當保險(plugin 不能代設,/smart-agent-route:route-setup 會幫你安全地寫入):
"autoCompactWindow": 153000
扣掉約 33k 的 buffer 後,約在 12 萬自動壓縮,在 10 萬提醒之後留 2 萬緩衝寫 handoff。33k 是作者機器的實測值(不確定是否固定);寫入後請用 /context 確認 Autocompact buffer 約 33k、壓縮點約 120k(有 modelSettings 逐模型覆蓋時要一併看)。
顯示 模型 │ ctx 95k │ 提醒 100k │ 壓縮 120k,壓縮點讀自 autoCompactWindow(沒設就不顯示)。plugin 不能設定主狀態列:plugin 安裝的使用者請用 /smart-agent-route:route-setup,它會把檔案放到固定位置;install.sh 安裝的用 bash install.sh --with-statusline,或手動貼進 settings.json:
"statusLine": { "type": "command", "command": "python3 ~/.claude/hooks/smart-agent-route/statusline.py" }
兩個選裝的 mod(Claude Code 的介面擴充,需要 2.1.299 以上;作者在 VS Code 擴充 2.1.301 實測):
| mod | 做什麼 |
|---|---|
agent-board | 常駐面板列出每個子 agent 的類型、模型、狀態、經過時間、token 與估計金額(API 牌價),上方顯示合計。可以縮小成一行摘要,用 /agent-board 開關;底部有「⇄ 回覆:精簡/詳細」切換鍵 |
reply-detail | 在 system prompt 注入回覆格式:預設精簡三段(現況/你要做的/需要你決定或提供),只對主對話生效。看板的切換鍵切到「詳細」後,之後每則回覆都是詳細版,直到切回精簡;只要單次詳細版時,直接輸入「詳細說明」就會把上一則改寫成詳細版 |
安裝:用 plugin 市集的話是 /plugin install agent-board@smart-agent-route、/plugin install reply-detail@smart-agent-route;用 install.sh 的話加 --with-agent-board、--with-reply-detail(VS Code 不能帶 --plugin-dir,腳本會把目錄加進 settings.json 的 env.CLAUDE_CODE_PLUGIN_DIRS)。裝完在 VS Code 執行 Developer: Reload Window。
agent-board 的設定(plugin 的 userConfig,預設都開):autoOpen 子 agent 啟動時自動開面板、persistent 對話一開始就開且不自動收起、detailButton 看板底部的回覆模式切換鍵(名稱沿用舊版)。切換鍵切到詳細時,看板會在你送出的每則訊息附上「這一輪寫詳細版」的要求;reload 後回到精簡。
限制:mod 不能在 VS Code 輸入框的工具列(模型選單那一排)加按鈕,所以切換鍵放在看板裡。回覆訊息底下和輸入框上方的橫條也能放,但 VS Code 會替 mod 畫的這些區塊加上外框和「From the … plugin」標籤(mod 關不掉),所以不採用。另外,Claude Code 對不是使用者自己打開的面板有寬度門檻(沒手動開過要 144 欄),VS Code 聊天面板常常不夠寬,面板會停在看不見的「未放置」狀態;常駐模式會在畫面重連、寬度改變、回合開始與結束時檢查並補開。
想知道每一輪固定要付多少(系統提示、工具定義、MCP、skills、記憶檔),在對話裡輸入 /context,或在 terminal 跑 claude -p "/context"(不用開互動對話,在本機計算、不呼叫模型)。作者機器上新對話的固定成本約 29k token,其中約 18k 是 Claude Code 內建的系統提示與工具定義;標為 deferred 的工具用到才載入,不計入。固定內容每輪都會命中快取,通常不是大頭,先處理對話長度與輸出,再考慮拔掉不用的 MCP 或 plugin。
<你的資料夾>/claude-memory/
├── MEMORY.md ← 常駐索引,session 開始時由 hook 帶入,預算 40 行
├── handoff.md ← 交接筆記,下個 session 自動帶入
├── topics/ ← agent 累積的知識
├── kb/ ← 你自己的筆記
└── web/ ← research 存的外部資料筆記(附來源與過期日)
沒有 claude-memory/ 時,記憶退回 Claude Code 自己的 auto memory,一樣可用;claude-memory/ 是進階選項,好處是可以進 git、跨環境共用。
MEMORY.md 只放觸發條件,知識本體放主題檔。這讓成本跟「這次用得到多少」成正比,而不是「總共記得多少」:知識可以無限成長,每次啟動的固定成本不變。索引寫成條件,不是主題:
setup.md — 環境設定相關(這是主題)setup.md — 要改路徑或指令跑不起來時先讀(這是條件)記憶放在哪裡:
claude-memory/,找不到才退回自己的 agent 記憶目錄(~/.claude/agent-memory/worker/)。autoMemoryEnabled 或設了 CLAUDE_CODE_DISABLE_AUTO_MEMORY,memory 欄位不會生效,連接資料夾就從選用變成必要。.md。請開新的專用 vault,不要指向你現有的個人筆記,因為 agent 有寫入權。保持 MEMORY.md 薄很重要:超過上限會被截斷(hook 最多帶入 120 行,auto memory 200 行),而且常駐內容是每個 session 都付的固定成本。它會腐化,需要定期整理;超過 150 行就是警訊。
餵知識庫有兩種方式:自己把 .md 放進 kb/(不用任何前處理,librarian 立刻找得到),或叫 harvester 去挖歷史對話(它會先列出可讀的 session 讓你選範圍)。先講清楚四個限制:
kb/,而那原本是你自己的東西。如果你在意這條界線,先叫它寫到 kb/harvested/。第一次安裝後跑一次,把已經累積的東西撈起來當起點;之後 worker 收工時會即時寫記憶,就很少需要再跑。
librarian 用關鍵詞搜尋與條件判斷做檢索。個人規模(數十到數百篇)這樣通常比向量好:不用多一套基礎設施、檢索失敗看得出原因、不會因為切塊破壞 Markdown 的結構。而且它會換同義詞、換語言、改搜檔名重試,固定的向量查詢做不到。
上千篇或大量 PDF、長逐字稿才值得考慮向量。屆時 kb/ 的 markdown 直接當語料。
skills/route-setup/templates/runner.md.example 是預留的位置,刻意不載入。Haiku 層是唯一真正省錢、也唯一真正有風險的一層:它不會停下來問你,會照著它以為的意思安靜做完。所以每個 Haiku agent 都必須綁死在一件具體任務上。
三個條件同時成立才啟用:
任何一項不成立,就繼續留在 worker。檢索(librarian)是例外,因為它是唯讀的。
不想用 marketplace 的話,repo 內附 install.sh,或直接 claude --plugin-dir ./plugins/smart-agent-route。
bash install.sh # 複製安裝(agent、skill、hook 放進 ~/.claude/,沒有命名空間前綴;重跑即更新)
bash install.sh --agents-only # 只複製 agent(hook 與 skill 不會生效)
bash install.sh --link # 用符號連結裝 agent、skill、hook(改 repo 立即生效)
bash install.sh --uninstall # 移除(依安裝清單,不動你自己的檔案)
bash install.sh --with-explore # 另外安裝 Haiku 版 Explore 到 ~/.claude/agents/
bash install.sh --with-statusline # 另外在 ~/.claude/settings.json 加 context 狀態列
bash install.sh --with-agent-board # 另外載入子 agent 看板 mod(寫入 settings.json 的 env.CLAUDE_CODE_PLUGIN_DIRS)
bash install.sh --with-reply-detail # 另外載入回覆格式 mod(同上)
注意事項:
worker,skill 是 /handoff(plugin 安裝則是 smart-agent-route:worker)。~/.claude/.smart-agent-route-installed。內容完全相同的既有檔案會併入清單;內容不同的(你自己的)不會覆蓋,並給警告。--with-statusline 可與預設的複製模式或 --link 併用,不能與 --agents-only、--uninstall 併用。它只動 settings.json 的 statusLine 這個 key:檔案不存在就建立,已有 statusLine 絕不覆蓋(只印出建議片段),其餘情況寫入前先備份成 settings.json.bak-<timestamp>。JSON 壞掉或沒有 python3 時什麼都不做。--with-agent-board、--with-reply-detail 的限制同 --with-statusline,兩者可以同時使用。它們只動 env.CLAUDE_CODE_PLUGIN_DIRS:已有的路徑保留,寫入前先備份;--uninstall 只移除自己加的路徑。settings.json 裡的 hooks,結尾會印出一段片段,由你自己併進 "hooks"。之後執行 /route-setup 取得完整功能。更多說明見 docs/部署安裝.md與 plugins/smart-agent-route/INSTALL.md(英文版:INSTALL.en.md)。
smart-agent-route/
├── .claude-plugin/marketplace.json ← 市集目錄
├── plugins/smart-agent-route/ ← plugin 本體
│ ├── .claude-plugin/plugin.json
│ ├── agents/ ← worker / worker-hard / bulk / librarian / harvester
│ ├── skills/ ← handoff / research / usage-stats / route-setup
│ ├── hooks/ ← SessionStart 帶入、context 長度提醒
│ (範本在 skills/route-setup/templates/:MEMORY.md、知識庫說明、Explore、Haiku 層範本)
├── plugins/agent-board/ ← 子 agent 看板 mod(選裝)
├── plugins/reply-detail/ ← 回覆格式 mod(選裝)
├── docs/
│ ├── spec.md ← 架構設計與實作附錄
│ ├── 部署安裝.md
│ └── 發佈到-GitHub.md
├── claude-memory/ ← 記憶庫(不進版控)
└── install.sh ← 不用 marketplace 的安裝方式
v0.8.0 — 早期版本。 0.7.0 以前叫 tiered-agents,升級請先移除舊版(見 docs/部署安裝.md)。 context 提醒 hook、handoff、bulk、Explore 覆蓋版移植自作者的個人設定:hook 已在真實對話中觸發、Explore 與 bulk 已確認跑在 Haiku,但都還在驗證期。worker-hard 升級鏈、research skill 與 plugin 形式的 hook 尚未實際使用。agent-board 與 reply-detail 已在 VS Code 擴充 2.1.301 實測過看板;常駐模式、看板裡的按鈕與回覆格式注入還在驗證。
已知會需要調整的地方:
description 匹配,不是保證。要確定就 @agent-smart-agent-route:workerMEMORY.md 會腐化,需要你定期整理(超過 150 行即是訊號)plugins/smart-agent-route/skills/route-setup/templates/runner.md.example 的啟用條件harvester 只讀得到本機 session歡迎開 issue。
MIT
hooks/register.js 233 lines1import {
2 agentLine,
3 countActive,
4 isActive,
5 layoutRow,
6 recordUsage,
7 shouldAutoOpen,
8 shouldStayOpen,
9 showDetailButton,
10 DETAIL_MODE_CONTEXT,
11 replyModeLabel,
12 sortAgents,
13 summarizeUsage,
14 collapsedLabel,
15} from './board-logic.js'
16
17const PANE = 'agent-board'
18
19// 計時器與快取在模組層級;reload 時 register 會重跑,狀態會歸零
20let timer = null
21let agents = []
22let nowMs = 0
23// 面板縮小時只畫一顆按鈕;記住使用者的選擇,自動開啟時沿用
24let collapsed = false
25// 依 agentId 累計的 token/金額(來自 turn.step 的 usage)
26const usageByAgent = new Map()
27// 依 agentId 記開始/結束時間與 spawn 解析後的模型:{ start, end, model }
28const metaByAgent = new Map()
29// 使用者用 /agent-board 關掉後,常駐模式就不再自動重開,直到再用 /agent-board 打開
30let userClosed = false
31// 回覆模式:'concise'(預設,照 reply-detail 的精簡版)或 'detail'(每則回覆都寫詳細版);reload 後回到精簡
32let replyMode = 'concise'
33
34// 面板要真的放上畫面才算開著:$.ui.panes() 也會列出等待放置(isPlaced: false)的面板
35async function isShown($) {
36 const panes = await $.ui.panes()
37 return panes.some((p) => p.id === PANE && p.isPlaced !== false)
38}
39
40// 常駐模式:面板不在畫面上就重開。
41// 引擎對非使用者觸發的 ui.open 有寬度門檻(沒手動開過要 144 欄、開過要 110 欄),
42// VS Code 的聊天面板常常不夠寬,session.start 開的面板就停在未放置;
43// 畫面重連、寬度改變、回合開始與結束時都檢查一次,其中 turn.start 由使用者送出訊息觸發,不受門檻限制
44async function ensureOpen($, options) {
45 if (!shouldStayOpen(options) || userClosed) return
46 try {
47 if (!(await isShown($))) await $.ui.open({ id: PANE, title: '子 agent' })
48 } catch (err) {
49 $.ui.log('agent-board:' + err.message, { to: 'debug' })
50 }
51}
52
53// 重新讀 agent 清單、更新狀態列;有進行中的 agent 就確保計時器在跑,沒有就停掉
54async function refresh($) {
55 agents = await $.agent.list()
56 nowMs = await $.clock.now()
57 // 第一次看到不再進行中的 agent 時記下結束時間,之後經過時間就不再增加
58 for (const a of agents) {
59 const m = metaByAgent.get(a.id)
60 if (m && m.end === undefined && !isActive(a)) m.end = nowMs
61 }
62 // 狀態列附上開啟方式:面板關掉後沒有按鈕能再打開,只能靠 /agent-board
63 const line = summarizeUsage(agents, usageByAgent)
64 $.ui.status(line && line + '・/agent-board 開關')
65 $.ui.invalidate('ui.render')
66 if (countActive(agents) > 0) {
67 if (timer === null) {
68 timer = $.clock.every(1000, () => refresh($))
69 }
70 } else if (timer !== null) {
71 timer.cancel()
72 timer = null
73 }
74}
75
76export function register(on, options) {
77 on('session.start', async ($, e, next) => {
78 await $.command.register({
79 name: 'agent-board',
80 description: '開關子 agent 狀態看板',
81 // 子 agent 在跑時主對話的 turn 通常還沒結束,不設 immediate 會等到 turn 結束才開
82 immediate: true,
83 })
84 // 常駐模式:一開始就開面板(不帶 focus),沒有子 agent 時顯示「目前沒有子 agent」
85 await ensureOpen($, options)
86 return next(e)
87 })
88
89 // 畫面(VS Code webview、終端機)連上或寬度改變時,補開沒放上畫面的面板
90 on('session.attach', async ($, e, next) => {
91 const result = await next(e)
92 await ensureOpen($, options)
93 return result
94 })
95 on('session.measure', async ($, e, next) => {
96 const result = await next(e)
97 await ensureOpen($, options)
98 return result
99 })
100
101 // 子 agent 即將啟動:先開面板(不帶 focus)再交出事件,
102 // 因為文件沒寫 next 是在 agent 開始還是結束時返回,先開才保證是即時的
103 on('agent.spawn', async ($, e, next) => {
104 // 看板出錯不能影響子 agent 本身的啟動
105 try {
106 if (shouldAutoOpen(options)) {
107 await $.ui.open({ id: PANE, title: '子 agent' })
108 }
109 } catch (err) {
110 $.ui.log('agent-board:' + err.message, { to: 'debug' })
111 }
112 let startMs
113 try {
114 startMs = await $.clock.now()
115 } catch (err) {
116 $.ui.log('agent-board:' + err.message, { to: 'debug' })
117 }
118 const result = await next(e)
119 try {
120 if (result && result.agentId) {
121 metaByAgent.set(result.agentId, { start: startMs, end: undefined, model: result.model })
122 }
123 await refresh($)
124 // agent 可能還沒出現在清單上,稍後補一次,避免計時器因「沒有進行中」而停掉
125 $.clock.after(1500, () => refresh($))
126 } catch (err) {
127 $.ui.log('agent-board:' + err.message, { to: 'debug' })
128 }
129 return result
130 })
131
132 // 子 agent 的每次模型請求:完成後把 usage 累加起來。主對話(沒有 agentId)直接放行。
133 // 記錄出錯不能影響請求,結果一定原樣回傳。只讀 usage,不呼叫模型、不寫對話紀錄。
134 on('turn.step', async function* ($, e, next) {
135 const result = yield* next(e)
136 try {
137 if (e.agentId && recordUsage(usageByAgent, e.agentId, result && result.usage, e.model)) {
138 $.ui.invalidate('ui.render')
139 }
140 } catch (err) {
141 // 不能讓看板的錯誤影響請求
142 }
143 return result
144 })
145
146 // 使用者送出下一則訊息時,若沒有進行中的 agent 就收起面板(常駐模式不收)
147 on('turn.start', async ($, e, next) => {
148 if (shouldStayOpen(options)) {
149 await ensureOpen($, options)
150 return next(e)
151 }
152 agents = await $.agent.list()
153 if (countActive(agents) === 0) {
154 await $.ui.close({ id: PANE })
155 }
156 return next(e)
157 })
158
159 // 回合結束時再檢查一次(例如回合中途畫面重連過)
160 on('turn.complete', async ($, e, next) => {
161 const result = await next(e)
162 await ensureOpen($, options)
163 return result
164 })
165
166 // 回覆模式為詳細時,在使用者送出的訊息附上詳細版要求;系統產生的訊息(isMeta)不動
167 on('prompt.submit', async ($, e, next) => {
168 if (replyMode !== 'detail' || e.isMeta) return next(e)
169 return next({ ...e, context: [...(e.context || []), DETAIL_MODE_CONTEXT] })
170 })
171
172 // /agent-board:開關面板,自己處理完不呼叫 next。
173 // 用 isPlaced 判斷:看不見的面板也在清單裡,只看 id 會把它當成開著而關掉
174 on('command.run', { command: 'agent-board' }, async ($) => {
175 if (await isShown($)) {
176 userClosed = true
177 await $.ui.close({ id: PANE })
178 } else {
179 userClosed = false
180 await $.ui.open({ id: PANE, title: '子 agent', closeOnEscape: true })
181 await refresh($)
182 }
183 return {}
184 })
185
186 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
187 if (e.requestId !== PANE) return next(e)
188 const { Box, Text, Button } = $.ui.resolve(e)
189
190 const summary = summarizeUsage(agents, usageByAgent) || '目前沒有子 agent'
191 const toggle = () => {
192 collapsed = !collapsed
193 $.ui.invalidate('ui.render')
194 }
195 // 底部按鈕列:縮小/展開,加上可關掉的回覆模式切換鍵
196 const switchMode = () => {
197 replyMode = replyMode === 'detail' ? 'concise' : 'detail'
198 $.ui.invalidate('ui.render')
199 }
200 const detail = !showDetailButton(options)
201 ? []
202 : [Button({ key: 'mode', label: replyModeLabel(replyMode), onPress: switchMode })]
203 const buttonRow = (first) => Box({ flexDirection: 'row', gap: 1, children: [first, ...detail] })
204 if (collapsed) {
205 return Box({
206 flexDirection: 'column',
207 children: [buttonRow(Button({ key: 'expand', label: '▸ ' + collapsedLabel(agents, usageByAgent), onPress: toggle }))],
208 })
209 }
210
211 const columns = e.props && e.props.bodyColumns
212 const rows = sortAgents(agents).flatMap((a) =>
213 layoutRow(
214 agentLine(a, usageByAgent.get(a.id), metaByAgent.get(a.id), nowMs),
215 a.description,
216 columns
217 ).map((line, i) =>
218 Text({ key: 'row-' + a.id + '-' + i, dimColor: i > 0, children: [line] })
219 )
220 )
221
222 return Box({
223 flexDirection: 'column',
224 children: [
225 Text({ bold: true, children: [summary] }),
226 ...rows,
227 Text({ dimColor: true, children: ['金額為估計(API 牌價)'] }),
228 buttonRow(Button({ key: 'collapse', label: '▾ 縮小', onPress: toggle })),
229 ],
230 })
231 })
232}
233hooks/board-logic.js 268 lines1// 純函式:不碰 $,方便單獨測試
2
3// 這些狀態算「還在進行中」
4const ACTIVE = ['pending', 'running', 'waiting']
5
6export function isActive(agent) {
7 return ACTIVE.includes(agent.status)
8}
9
10export function countActive(agents) {
11 return agents.filter(isActive).length
12}
13
14// options.autoOpen 預設 true;只有明確設成 false 才不自動開
15export function shouldAutoOpen(options) {
16 return !options || options.autoOpen !== false
17}
18
19// options.detailButton 預設 true:看板底部顯示回覆模式切換鍵(設定名稱沿用舊版,避免已設定的人失效)
20export function showDetailButton(options) {
21 return !options || options.detailButton !== false
22}
23
24// 回覆模式切到「詳細」時,附在每則使用者訊息後面的要求(prompt.submit 的 context,每輪都會送出)。
25// 不用 prompt.compose:2.1.301 實測 session 段落只在第一輪生效,之後改了也不會送出
26export const DETAIL_MODE_CONTEXT =
27 '回覆模式:詳細(使用者在看板切換):這一輪結束的回覆不用精簡版三段式,直接寫詳細版:背景、做了什麼、證據與檔案位置、取捨與風險。'
28
29// 看板上回覆模式切換鍵的文字:顯示目前模式,按下切到另一個
30export function replyModeLabel(mode) {
31 return mode === 'detail' ? '⇄ 回覆:詳細' : '⇄ 回覆:精簡'
32}
33
34// options.persistent 預設 true:session 開始就開面板,turn.start 不收起
35export function shouldStayOpen(options) {
36 return !options || options.persistent !== false
37}
38
39// 狀態列一行摘要,例如「子 agent:2 執行中・1 完成」
40export function summarize(agents) {
41 const active = countActive(agents)
42 const done = agents.filter((a) => a.status === 'completed').length
43 const failed = agents.filter((a) => a.status === 'failed' || a.status === 'killed').length
44 const parts = []
45 if (active > 0) parts.push(active + ' 執行中')
46 if (done > 0) parts.push(done + ' 完成')
47 if (failed > 0) parts.push(failed + ' 失敗')
48 return parts.length > 0 ? '子 agent:' + parts.join('・') : undefined
49}
50
51const STATUS_LABEL = {
52 pending: '等待中',
53 running: '執行中',
54 waiting: '等待輸入',
55 idle: '閒置',
56 completed: '完成',
57 failed: '失敗',
58 killed: '已終止',
59}
60
61export function statusLabel(status) {
62 return STATUS_LABEL[status] || status
63}
64
65// ---- 用量、金額與顯示格式 ----
66
67// 價格表:每百萬 token 美元,與 skills/usage-stats/usage_stats.py 的 PRICES 同值(該檔在別的外掛,不 import)。
68// 欄位順序:[input, output, cache read, 5 分鐘寫入]。模型名用前綴比對。
69const PRICES = {
70 'claude-opus-5-5': [4, 20, 0.2, 5],
71 'claude-sonnet-5-5': [2, 10, 0.2, 2.5],
72}
73// Haiku 依該次請求的 prompt(input+cache_read+cache_write)分兩段價
74const HAIKU_PREFIX = 'claude-haiku-5-5'
75const HAIKU_LOW = [0.1, 0.5, 0.01, 0.125]
76const HAIKU_HIGH = [0.5, 2.5, 0.05, 0.625]
77const HAIKU_LIMIT = 100000
78
79function n(v) {
80 return typeof v === 'number' && Number.isFinite(v) ? v : 0
81}
82
83// 單次請求的估計金額(美元);模型不認得回傳 null。
84// 估計值:TurnUsage 沒有分 5 分鐘/1 小時寫入,cache_write 一律用 5 分鐘寫入價(1 小時寫入實際較貴)。
85export function estimateCost(usage, model) {
86 if (!usage || typeof model !== 'string') return null
87 const inp = n(usage.input_tokens)
88 const out = n(usage.output_tokens)
89 const cr = n(usage.cache_read_input_tokens)
90 const cw = n(usage.cache_creation_input_tokens)
91 let price = null
92 if (model.startsWith(HAIKU_PREFIX)) {
93 price = inp + cr + cw <= HAIKU_LIMIT ? HAIKU_LOW : HAIKU_HIGH
94 } else {
95 for (const prefix of Object.keys(PRICES)) {
96 if (model.startsWith(prefix)) {
97 price = PRICES[prefix]
98 break
99 }
100 }
101 }
102 if (price === null) return null
103 return (inp * price[0] + out * price[1] + cr * price[2] + cw * price[3]) / 1_000_000
104}
105
106export function newUsage() {
107 return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, steps: 0, model: null, cost: 0, unpriced: 0 }
108}
109
110// 把一次 turn.step 的 usage 累加進 map(依 agentId)。沒有 agentId 或 usage 為 null 不動作,回傳是否有記錄。
111// 金額逐次請求計算再加總(Haiku 兩段價是看單次請求,不能用累計值算)。
112export function recordUsage(map, agentId, usage, fallbackModel) {
113 if (!agentId || !usage || typeof usage !== 'object') return false
114 const u = map.get(agentId) || newUsage()
115 const model = typeof usage.model === 'string' && usage.model ? usage.model : fallbackModel
116 u.input += n(usage.input_tokens)
117 u.output += n(usage.output_tokens)
118 u.cacheRead += n(usage.cache_read_input_tokens)
119 u.cacheWrite += n(usage.cache_creation_input_tokens)
120 u.steps += 1
121 if (typeof model === 'string' && model) u.model = model
122 const c = estimateCost(usage, model)
123 if (c === null) u.unpriced += 1
124 else u.cost += c
125 map.set(agentId, u)
126 return true
127}
128
129export function totalTokens(u) {
130 return u ? u.input + u.output + u.cacheRead + u.cacheWrite : 0
131}
132
133// 12.3k、1.2M;整數不帶 .0
134export function formatTokens(count) {
135 if (typeof count !== 'number' || !Number.isFinite(count) || count < 0) return '—'
136 if (count < 1000) return String(Math.round(count))
137 const trim = (x) => x.toFixed(1).replace(/\.0$/, '')
138 if (count < 999950) return trim(count / 1000) + 'k'
139 return trim(count / 1e6) + 'M'
140}
141
142// 45s、3m12s、1h05m
143export function formatElapsed(ms) {
144 if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return '—'
145 const s = Math.floor(ms / 1000)
146 if (s < 60) return s + 's'
147 const m = Math.floor(s / 60)
148 if (m < 60) return m + 'm' + String(s % 60).padStart(2, '0') + 's'
149 return Math.floor(m / 60) + 'h' + String(m % 60).padStart(2, '0') + 'm'
150}
151
152// claude-sonnet-5-5 → sonnet-5.5;去掉日期尾碼;認不得的原樣回傳;沒有值回傳 '—'
153export function shortModel(model) {
154 if (typeof model !== 'string' || !model) return '—'
155 const m = model.match(/^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?$/)
156 if (m) return m[1] + '-' + m[2] + (m[3] ? '.' + m[3] : '')
157 return model.replace(/^claude-/, '')
158}
159
160// 「約 $0.12」用的數字;null 回傳 '—'
161export function formatCost(usd) {
162 if (typeof usd !== 'number' || !Number.isFinite(usd)) return '—'
163 if (usd > 0 && usd < 0.01) return '<$0.01'
164 return '$' + usd.toFixed(2)
165}
166
167// 顯示寬度:全形/CJK 算 2 欄
168function charWidth(ch) {
169 const c = ch.codePointAt(0)
170 return (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) ||
171 (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe6f) || (c >= 0xff00 && c <= 0xff60) ||
172 (c >= 0xffe0 && c <= 0xffe6) || (c >= 0x1f300 && c <= 0x1faff)
173 ? 2
174 : 1
175}
176
177export function displayWidth(str) {
178 let w = 0
179 for (const ch of String(str)) w += charWidth(ch)
180 return w
181}
182
183// 依顯示寬度截斷,超過加「…」;也會把換行壓成空白
184export function truncate(str, width) {
185 const s = String(str ?? '').replace(/\s+/g, ' ').trim()
186 if (displayWidth(s) <= width) return s
187 let w = 0
188 let out = ''
189 for (const ch of s) {
190 const cw = charWidth(ch)
191 if (w + cw > width - 1) break
192 out += ch
193 w += cw
194 }
195 return out + '…'
196}
197
198function sums(agents, usageMap) {
199 let tokens = 0
200 let cost = 0
201 let unpriced = 0
202 let seen = false
203 for (const a of agents) {
204 const u = usageMap.get(a.id)
205 if (!u) continue
206 seen = true
207 tokens += totalTokens(u)
208 cost += u.cost
209 unpriced += u.unpriced
210 }
211 return { tokens, cost, unpriced, seen }
212}
213
214// 「N 執行中・M 完成|合計 X tokens・約 $Y」;用於面板第一行與狀態列(最多 200 字)
215export function summarizeUsage(agents, usageMap) {
216 const base = summarize(agents)
217 if (!base) return undefined
218 return withUsageTail(base, agents, usageMap)
219}
220
221// 縮小時的按鈕文字:列出執行中的類型(同類型合併成 ×N),看得出是誰在跑
222export function collapsedLabel(agents, usageMap) {
223 if (agents.length === 0) return '目前沒有子 agent'
224 const active = agents.filter(isActive)
225 if (active.length === 0) return withUsageTail('全部完成(' + agents.length + ' 個)', agents, usageMap)
226 const counts = new Map()
227 for (const a of active) counts.set(a.type || '—', (counts.get(a.type || '—') || 0) + 1)
228 const types = [...counts].map(([t, n]) => (n > 1 ? t + '×' + n : t)).join('、')
229 return withUsageTail('執行中:' + types, agents, usageMap)
230}
231
232function withUsageTail(head, agents, usageMap) {
233 const s = sums(agents, usageMap)
234 const cost = s.seen ? formatCost(s.cost) + (s.unpriced > 0 ? '+' : '') : '—'
235 const text = head + '|合計 ' + (s.seen ? formatTokens(s.tokens) : '—') + ' tokens・約 ' + cost
236 return text.length > 200 ? text.slice(0, 199) + '…' : text
237}
238
239// 執行中的排前面,其餘維持原順序
240export function sortAgents(agents) {
241 return [...agents].sort((a, b) => Number(isActive(b)) - Number(isActive(a)))
242}
243
244// 一個 agent 的資訊行(不含描述)。meta:{ start, end, model }
245export function agentLine(agent, usage, meta, now) {
246 const m = meta || {}
247 let elapsed = '—'
248 if (typeof m.start === 'number') {
249 const end = typeof m.end === 'number' ? m.end : isActive(agent) ? now : undefined
250 if (typeof end === 'number') elapsed = formatElapsed(end - m.start)
251 }
252 const model = (usage && usage.model) || m.model
253 const tokens = usage ? formatTokens(totalTokens(usage)) : '—'
254 const cost = usage && usage.steps > 0 ? (usage.unpriced > 0 && usage.cost === 0 ? '—' : '約 ' + formatCost(usage.cost)) : '—'
255 return [agent.type || '—', shortModel(model), statusLabel(agent.status), elapsed, tokens, cost].join('|')
256}
257
258// 依寬度排版:寬就把描述接在同一行,窄就放第二行(太窄則省略)。回傳字串陣列。
259export function layoutRow(info, description, columns) {
260 const cols = typeof columns === 'number' && columns > 0 ? columns : 60
261 const desc = String(description ?? '').trim()
262 if (!desc) return [info]
263 const room = cols - displayWidth(info) - 1
264 if (room >= 20) return [info + '|' + truncate(desc, room - 1)]
265 if (cols < 30) return [info]
266 return [info, ' ' + truncate(desc, cols - 2)]
267}
268