跳到內容

Hooks

本文件描述 src/extensibility/hooks/* 中的目前 hook 子系統程式碼

Hook 套件(src/extensibility/hooks/)仍作為 API 介面匯出並可使用,但預設的 CLI 執行時期現在初始化的是擴充執行器路徑。在目前的啟動流程中:

  • --hook 被視為 --extension 的別名(CLI 路徑會合併至 additionalExtensionPaths
  • 工具由 ExtensionToolWrapper 包裝,而非 HookToolWrapper
  • 情境轉換與生命週期發射透過 ExtensionRunner 進行

因此本文件記錄的是 hook 子系統本身的實作(型別/載入器/執行器/包裝器),包含舊版行為與限制。

  • src/extensibility/hooks/types.ts — hook 情境、事件型別與結果契約
  • src/extensibility/hooks/loader.ts — 模組載入與 hook 探索橋接
  • src/extensibility/hooks/runner.ts — 事件派送、命令查找與錯誤訊號
  • src/extensibility/hooks/tool-wrapper.ts — 工具前/後攔截包裝器
  • src/extensibility/hooks/index.ts — 匯出/重新匯出

Hook 模組必須預設匯出一個工廠函式:

import type { HookAPI } from "@f5-sales-demo/xcsh/hooks";
export default function hook(pi: HookAPI): void {
pi.on("tool_call", async (event, ctx) => {
if (event.toolName === "bash" && String(event.input.command ?? "").includes("rm -rf")) {
return { block: true, reason: "blocked by policy" };
}
});
}

工廠函式可以:

  • 透過 pi.on(...) 註冊事件處理器
  • 透過 pi.sendMessage(...) 傳送持久性自訂訊息
  • 透過 pi.appendEntry(...) 持久化非 LLM 狀態
  • 透過 pi.registerCommand(...) 註冊斜線命令
  • 透過 pi.registerMessageRenderer(...) 註冊自訂訊息渲染器
  • 透過 pi.exec(...) 執行 shell 命令

discoverAndLoadHooks(configuredPaths, cwd) 的執行步驟:

  1. 從能力登錄檔載入已探索的 hooks(loadCapability("hooks")
  2. 附加明確設定的路徑(依絕對路徑去重)
  3. 呼叫 loadHooks(allPaths, cwd)

loadHooks 接著匯入每個路徑並預期其具有 default 函式。

loader.ts 解析 hook 路徑的方式如下:

  • 絕對路徑:直接使用
  • ~ 路徑:展開後使用
  • 相對路徑:相對於 cwd 解析

hookCapability 的探索提供者仍以前/後 shell 風格的 hook 檔案為模型(例如 .claude/hooks/pre/*.xcsh/.../hooks/pre/*)。

此處的 hook 載入器使用動態模組匯入,並需要一個預設的 JS/TS hook 工廠函式。若探索到的 hook 路徑無法作為模組匯入,載入將失敗並回報於 LoadHooksResult.errors

Hook 事件在 types.ts 中具有強型別定義。

  • session_start
  • session_before_switch → 可回傳 { cancel?: boolean }
  • session_switch
  • session_before_branch → 可回傳 { cancel?: boolean; skipConversationRestore?: boolean }
  • session_branch
  • session_before_compact → 可回傳 { cancel?: boolean; compaction?: CompactionResult }
  • session.compacting → 可回傳 { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> }
  • session_compact
  • session_before_tree → 可回傳 { cancel?: boolean; summary?: { summary: string; details?: unknown } }
  • session_tree
  • session_shutdown
  • context → 可回傳 { messages?: Message[] }
  • before_agent_start → 可回傳 { message?: { customType; content; display; details } }
  • agent_start
  • agent_end
  • turn_start
  • turn_end
  • auto_compaction_start
  • auto_compaction_end
  • auto_retry_start
  • auto_retry_end
  • ttsr_triggered
  • todo_reminder
  • tool_call(執行前)→ 可回傳 { block?: boolean; reason?: string }
  • tool_result(執行後)→ 可回傳 { content?; details?; isError? }

這是 hook 子系統的核心前/後攔截模型。

Hook 工具攔截流程
tool_call 處理器
├─ 任何 { block: true }?── 是 ──> 拋出例外(工具已封鎖)
└─ 否
執行底層工具
├─ 成功 ──> tool_result 處理器可覆寫 { content, details }
└─ 錯誤 ──> 發射 tool_result(isError=true) 後重新拋出原始錯誤

HookToolWrapper.execute() 在工具執行前發射 tool_call

  • 若任何處理器回傳 { block: true },執行停止
  • 若處理器拋出例外,包裝器以安全失敗(fail-closed)方式封鎖執行
  • 回傳的 reason 將成為拋出的錯誤訊息

若未被封鎖,底層工具正常執行。

成功後,包裝器發射 tool_result 並帶有:

  • toolNametoolCallIdinput
  • content
  • details
  • isError: false

若處理器回傳覆寫值:

  • content 可替換結果內容
  • details 可替換結果詳情

工具失敗時,包裝器發射帶有 isError: true 及錯誤文字內容的 tool_result,然後重新拋出原始錯誤。

  • 單次呼叫的 LLM 情境,透過 contextmessages 替換鏈)
  • 成功工具呼叫的工具輸出內容/詳情(tool_result 路徑)
  • 代理程式啟動前注入的訊息,透過 before_agent_start
  • 透過 session_before_*session.compacting 取消/自訂壓縮/樹狀行為

在此實作中 Hooks 無法變異的內容

Section titled “在此實作中 Hooks 無法變異的內容”
  • 原地修改工具輸入參數(tool_call 上只能封鎖/允許)
  • 工具錯誤拋出後繼續執行(錯誤路徑會重新拋出)
  • 包裝器行為中的最終成功/錯誤狀態(回傳的 isError 已有型別定義,但 HookToolWrapper 不會套用)

能力提供者依優先順序排序(較高者優先)。去重依據能力金鑰,先者優先。

對於 hooks,能力金鑰為 ${type}:${tool}:${name}。來自較低優先順序提供者的重複項目會被標記並從有效探索清單中排除。

discoverAndLoadHooks 建立一個扁平的 allPaths 清單,依解析後的絕對路徑去重,然後 loadHooks 依序迭代。每個探索目錄中的檔案順序取決於 readdir 的輸出;hook 載入器不進行額外排序。

HookRunner 內部,順序由註冊序列決定,具有確定性:

  1. hooks 陣列順序
  2. 每個 hook/事件的處理器註冊順序

依事件型別的衝突行為:

  • tool_call:最後回傳的結果優先,除非某個處理器封鎖;第一個封鎖立即短路
  • tool_result:最後回傳的覆寫值優先(無短路)
  • context:鏈式執行;每個處理器接收前一個處理器的訊息輸出
  • before_agent_start:第一個回傳的訊息被保留;後續訊息忽略
  • session_before_*:追蹤最後回傳的結果;cancel: true 立即短路
  • session.compacting:最後回傳的結果優先

命令/渲染器衝突:

  • getCommand(name) 回傳跨 hooks 的第一個匹配(先載入者優先)
  • getMessageRenderer(customType) 回傳第一個匹配
  • getRegisteredCommands() 回傳所有命令(不去重)

HookUIContext 包含:

  • selectconfirminputeditor
  • notify
  • setStatus
  • custom
  • setEditorTextgetEditorText
  • theme getter

ctx.hasUI 表示互動式 UI 是否可用。

無 UI 執行時,預設的空操作情境行為為:

  • select/input/editor 回傳 undefined
  • confirm 回傳 false
  • notifysetStatussetEditorText 為空操作
  • getEditorText 回傳 ""

透過 ctx.ui.setStatus(key, text) 設定的 hook 狀態文字:

  • 依金鑰儲存
  • 依金鑰名稱排序
  • 經過清理(\r\n\t → 空格;重複空格合併)
  • 合併後依寬度截斷以供顯示
  • 無效模組或缺少預設匯出 → 擷取至 LoadHooksResult.errors
  • 其他 hooks 繼續載入

HookRunner.emit(...) 針對大多數事件捕捉處理器錯誤,並向監聽器發射 HookErrorhookPatheventerror),然後繼續執行。

emitToolCall(...) 較為嚴格:處理器錯誤在此不會被吞噬;它們會傳播至呼叫端。在 HookToolWrapper 中,這會封鎖工具呼叫(安全失敗)。

import type { HookAPI } from "@f5-sales-demo/xcsh/hooks";
export default function (pi: HookAPI): void {
pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "bash") return;
const cmd = String(event.input.command ?? "");
if (!cmd.includes("rm -rf")) return;
if (!ctx.hasUI) return { block: true, reason: "rm -rf blocked (no UI)" };
const ok = await ctx.ui.confirm("Dangerous command", `Allow: ${cmd}`);
if (!ok) return { block: true, reason: "user denied command" };
});
}
import type { HookAPI } from "@f5-sales-demo/xcsh/hooks";
export default function (pi: HookAPI): void {
pi.on("tool_result", async event => {
if (event.toolName !== "read" || event.isError) return;
const redacted = event.content.map(chunk => {
if (chunk.type !== "text") return chunk;
return { ...chunk, text: chunk.text.replaceAll(/API_KEY=\S+/g, "API_KEY=[REDACTED]") };
});
return { content: redacted };
});
}
import type { HookAPI } from "@f5-sales-demo/xcsh/hooks";
export default function (pi: HookAPI): void {
pi.on("context", async event => {
const filtered = event.messages.filter(msg => !(msg.role === "custom" && msg.customType === "debug-only"));
return { messages: filtered };
});
}

使用命令安全情境方法註冊斜線命令

Section titled “使用命令安全情境方法註冊斜線命令”
import type { HookAPI } from "@f5-sales-demo/xcsh/hooks";
export default function (pi: HookAPI): void {
pi.registerCommand("handoff", {
description: "Create a new session with setup message",
handler: async (_args, ctx) => {
await ctx.waitForIdle();
await ctx.newSession({
parentSession: ctx.sessionManager.getSessionFile(),
setup: async sm => {
sm.appendMessage({
role: "user",
content: [{ type: "text", text: "Continue from prior session summary." }],
timestamp: Date.now(),
});
},
});
},
});
}

src/extensibility/hooks/index.ts 匯出:

  • 載入 API(discoverAndLoadHooksloadHooks
  • 執行器與包裝器(HookRunnerHookToolWrapper
  • 所有 hook 型別
  • execCommand 重新匯出

套件根目錄(src/index.ts)將 hook 型別重新匯出,作為舊版相容介面。