Claude Code 源码深度讲解 · 必修篇 4/5

Hook 系统 — Extensibility 基础设施

Hook 让用户无需改源码就能在 agent 生命周期的 14 个关键点注入自定义逻辑。 这是把"产品的上限"交给用户创造力的核心机制。本文把 14 种事件、输入输出协议、阻断语义全部讲透。

01引言:Hook 是 agent 扩展性基石

没有 hook,用户扩展 agent 只能 fork 源码或写 wrapper;有 hook,写几行 shell 脚本就能植入业务逻辑。

1.1 典型用例

场景Hook 事件脚本做什么
提交前必须 typecheckPreToolUse (Edit/Write)tsc --noEmit,失败则 exit 2
记录所有 bash 命令PreToolUse (Bash)追加到审计日志
格式化刚编辑的文件PostToolUse (Edit)调 prettier
agent 完成发 Slack 通知Stopcurl Slack webhook
session 启动注入环境信息SessionStartstdout JSON additionalContexts
任务创建时登记 JiraTaskCreatedPOST Jira API

1.2 跟 middleware 的区别

为什么不用代码内置 middleware
middleware 要用户会 TypeScript,要 import SDK,要发布。 Hook 用 shell 脚本 + stdin/stdout JSON,任何语言都能写。 门槛从"会编程"降到"会写 shell"。受众扩大 10 倍。

0214 种 Hook 事件清单

Claude Code 在以下 14 个关键点触发 hook。

#事件触发点能阻断?
1SessionStart会话启动
2SessionEnd会话结束
3UserPromptSubmit用户发送消息前
4PreToolUse工具执行前
5PostToolUseFailure工具失败后
6Stop主 agent 准备结束 turn
7StopFailureStop hook 自己失败时
8SubagentStart子 agent 启动前
9SubagentStop子 agent 结束前
10TaskCreatedTaskCreate 写完文件后是(回滚)
11TaskCompletedTaskUpdate 标 completed 前
12PreCompactautocompact 开始前
13Notification客户端推送通知
14PostSampling模型采样完成后

2.1 阻断 vs 非阻断的区别

阻断事件意味着 hook 可以返回 exit 2 让对应操作不发生非阻断只是"发生时通知你,但改变不了什么"。

03Hook 在 agent 生命周期的位置

Session 启动 SessionStart hook 用户输入 UserPromptSubmit hook 模型采样 PostSampling hook 工具调用 PreToolUse hook(前) PostToolUseFailure(失败) 子 agent(可选) SubagentStart / Stop 回合结束 / 压缩 Stop / StopFailure PreCompact hook
Hook 散布在 session → turn → tool → agent → compact 各层生命周期节点

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)
Stopagent 不结束,继续一个 turn(用于"还没跑测试不准停")
SubagentStop子 agent 不结束,继续
TaskCreated任务回滚(deleteTask),抛错给模型
TaskCompleted不标完成,返回 success: false
PreCompactcompact 不执行

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 注入如何到达模型

  1. hook 输出 JSON 含 additionalContexts
  2. executeHooks 聚合所有 hook 的 additionalContexts
  3. createAttachmentMessage({ type: 'hook_additional_context', ...}) 包装
  4. 作为 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)
}
设计哲学:用户写他们想的,系统翻译意图
不强迫用户记住 "SubagentStop" 这个术语,让他们写自然的 "Stop"。 接口模糊度应该被框架吸收,不是推给用户

13PreCompact 阻断的特殊性

PreCompact 阻断 = 禁止整段 compact。后果比一般阻断严重得多。

13.1 为什么需要

某些工作流不希望历史被摘要(比如审计场景需要保留完整对话),PreCompact hook 能阻止。

13.2 风险

后果
如果持续阻断 PreCompact,token 超阈值后会进入硬阻塞 — 用户收到 "Prompt is too long" 错误。需要手动 /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可迁移设计原则

  1. 用 stdin/stdout JSON 做协议,不是编程 API — 任何语言都能写,降低使用门槛
  2. Exit code 表达结果 — 0/2/其他 三种状态足够覆盖所有场景
  3. 非阻断注入 = 受限扩展 — additionalContexts 让 hook 能影响行为但不能中断
  4. 事件分阻断/非阻断 — 只有少数真正重要的点允许阻断,其他是观察者
  5. 超时保护 — 用户代码必须有时间界限,否则会拖垮主流程
  6. 按来源分级信任 — 企业策略 > plugin > 用户,高层可以禁用低层的 hook 能力
  7. 作用域明确 — session / agent / 全局各一套,不串扰
  8. 意图翻译 — 用户写 "Stop" 系统翻译为 "SubagentStop",接口模糊度由框架吸收
最深的启示
Claude Code 的 hook 系统是"让用户写代码的能力"和"保证系统稳定性"的精妙平衡。 14 种事件、10+ 层信任、超时保护、作用域隔离 —— 任何一个环节缺失,hook 就从"扩展点"退化成"攻击面"。 设计扩展机制时,安全边界比功能丰富度更重要