Hook 执行引擎与错误处理深度解读
执行引擎概述
Claude Code 的 Hook 执行核心位于 source/src/utils/hooks.ts,总计超过 5000 行代码。本文聚焦于 execCommandHook() 函数(约 300 行),这是所有 command 类型 Hook 的执行引擎。
执行流程的完整路径:
getMatchingHooks()
→ execHookWithRetry()
→ execCommandHook() ← 本文重点
→ spawn(shell, command, {}) ← Node.js 子进程
→ stdin.write(jsonInput) ← 传入数据
→ 收集 stdout/stderr ← 读取输出
→ 检测 async 协议 ← 可能转后台
→ 处理 exit code ← 决定后续行为
Shell 子进程的创建
execCommandHook() 使用 Node.js 的 child_process.spawn() 来创建子进程:
// source/src/utils/hooks.ts 第 747 行起
async function execCommandHook(
hook: HookCommand & { type: 'command' },
hookEvent: HookEvent,
hookName: string,
jsonInput: string, // ← Hook 输入的 JSON 字符串
signal: AbortSignal,
...
) {
const isWindows = getPlatform() === 'windows'
const shellType = hook.shell ?? DEFAULT_HOOK_SHELL // 'bash' 或 'powershell'
const isPowerShell = shellType === 'powershell'
// Bash 路径:shell: true 让 Node 把整个字符串交给 shell 解析
child = spawn(finalCommand, [], {
env: envVars,
cwd: safeCwd,
shell: isWindows ? findGitBashPath() : true,
windowsHide: true, // Windows 上不显示控制台窗口
})
// PowerShell 路径:明确的参数列表,不使用 shell 选项
child = spawn(pwshPath, ['-NoProfile', '-NonInteractive', '-Command', cmd], {
env: envVars,
cwd: safeCwd,
windowsHide: true,
})
}
两条不同路径的设计原因:
- Bash 路径:利用 shell 的解析能力(管道、重定向、变量展开)
- PowerShell 路径:
-NoProfile -NonInteractive保证确定性,跳过用户配置脚本
Windows 特殊处理
在 Windows 上,Bash Hook 通过 Git Bash(Cygwin)执行,所有路径需要转换为 POSIX 格式(C:\Users\foo → /c/Users/foo),否则 Git Bash 无法解析:
const toHookPath =
isWindows && !isPowerShell
? (p: string) => windowsPathToPosixPath(p) // 转换为 /c/... 格式
: (p: string) => p // Unix/PowerShell 保持原样
JSON 数据的 stdin 传递
Hook 的输入数据通过 stdin 以 JSON 字符串形式传递:
// 在子进程启动后,立即写入 stdin
child.stdin.write(jsonInput + '\n', 'utf8')
child.stdin.end() // 关闭 stdin,发送 EOF 信号
这意味着在 Hook 脚本中,可以通过以下方式读取输入:
#!/bin/bash
# 读取整个 stdin 为变量
INPUT=$(cat)
# 用 jq 提取字段
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
echo "工具: $TOOL_NAME, 命令: $COMMAND"
#!/usr/bin/env python3
import json
import sys
# 读取 stdin
hook_input = json.loads(sys.stdin.read())
tool_name = hook_input.get('tool_name')
print(f"工具: {tool_name}")
注意末尾的 \n: 这个换行符非常重要。如果没有它,bash 的 read -r line 命令会在 EOF 时返回 exit 1(尽管变量是有值的),导致条件判断失败。这是源码中明确注释的行为:
// 注意:末尾的换行符与同步路径保持一致。
// 如果没有它,bash `read -r line` 会在 EOF 前返回 exit 1。
child.stdin.write(jsonInput + '\n', 'utf8')
从 stdout 读取修改后的数据
Hook 脚本可以通过 stdout 输出数据,系统会读取并解析:
child.stdout.on('data', data => {
stdout += data
output += data
// 检测 stdout 的第一行是否是 async 响应
if (!initialResponseChecked) {
const firstLine = firstLineOf(stdout).trim()
if (!firstLine.includes('}')) return // 等待完整的 JSON 行
initialResponseChecked = true
const parsed = jsonParse(firstLine)
if (isAsyncHookJSONOutput(parsed) && !forceSyncExecution) {
// 转入后台异步执行
executeInBackground({ ... })
}
}
})
child.stderr.on('data', data => {
stderr += data
output += data
})
async 协议:后台异步执行
这是 Hook 系统最精妙的设计之一。Hook 进程可以在启动后立即声明自己是异步的:
#!/bin/bash
# 声明异步执行
echo '{"async": true}'
# 之后是长时间运行的逻辑(不会阻塞 Claude)
sleep 30
curl -s "$WEBHOOK_URL" --data "$(cat)"
系统对 stdout 第一行的特殊处理逻辑:
- 读取第一行,检查是否是有效的 JSON
- 如果是
{"async": true},立即将进程转入后台(通过executeInBackground()) - 主流程立即返回
status: 0,Claude 继续工作 - 后台进程完成后,通过
AsyncHookRegistry处理结果
async 的两种声明方式:
// 方式 1:通过配置文件声明(推荐)
{
"type": "command",
"command": "./long-running-hook.sh",
"async": true
}
// 方式 2:运行时通过 stdout 第一行声明(兼容旧版本)
// 脚本第一行输出:{"async": true}
asyncRewake 模式: 这是 async 的增强版。后台进程以 exit code 2 退出时,会将消息通过 enqueuePendingNotification() 注入到下一次模型交互中,实现"后台任务失败后唤醒 Claude"的能力。
超时机制
每个 Hook 都有超时保护,默认值为 10 分钟:
const TOOL_HOOK_EXECUTION_TIMEOUT_MS = 10 * 60 * 1000 // 10 分钟
const hookTimeoutMs = hook.timeout
? hook.timeout * 1000 // hook.timeout 是秒,乘以 1000 转毫秒
: TOOL_HOOK_EXECUTION_TIMEOUT_MS
SessionEnd Hook 的特殊超时: 会话结束时的 Hook 需要更紧的时间限制(默认 1500ms),因为会话关闭不应该被 Hook 无限期阻塞:
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
}
可以通过环境变量 CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS 覆盖此值,适合需要较长清理时间的场景。
超时通过 AbortSignal 实现,wrapSpawn() 在超时后强制杀死子进程:
const shellCommand = wrapSpawn(child, signal, hookTimeoutMs, hookTaskOutput)
环境变量注入
Hook 进程会继承主进程的环境变量(通过 subprocessEnv()),并额外注入 Claude Code 特有的变量:
const envVars: NodeJS.ProcessEnv = {
...subprocessEnv(), // 继承父进程环境
CLAUDE_PROJECT_DIR: toHookPath(projectDir), // 项目根目录
// 插件相关(仅插件 Hook)
CLAUDE_PLUGIN_ROOT: toHookPath(pluginRoot),
CLAUDE_PLUGIN_DATA: toHookPath(getPluginDataDir(pluginId)),
// 插件用户配置(作为 CLAUDE_PLUGIN_OPTION_XXX 形式)
CLAUDE_PLUGIN_OPTION_API_KEY: pluginOpts.api_key,
// 会话环境文件(仅特定事件)
CLAUDE_ENV_FILE: await getHookEnvFilePath(hookEvent, hookIndex),
}
CLAUDE_ENV_FILE 是一个特别有用的机制:对于 SessionStart、Setup、CwdChanged、FileChanged 事件,系统设置此环境变量指向一个 .sh 文件。Hook 脚本向该文件写入 export KEY=VALUE 语句,这些环境变量会被注入到后续所有 Bash 工具命令中:
#!/bin/bash
# SessionStart Hook:设置项目环境
INPUT=$(cat)
ENV_FILE="$CLAUDE_ENV_FILE"
# 写入环境变量,后续的 BashTool 调用都会看到这些变量
echo "export DOCKER_REGISTRY=registry.company.com" >> "$ENV_FILE"
echo "export KUBECONFIG=/home/$USER/.kube/production.yaml" >> "$ENV_FILE"
错误处理与错误隔离
exit code 的完整语义
// source/src/utils/hooks.ts 中处理 exit code 的逻辑
if (exitCode === 2) {
// 阻断执行,将 stderr 展示给 Claude(或操作拦截者)
result.blockingError = {
blockingError: stderr || stdout,
command: hook.command,
}
result.outcome = 'blocking'
} else if (exitCode !== 0) {
// 非阻断错误,仅将 stderr 展示给用户,不影响 Claude 的决策
result.outcome = 'non_blocking_error'
// stderr 会在 UI 中显示,但不注入到 Claude 的上下文
} else {
// 成功
result.outcome = 'success'
}
错误隔离原则
Claude Code 对 Hook 错误采取"隔离但不崩溃"的原则:
- 超时:进程被强制终止,产生
non_blocking_error(不是blocking) - 进程崩溃(exit 1):被视为非阻断错误,Claude 继续工作
- 插件目录消失:在 spawn 前检查,抛出异常,被捕获为非阻断错误
- JSON 解析失败:stdout 被当作纯文本处理,而不是终止执行
// 防止"插件被删除但 Hook 仍在运行"的场景
if (!(await pathExists(pluginRoot))) {
throw new Error(
`Plugin directory does not exist: ${pluginRoot}` +
(pluginId ? ` (${pluginId} — run /plugin to reinstall)` : '')
)
}
// 这里的 throw 会被上层 catch,转化为 non_blocking_error
为什么 exit 1 不是 blocking? 这是一个深思熟虑的决策。如果 Hook 脚本因为环境问题(如 Python 未安装、依赖缺失)意外崩溃,不应该阻止 Claude 继续工作。只有开发者明确使用 exit 2 时,才表达"我有意阻止这个操作"。
Prompt 请求协议
在 command Hook 中,还有一个更高级的双向通信协议:prompt 请求(elicitation)。Hook 脚本可以在执行过程中向用户请求额外的输入:
#!/bin/bash
INPUT=$(cat)
# 向用户请求确认
echo '{"prompt": "req-1", "message": "是否继续?", "options": [{"key": "y", "label": "是"}, {"key": "n", "label": "否"}]}'
# 等待用户响应(通过 stdin 收到响应)
read -r RESPONSE
SELECTED=$(echo "$RESPONSE" | jq -r '.selected')
if [ "$SELECTED" = "n" ]; then
echo "用户取消了操作" >&2
exit 2
fi
系统通过解析 stdout 中的每一行 JSON,检测是否是 PromptRequest 格式,如果是则调用 UI 弹窗请求用户输入,并将响应写回 stdin:
// source/src/utils/hooks.ts
if (requestPrompt) {
const validation = promptRequestSchema().safeParse(parsed)
if (validation.success) {
// 发现 prompt 请求,弹出 UI 对话框
const response = await requestPrompt(validation.data)
// 将用户响应写回 stdin
child.stdin.write(jsonStringify(response) + '\n', 'utf8')
}
}
Hook 执行的并发模型
默认情况下,同一事件的多个 Hook 串行执行。但通过 async: true 或 asyncRewake: true,单个 Hook 可以转入后台,不阻塞后续 Hook 的执行。
AsyncHookRegistry 管理所有正在后台运行的异步 Hook:
// source/src/utils/hooks/AsyncHookRegistry.ts
export function registerPendingAsyncHook({
processId,
hookId,
asyncResponse,
hookEvent,
...
}) {
pendingHooks.set(processId, {
processId,
hookId,
startTime: Date.now(),
shellCommand,
...
})
}
小结
Claude Code 的 Hook 执行引擎是一个工程质量相当高的子进程管理系统:
- 精确的 stdin/stdout 协议:JSON 输入传入,JSON 输出(可选)读取
- 优雅的 async 协议:通过第一行 JSON 实现非阻塞后台执行
- 分层的错误语义:exit 0/2/其他三种明确的行为模式
- 安全的超时保护:默认 10 分钟,SessionEnd 特殊 1.5 秒
- 跨平台兼容:Bash/PowerShell 双路径,Windows POSIX 路径转换