一次 Claude Code 会话会触发 30 个 hook 事件。只有 3 个能回话。

Claude Code hook 事件的完整清单:每个事件何时触发、其中哪 15 个能阻断,以及那条悄悄吞掉大部分 hook 输出的 stdout 规则。一份在数千次代理会话的生产环境中跑出来的实战参考。

给 Claude Code 接 hook 的时候,有两种故障反复出现,而且它们看上去毫不相干。

第一种:你加了一个 hook,什么都没发生。没有报错,没有警告,没有日志行。这个 hook 就是从来没跑过。

第二种:hook 明明跑了,你能在磁盘上看到它的副作用,但它打给代理看的那条消息始终没送到。代理表现得就像这个 hook 什么都没说过。

两者的根源是同一个。hook 系统比大多数文章覆盖的那几个事件更庞大、也更不统一,而决定谁能对代理说话的规则并不是你会猜到的那一套。这就是我们当初希望有人写给我们的参考。我们在这些 hook 之上构建了 AgentsRoom,下面的每一条要么引自官方参考文档,要么是在生产环境里量出来的。

是 30 个事件,不是六个

大多数指南讲的是 PreToolUsePostToolUseUserPromptSubmitStopNotificationSubagentStop。这六个是真实存在的,也承担了大部分有用的工作。它们同时也只占全部事件的五分之一。

完整清单,按各自观察的对象分组:

分组事件
会话SessionStartSessionEndSetup
提示UserPromptSubmitUserPromptExpansion
工具PreToolUsePostToolUsePostToolUseFailurePostToolBatch
权限PermissionRequestPermissionDenied
回合StopStopFailure
子代理与任务SubagentStartSubagentStopTaskCreatedTaskCompletedTeammateIdle
上下文PreCompactPostCompactInstructionsLoaded
环境FileChangedCwdChangedConfigChange
WorktreeWorktreeCreateWorktreeRemove
界面NotificationMessageDisplay
MCP elicitationElicitationElicitationResult

Claude Code 全部 30 个 hook 事件在一次代理会话中按触发顺序排列的时间线示意图,从 SessionStart 开始,经过 UserPromptSubmit、PreToolUse、PostToolUse、Stop,直到 SessionEnd,并标出哪些事件能阻断代理。

事件在同一次会话中的触发顺序。工具块每次工具调用重复一遍,整个提示块每回合重复一遍。

其中几个事件会改变你对这套系统的理解。PostToolUseFailure 是存在的,所以「工具到底成没成功」这条分支本身就是一个事件,而不是你从 payload 里推断出来的东西。PostToolBatch 在一批并行工具调用结束后触发一次,正好适合把 linter 跑一次,而不是每次编辑都跑一次。InstructionsLoadedCLAUDE.md 被读取时触发,于是你有了一个挂载点,用来确认代理真的加载了你以为它加载了的那套规则。

吞掉大部分 hook 输出的那条 stdout 规则

这是本页最有用的一点。

退出码为 0 时,Claude Code 会解析 stdout 里的 JSON 输出字段。但这份 stdout 会不会被展示给代理,取决于是哪个事件,而例外只有短短一串。参考文档原文:

对大多数事件而言,stdout 会写入调试日志,但不会显示在转录记录里。例外是 UserPromptSubmitUserPromptExpansionSessionStart,在这三个事件上 stdout 会作为上下文加入,Claude 能看到它并据此行动。

三十个事件里只有三个。如果你在 PostToolUse 的 hook 里 echo "warning: this migration is destructive",指望代理读到它,那它永远读不到。你的文字进了调试日志。

从其他任何事件把文本送到代理面前,办法恰好只有两个:

  1. 以 2 退出并写 stderr。 退出码为 2 时,Claude Code 会忽略 stdout 以及其中的任何 JSON,并把 stderr 作为错误消息回喂给代理。
  2. 以 0 退出并打印一个 JSON 对象,其中携带 hookSpecificOutput.additionalContext

注意第一条里的不对称。退出 0 意味着 stdout 有用、stderr 没用。退出 2 意味着 stderr 有用、stdout 被整个丢弃。把这两者搞反,就是一个 hook 看起来完全正确却始终哑火的原因。

其他任何退出码都是非阻断错误。转录记录里会出现一条 <hook name> hook error 提示,带上 stderr 的第一行,执行继续进行,完整的 stderr 落进调试日志。

Claude Code hook 退出码示意图:退出码 0 只在三个事件上把 stdout 里的 JSON 送给代理,退出码 2 阻断该操作并把 stderr 送给代理,其他任何退出码都是写入调试日志的非阻断错误。

按退出码来看,哪条通道能到达代理。虚线那条是大家以为存在、实际并不存在的路径。

恰好一半能阻断

十五个事件会在退出码 2 时停下当前操作。十五个无视它,继续往下走。

能阻断: PreToolUsePermissionRequestUserPromptSubmitUserPromptExpansionStopSubagentStopTeammateIdleTaskCreatedTaskCompletedConfigChangePostToolBatchPreCompactElicitationElicitationResultWorktreeCreate

不能阻断: PostToolUsePostToolUseFailurePermissionDeniedStopFailureNotificationSubagentStartSessionStartSetupSessionEndCwdChangedFileChangedPostCompactWorktreeRemoveInstructionsLoadedMessageDisplay

实际后果是:护栏应该放在 PreToolUse,绝不放在 PostToolUsePostToolUse 是在工具成功之后才触发的。在那里以 2 退出并不会撤销那次写入,只会在破坏已经落到磁盘上的时候打印一条错误。你要拦住 rm -rf,能下手的地方恰好只有一个。

PostToolBatch 能阻断而 PostToolUse 不能,这一点值得多看一眼。它意味着一次批次级别的检查仍然可以在并行编辑落地之后叫停这一回合,这是整个系统里最接近写后否决的东西。

matcher 是精确匹配,直到它突然不是

matcher 字段会根据自身包含的字符切换求值策略,而且没有任何东西告诉你它走了哪条路。

Matcher求值方式
"*""",或不写匹配一切
只有字母、数字、_-、空格、,|精确字符串,或按 |, 拆分的精确字符串列表
其他任何情况不带锚点的 JavaScript 正则表达式

坑就在「不带锚点」上。参考文档明确说这个正则是用 RegExp.prototype.test 测试的,只要值里任何位置匹配上就算成功。所以 Edit.* 会匹配 Edit也会匹配 NotebookEdit。如果你指的是一个工具,就写 ^Edit$

两个与版本相关的行为,值得在你朝错误的方向查错之前先知道:

  • 逗号分隔符和对空白的容忍需要 Claude Code v2.1.191 或更高版本。
  • 连字符是在 v2.1.195 才加入精确匹配字符集的。在那之前,像 code-reviewer 这样的 matcher 会被当成不带锚点的正则,因此也会为 senior-code-reviewer 触发。

在 AgentsRoom 里,我们用 Write|Edit|MultiEdit|NotebookEdit 限定自己的文件归属 hook,它会走精确字符串那条路,只匹配这四个工具,别的一个都不碰。我们安装的生命周期 hook 完全不带 matcher,因为它们总是与我们相关。

有六个地方能定义 hook,而且它们会合并

第一反应是去找一个优先级顺序。并没有,而这恰恰是有意思的地方。

位置作用范围
~/.claude/settings.json你的所有项目,仅限本机
.claude/settings.json单个项目,可提交到版本库
.claude/settings.local.json单个项目,被 Claude Code 加入 gitignore
Managed policy settings整个组织,由管理员控制
插件的 hooks/hooks.json插件启用期间
Skill 或 agent 的 frontmatter该组件激活期间

参考文档原文:

hook 条目在各个 settings 层级之间是合并而不是相互替换的:用户、项目和本地 settings 各自添加自己的 hook,不会移除受管的那些,而且 disableAllHooks 设置无法从受管 settings 之外禁用受管 hook。

所以项目里的 hook 从不覆盖全局的那个,它只是叠加在上面。六个来源,全部是叠加的。一个 PostToolUse 格式化 hook 在你的用户 settings 里定义过一次、在项目里又定义了一次,那它每次编辑就跑两遍,唯一的症状是你觉得有点慢。

示意图:六个可以定义 Claude Code hook 的 settings 位置,它们全部以叠加方式合并成一个 hook 集合,而不是相互覆盖。

六个来源,一个合并后的集合。这里没有任何东西会覆盖任何东西。

这也解释了为什么 .claude/settings.local.json 是一个工具往别人项目里装 hook 的正确位置。它的作用范围是项目级的,Claude Code 会把它加进 gitignore,而且不需要任何命令行参数就会被加载。AgentsRoom 的条目就写在那里,这样用户提交到版本库的 .claude/settings.json 永远不会被动到,他的同事也不会继承一条与某台机器绑定的路径。

在生产环境跑 hook 教会我们的事

AgentsRoom 会往它打开的每一个项目里装 hook,用来确定性地跟踪代理状态,并把被修改的文件归属到正确的代理身上。有些事情只有在这个规模上才显得出来。

不认识的事件名会被悄悄忽略。 这一点文档里没写,而我们依赖它。当我们往安装程序里加一个新的生命周期事件时,CLI 版本较旧的用户会拿到一份 settings.local.json,里面有一个他们的二进制文件从没听说过的事件名。什么都不会坏,什么都不会告警,这条条目直接被跳过。正是这一点,让安装程序可以安全地抢在 CLI 发版之前发布。当然,这也必然意味着一个拼写错误换来的是彻底的沉默,而不是一条报错。

agent_id 是你判断自己身处子代理之中的依据。 这个字段只在 hook 于子代理调用内部触发时才出现。它比听上去更重要:Stop 在子代理结束自己那一回合时也会触发,不只是主代理结束时。一条天真的「看到 Stop 就把会话标记为完成」的规则,会在任何一个子代理第一次返回时就把整个会话判定为结束。我们跳过带 agent_id 的回合事件,正是因为这个。

不要为当前这一回合去读 transcript_path 参考文档警告说,转录记录是异步写入的,可能落后于内存里的对话,所以你的 hook 触发时,最近的那几条消息可能还不在里面。StopSubagentStop 会收到 last_assistant_message,就是为了让你永远不必去和文件赛跑。

hook 是唯一可靠的状态信号。 在有 hook 之前,我们靠扒 PTY 来判断一个代理是在思考、在等待还是已经完成。只要 CLI 改用终端的备用屏幕缓冲区渲染,这套就崩了,而 /tui fullscreen 干的正是这件事。hook 在任何渲染方式下的触发都完全一致。如果你要做任何从外部观察代理的东西,这就是该建在上面的那一层,扒终端最多只能留作兜底。

async: true 不花钱。 一条 hook 命令可以声明 async: true,代理不会等它。我们的 hook 以 2 秒为上限向一个本地端点发 POST,然后就返回;即使接收端的应用已经关闭,代理的回合延迟也不受影响。如果你的 hook 只负责观察、从不做决定,就把它设成异步,别再为它付代价。

绝不要让 hook 往终端里写垃圾。 我们的脚本吞掉所有异常,包括最顶层的。一个未被捕获的 Python 调用栈从 hook 里冒出来,不只是悄悄失败,它会在用户干活干到一半的终端会话里打印出一整段栈回溯。

超时

默认值都很宽松,只有三个例外不是:

hook 类型默认超时
commandhttpmcp_tool600 s
prompt30 s
agent60 s
UserPromptSubmit(command、http、mcp_tool)30 s
MessageDisplay(command、http、mcp_tool)10 s
SessionEnd所有 hook 共享 1.5 s,会被抬高以匹配某个更长的单 hook timeout,最多 60 s

SessionEnd 的预算是最让人意外的那个。它是一份共享预算,不是每个 hook 各自的额度,所以三个清理 hook 会一起分那 1.5 秒,除非你显式把它调高。

简短版

  • 一共有 30 个事件。出名的只有六个。
  • stdout 只在 UserPromptSubmitUserPromptExpansionSessionStart 上到达代理。其他所有地方,要么以 2 退出配 stderr,要么在 JSON 里用 additionalContext
  • 15 个事件在退出码 2 时阻断,15 个无视它。护栏放在 PreToolUse
  • matcher 都是精确字符串,直到某个特殊字符把它变成不带锚点的正则。
  • 六个 settings 来源以叠加方式合并。没有任何东西覆盖任何东西。
  • 事件名拼错会彻底静默地失败。

如果比起推理,你更想直接看着这些事件触发,那正是我们做的东西:AgentsRoom 按代理、按项目、按运行展示每一次 hook 触发,覆盖数十个并行代理子代理会话。你在自己 settings 里配置的 hook 会原样继续工作,因为它跑的是真正的 CLI。

继续阅读

下载 AgentsRoom

在一个窗口中运行你所有项目的 AI 智能体(Claude、Codex、Antigravity CLI、OpenCode、Aider、Grok Build、Mistral Vibe、Kimi Code)。

免费下载 AgentsRoom

配套应用:随时随地监控你的 Agent

使用 Claude、Codex、Antigravity CLI 或其他 AI 提供商。

获取扩展
Chrome Web Store

把 Bug 和需求直接发送到您的公开待办清单。

AgentsRoom 实际运行一瞥。

多项目管理
多供应商
多代理运行
实时状态
文件差异与提交
移动应用
实时预览
代理团队
浏览器自动化
Backlog 驱动开发
提示词库
技能库
查看所有功能