Day 14 / 共 20 天 · 第 3 周 扩展能力 · 加深版

事件 Hooks 系统

昨天(Day 13)Skills 是"你主动触发一段流程";今天 Hooks 反过来——系统在生命周期关键节点自动回调你配好的逻辑(工具执行前拦一下、模型想收工时检查一下)。贴真实的事件枚举,讲清配置与拦截,并和 React UI hooks 划清界限。

📍 你在整门课的位置 · 第 3 周「扩展能力」(命令/MCP/Skill 是"加能力",Hook 是"在流程节点插一脚")
D11 Slash 命令 D12 MCP 客户端 D13 Skills 技能 D14 Hooks 事件 D15 子代理
L01

先分清两种 hook(最容易懵)

🤔 痛点:你想在 Agent 干某事前后"管一管" 模型有时会想跑 rm -rf、改完代码不格式化、测试没过就说"搞定了"。你没法整天盯着屏幕手动拦。怎么办?
💡 本质:Hook = 施工现场的"关卡签字" 事件 Hook 就像装修施工现场每道工序前后的监理签字点:拆墙前监理来看一眼(不合规就叫停 = PreToolUse 拦截),刷完漆自动验收(PostToolUse 跑格式化),工人说"完工"时监理复查(Stop 时测试没过不让走)。你事先规定好"到哪个节点叫谁来管",之后系统自动喊人。

项目里 "hook" 指两种完全不同的东西,务必分清:

React UI hooks(不是今天主题)
src/hooks/*.ts 里绝大多数 useXxx——如 useMergedToolsuseCanUseTool。它们是 Ink/React 的界面状态钩子(Day 04/06/09 见过)。
事件 Hooks(今天主题)
src/utils/hooks.ts(5190 行)+ src/utils/hooks/settings.json 驱动的生命周期钩子——PreToolUse/PostToolUse/Stop 等。
为什么同名? 纯属英文 "hook"(钩子)一词多义。React 的 hook 是"往组件里挂状态";事件 hook 是"往流程里挂拦截逻辑"。看路径就能区分src/hooks/useXxx.ts 是 UI;src/utils/hooks.tssrc/utils/hooks/ 是事件系统。今天只讲后者。
L02

Hook 是什么:在节点插入你的逻辑

事件 Hook 让你在 Agent 干活的关键节点插入自定义命令/脚本。典型用途:

  • 工具执行(PreToolUse)拦一下——"不许 Bash 跑 rm"、"Edit 前先备份"。
  • 工具执行(PostToolUse)做点事——"改完 .ts 文件自动跑 prettier"。
  • 模型想收工时(Stop)检查——"测试没过不许停"(Day 10 讲的 Stop hook 续命)。
  • 会话开始/结束、提交 prompt 时、压缩前后……
和 Skill/命令的区别 命令/Skill 是"你主动触发一个功能";Hook 是"系统在特定时刻自动触发你预先配好的逻辑"。你不用手动调 hook——你配好"PreToolUse 时跑这个脚本",之后每次工具执行前它就自动跑。这是"事件驱动"——把控制权交给系统,在对的时刻回调你。
L03

27 种事件(真实枚举)

HOOK_EVENTSsrc/entrypoints/sdk/coreTypes.ts:25)——真实的完整枚举,共 27 个:

export const HOOK_EVENTS = [
  'PreToolUse', 'PostToolUse', 'PostToolUseFailure',
  'Notification', 'UserPromptSubmit', 'SessionStart', 'SessionEnd',
  'Stop', 'StopFailure', 'SubagentStart', 'SubagentStop',
  'PreCompact', 'PostCompact', 'PermissionRequest', 'PermissionDenied',
  'Setup', 'TeammateIdle', 'TaskCreated', 'TaskCompleted',
  'Elicitation', 'ElicitationResult', 'ConfigChange',
  'WorktreeCreate', 'WorktreeRemove', 'InstructionsLoaded',
  'CwdChanged', 'FileChanged',
] as const
PreToolUsePostToolUseUserPromptSubmit StopSessionStart/EndPre/PostCompact Subagent Start/StopPermission…FileChanged…共 27 个
这份枚举本身就是"Agent 一次运行会经历哪些阶段"的地图:从会话开始(SessionStart)、你提交输入(UserPromptSubmit)、每次工具执行前后(Pre/PostToolUse)、压缩前后(Pre/PostCompact,Day 10)、子代理启停(Subagent Start/Stop,Day 15)、想收工(Stop,Day 10)、到会话结束(SessionEnd)。几乎每个"值得插一脚"的时刻都有对应事件。as const 让这个数组变成只读的字面量类型(TypeScript 能精确知道就这 27 个字符串)。
L04

怎么配(settings.json)

Hook 配在 settings.json 里,结构是"事件 → 匹配器 → 要跑的 hook":

{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash",              // 只对 Bash 工具触发
        "hooks": [{ "type": "command", "command": "./guard.sh", "timeout": 5000 }] }
    ],
    "PostToolUse": [
      { "matcher": "Edit|Write",        // 正则:Edit 或 Write
        "hooks": [{ "type": "command", "command": "prettier --write $FILE" }] }
    ]
  }
}

matcher 决定"这个 hook 对哪些情况触发"——工具类事件用工具名(支持正则 Edit|Write),无 matcher 视为匹配全部。Hook 来源和 Day 09 权限一样分层(用户/项目/本地/策略),另加插件 hook、session hook(如技能注册的,Day 13)、内置 hook。

L05

退出码约定:hook 怎么影响流程

command 型 hook 是跑一个 shell 命令,它用退出码 + stderr 来影响 AgenthooksConfigManager.ts:26getHookEventMetadata)。以 PreToolUse 为例:

exit 0

放行,不显示 stdout(一切正常)

exit 2

阻止工具调用,并把 stderr 内容给模型(告诉它为什么被拦)

其它码

只给用户看(不给模型),但工具继续执行

模型想调 Bashrm -rf /tmp/x PreToolUse 关卡跑 guard.sh 看退出码 exit 0 → 放行,工具照常执行一切正常 exit 2 → 阻止调用stderr 内容告诉模型"为什么被拦"
图注:PreToolUse hook 用退出码当"信号灯"——0 放行、2 拦截并向模型解释。
📝 举个例子:一个 3 行的拦截脚本 guard.sh 内容:grep -q 'rm -rf' && { echo "危险命令,已阻止">&2; exit 2; }; exit 0 → 模型想跑 rm -rf → 脚本 exit 2 → 工具不执行,模型收到 stderr "危险命令,已阻止",于是换个更安全的做法。
退出码 2 是关键:它让一个外部脚本能否决模型的工具调用。比如你的 guard.sh 检测到模型要 rm -rf,就 echo "危险命令,已阻止" >&2; exit 2——工具不执行,模型看到 stderr 里的原因、换个做法。UserPromptSubmit 的 exit 2 更狠:会擦除你的原始 prompt 并阻止处理用退出码当"信号"是 Unix 老传统(0=成功,非0=各种失败),这里用它做"hook 能不能拦住流程"的约定,简单有效。stderr = 标准错误输出(>&2 把内容写到它)。
L06

匹配与执行

匹配 getMatchingHookssrc/utils/hooks.ts:1739):拿到当前事件的所有 matcher → 按事件类型算 matchQuery(工具事件用 tool_name、SessionStart 用 source、FileChanged 用文件名)→ matchesPattern 过滤 → 标注来源、去重。

执行:各事件有独立入口——executePreToolHooks:3538)、executePostToolHooks:3594)、executeStopHooks:3791)等,都是 async generator 逐条 yield 进度。command 型用 spawn 起子进程执行;启用沙箱时还加 network-only 沙箱。

工具侧的接线在 src/services/tools/toolHooks.ts——Day 07 执行管线里的第 5 关(PreToolUse)和第 9 关(PostToolUse)就是调这里。PreToolUse hook 甚至能返回权限决策(allow/ask/deny)、改输入、或直接中止(Day 07 讲过)。

network-only 沙箱是什么? 跑 hook 脚本时,可以把它关进一个"只能上网、不能乱碰文件系统"的沙箱限制它的破坏力。因为 hook 是用户配的外部脚本,给它加沙箱是纵深防御——即使脚本有问题也限制影响范围。这跟 Day 16 沙箱预览、gov-agents 的隔离思想一致。
L07

五种 hook 形态

hook 不只有"跑命令"一种。type 字段可选五种(hooksSettings.ts):

type做什么
command跑一个 shell 命令(最常用,退出码约定见 L05)
prompt喂给一个 LLM 判断(让模型来决定拦不拦)
agent跑一个子代理(Day 15)
http打一个 HTTP 回调(通知外部系统)
function/callback内部程序化 hook(代码里注册的)
prompt 型 hook 很有意思:它用一个 LLM 来判断"这个操作该不该拦"——比写死的 shell 规则更灵活(能理解语义)。比如"如果模型要改的代码看起来会引入安全漏洞就拦"这种模糊判断,shell 脚本很难写,交给一个 LLM 判就行。这体现了"hook 不只是脚本钩子,还能是智能判断点"。另有轻量事件广播hookEvents.ts):把 hook 执行状态推给 UI,和执行逻辑解耦。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • 两种 hook 怎么区分?(src/hooks 是 React UI;src/utils/hooks 是事件系统)
  • 事件 hook 和命令/skill 的区别?(自动触发 vs 主动调用)
  • 27 种事件覆盖了 Agent 哪些阶段?
  • PreToolUse 退出码 0/2/其它分别什么效果?
  • 五种 hook 形态?prompt 型为什么灵活?

✋ 动手:对着真实代码读一遍

# 1. 27 种事件枚举(L03)
sed -n '25,53p' src/entrypoints/sdk/coreTypes.ts

# 2. 退出码约定(L05)
sed -n '26,90p' src/utils/hooks/hooksConfigManager.ts

# 3. 匹配与执行入口(L06)
grep -n "getMatchingHooks\|executePreToolHooks\|executePostToolHooks\|executeStopHooks" src/utils/hooks.ts | head

# 4. 工具侧接线(对应 Day 07 管线第 5/9 关)
sed -n '19,50p' src/services/tools/toolHooks.ts

# 5. 配一个 PreToolUse hook 拦 Bash(settings.json)
# hooks.PreToolUse[].matcher="Bash", command="echo blocked>&2;exit 2"
明天预告 · Day 15:第 3 周收官。Day 15 讲子代理——Agent 怎么派生"子 Agent"帮它并行干活。你会看到一个惊人简单的真相:子代理就是递归调用 query()(真实代码)。

← Day 13 Skills Day 15 · 子代理 / Task / coordinator →