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