Skip to content

Hooks: the user-hook execution chain

源码版本v4.0.10

Responsibilities

Cline's hooks system lets users plug their own shell scripts into a handful of lifecycle points of the main agent loop (before submitting a prompt, before a tool runs, after a tool runs, task start/end, notification dispatched, before compaction). Scripts receive structured JSON input via stdin and can return JSON via stdout to steer Cline's behavior — most importantly, cancel: true blocks the imminent action, and contextModification appends extra context to the conversation. The whole mechanism has three layers: HookExecutor is the entry point that dispatches (executeHook:58), HookFactory + HookRunner handle script discovery and execution (HookRunner exec:289), and HookProcess manages the underlying subprocess (HookProcess:89).

It spans the entire Task lifecycle. PreToolUse / PostToolUse are called by ToolExecutor before and after tool execution (PreToolUse executeHook:73); UserPromptSubmit / TaskStart / TaskResume / TaskCancel are called by the main Task at the corresponding lifecycle points (UserPromptSubmit:1214); Notification is dispatched centrally by the NotificationHook module (emitNotificationHook:50). The "blockable" semantics of a hook only apply at cancellable points; fire-and-forget hooks like Notification ignore a returned cancel.

Design motivation

  • Nine standard hooks: PreToolUse / PostToolUse / UserPromptSubmit / TaskStart / TaskResume / TaskCancel / TaskComplete / Notification / PreCompact (Hooks interface:102), covering every key lifecycle point.
  • JSON in / JSON out: the stdin a hook script receives is JSON with metadata such as clineVersion / hookName / timestamp / workspaceRoots / userId (completeParams:198), and the output is JSON too.
  • Three output fields: cancel blocks an action, contextModification appends context, and errorMessage attaches an error explanation (HookExecutionResult:33).
  • A cancellable flag: the caller decides whether a hook can cancel an action; Notification passes isCancellable: false (not cancellable:61), while PreToolUse passes true.
  • Process isolation + registry: HookProcessRegistry tracks every active hook process, and terminateAll kills them all when a task is cancelled (HookProcessRegistry:17).
  • Cross-platform launcher: getHookLaunchConfig distinguishes Windows PowerShell from Unix shebang (getHookLaunchConfig:44), so .sh / .ps1 / .js scripts all run.
  • Stdout streamed back to the UI: HookProcess emits stdout/stderr line by line, and streamCallback prefixes each line with [source stream path] before echoing it to the UI (streamCallback:124), so even long-running hooks show progress.
  • JSON extraction fallback: when a hook script interleaves debug logs before or after the JSON, the parser scans backward matching brackets to find the last complete JSON object (JSON extraction:379).

Key files

Data flow

Taking PreToolUse as the example, since it's the most complete "blockable" chain. Before actually running a tool, ToolExecutor calls 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")
}

This sits near PreToolUse caller:73. Inside executeHook it first checks hooksEnabled (early return:72); if disabled it returns immediately. Then HookFactory.hasHook checks whether a script is configured for this hook name (hasHook:80); if not, it also returns. When configured, it creates a HookProcess and spawns the subprocess:

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()

This sits near spawn child:176. After the subprocess exits, HookRunner.exec feeds stdout to parseJsonOutput (parseJsonOutput:349); if there's valid JSON it follows the JSON (even when exitCode is non-zero, JSON wins) (JSON priority:454); otherwise it falls back to exitCode. cancel: true propagates back up through HookExecutor.executeHook, and ToolHookUtils throws PreToolUseHookCancellationError and calls cancelTask.

Boundaries and failure modes

  • PreToolUse cancel aborts the whole task: it doesn't just skip this tool — it calls cancelTask and stops the entire agent loop (cancelTask on cancel:100).
  • PostToolUse cancel does not abort the task: runPostToolUseHook on cancel only says an error and returns true; the tool already ran and can't be rolled back (post cancel soft:494).
  • Notification hook ignores cancel and contextModification: it's a one-way notification, so output fields are explicitly ignored (ignore unsupported output:70).
  • Abort signal rejects immediately: HookProcess listens to abortSignal; on abort it kills the subprocess and rejects without waiting for close (abortHandler:139).
  • Timeout error includes the script path: the timeout error message carries this.scriptPath and the timeout in milliseconds, so users can pinpoint which hook hung (timeout error:192).
  • 1 MB output cap: MAX_HOOK_OUTPUT_SIZE cuts off unbounded output; beyond it, a truncation notice is emitted (output truncation:311).
  • contextModification truncation: MAX_CONTEXT_MODIFICATION_SIZE prevents hooks from injecting oversized context; beyond it the content is truncated with a notice (context truncation:363).
  • PreToolUse skips attempt_completion: attempt_completion is the finishing tool and isn't subject to PreToolUse blocking (skip attempt_completion:30).
  • NoOp hook returns proto defaults: a hook without a configured script returns cancel: false / contextModification: "" / errorMessage: ""; the executor treats this as a normal no-op (NoOp runner:238).
  • JSON extraction scans right-to-left: when a hook script's leading debug logs make the first JSON.parse fail, parseJsonOutput walks brackets to find the last complete object (brace scan:385); this fallback is essential.

Summary

The hooks system is the extension slot Cline leaves for users to plug scripts into key points of the agent loop. To see the moments at which the main Task fires hooks, read agent-loop/task-class; to see how the tool executor threads PreToolUse / PostToolUse together, read cap-tools; to see how StateManager supplies metadata like workspaceRoots to hooks, read state-manager.

See official docs: Cline docs · README