第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.ts 的 getHookEventMetadata 函数中(第 28-264 行)。按生命周期阶段可分为五组:
工具执行生命周期
| 事件 | 触发时机 | matcher 字段 | 退出码 2 的行为 |
|---|---|---|---|
PreToolUse | 工具执行前 | tool_name | 阻塞工具调用,stderr 发送给模型 |
PostToolUse | 工具执行成功后 | tool_name | stderr 立即发送给模型 |
PostToolUseFailure | 工具执行失败后 | tool_name | stderr 立即发送给模型 |
PermissionRequest | 权限对话框显示时 | tool_name | 使用 Hook 决策 |
PermissionDenied | auto 模式分类器拒绝工具调用后 | 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 |
Stop | Claude 即将结束响应前 | — | 退出码 2 让对话继续 |
StopFailure | API 错误导致回合结束时 | 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/...) |
InstructionsLoaded | CLAUDE.md 或规则文件加载时 | load_reason (session_start/path_glob_match/...) |
压缩、MCP 交互与 Worktree
| 事件 | 触发时机 | matcher 字段 |
|---|---|---|
PreCompact | 对话压缩前 | trigger (manual/auto) |
PostCompact | 对话压缩后 | trigger (manual/auto) |
Elicitation | MCP 服务器请求用户输入时 | mcp_server_name |
ElicitationResult | 用户响应 MCP elicitation 后 | mcp_server_name |
WorktreeCreate | 创建隔离工作树时 | — |
WorktreeRemove | 移除工作树时 | — |
18.2 四种 Hook 类型
Hooks 系统支持四种可持久化的 Hook 类型,加上两种运行时注册的内部类型。所有可持久化类型的 schema 定义在 schemas/hooks.ts 的 buildHookSchemas 函数中(第 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 不支持 SessionStart 和 Setup 事件(第 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 可以通过两种方式进入后台执行:
- 配置声明:设置
async: true或asyncRewake: true(第 995-1029 行) - 运行时声明: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
}
规则很简单但至关重要:
- 非交互模式(SDK):信任是隐含的,所有 Hook 直接执行
- 交互模式:所有 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 行)实现了多层过滤逻辑:
- 如果 policySettings 设置了
disableAllHooks: true,返回空配置 - 如果 policySettings 设置了
allowManagedHooksOnly: true,仅返回 managed hooks - 如果启用了
strictPluginOnlyCustomization策略,阻塞 user/project/local 设置中的 hooks - 如果非 managed 设置中设置了
disableAllHooks,仅 managed hooks 运行 - 否则返回所有来源的合并配置
快照更新
当用户通过 /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 行)支持三种模式:
- 精确匹配:
Write仅匹配工具名Write - 管道分隔:
Write|Edit匹配Write或Edit - 正则表达式:
^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 设置中会被合并为一个。
callback 和 function 类型 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 配置合并为一个统一列表。来源按优先级从高到低排列:
- 配置快照(settings.json 合并结果):通过
getHooksConfigFromSnapshot()获取 - 注册式 Hook(SDK callback + 插件原生 Hook):通过
getRegisteredHooks()获取 - 会话 Hook(Agent frontmatter 注册的 Hook):通过
getSessionHooks()获取 - 会话函数 Hook(结构化输出强制器等):通过
getSessionFunctionHooks()获取
当 allowManagedHooksOnly 策略启用时,来源 2-4 中的非 managed Hook 被跳过。这个过滤发生在合并阶段,而非执行阶段——从根本上阻断了非 managed Hook 进入执行管线的可能性。
hasHookForEvent 函数(第 1582-1593 行)是一个轻量级的存在性检查——它不构建完整的合并列表,而是在找到第一个匹配后立即返回。这用于热路径上的短路优化(如 InstructionsLoaded 和 WorktreeCreate 事件),避免在没有任何 Hook 配置时执行不必要的 createBaseHookInput 和 getMatchingHooks 调用。
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 官方暴露的几项能力:
- 插件可以自带
hooks/hooks.json,在多个生命周期事件上挂载命令型 Hook - Hook 通过 stdin 收到结构化 JSON,而不是一段模糊的环境变量
- 所有 Hook 输入都自带
session_id、transcript_path、cwd Stop/SubagentStop额外带有last_assistant_message、agent_transcript_path这类高价值字段- Hook 命令可以使用
${CLAUDE_PLUGIN_ROOT}指向插件自身 bundle 目录 async: true允许插件在后台做网络投递,而不阻塞主交互路径
一个外部插件如何拼出完整 trace
LangSmith 插件注册了 9 个 Hook 事件:
| Hook 事件 | 作用 |
|---|---|
UserPromptSubmit | 为当前 turn 创建 LangSmith 根 run |
PreToolUse | 记录工具真实开始时间 |
PostToolUse | 追踪普通工具;为 Agent 工具预留父 run |
Stop | 增量读取 transcript,重建 turn/llm/tool 层级 |
StopFailure | API 错误时关闭悬空 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_idcurrent_trace_idcurrent_dotted_ordercurrent_turn_numberlast_line
这样后续的 PostToolUse、Stop、PostCompact 都知道应该把自己的 run 挂到哪一个父节点下面。
这是一个很关键的设计选择。很多人直觉上会把 tracing 放在 Stop 里"一次性生成",但那样会失去两个能力:
- 无法为正在进行中的 turn提供稳定的父 run 标识
- 无法把后续异步事件(如工具执行、压缩)正确地挂到当前 turn 之下
UserPromptSubmit 的意义不是"用户发了消息",而是为本轮交互建立一个全局锚点。
核心二:Transcript 是事实日志,Hooks 只是辅助信号
真正的内容重建发生在 Stop Hook。
插件不会依赖 Hook 输入中的单一字段来构造整轮 trace,而是把 transcript_path 当作权威事件日志,增量读取自上次处理之后的新 JSONL 行,然后:
- 按
message.id合并 assistant 的 streaming chunks - 将
tool_use与后续tool_result配对 - 把一轮用户输入整理成
Turn - 再把
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 做两件事:
PreToolUse记录tool_use_id -> start_timePostToolUse在工具完成后立刻创建普通工具的 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 官方已经给了它两块关键拼图:
SubagentStop事件agent_transcript_path
仅靠这两项还不够。因为插件还需要知道:这个子 Agent 应该挂在哪个 Agent tool run 下面?
于是它采用了三段式设计:
第一段:PostToolUse 处理 Agent 工具
当工具返回里带 agentId 时,插件并不立即创建最终的 Agent tool run,而是把下面的信息登记到 task_run_map:
run_iddotted_orderdeferred.start_timedeferred.end_timedeferred.inputs / outputs
第二段:SubagentStop 只排队,不立即 trace
SubagentStop 拿到 agent_id、agent_type、agent_transcript_path 后,只把它追加进 pending_subagent_traces,并不立刻向 LangSmith 发请求。
第三段:主 Stop 统一出清
主线程 Stop 在 turn 完成后再:
- 重新读取共享 state
- 合并
task_run_map - 取出
pending_subagent_traces - 读取子 Agent transcript
- 在 Agent tool run 下创建一个中间的
Subagentchain - 把子 Agent 内部各个 turn 再逐一 trace 进去
之所以要这样分三步,是因为 PostToolUse 和 SubagentStop 都可能是异步 Hook,存在竞态。如果 SubagentStop 一拿到 transcript path 就立刻 trace,就有可能发生:
- 还没拿到对应的 Agent tool run ID
- 不知道父 dotted order
- 最终只能生成一个悬空的 subagent trace
这个案例非常清楚地说明:Claude Code 的 Hook 系统不是线性回调模型,而是并发事件源。外部插件必须自己补一个状态协调层。
核心五:为什么它能跟踪压缩运行
压缩 tracing 并不是插件自己从 transcript 猜出来的,而是直接利用了 PreCompact / PostCompact 两个官方事件。
它的做法很简单但很有效:
PreCompact把当前时间记为compaction_start_timePostCompact读取trigger和compact_summary- 用这三项信息创建一个
Context Compactionrun
这说明 Claude Code 对插件暴露的不只是"工具前后"这类经典 Hook 点,而是连上下文压缩这种Agent 内部自维护行为也暴露成了一等事件。这正是外部 observability 插件能追踪"压缩运行"的原因。
Claude Code 给这个插件开放了哪些真正关键的能力
从源码看,LangSmith 插件真正吃到的 Claude Code "特效"有六个:
| 宿主能力 | 为什么关键 |
|---|---|
hooks/hooks.json 插件入口 | 允许插件在宿主生命周期中注册命令型 Hook |
| 结构化 stdin JSON | Hook 拿到的是字段化输入,不需要自己 parse 日志文本 |
transcript_path | 插件能把 transcript 当成 durable event log 来增量读取 |
last_assistant_message | Stop 可修补 transcript 尚未完全落盘的尾部响应 |
agent_transcript_path + SubagentStop | 子 Agent tracing 成为可能,而不是只能看见主线程里的 Task 工具 |
${CLAUDE_PLUGIN_ROOT} + async: true | 插件能稳定引用自己的 bundle,并把网络投递放到后台执行 |
这也是为什么它不是一个通用"终端录屏器"。它依赖的是Claude Code 明确设计过的插件宿主接口,而不是碰巧可用的副作用。
边界:它不是 API 级 tracing
虽然这个插件已经能做出相当完整的运行时追踪,但它的边界也很明确:
-
它追的是 Claude Code 运行时,不是底层 API 原始请求。 它看到的是 transcript 与 hook 输入重建出来的结构,而不是 Anthropic API 的每一个原始字段。
-
子 Agent 目前只能在完成后追踪。 这不是插件作者偷懒,而是由信号面决定的:只有在
SubagentStop发生时,插件才拿到完整的agent_transcript_path。如果用户在子 Agent 运行中途打断,README 也明确承认这类 subagent run 不会被追到。 -
压缩事件只看到 summary,不看到压缩内部所有中间状态。
PostCompact暴露的是trigger + compact_summary,足以用于 observability,但不是完整的 compaction 调试转储。
这对 Agent 构建者意味着什么
这个案例最值得学习的不是"如何对接 LangSmith",而是它揭示了一个更一般的架构原则:
当宿主已经提供生命周期 Hook 和持久化 transcript 时,外部插件完全可以在不 patch 主系统的情况下,重建出高质量的运行时观测。
这背后有三个可复用的经验:
- 先找宿主暴露的结构化事件面,而不是先想抓包。
- 把 transcript 当作事实日志,把 Hook 当作元事件补丁。
- 为并发 Hook 设计一个本地状态机,负责去重、配对和延迟出清。
如果你想为自己的 Agent 系统提供外部可观测性,这个案例几乎可以当作一个模板:不要急着开放整个内部状态机,只要开放少量关键 Hook 字段和一份 durable transcript,第三方就能构建出相当强的集成。
版本演化:v2.1.92 — Stop Hook 动态管理
以下分析基于 v2.1.92 bundle 字符串信号推断,无完整源码佐证。
v2.1.92 新增了三个事件:tengu_stop_hook_added、tengu_stop_hook_command、tengu_stop_hook_removed。这揭示了一个重要的架构演进:Hook 配置从纯静态走向运行时可管理。
从静态到动态
在 v2.1.88 中(也是本章前面所有分析的基础),Hook 配置完全是静态的。你在 settings.json、.claude/settings.json 或 plugin.json 中定义 Hook,启动会话时加载,会话期间不可变。想改 Hook?编辑配置文件,重启会话。
v2.1.92 打破了这个限制——至少对 Stop Hook 是如此。三个新事件对应了完整的 CRUD 生命周期中的三个操作:
stop_hook_added:运行时添加一个 Stop Hookstop_hook_command:Stop Hook 被执行stop_hook_removed:运行时移除一个 Stop Hook
这意味着用户可以在会话中途说"从现在开始,每次停止后帮我运行测试",Agent 调用某个命令注册一个 Stop Hook,之后每次 Agent Loop 停止时这个 Hook 都会触发——不需要退出会话、编辑配置、再重新进入。
为什么 Stop Hook 最先获得动态管理
这个选择不是偶然的。Stop Hook 有三个特性使它最适合动态管理:
-
任务相关性强:Stop Hook 的典型用途是"Agent 完成一轮后做什么"——运行测试、自动提交、格式化代码、发送通知。这些需求随任务变化:写代码时想自动运行
cargo check,写文档时不需要。 -
安全风险低:Stop Hook 在 Agent 停止后触发,不影响 Agent 的决策过程。相比之下,PreToolUse Hook 可以阻止工具执行(详见 18.3 节),动态修改它会引入安全风险——攻击者可能通过提示注入让 Agent 移除安全检查 Hook。
-
用户意图明确:添加和移除 Stop Hook 是用户的显式行为,不是 Agent 自主决策。事件名中的
added和removed(而非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 系统的设计体现了几个工程权衡:
- 灵活性 vs 安全性:通过信任门控和退出码语义,在"允许任意命令执行"和"防止恶意利用"之间取得平衡
- 同步 vs 异步:异步生成器 + 后台 Hook + asyncRewake 三级策略,让用户选择阻塞程度
- 简单 vs 强大:从简单的 Shell 命令到完整的 Agent 验证器,四种类型覆盖不同复杂度需求
- 隔离 vs 共享:配置快照机制 + 命名空间化去重键,确保多来源配置不互相干扰
- 宿主接口 vs 深度侵入:只要 Hook 面和 transcript 设计得足够好,外部插件就能实现强可观测性,而不必 patch 主系统
下一章我们将看到另一种用户自定义机制——CLAUDE.md 指令系统,它不是通过代码执行来影响行为,而是通过自然语言指令直接控制模型的输出。