本文核对的是公开镜像的 2026 年 3 月 31 日源码快照。该镜像并非 Anthropic 官方源码发布;下文描述这一固定快照,不能据此断言当前发行版具有相同实现。官方文档用于说明产品或 API 合同。

上一篇讲 subagent 时,已经碰到 SubagentStart、 SubagentStop 这些名字。它们不是孤立功能,而是 hooks 系统的一部分。 如果只把 hooks 当成“跑命令”,会错过一个更重要的事实: hooks 是 Claude Code 留在关键执行步骤中的受控扩展点。

本篇的主线很简单:先看 hook 事件和输入 schema 对应哪些执行步骤,再看 getMatchingHooks() 怎样把 settings、plugin、skill 和 session hooks 归并成待执行列表; 然后沿工具调用追 PreToolUse 与 PostToolUse, 最后把 UserPromptSubmit、SessionStart、Stop、 PreCompact 和 PostCompact 接回一次回合的生命周期。

阅读目标:这篇不做 hooks 配置教程,也不枚举所有事件。 我们只追四处代码:用户输入、hook 匹配、工具执行、回合结束与 compact。 读完以后,应该能回答三个问题:hook 的输出什么时候会进入模型收到的消息; hook 的 allow 为什么不是越权通道;Compact 为什么也要跑 SessionStart hooks。

本文的产品行为以 Claude Code hooks 文档为参考,源码行为来自 Rememorio/claude-code 公开镜像。 本文只描述客户端公开源码可见的行为,不推断服务端如何调度模型或实现更深层安全策略。

hook event
  -> getMatchingHooks(settings, plugin, skill, session)
  -> execute matched hook handlers
  -> parse stdout as protocol result
  -> { decision, additionalContext, updatedInput? }

PreToolUse runs before permission/execution.
PostToolUse runs after the tool result exists.
Stop and Compact run at turn or memory boundaries.
源码形状:Hook 的关键不是“能跑脚本”,而是 stdout 解析后会影响哪一步执行。

一、先把 Hooks 放回一次回合里

Hooks 最容易被误读,是因为名字太像传统回调。回调通常让人想到“事件之后通知我一下”。 但 Claude Code 的 hook 事件表不是一串通知名,而是覆盖了输入、工具、回合和会话等不同执行步骤。 HOOK_EVENTS 里同时有 UserPromptSubmit、PreToolUse、PostToolUse、 PermissionRequest、SessionStart、Stop、 PreCompact、PostCompact、SubagentStart 这些名字。 它们分布在一轮任务的不同位置,不是同一种“事后通知”。

输入 schema 也在强调这一点。 BaseHookInputSchema 会给每个 hook 带上 session_id、transcript_path、cwd、 permission mode 和可选的 agent_id / agent_type。 工具类 hook 再追加 tool_name、tool_input、tool_use_id; SessionStart 追加 source,Stop 追加 last_assistant_message, PreCompact 带触发原因与自定义指令,PostCompact 才带生成后的 summary。

执行位置 hook 看到什么 它能影响什么
UserPromptSubmit 用户输入完成后的 prompt 文本。 阻断本轮查询,或追加 hook_additional_context。
PreToolUse 工具名、工具输入、tool_use id。 改输入、追加上下文、给权限层一个 allow/ask/deny 候选。
PostToolUse 工具名、工具输入、工具输出。 追加上下文,或只对 MCP 工具替换输出。
SessionStart 启动来源:startup、resume、clear 或 compact。 注入初始上下文、初始用户消息、文件 watch paths。
Stop / Compact 回合结束、压缩触发源和 compact summary。 决定是否继续、合并 compact 指令、在压缩后补回上下文。

所以第一层心智模型不是“某事件来了,执行脚本”,而是: Claude Code 在关键步骤把当前状态交给 hook,hook 返回一份受限输出, runtime 再决定哪些字段能进入下一步。

二、Hook 先被归并和匹配,才会执行

能不能跑 hook,第一步不是执行命令,而是归并配置。 getHooksConfig() 会从 snapshot、registered hooks、session hooks、session function hooks 里收集当前事件的候选项。 policy 开启 managed-only 时,plugin hooks 和 session hooks 会被跳过,避免非受管 hook 绕过策略。

2.1 匹配条件来自事件本身

归并后还要匹配。 getMatchingHooks() 会先为不同事件提取 matchQuery:工具事件用 tool_name, SessionStart 用 source,compact 事件用 trigger, notification 用 notification_type,subagent 事件用 agent_type。 这解释了为什么 hooks 配置里的 matcher 不是统一匹配同一个字段。

matcher 本身支持精确字符串、管道分隔列表和正则。 matchesPattern() 还会兼容 legacy tool name。 另外,if 条件 只在工具相关事件里求值,例如 Bash(git *) 这种条件要靠工具的 permission matcher 才能判断。

2.2 执行前还有信任与去重

getMatchingHooks() 会按来源去重 command、prompt、agent 和 HTTP hooks; 同一个命令在不同 plugin 或 skill 里不会被错误合并,同一来源里的重复项则会收敛。 真正执行时, executeHooks() 还会先检查全局禁用、simple mode、workspace trust,再用主 session 或当前 agentId 查匹配列表。 这说明 hook 不是绕开运行时的后门:全局开关、simple mode 和 workspace trust 仍会决定它能否执行。

三、工具 Hooks:PreToolUse 不是 permission 的后门

工具路径最能说明 hooks 的真实位置。 模型在流里吐出 tool_use 后,Claude Code 不是立刻调用工具。 checkPermissionsAndCallTool() 会先准备一个给 hooks 和 permission 使用的输入副本,再执行 runPreToolUseHooks()。 这个位置在权限决策之前,所以 PreToolUse 可以改输入、要求阻断、追加上下文,也可以给出一个权限行为候选。

3.1 Hook allow 不会绕过 settings deny/ask

这里有个很容易误解的点:PreToolUse 返回 allow,不代表一定执行。 resolveHookPermissionDecision() 的注释直接写明了不变量:hook 的 allow 不会绕过 settings.json 里的 deny/ask 规则。 如果工具需要用户交互且 hook 没有提供 updatedInput 来满足交互,或者运行时要求走 canUseTool,仍然会进入正常权限流; 如果规则检查命中 deny,deny 会覆盖 hook allow;如果命中 ask,就交给正常权限路径处理。否则 hook allow 可直接返回,不能把这条规则与工具检查快路画成必经完整权限栈。

这里的checkRuleBasedPermissions()仍会解析输入 schema 并运行 tool.checkPermissions(),保留工具级 deny、内容规则 ask 和 safetyCheck ask;它跳过的是完整的模式与默认放行流程,并非只匹配 settings。

Claude Code 权限决策栈图,展示 deny rule、ask rule、tool check、safety check、bypass mode、allow rule 和 final ask 的优先级
图中是正常权限路径;hook allow 另有规则检查快路,仍保留 settings deny/ask,但不必重跑图中全部步骤。

3.2 PostToolUse 可以补上下文,但替换输出只限 MCP

工具执行结束后,runPostToolUseHooks() 会消费 executePostToolHooks() 的结果。它可以产出 blocking error、阻断后续继续、追加 hook_additional_context。 但 updatedMCPToolOutput 有一个明确限制:只有 isMcpTool(tool) 为真时才会生效。 工具执行层 也在同一位置把 MCP 输出替换后再写入 tool result。

这条限制很关键。Hook 可以参与工具结果进入下一轮之前的处理,但不会让普通内置工具随便替换掉自己的输出。 运行时在这里守住的是工具结果的来源和可解释性。

四、输出协议:stdout 要先被解释

Hook 最后当然要输出东西。但 Claude Code 看的不是“stdout 里有什么字”,而是“stdout 能不能解析成约定输出”。 syncHookResponseSchema 和 hookJSONOutputSchema 定义了可被 runtime 识别的字段:continue、stopReason、 decision、systemMessage,以及按事件区分的 hookSpecificOutput。

parseHookOutput() 的规则也很直白:stdout trim 后不是 { 开头,就按普通文本处理; 是 JSON 才会走 schema 校验。 校验后的结构进入 processHookJSONOutput(), 再被翻译成 HookResult:阻断继续、permission behavior、updated input、 additional context、MCP output replacement、retry 等都在这里生成。

command hook 还有一个非 JSON 的阻断信号: 执行结果处理 会先尝试解析 JSON;如果不是 JSON,状态码 0 走成功,状态码 2 表示 blocking feedback, 其他非零状态码则作为 non-blocking error 展示给用户。 也就是说,stdout 是载体,JSON 是主要的结构化协议,exit code 2 是命令 hook 的简洁阻断通道。

输出形态 runtime 怎样看 典型后果
普通 stdout 不是 JSON 时,更多是显示和记录材料。 不会自动变成权限决策或输入改写。
command exit code 2 非 JSON 分支里的 blocking feedback。 生成 blockingError,阻止当前步骤继续。
{"continue": false} 被翻译成 preventContinuation。 当前步骤停止后续执行,可携带 stopReason。
PreToolUse.updatedInput 进入工具执行前的输入替换路径。 要么跟随 allow/ask,要么以 passthrough 进入正常权限流。
additionalContext 聚合成 hook_additional_context attachment。 可以作为附加消息进入后续模型请求。
{"async": true} 被 async hook 分支识别。 当前步骤不等待完整结果,但仍记录 hook 的执行状态。

多个 hooks 同时命中时,还要聚合。 executeHooks() 的结果归并 有一条关键优先级:permission behavior 按 deny > ask > allow 处理。 所以 hooks 之间不是谁最后输出谁赢,而是运行时按风险优先级归并。

4.1 异步接受、进程完成、进入模型是三件事

耗时检查不一定要卡住当前回合。命令 hook 可以通过配置 async,或把 {"async":true} 写成 stdout 的第一行来请求后台运行;forceSyncExecution 会阻止这次转移。返回的 backgrounded: true 和成功状态只表示运行时已接管后台进程,不表示检查已经完成。

配置 async / 首行 {"async":true}
  → 后台注册:当前步骤继续
  → 进程 completed:才读取最终输出
  → 后续主线程收集 attachment
  → systemMessage / additionalContext 进入模型消息
注册成功 ≠ 进程成功 ≠ 模型已收到结果
形状级状态序列:普通 async hook 的结果通过后续附件收集交付,不会回到已经结束的权限关口。

注册表只在进程为 completed 时提取响应,跳过首行 async 标记,标记响应已被收集并移除条目;仍运行、被杀死或没有 stdout 的情况各自处理。注册表是进程内 Map,不是可跨进程恢复的持久任务队列。后续附件收集生成 async_hook_response,消息转换只把 systemMessage 与 additionalContext 变成模型上下文,不会重新执行旧的 permission decision 或回滚工具。

asyncRewake 是另一条明确分支:它绕过注册表,在进程结束且 exit code 为 2 时排入 task notification,从空闲状态唤醒模型或在忙碌时注入排队消息。不能把这种主动唤醒能力推广到普通 async hook。

五、Prompt、SessionStart、Stop、Compact 各自影响不同步骤

工具路径讲清楚后,再回头看非工具 hook,就更容易理解:它们不是工具钩子的旁支, 而是在输入、会话恢复、回合结束和压缩等位置做同样的事。

5.1 UserPromptSubmit 在查询前检查输入

用户输入先走基础解析,再进入 executeUserPromptSubmitHooks()。 如果 hook 返回 blocking error,源码会返回一个 warning,并把本轮 shouldQuery 设成 false; 如果返回 preventContinuation,它会保留原 prompt 在上下文里的位置,但停止查询; 如果有 additionalContext,就转成 hook_additional_context message。

这一步说明 hook 可以在模型看见 prompt 之前改变本轮上下文。它不是“模型回答之后再通知脚本”, 而是模型查询前的一次检查。

5.2 SessionStart 不是只在进程启动时跑

SessionStart 的 source 有 startup、resume、 clear、compact。 processSessionStartHooks() 会加载 plugin hooks,执行 SessionStart hooks,然后收集 additionalContext、 initialUserMessage 和 watchPaths。 最后,额外上下文会变成一个 hook_additional_context attachment。

所以 SessionStart 不是狭义“启动回调”。resume、clear 和 compact 后的 session 重新进入可运行状态时, 它也可以把需要的上下文补回来。

5.3 Stop hook 管的是回合结束,不是工具结束

Stop 发生在回合末尾。 handleStopHooks() 会先构造 REPLHookContext,在主线程查询时保存 cache-safe params。 后面它还会启动一些回合结束的后台 bookkeeping,然后调用 executeStopHooks()。 Stop hook 的 continue: false 会变成 preventContinuation,生成 hook_stopped_continuation 并终止查询。另一种 blocking error 则表示“还不能结束”:query loop把反馈加入 messages,以 stopHookActive: true 继续请求模型。两种“阻断”对后续执行的影响相反。

这和 PostToolUse 的位置不同。PostToolUse 关心一个工具输出进入下一轮之前是否需要处理; Stop hook 关心这一轮 assistant 已经停住之后,是否允许本轮真正结束。

5.4 Compact 前后都有 hook

Compact 更能说明 hook 的执行位置会改变它的作用。 在 full compact 里,源码先发出 pre_compact progress, 然后执行 executePreCompactHooks(), 把 hook 产生的新指令合并进 custom instructions。 summary 生成完成、文件和工具相关 attachments 准备好后, 它还会以 compact 为 source 执行 SessionStart hooks。 最后再执行 PostCompact hooks。此时替换消息的组成部分已经准备好,但 compactConversation() 尚未返回;query loop 会在随后接收结果并替换本轮消息。

Compact 按条件生成摘要,准备恢复材料与 SessionStart 结果,执行 PostCompact 后由 query 安装新消息
PreCompact 在摘要生成前执行;SessionStart(compact) 结果加入恢复材料;PostCompact 完成后,query loop 才安装返回的新消息。

这段执行顺序解释了一个常见疑问:为什么 compact 后还要跑 SessionStart? 因为 compact 后发给模型的内容已经不是原始长历史,而是由 summary、压缩分界点、恢复 attachments 和 hook messages 组成的新起点。 这个新起点需要重新接上 session 级上下文。

六、怎么用:把 hook 当运行时关口,不当万能脚本

到这里可以把 hooks 的使用规则压成一张表。

你想做什么 应该选哪个 hook 仍然适用的限制
在用户 prompt 进模型前补项目上下文。 UserPromptSubmit 或 SessionStart。 补充内容要标记为 hook 上下文,不要伪装成用户原话。
工具执行前做参数清洗或风险拦截。 PreToolUse。 初始输入先过 schema/validate;改写后按分支做权限处理,不能假定完整语义校验必定重跑。
把某个工具输出变成下一轮可读提示。 PostToolUse。 追加上下文可以通用;替换工具输出只对 MCP tool 生效。
权限弹窗前自动给出 allow、ask 或 deny 候选。 PreToolUse / PermissionRequest。 allow 不是越权,deny/ask 规则仍优先。
回合结束时做收尾或阻断继续。 Stop / SubagentStop。 它处理整轮结束,不处理单个工具的输出。
compact 前改总结指令,compact 后补上下文。 PreCompact、SessionStart(compact)、PostCompact。 compact 后模型收到的是新消息组合,要重新补充必要上下文。

所以,Hooks 的关键不是“能跑脚本”。关键是 Claude Code 把脚本输出收进一份小协议, 再在每个执行位置只接受约定的字段。能补上下文的地方补上下文, 能改输入的地方改输入,能阻断继续的地方阻断继续,不能越过权限和工具合约的地方就不会越过去。

下一篇会继续沿这条“可继续运行”的线往后读: transcript、resume 和 session restore 为什么不是把旧聊天重新贴回窗口,而是恢复一套能继续执行的 runtime 状态。

参考源码与文档