Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

第18章:Hooks — 用户自定义拦截点

定位:本章分析 Hooks 系统——在Agent生命周期的26个事件点注册自定义Shell命令、LLM提示词或HTTP请求的机制。前置依赖:第16章(权限系统)。适用场景:想了解CC用户自定义拦截点机制的读者,或想在自己的Agent中实现hook系统的开发者。

为什么这很重要

Claude Code 的权限系统(第16章)和 YOLO 分类器(第17章)提供了内置的安全防线,但它们都是"预设好的"——用户无法在工具执行流水线的关键节点插入自己的逻辑。Hooks 系统填补了这个空白:它允许用户在 AI Agent 生命周期的 26 个事件点注册自定义的 Shell 命令、LLM 提示词、HTTP 请求或 Agent 验证器,实现从"格式检查"到"自动部署"的任意工作流定制。

这不是一个简单的"回调函数"机制。Hooks 系统必须解决四个核心难题:信任——任意命令执行的安全边界在哪里?超时——Hook 挂死时如何防止阻塞整个 Agent 循环?语义——Hook 的退出码如何转化为"允许"或"阻塞"决策?以及配置隔离——多来源的 Hook 配置如何合并而不互相干扰?

本章将从源码层面完整剖析这套机制。

Hook 事件生命周期总览

flowchart LR
    subgraph SESSION ["会话生命周期"]
        direction TB
        SS["SessionStart"] --> SETUP["Setup"]
    end

    subgraph TOOL ["工具执行生命周期"]
        direction TB
        PRE["PreToolUse"] --> PERM{"权限检查"}
        PERM -- 需要确认 --> PR["PermissionRequest"]
        PERM -- 通过 --> EXEC["执行工具"]
        PR -- 允许 --> EXEC
        PR -- 拒绝 --> PD["PermissionDenied"]
        EXEC -- 成功 --> POST["PostToolUse"]
        EXEC -- 失败 --> POSTF["PostToolUseFailure"]
    end

    subgraph RESPOND ["响应生命周期"]
        direction TB
        UPS["UserPromptSubmit"] --> TOOL2["工具调用循环"]
        TOOL2 --> STOP["Stop"]
        STOP -- "退出码 2" --> TOOL2
    end

    subgraph END_PHASE ["结束"]
        direction TB
        SE["SessionEnd<br/>超时: 1.5s"]
    end

    SESSION --> RESPOND
    RESPOND --> END_PHASE

18.1 Hook 事件类型完整清单

Hooks 系统支持 26 种事件类型,定义在 hooksConfigManager.tsgetHookEventMetadata 函数中(第 28-264 行)。按生命周期阶段可分为五组:

工具执行生命周期

事件触发时机matcher 字段退出码 2 的行为
PreToolUse工具执行前tool_name阻塞工具调用,stderr 发送给模型
PostToolUse工具执行成功后tool_namestderr 立即发送给模型
PostToolUseFailure工具执行失败后tool_namestderr 立即发送给模型
PermissionRequest权限对话框显示时tool_name使用 Hook 决策
PermissionDeniedauto 模式分类器拒绝工具调用后tool_name

PreToolUse 是最常用的 Hook 点。它的 hookSpecificOutput 支持三种权限决策(第 72-78 行,types/hooks.ts):

// types/hooks.ts:72-78
z.object({
  hookEventName: z.literal('PreToolUse'),
  permissionDecision: permissionBehaviorSchema().optional(),
  permissionDecisionReason: z.string().optional(),
  updatedInput: z.record(z.string(), z.unknown()).optional(),
  additionalContext: z.string().optional(),
})

注意 updatedInput 字段——Hook 不仅可以决定"是否允许",还可以修改工具的输入参数。这使得"重写命令"成为可能:比如在所有 git push 前自动添加 --no-verify

会话生命周期

事件触发时机matcher 字段特殊行为
SessionStart新会话/恢复/清空/压缩时source (startup/resume/clear/compact)stdout 发送给 Claude,阻塞错误被忽略
SessionEnd会话结束时reason (clear/logout/prompt_input_exit/other)超时仅 1.5 秒
Setup仓库初始化和维护时trigger (init/maintenance)stdout 发送给 Claude
StopClaude 即将结束响应前退出码 2 让对话继续
StopFailureAPI 错误导致回合结束时error (rate_limit/authentication_failed/...)fire-and-forget
UserPromptSubmit用户提交提示词时退出码 2 阻塞处理并擦除原始提示词

SessionStart Hook 有一个独特的能力:通过 CLAUDE_ENV_FILE 环境变量,Hook 可以将 bash export 语句写入指定文件,这些环境变量会在后续所有 BashTool 命令中生效(第 917-926 行,hooks.ts):

// hooks.ts:917-926
if (
  !isPowerShell &&
  (hookEvent === 'SessionStart' ||
    hookEvent === 'Setup' ||
    hookEvent === 'CwdChanged' ||
    hookEvent === 'FileChanged') &&
  hookIndex !== undefined
) {
  envVars.CLAUDE_ENV_FILE = await getHookEnvFilePath(hookEvent, hookIndex)
}

多 Agent 生命周期

事件触发时机matcher 字段
SubagentStart子 Agent 启动时agent_type
SubagentStop子 Agent 即将结束响应前agent_type
TeammateIdle队友即将进入空闲状态
TaskCreated任务创建时
TaskCompleted任务完成时

文件与配置变更

事件触发时机matcher 字段
FileChanged被监听文件变更时文件名 (e.g. .envrc|.env)
CwdChanged工作目录变更后
ConfigChange配置文件在会话期间变更时source (user_settings/project_settings/...)
InstructionsLoadedCLAUDE.md 或规则文件加载时load_reason (session_start/path_glob_match/...)

压缩、MCP 交互与 Worktree

事件触发时机matcher 字段
PreCompact对话压缩前trigger (manual/auto)
PostCompact对话压缩后trigger (manual/auto)
ElicitationMCP 服务器请求用户输入时mcp_server_name
ElicitationResult用户响应 MCP elicitation 后mcp_server_name
WorktreeCreate创建隔离工作树时
WorktreeRemove移除工作树时

18.2 四种 Hook 类型

Hooks 系统支持四种可持久化的 Hook 类型,加上两种运行时注册的内部类型。所有可持久化类型的 schema 定义在 schemas/hooks.tsbuildHookSchemas 函数中(第 31-163 行)。

command 类型:Shell 命令

最基础也最常用的类型:

// schemas/hooks.ts:32-65
const BashCommandHookSchema = z.object({
  type: z.literal('command'),
  command: z.string(),
  if: IfConditionSchema(),
  shell: z.enum(SHELL_TYPES).optional(),   // 'bash' | 'powershell'
  timeout: z.number().positive().optional(),
  statusMessage: z.string().optional(),
  once: z.boolean().optional(),            // 执行一次后移除
  async: z.boolean().optional(),           // 后台执行,不阻塞
  asyncRewake: z.boolean().optional(),     // 后台执行,退出码2时唤醒模型
})

shell 字段控制解释器选择(第 790-791 行,hooks.ts)——默认为 bash(实际使用 $SHELL,支持 bash/zsh/sh),powershell 使用 pwsh。两条执行路径完全分离:bash 路径会做 Windows Git Bash 路径转换(C:\Users\foo -> /c/Users/foo)、.sh 文件自动 bash 前缀、CLAUDE_CODE_SHELL_PREFIX 包装;PowerShell 路径则跳过所有这些,使用原生 Windows 路径。

if 字段提供了细粒度的条件过滤。它使用权限规则语法(如 Bash(git *)),在 Hook 匹配阶段而非 spawn 之后评估——避免为不匹配的命令启动无用进程(第 1390-1421 行,hooks.ts):

// hooks.ts:1390-1421
async function prepareIfConditionMatcher(
  hookInput: HookInput,
  tools: Tools | undefined,
): Promise<IfConditionMatcher | undefined> {
  if (
    hookInput.hook_event_name !== 'PreToolUse' &&
    hookInput.hook_event_name !== 'PostToolUse' &&
    hookInput.hook_event_name !== 'PostToolUseFailure' &&
    hookInput.hook_event_name !== 'PermissionRequest'
  ) {
    return undefined
  }
  // ...复用权限规则解析器和工具的 preparePermissionMatcher
}

prompt 类型:LLM 评估

将 Hook 输入发送给一个轻量级 LLM 进行评估:

// schemas/hooks.ts:67-95
const PromptHookSchema = z.object({
  type: z.literal('prompt'),
  prompt: z.string(),     // 使用 $ARGUMENTS 占位符注入 Hook 输入 JSON
  if: IfConditionSchema(),
  model: z.string().optional(),  // 默认使用小型快速模型
  statusMessage: z.string().optional(),
  once: z.boolean().optional(),
})

agent 类型:Agent 验证器

比 prompt 更强大——它会启动一个完整的 Agent 循环来验证某个条件:

// schemas/hooks.ts:128-163
const AgentHookSchema = z.object({
  type: z.literal('agent'),
  prompt: z.string(),     // "Verify that unit tests ran and passed."
  if: IfConditionSchema(),
  timeout: z.number().positive().optional(),  // 默认 60 秒
  model: z.string().optional(),  // 默认使用 Haiku
  statusMessage: z.string().optional(),
  once: z.boolean().optional(),
})

源码中有一条重要的设计注释(第 130-141 行):prompt 字段曾被 .transform() 包装为函数,导致 JSON.stringify 时丢失——这个 Bug 被追踪为 gh-24920/CC-79,现已修复。

http 类型:Webhook

将 Hook 输入 POST 到指定 URL:

// schemas/hooks.ts:97-126
const HttpHookSchema = z.object({
  type: z.literal('http'),
  url: z.string().url(),
  if: IfConditionSchema(),
  timeout: z.number().positive().optional(),
  headers: z.record(z.string(), z.string()).optional(),
  allowedEnvVars: z.array(z.string()).optional(),
  statusMessage: z.string().optional(),
  once: z.boolean().optional(),
})

headers 支持环境变量插值($VAR_NAME${VAR_NAME}),但只有 allowedEnvVars 中列出的变量才会被解析——这是一个显式白名单机制,防止意外泄露敏感环境变量。

需要注意:HTTP Hook 不支持 SessionStartSetup 事件(第 1853-1864 行,hooks.ts),因为在 headless 模式下 sandbox ask 回调会死锁。

内部类型:callback 和 function

这两种类型无法通过配置文件定义,仅供 SDK 和内部组件注册。callback 类型用于 attribution hooks、session file access hooks 等内部功能;function 类型由 Agent 前言(frontmatter)注册的结构化输出强制器使用。


18.3 执行模型

异步生成器架构

executeHooks 是整个系统的核心函数(第 1952-2098 行,hooks.ts),它被声明为 async function*——一个异步生成器:

// hooks.ts:1952-1977
async function* executeHooks({
  hookInput,
  toolUseID,
  matchQuery,
  signal,
  timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
  toolUseContext,
  messages,
  forceSyncExecution,
  requestPrompt,
  toolInputSummary,
}: { /* ... */ }): AsyncGenerator<AggregatedHookResult> {

这个设计允许调用者通过 for await...of 逐步接收 Hook 执行结果,实现流式处理。每个 Hook 在执行前先 yield 一个 progress 消息,执行完成后 yield 最终结果。

超时策略

超时策略根据事件类型分为两档:

默认超时:10 分钟。 定义在第 166 行:

// hooks.ts:166
const TOOL_HOOK_EXECUTION_TIMEOUT_MS = 10 * 60 * 1000

这个较长的超时适用于大多数 Hook 事件——用户的 CI 脚本、测试套件、构建命令都可能需要数分钟。

SessionEnd 超时:1.5 秒。 定义在第 175-182 行:

// hooks.ts:174-182
const SESSION_END_HOOK_TIMEOUT_MS_DEFAULT = 1500
export function getSessionEndHookTimeoutMs(): number {
  const raw = process.env.CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS
  const parsed = raw ? parseInt(raw, 10) : NaN
  return Number.isFinite(parsed) && parsed > 0
    ? parsed
    : SESSION_END_HOOK_TIMEOUT_MS_DEFAULT
}

SessionEnd Hook 在关闭/清空时运行,必须有极其紧凑的超时约束——否则用户按 Ctrl+C 后还要等 10 分钟才能退出。1.5 秒同时作为单个 Hook 的默认超时和整体 AbortSignal 上限(因为所有 Hook 并行执行)。用户可通过 CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS 环境变量覆盖。

每个 Hook 还可以通过 timeout 字段指定自己的超时时间(秒),它会覆盖默认值(第 877-879 行):

// hooks.ts:877-879
const hookTimeoutMs = hook.timeout
  ? hook.timeout * 1000
  : TOOL_HOOK_EXECUTION_TIMEOUT_MS

异步后台 Hook

Hook 可以通过两种方式进入后台执行:

  1. 配置声明:设置 async: trueasyncRewake: true(第 995-1029 行)
  2. 运行时声明:Hook 在第一行输出 {"async": true} JSON(第 1117-1164 行)

两者的关键区别在于 asyncRewake:当设置此标志时,后台 Hook 不注册到异步注册表,而是在完成后检查退出码——如果退出码为 2,它会通过 enqueuePendingNotification 将错误消息作为 task-notification 入队,唤醒模型继续处理(第 205-244 行)。

后台 Hook 执行时的一个微妙细节:必须在 backgrounding 之前写入 stdin,否则 bash 的 read -r line 会因 EOF 返回退出码 1——这个 Bug 被追踪为 gh-30509/CC-161(第 1001-1008 行的注释)。

提示词请求协议

command 类型 Hook 支持一种双向交互协议:Hook 进程可以向 stdout 写入 JSON 格式的提示词请求,Claude Code 将向用户显示选择对话框,并将用户选择通过 stdin 回传:

// types/hooks.ts:28-40
export const promptRequestSchema = lazySchema(() =>
  z.object({
    prompt: z.string(),       // 请求 ID
    message: z.string(),      // 显示给用户的消息
    options: z.array(
      z.object({
        key: z.string(),
        label: z.string(),
        description: z.string().optional(),
      }),
    ),
  }),
)

这个协议是序列化的——多个提示词请求会按顺序处理(第 1064 行的 promptChain),确保响应不会乱序。


18.4 退出码语义

退出码是 Hook 与 Claude Code 之间的主要通信协议:

退出码语义行为
0成功/允许stdout/stderr 不显示(或仅在 transcript 模式显示)
2阻塞错误stderr 发送给模型,阻塞当前操作
其他非阻塞错误stderr 仅显示给用户,操作继续

但不同事件类型对退出码的解释有所不同。以下是关键差异:

  • PreToolUse:退出码 2 阻塞工具调用并将 stderr 发送给模型;退出码 0 的 stdout/stderr 不显示
  • Stop:退出码 2 将 stderr 发送给模型并继续对话(而非结束)——这是"继续编码"模式的实现基础
  • UserPromptSubmit:退出码 2 阻塞处理、擦除原始提示词、并仅向用户显示 stderr
  • SessionStart/Setup:阻塞错误被忽略——这些事件不允许 Hook 阻塞启动流程
  • StopFailure:fire-and-forget,所有输出和退出码都被忽略

JSON 输出协议

除了退出码,Hook 还可以通过 stdout 输出 JSON 来传递结构化信息。parseHookOutput 函数(第 399-451 行)的逻辑是:如果 stdout 以 { 开头,尝试 JSON 解析并通过 Zod schema 验证;否则视为纯文本。

JSON 输出的完整 schema 定义在 types/hooks.ts:50-176。核心字段包括:

// types/hooks.ts:50-66
export const syncHookResponseSchema = lazySchema(() =>
  z.object({
    continue: z.boolean().optional(),       // false = 停止执行
    suppressOutput: z.boolean().optional(), // true = 隐藏 stdout
    stopReason: z.string().optional(),      // continue=false 时的消息
    decision: z.enum(['approve', 'block']).optional(),
    reason: z.string().optional(),
    systemMessage: z.string().optional(),   // 显示给用户的警告
    hookSpecificOutput: z.union([/* 按事件类型的专有输出 */]).optional(),
  }),
)

hookSpecificOutput 是一个判别联合(discriminated union),每个事件类型都有自己的专有字段。例如 PermissionRequest 事件(第 121-133 行)支持 allow/deny 决策和权限更新:

// types/hooks.ts:121-133
z.object({
  hookEventName: z.literal('PermissionRequest'),
  decision: z.union([
    z.object({
      behavior: z.literal('allow'),
      updatedInput: z.record(z.string(), z.unknown()).optional(),
      updatedPermissions: z.array(permissionUpdateSchema()).optional(),
    }),
    z.object({
      behavior: z.literal('deny'),
      message: z.string().optional(),
      interrupt: z.boolean().optional(),
    }),
  ]),
})

18.5 信任门控

Hooks 执行的安全门控由 shouldSkipHookDueToTrust 函数(第 286-296 行)实现:

// hooks.ts:286-296
export function shouldSkipHookDueToTrust(): boolean {
  const isInteractive = !getIsNonInteractiveSession()
  if (!isInteractive) {
    return false  // SDK 模式下信任是隐含的
  }
  const hasTrust = checkHasTrustDialogAccepted()
  return !hasTrust
}

规则很简单但至关重要:

  1. 非交互模式(SDK):信任是隐含的,所有 Hook 直接执行
  2. 交互模式所有 Hook 都需要信任对话框确认

代码注释(第 267-285 行)详细解释了"为什么是所有":Hook 配置在 captureHooksConfigSnapshot() 阶段就被捕获,这发生在信任对话框显示之前。虽然大多数 Hook 通过正常程序流不会在信任确认前执行,但历史上存在两个漏洞——SessionEnd Hook 在用户拒绝信任时仍然执行,SubagentStop Hook 在子 Agent 在信任确认前完成时执行。纵深防御原则要求对所有 Hook 统一检查。

executeHooks 函数也在执行前进行集中检查(第 1993-1999 行):

// hooks.ts:1993-1999
if (shouldSkipHookDueToTrust()) {
  logForDebugging(
    `Skipping ${hookName} hook execution - workspace trust not accepted`,
  )
  return
}

此外,disableAllHooks 设置提供了更极端的控制(第 1978-1979 行)——如果在 policySettings 中设置,则禁用所有 Hook 包括 managed Hook;如果在非 managed 设置中设置,则仅禁用非 managed Hook(managed Hook 仍然运行)。


18.6 配置快照追踪

Hook 配置不是每次执行时实时读取,而是通过快照机制管理。hooksConfigSnapshot.ts 定义了这套系统:

快照捕获

captureHooksConfigSnapshot()(第 95-97 行)在应用启动时调用一次:

// hooksConfigSnapshot.ts:95-97
export function captureHooksConfigSnapshot(): void {
  initialHooksConfig = getHooksFromAllowedSources()
}

来源过滤

getHooksFromAllowedSources()(第 18-53 行)实现了多层过滤逻辑:

  1. 如果 policySettings 设置了 disableAllHooks: true,返回空配置
  2. 如果 policySettings 设置了 allowManagedHooksOnly: true,仅返回 managed hooks
  3. 如果启用了 strictPluginOnlyCustomization 策略,阻塞 user/project/local 设置中的 hooks
  4. 如果非 managed 设置中设置了 disableAllHooks,仅 managed hooks 运行
  5. 否则返回所有来源的合并配置

快照更新

当用户通过 /hooks 命令修改 Hook 配置时,updateHooksConfigSnapshot()(第 104-112 行)被调用:

// hooksConfigSnapshot.ts:104-112
export function updateHooksConfigSnapshot(): void {
  resetSettingsCache()  // 确保从磁盘读取最新设置
  initialHooksConfig = getHooksFromAllowedSources()
}

注意 resetSettingsCache() 的调用——没有它,快照可能使用过期的缓存设置。这是因为文件监视器的稳定性阈值可能尚未触发(注释中提到了这一点)。


18.7 匹配与去重

Matcher 模式

每个 Hook 配置可以指定一个 matcher 字段,用于精确筛选触发条件。matchesPattern 函数(第 1346-1381 行)支持三种模式:

  1. 精确匹配Write 仅匹配工具名 Write
  2. 管道分隔Write|Edit 匹配 WriteEdit
  3. 正则表达式^Write.* 匹配所有以 Write 开头的工具名

判断依据是字符串内容:如果仅包含 [a-zA-Z0-9_|],视为简单匹配;否则视为正则。

去重机制

同一命令可能在多个配置源(user/project/local)中定义,去重由 hookDedupKey 函数(第 1453-1455 行)实现:

// hooks.ts:1453-1455
function hookDedupKey(m: MatchedHook, payload: string): string {
  return `${m.pluginRoot ?? m.skillRoot ?? ''}\0${payload}`
}

关键设计:去重键按来源上下文命名空间化——同一个 echo hello 命令在不同插件目录中不会被去重(因为展开 ${CLAUDE_PLUGIN_ROOT} 后指向不同文件),但同一命令在 user/project/local 设置中会被合并为一个。

callbackfunction 类型 Hook 跳过去重——它们每个实例都是唯一的。当所有匹配的 Hook 都是 callback/function 类型时,还有一个快速路径(第 1723-1729 行),完全跳过 6 轮过滤和 Map 构建,微基准测试显示性能提升 44 倍。


18.8 实际配置示例

示例1:PreToolUse 格式检查

在每次 TypeScript 文件写入前自动运行格式检查:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "FILE=$(echo $ARGUMENTS | jq -r '.file_path') && prettier --check \"$CLAUDE_PROJECT_DIR/$FILE\" 2>&1 || echo '{\"decision\":\"block\",\"reason\":\"File does not pass prettier formatting\"}'",
            "if": "Write(*.ts)",
            "statusMessage": "Checking formatting..."
          }
        ]
      }
    ]
  }
}

这个配置展示了几个关键能力:

  • matcher: "Write|Edit" 使用管道分隔匹配两个工具
  • if: "Write(*.ts)" 使用权限规则语法进一步过滤——在此示例中仅对 .ts 文件生效。if 字段支持任意权限规则模式,如 "Bash(git *)" 仅匹配 git 命令、"Edit(src/**)" 仅匹配 src 目录下的编辑、"Read(*.py)" 仅匹配 Python 文件读取
  • $CLAUDE_PROJECT_DIR 环境变量自动设置为项目根目录(第 813-816 行)
  • Hook 输入 JSON 通过 stdin 传入,Hook 可用 $ARGUMENTS 引用或直接从 stdin 读取
  • JSON 输出协议中的 decision: "block" 阻止不合格的写入

示例2:SessionStart 环境初始化 + Stop 自动验证

结合 SessionStart 和 Stop Hook 实现"自动开发环境":

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup",
        "hooks": [
          {
            "type": "command",
            "command": "echo 'export NODE_ENV=development' >> $CLAUDE_ENV_FILE && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"Dev environment configured. Node: '$(node -v)'\"}}'",
            "statusMessage": "Setting up dev environment..."
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "agent",
            "prompt": "Check if there are uncommitted changes. If so, create an appropriate commit message and commit them. Verify the commit was successful.",
            "timeout": 120,
            "model": "claude-sonnet-4-6",
            "statusMessage": "Auto-committing changes..."
          }
        ]
      }
    ]
  }
}

这个示例展示了:

  • SessionStart Hook 使用 CLAUDE_ENV_FILE 注入环境变量到后续 Bash 命令中
  • additionalContext 将信息发送给 Claude 作为上下文
  • Stop Hook 使用 agent 类型启动一个完整的验证 Agent
  • timeout: 120 覆盖默认的 60 秒超时

18.9 Hook 来源层级与合并

getHooksConfig 函数(第 1492-1566 行)负责将来自不同来源的 Hook 配置合并为一个统一列表。来源按优先级从高到低排列:

  1. 配置快照(settings.json 合并结果):通过 getHooksConfigFromSnapshot() 获取
  2. 注册式 Hook(SDK callback + 插件原生 Hook):通过 getRegisteredHooks() 获取
  3. 会话 Hook(Agent frontmatter 注册的 Hook):通过 getSessionHooks() 获取
  4. 会话函数 Hook(结构化输出强制器等):通过 getSessionFunctionHooks() 获取

allowManagedHooksOnly 策略启用时,来源 2-4 中的非 managed Hook 被跳过。这个过滤发生在合并阶段,而非执行阶段——从根本上阻断了非 managed Hook 进入执行管线的可能性。

hasHookForEvent 函数(第 1582-1593 行)是一个轻量级的存在性检查——它不构建完整的合并列表,而是在找到第一个匹配后立即返回。这用于热路径上的短路优化(如 InstructionsLoadedWorktreeCreate 事件),避免在没有任何 Hook 配置时执行不必要的 createBaseHookInputgetMatchingHooks 调用。


18.10 进程管理与 Shell 分支

Hook 的进程 spawn 逻辑(第 940-984 行)根据 Shell 类型分为两条完全独立的路径:

Bash 路径:

// hooks.ts:976-983
const shell = isWindows ? findGitBashPath() : true
child = spawn(finalCommand, [], {
  env: envVars,
  cwd: safeCwd,
  shell,
  windowsHide: true,
})

在 Windows 上使用 Git Bash 而非 cmd.exe——这意味着所有路径都必须是 POSIX 格式。windowsPathToPosixPath() 是纯 JS 正则转换(有 LRU-500 缓存),不需要 shell-out 调用 cygpath。

PowerShell 路径:

// hooks.ts:967-972
child = spawn(pwshPath, buildPowerShellArgs(finalCommand), {
  env: envVars,
  cwd: safeCwd,
  windowsHide: true,
})

使用 -NoProfile -NonInteractive -Command 参数——跳过用户 profile 脚本(更快、更确定),在需要输入时快速失败而非挂起。

一个微妙的安全检查:在 spawn 前验证 getCwd() 返回的目录是否存在(第 931-938 行)。当 Agent 工作树被移除时,AsyncLocalStorage 可能返回已删除的路径,此时回退到 getOriginalCwd()

插件 Hook 的变量替换

当 Hook 来自插件时,命令字符串中的模板变量会在 spawn 前被替换(第 818-857 行):

  • ${CLAUDE_PLUGIN_ROOT}:插件的安装目录
  • ${CLAUDE_PLUGIN_DATA}:插件的持久化数据目录
  • ${user_config.X}:用户通过 /plugin 配置的选项值

替换顺序很重要:插件变量先于用户配置变量——这防止用户配置值中包含 ${CLAUDE_PLUGIN_ROOT} 字面量时被二次解析。如果插件目录不存在(可能因 GC 竞争或并发会话删除),代码会在 spawn 前抛出明确错误(第 831-836 行),而不是让命令在找不到脚本后以退出码 2 退出——后者会被误解为"有意阻塞"。

插件选项还会作为环境变量暴露(第 898-906 行),命名格式为 CLAUDE_PLUGIN_OPTION_<KEY>,KEY 被转为大写并用下划线替换非标识符字符。这允许 Hook 脚本通过环境变量读取配置,而不必在命令字符串中使用 ${user_config.X} 模板。


18.11 案例:用 Hooks 构建 LangSmith 运行时追踪

开源项目 langsmith-claude-code-plugins 提供了一个很有代表性的案例:它没有修改 Claude Code 源码,也没有代理 Anthropic API 请求,却能追踪 turn、工具调用、子 Agent 和压缩事件。这说明 Hooks 系统的价值不只是"在某个事件点执行一段脚本",而是已经足够构成一个外部集成面(integration surface)。

这个插件的关键思路可以概括为一句话:

用 Hooks 采集生命周期信号,用 transcript 作为事实日志,用本地状态机把零散信号重组为一棵完整的 trace 树。

它依赖的不是黑魔法,而是 Claude Code 官方暴露的几项能力:

  1. 插件可以自带 hooks/hooks.json,在多个生命周期事件上挂载命令型 Hook
  2. Hook 通过 stdin 收到结构化 JSON,而不是一段模糊的环境变量
  3. 所有 Hook 输入都自带 session_idtranscript_pathcwd
  4. Stop / SubagentStop 额外带有 last_assistant_messageagent_transcript_path 这类高价值字段
  5. Hook 命令可以使用 ${CLAUDE_PLUGIN_ROOT} 指向插件自身 bundle 目录
  6. async: true 允许插件在后台做网络投递,而不阻塞主交互路径

一个外部插件如何拼出完整 trace

LangSmith 插件注册了 9 个 Hook 事件:

Hook 事件作用
UserPromptSubmit为当前 turn 创建 LangSmith 根 run
PreToolUse记录工具真实开始时间
PostToolUse追踪普通工具;为 Agent 工具预留父 run
Stop增量读取 transcript,重建 turn/llm/tool 层级
StopFailureAPI 错误时关闭悬空 run
SubagentStop记录子 Agent transcript 路径,延后到主 Stop 统一处理
PreCompact记录压缩开始时间
PostCompact追踪压缩事件和 summary
SessionEnd用户退出或 /clear 时收尾,补齐中断 turn

它们之间的协作关系如下:

flowchart TD
    A["UserPromptSubmit<br/>创建 turn 根 run"] --> B["state.json<br/>current_turn_run_id / trace_id / dotted_order"]
    B --> C["PreToolUse<br/>记录 tool_start_times"]
    C --> D["PostToolUse<br/>普通工具直接 trace"]
    C --> E["PostToolUse<br/>Agent 工具仅登记 task_run_map"]
    E --> F["SubagentStop<br/>登记 pending_subagent_traces"]
    D --> G["Stop<br/>增量读取 transcript"]
    F --> G
    B --> G
    G --> H["traceTurn()<br/>重建 Claude / Tool / Claude"]
    G --> I["tracePendingSubagents()<br/>将子 Agent 挂到 Agent tool 下"]
    J["PreCompact"] --> K["PostCompact<br/>记录 compaction run"]
    L["SessionEnd / StopFailure"] --> M["关闭悬空 run / 中断 turn"]

这个流程最值得注意的地方是:没有任何一个 Hook 能独立完成 tracing。真正的设计不是"在 Stop 里读一遍 transcript 就行",而是把多个生命周期事件各自贡献的局部信号拼起来。

核心一:UserPromptSubmit 先建根节点

插件在 UserPromptSubmit 事件发生时,先创建一个 Claude Code Turn 的根 run,并把以下状态写到本地状态文件:

  • current_turn_run_id
  • current_trace_id
  • current_dotted_order
  • current_turn_number
  • last_line

这样后续的 PostToolUseStopPostCompact 都知道应该把自己的 run 挂到哪一个父节点下面。

这是一个很关键的设计选择。很多人直觉上会把 tracing 放在 Stop 里"一次性生成",但那样会失去两个能力:

  1. 无法为正在进行中的 turn提供稳定的父 run 标识
  2. 无法把后续异步事件(如工具执行、压缩)正确地挂到当前 turn 之下

UserPromptSubmit 的意义不是"用户发了消息",而是为本轮交互建立一个全局锚点

核心二:Transcript 是事实日志,Hooks 只是辅助信号

真正的内容重建发生在 Stop Hook。

插件不会依赖 Hook 输入中的单一字段来构造整轮 trace,而是把 transcript_path 当作权威事件日志,增量读取自上次处理之后的新 JSONL 行,然后:

  1. message.id 合并 assistant 的 streaming chunks
  2. tool_use 与后续 tool_result 配对
  3. 把一轮用户输入整理成 Turn
  4. 再把 Turn 转换成 LangSmith 的层级结构: Claude Code Turn -> Claude(llm) -> Tool -> Claude(llm) ...

这套做法背后有一个重要判断:Hooks 提供的是时间点,transcript 提供的是事实顺序。

如果只依赖 Hook:

  • 你能知道"某个工具执行了"
  • 但你未必知道它出现在第几个 LLM call 之后
  • 也难以准确恢复 tool call 前后完整的上下文

如果只依赖 transcript:

  • 你能恢复消息和工具顺序
  • 但你拿不到工具的真实墙钟起止时间
  • 也无法及时感知 compaction、session end、API failure 这类宿主级事件

所以这个插件的真正技巧不是 transcript,也不是 hooks,而是两者的角色分工

  • transcript 负责语义真相
  • hooks 负责运行时元信息

核心三:为什么还要 PreToolUse / PostToolUse

如果 Stop 已经能从 transcript 恢复工具调用,为什么还需要 PreToolUse / PostToolUse

答案是:因为 transcript 更像消息历史,而不是精确的工具计时器

LangSmith 插件用这两个 Hook 做两件事:

  1. PreToolUse 记录 tool_use_id -> start_time
  2. PostToolUse 在工具完成后立刻创建普通工具的 tool run,并把 tool_use_id 记入 traced_tool_use_ids

这样 Stop 在重放 transcript 时就能跳过这些已经追踪过的普通工具,避免重复建 run。同时,last_tool_end_time 还能帮助 Stop 修正 transcript 落盘延迟带来的时间误差。

换句话说:

  • Stop 解决的是语义重建
  • Pre/PostToolUse 解决的是时序精度

这是一个非常典型的宿主扩展模式:语义日志和性能计时来自不同信号源,不能强行合并成一个来源。

核心四:子 Agent 追踪为什么必须分三段

这个插件最精妙的地方,是它如何追踪子 Agent。

Claude Code 官方已经给了它两块关键拼图:

  1. SubagentStop 事件
  2. agent_transcript_path

仅靠这两项还不够。因为插件还需要知道:这个子 Agent 应该挂在哪个 Agent tool run 下面?

于是它采用了三段式设计:

第一段:PostToolUse 处理 Agent 工具

当工具返回里带 agentId 时,插件并不立即创建最终的 Agent tool run,而是把下面的信息登记到 task_run_map

  • run_id
  • dotted_order
  • deferred.start_time
  • deferred.end_time
  • deferred.inputs / outputs

第二段:SubagentStop 只排队,不立即 trace

SubagentStop 拿到 agent_idagent_typeagent_transcript_path 后,只把它追加进 pending_subagent_traces,并不立刻向 LangSmith 发请求。

第三段:主 Stop 统一出清

主线程 Stop 在 turn 完成后再:

  1. 重新读取共享 state
  2. 合并 task_run_map
  3. 取出 pending_subagent_traces
  4. 读取子 Agent transcript
  5. 在 Agent tool run 下创建一个中间的 Subagent chain
  6. 把子 Agent 内部各个 turn 再逐一 trace 进去

之所以要这样分三步,是因为 PostToolUseSubagentStop 都可能是异步 Hook,存在竞态。如果 SubagentStop 一拿到 transcript path 就立刻 trace,就有可能发生:

  • 还没拿到对应的 Agent tool run ID
  • 不知道父 dotted order
  • 最终只能生成一个悬空的 subagent trace

这个案例非常清楚地说明:Claude Code 的 Hook 系统不是线性回调模型,而是并发事件源。外部插件必须自己补一个状态协调层。

核心五:为什么它能跟踪压缩运行

压缩 tracing 并不是插件自己从 transcript 猜出来的,而是直接利用了 PreCompact / PostCompact 两个官方事件。

它的做法很简单但很有效:

  1. PreCompact 把当前时间记为 compaction_start_time
  2. PostCompact 读取 triggercompact_summary
  3. 用这三项信息创建一个 Context Compaction run

这说明 Claude Code 对插件暴露的不只是"工具前后"这类经典 Hook 点,而是连上下文压缩这种Agent 内部自维护行为也暴露成了一等事件。这正是外部 observability 插件能追踪"压缩运行"的原因。

Claude Code 给这个插件开放了哪些真正关键的能力

从源码看,LangSmith 插件真正吃到的 Claude Code "特效"有六个:

宿主能力为什么关键
hooks/hooks.json 插件入口允许插件在宿主生命周期中注册命令型 Hook
结构化 stdin JSONHook 拿到的是字段化输入,不需要自己 parse 日志文本
transcript_path插件能把 transcript 当成 durable event log 来增量读取
last_assistant_messageStop 可修补 transcript 尚未完全落盘的尾部响应
agent_transcript_path + SubagentStop子 Agent tracing 成为可能,而不是只能看见主线程里的 Task 工具
${CLAUDE_PLUGIN_ROOT} + async: true插件能稳定引用自己的 bundle,并把网络投递放到后台执行

这也是为什么它不是一个通用"终端录屏器"。它依赖的是Claude Code 明确设计过的插件宿主接口,而不是碰巧可用的副作用。

边界:它不是 API 级 tracing

虽然这个插件已经能做出相当完整的运行时追踪,但它的边界也很明确:

  1. 它追的是 Claude Code 运行时,不是底层 API 原始请求。 它看到的是 transcript 与 hook 输入重建出来的结构,而不是 Anthropic API 的每一个原始字段。

  2. 子 Agent 目前只能在完成后追踪。 这不是插件作者偷懒,而是由信号面决定的:只有在 SubagentStop 发生时,插件才拿到完整的 agent_transcript_path。如果用户在子 Agent 运行中途打断,README 也明确承认这类 subagent run 不会被追到。

  3. 压缩事件只看到 summary,不看到压缩内部所有中间状态。 PostCompact 暴露的是 trigger + compact_summary,足以用于 observability,但不是完整的 compaction 调试转储。

这对 Agent 构建者意味着什么

这个案例最值得学习的不是"如何对接 LangSmith",而是它揭示了一个更一般的架构原则:

当宿主已经提供生命周期 Hook 和持久化 transcript 时,外部插件完全可以在不 patch 主系统的情况下,重建出高质量的运行时观测。

这背后有三个可复用的经验:

  1. 先找宿主暴露的结构化事件面,而不是先想抓包。
  2. 把 transcript 当作事实日志,把 Hook 当作元事件补丁。
  3. 为并发 Hook 设计一个本地状态机,负责去重、配对和延迟出清。

如果你想为自己的 Agent 系统提供外部可观测性,这个案例几乎可以当作一个模板:不要急着开放整个内部状态机,只要开放少量关键 Hook 字段和一份 durable transcript,第三方就能构建出相当强的集成。


版本演化:v2.1.92 — Stop Hook 动态管理

以下分析基于 v2.1.92 bundle 字符串信号推断,无完整源码佐证。

v2.1.92 新增了三个事件:tengu_stop_hook_addedtengu_stop_hook_commandtengu_stop_hook_removed。这揭示了一个重要的架构演进:Hook 配置从纯静态走向运行时可管理

从静态到动态

在 v2.1.88 中(也是本章前面所有分析的基础),Hook 配置完全是静态的。你在 settings.json.claude/settings.jsonplugin.json 中定义 Hook,启动会话时加载,会话期间不可变。想改 Hook?编辑配置文件,重启会话。

v2.1.92 打破了这个限制——至少对 Stop Hook 是如此。三个新事件对应了完整的 CRUD 生命周期中的三个操作:

  • stop_hook_added:运行时添加一个 Stop Hook
  • stop_hook_command:Stop Hook 被执行
  • stop_hook_removed:运行时移除一个 Stop Hook

这意味着用户可以在会话中途说"从现在开始,每次停止后帮我运行测试",Agent 调用某个命令注册一个 Stop Hook,之后每次 Agent Loop 停止时这个 Hook 都会触发——不需要退出会话、编辑配置、再重新进入。

为什么 Stop Hook 最先获得动态管理

这个选择不是偶然的。Stop Hook 有三个特性使它最适合动态管理:

  1. 任务相关性强:Stop Hook 的典型用途是"Agent 完成一轮后做什么"——运行测试、自动提交、格式化代码、发送通知。这些需求随任务变化:写代码时想自动运行 cargo check,写文档时不需要。

  2. 安全风险低:Stop Hook 在 Agent 停止后触发,不影响 Agent 的决策过程。相比之下,PreToolUse Hook 可以阻止工具执行(详见 18.3 节),动态修改它会引入安全风险——攻击者可能通过提示注入让 Agent 移除安全检查 Hook。

  3. 用户意图明确:添加和移除 Stop Hook 是用户的显式行为,不是 Agent 自主决策。事件名中的 addedremoved(而非 auto_added)暗示这是用户驱动的操作。

设计哲学:Hook 管理的渐进开放

将这个变化放在 Hook 系统的整体架构中看,v2.1.88 的 Hook 有四种来源(详见 18.6 节):命令型(settings.json)、SDK 回调、注册式(getRegisteredHooks)、插件原生(plugin hooks.json)。这四种都是静态配置。

v2.1.92 的动态 Stop Hook 可以被视为第五种来源——运行时用户命令。它与"渐进式自主"(详见第 27 章)的理念一致:用户在会话中逐步调整 Agent 的行为,而不是在会话开始前就必须完整规划所有配置。

可以预见,如果 Stop Hook 的动态管理被验证成功,未来可能会扩展到 PostToolUse Hook("这次任务中,每次写文件后帮我运行 lint")——但 PreToolUse Hook 的动态管理应该会更慎重,因为它直接影响安全策略。


模式提炼

模式一:退出码即协议(Exit Code as Protocol)

解决的问题:Shell 命令与宿主进程之间需要一种轻量级的语义通信机制。

代码模板:定义明确的退出码语义——0 表示成功/允许,2 表示阻塞错误(stderr 发送给模型),其他值表示非阻塞错误(仅显示给用户)。不同事件类型可以对相同退出码赋予不同语义(如 Stop 事件的退出码 2 表示"继续对话")。

前置条件:Hook 开发者需要文档化的退出码契约。

模式二:配置快照隔离(Config Snapshot Isolation)

解决的问题:配置文件可能在运行时被修改,导致前后不一致的行为。

代码模板:在启动时捕获配置快照(captureHooksConfigSnapshot),运行时使用快照而非实时读取。仅在用户显式修改时更新快照(updateHooksConfigSnapshot),更新前重置设置缓存确保读取最新值。

前置条件:配置变更频率低于执行频率。

模式三:命名空间化去重(Namespaced Deduplication)

解决的问题:同一 Hook 命令可能出现在多个配置源中,需要去重但不能跨上下文合并。

代码模板:去重键包含来源上下文(如插件目录路径),同一命令在不同插件中保持独立,在同一来源的 user/project/local 层级中合并。

前置条件:Hook 有明确的来源标识。

模式四:宿主信号重组(Host Signal Reconstruction)

解决的问题:外部插件想构建高质量 tracing,但宿主暴露的是分散的生命周期事件,而不是一棵现成的 trace 树。

代码模板:用 Hook 采集元事件(开始时间、结束时间、子任务路径、压缩 summary),用 transcript 作为事实日志重放语义顺序,再通过本地状态文件维护游标、父子映射和待处理队列,最终在外部系统中重建完整层级。

前置条件:宿主至少暴露结构化 Hook 输入和可增量读取的 transcript。


小结

Hooks 系统的设计体现了几个工程权衡:

  1. 灵活性 vs 安全性:通过信任门控和退出码语义,在"允许任意命令执行"和"防止恶意利用"之间取得平衡
  2. 同步 vs 异步:异步生成器 + 后台 Hook + asyncRewake 三级策略,让用户选择阻塞程度
  3. 简单 vs 强大:从简单的 Shell 命令到完整的 Agent 验证器,四种类型覆盖不同复杂度需求
  4. 隔离 vs 共享:配置快照机制 + 命名空间化去重键,确保多来源配置不互相干扰
  5. 宿主接口 vs 深度侵入:只要 Hook 面和 transcript 设计得足够好,外部插件就能实现强可观测性,而不必 patch 主系统

下一章我们将看到另一种用户自定义机制——CLAUDE.md 指令系统,它不是通过代码执行来影响行为,而是通过自然语言指令直接控制模型的输出。