Web 層級結構伺服器
人類使用者常常在另一台裝置上為代理工作,無法看到子代理的終端機。這個技能將整個生成樹變成一個他們只需打開一次就能持續觀看的即時頁面:上方是可點擊的圖表,下方是所選代理的完整日誌。
此技能的安裝環境——fleet where web-hierarchy-server 指令輸出的目錄——包含了本次安裝的兩個檔案:CONTEXT.md 標示了代理的名稱、連接埠和 URL,而 config.json 則儲存了所有腳本會讀取的值。同一目錄下的 config.example.json 說明了每個鍵的用途。腳本會透過執行相同的指令來找到 config.json,或者讀取 WEB_HIERARCHY_CONFIG 環境變數所指定的檔案(如果有的話);其中相對路徑會以該檔案所在的目錄為基準進行解析。
透過指定路徑來執行檢查和腳本:
"${CLAUDE_SKILL_DIR}/installation/check" # 檢查此配置目錄是否有伺服器在運行,以及是否為最新
node "${CLAUDE_SKILL_DIR}/serve.mjs" --help # 顯示要使用的配置、記錄根目錄和連接埠
一條鐵則:每個配置目錄只能有一個伺服器
一個配置目錄(CLAUDE_CONFIG_DIR)是一個代理的記錄根目錄,並且只能有一個伺服器為其提供服務。同一連接埠上的第二個伺服器就是這條規則要防止的錯誤:無論哪個程序搶先綁定成功並提供頁面服務,另一個程序都會因 EADDRINUSE 錯誤而崩潰,但從使用者的角度來看,頁面看起來都正常。在啟動任何伺服器之前,請先確定該配置目錄尚未有伺服器在運行(installation/check 會回答這個問題,並且不會啟動任何東西)。
永久的運作形式是服務單元 (installation/web-hierarchy-server.service.example):隨機器啟動、Restart=always、在系統啟動後、任何委派工作開始之前就上線,並且能在重啟後繼續存活。安裝服務單元需要 root 權限,這需要人類操作。在 macOS 上,它是使用者 GUI 域中的一個 LaunchAgent (installation/web-hierarchy-server.plist.example),無需 sudo 即可安裝。在沒有人執行過服務單元的情況下,伺服器會以分離程序的形式啟動一次(Linux 上使用 setsid nohup ... &,macOS 上使用 nohup ... &,因為沒有 setsid,所以它會比啟動它的會話更長壽)——這種方式可以運作,但無法在重啟後存活。installation/check 會以文字明確指出這一點,而不是回報綠燈。
對 serve.mjs 或 format.mjs 的編輯預設是「延遲生效」的。 Node 只在程序啟動時載入它們一次,因此已生效的變更需要等到下次重啟才會影響到正在運行的頁面。這是預期的行為,不應該因此重啟一個正在被觀看的伺服器,而這也正是「我的修復沒有生效」的回報通常指向一個持有舊程式碼的運行中程序的原因。
樹狀結構是被「提供」的;日誌則是被「獲取」的
這是整個系統的架構,而且這是一個關於規模的規則,而不是風格偏好。
一個會話的樹狀結構成本約為每個代理 1KB,無論該代理做了什麼。它的日誌大小是無上限的,而且渲染日誌也會一併載入其中的圖片。將每個代理的日誌放入每個快照會使一個 228 個代理的會話的 GET / 請求達到 358MB,其中 110MB 是 base64 編碼的圖片資料,並且在每次 10 秒輪詢時重新發送。這樣一來,沒有東西會壞掉,但頁面根本無法被打開。
因此,載荷被分開處理,每個路由只攜帶一種東西:
| 路由 | 攜帶內容 | 成本 |
|---|---|---|
GET / | 完整樹狀結構、根會話的日誌,以及該內容中內嵌為 data URI 的圖片 | 一份完整的文件,無需伺服器即可正常運作 |
GET /state | 完整樹狀結構和不含日誌(?logs=<id>,<id> 可指定例外);?view=active 可將圖表範圍縮小到活躍視圖 | 這是 10 秒輪詢每 tick 都要支付的費用 |
GET /agent/<id> | 僅該代理的已渲染日誌 | 只在觀看者選擇某個區塊時進行一次擷取 |
GET /agent/<id>?since=<n> | 僅索引 n 之後的事件 | 對於安靜的代理,回傳空陣列 |
GET /img/<hash> | 按內容雜湊值提供的單張圖片,immutable | 每個觀看者每張圖片只載入一次,之後快取 |
在修改任何內容之前,有兩個後果值得了解:
since索引的是樹狀結構自身的count。 樹狀結構中的一行記錄了代理的實際事件計數,即使該代理的日誌未被渲染,而客戶端會將此數字作為since傳回。兩者都來自同一次記錄解析,並且必須保持一致:如果在其他地方推導出其中一個數字,客戶端就會悄悄地跳過或重複事件,結果就是看到的日誌微妙地出錯。GET /img/<hash>是內容定址的,因此回應會被永久快取。 這就是為什麼未知的雜湊值會回傳 404 而不是空白的 200——空白的 200 對該觀看者來說意味著一張永久損壞的圖片,而且重新整理頁面也無法修復。
如果失去了這個拆分機制,並不會有東西顯示紅色錯誤。 將所有日誌放入 GET /state,或將圖片內嵌到每個快照中,樹狀結構仍然是對的,標籤也是對的,頁面可以渲染,但體積會達到數百 MB。大小是我們要保護的屬性;serve.test.mjs 中的大小測試(第 6-9 行)是唯一能發現它退化的地方,而且每一行都是一個配對——這裡不存在,那裡存在——因為「X 不存在」這個斷言在錯誤的情況下也可能成立。
已儲存頁面「有」什麼,以及「沒有」什麼
GET / 是一個獨立的 HTML 文件:內聯 CSS 和 JS,沒有外部資源,並且包含一個內建的快照,即使沒有伺服器也能正常渲染。內建的內容是完整樹狀結構和根會話的日誌,以及該內容的圖片(以 data URI 形式)。
沒有內建:其他所有代理的日誌。 這些日誌不在文件中;它們會在點選對應區塊時從 /agent/<id> 取得。因此,一個已儲存到磁碟的頁面,或伺服器已停止的頁面,會顯示完整的圖表和根會話的日誌,但在點擊子代理區塊時則不會顯示任何內容。這些日誌中的圖片也是如此:它們是從 /img/<hash> 渲染的,而已儲存的文件背後沒有任何伺服器。
若要將所有日誌凍結成一個單一檔案,請使用 build.mjs——這個靜態建置工具會將每個代理的日誌和每張圖片都內嵌進去,因為紀念性存檔不需要從任何地方擷取資料。這是唯一一個要求 snapshot() 提供所有日誌的呼叫者(logsFor: null)。
如何追蹤會話
伺服器是長壽命的;Claude 會話則不是:結束會話、更新或重啟服務單元都會產生一個新的會話 ID。因此,伺服器在每次建立快照時都會重新選取最近活躍的會話,而不是在啟動時就決定一個,並且在沒有任何會話存在時提供一個 「正在等待會話...」 的頁面——這是開機時的正常狀態。「最近活躍」包括根記錄,而不僅僅是子代理檔案:一個剛啟動且尚未進行委派的新會話會立即被追蹤,並以僅有根節點的樹狀結構形式渲染,生成樹則會從第一次委派開始增長。當新會話開始時,無需重啟任何東西;頁面會偵測到 ID 變化並重建其日誌。
使用 --session=<id> 則會固定追蹤一個特定的會話(用於除錯的凍結視圖)。
配置目錄下的每一個專案 slug 都會被掃描,而不是只掃描一個。會話的記錄會放在其工作目錄的專案 slug 下,因此在使用 git worktree 進行的會話會寫入該 worktree 的 slug 下;固定一個 slug 會讓所有 worktree 會話變得不可見,但頁面仍然能正常渲染。
頁面顯示的是哪個代理
根區塊會以配置目錄所屬的 fleet 成員來標示,透過 config.json 的 fleet.by_config_dir 解析。鍵是推導出來的,而不是原始的目錄名稱,而推導過程是值得閱讀的部分:
format.mjs的fleetKey()會取配置目錄的原始目錄名稱,除非該原始目錄名稱正好是字面意義的.claude,在這種情況下它會取上層目錄的原始目錄名稱。一個為每個代理提供家目錄的 fleet 會將每個配置目錄放在<home>/<agent>/.claude路徑下,因此如果只用原始目錄名稱作為鍵,所有代理都會衝突成同一個條目,每個代理的頁面都會被標示為該條目所指向的代理名稱。
這個失敗是無聲的:頁面可以完整渲染,但顯示了錯誤的名稱,而打開兩個頁面的觀看者無法區分它們,也不知道它們無法被區分。字面意義的 .claude 條目會作為備用方案被尊重,因此使用 .claude 作為鍵的單代理安裝可以保留其標籤;但它無法區分兩個代理,因此在存在多個代理的情況下不應該使用。這個推導出的鍵也會用來從 config.json 的 servers 中選取預設連接埠,原因相同:如果使用原始目錄名稱匹配,每個代理都會拿到第一個條目的連接埠。
檔案
serve.mjs- 即時伺服器:提供GET /、GET /state、GET /agent/<id>和GET /img/<hash>(請參閱上方路由表)。主要工具。 支援--port=N、--host=<addr>、--cap=KB、--session=<id>,每個選項都預設從config.json讀取;--help會列印使用說明、解析後的配置路徑、記錄根目錄和預設連接埠,並且不會綁定任何東西。使用--port=0會取得一個短暫連接埠,橫幅會顯示它實際取得的連接埠。format.mjs- 共享格式化器:負責記錄解析、生成樹佈局、每個事件的 HTML、主題 CSS,以及讀取區域設定值的地方(cfg()讀取fleet where指定的config.json,或是在設定了WEB_HIERARCHY_CONFIG時讀取該檔案)。build.mjs- 可選的靜態快照工具(node build.mjs <out.html>):將當前狀態建置成一個自包含的檔案,作為凍結的、可分享的記錄。用於紀念性存檔,而非即時檢視。identities.json+avatars/- 顯示名稱名冊,除非identities.file指定了另一個檔案。serve.test.mjs+fixtures/- 測試:它在一個短暫連接埠上啟動一個真實伺服器,針對一個合成的記錄樹進行測試,斷言樹狀結構、驅動每個路由(不含日誌的樹、僅一個代理的日誌、?since只傳送新內容、按字節比對提供圖片的雜湊值),然後刪除一個子代理的記錄,並要求樹狀結構和該代理專屬的路由都失去它。其標頭說明了每個測試項目的目的、每個大小測試項目是哪個配對的一半,以及為什麼「負控制組」是承載負載的關鍵部分。第 11 項要求GET /state?view=active必須移除預設視圖會顯示的已停止區塊。view.test.mjs- 獨立的「僅活躍」規則測試(filterView()針對一個合成樹):活躍區塊、它們上方的區塊和根節點,以及被丟棄的已停止兄弟節點和子節點,而「全部」則回傳所有未修改的行。format.test.mjs- 測試如何找到並讀取配置:當WEB_HIERARCHY_CONFIG未設定時透過fleet where(由FLEET_BIN選取的假 fleet)、環境變數優先、失敗的 fleet 命令會被傳遞而不是被猜測過去、相對路徑、預設名冊,以及packages.dir_pattern。installation/- 服務單元範本、安裝程序和check。
頁面顯示內容
它看起來像是以深色色盲友善(dark-daltonized)主題顯示的 Claude Code 主控台——色板直接取自 Claude Code 套件(黑色背景、白色等寬字體、成功藍色 #3399ff 的工具提示、錯誤 #ff6666、帶有「...+N 行」展開器的暗淡結果行、思考標記、以及類似主控台輸入的任務和用戶訊息框)。
- 生成樹圖表。 根會話是頂部節點;所有沒有父節點的代理都掛在它下面。藍色 = 活躍(時間範圍旁邊有一個旋轉的 spinner 弧線;在
prefers-reduced-motion下為靜態),灰色 = 已停止。點擊一個區塊會在下方顯示該代理的完整日誌。 - 圖表上方的「全部 / 僅活躍」開關。 「全部」是預設值,會繪製所有區塊。「僅活躍」會繪製藍色區塊、這些區塊上方的所有區塊以及根節點:活躍標誌是區塊著色的依據,因此開關永遠不會與顏色不一致。保留上方的區塊是因為,等待長時間運行的子代理的父代理不會在其自身記錄中寫入任何內容,因此在子代理運行時會被顯示為已停止;如果沒有這些上方區塊,子代理就會孤立無援。如果沒有任何活躍代理,則視圖會只顯示根節點。此開關只影響圖表:時間戳仍然會計算整個會話(加上「顯示 N 個」),而指向隱藏代理的連結
#a-<id>仍然可以打開其日誌。這個選擇會透過瀏覽器的localStorage為每個觀看者記住;如果儲存空間被阻擋,該開關在該次造訪中仍然有效。format.mjs的filterView()是實作規則;view.test.mjs固定了這個行為。 - 每個區塊上的代理任務描述。 資料來源:產生
Agent呼叫時的description,該值會被框架複製到agent-<id>.meta.json中。如果沒有此欄位,則使用代理任務提示的第一行。描述可以換行,區塊會隨之伸展。 - 每個區塊的最後一行日誌,作為懸停工具提示。 當有數百個代理時,這通常是區分兩個具有相同類型和描述的區塊的唯一方法;而對於活躍的代理,它顯示的是它正在做的事。它被放在樹狀結構中,因為它是唯一一個成本足夠低的日誌內容:純文字、被截斷、絕非 HTML——一旦它渲染標記,就會把圖片和展開器帶入每個快照。
- 每個區塊上的模型資訊。
parseTranscript會從記錄的助理回覆輪次中讀取模型(去掉claude-前綴);如果代理尚未回覆,則使用其.meta.json中的粗略別名。如果一個節點兩者都沒有,則不會渲染任何行,而不是顯示佔位字元。 - 具名的代理與頭像。 每個區塊會顯示名稱、其頭像、agentType、描述、模型和時間範圍,因此可以一眼區分五個相同類型的並發代理。
- 名稱僅是顯示標籤:從
identities.file的名冊中,作為 (agentType, 該類型內的生成順序) 的純函數來分配,並在每次重新整理時根據記錄重新計算。沒有任何東西被儲存在任何地方,代理們也不知道自己的名字——框架擁有記錄,而這個格式化器是一個唯讀的觀察者。 - 使用生成順序,不是代理 ID 的雜湊值:當有 N 個名稱和 N 個代理時,雜湊值會因鴿巢原理而發生衝突;而生成順序則不會。
names的順序是關鍵的——只能附加。 重新排序或在中間插入會無聲地重新命名所有過去會話的圖表。avatar= 角色的輪廓;portraits= 可選的、針對某個名稱的自訂藝術圖案。 頭像會識別角色
- 名稱僅是顯示標籤:從