SLOPSHOPPER

agent-board

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

panecommandstatusprompttimeragents
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-board
│ ┃ agent-board ✕ › fix the failing auth test and add an audit log call │ ┃ 目前沒有子 agent │ ┃ 金額為估計(API 牌價) ⏺ Read(src/auth.ts) │ ┃ [ ▾ 縮小 ] [ ⇄ 回覆:精簡 ] ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /agent-board │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · agent-board
目前沒有子 agent 金額為估計(API 牌價) [ ▾ 縮小 ] [ ⇄ 回覆:精簡 ]
README

Smart Agent Route

讓 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.

目的:用同樣的預算做更多事,而且長時間工作也不會因為對話越拉越長而變貴、變亂。

為什麼用它

  • 貴的模型只用在刀口上:Opus 只負責想與判斷,寫程式交給 Sonnet,讀 log、看截圖、查資料交給 Haiku。
  • 長對話不再失控:context 超過 10 萬 token 就提醒,先寫交接筆記;壓縮、/clear 或隔天開新對話,進度都會自動接回來。
  • 裝完就能用:兩行指令,不用改你的 CLAUDE.md。進階設定一次只問一項,改設定前先備份。
  • 做完會驗證,岔路會停下來問:子 agent 回報完成前必須驗證;遇到需要決策的地方,列出選項讓你選,不會自作主張。失敗有固定的升級順序,不會無限重試。
  • 記憶跨對話累積:記憶就是一般的 Markdown 檔,可以進 git、用 Obsidian 開;查外部資料時附上來源。
  • 效果量得到:usage-stats 可以量出主對話 context 長度、每個 agent 用了多少 token、依 API 牌價估算的金額,也能輸出一份有每日圖表的 HTML 報表。
  • 看得到子 agent 在做什麼(選裝):agent-board 看板常駐在輸入框上方,即時列出每個子 agent 的類型、模型、狀態、經過時間、token 與估計金額。
  • 回覆一眼看懂(選裝):reply-detail 讓回覆預設只講現況、你要做的、需要你決定的事;要細節時用看板的「⇄ 回覆:精簡/詳細」切換鍵,切到詳細後每則都寫詳細版。

這解決什麼問題

用 Claude Code 一陣子,常會遇到兩件事:

  • 貴:所有事都由主對話的 Opus 親自做,包括讀長 log、看一堆截圖、改幾十個檔案。這些工作大多用便宜的模型就夠了。
  • 對話越聊越長:每一則訊息、每一次讀檔的結果,之後每一輪都會重新送給模型一次。對話拖到後面,大部分花費都在重讀舊內容。實測一位重度使用者的 21 個 session,主對話每次呼叫的 context 中位數約 24 萬 token。

這個 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。)

裝完就能用。接著建議做兩件事:

  1. 主對話設成 Opus:/model opus。plugin 不會自動改。
  2. 開啟完整功能:執行 /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 還是隔天開新對話,交接筆記都會自動帶回來,不用重新解釋一次。

名詞小抄

  • context:模型這一輪看得到的全部內容,包括系統提示、對話紀錄、工具結果。以 token 計算,越長越貴。
  • 子 agent(subagent):主對話派出去的助手,在自己獨立的 context 裡工作,只把結論交回來。
  • skill:可以呼叫的固定作業流程,例如寫交接筆記的 handoff。
  • hook:Claude Code 在特定時機自動執行的腳本,例如 session 開始時、你送出訊息時。
  • effort:模型思考的力道。越高想得越久,也越貴。
  • 自動壓縮(autocompact):context 快滿時,Claude Code 把舊對話壓成摘要。
  • Cowork:Claude 桌面版的代理工作模式。

運作方式

你 ──► 主對話(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 過長時提醒
元件模型做什麼
workerSonnet,effort medium方向已定的實作:寫程式、修 bug、補測試。回報完成前必須先驗證
worker-hardSonnet,effort high只在 worker 失敗後使用,派工時必須附上前次失敗報告
bulkHaiku大量讀取、只回結論:大量圖片、長 log、網頁、輸出很多的指令,以及機械式的批次修改
librarianHaiku,effort low查知識庫與記憶。只有 Read、Glob、Grep,什麼都改不了;回傳原文片段與來源,並說明搜過哪些詞、什麼沒找到
harvesterSonnet把歷史對話逐字稿挖進知識庫筆記。只在你明確要求時才跑,成本不低
Explore(選裝)Haiku內建 Explore 在主對話是 Opus 時也跑 Opus。plugin 蓋不掉它(只有使用者或專案層級、名為 Explore 的 agent 能覆蓋),所以用 route-setup 或 bash install.sh --with-explore 把 Haiku 版裝進 ~/.claude/agents/
  • Skills:handoff 寫 handoff.md;research 查外部資料並附來源;usage-stats 量測主對話 context 長度、各 agent 的派工次數、token 用量與估計金額(唯讀;加 --html 可輸出圖表報表);route-setup 是一次性完整設定。
  • Hooks:SessionStart 帶入分工慣例、記憶索引與交接筆記;UserPromptSubmit 在 context 過長時提醒。

主對話怎麼分工,不靠你的 CLAUDE.md:SessionStart hook 會在每個 session 開始時把分工表帶進去(內容見 hooks/guide.md)。設計理由與取捨見 docs/spec.md。

為什麼這樣省

路由發生在 Opus 之後,不是之前。 判斷意圖就得先讀懂訊息,而讀懂就等於規劃了,所以沒辦法在前面放一個便宜的路由器。Opus 永遠是入口,由它派工。

省下的錢來自四個地方,照影響大小排序:

  1. Context 隔離(最大的一筆,而且是複利)。在主對話裡跑 30 次工具呼叫,那 30 份結果會被反覆計費到對話結束;派給子 agent 的話,主對話只收到一份摘要。派工要整包派,不要一件一件派,因為每次啟動子 agent 都要重付一次系統提示與記憶注入。
  2. 模型分層。 有用,但是線性的:省的比例大致等於價差。
  3. effort 等級。 worker 用 medium,失敗才升級到 worker-hard(high);librarian 用 low。
  4. 主對話別開太久。 隔離只管得到工具呼叫,管不到對話本身變長,所以 plugin 另外盯著長度,超過門檻就提醒寫交接筆記(見進階設定)。話題告一段落時,換新對話或 /clear 仍然最省。

失敗怎麼辦

子 agent 收工時用固定的回報格式,主對話只看前兩行就能決定下一步:狀態行(完成、失敗、需要決策)與驗證行(通過、未驗證、失敗)。「完成」只能搭配「驗證通過」。遇到需要決策的岔路時,子 agent 會停下來回報有哪些選項與各自的影響,不會自己選一條路走完。

失敗升級依序:補背景後同一個 agent 重派 → bulk 失敗改 worker → worker 失敗改 worker-hard(附前次失敗報告)→ 仍失敗就停下來重新規劃或問你。不會無限升級。


進階設定

context 提醒與自動壓縮

  • UserPromptSubmit hook 在 context 超過 10 萬 token 時提醒一次,之後每多 4 萬再提醒。門檻可用環境變數 CONTEXT_REMIND_TOKENS 調整。
  • 提醒時 Claude 用 handoff skill 把現況寫進 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" }

子 agent 看板與回覆模式切換鍵(選用)

兩個選裝的 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 — 要改路徑或指令跑不起來時先讀(這是條件)

記憶放在哪裡:

  • agent 優先使用已連接(工作)資料夾裡的 claude-memory/,找不到才退回自己的 agent 記憶目錄(~/.claude/agent-memory/worker/)。
  • Claude Code(終端機)裡退回路徑可以跨對話存活。Cowork 的沙盒家目錄每個 session 重建,只有連接資料夾活得下來:每次開新對話都要重新連接。
  • 如果你關過 autoMemoryEnabled 或設了 CLAUDE_CODE_DISABLE_AUTO_MEMORY,memory 欄位不會生效,連接資料夾就從選用變成必要。
  • 資料夾直接用 Obsidian 開就是一個 vault,不需要任何整合,vault 本來就只是一堆 .md。請開新的專用 vault,不要指向你現有的個人筆記,因為 agent 有寫入權。
  • 記憶要記「下次怎麼做」,不是「這次做了什麼」。流水帳不會讓下一次變快。

保持 MEMORY.md 薄很重要:超過上限會被截斷(hook 最多帶入 120 行,auto memory 200 行),而且常駐內容是每個 session 都付的固定成本。它會腐化,需要定期整理;超過 150 行就是警訊。

知識庫與 harvester

餵知識庫有兩種方式:自己把 .md 放進 kb/(不用任何前處理,librarian 立刻找得到),或叫 harvester 去挖歷史對話(它會先列出可讀的 session 讓你選範圍)。先講清楚四個限制:

  • 只看得到本機的 session;網頁版或手機上的對話不在範圍內。
  • 成本不低,所以是偶爾跑一次的批次作業,不是常駐功能。
  • 萃取一定會失真:產出值得抽查,尤其是頭幾次。
  • 它會寫進 kb/,而那原本是你自己的東西。如果你在意這條界線,先叫它寫到 kb/harvested/。

第一次安裝後跑一次,把已經累積的東西撈起來當起點;之後 worker 收工時會即時寫記憶,就很少需要再跑。


設計取捨

這是 RAG,只是不用向量

librarian 用關鍵詞搜尋與條件判斷做檢索。個人規模(數十到數百篇)這樣通常比向量好:不用多一套基礎設施、檢索失敗看得出原因、不會因為切塊破壞 Markdown 的結構。而且它會換同義詞、換語言、改搜檔名重試,固定的向量查詢做不到。

上千篇或大量 PDF、長逐字稿才值得考慮向量。屆時 kb/ 的 markdown 直接當語料。

什麼時候加 Haiku 執行層

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(同上)

注意事項:

  • 這種模式下名稱沒有命名空間:agent 是 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 只移除自己加的路徑。
  • install.sh 不會改你 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:worker
  • hook 在 Cowork 的行為未驗證
  • MEMORY.md 會腐化,需要你定期整理(超過 150 行即是訊號)
  • Haiku 執行層預設留空,見 plugins/smart-agent-route/skills/route-setup/templates/runner.md.example 的啟用條件
  • harvester 只讀得到本機 session
  • mod 的 API 標示為搶先體驗(early access),Claude Code 改版時可能要跟著調整

歡迎開 issue。


授權

MIT

Source 2 files
hooks/register.js 233 lines
1import {
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}
233
hooks/board-logic.js 268 lines
1// 純函式:不碰 $,方便單獨測試
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