Hook 系统 — Extensibility 基础设施
Hook 让用户无需改源码就能在 agent 生命周期的 14 个关键点注入自定义逻辑。 这是把"产品的上限"交给用户创造力的核心机制。本文把 14 种事件、输入输出协议、阻断语义全部讲透。
01引言:Hook 是 agent 扩展性基石
没有 hook,用户扩展 agent 只能 fork 源码或写 wrapper;有 hook,写几行 shell 脚本就能植入业务逻辑。
1.1 典型用例
| 场景 | Hook 事件 | 脚本做什么 |
|---|---|---|
| 提交前必须 typecheck | PreToolUse (Edit/Write) | 跑 tsc --noEmit,失败则 exit 2 |
| 记录所有 bash 命令 | PreToolUse (Bash) | 追加到审计日志 |
| 格式化刚编辑的文件 | PostToolUse (Edit) | 调 prettier |
| agent 完成发 Slack 通知 | Stop | curl Slack webhook |
| session 启动注入环境信息 | SessionStart | stdout JSON additionalContexts |
| 任务创建时登记 Jira | TaskCreated | POST Jira API |
1.2 跟 middleware 的区别
0214 种 Hook 事件清单
Claude Code 在以下 14 个关键点触发 hook。
| # | 事件 | 触发点 | 能阻断? |
|---|---|---|---|
| 1 | SessionStart | 会话启动 | 否 |
| 2 | SessionEnd | 会话结束 | 否 |
| 3 | UserPromptSubmit | 用户发送消息前 | 是 |
| 4 | PreToolUse | 工具执行前 | 是 |
| 5 | PostToolUseFailure | 工具失败后 | 否 |
| 6 | Stop | 主 agent 准备结束 turn | 是 |
| 7 | StopFailure | Stop hook 自己失败时 | 否 |
| 8 | SubagentStart | 子 agent 启动前 | 否 |
| 9 | SubagentStop | 子 agent 结束前 | 是 |
| 10 | TaskCreated | TaskCreate 写完文件后 | 是(回滚) |
| 11 | TaskCompleted | TaskUpdate 标 completed 前 | 是 |
| 12 | PreCompact | autocompact 开始前 | 是 |
| 13 | Notification | 客户端推送通知 | 否 |
| 14 | PostSampling | 模型采样完成后 | 否 |
2.1 阻断 vs 非阻断的区别
阻断事件意味着 hook 可以返回 exit 2 让对应操作不发生。 非阻断只是"发生时通知你,但改变不了什么"。
03Hook 在 agent 生命周期的位置
04配置语法
4.1 标准格式
// settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write", // 工具名正则,可省略(匹配所有)
"command": "./scripts/pre-edit.sh", // 可执行脚本
"timeout": 30000, // 可选超时(默认 60s)
"env": { "HOOK_DEBUG": "1" } // 可选额外 env
}
],
"Stop": [
{ "command": "curl https://slack.webhook.url -d 'done'" }
]
}
}
4.2 matcher 语义
- PreToolUse / PostToolUseFailure:matcher 是工具名,支持正则或 glob
- Notification:matcher 是 notification type
- 其他事件:matcher 通常不用,留空即对所有触发
4.3 多个 hook 串行执行
{
"PreToolUse": [
{ "matcher": "Bash", "command": "./audit.sh" },
{ "matcher": "Bash", "command": "./security-check.sh" }
]
}
// Bash 调用会串行跑 audit.sh → security-check.sh
// 任一返回 exit 2 → 整个工具调用被阻断
05输入协议(stdin JSON)
Hook 从 stdin 读取一个 JSON 对象,字段取决于事件类型。
5.1 通用字段(所有事件都有)
{
"hook_event_name": "PreToolUse",
"session_id": "abc123...",
"cwd": "/Users/me/project",
"transcript_path": "~/.claude/projects/.../abc123.jsonl",
"permission_mode": "default"
}
5.2 PreToolUse 额外字段
{
"hook_event_name": "PreToolUse",
"tool_name": "Edit",
"tool_input": {
"file_path": "/Users/me/project/src/foo.ts",
"old_string": "foo",
"new_string": "bar"
},
"tool_use_id": "toolu_X"
}
5.3 SubagentStart 额外字段
{
"hook_event_name": "SubagentStart",
"agent_id": "agent_xyz",
"agent_type": "security-reviewer"
}
5.4 TaskCreated 额外字段
{
"hook_event_name": "TaskCreated",
"task_id": "42",
"task_subject": "Add login page",
"task_description": "Implement JWT-based auth...",
"teammate_name": "team-lead",
"team_name": "myteam"
}
06输出协议(exit code + stdout JSON)
Hook 用 exit code + stdout 传达结果。
6.1 Exit code 语义
| Exit | 含义 | 下一步 |
|---|---|---|
0 | 成功,允许继续 | 可能消费 stdout JSON |
2 | 阻断 — stderr 作为错误原因 | 对应操作被中断 |
| 其他 | hook 自己出错 | 忽略该 hook,继续主流程 |
6.2 Stdout JSON 结构
{
// 阻断消息(与 exit 2 语义重复,但更结构化)
"blockingError": "Type check failed",
// 非阻断:追加到模型上下文
"additionalContexts": [
"Current git branch is feature/auth",
"CI last run: PASS"
],
// Permission 类 hook 的决策
"permissionDecision": "allow",
"permissionDecisionReason": "Matched safe-pattern allowlist",
// 改输入(PreToolUse)
"updatedInput": { "file_path": "/normalized/path" },
// PreToolUse hookSpecificOutput
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "..."
}
}
6.3 输出示例:typecheck hook
#!/bin/bash
# 读 hook 输入
input=$(cat)
tool_name=$(echo "$input" | jq -r .tool_name)
if [[ "$tool_name" == "Edit" || "$tool_name" == "Write" ]]; then
if ! tsc --noEmit; then
echo "TypeScript check failed before $tool_name" >&2
exit 2 # 阻断
fi
fi
exit 0
07阻断语义
阻断是 hook 最强大的能力,但只对特定事件有效。
7.1 阻断对应结果
| 事件 | 阻断效果 |
|---|---|
| UserPromptSubmit | 用户消息不发送给模型 |
| PreToolUse | 工具不执行,模型收到 tool_result(is_error) |
| Stop | agent 不结束,继续一个 turn(用于"还没跑测试不准停") |
| SubagentStop | 子 agent 不结束,继续 |
| TaskCreated | 任务回滚(deleteTask),抛错给模型 |
| TaskCompleted | 不标完成,返回 success: false |
| PreCompact | compact 不执行 |
7.2 模型如何知道被阻断
// PreToolUse 阻断后,模型收到:
{
type: 'tool_result',
tool_use_id: 'toolu_X',
is_error: true,
content: "Execution stopped by PreToolUse hook: TypeScript check failed before Edit"
}
// 模型读到错误 → 可能自己修 bug 再重试,或告诉用户原因
7.3 "Stop 阻断"的特殊用法
// Stop hook 返回 exit 2 → agent 不结束
// 经典用途:
// "如果还没跑 test,塞一条 user message 让 agent 继续跑 test"
// 实现:
if ! git diff --exit-code; then
echo '{"blockingError": "You have uncommitted changes. Run tests first."}'
exit 2
fi
08非阻断注入 additionalContexts
不阻断的 hook 也能改变 agent 行为 — 通过注入上下文。
8.1 SessionStart 注入
#!/bin/bash
# 会话启动时注入当前环境信息
branch=$(git branch --show-current)
recent_commits=$(git log --oneline -5)
cat <<EOF
{
"additionalContexts": [
"Current branch: $branch",
"Recent commits:\n$recent_commits"
]
}
EOF
exit 0
8.2 注入如何到达模型
- hook 输出 JSON 含 additionalContexts
- executeHooks 聚合所有 hook 的 additionalContexts
- 用
createAttachmentMessage({ type: 'hook_additional_context', ...})包装 - 作为 user message 插入 initialMessages 之前(isMeta: true,不在 UI 显示)
8.3 SubagentStart 的特殊注入
SubagentStart 的 additionalContexts 特别重要 — 它是给子 agent 的"预备知识":
// runAgent.ts 里
for await (const hookResult of executeSubagentStartHooks(agentId, agentType, signal)) {
if (hookResult.additionalContexts?.length) {
additionalContexts.push(...hookResult.additionalContexts)
}
}
// 这些 contexts 会作为 initialMessages 的第一条注入
// 相当于在 agent 开始前"给它简报"
09超时与 AbortSignal
Hook 是用户代码,不能让它卡死整个 agent。
9.1 默认超时
const TOOL_HOOK_EXECUTION_TIMEOUT_MS = 60_000 // 60 秒
// hook 运行超过这个时间 → SIGTERM → SIGKILL
// 超时的 hook 视为失败,不阻断主流程(exit code ≠ 0 且 ≠ 2 → ignore)
9.2 AbortSignal 传播
// 所有 hook 都接收 AbortSignal
executeHooks({
hookInput,
toolUseID,
signal: toolUseContext.abortController.signal,
timeoutMs,
})
// 内部实现:
// 1. spawn 子进程跑 hook
// 2. 监听 signal.aborted → child.kill()
// 3. 同时定时器 timeoutMs → child.kill()
// 4. 先到的生效
9.3 用户覆盖超时
{ "command": "./long-audit.sh", "timeout": 180000 }
// 某些 hook 确实需要更长时间(如全项目 lint / test)
10admin-trusted 信任分级
企业策略可以限制"谁能注册 hook",防止用户自己加绕过审计的钩子。
10.1 来源信任级别
| Hook 来源 | admin-trusted? | 受 strictPluginOnlyCustomization 限制 |
|---|---|---|
| managed-settings.json | 是 | 不受限(本身就是企业级) |
| policySettings | 是 | 不受限 |
| plugin 提供 | 是 | 不受限(plugin 经过审计签名) |
| built-in agent frontmatter | 是 | 不受限 |
| user settings | 否 | 被限制 |
| project settings | 否 | 被限制 |
| user agent frontmatter | 否 | 被限制 |
10.2 受限时的行为
// runAgent.ts:570
const hooksAllowedForThisAgent =
!isRestrictedToPluginOnly('hooks') ||
isSourceAdminTrusted(agentDefinition.source)
if (agentDefinition.hooks && hooksAllowedForThisAgent) {
registerFrontmatterHooks(...) // 允许
}
// 否则 frontmatter hooks 被静默忽略
11Frontmatter hook 生命周期
agent 的 markdown frontmatter 可以定义 hook,仅在该 agent 活着期间生效。
11.1 示例
# ~/.claude/agents/security-auditor.md
---
description: "Security audit specialist"
hooks:
PreToolUse:
- matcher: "Bash"
command: "./audit/log-bash.sh"
Stop:
- command: "./audit/final-report.sh"
---
You are a security auditor...
11.2 注册时机
// runAgent.ts:573 — agent 启动时注册
registerFrontmatterHooks(
rootSetAppState,
agentId, // ← 关键:按 agentId 作用域
agentDefinition.hooks,
`agent '${agentDefinition.agentType}'`,
true, // isAgent
)
11.3 agent 结束时自动清理
// runAgent 的 finally 块
clearSessionHooks(agentId)
// 该 agent 注册的所有 hook 从 registry 移除
// 不影响其他 agent 或 session 级 hook
12Stop → SubagentStop 转换
这是 frontmatter hook 里一个关键细节 — agent 写 hooks.Stop 意图明确,系统自动"翻译"为 SubagentStop。
12.1 问题
# agent frontmatter
hooks:
Stop:
- command: "notify agent done"
# 用户期望:该 agent 结束时跑
# 但 Stop 事件本意是"主对话 Stop"
# 如果不转换,主线程结束才跑 → 错位
12.2 解决:注册时自动改名
// registerFrontmatterHooks.ts 内部
for (const [eventName, configs] of Object.entries(hooks)) {
const effectiveEvent = isAgent && eventName === 'Stop'
? 'SubagentStop'
: eventName
// 注册时用 effectiveEvent
registerHook(agentId, effectiveEvent, configs)
}
13PreCompact 阻断的特殊性
PreCompact 阻断 = 禁止整段 compact。后果比一般阻断严重得多。
13.1 为什么需要
某些工作流不希望历史被摘要(比如审计场景需要保留完整对话),PreCompact hook 能阻止。
13.2 风险
/compact 或退出会话。
13.3 使用场景
#!/bin/bash
# 审计模式:记录压缩点,但不阻止
echo "Compact triggered at $(date)" >> /audit/compact.log
exit 0 # 注意是 0,不是 2
# 严格模式:彻底阻止 — 不推荐
# exit 2
14Notification hook
Notification 用于把 Claude Code 事件推送到外部系统。
14.1 通知类型
{
"hook_event_name": "Notification",
"notification_type": "task_complete", // 或 'waiting_for_input', 'error', ...
"title": "Task completed",
"message": "Agent finished the auth refactor"
}
14.2 典型用法
#!/bin/bash
input=$(cat)
type=$(echo "$input" | jq -r .notification_type)
title=$(echo "$input" | jq -r .title)
case "$type" in
task_complete)
osascript -e "display notification \"$title\" with title \"Claude Code\""
;;
waiting_for_input)
# macOS 声音提醒
afplay /System/Library/Sounds/Tink.aiff
;;
esac
15实战:写一个 typecheck hook
一个完整例子,在 Edit/Write 前自动跑 tsc 检查。
15.1 hook 脚本
#!/bin/bash
# ~/project/.claude/hooks/pre-edit-typecheck.sh
set -euo pipefail
# 读 hook 输入
input=$(cat)
tool_name=$(echo "$input" | jq -r .tool_name)
cwd=$(echo "$input" | jq -r .cwd)
# 只对 Edit/Write 触发(额外保险,虽然 matcher 也会过滤)
if [[ "$tool_name" != "Edit" && "$tool_name" != "Write" ]]; then
exit 0
fi
# 切到项目目录
cd "$cwd"
# 跑 TypeScript 检查
if ! npx tsc --noEmit 2> /tmp/tsc-errors.log; then
errors=$(head -20 /tmp/tsc-errors.log)
cat <<EOF >&2
TypeScript errors exist before edit. Fix them first:
$errors
EOF
exit 2 # 阻断
fi
# 或者返回结构化:
# cat <<EOF
# {"blockingError": "TypeScript errors: $errors"}
# EOF
# exit 2
exit 0
15.2 注册
// ~/project/.claude/settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"command": "./.claude/hooks/pre-edit-typecheck.sh",
"timeout": 30000
}
]
}
}
15.3 测试
# 让脚本可执行
chmod +x .claude/hooks/pre-edit-typecheck.sh
# 启动 Claude Code
claude
# 让它尝试 Edit 一个文件 — 如果项目有 type error,模型会收到阻断错误
# 调试时看 /hooks status 验证 hook 已加载
16可迁移设计原则
- 用 stdin/stdout JSON 做协议,不是编程 API — 任何语言都能写,降低使用门槛
- Exit code 表达结果 — 0/2/其他 三种状态足够覆盖所有场景
- 非阻断注入 = 受限扩展 — additionalContexts 让 hook 能影响行为但不能中断
- 事件分阻断/非阻断 — 只有少数真正重要的点允许阻断,其他是观察者
- 超时保护 — 用户代码必须有时间界限,否则会拖垮主流程
- 按来源分级信任 — 企业策略 > plugin > 用户,高层可以禁用低层的 hook 能力
- 作用域明确 — session / agent / 全局各一套,不串扰
- 意图翻译 — 用户写 "Stop" 系统翻译为 "SubagentStop",接口模糊度由框架吸收