Skip to content

Hooks:使用者鉤子執行鏈

源码版本v4.0.10

職責

Cline 的 hooks 系統讓使用者在主 agent loop 的若干生命點(提交 prompt 前、工具用前、工具用後、任務開始/結束、通知發出、壓縮前)插入自己的 shell 腳本。腳本能讀 stdin 拿到結構化 JSON 輸入,能透過 stdout 回傳 JSON 控制 Cline 行為——最關鍵的是 cancel: true 可以阻止即將發生的動作,contextModification 能往對話裡追加額外上下文。整套機制分三層:HookExecutor 作為入口分發 (executeHook:58),HookFactory + HookRunner 負責具體腳本的發現與執行 (HookRunner exec:289),HookProcess 管理底層子程序 (HookProcess:89)。

它橫跨整個 Task 生命週期。PreToolUse / PostToolUse 由 ToolExecutor 在工具執行前後調 (PreToolUse executeHook:73);UserPromptSubmit / TaskStart / TaskResume / TaskCancel 由主 Task 在對應生命點調 (UserPromptSubmit:1214);Notification 由 NotificationHook 模組統一發出 (emitNotificationHook:50)。hook 的「可阻斷」語義只在可取消的點生效,Notification 這種 fire-and-forget 的 hook 即便回傳 cancel 也會被忽略。

設計動機

  • 9 種標準 hook:PreToolUse / PostToolUse / UserPromptSubmit / TaskStart / TaskResume / TaskCancel / TaskComplete / Notification / PreCompact (Hooks interface:102),覆蓋所有關鍵生命點。
  • JSON in / JSON out:hook 腳本收到的 stdin 是帶 clineVersion / hookName / timestamp / workspaceRoots / userId 等元資料的 JSON (completeParams:198),輸出也是 JSON。
  • 三種回傳欄位:cancel 阻斷動作、contextModification 追加上下文、errorMessage 附加錯誤說明 (HookExecutionResult:33)。
  • cancellable 標誌位:呼叫方決定該 hook 能不能取消動作,NotificationisCancellable: false (not cancellable:61),PreToolUse 傳 true。
  • 程序隔離 + 註冊表:HookProcessRegistry 追蹤所有活動 hook 程序,terminateAll 在 task 取消時統一殺 (HookProcessRegistry:17)。
  • 跨平台啟動器:getHookLaunchConfig 區分 Windows PowerShell 與 Unix shebang (getHookLaunchConfig:44),讓使用者用 .sh / .ps1 / .js 都能跑。
  • stdout 流式回顯:HookProcess 把 stdout/stderr 按行 emit,streamCallback[source stream path] 前綴後回顯給 UI (streamCallback:124),長 hook 也能看到進度。
  • JSON 提取兜底:hook 腳本在 JSON 前後混列印除錯日誌時,從後往前掃括號配對找出最後一個完整 JSON 物件 (JSON extraction:379)。

關鍵檔案

資料流

PreToolUse 為例,這是最完整的「可阻斷」鏈。ToolExecutor 在真正執行工具前先調 ToolHookUtils.executePreToolUseHook:

typescript
// apps/vscode/src/core/task/tools/utils/ToolHookUtils.ts
const { executeHook } = await import("@core/hooks/hook-executor")

const pendingToolInfo: any = { tool: block.name }
if (block.params.path) pendingToolInfo.path = block.params.path
if (block.params.command) pendingToolInfo.command = block.params.command
// ...

const preToolResult = await executeHook({
  hookName: "PreToolUse",
  hookInput: {
    preToolUse: { toolName: block.name, parameters: block.params },
  },
  isCancellable: true,
  say: config.callbacks.say,
  setActiveHookExecution: config.callbacks.setActiveHookExecution,
  clearActiveHookExecution: config.callbacks.clearActiveHookExecution,
  messageStateHandler: config.messageState,
  taskId: config.taskId,
  hooksEnabled,
  model: getHookModelContext(config.api, config.services.stateManager),
  toolName: block.name,
  pendingToolInfo,
})

if (preToolResult.cancel === true) {
  await config.callbacks.clearActiveHookExecution()
  await config.callbacks.cancelTask()
  throw new PreToolUseHookCancellationError(preToolResult.errorMessage || "PreToolUse hook requested cancellation")
}

if (preToolResult.contextModification) {
  ToolHookUtils.addHookContextToConversation(config, preToolResult.contextModification, "PreToolUse")
}

這段在 PreToolUse caller:73 附近。executeHook 進去後先看 hooksEnabled (early return:72),關了直接回傳。然後 HookFactory.hasHook 查是否配了這個 hook 名的腳本 (hasHook:80),沒配也直接回傳。配上後建 HookProcess 並 spawn 子程序:

typescript
// apps/vscode/src/core/hooks/HookProcess.ts
this.childProcess = spawn(launchConfig.command, launchConfig.args, {
  stdio: ["pipe", "pipe", "pipe"],
  shell: launchConfig.shell,
  detached: launchConfig.detached,
  cwd: this.cwd,
  windowsHide: true,
})

this.timeoutHandle = setTimeout(() => {
  if (this.childProcess && !this.isCompleted) {
    this.childProcess.kill("SIGTERM")
    reject(new Error(`Hook execution timed out after ${this.timeoutMs}ms.`))
  }
}, this.timeoutMs)

this.childProcess.stdout?.on("data", (data) => {
  this.stdoutBuffer += output
  this.handleOutput(output, didEmitEmptyLine, "stdout")
})
// ...
this.childProcess.on("close", (code, signal) => {
  this.exitCode = code
  // resolve / reject
})

this.childProcess.stdin?.write(inputJson)
this.childProcess.stdin?.end()

這段在 spawn child:176 附近。子程序退出後,HookRunner.exec 拿 stdout 去 parseJsonOutput (parseJsonOutput:349),有合法 JSON 就按 JSON 走(即便 exitCode 非 0 也優先)(JSON priority:454),沒有就按 exitCode 判斷。cancel: true 沿著 HookExecutor.executeHook 一路回傳,ToolHookUtils 拿到後拋 PreToolUseHookCancellationError 並調 cancelTask

邊界與失敗

  • PreToolUse cancel 直接 abort 整個 task:不只是跳過這個工具,而是調 cancelTask 把整條 agent loop 都停掉 (cancelTask on cancel:100)。
  • PostToolUse cancel 不 abort task:runPostToolUseHook 收到 cancel 只 say error 回傳 true,工具已執行完無法回滾 (post cancel soft:494)。
  • Notification hook 忽略 cancel 與 contextModification:這種 hook 是單向通知,輸出欄位被顯式忽略 (ignore unsupported output:70)。
  • abort signal 立即 reject:HookProcess 監聽 abortSignal,一旦 abort 立即 kill 子程序並 reject,不等 close (abortHandler:139)。
  • 超時按腳本路徑報錯:timeout 錯誤訊息帶 this.scriptPath 和超時毫秒數,方便使用者定位哪個 hook 卡死 (timeout error:192)。
  • 輸出 1MB 上限:MAX_HOOK_OUTPUT_SIZE 攔住無限輸出,超了 emit 一條 truncation 提示 (output truncation:311)。
  • contextModification 截斷:MAX_CONTEXT_MODIFICATION_SIZE 防止 hook 注入超大上下文,超了截斷加提示 (context truncation:363)。
  • PreToolUse 跳過 attempt_completion:attempt_completion 是收尾工具,不走 PreToolUse 阻斷 (skip attempt_completion:30)。
  • NoOp hook 回傳 proto defaults:沒找到腳本的 hook 回傳 cancel: false / contextModification: "" / errorMessage: "",executor 把這視為正常 no-op (NoOp runner:238)。
  • JSON 提取從右往左掃:hook 腳本前面打除錯日誌導致首次 JSON.parse 失敗時,parseJsonOutput 用括號配對找最後一個完整物件 (brace scan:385),兜底很關鍵。

小結

Hooks 系統是 Cline 給使用者留的「在 agent loop 關鍵點插腳本」的擴充位。想看主 Task 在哪些時刻觸發 hook 可以讀 agent-loop/task-class;想看工具執行器如何把 PreToolUse / PostToolUse 串起來可以讀 cap-tools;想看 StateManager 如何為 hook 提供 workspaceRoots 等元資料可以讀 state-manager

對照官方資料:Cline 文件 · README