SLOPSHOPPER

session-picker

控制台:輸入框上方的 Session/Tool/MCP/流程/記憶/額度 按鈕與側邊面板(未結案/暫時結案/已結案 session 點選切換、工具清單、MCP 伺服器啟用/停用、計畫進度與本 session 即時活動、記憶檔檢視與複製路徑、Claude/Codex…

panebandrowsguardcommandprocess
v0.7.0no licenseupdated 2026-10-20bai-collab/claude_code_mod/plugins/session-picker
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-picker
│ ┃ 控制台 ✕ › fix the failing auth test and add an audit log call │ ┃ [Session] │ ┃ [未結案(0)] ⏺ Read(src/auth.ts) │ ┃ 最近 0 個未結案 session ⎿ Read 6 lines │ ┃ 讀取 session 清單失敗:JSON Parse error: ⏺ Update(src/auth.ts) │ ┃ Unexpected identifier "dev" ⎿ 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 │ │ › /sessions │ │ 控制台 Session Tool ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
控制台 Session ⟨Claude Code's own drawing⟩
Pane · 控制台
[Session] [未結案(0)] 最近 0 個未結案 session 讀取 session 清單失敗:JSON Parse error: Unexpected identifier "dev" [ 重新整理 ]
README

控制台(未結案 session 點選清單+工具+MCP+流程+記憶+額度)

在 Claude Code 輸入框上方放一列按鈕,點開側邊面板「控制台」,有六個分頁(版本 0.7.0);另外每則助理回覆下方會列出路徑與網址的複製按鈕(S5):

  • Session:本工作區的 session 分成「未結案/暫時結案/已結案」三個子分頁,各列最近 10 個,點一下就原地切換過去(S1,之後加上子分頁)。
  • Tool:目前可用的工具清單(唯讀)。
  • MCP:MCP 伺服器與狀態,每台一個「停用」或「啟用」按鈕。
  • 流程:上半是 planned/ 最近一份計畫書的節點進度,下半是本 session 即時的工具與 subagent 活動(S3;S4 起工具目標會先遮罩秘密)。
  • 記憶:本 session 已載入的記憶檔,加上自動記憶資料夾裡未載入的記憶檔;可在面板內看原文、複製絕對路徑(S4)。
  • 額度:Claude 與 Codex 的 5 小時、週用量百分比,照目前速度推算何時到 95%/100%、會不會比重置先到;AGY 只有使用次數(S6)。

使用者決定(2026-10-18)

  1. Session 範圍:只列本工作區(<設定目錄>/projects/<工作目錄轉成的目錄名>/,目錄名是把非英數字元全換成 -)。
  2. 分組:看該 session 最新一筆 custom-title 第一個 |/| 之前的前綴(去頭尾空白、整段完全相等):進行中、待驗收 → 未結案;暫結案、暫時結案 → 暫時結案;結案 → 已結案(所以「暫結案 | x」不會落到已結案);沒有狀態前綴的不列。
  3. 打開方式:原地切換,外掛觸發 /resume <sessionId>;不開新終端機分頁。
  4. 入口:輸入框上方常駐按鈕列+側邊面板分頁;Tool 分頁只列工具與說明;MCP 分頁列伺服器與狀態並可啟用/停用。

入口

入口作用
輸入框上方的 Session、Tool、MCP、流程、記憶、額度 按鈕開啟(或切到)控制台並顯示該分頁
/panel開啟控制台,顯示上一次的分頁
/panel auto on、/panel auto off設定「開新對話、/clear、resume、fork 時自動開面板」,預設開;設定存在外掛的 $.store(key autoOpen),指令會回覆確認
/panel copybar on、/panel copybar off設定「助理回覆下方的複製按鈕」是否顯示,預設開;設定存在 $.store(key copyBar),啟動與 /clear、resume、fork 時讀進外掛狀態,指令會回覆確認
/sessions開啟控制台並切到 Session 分頁
  • 按鈕列刻意不設快捷鍵:空輸入框時按一個數字會被當成按下 band 的按鈕。
  • 按鈕是固定的淡黃色色塊(S5,使用者指定):一般按鈕底 #FFF3B0、使用中的分頁底 #F2C94C(字粗體),字一律深色 #3B3000。不再跟 /theme、/color 變(色碼寫死,常數是 hooks/register.tsx 的 CHIP_BG、CHIP_FG、CHIP_ACTIVE_BG);Button 沒有文字色屬性,所以每顆按鈕都用子層 Text 指定深色字,按鈕的 label、key、行為不變。
  • 問卷(survey)占用 band 時按鈕列會讓位;與其他外掛的 band 內容疊加(先畫別人的,我們的按鈕列放在它上面)。
  • 面板要求 44 欄寬(這只是「請求」,使用者拖過的寬度優先)。

分頁說明

  • Session:上方一排子分頁 未結案(N)、暫時結案(N)、已結案(N)(使用中的顯示成 […]),各列最近 10 個、依 transcript 修改時間新到舊;點列切換(含已結案的,用來重開舊對話)。腳本一次掃完、三組一起回傳,切換子分頁不重跑腳本;「重新整理」(快捷鍵 r)會重讀並保留目前子分頁;/sessions 一律落在「未結案」。目前這個 session 不列出。子分頁按鈕不設快捷鍵(與其他分頁按鈕一致)。
  • Tool:用引擎的 $.tool.list(),分成「內建」與「MCP」(名稱以 mcp__ 開頭、或引擎標為 MCP)兩組,每列「名稱+說明第一行」,依面板欄寬截斷;上方顯示總數。沒有任何動作。
  • MCP:已連線的伺服器(名稱與工具數)來自 $.session.usage({ breakdown: "summary" }) 的 MCP 工具清單;已停用的伺服器來自設定檔(見下)。按鈕執行:
  • 已連線 → 「停用」→ /mcp disable <名稱>
  • 已停用 → 「啟用」→ /mcp enable <名稱>
  • 名稱為空、含空白或控制字元時不送出,改顯示錯誤列(避免多出參數)。
  • 按下後該列標為「處理中…」,指令結束後重新讀取清單;引擎拒絕時顯示「MCP 指令失敗:…」。

流程分頁(S3)

分頁列與按鈕列各多一個「流程」(key tab-flow/band-flow,仍無快捷鍵)。分兩區:

上:計畫進度

  • 自動挑計畫書:列出 <session root>/planned/ 直接底下的 *.md(complete/、shelved/ 等子目錄與非 .md 檔不看),依修改時間新到舊取前 5 份,預設選最新一份。‹/›(plan-prev/plan-next)在這 5 份之間切換(到頭繞回,旁邊顯示 1/5);「重新整理」(flow-refresh,快捷鍵 r)重讀目前這份並保留選擇。
  • 標題:計畫:<檔名> 已執行 a/共 n(太窄時縮檔名、保留計數)。
  • 節點解析:只看 `` 圍欄區塊內的行;找 [ID](英數字與連字號),其後用全形「|」切成 名稱|agent|狀態`,狀態以「已執行/未執行/有問題」開頭。欄位不全、狀態不是這三個詞、ID 不合法的行直接忽略;多個區塊依檔案順序合併。
  • 每個節點一行:
符號意義
✔ P0 …已執行
▶ P4 … ← 目前第一個未執行的節點(不含「未觸發」分支)
○ P5 …其餘未執行
✘ P5 …(有問題)有問題
· W2 …(未觸發)狀態備註含「未觸發」的分支(不算待辦、不會成為「目前」)

節點名稱後面接淡色的 |<agent>,依面板欄寬截短。

  • 重新讀檔的時機:切到流程分頁時、按「重新整理」、主迴圈每個 turn.complete(只在上次顯示的是流程分頁、且控制台還開著時)。讀檔失敗只顯示一行錯誤,不影響其他區塊。

下:本 session 即時活動

  • 標題:本 session:工具 x 次(MCP y)・agent z 個;沒有活動時顯示「外掛載入後的活動會顯示在這裡(載入前的不會補上)」。
  • 以文字樹顯示最近 5 輪(最新在下):
第 12 輪 ─┬─ Bash  node scripts/review-check.mjs  3.2s
          ├─ Agent executor(sonnet)─┬─ Read  register.tsx  40ms
          │                           └─ Bash  claude plugin test  2.5s
          └─ MCP brave-search › brave_web_search  1.1s ✘

mcp__<伺服器>__<工具> 顯示成 MCP <伺服器> › <工具>;失敗(工具回報錯誤、被拒、丟錯)加 ✘;還在執行的顯示 …。

  • 追蹤方式:turn.start(只算主迴圈,算出輪次)、tool.call、agent.spawn、turn.complete(見上)四個 hook,一律原樣 return next(e)、不改寫、不阻擋;記錄寫失敗也不影響工具。Agent 工具本身由 agent.spawn 記成一個 agent 節點,不重複記成工具。
  • subagent 從屬:subagent 自己的 tool.call 帶有 agentId(型別檔 AgentLoop),agent.spawn 的回覆帶同一個 agentId,兩者相同就把該工具掛在那個 agent 底下;巢狀 spawn 用 parentAgentId。對不到 agent 紀錄的(例如 spawn 回覆前就先呼叫)平放在該輪頂層。
  • 只存:工具名稱、截短目標(最多 60 欄:檔案取檔名、指令取第一行、pattern/query/description 原文、網址只取主機;MCP 工具不存目標)、agent 類型與說明(最多 40 欄)與模型、耗時、成功或失敗、agent 從屬、輪次。絕不存:工具輸入全文、檔案內容、工具結果。目標在截短前先遮罩秘密(見下方「流程分頁的秘密遮罩」)。
  • 最多保留最近 200 筆(較舊的丟棄,上方的次數照實累計);/clear、resume、fork 時整個重置。
  • 只有外掛載入之後發生的活動會顯示,載入前的不會補上;全部存在外掛的記憶體狀態,不寫檔。

記憶分頁(S4)

分頁列與按鈕列各多一個「記憶」(key tab-memory/band-memory,仍無快捷鍵)。資料只在切到這個分頁、或按「重新整理」(memory-refresh,快捷鍵 r)時讀,不是每次重畫都讀。

已載入的記憶檔

  • 來源:$.session.usage({ breakdown: "summary" }) 的 context.breakdown.memoryFiles(每筆有絕對路徑、類型、估算 tokens;summary 只在本機估算、不送請求)。與 MCP 分頁共用同一個呼叫函式。
  • 標題:「本 session 載入 N 個記憶檔(約 X tokens)」。
  • 每筆三行:類型標籤(引擎給的 Project、User、Local、Managed、AutoMem,原樣顯示)與 tokens(例如 1.2k tokens)、絕對路徑、兩個按鈕「看內容」(mem-view:<序號>)與「複製路徑」(mem-copy:<序號>)。
  • 路徑顯示:只有以磁碟代號開頭(C:/…、F:/…)的才把 / 換成反斜線,其他路徑(例如 /home/…)原樣。

記憶資料夾

  • 取「類型為 AutoMem 的那筆」所在的目錄(不寫死路徑),用 $.fs.list 列出該目錄直接底下的 *.md,排除已在上面清單的檔案,依檔名排序;區塊標題「記憶資料夾(未載入,被引用時才讀)M 個」,每列同樣有「看內容」(memdir-view:<序號>)與「複製路徑」(memdir-copy:<序號>)。
  • 最多列 30 個,其餘以淡色「還有 K 個…」帶過(沒有分頁);子目錄與非 .md 檔不列。
  • 已載入清單裡沒有 AutoMem 時不列資料夾,顯示淡色說明。

看內容、複製路徑

  • 「看內容」:用 $.fs.read 讀檔,面板切到檢視畫面——第一行是絕對路徑,接著「返回」(mem-back)與「複製路徑」(mem-view-copy),下面是檔案原文逐行顯示。原文不遮罩(這是使用者讀自己的檔案,刻意如此);只把控制字元(含 ANSI 跳脫)換成空白、tab 換成兩個空白,避免弄亂畫面;超過 2000 行只顯示前 2000 行並註明還有幾行。讀取失敗顯示「讀取失敗:…」並停在清單。
  • 「複製路徑」:$.ui.copy({ text: <Windows 絕對路徑>, surface }),surface 取自按鈕 onPress 的引數(press.surface)。成功顯示「✔ 已複製:<路徑>」,失敗顯示「✘ 複製失敗(<原因>):<路徑>」(原因是引擎回的 no-surface、no-clipboard、refused,或丟出的錯誤訊息),路徑仍在畫面上可手動選取。
  • 剪貼簿由終端機的剪貼簿工具或 OSC 52 寫入;OSC 52 不會回報成功與否,有些終端機會丟掉它卻仍顯示「已複製」,貼不出來時請直接選取畫面上的路徑。
  • 這個分頁絕不寫檔、不執行任何程序。

額度分頁(S6)

分頁列與按鈕列各多一個「額度」(key tab-quota/band-quota,無快捷鍵)。三個區塊:

額度預測
Claude
  週用量 █████████░░░░│░ 62% ⚠15時後95%
  5 小時 ██░░░░░░░░░░░│░ 18% 資料不足
Codex 最後更新 12 分鐘前
  週用量 ███░░░░░░░░░░│░ 25% 重置前安全
  5 小時 ████░░░░░░░░░│░ 27% 資料不足
AGY 無額度與 token 資料
  使用次數:今天 3|近 7 天 11(依本機對話資料夾)
  • 進度條:每個視窗只佔 1 行。「█」是已用、「░」是未用,「│」是 95% 的門檻標記(取代該位置的一格,後面至少留一格)。條寬依面板欄寬計算(約 6~24 格),整行不超過欄寬;極窄時整行以省略號截斷。
  • 顏色:整行同一個顏色,百分比 ≥ 95 用 error、≥ 70 用 warning、其他用 success(引擎的主題色名稱)。
  • 行尾狀態(依序判斷,第一個符合的就是):讀數已過重置時間→「等新讀數」;已達 100%→「已滿」;已達 95%→「已達95%」;資料不足→「資料不足」;照目前速度重置前會到 95%→「⚠<時間>後95%」(例如「15時」「5.3時」「40分」);其他→「重置前安全」。
  • 速度、重置時間、「根據 N 筆」等細節不再顯示(精簡版);預測仍照下面的規則計算。
  • 資料來源(都不讀登入憑證):
  • Claude:主迴圈每次 turn.complete 用 $.session.usage() 取 rateLimits(five_hour、seven_day 的 percentUsed、resetsAt),記一筆讀數;不必開著分頁,否則沒有歷史可算速度。subagent 的 turn.complete 不取樣。
  • Codex:scripts/read-quota.mjs 找 ~/.codex/sessions/YYYY/MM/DD/ 最新的 rollout-*.jsonl,只讀檔尾 512 KB,取最後一筆 rate_limits(頂層或 payload 底下都認);primary 當 5 小時、secondary 當週。最新檔沒有讀數時往較舊的檔案最多再找 5 個。讀數時間用紀錄檔的修改時間。
  • AGY:同一支腳本只對 ~/.gemini/antigravity-cli/brain/、~/.gemini/antigravity/brain/ 做 readdir+stat,數修改時間在今天、近 7 天的子資料夾(一個資料夾算一次),不讀任何檔案內容。AGY 的本機紀錄沒有額度或 token 欄位,CLI 也沒有查額度的指令,所以只能給次數;要剩餘額度得讀 OAuth 憑證,不在本外掛範圍。
  • 腳本 stdout 只有 { codex: { primary, secondary, observedAt } | null, codexReason?, agy: { today, week } | null, agyReason? };對話內容、指令、帳號、credits 一律不輸出(有測試)。
  • 預測:
  • 同一來源、同一視窗的 resetsAt 相差 ≤ 120 秒算同一個重置週期;resetsAt 改變或百分比下降 ≥ 5 點算新週期,舊資料不參與。
  • 速度=回看範圍內(週用量 6 小時、5 小時用量 60 分鐘,以最後一筆讀數為準)第一筆到最後一筆的上升量 ÷ 時間。跨度不到 10 分鐘或上升不到 1 點時顯示「資料不足」,不硬給數字。其他裝置用掉的額度造成的跳升照實計入。
  • 到 95% 的時間與重置時間比較:會先到就在行尾標「⚠<時間>後95%」,否則是「重置前安全」;已到顯示「已達95%」或「已滿」;最後一筆的重置時間已過顯示「等新讀數」。預測不算到 100% 的時間。
  • 重新讀取的時機:切到額度分頁、按「重新整理」(quota-refresh,快捷鍵 r)、分頁開著時每次主迴圈 turn.complete 才跑腳本(逾時 10 秒)。
  • 歷史檔:<CLAUDE_CONFIG_DIR,未設則 ~/.claude>/plugin-data/session-picker/quota-history.json,格式 { samples: [{ source, window, pct, resetsAt, at }] },只有這五個欄位。有新讀數且距上次寫入 ≥ 2 分鐘才寫;寫前先讀檔再合併去重,多個同時開著的 session 不會互相蓋掉。每個來源與視窗只留最近 7 天、最多 500 筆;相鄰同值且相隔不到 5 分鐘的不重複存。檔案損壞時顯示「歷史檔格式不符,改用本次記錄重建」並以這次的記錄覆寫;讀寫失敗只顯示一行淡色字,預測改用記憶體中的讀數。刪掉這個檔不影響其他功能。
  • 還沒有讀數時,Claude 顯示「還沒有讀數(Claude 要回覆過一次才有)」;rateLimits 是空的(不是訂閱方案)顯示「此帳號沒有額度視窗」。腳本失敗顯示「讀取 Codex/AGY 失敗(…)」,其他區塊照常。

回覆下方的複製按鈕(S5)

每則助理回覆(ui.render 的 AssistantMessage)下方,若文字裡有 Windows 絕對路徑、網址或 `` 圍欄裡的指令,就多一列複製按鈕。做法:先 next(e) 取得引擎的繪製,原樣放在上面,再把一列按鈕疊在它下面;e.props 一個字都不改(isSummary、onScreen` 照引擎給的)。只改畫面,不改存下來的訊息(ctrl+o 看到的仍是原文)。

  • 什麼時候不畫:摘要區塊(isSummary)、/panel copybar off、或沒有任何目標——這三種情況直接把 next(e) 的結果(同一個物件)回傳。
  • 偵測什麼(只對回覆文字做樣式比對,不碰檔案或程序):
  • Windows 絕對路徑:磁碟代號加 :\ 或 :/,後面接路徑字元,遇到空白、反引號、引號、<>|*? 與中文標點(,。、;:「」『』()!?)就停;結尾的 .,;:)]} 與全形標點剝掉。原樣保留(不轉換斜線)。磁碟代號前面不可緊接英數字(xF:\foo 不算),中文字緊接在前面則算(「在F:\x」)。F: 單獨、12:30 這類時間不算。
  • 網址:http://、https:// 開頭,遇到空白、反引號、引號、<> 與中文標點停;結尾的 .,;:)]}> 剝掉。網址裡的 C:/… 不會另外當成路徑。
  • 去重(同一種類、同一段原文只出現一次),依第一次出現的順序;沒產生指令按鈕的圍欄 `text 區塊與行內反引號裡的路徑、網址也抓。
  • 指令按鈕:成對的 `` 圍欄區塊(\r\n 與 \n` 都認、圍欄可縮排)。先去掉頭尾空白行、再去掉非空白行共同的最小縮排,之後依序判斷:
  • 內容是空的:沒有按鈕。
  • 內容剛好是一個路徑或一個網址:沒有指令按鈕,區塊當一般文字,照舊抓成路徑/網址按鈕。
  • 第一行是 # 標題(先於單行規則判斷):標題當標籤,複製內容是其餘各行(去掉標題後緊接的空白行);只有標題、沒有其餘內容就沒有按鈕。
  • 單行且以 / 開頭:標籤是不含斜線的指令名稱(/rename 結案 | x|y → rename、/copy 2 → copy;名稱是空的就用那個字本身),複製整行。
  • 其他單行:標籤是前兩個字,含 \ 或 / 的字只留最後一段(node harness/cli/review-loop.mjs a.json → node review-loop.mjs、npm.cmd test → npm.cmd test),複製整行。
  • 多行且沒有標題:標籤是 指令(N 行),複製整段內容。
  • 產生指令按鈕的區塊(從開頭圍欄到結尾圍欄)整塊不再掃路徑與網址,所以指令裡的路徑不會另外變成按鈕;沒產生按鈕的區塊(含沒有結尾的圍欄)照一般文字掃描。
  • 想讓多行指令有好懂的按鈕名稱,可在區塊第一行寫 # 標題;按下去複製的不含這一行。
  • 每則最多 8 顆(路徑、網址、指令共用),超過時在按鈕列尾端以淡色顯示「還有 K 個」。
  • 標籤:路徑取最後一個非空區段(檔名或資料夾名);網址取 主機[:埠] 加 / 加最後一個非空路徑區段(例如 127.0.0.1:5181/n5.html、github.com/paneline),查詢字串、片段與帳密(@ 之前)不進標籤;指令按鈕見上。標籤先套 maskSecrets(見下方「流程分頁的秘密遮罩」)再截到 28 欄(超過加 …),前面加 ⧉ 。
  • 複製的是原文:按下去 $.ui.copy({ text: <訊息裡的原文>, surface: press.surface }),標籤遮罩不影響複製內容(含 token 的網址會完整複製)。狀態列顯示在那一則訊息的按鈕下面:成功「✔ 已複製:<原文>」、失敗「✘ 複製失敗(<原因>):<原文>」。狀態列顯示的目標會經 maskSecrets 遮罩(例如 token=*),但實際複製的文字是原文、不遮罩**;多行指令的狀態列只有一行:遮罩後的第一個非空行加 …(共 N 行)(copyShownOf);記憶分頁複製路徑的狀態列同樣遮罩顯示、複製原路徑。全外掛只留最近一筆狀態,按了另一則訊息的按鈕,前一則的狀態就消失。訊息以內容的 FNV-1a 雜湊(messageKeyOf)辨識;回覆還在串流、文字變動時,狀態不會跟著帶過去。
  • 按鈕 key 是 copy:<訊息鍵>:<序號>,沒有快捷鍵。複製按鈕用 plain 外觀(沒有 [ ] 外框),配色與其他按鈕相同。
  • 限制:不支援含空白的路徑(遇空白就停);UNC(\\server\share)、相對路徑、file://、ftp:// 都不抓;只對 AssistantMessage 文字比對,不抓工具輸出;路徑末尾的 ) 一律剝掉(C:\a\f(1) 會變成 C:\a\f(1);沒有結尾的 `` 圍欄會被忽略、維持一般文字(不出指令按鈕);指令按鈕只認 `` 圍欄(不認縮排四格的程式碼區塊或行內反引號),也不判斷內容是不是真的指令。
  • ui.copy 的 OSC 52 路徑不回報成功與否(見記憶分頁),貼不出來時請直接選取畫面上的路徑。

流程分頁的秘密遮罩(S4)

流程分頁的工具目標(以及 agent 說明)在截短之前先經過 maskSecrets(hooks/flow-model.ts),所以外掛狀態與畫面都不會留下秘密本體;秘密部分換成 ***,保留可辨識的前綴:

樣式遮罩結果範例
sk- 加 8 個以上英數、_、-(含 sk-ant-…、sk-proj-…;前面不可緊接英數字,所以 task-… 不受影響)sk-ant-api03-ABCDEFGH… → sk-***
ghp_/gho_/ghu_/ghs_/ghr_ 加 20 個以上英數ghp_***
github_pat_ 加 20 個以上英數、_github_pat_***
xoxa-/xoxb-/xoxp-/xoxr-/xoxs- 加 10 個以上xoxb-***
AKIA 加 16 個大寫英數AKIA***
AIza 加 30 個以上AIza***
Bearer <8 個以上字元>(不分大小寫)Bearer ***
Authorization: <值>(含 Bearer/Basic/Token 前綴)Authorization: ***
鍵名為 api_key/api-key/apikey/token/secret/password/passwd/pwd/auth/authorization 的 鍵=值、鍵: 值、JSON "鍵": "值",以及 --鍵 值、--鍵=值API_KEY=*、password: *、--token ***
  • 值的範圍:到空白或引號為止;被引號包住的值整段換掉並保留引號。
  • 不會誤遮的例子(有測試):node scripts/review-check.mjs、git status、檔案路徑、task-management、--max-tokens 4096、author=bob、cat /etc/passwd。
  • 限制:這是樣式比對,不保證涵蓋所有秘密——沒有固定前綴的金鑰(不在上述鍵名之後的隨機字串)、JWT、https://帳號:密碼@主機 內嵌的帳密、鍵名不在清單上的變數都不會被遮;鍵名後面再接字(例如 token_file=…)也不在比對範圍。PWD=/work 這種環境變數會被當成 pwd 一併遮掉(過度遮罩,不影響安全)。遮罩只作用在流程分頁;記憶分頁看內容的原文不遮罩。

資料只在分頁顯示時(以及按「重新整理」或操作後)讀取,不是每次重畫都讀。

檔案

  • .claude-plugin/plugin.json、types/index.d.ts:外掛清單與狀態型別。
  • hooks/hooks.json、hooks/register.tsx:註冊指令、按鈕列、面板與六個分頁、自動開啟、即時活動追蹤 hook、額度取樣與歷史檔、記憶分頁的讀檔與複製、助理回覆下方的複製按鈕。
  • hooks/copy-model.ts:複製按鈕的純函式(路徑、網址與圍欄指令的抽取、標籤、狀態列顯示文字、訊息鍵;不碰 $)。
  • hooks/flow-model.ts:流程與記憶分頁的純函式(計畫書解析、目標摘要、秘密遮罩、活動紀錄、文字樹、路徑與 tokens 顯示;不碰 $,驗證器規定 $ 不跨檔)。
  • scripts/list-sessions.mjs:唯讀 Node 腳本(只用內建模組),找出每個 transcript 最後一筆標題,輸出 { ok, dir, groups: { open, paused, closed } }(每組至多 --limit 個,三組都滿就提前停止掃描)。transcript 可達 20 MB 以上,超過引擎 $.fs.read 的 4 MiB 上限,所以不在 hook 內讀檔。
  • scripts/list-sessions.test.mjs:腳本測試(node --test)。
  • hooks/quota-model.ts:額度分頁的純函式(分段、速度、預測、歷史合併與上限、腳本輸出驗證、顯示字串;不碰 $)。
  • scripts/read-quota.mjs:唯讀 Node 腳本(只用內建模組),讀 Codex 紀錄檔尾的 rate_limits 與 AGY 對話資料夾數,輸出規定欄位的 JSON;錯誤只寫原因、結束碼一律 0。
  • scripts/read-quota.test.mjs:讀取腳本測試(node --test,在暫存目錄造假資料)。
  • tests/quota-model.test.ts:額度純函式測試。
  • tests/quota.test.tsx:額度分頁(顯示、腳本失敗、歷史檔讀寫與合併、取樣時機、寫入間隔、設定目錄)測試。
  • tests/register.test.tsx:Session 分頁測試(S1)。
  • tests/panel.test.tsx:按鈕列、按鈕配色(淡黃色)、分頁、Tool、MCP、自動開啟測試。
  • tests/flow-model.test.ts:計畫書解析、目標摘要、文字樹、200 筆上限、秘密遮罩、路徑顯示的純函式測試。
  • tests/flow.test.tsx:流程分頁(計畫切換、讀檔失敗、即時活動、不存輸入全文、遮罩、重置)測試。
  • tests/copy-model.test.ts:路徑與網址抽取、圍欄指令按鈕(各判斷規則、CRLF、縮排、未結尾圍欄、混合順序與去重、8 顆上限)、標籤、遮罩、狀態列顯示文字、訊息鍵的純函式測試。
  • tests/copybar.test.tsx:回覆下方複製按鈕(引擎繪製保留、摘要與無目標原樣回傳、開關、複製與狀態、失敗原因、8 顆上限、多行標題指令按鈕、配色)測試。
  • tests/memory.test.tsx:記憶分頁(已載入清單、記憶資料夾、看內容、複製路徑、不寫檔)測試。
  • scripts/review-check.mjs:依序跑 validate、plugin test、兩支腳本測試。
  • tsconfig.json:TypeScript 檢查設定。

試用一次

先切到要工作的資料夾(Session 分頁列的是這個資料夾的 session),再在 PowerShell 執行:

claude --plugin-dir <外掛資料夾的完整路徑>

進入後輸入框上方會出現 控制台 Session Tool MCP 流程 記憶 額度。

雙擊啟動(Windows)

open-claude-with-panel.cmd:切到工作資料夾、確認以 API 金鑰登入、用 --plugin-dir 載入本外掛。

  • 工作資料夾預設是外掛資料夾往上兩層(本機原本的 <repo>\projects\claude-session-picker 配置)。
  • 從 GitHub clone 下來時,請先設定使用者環境變數 CLAUDE_WORKDIR 為你的工作資料夾,例如 PowerShell:[Environment]::SetEnvironmentVariable('CLAUDE_WORKDIR','D:\work','User'),重開終端機後雙擊即可。
  • .claude-plugin/types/ 是 Claude Code 在本機以 /plugin-types 產生的型別檔,不放進版本庫(見 .gitignore)。

全螢幕與鍵盤操作

  • 側邊停靠(dock)需要全螢幕(alternate-screen)且終端機寬度 ≥ 110 欄;窄於這個寬度時面板改顯示在輸入框上方(inline)。外掛自己觸發的自動開啟,需寬度 ≥ 144 欄(使用者手動開過、且沒手動關掉的話降為 110 欄)才會畫出;使用者按按鈕或下指令開啟則任何寬度都會畫。
  • 滑鼠點擊只在全螢幕配置可用;主畫面模式不回報點擊(面板會顯示提示),請移動焦點到項目後按 Enter。
  • 面板內:Tab 移動焦點、Enter 按下按鈕、r 重新整理、Esc 關閉。
  • 按鈕列:ctrl+x ctrl+a 收合(型別檔註解)、ctrl+x tab 把焦點移到 band 或面板(型別檔註解)。

讀什麼、不寫什麼

  • 讀:
  • <設定目錄>/projects/<目錄名>/*.jsonl 的檔案屬性與標題行(唯讀;設定目錄為環境變數 CLAUDE_CONFIG_DIR,未設則 ~/.claude)。
  • MCP 分頁:$.session.usage(MCP 工具清單)、$.tool.list(),以及 <CLAUDE_CONFIG_DIR 或家目錄>/.claude.json 一個檔,只取出本專案的 disabledMcpServers、disabledMcpjsonServers 兩個名單裡的伺服器名稱;其他欄位(含 env、headers、args、token)不顯示、不記錄、不存。
  • 流程分頁:$.fs.list/$.fs.read 讀 <session root>/planned/*.md(只讀);工具與 agent 活動來自 tool.call、agent.spawn、turn.start、turn.complete 事件(見上,只存摘要)。
  • 記憶分頁:$.session.usage(memoryFiles)、$.fs.list 列 AutoMem 所在資料夾、$.fs.read 讀你按「看內容」的那一個檔(只讀);「複製路徑」用 $.ui.copy,只複製路徑文字。
  • 複製按鈕:只讀 AssistantMessage 的文字(樣式比對)與 $.store 的 copyBar;按下去用 $.ui.copy 複製該則訊息裡的路徑、網址或圍欄指令原文,不碰檔案、不執行程序(指令只是複製,不會被執行)。
  • 額度分頁:$.session.usage(rateLimits);scripts/read-quota.mjs 讀 ~/.codex/sessions/ 最新紀錄檔的檔尾、列 AGY 對話資料夾(見上);讀自己的歷史檔。
  • 環境變數:CLAUDE_CONFIG_DIR、HOME、USERPROFILE(只為了找上述設定檔與歷史檔)。
  • 寫:只寫一個檔,額度分頁的歷史檔 <CLAUDE_CONFIG_DIR 或 ~/.claude>/plugin-data/session-picker/quota-history.json(只有讀數的五個欄位,見上);autoOpen、copyBar 開關存在引擎的 $.store,不是檔案。唯一對外動作是 /resume <id>、/mcp enable|disable <名稱>(由使用者按鈕觸發)與本外掛的兩支 node 腳本(argv、無 shell);記憶分頁只多一個動作:把路徑文字複製到剪貼簿。
  • 不使用 $.http、$.model、$.settings、$.session.messages。
  • 面板只顯示標題、相對時間、工具名稱與說明、伺服器名稱與狀態、計畫書節點與活動摘要,不顯示對話內容;唯一例外是你主動按「看內容」時顯示的那個記憶檔原文。

已知限制與不確定處

  • 引擎是否允許外掛用 $.command.run({ command: "resume", args }) 觸發內建 /resume 並在原 session 內切換(S1 已由使用者實機確認可用);$.command.run({ command: "mcp", args: "disable <名稱>" }) 是否被引擎接受尚未實機確認,被拒絕時面板顯示「MCP 指令失敗:…」,不改動其他東西。
  • MCP 分頁的狀態只有「已連線」「已停用」「處理中」。引擎沒有提供「連線失敗/連線中」的伺服器清單(沒有工具的伺服器不會出現在 session.usage 的 MCP 工具清單裡),所以連線失敗或還在連線的伺服器不會列出。
  • 「已停用」名單來自 .claude.json 裡本專案的 disabledMcpServers/disabledMcpjsonServers(paneline 同法);讀不到檔案(不存在、超過 4 MiB、被拒)時面板顯示提示,已停用的伺服器可能沒列出。
  • 流程分頁的活動不會補上外掛載入前的紀錄;主迴圈 turn.complete 才會自動重讀計畫書(計畫書在一輪中途改變,要等到那輪結束或按「重新整理」才看得到)。subagent 的歸屬以 agentId 相符為準,agent.spawn 回覆前的短暫空檔內該 agent 的工具會先平放在頂層。
  • 流程分頁的 hook 只由測試與驗證器檢查,尚未實機確認;本機沒有 tsc,型別只靠驗證器與測試執行把關。
  • 記憶分頁尚未實機確認:$.session.usage 的 memoryFiles 在實機是否含 AutoMem、剪貼簿在你的終端機是否真的寫入,都要實測;ui.copy 的 OSC 52 路徑不回報成功與否(見上)。
  • 複製按鈕與淡黃色配色尚未實機確認:非 plain 的按鈕(面板內的「重新整理」「看內容」等)在終端機會畫 [ ] 外框,外框字元的顏色由引擎決定,不一定是深色;請實機看可讀性。
  • 額度分頁尚未實機確認:$.session.usage() 的 rateLimits 在實機每輪是否都有值、Codex 紀錄格式日後是否改版(找不到欄位時顯示「讀不到」),都要實測;預測只是線性估計。
  • 自動開啟是否真的畫出,取決於終端機寬度與全螢幕(見上);被拒時不會報錯。
  • 型別檔是 Claude Code 2.1.302 產生的(外掛內 .claude-plugin/types/claude-code/index.d.ts)。

檢查

node scripts/review-check.mjs
Source 5 files
hooks/register.tsx 1417 lines
1import { atom, read, update } from "claude-code";
2import type { ElementTable, EngineInterface, Register, RenderSurface, ToolCallResult, UiPressArgument } from "claude-code";
3
4import { copyShownOf, extractCopyTargets, messageKeyOf } from "./copy-model";
5import {
6  activityLines,
7  addEvent,
8  CONTROL_CHARS,
9  DESCRIPTION_COLUMNS,
10  dirnameOf,
11  displayPath,
12  dropEvent,
13  emptyActivity,
14  fileNameOf,
15  fit,
16  formatTokens,
17  isDrivePath,
18  isMcpTool,
19  maskSecrets,
20  mcpCommandName,
21  parsePlan,
22  patchEvent,
23  planHeader,
24  planLines,
25  singleLine,
26  startTurn,
27  targetOf,
28  truncate,
29} from "./flow-model";
30import type { ActivityState, PlanNode } from "./flow-model";
31import {
32  addSample,
33  agoLabel,
34  claudeSamples,
35  codexSamples,
36  currentCycle,
37  forecastOf,
38  mergeHistory,
39  parseHistory,
40  parseScriptOutput,
41  quotaBarLine,
42  serializeHistory,
43  trimHistory,
44} from "./quota-model";
45import type { AgyCounts, QuotaSource, QuotaWindow, Sample } from "./quota-model";
46
47const SESSIONS_COMMAND = "sessions";
48const PANEL_COMMAND = "panel";
49const PANE_ID = "control-panel";
50const PANE_TITLE = "控制台";
51const DOCK_COLUMNS = 44;
52const AUTO_OPEN_KEY = "autoOpen";
53const LIST_LIMIT = 10;
54const TITLE_COLUMNS = 40;
55const BAND_LABEL_COLUMNS = 28;
56const PLAN_DIR = "planned";
57const KEPT_PLANS = 5;
58// 狀態前綴(進行中、待驗收、暫結案、暫時結案、結案),顯示標題時去掉;長的「暫時結案」「暫結案」要排在「結案」前面。
59const PREFIX_PATTERN = /^\s*(?:進行中|待驗收|暫時結案|暫結案|結案)\s*[||]\s*/;
60const UNSAFE_SERVER_NAME = /[\s\p{Cc}]/u;
61const CLAUDE_CONFIG_FILE = ".claude.json";
62const MEMORY_DIR_LIMIT = 30;
63const MEMORY_VIEW_LINES = 2000;
64const AUTO_MEMORY_TYPE = "AutoMem";
65const COPY_BAR_KEY = "copyBar";
66// 按鈕一律是固定的淡黃色色塊(S5,使用者指定;不再跟 /theme、/color 變):
67// 一般按鈕底 #FFF3B0、使用中的分頁底 #F2C94C,兩者字都是深色 #3B3000(Button 沒有文字色屬性,改用子層 Text)。
68const CHIP_BG = "#FFF3B0";
69const CHIP_FG = "#3B3000";
70const CHIP_ACTIVE_BG = "#F2C94C";
71// 額度分頁:歷史檔放在 Claude 設定資料夾底下;兩次寫檔至少隔 2 分鐘;讀取腳本 10 秒逾時。
72const QUOTA_HISTORY_FILE = "plugin-data/session-picker/quota-history.json";
73const QUOTA_WRITE_GAP_MS = 120_000;
74const QUOTA_SCRIPT_TIMEOUT_MS = 10_000;
75const WRITE_ERROR_PREFIX = "寫入歷史檔失敗:";
76const QUOTA_WINDOWS: [QuotaWindow, string][] = [
77  ["seven_day", "週用量"],
78  ["five_hour", "5 小時"],
79];
80
81type ChipUi = Pick<ElementTable, "Box" | "Button" | "Text">;
82type ChipOptions = {
83  boxKey: string;
84  // Button 的 key(按下、查找用的位址);label 同時是畫面上的字。
85  key: string;
86  label: string;
87  onPress: (press: UiPressArgument) => void;
88  hotkey?: string;
89  plain?: boolean;
90  active?: boolean;
91  // 左右各留 1 格(plain 按鈕沒有 [ ] 外框)。
92  padded?: boolean;
93};
94
95// 一顆色塊:有底色的小方塊包一個 Button,Button 的子層 Text 指定深色字(粗體只給使用中的分頁)。
96function chipButton(ui: ChipUi, options: ChipOptions) {
97  const { Box, Button, Text } = ui;
98  return (
99    <Box
100      key={options.boxKey}
101      flexDirection="row"
102      flexShrink={0}
103      alignSelf="flex-start"
104      paddingX={options.padded ? 1 : 0}
105      backgroundColor={options.active ? CHIP_ACTIVE_BG : CHIP_BG}
106    >
107      <Button
108        key={options.key}
109        label={options.label}
110        {...(options.plain ? { plain: true as const } : {})}
111        {...(options.hotkey === undefined ? {} : { hotkey: options.hotkey })}
112        onPress={options.onPress}
113      >
114        <Text bold={options.active === true} color={CHIP_FG}>
115          {options.label}
116        </Text>
117      </Button>
118    </Box>
119  );
120}
121
122type Tab = "session" | "tool" | "mcp" | "flow" | "memory" | "quota";
123
124const TABS: { id: Tab; label: string }[] = [
125  { id: "session", label: "Session" },
126  { id: "tool", label: "Tool" },
127  { id: "mcp", label: "MCP" },
128  { id: "flow", label: "流程" },
129  { id: "memory", label: "記憶" },
130  { id: "quota", label: "額度" },
131];
132
133type SessionGroup = "open" | "paused" | "closed";
134type SessionEntry = { id: string; title: string; mtimeMs: number };
135
136// Session 分頁的三個子分頁:未結案/暫時結案/已結案(腳本一次回傳三組,切換子分頁不重跑腳本)。
137const SESSION_GROUPS: { id: SessionGroup; label: string; header: (count: number) => string; empty: string }[] = [
138  { id: "open", label: "未結案", header: (count) => `最近 ${count} 個未結案 session`, empty: "沒有未結案的 session" },
139  { id: "paused", label: "暫時結案", header: (count) => `最近 ${count} 個暫時結案 session`, empty: "沒有暫時結案的 session" },
140  { id: "closed", label: "已結案", header: (count) => `最近 ${count} 個已結案 session`, empty: "沒有已結案的 session" },
141];
142
143type Picker = {
144  groups: Record<SessionGroup, SessionEntry[]>;
145  error: string;
146  actionError: string;
147  loadedAt: number;
148};
149
150type ToolRow = { name: string; description: string; mcp: boolean };
151type ToolsState = { tools: ToolRow[]; error: string };
152
153type McpServer = { name: string; connected: boolean; tools: number };
154type McpState = {
155  servers: McpServer[];
156  pending: string[];
157  error: string;
158  actionError: string;
159  notice: string;
160};
161
162const pickerAtom = atom(
163  { plugin: "session-picker", key: "picker" } as const,
164  { groups: emptyGroups(), error: "", actionError: "", loadedAt: 0 } as Picker,
165);
166// 目前選的 Session 子分頁;獨立成 atom,重新整理(整個 picker 被換掉)時不會跳回「未結案」。
167const sessionGroupAtom = atom({ plugin: "session-picker", key: "sessionGroup" } as const, "open" as SessionGroup);
168const tabAtom = atom({ plugin: "session-picker", key: "tab" } as const, "session" as Tab);
169const toolsAtom = atom(
170  { plugin: "session-picker", key: "tools" } as const,
171  { tools: [], error: "" } as ToolsState,
172);
173const mcpAtom = atom(
174  { plugin: "session-picker", key: "mcp" } as const,
175  { servers: [], pending: [], error: "", actionError: "", notice: "" } as McpState,
176);
177
178type PlanState = {
179  files: { name: string; mtimeMs: number }[];
180  selected: string;
181  nodes: PlanNode[];
182  error: string;
183};
184
185const planAtom = atom(
186  { plugin: "session-picker", key: "plan" } as const,
187  { files: [], selected: "", nodes: [], error: "" } as PlanState,
188);
189let spawnSeq = 0;
190
191const activityAtom = atom({ plugin: "session-picker", key: "activity" } as const, emptyActivity() as ActivityState);
192
193// 回覆下方複製按鈕:開關讀進 atom(render hook 不必每次 await store);
194// 「已複製」狀態只留最近一筆(key 是訊息鍵,只顯示在那一則訊息下面)。
195type CopyStatus = { key: string; text: string; isError: boolean };
196
197const copyBarAtom = atom({ plugin: "session-picker", key: "copyBar" } as const, true);
198const copyStatusAtom = atom(
199  { plugin: "session-picker", key: "copyStatus" } as const,
200  { key: "", text: "", isError: false } as CopyStatus,
201);
202
203type MemoryFile = { type: string; path: string; tokens: number };
204type MemoryState = {
205  files: MemoryFile[];
206  // 載入清單裡有沒有 AutoMem 那筆(沒有就不知道記憶資料夾在哪)。
207  hasAutoMem: boolean;
208  // 記憶資料夾裡未載入的 *.md(絕對路徑,依檔名排序,最多顯示 30 個)與其餘個數。
209  folder: string[];
210  folderMore: number;
211  folderError: string;
212  error: string;
213  viewing: { path: string; lines: string[]; omitted: number } | null;
214  status: string;
215  statusIsError: boolean;
216};
217
218const memoryAtom = atom(
219  { plugin: "session-picker", key: "memory" } as const,
220  {
221    files: [],
222    hasAutoMem: false,
223    folder: [],
224    folderMore: 0,
225    folderError: "",
226    error: "",
227    viewing: null,
228    status: "",
229    statusIsError: false,
230  } as MemoryState,
231);
232
233type QuotaState = {
234  // 記憶體裡的讀數(含從歷史檔合併進來的),只有 Sample 的五個欄位。
235  samples: Sample[];
236  // Codex 最後一筆讀數的紀錄檔修改時間(0=沒有)。
237  codexAt: number;
238  codexReason: string;
239  agy: AgyCounts | null;
240  agyReason: string;
241  // Claude 回報的額度視窗是空的(不是訂閱方案或還沒有第一筆讀數)。
242  claudeEmpty: boolean;
243  historyError: string;
244  scriptError: string;
245  lastWriteAt: number;
246};
247
248const quotaAtom = atom(
249  { plugin: "session-picker", key: "quota" } as const,
250  {
251    samples: [],
252    codexAt: 0,
253    codexReason: "",
254    agy: null,
255    agyReason: "",
256    claudeEmpty: false,
257    historyError: "",
258    scriptError: "",
259    lastWriteAt: 0,
260  } as QuotaState,
261);
262
263function emptyGroups(): Picker["groups"] {
264  return { open: [], paused: [], closed: [] };
265}
266
267function messageOf(error: unknown): string {
268  return error instanceof Error ? error.message : String(error);
269}
270
271// ---------- Session 分頁(S1:讀清單、切換)----------
272
273// 讀取清單:唯一的外部呼叫是 node 腳本(argv、無 shell);失敗都存成錯誤字串,不丟出。
274async function loadList($: EngineInterface): Promise<void> {
275  const next: Picker = { groups: emptyGroups(), error: "", actionError: "", loadedAt: 0 };
276  try {
277    const [root, id, now] = await Promise.all([$.session.root(), $.session.id(), $.clock.now()]);
278    next.loadedAt = now;
279    const result = await $.process.run(
280      [
281        "node",
282        `${$.plugin.root}/scripts/list-sessions.mjs`,
283        "--cwd",
284        root,
285        "--exclude",
286        id,
287        "--limit",
288        String(LIST_LIMIT),
289      ],
290      { timeoutMs: 20000 },
291    );
292    const firstLine = (result.stderr.trim().split("\n")[0] ?? "").trim();
293    if (result.exitCode !== 0) {
294      next.error = `讀取 session 清單失敗(結束碼 ${result.exitCode})${firstLine ? `:${firstLine}` : ""}`;
295    } else {
296      const parsed = JSON.parse(result.stdout) as {
297        ok?: boolean;
298        error?: string;
299        groups?: Partial<Record<SessionGroup, SessionEntry[]>>;
300      };
301      const groups = parsed.groups;
302      if (parsed.ok === true && groups !== undefined && SESSION_GROUPS.every((entry) => Array.isArray(groups[entry.id]))) {
303        next.groups = { open: groups.open ?? [], paused: groups.paused ?? [], closed: groups.closed ?? [] };
304      } else next.error = `讀取 session 清單失敗:${parsed.error ?? "回應格式不符"}`;
305    }
306  } catch (error) {
307    next.error = `讀取 session 清單失敗:${messageOf(error)}`;
308  }
309  await update($, pickerAtom, () => next);
310}
311
312async function resumeSession($: EngineInterface, id: string): Promise<void> {
313  try {
314    await $.command.run({ command: "resume", args: id });
315  } catch (error) {
316    await update($, pickerAtom, (state) => ({ ...state, actionError: `切換失敗:${messageOf(error)}` }));
317  }
318}
319
320// ---------- Tool 分頁(唯讀)----------
321
322async function loadTools($: EngineInterface): Promise<void> {
323  let next: ToolsState;
324  try {
325    const list = await $.tool.list();
326    next = {
327      tools: list.map((tool) => ({
328        name: singleLine(tool.name),
329        description: singleLine((tool.description ?? "").split("\n")[0] ?? ""),
330        mcp: tool.mcp === true || tool.name.startsWith("mcp__"),
331      })),
332      error: "",
333    };
334  } catch (error) {
335    next = { tools: [], error: `讀取工具清單失敗:${messageOf(error)}` };
336  }
337  await update($, toolsAtom, () => next);
338}
339
340// ---------- MCP 分頁 ----------
341
342function normalizePath(path: string): string {
343  return path.replace(/\\/g, "/").replace(/\/+$/, "") || "/";
344}
345
346function ancestorsOf(path: string): string[] {
347  const parts = normalizePath(path).split("/");
348  return parts.map((_, index) => parts.slice(0, parts.length - index).join("/") || "/");
349}
350
351// 只從設定檔取出「本專案的停用伺服器名稱」;其他欄位(env、headers、args、token…)一律不碰、不存、不顯示。
352function disabledNamesOf(configText: string, cwd: string): string[] {
353  let parsed: unknown;
354  try {
355    parsed = JSON.parse(configText);
356  } catch {
357    return [];
358  }
359  const projects = (parsed as { projects?: Record<string, unknown> } | null)?.projects;
360  if (projects === null || typeof projects !== "object") return [];
361  const byPath = new Map<string, unknown>();
362  for (const [path, project] of Object.entries(projects)) byPath.set(normalizePath(path), project);
363  const root = ancestorsOf(cwd).find((path) => byPath.has(path));
364  const project = root === undefined ? undefined : (byPath.get(root) as Record<string, unknown> | undefined);
365  if (project === undefined || project === null || typeof project !== "object") return [];
366  const names: string[] = [];
367  for (const key of ["disabledMcpServers", "disabledMcpjsonServers"]) {
368    const value = project[key];
369    if (Array.isArray(value)) for (const name of value) if (typeof name === "string") names.push(name);
370  }
371  return names;
372}
373
374async function configPathOf($: EngineInterface): Promise<string | undefined> {
375  const configDir = await $.env.get("CLAUDE_CONFIG_DIR");
376  if (configDir) return `${configDir}/${CLAUDE_CONFIG_FILE}`;
377  const home = (await $.env.get("HOME")) ?? (await $.env.get("USERPROFILE"));
378  return home ? `${home}/${CLAUDE_CONFIG_FILE}` : undefined;
379}
380
381// MCP 與記憶分頁共用:summary 只在本機估算、不送任何請求;memoryFiles、mcpTools 都在 context.breakdown 底下。
382async function usageBreakdown($: EngineInterface) {
383  const { context } = await $.session.usage({ breakdown: "summary" });
384  return context.breakdown;
385}
386
387async function loadMcp($: EngineInterface): Promise<void> {
388  const next: McpState = { servers: [], pending: [], error: "", actionError: "", notice: "" };
389  try {
390    const breakdown = await usageBreakdown($);
391    const counts = new Map<string, number>();
392    for (const tool of breakdown?.mcpTools ?? []) {
393      const name = mcpCommandName(tool.serverName);
394      counts.set(name, (counts.get(name) ?? 0) + 1);
395    }
396    const servers: McpServer[] = [...counts].map(([name, tools]) => ({ name, connected: true, tools }));
397
398    try {
399      const path = await configPathOf($);
400      if (path === undefined) throw new Error("找不到使用者目錄");
401      const text = await $.fs.read(path);
402      const cwd = await $.session.cwd();
403      for (const name of disabledNamesOf(text, cwd)) {
404        if (!counts.has(name)) servers.push({ name, connected: false, tools: 0 });
405      }
406    } catch (error) {
407      next.notice = `未能讀取停用名單(${messageOf(error)}),已停用的伺服器可能沒有列出`;
408    }
409    next.servers = servers;
410  } catch (error) {
411    next.error = `讀取 MCP 伺服器失敗:${messageOf(error)}`;
412  }
413  await update($, mcpAtom, (state) => ({ ...next, pending: state.pending }));
414}
415
416async function toggleMcp($: EngineInterface, server: McpServer): Promise<void> {
417  const action = server.connected ? "disable" : "enable";
418  const verb = server.connected ? "停用" : "啟用";
419  if (server.name === "" || UNSAFE_SERVER_NAME.test(server.name)) {
420    await update($, mcpAtom, (state) => ({
421      ...state,
422      actionError: `無法${verb}「${singleLine(server.name) || "(空白名稱)"}」:名稱含空白或控制字元,未送出指令`,
423    }));
424    return;
425  }
426  await update($, mcpAtom, (state) => ({
427    ...state,
428    actionError: "",
429    pending: [...state.pending, server.name],
430  }));
431  let failure = "";
432  try {
433    await $.command.run({ command: "mcp", args: `${action} ${server.name}` });
434  } catch (error) {
435    failure = `MCP 指令失敗:${singleLine(messageOf(error))}`;
436  }
437  await update($, mcpAtom, (state) => ({
438    ...state,
439    pending: state.pending.filter((name) => name !== server.name),
440  }));
441  await loadMcp($);
442  if (failure) await update($, mcpAtom, (state) => ({ ...state, actionError: failure }));
443}
444
445// ---------- 記憶分頁(S4:已載入的記憶檔+記憶資料夾;只讀、只複製路徑)----------
446
447function pathKey(path: string): string {
448  const normalized = normalizePath(path);
449  return isDrivePath(path) ? normalized.toLowerCase() : normalized;
450}
451
452async function loadMemory($: EngineInterface): Promise<void> {
453  const next: MemoryState = {
454    files: [],
455    hasAutoMem: false,
456    folder: [],
457    folderMore: 0,
458    folderError: "",
459    error: "",
460    viewing: null,
461    status: "",
462    statusIsError: false,
463  };
464  try {
465    const breakdown = await usageBreakdown($);
466    next.files = (breakdown?.memoryFiles ?? []).map((file) => ({
467      type: singleLine(file.type),
468      path: file.path,
469      tokens: file.tokens,
470    }));
471  } catch (error) {
472    next.error = `讀取記憶檔清單失敗:${singleLine(messageOf(error))}`;
473  }
474  const auto = next.files.find((file) => file.type === AUTO_MEMORY_TYPE);
475  next.hasAutoMem = auto !== undefined;
476  if (auto !== undefined) {
477    const dir = dirnameOf(auto.path);
478    try {
479      const loaded = new Set(next.files.map((file) => pathKey(file.path)));
480      const names = (await $.fs.list(dir))
481        .filter((entry) => entry.kind === "file" && /\.md$/i.test(entry.name))
482        .map((entry) => entry.name)
483        .filter((name) => !loaded.has(pathKey(`${dir}/${name}`)))
484        .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
485      next.folder = names.slice(0, MEMORY_DIR_LIMIT).map((name) => `${dir}/${name}`);
486      next.folderMore = Math.max(names.length - MEMORY_DIR_LIMIT, 0);
487    } catch (error) {
488      next.folderError = `讀取記憶資料夾失敗:${singleLine(messageOf(error))}`;
489    }
490  }
491  await update($, memoryAtom, () => next);
492}
493
494// 只讀 fs.read;內容原樣顯示(不遮罩,使用者讀自己的檔案),只把控制字元換成空白以免弄亂畫面。
495async function viewMemory($: EngineInterface, path: string): Promise<void> {
496  try {
497    const text = await $.fs.read(path);
498    const all = text.split(/\r?\n/).map((line) => line.replace(/\t/g, "  ").replace(CONTROL_CHARS, " "));
499    await update($, memoryAtom, (state) => ({
500      ...state,
501      viewing: { path, lines: all.slice(0, MEMORY_VIEW_LINES), omitted: Math.max(all.length - MEMORY_VIEW_LINES, 0) },
502      status: "",
503      statusIsError: false,
504    }));
505  } catch (error) {
506    await update($, memoryAtom, (state) => ({
507      ...state,
508      status: `讀取失敗:${singleLine(messageOf(error))}`,
509      statusIsError: true,
510    }));
511  }
512}
513
514async function backToMemoryList($: EngineInterface): Promise<void> {
515  await update($, memoryAtom, (state) => ({ ...state, viewing: null, status: "", statusIsError: false }));
516}
517
518// 複製絕對路徑(Windows 反斜線形式);surface 取自按鈕的 press 引數。失敗時顯示原因,路徑仍在畫面上可手動選取。
519async function copyMemoryPath($: EngineInterface, path: string, surface: RenderSurface): Promise<void> {
520  const copied = displayPath(path);
521  const shown = maskSecrets(copied);
522  let status: string;
523  let statusIsError = false;
524  try {
525    const result = await $.ui.copy({ text: copied, surface });
526    if (result.isCopied) status = `✔ 已複製:${shown}`;
527    else {
528      status = `✘ 複製失敗(${result.reason}):${shown}`;
529      statusIsError = true;
530    }
531  } catch (error) {
532    status = `✘ 複製失敗(${singleLine(messageOf(error))}):${shown}`;
533    statusIsError = true;
534  }
535  await update($, memoryAtom, (state) => ({ ...state, status, statusIsError }));
536}
537
538// ---------- 回覆下方的複製按鈕(S5)----------
539
540// 複製原文(一字不改);狀態只顯示在該則訊息下面,顯示用的文字去掉控制字元並遮罩疑似祕密(複製的仍是原文);
541// 多行指令只顯示第一行加「…(共 N 行)」,狀態列保持單行。
542async function copyReplyTarget($: EngineInterface, msgKey: string, raw: string, surface: RenderSurface): Promise<void> {
543  const shown = copyShownOf(raw);
544  let text: string;
545  let isError = false;
546  try {
547    const result = await $.ui.copy({ text: raw, surface });
548    if (result.isCopied) text = `✔ 已複製:${shown}`;
549    else {
550      text = `✘ 複製失敗(${result.reason}):${shown}`;
551      isError = true;
552    }
553  } catch (error) {
554    text = `✘ 複製失敗(${singleLine(messageOf(error))}):${shown}`;
555    isError = true;
556  }
557  await update($, copyStatusAtom, () => ({ key: msgKey, text, isError }));
558}
559
560// 讀不到設定時視為預設值(開啟)。
561async function copyBarOf($: EngineInterface): Promise<boolean> {
562  try {
563    return (await $.store.get(COPY_BAR_KEY)) !== false;
564  } catch {
565    return true;
566  }
567}
568
569async function syncCopyBar($: EngineInterface): Promise<void> {
570  const isOn = await copyBarOf($);
571  await quietly(update($, copyBarAtom, () => isOn));
572}
573
574// ---------- 流程分頁(S3:計畫進度+本 session 即時活動)----------
575
576// 只讀:列出 <session root>/planned 直接底下的 *.md(子目錄不看),依修改時間新到舊取前 5 份,
577// 讀選定的那份並解析節點。任何失敗都存成錯誤字串,不丟出。
578async function loadPlan($: EngineInterface, pick?: string): Promise<void> {
579  const wanted = pick ?? (await read($, planAtom)).selected;
580  const next: PlanState = { files: [], selected: "", nodes: [], error: "" };
581  try {
582    const dir = `${normalizePath(await $.session.root())}/${PLAN_DIR}`;
583    const entries = await $.fs.list(dir);
584    next.files = entries
585      .filter((entry) => entry.kind === "file" && /\.md$/i.test(entry.name))
586      .sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name))
587      .slice(0, KEPT_PLANS)
588      .map((entry) => ({ name: entry.name, mtimeMs: entry.mtimeMs }));
589    const chosen = next.files.find((file) => file.name === wanted) ?? next.files[0];
590    if (chosen !== undefined) {
591      next.selected = chosen.name;
592      try {
593        next.nodes = parsePlan(await $.fs.read(`${dir}/${chosen.name}`));
594      } catch (error) {
595        next.error = `讀取計畫書失敗:${singleLine(messageOf(error))}`;
596      }
597    }
598  } catch (error) {
599    next.error = `讀取 ${PLAN_DIR}/ 失敗:${singleLine(messageOf(error))}`;
600  }
601  await update($, planAtom, () => next);
602}
603
604// ‹ 往較新、› 往較舊(清單新到舊),到頭就繞回另一端。
605async function stepPlan($: EngineInterface, delta: number): Promise<void> {
606  const { files, selected } = await read($, planAtom);
607  if (files.length === 0) return;
608  const at = Math.max(
609    files.findIndex((file) => file.name === selected),
610    0,
611  );
612  await loadPlan($, files[(at + delta + files.length) % files.length]?.name);
613}
614
615// 追蹤用的寫入失敗不可影響工具、turn 本身。
616async function quietly(work: Promise<unknown>): Promise<void> {
617  try {
618    await work;
619  } catch {
620    // 追蹤是附帶功能,寫不進去就算了
621  }
622}
623
624// 本輪面板若開在流程分頁就重讀計畫書(節點狀態會隨工作進度改變)。
625async function refreshFlowIfOpen($: EngineInterface): Promise<void> {
626  try {
627    if ((await read($, tabAtom)) !== "flow") return;
628    if (!(await $.ui.panes()).some((pane) => pane.id === PANE_ID)) return;
629    await loadPlan($);
630  } catch {
631    // 重讀失敗不影響 turn
632  }
633}
634
635// ---------- 額度分頁(Claude/Codex 預測、AGY 使用次數)----------
636
637// 歷史檔路徑:CLAUDE_CONFIG_DIR,否則 HOME(或 USERPROFILE)/.claude;都沒有就不存歷史。
638async function quotaHistoryPathOf($: EngineInterface): Promise<string | undefined> {
639  const configDir = await $.env.get("CLAUDE_CONFIG_DIR");
640  if (configDir) return `${normalizePath(configDir)}/${QUOTA_HISTORY_FILE}`;
641  const home = (await $.env.get("HOME")) ?? (await $.env.get("USERPROFILE"));
642  return home ? `${normalizePath(home)}/.claude/${QUOTA_HISTORY_FILE}` : undefined;
643}
644
645// 讀歷史檔:檔案不存在回傳 [];格式不符回傳 undefined(改用記憶體樣本重建)。讀檔錯誤往外丟。
646async function readQuotaHistory($: EngineInterface, path: string): Promise<Sample[] | undefined> {
647  if (!(await $.fs.exists(path))) return [];
648  return parseHistory(await $.fs.read(path));
649}
650
651// 把歷史檔合併進記憶體(分頁打開、重新整理時;其他 session 寫進去的讀數也會一起顯示)。
652async function mergeQuotaHistory($: EngineInterface, now: number): Promise<void> {
653  let historyError = "";
654  let fileSamples: Sample[] = [];
655  try {
656    const path = await quotaHistoryPathOf($);
657    if (path !== undefined) {
658      const parsed = await readQuotaHistory($, path);
659      if (parsed === undefined) historyError = "歷史檔格式不符,改用本次記錄重建";
660      else fileSamples = parsed;
661    }
662  } catch (error) {
663    historyError = `讀取歷史檔失敗:${singleLine(messageOf(error))}`;
664  }
665  await update($, quotaAtom, (state) => ({
666    ...state,
667    samples: trimHistory(mergeHistory(fileSamples, state.samples), now),
668    historyError,
669  }));
670}
671
672// 寫歷史檔:寫前先讀檔再合併(多個 session 同時開著也不會互相蓋掉);距上次寫入不到 2 分鐘就先不寫。
673async function persistQuota($: EngineInterface, now: number): Promise<void> {
674  const state = await read($, quotaAtom);
675  if (state.samples.length === 0 || now - state.lastWriteAt < QUOTA_WRITE_GAP_MS) return;
676  let writeError = "";
677  let merged = state.samples;
678  try {
679    const path = await quotaHistoryPathOf($);
680    if (path === undefined) return;
681    // 格式不符的舊檔當成空的,由這次的記錄重建。
682    const fileSamples = (await readQuotaHistory($, path)) ?? [];
683    merged = trimHistory(mergeHistory(fileSamples, state.samples), now);
684    await $.fs.write(path, serializeHistory(merged));
685  } catch (error) {
686    writeError = `${WRITE_ERROR_PREFIX}${singleLine(messageOf(error))}`;
687  }
688  await update($, quotaAtom, (current) => ({
689    ...current,
690    samples: trimHistory(mergeHistory(merged, current.samples), now),
691    // 寫成功只清掉上次的寫入錯誤,讀取時的說明(例如已重建)留著。
692    historyError: writeError || (current.historyError.startsWith(WRITE_ERROR_PREFIX) ? "" : current.historyError),
693    lastWriteAt: now,
694  }));
695}
696
697async function recordQuota($: EngineInterface, fresh: Sample[], now: number): Promise<void> {
698  if (fresh.length === 0) return;
699  await update($, quotaAtom, (state) => ({
700    ...state,
701    samples: fresh.reduce((history, sample) => addSample(history, sample, now), state.samples),
702  }));
703  await persistQuota($, now);
704}
705
706// 每輪(主迴圈)記一筆 Claude 讀數;不必開著分頁,否則沒有歷史可算速度。整段失敗都吞掉,不影響 turn。
707async function sampleClaudeQuota($: EngineInterface): Promise<void> {
708  try {
709    const { rateLimits } = await $.session.usage();
710    const now = await $.clock.now();
711    const fresh = claudeSamples(rateLimits, now);
712    const claudeEmpty = fresh.length === 0;
713    if ((await read($, quotaAtom)).claudeEmpty !== claudeEmpty) {
714      await update($, quotaAtom, (state) => ({ ...state, claudeEmpty }));
715    }
716    await recordQuota($, fresh, now);
717  } catch {
718    // 取樣是附帶功能,失敗就算了
719  }
720}
721
722// 讀 Codex 與 AGY:唯一的外部呼叫是 node 腳本(argv、無 shell);失敗都存成錯誤字串,不丟出。
723async function loadQuota($: EngineInterface): Promise<void> {
724  let now = 0;
725  try {
726    now = await $.clock.now();
727    await mergeQuotaHistory($, now);
728    const result = await $.process.run(["node", `${$.plugin.root}/scripts/read-quota.mjs`], {
729      timeoutMs: QUOTA_SCRIPT_TIMEOUT_MS,
730    });
731    const firstLine = (result.stderr.trim().split("\n")[0] ?? "").trim();
732    const parsed = result.exitCode === 0 ? parseScriptOutput(result.stdout) : undefined;
733    if (parsed === undefined) {
734      const why = result.exitCode !== 0 ? `結束碼 ${result.exitCode}` : "回應格式不符";
735      await update($, quotaAtom, (state) => ({
736        ...state,
737        scriptError: `讀取 Codex/AGY 失敗(${why})${firstLine ? `:${singleLine(firstLine)}` : ""}`,
738      }));
739      return;
740    }
741    await update($, quotaAtom, (state) => ({
742      ...state,
743      codexAt: parsed.codex?.observedAt ?? state.codexAt,
744      codexReason: parsed.codexReason ?? "",
745      agy: parsed.agy,
746      agyReason: parsed.agyReason ?? "",
747      scriptError: "",
748    }));
749    if (parsed.codex !== null) await recordQuota($, codexSamples(parsed.codex), now);
750  } catch (error) {
751    await update($, quotaAtom, (state) => ({
752      ...state,
753      scriptError: `讀取 Codex/AGY 失敗:${singleLine(messageOf(error))}`,
754    }));
755  }
756}
757
758// 本輪面板若開在額度分頁就重讀 Codex/AGY。
759async function refreshQuotaIfOpen($: EngineInterface): Promise<void> {
760  try {
761    if ((await read($, tabAtom)) !== "quota") return;
762    if (!(await $.ui.panes()).some((pane) => pane.id === PANE_ID)) return;
763    await loadQuota($);
764  } catch {
765    // 重讀失敗不影響 turn
766  }
767}
768
769// 一個來源的兩個視窗(週用量在前),每個視窗一行進度條;沒有讀數的視窗不列。
770function quotaRows(
771  samples: Sample[],
772  source: QuotaSource,
773  now: number,
774  columns: number,
775): ReturnType<typeof quotaBarLine>[] {
776  const rows: ReturnType<typeof quotaBarLine>[] = [];
777  for (const [window, label] of QUOTA_WINDOWS) {
778    const forecast = forecastOf(currentCycle(samples, source, window), now);
779    if (forecast !== undefined) rows.push(quotaBarLine(label, forecast, columns));
780  }
781  return rows;
782}
783
784// ---------- 面板開啟、分頁切換 ----------
785
786async function loadTab($: EngineInterface, tab: Tab): Promise<void> {
787  if (tab === "session") await loadList($);
788  else if (tab === "tool") await loadTools($);
789  else if (tab === "mcp") await loadMcp($);
790  else if (tab === "memory") await loadMemory($);
791  else if (tab === "quota") await loadQuota($);
792  else await loadPlan($);
793}
794
795async function switchTab($: EngineInterface, tab: Tab): Promise<void> {
796  await update($, tabAtom, () => tab);
797  await loadTab($, tab);
798}
799
800// 開啟控制台:先讀目前分頁的資料再開面板;被引擎拒絕時由呼叫端接住。
801async function openPanel($: EngineInterface, tab: Tab | undefined, focus: boolean): Promise<void> {
802  const target = tab ?? (await read($, tabAtom));
803  await switchTab($, target);
804  await $.ui.open({
805    id: PANE_ID,
806    title: PANE_TITLE,
807    closeOnEscape: true,
808    columns: DOCK_COLUMNS,
809    ...(focus ? { focus: true as const } : {}),
810  });
811}
812
813function openQuietly($: EngineInterface, tab: Tab | undefined, focus: boolean): void {
814  openPanel($, tab, focus).catch((error: unknown) => {
815    $.ui.log(`開啟控制台失敗:${messageOf(error)}`, { to: "debug" });
816  });
817}
818
819// 讀不到設定時視為預設值(開啟);不讓設定讀取失敗影響 session.start。
820async function autoOpenOf($: EngineInterface): Promise<boolean> {
821  try {
822    return (await $.store.get(AUTO_OPEN_KEY)) !== false;
823  } catch {
824    return true;
825  }
826}
827
828// ---------- 顯示輔助 ----------
829
830function relativeTime(mtimeMs: number, now: number): string {
831  const seconds = Math.max(0, Math.floor((now - mtimeMs) / 1000));
832  if (seconds < 60) return "剛剛";
833  const minutes = Math.floor(seconds / 60);
834  if (minutes < 60) return `${minutes} 分鐘前`;
835  const hours = Math.floor(minutes / 60);
836  if (hours < 24) return `${hours} 小時前`;
837  return `${Math.floor(hours / 24)} 天前`;
838}
839
840function labelOf(session: SessionEntry, now: number): string {
841  const title = session.title.replace(PREFIX_PATTERN, "").replace(/\s+/g, " ").trim() || "(無標題)";
842  return `${truncate(title, TITLE_COLUMNS)} · ${relativeTime(session.mtimeMs, now)}`;
843}
844
845function toolLine(tool: ToolRow, columns: number): string {
846  const text = tool.description ? `${tool.name}  ${tool.description}` : tool.name;
847  return truncate(text, columns);
848}
849
850export const register: Register = (on) => {
851  on("session.start", { isInteractive: true }, async ($, e, next) => {
852    await $.command.register({ name: SESSIONS_COMMAND, description: "開啟控制台(Session 分頁:未結案/暫時結案/已結案 session)" });
853    await $.command.register({
854      name: PANEL_COMMAND,
855      description: "開啟控制台;/panel auto on|off 設定自動開啟;/panel copybar on|off 設定回覆下方的複製按鈕",
856    });
857    await syncCopyBar($);
858    if (await autoOpenOf($)) openQuietly($, undefined, false);
859    return next(e);
860  });
861
862  on("classic.SessionStart", { source: ["clear", "resume", "fork"] }, async ($, e, next) => {
863    // 對話換了(/clear、resume、fork):舊的即時活動不屬於「本 session」,清掉。
864    await quietly(update($, activityAtom, () => emptyActivity()));
865    await syncCopyBar($);
866    if (await autoOpenOf($)) openQuietly($, undefined, false);
867    return next(e);
868  });
869
870  // ----- 即時活動追蹤:每個 hook 都原樣交還 next(e),只記名稱、截短目標、耗時、成敗與從屬 -----
871
872  on("turn.start", async ($, e, next) => {
873    if (e.agentId === undefined) await quietly(update($, activityAtom, (state) => startTurn(state)));
874    return next(e);
875  });
876
877  on("turn.complete", async ($, e, next) => {
878    if (e.agentId === undefined) {
879      await sampleClaudeQuota($);
880      await refreshFlowIfOpen($);
881      await refreshQuotaIfOpen($);
882    }
883    return next(e);
884  });
885
886  on("tool.call", async ($, e, next) => {
887    // Agent 工具本身由 agent.spawn 記成一個 agent 節點,不重複記成工具。
888    const toolName: string = e.tool;
889    if (toolName === "Agent" || toolName === "Task") return next(e);
890    const id = e.tool_use_id;
891    const startedAt = await $.clock.now();
892    await quietly(
893      update($, activityAtom, (state) =>
894        addEvent(state, {
895          id,
896          kind: "tool",
897          name: singleLine(e.tool),
898          target: isMcpTool(e.tool) ? "" : targetOf(e),
899          ...(e.agentId === undefined ? {} : { agentId: e.agentId }),
900          model: "",
901          startedAt,
902        }),
903      ),
904    );
905    const finish = async (ok: boolean): Promise<void> => {
906      const durationMs = (await $.clock.now()) - startedAt;
907      await quietly(update($, activityAtom, (state) => patchEvent(state, id, { durationMs, ok })));
908    };
909    let ran: ToolCallResult;
910    try {
911      ran = await next(e);
912    } catch (error) {
913      await finish(false);
914      throw error;
915    }
916    await finish(ran.deny === undefined && ran.isError !== true);
917    return ran;
918  });
919
920  on("agent.spawn", async ($, e, next) => {
921    const id = `agent:${e.tool_use_id}:${await $.clock.now()}:${++spawnSeq}`;
922    await quietly(
923      update($, activityAtom, (state) =>
924        addEvent(state, {
925          id,
926          kind: "agent",
927          name: singleLine(e.subagentType),
928          target: fit(maskSecrets(singleLine(e.description)), DESCRIPTION_COLUMNS),
929          ...(e.parentAgentId === undefined ? {} : { parentAgentId: e.parentAgentId }),
930          model: singleLine(e.model ?? ""),
931          startedAt: 0,
932        }),
933      ),
934    );
935    let spawn: Awaited<ReturnType<typeof next>>;
936    try {
937      spawn = await next(e);
938    } catch (error) {
939      await quietly(update($, activityAtom, (state) => dropEvent(state, id)));
940      throw error;
941    }
942    if (spawn.deny !== undefined) {
943      await quietly(update($, activityAtom, (state) => dropEvent(state, id)));
944    } else {
945      await quietly(
946        update($, activityAtom, (state) =>
947          patchEvent(state, id, {
948            model: singleLine(spawn.model),
949            ...(spawn.agentId === undefined ? {} : { agentId: spawn.agentId }),
950          }),
951        ),
952      );
953    }
954    return spawn;
955  });
956
957  on("command.run", { command: SESSIONS_COMMAND }, async ($) => {
958    // /sessions 一律落在「未結案」子分頁。
959    await update($, sessionGroupAtom, () => "open");
960    await openPanel($, "session", true);
961    return {};
962  }).catch(($, e, next) => {
963    const text = `開啟控制台失敗:${next.error.message ?? "未知錯誤"}`;
964    $.ui.log(text, { to: "debug" });
965    return { text };
966  });
967
968  on("command.run", { command: PANEL_COMMAND }, async ($, e) => {
969    const args = e.args.trim().split(/\s+/).filter((part) => part !== "");
970    if (args.length === 0) {
971      await openPanel($, undefined, true);
972      return {};
973    }
974    const [sub, value] = args;
975    if (args.length === 2 && sub === "auto" && (value === "on" || value === "off")) {
976      await $.store.set(AUTO_OPEN_KEY, value === "on");
977      return {
978        text:
979          value === "on"
980            ? "控制台自動開啟:已開啟(開新對話、/clear、resume、fork 時自動開面板)"
981            : "控制台自動開啟:已關閉(仍可用 /panel、/sessions 或輸入框上方的按鈕開啟)",
982      };
983    }
984    if (args.length === 2 && sub === "copybar" && (value === "on" || value === "off")) {
985      await $.store.set(COPY_BAR_KEY, value === "on");
986      await update($, copyBarAtom, () => value === "on");
987      return {
988        text:
989          value === "on"
990            ? "回覆下方的複製按鈕:已開啟(回覆含 Windows 絕對路徑或網址時,下方列出複製按鈕)"
991            : "回覆下方的複製按鈕:已關閉",
992      };
993    }
994    return { text: "用法:/panel 開啟控制台;/panel auto on|off 設定自動開啟;/panel copybar on|off 設定回覆下方的複製按鈕" };
995  }).catch(($, e, next) => {
996    const text = `開啟控制台失敗:${next.error.message ?? "未知錯誤"}`;
997    $.ui.log(text, { to: "debug" });
998    return { text };
999  });
1000
1001  // 助理回覆下方的複製按鈕列:先取引擎的繪製(原樣保留),再把一列按鈕疊在它下面。
1002  // 摘要區塊、開關關閉、沒有路徑/網址時原樣回傳 drawn(同一個物件);絕不改動 e.props(isSummary、onScreen 照引擎給的)。
1003  on("ui.render", { component: "AssistantMessage" }, async ($, e, next) => {
1004    const drawn = await next(e);
1005    if (e.props.isSummary === true) return drawn;
1006    if (!(await read($, copyBarAtom))) return drawn;
1007    const { shown, more } = extractCopyTargets(e.props.text);
1008    if (shown.length === 0) return drawn;
1009
1010    const msgKey = messageKeyOf(e.props.text);
1011    const status = await read($, copyStatusAtom);
1012    const { Box, Button, Text } = $.ui.resolve(e);
1013    return (
1014      <Box key={`copybar-${msgKey}`} flexDirection="column">
1015        {drawn}
1016        <Box key={`copybar-row-${msgKey}`} flexDirection="row" flexWrap="wrap" columnGap={1}>
1017          {shown.map((target, index) =>
1018            chipButton(
1019              { Box, Button, Text },
1020              {
1021                boxKey: `copy-chip-${msgKey}-${index}`,
1022                key: `copy:${msgKey}:${index}`,
1023                label: target.label,
1024                plain: true,
1025                padded: true,
1026                onPress: (press) => void copyReplyTarget($, msgKey, target.raw, press.surface),
1027              },
1028            ),
1029          )}
1030          {more > 0 ? <Text dimColor>還有 {more} 個</Text> : null}
1031        </Box>
1032        {status.key === msgKey && status.text !== "" ? (
1033          status.isError ? (
1034            <Text key={`copy-status-${msgKey}`} color="error">
1035              {status.text}
1036            </Text>
1037          ) : (
1038            <Text key={`copy-status-${msgKey}`}>{status.text}</Text>
1039          )
1040        ) : null}
1041      </Box>
1042    );
1043  });
1044
1045  // 輸入框上方的按鈕列:與其他外掛的 AbovePrompt 疊加(先取下一層,再把自己放在它上面)。
1046  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
1047    const below = await next(e);
1048    if (e.props.hasSurvey) return below;
1049
1050    const { Box, Button, Text } = $.ui.resolve(e);
1051    const showLabel = e.props.bodyColumns >= BAND_LABEL_COLUMNS;
1052    return (
1053      <Box key="band" flexDirection="column">
1054        <Box key="band-row" flexDirection="row" columnGap={1} width={e.props.bodyColumns} height={1} overflow="hidden">
1055          {showLabel ? (
1056            <Text dimColor wrap="truncate-end">
1057              控制台
1058            </Text>
1059          ) : null}
1060          {TABS.map((tab) =>
1061            chipButton(
1062              { Box, Button, Text },
1063              {
1064                boxKey: `band-chip-${tab.id}`,
1065                key: `band-${tab.id}`,
1066                label: tab.label,
1067                plain: true,
1068                padded: true,
1069                onPress: () => openQuietly($, tab.id, true),
1070              },
1071            ),
1072          )}
1073        </Box>
1074        {below}
1075      </Box>
1076    );
1077  });
1078
1079  on("ui.render", { component: "Pane", requestId: PANE_ID }, async ($, e) => {
1080    const [tab, picker, sessionGroup, tools, mcp, memory, now] = await Promise.all([
1081      read($, tabAtom),
1082      read($, pickerAtom),
1083      read($, sessionGroupAtom),
1084      read($, toolsAtom),
1085      read($, mcpAtom),
1086      read($, memoryAtom),
1087      $.clock.now(),
1088    ]);
1089    const { Box, Button, Text } = $.ui.resolve(e);
1090    const chip = (options: ChipOptions) => chipButton({ Box, Button, Text }, options);
1091    const showKeyboardHint = e.surface === "terminal" && e.viewport?.isFullscreen === false;
1092    const columns = Math.max(e.props.bodyColumns, 12);
1093
1094    const tabBar = (
1095      <Box key="tabs" flexDirection="row" flexWrap="wrap" columnGap={1}>
1096        {TABS.map((entry) => {
1097          const isActive = entry.id === tab;
1098          const label = isActive ? `[${entry.label}]` : entry.label;
1099          return chip({
1100            boxKey: `tab-chip-${entry.id}`,
1101            key: `tab-${entry.id}`,
1102            label,
1103            plain: true,
1104            active: isActive,
1105            padded: true,
1106            onPress: () => void switchTab($, entry.id),
1107          });
1108        })}
1109      </Box>
1110    );
1111
1112    let body;
1113    if (tab === "session") {
1114      const group = SESSION_GROUPS.find((entry) => entry.id === sessionGroup) ?? SESSION_GROUPS[0]!;
1115      const sessions = picker.groups[group.id];
1116      const groupBar = (
1117        <Box key="groups" flexDirection="row" flexWrap="wrap" columnGap={1}>
1118          {SESSION_GROUPS.map((entry) => {
1119            const isActive = entry.id === group.id;
1120            const text = `${entry.label}(${picker.groups[entry.id].length})`;
1121            return chip({
1122              boxKey: `group-chip-${entry.id}`,
1123              key: `group-${entry.id}`,
1124              label: isActive ? `[${text}]` : text,
1125              plain: true,
1126              active: isActive,
1127              padded: true,
1128              onPress: () => void update($, sessionGroupAtom, () => entry.id),
1129            });
1130          })}
1131        </Box>
1132      );
1133      body = (
1134        <Box key="session" flexDirection="column">
1135          {groupBar}
1136          <Text>{group.header(sessions.length)}</Text>
1137          {picker.error ? <Text color="error">{picker.error}</Text> : null}
1138          {!picker.error && sessions.length === 0 ? <Text dimColor>{group.empty}</Text> : null}
1139          {sessions.map((session) =>
1140            chip({
1141              boxKey: `chip-${session.id}`,
1142              key: session.id,
1143              label: labelOf(session, now),
1144              onPress: () => void resumeSession($, session.id),
1145            }),
1146          )}
1147          {picker.actionError ? <Text color="error">{picker.actionError}</Text> : null}
1148          {chip({ boxKey: "chip-refresh", key: "refresh", label: "重新整理", hotkey: "r", onPress: () => void loadList($) })}
1149        </Box>
1150      );
1151    } else if (tab === "tool") {
1152      const builtin = tools.tools.filter((tool) => !tool.mcp);
1153      const external = tools.tools.filter((tool) => tool.mcp);
1154      body = (
1155        <Box key="tool" flexDirection="column">
1156          <Text>
1157            共 {tools.tools.length} 個工具(內建 {builtin.length}/MCP {external.length})
1158          </Text>
1159          {tools.error ? <Text color="error">{tools.error}</Text> : null}
1160          {!tools.error && tools.tools.length === 0 ? <Text dimColor>目前沒有可用工具</Text> : null}
1161          {builtin.length > 0 ? <Text bold>內建({builtin.length})</Text> : null}
1162          {builtin.map((tool) => (
1163            <Text key={`tool-${tool.name}`} wrap="truncate-end">
1164              {toolLine(tool, columns)}
1165            </Text>
1166          ))}
1167          {external.length > 0 ? <Text bold>MCP({external.length})</Text> : null}
1168          {external.map((tool) => (
1169            <Text key={`tool-${tool.name}`} wrap="truncate-end">
1170              {toolLine(tool, columns)}
1171            </Text>
1172          ))}
1173          {chip({
1174            boxKey: "chip-tool-refresh",
1175            key: "tool-refresh",
1176            label: "重新整理",
1177            hotkey: "r",
1178            onPress: () => void loadTools($),
1179          })}
1180        </Box>
1181      );
1182    } else if (tab === "memory") {
1183      const statusLine = memory.status ? (
1184        memory.statusIsError ? (
1185          <Text key="mem-status" color="error">
1186            {memory.status}
1187          </Text>
1188        ) : (
1189          <Text key="mem-status">{memory.status}</Text>
1190        )
1191      ) : null;
1192      if (memory.viewing !== null) {
1193        const { path, lines, omitted } = memory.viewing;
1194        body = (
1195          <Box key="memory-view" flexDirection="column">
1196            <Text bold>{displayPath(path)}</Text>
1197            <Box key="mem-view-actions" flexDirection="row" columnGap={1}>
1198              {chip({ boxKey: "chip-mem-back", key: "mem-back", label: "返回", onPress: () => void backToMemoryList($) })}
1199              {chip({
1200                boxKey: "chip-mem-view-copy",
hooks/copy-model.ts 217 lines
1// 回覆下方「複製按鈕」用的純函式:從助理回覆文字抽出 Windows 絕對路徑、網址與 ``` 圍欄裡的指令、產生按鈕標籤、算訊息鍵。
2// 這個檔不碰 `$`、不宣告 atom(驗證器規則:`$` 不跨檔),只收發純資料;只對文字做樣式比對,不碰檔案或程序。
3
4import { fit, maskSecrets, singleLine } from "./flow-model";
5
6export const MAX_COPY_CHIPS = 8;
7export const COPY_LABEL_COLUMNS = 28;
8export const COPY_LABEL_PREFIX = "⧉ ";
9
10// 連結類(路徑、網址);指令類是從 ``` 圍欄區塊來的。
11export type LinkKind = "path" | "url";
12export type CopyTargetKind = LinkKind | "command";
13
14export type CopyTarget = {
15  // 原文:按下去複製的就是它,一字不改(含結尾的斜線;指令區塊則是整段內容,含換行)。
16  raw: string;
17  kind: CopyTargetKind;
18  // 已遮罩、截短、加前綴的按鈕標籤。
19  label: string;
20};
21
22export type CopyTargets = {
23  // 最多 MAX_COPY_CHIPS 個,依第一次出現的順序。
24  shown: CopyTarget[];
25  // 去重後超出上限的個數。
26  more: number;
27};
28
29// 路徑/網址共同的終止字元:空白、反引號、各種引號、全形標點。
30const QUOTES = String.raw`\x60'"‘’“”`;
31const CJK_PUNCTUATION = ",。、;:「」『』()!?";
32const PATH_STOP = String.raw`\s${QUOTES}<>|*?${CJK_PUNCTUATION}`;
33const URL_STOP = String.raw`\s${QUOTES}<>${CJK_PUNCTUATION}`;
34
35// 一個 regex 同時掃網址與路徑(由左到右、網址優先),所以網址裡的 `C:/…` 不會被另外當成路徑。
36// 路徑:磁碟代號前面不可緊接英數字(`xF:\` 不算);中文字緊接在前面則算(「在F:\x」)。
37const TARGET_PATTERN = new RegExp(
38  String.raw`(https?:\/\/[^${URL_STOP}]+)|((?<![A-Za-z0-9])[A-Za-z]:[\\/][^${PATH_STOP}]+)`,
39  "gi",
40);
41
42// 結尾要剝掉的標點(句號、逗號、括號、閉引號、全形標點);中間的不動。
43const TRAILING = new Set([...".,;:)]}>", ...CJK_PUNCTUATION, "…"]);
44const URL_BODY = /^https?:\/\/./i;
45const DRIVE_ONLY = /^[A-Za-z]:$/;
46
47function stripTrailing(text: string): string {
48  let end = text.length;
49  while (end > 0 && TRAILING.has(text[end - 1] ?? "")) end--;
50  return text.slice(0, end);
51}
52
53// 依第一次出現的順序列出所有不重複的目標原文(不限個數)。
54export function findCopyTargets(text: string): { raw: string; kind: LinkKind }[] {
55  const seen = new Set<string>();
56  const found: { raw: string; kind: LinkKind }[] = [];
57  for (const match of text.matchAll(TARGET_PATTERN)) {
58    const kind: LinkKind = match[1] === undefined ? "path" : "url";
59    const raw = stripTrailing(match[0]);
60    if (kind === "url" && !URL_BODY.test(raw)) continue;
61    if (kind === "path" && raw.length < 3) continue;
62    if (seen.has(raw)) continue;
63    seen.add(raw);
64    found.push({ raw, kind });
65  }
66  return found;
67}
68
69// 路徑取最後一個非空的區段(資料夾或檔名);只有磁碟代號時顯示 `F:\`。
70function pathLabel(raw: string): string {
71  const segments = raw.split(/[\\/]+/).filter((segment) => segment !== "");
72  const last = segments.at(-1) ?? raw;
73  return segments.length === 1 && DRIVE_ONLY.test(last) ? `${last}\\` : last;
74}
75
76// 網址取 `主機[:埠]`,有路徑時再加 `/最後一個非空區段`;帳密(@ 之前)、查詢字串與片段都不進標籤。
77function urlLabel(raw: string): string {
78  const parts = /^https?:\/\/([^/?#]*)([^?#]*)/i.exec(raw);
79  const host = (parts?.[1] ?? raw).split("@").pop() ?? "";
80  const last = (parts?.[2] ?? "").split("/").filter((segment) => segment !== "").at(-1);
81  return last === undefined ? host : `${host}/${last}`;
82}
83
84// 標籤一律:先遮罩再截短(標籤不留秘密)、加前綴;複製的原文不受影響。
85function labelOfBase(base: string): string {
86  return `${COPY_LABEL_PREFIX}${fit(maskSecrets(singleLine(base)), COPY_LABEL_COLUMNS)}`;
87}
88
89export function copyLabelOf(raw: string, kind: LinkKind): string {
90  return labelOfBase(kind === "path" ? pathLabel(raw) : urlLabel(raw));
91}
92
93// ---------- ``` 圍欄裡的指令 ----------
94
95const FENCE_OPENER = /^\s*```[^`]*$/;
96const FENCE_CLOSER = /^\s*```\s*$/;
97const TITLE_LINE = /^#\s+(.+)$/;
98
99const isBlank = (line: string): boolean => line.trim() === "";
100
101// 去掉頭尾空白行,再去掉非空白行共同的最小縮排。
102function blockLines(lines: string[]): string[] {
103  let start = 0;
104  let end = lines.length;
105  while (start < end && isBlank(lines[start] ?? "")) start++;
106  while (end > start && isBlank(lines[end - 1] ?? "")) end--;
107  const body = lines.slice(start, end);
108  const indents = body.filter((line) => !isBlank(line)).map((line) => /^\s*/.exec(line)?.[0].length ?? 0);
109  const common = indents.length === 0 ? 0 : Math.min(...indents);
110  return body.map((line) => line.slice(common));
111}
112
113// 含 `\` 或 `/` 的字取最後一個非空區段(`harness/cli/review-loop.mjs` → `review-loop.mjs`)。
114function shortWord(word: string): string {
115  if (!/[\\/]/.test(word)) return word;
116  return word.split(/[\\/]+/).filter((segment) => segment !== "").at(-1) ?? word;
117}
118
119// 一個區塊(已去頭尾空白行與共同縮排)→ 指令按鈕;不產生按鈕時回 undefined(區塊維持一般文字)。
120function commandOfBlock(lines: string[]): { raw: string; label: string } | undefined {
121  const content = lines.join("\n");
122  if (content === "") return undefined;
123  const only = findCopyTargets(content);
124  if (only.length === 1 && only[0]?.raw === content) return undefined;
125
126  // `# 標題` 開頭:標題當標籤,其餘(去掉標題後緊接的空白行)當複製內容;先於單行規則判斷。
127  const title = TITLE_LINE.exec(lines[0] ?? "");
128  if (title !== null) {
129    const rest = lines.slice(1);
130    while (rest.length > 0 && isBlank(rest[0] ?? "")) rest.shift();
131    if (rest.length === 0) return undefined;
132    return { raw: rest.join("\n"), label: labelOfBase((title[1] ?? "").trim()) };
133  }
134
135  if (lines.length === 1) {
136    const words = content.split(/\s+/);
137    const first = words[0] ?? content;
138    if (first.startsWith("/")) {
139      return { raw: content, label: labelOfBase(first.slice(1) === "" ? first : first.slice(1)) };
140    }
141    return { raw: content, label: labelOfBase(words.slice(0, 2).map(shortWord).join(" ")) };
142  }
143  return { raw: content, label: labelOfBase(`指令(${lines.length} 行)`) };
144}
145
146// 狀態列顯示用:單行遮罩;多行只顯示遮罩後的第一個非空行加「…(共 N 行)」。複製的仍是原文。
147export function copyShownOf(raw: string): string {
148  const lines = raw.split(/\r?\n/);
149  if (lines.length === 1) return maskSecrets(singleLine(raw));
150  const first = lines.find((line) => !isBlank(line)) ?? "";
151  return `${maskSecrets(singleLine(first))}…(共 ${lines.length} 行)`;
152}
153
154// 依出現順序列出全部(不限個數、未去重):產生按鈕的圍欄區塊(開頭到結尾那行都不再掃路徑與網址)給指令按鈕,其餘文字照舊掃路徑與網址。
155function collectTargets(text: string): CopyTarget[] {
156  const out: CopyTarget[] = [];
157  const lines = text.split(/\r?\n/);
158  let prose: string[] = [];
159  const flush = (): void => {
160    for (const { raw, kind } of findCopyTargets(prose.join("\n"))) out.push({ raw, kind, label: copyLabelOf(raw, kind) });
161    prose = [];
162  };
163
164  let index = 0;
165  while (index < lines.length) {
166    const line = lines[index] ?? "";
167    let close = -1;
168    if (FENCE_OPENER.test(line)) {
169      for (let next = index + 1; next < lines.length; next++) {
170        if (FENCE_CLOSER.test(lines[next] ?? "")) {
171          close = next;
172          break;
173        }
174      }
175    }
176    // 沒有結尾的開頭圍欄忽略,維持一般文字(下一行照常往下看)。
177    if (close === -1) {
178      prose.push(line);
179      index++;
180      continue;
181    }
182    // 沒產生按鈕的圍欄區塊整塊(含結尾那行,避免它被當成下一個開頭)併回一般文字;只有產生按鈕的才整塊略過。
183    const command = commandOfBlock(blockLines(lines.slice(index + 1, close)));
184    if (command === undefined) {
185      prose.push(...lines.slice(index, close + 1));
186      index = close + 1;
187      continue;
188    }
189    flush();
190    out.push({ raw: command.raw, kind: "command", label: command.label });
191    index = close + 1;
192  }
193  flush();
194  return out;
195}
196
197export function extractCopyTargets(text: string, max = MAX_COPY_CHIPS): CopyTargets {
198  const seen = new Set<string>();
199  const all = collectTargets(text).filter((target) => {
200    const key = `${target.kind}\0${target.raw}`;
201    if (seen.has(key)) return false;
202    seen.add(key);
203    return true;
204  });
205  return { shown: all.slice(0, max), more: Math.max(all.length - max, 0) };
206}
207
208// 訊息鍵:FNV-1a 32 位元雜湊(純函式、不需要 crypto),只用來讓「已複製」狀態對得到同一則訊息。
209export function messageKeyOf(text: string): string {
210  let hash = 0x811c9dc5;
211  for (let index = 0; index < text.length; index++) {
212    hash ^= text.charCodeAt(index);
213    hash = Math.imul(hash, 0x01000193);
214  }
215  return (hash >>> 0).toString(16).padStart(8, "0");
216}
217
hooks/flow-model.ts 429 lines
1// 「流程」分頁用的純函式:計畫書解析、活動事件紀錄與文字樹。
2// 這個檔不碰 `$`、不宣告 atom(驗證器規則:`$` 不跨檔),只收發純資料。
3
4// ---------- 共用顯示輔助(Session/Tool/MCP 分頁也用)----------
5
6export const CONTROL_CHARS = /\p{Cc}/gu;
7const PLUGIN_TOOL_PREFIX = /^plugin_([^_]+)_(.+)$/;
8
9// 外部文字(工具說明、MCP 伺服器名稱、檔名)一律去掉控制字元(含 ANSI 跳脫)再顯示。
10export function singleLine(text: string): string {
11  return text.replace(CONTROL_CHARS, " ").replace(/\s+/g, " ").trim();
12}
13
14export function mcpCommandName(name: string): string {
15  return name.replace(PLUGIN_TOOL_PREFIX, "plugin:$1:$2");
16}
17
18export function displayWidth(char: string): number {
19  const code = char.codePointAt(0) ?? 0;
20  const isWide =
21    (code >= 0x1100 && code <= 0x115f) ||
22    (code >= 0x2e80 && code <= 0xa4cf) ||
23    (code >= 0xac00 && code <= 0xd7a3) ||
24    (code >= 0xf900 && code <= 0xfaff) ||
25    (code >= 0xfe30 && code <= 0xfe6f) ||
26    (code >= 0xff00 && code <= 0xff60) ||
27    (code >= 0xffe0 && code <= 0xffe6) ||
28    (code >= 0x1f300 && code <= 0x1faff);
29  return isWide ? 2 : 1;
30}
31
32export function textWidth(text: string): number {
33  let width = 0;
34  for (const char of text) width += displayWidth(char);
35  return width;
36}
37
38// 一律保留一格給省略號(即使剛好放得下也截斷);Session/Tool 分頁沿用這個行為。
39export function truncate(text: string, maxColumns: number): string {
40  let width = 0;
41  let out = "";
42  for (const char of text) {
43    const w = displayWidth(char);
44    if (width + w > maxColumns - 1) return `${out}…`;
45    width += w;
46    out += char;
47  }
48  return out;
49}
50
51// 放得下就原樣,放不下才截斷加省略號。
52export function fit(text: string, maxColumns: number): string {
53  return textWidth(text) <= maxColumns ? text : truncate(text, maxColumns);
54}
55
56// ---------- 計畫書解析 ----------
57
58export type PlanStatus = "done" | "problem" | "todo";
59
60export type PlanNode = {
61  id: string;
62  name: string;
63  agent: string;
64  status: PlanStatus;
65  // 狀態備註含「未觸發」:分支沒走到,不算待辦。
66  skipped: boolean;
67};
68
69const NODE_ID = /\[([A-Za-z0-9-]+)\]/g;
70const STATUS_WORD = /^(已執行|未執行|有問題)/;
71const FENCE = /^\s*```/;
72const BAR = "|";
73
74const STATUS_OF: Record<string, PlanStatus> = {
75  已執行: "done",
76  未執行: "todo",
77  有問題: "problem",
78};
79
80// 單行節點:找 `[ID]`,其後以全形「|」切成 名稱|agent|狀態;狀態以三個關鍵字之一開頭。
81export function parseNodeLine(line: string): PlanNode | undefined {
82  for (const match of line.matchAll(NODE_ID)) {
83    const id = match[1] ?? "";
84    const rest = line.slice((match.index ?? 0) + match[0].length);
85    const parts = rest.split(BAR);
86    if (parts.length < 3) continue;
87    const statusAt = parts.findIndex((part, index) => index >= 2 && STATUS_WORD.test(part.trim()));
88    if (statusAt < 0) continue;
89    const statusText = parts.slice(statusAt).join(BAR).trim();
90    const word = STATUS_WORD.exec(statusText)?.[1] ?? "";
91    return {
92      id,
93      name: singleLine(parts[0] ?? ""),
94      agent: singleLine(parts.slice(1, statusAt).join(BAR)),
95      status: STATUS_OF[word] ?? "todo",
96      skipped: statusText.includes("未觸發"),
97    };
98  }
99  return undefined;
100}
101
102// 只看 ``` 圍欄區塊內的行,依檔案順序收集所有節點;沒有節點的區塊自然被略過。
103export function parsePlan(text: string): PlanNode[] {
104  const nodes: PlanNode[] = [];
105  let inFence = false;
106  for (const line of text.split(/\r?\n/)) {
107    if (FENCE.test(line)) {
108      inFence = !inFence;
109      continue;
110    }
111    if (!inFence) continue;
112    const node = parseNodeLine(line);
113    if (node !== undefined) nodes.push(node);
114  }
115  return nodes;
116}
117
118// 第一個「未執行」且不是未觸發分支的節點;沒有則 -1。
119export function currentIndexOf(nodes: PlanNode[]): number {
120  return nodes.findIndex((node) => node.status === "todo" && !node.skipped);
121}
122
123export function doneCount(nodes: PlanNode[]): number {
124  return nodes.filter((node) => node.status === "done").length;
125}
126
127export type PlanLine = { head: string; agent: string; isMuted: boolean };
128
129// 每個節點一行:head 是符號、ID、名稱(含固定後綴),agent 另列(淡色);兩者合計不超過 columns。
130export function planLines(nodes: PlanNode[], columns: number): PlanLine[] {
131  const current = currentIndexOf(nodes);
132  return nodes.map((node, index) => {
133    let mark: string;
134    let suffix = "";
135    if (node.skipped && node.status === "todo") {
136      mark = "·";
137      suffix = "(未觸發)";
138    } else if (node.status === "done") {
139      mark = "✔";
140    } else if (node.status === "problem") {
141      mark = "✘";
142      suffix = "(有問題)";
143    } else if (index === current) {
144      mark = "▶";
145      suffix = " ← 目前";
146    } else {
147      mark = "○";
148    }
149    const lead = `${mark} ${node.id} `;
150    const room = Math.max(columns - textWidth(lead) - textWidth(suffix), 6);
151    const head = `${lead}${fit(node.name, room)}${suffix}`;
152    const left = columns - textWidth(head);
153    const agent = node.agent !== "" && left >= 6 ? fit(`|${node.agent}`, left) : "";
154    return { head, agent, isMuted: node.skipped && node.status === "todo" };
155  });
156}
157
158// 標題一行:「計畫:<檔名>  已執行 a/共 n」;放不下時只縮檔名,計數保留。
159export function planHeader(fileName: string, nodes: PlanNode[], columns: number): string {
160  const tail = `  已執行 ${doneCount(nodes)}/共 ${nodes.length}`;
161  const room = Math.max(columns - textWidth("計畫:") - textWidth(tail), 8);
162  return `計畫:${fit(fileName, room)}${tail}`;
163}
164
165// ---------- 記憶分頁用的路徑與數字顯示 ----------
166
167const DRIVE_PATH = /^[A-Za-z]:[\\/]/;
168
169export function isDrivePath(path: string): boolean {
170  return DRIVE_PATH.test(path);
171}
172
173// 引擎給的路徑可能是 C:/… 形式;只有以磁碟代號開頭時才把 / 換成反斜線,其他路徑原樣。
174export function displayPath(path: string): string {
175  return isDrivePath(path) ? path.replace(/\//g, "\\") : path;
176}
177
178export function formatTokens(tokens: number): string {
179  if (tokens < 1000) return String(Math.round(tokens));
180  return `${(tokens / 1000).toFixed(1).replace(/\.0$/, "")}k`;
181}
182
183export function dirnameOf(path: string): string {
184  const at = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
185  return at <= 0 ? "/" : path.slice(0, at);
186}
187
188export function fileNameOf(path: string): string {
189  return path.slice(Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1);
190}
191
192// ---------- 秘密遮罩(只靠樣式比對,無法保證涵蓋所有秘密)----------
193
194// 先遮罩再截短:狀態與畫面都不會留下秘密。保留可辨識的前綴(`sk-`、`Bearer`、`token=`),只把秘密本體換成 `***`。
195const SECRET_KEYWORD = String.raw`(?:api[_-]?key|token|secret|password|passwd|pwd|auth(?:orization)?)`;
196const SECRET_VALUE = String.raw`("[^"]*"|'[^']*'|[^\s"']+)`;
197const AUTHORIZATION_HEADER = /\b(Authorization)(\s*:\s*)(?:(?:Bearer|Basic|Token)\s+)?[^\s"']+/gi;
198const BEARER_TOKEN = /\b(Bearer)(\s+)[A-Za-z0-9._~+/=-]{8,}/gi;
199// 鍵名+ = 或 :(含 JSON 的 "token": "…");關鍵字後面不能再接英數字,所以 tokens、author 不會被遮。
200const KEY_VALUE = new RegExp(String.raw`(${SECRET_KEYWORD}["']?\s*[=:]\s*)${SECRET_VALUE}`, "gi");
201// --token 值(空白分隔的旗標形式);只認 -- 開頭的旗標,一般文字裡的 "token abc" 不動。
202const KEY_FLAG = new RegExp(String.raw`(^|\s)(--[A-Za-z0-9_.-]{0,40}${SECRET_KEYWORD}\s+)${SECRET_VALUE}`, "gi");
203
204const TOKEN_FORMS: [RegExp, string][] = [
205  [/(?<![A-Za-z0-9])sk-[A-Za-z0-9_-]{8,}/g, "sk-***"],
206  [/(?<![A-Za-z0-9])(gh[pousr]_)[A-Za-z0-9]{20,}/g, "$1***"],
207  [/(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}/g, "github_pat_***"],
208  [/(?<![A-Za-z0-9])(xox[abprs]-)[A-Za-z0-9-]{10,}/g, "$1***"],
209  [/(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}/g, "AKIA***"],
210  [/(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{30,}/g, "AIza***"],
211];
212
213// 被引號包住的值保留引號,只換內容。
214function maskedValue(value: string): string {
215  const quote = value.startsWith("\"") || value.startsWith("'") ? value[0] : "";
216  return `${quote}***${quote}`;
217}
218
219export function maskSecrets(text: string): string {
220  let out = text
221    .replace(AUTHORIZATION_HEADER, "$1$2***")
222    .replace(BEARER_TOKEN, "$1$2***");
223  for (const [pattern, replacement] of TOKEN_FORMS) out = out.replace(pattern, replacement);
224  return out
225    .replace(KEY_VALUE, (_all, head: string, value: string) => `${head}${maskedValue(value)}`)
226    .replace(KEY_FLAG, (_all, lead: string, head: string, value: string) => `${lead}${head}${maskedValue(value)}`);
227}
228
229// ---------- 活動事件(只存名稱、截短目標、耗時、成敗、agent 從屬)----------
230
231export const TARGET_COLUMNS = 60;
232export const DESCRIPTION_COLUMNS = 40;
233export const KEPT_EVENTS = 200;
234export const SHOWN_TURNS = 5;
235
236export type FlowEvent = {
237  id: string;
238  kind: "tool" | "agent";
239  turn: number;
240  // 工具名稱;agent 則是 agent 類型。
241  name: string;
242  // 工具:截短目標(≤60 欄);agent:說明(≤40 欄)。
243  target: string;
244  // 工具:它在哪個 subagent 迴圈執行;agent:自己的 id(spawn 回覆後才有)。主迴圈為 undefined。
245  agentId?: string;
246  // 只有 agent:它是在哪個 agent 迴圈裡被 spawn 的。
247  parentAgentId?: string;
248  // 只有 agent:模型別名或 id。
249  model: string;
250  startedAt: number;
251  // 結束後才有;兩者都 undefined 代表還在執行。
252  durationMs?: number;
253  ok?: boolean;
254};
255
256export type ActivityState = {
257  events: FlowEvent[];
258  turn: number;
259  tools: number;
260  mcp: number;
261  agents: number;
262};
263
264export function emptyActivity(): ActivityState {
265  return { events: [], turn: 0, tools: 0, mcp: 0, agents: 0 };
266}
267
268export function isMcpTool(name: string): boolean {
269  return name.startsWith("mcp__");
270}
271
272function baseName(path: string): string {
273  return path.replace(/\\/g, "/").replace(/\/+$/, "").split("/").pop() ?? path;
274}
275
276function hostOf(url: string): string {
277  return /^[a-z][a-z0-9+.-]*:\/\/([^/?#:]*)/i.exec(url)?.[1] ?? url;
278}
279
280// 目標摘要:檔案取檔名、指令取第一行、pattern/query/description 原文、網址取主機;
281// 一律去控制字元、遮罩秘密(先遮罩再截短)並截到 60 欄。輸入的其餘欄位(內容、完整指令)不經過這裡就不會被保存。
282export function targetOf(call: object): string {
283  const fields = call as Record<string, unknown>;
284  const text = (key: string): string => {
285    const value = fields[key];
286    return typeof value === "string" ? singleLine(value) : "";
287  };
288  const picked =
289    text("file_path") !== ""
290      ? baseName(text("file_path"))
291      : text("notebook_path") !== ""
292        ? baseName(text("notebook_path"))
293        : text("command") !== ""
294          ? text("command")
295          : text("pattern") !== ""
296            ? text("pattern")
297            : text("url") !== ""
298              ? hostOf(text("url"))
299              : text("query") !== ""
300                ? text("query")
301                : text("description");
302  return fit(maskSecrets(picked), TARGET_COLUMNS);
303}
304
305export function addEvent(state: ActivityState, event: Omit<FlowEvent, "turn">): ActivityState {
306  const counted =
307    event.kind === "agent"
308      ? { agents: state.agents + 1 }
309      : { tools: state.tools + 1, mcp: state.mcp + (isMcpTool(event.name) ? 1 : 0) };
310  return {
311    ...state,
312    ...counted,
313    events: [...state.events, { ...event, turn: state.turn }].slice(-KEPT_EVENTS),
314  };
315}
316
317export function patchEvent(state: ActivityState, id: string, patch: Partial<FlowEvent>): ActivityState {
318  return { ...state, events: state.events.map((event) => (event.id === id ? { ...event, ...patch } : event)) };
319}
320
321// spawn 被拒或失敗:那個 agent 沒有真的開始,把它從紀錄與計數拿掉。
322export function dropEvent(state: ActivityState, id: string): ActivityState {
323  const dropped = state.events.find((event) => event.id === id);
324  if (dropped === undefined) return state;
325  return {
326    ...state,
327    agents: state.agents - (dropped.kind === "agent" ? 1 : 0),
328    events: state.events.filter((event) => event.id !== id),
329  };
330}
331
332export function startTurn(state: ActivityState): ActivityState {
333  return { ...state, turn: state.turn + 1 };
334}
335
336// ---------- 文字樹 ----------
337
338export function formatDuration(ms: number): string {
339  return ms < 1000 ? `${Math.max(0, Math.round(ms))}ms` : `${(ms / 1000).toFixed(1)}s`;
340}
341
342type TreeNode = { text: string; suffix: string; children: TreeNode[]; joint: string };
343
344function toolText(event: FlowEvent): string {
345  if (isMcpTool(event.name)) {
346    const [, server = "", ...tool] = event.name.split("__");
347    return `MCP ${mcpCommandName(server)} › ${tool.join("__")}`;
348  }
349  return event.target === "" ? event.name : `${event.name}  ${event.target}`;
350}
351
352function toolSuffix(event: FlowEvent): string {
353  if (event.durationMs === undefined) return "  …";
354  return `  ${formatDuration(event.durationMs)}${event.ok === false ? " ✘" : ""}`;
355}
356
357function agentText(event: FlowEvent, hasChildren: boolean): string {
358  const model = event.model === "" ? "" : `(${event.model})`;
359  const label = `Agent ${event.name}${model}`;
360  return !hasChildren && event.target !== "" ? `${label}  ${event.target}` : label;
361}
362
363function renderNode(node: TreeNode, first: string, rest: string, columns: number): string[] {
364  if (node.children.length === 0) {
365    const room = Math.max(columns - textWidth(first) - textWidth(node.suffix), 6);
366    return [fit(`${first}${fit(node.text, room)}${node.suffix}`, columns)];
367  }
368  const head = `${node.text}${node.joint}`;
369  const pad = " ".repeat(textWidth(head));
370  const count = node.children.length;
371  return node.children.flatMap((child, index) => {
372    const isLast = index === count - 1;
373    if (index === 0) {
374      return renderNode(
375        child,
376        `${first}${head}${count > 1 ? "┬" : "─"}─ `,
377        `${rest}${pad}${count > 1 ? "│" : " "}  `,
378        columns,
379      );
380    }
381    return renderNode(
382      child,
383      `${rest}${pad}${isLast ? "└" : "├"}─ `,
384      `${rest}${pad}${isLast ? " " : "│"}  `,
385      columns,
386    );
387  });
388}
389
390// 最近 SHOWN_TURNS 輪(最新在下):每輪一棵樹;subagent 的工具掛在 agent 底下,
391// 對不到 agent 的(例如 spawn 還沒回覆)平放在該輪頂層。
392export function activityLines(events: FlowEvent[], columns: number): string[] {
393  const agentById = new Map<string, FlowEvent>();
394  for (const event of events) {
395    if (event.kind === "agent" && event.agentId !== undefined) agentById.set(event.agentId, event);
396  }
397  const childrenOf = new Map<FlowEvent, FlowEvent[]>();
398  const topOfTurn = new Map<number, FlowEvent[]>();
399  for (const event of events) {
400    const parentId = event.kind === "agent" ? event.parentAgentId : event.agentId;
401    const parent = parentId === undefined ? undefined : agentById.get(parentId);
402    const bucket =
403      parent !== undefined && parent !== event
404        ? (childrenOf.get(parent) ?? childrenOf.set(parent, []).get(parent))
405        : (topOfTurn.get(event.turn) ?? topOfTurn.set(event.turn, []).get(event.turn));
406    bucket?.push(event);
407  }
408
409  const nodeOf = (event: FlowEvent): TreeNode => {
410    const kids = (childrenOf.get(event) ?? []).map(nodeOf);
411    if (event.kind === "agent") {
412      return { text: agentText(event, kids.length > 0), suffix: "", children: kids, joint: "─" };
413    }
414    return { text: toolText(event), suffix: toolSuffix(event), children: [], joint: "" };
415  };
416
417  const turns = [...topOfTurn.keys()].sort((a, b) => a - b).slice(-SHOWN_TURNS);
418  return turns.flatMap((turn) => {
419    const label = turn === 0 ? "載入前起算的一輪" : `第 ${turn} 輪`;
420    const root: TreeNode = {
421      text: label,
422      suffix: "",
423      children: (topOfTurn.get(turn) ?? []).map(nodeOf),
424      joint: " ─",
425    };
426    return renderNode(root, "", "", columns);
427  });
428}
429
hooks/quota-model.ts 360 lines
1// 額度分頁的純函式:樣本分段、消耗速度、到 95%/100% 的預測、歷史合併與顯示字串。
2// 不碰 `$`(驗證器規定 `$` 不跨檔);時間一律用毫秒數計算,現在時間由呼叫端傳入。
3import { fit, textWidth } from "./flow-model";
4
5export type QuotaSource = "claude" | "codex";
6export type QuotaWindow = "five_hour" | "seven_day";
7
8// 一筆讀數:只有這五個欄位會寫進歷史檔(沒有任何對話內容或帳號)。
9export type Sample = {
10  source: QuotaSource;
11  window: QuotaWindow;
12  pct: number;
13  // 該視窗的重置時間(毫秒)。
14  resetsAt: number;
15  // 取得讀數的時間(毫秒)。
16  at: number;
17};
18
19const HOUR_MS = 3_600_000;
20const MINUTE_MS = 60_000;
21const DAY_MS = 24 * HOUR_MS;
22
23// 同一個週期:resetsAt 相差 ≤ 120 秒;pct 下降 ≥ 5 點視為新週期。
24const SAME_RESET_MS = 120_000;
25const DROP_POINTS = 5;
26// 速度的回看範圍、最短跨度與最小上升量。
27const LOOKBACK_MS: Record<QuotaWindow, number> = { seven_day: 6 * HOUR_MS, five_hour: 60 * MINUTE_MS };
28const MIN_SPAN_MS = 10 * MINUTE_MS;
29const MIN_RISE = 1;
30const WARN_PCT = 95;
31const FULL_PCT = 100;
32// 歷史上限:每個來源與視窗最近 7 天、最多 500 筆;相鄰且相隔不到 5 分鐘的同值讀數不重複存。
33const KEEP_MS = 7 * DAY_MS;
34const KEEP_COUNT = 500;
35const SAME_VALUE_GAP_MS = 5 * MINUTE_MS;
36
37function byTime(a: Sample, b: Sample): number {
38  return (
39    a.at - b.at ||
40    (a.source < b.source ? -1 : a.source > b.source ? 1 : 0) ||
41    (a.window < b.window ? -1 : a.window > b.window ? 1 : 0)
42  );
43}
44
45function keyOf(sample: Sample): string {
46  return `${sample.source}|${sample.window}|${sample.at}`;
47}
48
49// ---------- 分段與預測 ----------
50
51// 取某來源、某視窗「目前這個週期」的樣本(依時間排序):resetsAt 改變或 pct 下降 ≥ 5 點之前的舊資料不算。
52export function currentCycle(samples: Sample[], source: QuotaSource, window: QuotaWindow): Sample[] {
53  const own = samples.filter((sample) => sample.source === source && sample.window === window).sort(byTime);
54  let start = 0;
55  for (let index = 1; index < own.length; index++) {
56    const prev = own[index - 1]!;
57    const cur = own[index]!;
58    if (Math.abs(cur.resetsAt - prev.resetsAt) > SAME_RESET_MS || prev.pct - cur.pct >= DROP_POINTS) start = index;
59  }
60  return own.slice(start);
61}
62
63export type Forecast = {
64  pct: number;
65  resetsAt: number;
66  // 最後一筆讀數的時間。
67  at: number;
68  // 用來算速度的樣本筆數與跨度(回看範圍內)。
69  count: number;
70  spanMs: number;
71  // 每小時上升的百分點;資料不足時沒有。
72  ratePerHour?: number;
73  // 資料不足的原因:span=樣本太少或跨度不到 10 分鐘,flat=沒有上升到 1 點。
74  insufficient?: "span" | "flat";
75  // 目前已到的門檻(0=都沒到)。
76  reached: 0 | 95 | 100;
77  // 照目前速度還要幾小時到 95%/100%(已到、資料不足或已過重置時間時沒有)。
78  hours95?: number;
79  hours100?: number;
80  // 到 95%(100%)的時間點早於重置時間。
81  beforeReset?: boolean;
82  beforeReset100?: boolean;
83  // 最後一筆讀數的重置時間已過:讀數過期,不預測。
84  isExpired: boolean;
85};
86
87// 以目前週期的樣本(可未排序)算預測;沒有樣本回傳 undefined。
88export function forecastOf(cycle: Sample[], now: number): Forecast | undefined {
89  const sorted = [...cycle].sort(byTime);
90  const last = sorted[sorted.length - 1];
91  if (last === undefined) return undefined;
92  const used = sorted.filter((sample) => sample.at >= last.at - LOOKBACK_MS[last.window]);
93  const first = used[0]!;
94  const spanMs = last.at - first.at;
95  const rise = last.pct - first.pct;
96  const reached = last.pct >= FULL_PCT ? FULL_PCT : last.pct >= WARN_PCT ? WARN_PCT : 0;
97  const forecast: Forecast = {
98    pct: last.pct,
99    resetsAt: last.resetsAt,
100    at: last.at,
101    count: used.length,
102    spanMs,
103    reached,
104    isExpired: last.resetsAt <= now,
105  };
106  if (spanMs < MIN_SPAN_MS) forecast.insufficient = "span";
107  else if (rise < MIN_RISE) forecast.insufficient = "flat";
108  else forecast.ratePerHour = rise / (spanMs / HOUR_MS);
109
110  const rate = forecast.ratePerHour;
111  if (rate !== undefined && reached === 0 && !forecast.isExpired) {
112    forecast.hours95 = (WARN_PCT - last.pct) / rate;
113    forecast.hours100 = (FULL_PCT - last.pct) / rate;
114    forecast.beforeReset = now + forecast.hours95 * HOUR_MS < last.resetsAt;
115    forecast.beforeReset100 = now + forecast.hours100 * HOUR_MS < last.resetsAt;
116  }
117  return forecast;
118}
119
120// ---------- 歷史 ----------
121
122// 依 (source, window, at) 去重後依時間排序;同一鍵以後者(記憶體)為準。
123export function mergeHistory(fileSamples: Sample[], memSamples: Sample[]): Sample[] {
124  const byKey = new Map<string, Sample>();
125  for (const sample of [...fileSamples, ...memSamples]) byKey.set(keyOf(sample), sample);
126  return [...byKey.values()].sort(byTime);
127}
128
129// 每個來源與視窗只留最近 7 天、最多 500 筆(較舊的丟棄)。
130export function trimHistory(samples: Sample[], now: number): Sample[] {
131  const groups = new Map<string, Sample[]>();
132  for (const sample of [...samples].sort(byTime)) {
133    if (sample.at < now - KEEP_MS) continue;
134    const key = `${sample.source}|${sample.window}`;
135    const group = groups.get(key);
136    if (group === undefined) groups.set(key, [sample]);
137    else group.push(sample);
138  }
139  const kept: Sample[] = [];
140  for (const group of groups.values()) kept.push(...group.slice(-KEEP_COUNT));
141  return kept.sort(byTime);
142}
143
144// 加一筆樣本:與同來源、同視窗前一筆相比,pct 相同、同一重置週期、相隔不到 5 分鐘就不重複存(回傳原陣列)。
145export function addSample(history: Sample[], sample: Sample, now: number): Sample[] {
146  let prev: Sample | undefined;
147  for (const entry of history) {
148    if (entry.source !== sample.source || entry.window !== sample.window || entry.at > sample.at) continue;
149    if (prev === undefined || entry.at >= prev.at) prev = entry;
150  }
151  if (prev !== undefined) {
152    const isSameValue =
153      prev.pct === sample.pct &&
154      Math.abs(prev.resetsAt - sample.resetsAt) <= SAME_RESET_MS &&
155      sample.at - prev.at < SAME_VALUE_GAP_MS;
156    if (isSameValue) return history;
157  }
158  return trimHistory(mergeHistory(history, [sample]), now);
159}
160
161function isSource(value: unknown): value is QuotaSource {
162  return value === "claude" || value === "codex";
163}
164
165function isWindow(value: unknown): value is QuotaWindow {
166  return value === "five_hour" || value === "seven_day";
167}
168
169function isFiniteNumber(value: unknown): value is number {
170  return typeof value === "number" && Number.isFinite(value);
171}
172
173function sampleOf(value: unknown): Sample | undefined {
174  if (value === null || typeof value !== "object") return undefined;
175  const { source, window, pct, resetsAt, at } = value as Record<string, unknown>;
176  if (!isSource(source) || !isWindow(window) || !isFiniteNumber(pct) || !isFiniteNumber(resetsAt) || !isFiniteNumber(at)) {
177    return undefined;
178  }
179  return { source, window, pct, resetsAt, at };
180}
181
182// 歷史檔格式 `{samples:[...]}`;整個檔案無法解析或格式不符回傳 undefined(呼叫端用記憶體樣本重建),單筆壞掉的略過。
183export function parseHistory(text: string): Sample[] | undefined {
184  let parsed: unknown;
185  try {
186    parsed = JSON.parse(text);
187  } catch {
188    return undefined;
189  }
190  const list = (parsed as { samples?: unknown } | null)?.samples;
191  if (!Array.isArray(list)) return undefined;
192  return list.flatMap((item) => {
193    const sample = sampleOf(item);
194    return sample === undefined ? [] : [sample];
195  });
196}
197
198// 只輸出 Sample 的五個欄位,不會夾帶其他東西。
199export function serializeHistory(samples: Sample[]): string {
200  const rows = samples.map(({ source, window, pct, resetsAt, at }) => ({ source, window, pct, resetsAt, at }));
201  return `${JSON.stringify({ samples: rows })}\n`;
202}
203
204// ---------- 取樣:Claude 與 Codex ----------
205
206// `$.session.usage().rateLimits`:只取 five_hour、seven_day 且有重置時間的(spend_limit 等不算)。
207export function claudeSamples(limits: { kind: string; percentUsed: number; resetsAt?: string }[], now: number): Sample[] {
208  const out: Sample[] = [];
209  for (const limit of limits) {
210    if (!isWindow(limit.kind) || !isFiniteNumber(limit.percentUsed) || limit.resetsAt === undefined) continue;
211    const resetsAt = Date.parse(limit.resetsAt);
212    if (!Number.isFinite(resetsAt)) continue;
213    out.push({ source: "claude", window: limit.kind, pct: limit.percentUsed, resetsAt, at: now });
214  }
215  return out;
216}
217
218export type CodexWindow = { pct: number; windowMinutes: number; resetsAt: number };
219export type CodexReading = { primary?: CodexWindow; secondary?: CodexWindow; observedAt: number };
220export type AgyCounts = { today: number; week: number };
221// 讀取腳本 stdout 的全部欄位。
222export type ScriptResult = {
223  codex: CodexReading | null;
224  codexReason?: string;
225  agy: AgyCounts | null;
226  agyReason?: string;
227};
228
229// Codex 的 primary 是 5 小時、secondary 是週;observedAt(紀錄檔修改時間)當樣本的 at。
230export function codexSamples(reading: CodexReading): Sample[] {
231  const out: Sample[] = [];
232  const pairs: [CodexWindow | undefined, QuotaWindow][] = [
233    [reading.primary, "five_hour"],
234    [reading.secondary, "seven_day"],
235  ];
236  for (const [entry, window] of pairs) {
237    if (entry !== undefined) {
238      out.push({ source: "codex", window, pct: entry.pct, resetsAt: entry.resetsAt, at: reading.observedAt });
239    }
240  }
241  return out;
242}
243
244function codexWindowOf(value: unknown): CodexWindow | undefined {
245  if (value === null || typeof value !== "object") return undefined;
246  const { pct, windowMinutes, resetsAt } = value as Record<string, unknown>;
247  if (!isFiniteNumber(pct) || !isFiniteNumber(windowMinutes) || !isFiniteNumber(resetsAt)) return undefined;
248  return { pct, windowMinutes, resetsAt };
249}
250
251// 驗證並取出腳本輸出的欄位(多餘的欄位丟掉);格式不符回傳 undefined。
252export function parseScriptOutput(text: string): ScriptResult | undefined {
253  let parsed: unknown;
254  try {
255    parsed = JSON.parse(text);
256  } catch {
257    return undefined;
258  }
259  if (parsed === null || typeof parsed !== "object") return undefined;
260  const raw = parsed as Record<string, unknown>;
261  const result: ScriptResult = { codex: null, agy: null };
262
263  const codex = raw.codex;
264  if (codex !== null && codex !== undefined) {
265    if (typeof codex !== "object") return undefined;
266    const body = codex as Record<string, unknown>;
267    const primary = codexWindowOf(body.primary);
268    const secondary = codexWindowOf(body.secondary);
269    if (!isFiniteNumber(body.observedAt) || (primary === undefined && secondary === undefined)) return undefined;
270    result.codex = {
271      ...(primary === undefined ? {} : { primary }),
272      ...(secondary === undefined ? {} : { secondary }),
273      observedAt: body.observedAt,
274    };
275  }
276  if (typeof raw.codexReason === "string") result.codexReason = raw.codexReason;
277
278  const agy = raw.agy;
279  if (agy !== null && agy !== undefined) {
280    if (typeof agy !== "object") return undefined;
281    const { today, week } = agy as Record<string, unknown>;
282    if (!isFiniteNumber(today) || !isFiniteNumber(week)) return undefined;
283    result.agy = { today, week };
284  }
285  if (typeof raw.agyReason === "string") result.agyReason = raw.agyReason;
286  return result;
287}
288
289// ---------- 顯示 ----------
290
291function pad2(value: number): string {
292  return String(value).padStart(2, "0");
293}
294
295// 本地時間:同一天只顯示 `14:30`,不同天顯示 `10/23 08:00`。
296export function clockLabel(ms: number, now: number): string {
297  const date = new Date(ms);
298  const today = new Date(now);
299  const time = `${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
300  const isSameDay =
301    date.getFullYear() === today.getFullYear() && date.getMonth() === today.getMonth() && date.getDate() === today.getDate();
302  return isSameDay ? time : `${date.getMonth() + 1}/${date.getDate()} ${time}`;
303}
304
305export function agoLabel(at: number, now: number): string {
306  const seconds = Math.max(0, Math.floor((now - at) / 1000));
307  if (seconds < 60) return "剛剛";
308  const minutes = Math.floor(seconds / 60);
309  if (minutes < 60) return `${minutes} 分鐘前`;
310  const hours = Math.floor(minutes / 60);
311  if (hours < 24) return `${hours} 小時前`;
312  return `${Math.floor(hours / 24)} 天前`;
313}
314
315function oneDecimal(value: number): string {
316  return String(Math.round(value * 10) / 10);
317}
318
319function hoursLabel(hours: number): string {
320  if (hours < 1) return `${Math.max(1, Math.round(hours * 60))} 分`;
321  return hours < 10 ? `${oneDecimal(hours)} 時` : `${Math.round(hours)} 時`;
322}
323
324// 進度條:「█」已用、「░」未用,95% 的位置放一格「│」當門檻標記(取代該格)。條寬含標記;標記後面至少留一格。
325const BAR_MIN = 6;
326const BAR_MAX = 24;
327
328function barOf(pct: number, width: number): string {
329  const filled = Math.min(width, Math.max(0, Math.round((pct / 100) * width)));
330  const mark = Math.min(width - 2, Math.round((WARN_PCT / 100) * width) - 1);
331  let bar = "";
332  for (let index = 0; index < width; index++) bar += index === mark ? "│" : index < filled ? "█" : "░";
333  return bar;
334}
335
336// 行尾的簡短狀態,依序判斷。
337function statusOf(forecast: Forecast): string {
338  if (forecast.isExpired) return "等新讀數";
339  if (forecast.reached === FULL_PCT) return "已滿";
340  if (forecast.reached === WARN_PCT) return "已達95%";
341  if (forecast.ratePerHour === undefined) return "資料不足";
342  if (forecast.beforeReset === true && forecast.hours95 !== undefined) {
343    return `⚠${hoursLabel(forecast.hours95).replace(" ", "")}後95%`;
344  }
345  return "重置前安全";
346}
347
348// 一個視窗的一行:`  週用量 █████████░░░░│░ 62% ⚠15時後95%`;整行寬度不超過 columns,顏色等級依百分比(≥95 error、≥70 warning)。
349export function quotaBarLine(
350  label: string,
351  forecast: Forecast,
352  columns: number,
353): { text: string; level: "success" | "warning" | "error" } {
354  const head = `  ${label} `;
355  const tail = ` ${oneDecimal(forecast.pct)}% ${statusOf(forecast)}`;
356  const width = Math.min(BAR_MAX, Math.max(BAR_MIN, columns - textWidth(head) - textWidth(tail)));
357  const level = forecast.pct >= WARN_PCT ? "error" : forecast.pct >= 70 ? "warning" : "success";
358  return { text: fit(`${head}${barOf(forecast.pct, width)}${tail}`, columns), level };
359}
360
types/index.d.ts 91 lines
1declare module "claude-code" {
2  interface PluginState {
3    "session-picker": {
4      picker: {
5        groups: {
6          open: { id: string; title: string; mtimeMs: number }[];
7          paused: { id: string; title: string; mtimeMs: number }[];
8          closed: { id: string; title: string; mtimeMs: number }[];
9        };
10        error: string;
11        actionError: string;
12        loadedAt: number;
13      };
14      sessionGroup: "open" | "paused" | "closed";
15      tab: "session" | "tool" | "mcp" | "flow" | "memory" | "quota";
16      quota: {
17        samples: {
18          source: "claude" | "codex";
19          window: "five_hour" | "seven_day";
20          pct: number;
21          resetsAt: number;
22          at: number;
23        }[];
24        codexAt: number;
25        codexReason: string;
26        agy: { today: number; week: number } | null;
27        agyReason: string;
28        claudeEmpty: boolean;
29        historyError: string;
30        scriptError: string;
31        lastWriteAt: number;
32      };
33      tools: {
34        tools: { name: string; description: string; mcp: boolean }[];
35        error: string;
36      };
37      mcp: {
38        servers: { name: string; connected: boolean; tools: number }[];
39        pending: string[];
40        error: string;
41        actionError: string;
42        notice: string;
43      };
44      memory: {
45        files: { type: string; path: string; tokens: number }[];
46        hasAutoMem: boolean;
47        folder: string[];
48        folderMore: number;
49        folderError: string;
50        error: string;
51        viewing: { path: string; lines: string[]; omitted: number } | null;
52        status: string;
53        statusIsError: boolean;
54      };
55      plan: {
56        files: { name: string; mtimeMs: number }[];
57        selected: string;
58        nodes: {
59          id: string;
60          name: string;
61          agent: string;
62          status: "done" | "problem" | "todo";
63          skipped: boolean;
64        }[];
65        error: string;
66      };
67      copyBar: boolean;
68      copyStatus: { key: string; text: string; isError: boolean };
69      activity: {
70        events: {
71          id: string;
72          kind: "tool" | "agent";
73          turn: number;
74          name: string;
75          target: string;
76          agentId?: string;
77          parentAgentId?: string;
78          model: string;
79          startedAt: number;
80          durationMs?: number;
81          ok?: boolean;
82        }[];
83        turn: number;
84        tools: number;
85        mcp: number;
86        agents: number;
87      };
88    };
89  }
90}
91