ClaudeClaude Code系列第 12 / 15 篇
Claude Code Hooks 速查:33 个事件的触发时机、实测顺序、输入字段、退出码和返回值
写 hook 时要查的东西放在一页:33 个事件各在什么时候触发、能不能拦、matcher 过滤的是什么,退出码和返回的 JSON 各起什么作用。本站给每个事件挂了记录脚本,在服务器上实际触发到 27 个,并逐项验证了退出码、拒绝、改写参数、注入上下文这些行为。
本站已实测,测试日期:2026-10-13。
本页目录(11 节)
这一篇是查表用的:我想在某个时刻自动做点事,该挂哪个事件?脚本会收到什么?返回什么才能拦住?
怎么写第一个 hook、三个拿去就能用的例子,在《Claude Code Hooks 实战》里。这一篇不重复那些,只管把参考页里的规则列全,并且每一类都实际跑一遍。
| 实测环境 | |
|---|---|
| 日期 | 2026-10-13 |
| 版本 | Claude Code 2.1.296,Linux 服务器 |
| 模型 | Sonnet 5.5 |
| 连接方式 | 通过酷站AI 中转站,不是直连官方 |
| 做法 | 一个小项目,给 31 个事件挂同一个记录脚本;无界面跑了 22 次,交互式会话两段 |
先说实测的结论:
- 33 个事件里实际触发到 27 个,顺序、字段和文档对得上。没触发到的 6 个见文末。
- 退出码 0、1、2,返回 JSON 拒绝、改写参数、注入上下文、不让结束,结果都和文档一致。
- 有四处容易踩:信任提示默认选的是“退出”;无界面模式不问就信任项目里的 hook;
FileChanged在cd之后不再触发;提醒类的通知要等 6 秒或 60 秒才来。详见第六节。
一、事件总表
“退出码 2”一栏是脚本以 2 退出时发生什么,也就是这个事件能不能用来拦。“实测”一栏打勾的是本站这次触发到的。
会话和环境
| 事件 | 什么时候触发 | matcher 过滤的是 | 退出码 2 | 实测 |
|---|---|---|---|---|
SessionStart |
会话开始或恢复 | 怎么开始的:startup、resume、clear、compact、fork |
不能拦 | ✓ |
Setup |
带 --init-only 启动,或 -p 模式下带 --init、--maintenance |
init、maintenance |
忽略 | ✓ |
InstructionsLoaded |
CLAUDE.md 或 .claude/rules/ 下的文件被载入上下文 |
载入的原因,如 session_start、compact |
忽略 | ✓ |
ConfigChange |
会话中途配置文件变了 | 来源,如 user_settings、project_settings、local_settings |
能拦,改动不生效(组织策略除外) | ✓ |
CwdChanged |
工作目录变了,比如执行了 cd |
不支持 | 不能拦 | ✓ |
DirectoryAdded |
用 /add-dir 加了工作目录 |
怎么加的 | 不能拦 | ✓ |
FileChanged |
被监视的文件在磁盘上变了 | 要监视的文件名,写字面的名字 | 不能拦 | ✓ |
SessionEnd |
会话结束 | 原因:clear、resume、logout、prompt_input_exit、other |
不能拦 | ✓ |
每一轮
| 事件 | 什么时候触发 | matcher 过滤的是 | 退出码 2 | 实测 |
|---|---|---|---|---|
UserPromptSubmit |
提示词提交之后、模型处理之前 | 不支持 | 能拦,提示词到不了模型 | ✓ |
UserPromptExpansion |
你输入的命令展开成提示词时 | 命令名 | 能拦 | ✓ |
MessageDisplay |
显示助手的文字时 | 不支持 | 不能拦 | ✓ |
Stop |
模型答完 | 不支持 | 不让停,对话继续 | ✓ |
StopFailure |
这一轮因为接口报错结束 | 错误类型,如 rate_limit、model_not_found |
忽略 | ✓ |
Notification |
Claude Code 发通知时 | 通知类型,如 permission_prompt、idle_prompt |
忽略 | ✓ |
工具调用
| 事件 | 什么时候触发 | matcher 过滤的是 | 退出码 2 | 实测 |
|---|---|---|---|---|
PreToolUse |
工具执行之前 | 工具名 | 拦下这次调用 | ✓ |
PermissionRequest |
这次调用需要一个权限决定时 | 工具名 | 不认退出码,要拒绝得返回决定 | ✓ |
PermissionDenied |
auto 模式拒绝了一次调用 | 工具名 | 忽略 | 没触发 |
PostToolUse |
工具成功之后 | 工具名 | 拦不了,stderr 会给模型看 | ✓ |
PostToolUseFailure |
工具失败之后 | 工具名 | 同上 | ✓ |
PostToolBatch |
一批并行的调用都结束、下一次调模型之前 | 不支持 | 停掉循环 | ✓ |
子代理和任务
| 事件 | 什么时候触发 | matcher 过滤的是 | 退出码 2 | 实测 |
|---|---|---|---|---|
SubagentStart |
子代理启动 | 代理类型,如 Explore |
不能拦 | ✓ |
SubagentStop |
子代理结束 | 代理类型 | 不让它停 | ✓ |
TaskCreated |
用任务工具建一个任务时 | 不支持 | 撤销创建 | ✓ |
TaskCompleted |
任务被标成完成时 | 不支持 | 不让标成完成 | ✓ |
TeammateIdle |
代理团队里的队友要空闲时 | 不支持 | 让它接着干 | 没触发 |
压缩和换模型
| 事件 | 什么时候触发 | matcher 过滤的是 | 退出码 2 | 实测 |
|---|---|---|---|---|
PreCompact |
压缩上下文之前 | manual、auto |
拦下压缩 | ✓ |
PostCompact |
压缩完成之后 | 同上 | 不能拦 | ✓ |
PreModelSwitch |
你或客户端要求换模型,生效之前 | 目标模型的名字 | 拦下切换 | ✓ |
PostModelSwitch |
模型换了之后,包括 Claude Code 自己换的 | 同上 | 不能拦 | ✓ |
工作树和 MCP
| 事件 | 什么时候触发 | matcher 过滤的是 | 退出码 2 | 实测 |
|---|---|---|---|---|
WorktreeCreate |
要建工作树时。挂了它就接管默认的 git 行为 | 不支持 | 任何非零退出码都让创建失败 | 没挂 |
WorktreeRemove |
上面那个 hook 建的工作树被删除时 | 不支持 | 非零退出码让删除失败 | 没挂 |
Elicitation |
MCP 服务器在工具调用中途要你输入 | MCP 服务器名 | 拒绝这次请求 | 没触发 |
ElicitationResult |
你答完之后、回给服务器之前 | 同上 | 拦下回答 | 没触发 |
二、实测:事件按什么顺序来
怎么记的
一个小项目,.claude/settings.json 里给每个事件挂同一个脚本。脚本把收到的 JSON 里有哪些字段、短的字段值记成一行:
#!/usr/bin/env python3
"""把每次 hook 收到的输入记成一行:事件名、有哪些字段、短的字段值。不记环境变量。"""
import json, os, sys, time
d = json.load(sys.stdin)
skip = {"session_id", "prompt_id", "transcript_path", "cwd", "scratchpad_dir", "hook_event_name"}
vals = {k: v for k, v in d.items() if k not in skip and isinstance(v, (str, int, float, bool)) and len(str(v)) <= 90}
rec = {"t": round(time.time(), 2), "event": d.get("hook_event_name"), "keys": sorted(d.keys()), "vals": vals}
root = os.environ.get("CLAUDE_PROJECT_DIR", ".")
with open(os.path.join(root, "hooks.jsonl"), "a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
WorktreeCreate 和 WorktreeRemove 没有挂。原因见上表:挂上之后,建工作树这件事就归你的脚本管,脚本要把路径打印出来,只记日志的脚本会让创建失败。
一个最普通的任务
无界面模式,让它把 README 里的版本号加一,再跑一次 git status:
0.0s SessionStart source=startup
0.1s InstructionsLoaded load_reason=session_start memory_type=Project
0.4s UserPromptSubmit prompt=把 README.md 里的 version 加一,然后运行 git status --short…
7.4s PreToolUse tool_name=Read
7.5s PostToolUse tool_name=Read duration_ms=32
7.8s PostToolBatch
76.6s PreToolUse tool_name=Edit
76.7s PostToolUse tool_name=Edit duration_ms=21
76.7s PreToolUse tool_name=Bash
77.0s PostToolUse tool_name=Bash duration_ms=185
77.1s PostToolBatch
80.3s MessageDisplay final=True
80.7s Stop stop_hook_active=False
80.8s SessionEnd reason=other
这段输出删掉了 tool_use_id、tool_input 这些长字段,其余是原样。能看出三件事:
- 每次工具调用一对
PreToolUse和PostToolUse,每一批调用结束一次PostToolBatch。 第二轮模型同时发出了 Edit 和 Bash 两个调用,它们各有一对,合起来只有一次PostToolBatch。 -p模式下 hook 照常运行,结束时的原因是other。- 时间上的大段空白是在等模型,不是在等 hook。
其他情况各触发什么
每一行都是单独跑的一次,只列比上面多出来的事件:
| 做了什么 | 多出来的事件 |
|---|---|
| 让它派一个 Explore 子代理 | PreToolUse(Agent)→ SubagentStart → 子代理自己的工具事件 → SubagentStop → PostToolUse(Agent),之后又来了一次 UserPromptSubmit |
| 让它运行一条会失败的命令 | PreToolUse → PostToolUseFailure,没有 PostToolUse |
输入一条自定义命令 /explain |
UserPromptExpansion 先于 UserPromptSubmit |
| 让它用任务列表拆成两步 | 每次建任务:PreToolUse → TaskCreated → PostToolUse;标成完成时 TaskCompleted 夹在同样的位置 |
| 模型名写错 | UserPromptSubmit → StopFailure(model_not_found),没有 Stop |
claude --init-only |
Setup(init)→ InstructionsLoaded → SessionStart → SessionEnd |
| 手动模式下调用没放行的工具,无界面 | PreToolUse → PermissionRequest,工具没有执行 |
| 同上,交互式 | PreToolUse → PermissionRequest → 6 秒后 Notification(permission_prompt)→ 批准后 PostToolUse |
/compact |
PreCompact(manual)→ SubagentStop → SessionStart(compact)→ PostCompact;下一次提问时 InstructionsLoaded(compact) |
/model 换模型 |
PreModelSwitch → PostModelSwitch |
让它运行 cd src && pwd |
PostToolUse 之后 CwdChanged |
在会话外面新建、修改 .env |
FileChanged(add),再一次 FileChanged(change) |
在会话外面写 settings.local.json |
ConfigChange(local_settings) |
/add-dir 并确认 |
DirectoryAdded(slash_command) |
| 答完之后 60 秒不动 | Notification(idle_prompt) |
/clear |
SessionEnd(clear)→ SessionStart(clear) |
/exit |
SessionEnd(prompt_input_exit) |
几处文档里不显眼、实测才看清的:
- 子代理里面的事件带着
agent_id和agent_type,用它区分是主会话还是子代理在调工具。 - 子代理交还结果之后,会多一次
UserPromptSubmit,这一次不是你打的字。 文档说这个事件在 Claude Code 自己发起的轮次上也会触发,这就是一例。脚本如果假定每次UserPromptSubmit的prompt都是用户输入的话,在这里会判断错。 /compact会触发一次SubagentStop和一次SessionStart。 挂在SessionStart上的脚本如果只想在真正开场时运行,matcher 写startup。
三、脚本会收到什么
每个事件都有的字段
| 字段 | 内容 |
|---|---|
session_id |
会话的编号 |
prompt_id |
当前这条用户提示的编号,第一次输入之前没有 |
transcript_path |
会话记录文件的路径。它是异步写的,可能还没包含这一轮最新的消息 |
cwd |
hook 被调用时的工作目录 |
permission_mode |
权限模式。界面上叫 Manual 的那个,这里是 default。不是每个事件都有 |
effort |
一个对象,level 是当前的 effort 档位 |
hook_event_name |
触发的事件名 |
实测触发到的 27 个事件里,带 permission_mode 的只有 9 个:UserPromptSubmit、UserPromptExpansion、PreToolUse、PermissionRequest、PostToolUse、PostToolUseFailure、PostToolBatch、Stop、SubagentStop。其余 18 个都没有,脚本里别假定它一定在。
各事件多出来的字段(实测到的)
| 事件 | 多出来的字段 |
|---|---|
SessionStart |
source,交互式会话里还有 model |
Setup |
trigger |
InstructionsLoaded |
file_path、load_reason、memory_type |
UserPromptSubmit |
prompt |
UserPromptExpansion |
command_name、command_args、command_source、expansion_type、prompt |
PreToolUse |
tool_name、tool_input、tool_use_id;在子代理里还有 agent_id、agent_type |
PermissionRequest |
tool_name、tool_input、permission_suggestions |
PostToolUse |
上面三个,加 tool_response、duration_ms |
PostToolUseFailure |
tool_name、tool_input、tool_use_id、error、is_interrupt、duration_ms |
PostToolBatch |
tool_calls |
MessageDisplay |
delta、final、index、message_id、turn_id |
Notification |
message、notification_type |
SubagentStart |
agent_id、agent_type |
SubagentStop |
agent_id、agent_type、agent_transcript_path、last_assistant_message、stop_hook_active |
TaskCreated、TaskCompleted |
task_id、task_subject、task_description |
Stop |
last_assistant_message、stop_hook_active、background_tasks、session_crons |
StopFailure |
error、last_assistant_message |
PreCompact |
trigger、custom_instructions |
PostCompact |
trigger、compact_summary |
PreModelSwitch、PostModelSwitch |
from_model、to_model、requested_model、source、context_tokens、prompt_cache_warm、cache_ttl、estimated_cache_write_usd、pricing |
CwdChanged |
old_cwd、new_cwd |
FileChanged |
file_path、event(add、change、unlink) |
ConfigChange |
source、file_path |
DirectoryAdded |
directory、source |
SessionEnd |
reason |
换模型那一行值得留意:hook 能直接拿到“这次切换要重写多少缓存、估计多少钱”。实测从 Sonnet 换到 Opus,上下文 1,327 个 token,estimated_cache_write_usd 是 0.0066。
要拿这一轮最后的回答,用 Stop 里的 last_assistant_message,不要去读记录文件。
四、配置怎么写
写在哪
| 位置 | 作用范围 |
|---|---|
~/.claude/settings.json |
你的所有项目 |
.claude/settings.json |
一个项目,可以提交进仓库 |
.claude/settings.local.json |
一个项目,只在本机 |
插件的 hooks/hooks.json |
插件启用期间 |
| skill 的头部 | 这个 skill 被调用之后,直到会话结束 |
| 子代理的头部 | 这个子代理运行期间 |
写在子代理头部的 Stop 会被当成 SubagentStop。写在 skill 头部的 hook 可以加 once: true,成功运行一次后自动移除。
matcher 的三种写法
| 写成 | 当作 | 例子 |
|---|---|---|
"*"、空字符串,或者不写 |
全部匹配 | — |
| 只有字母、数字、下划线、连字符、空格、逗号和竖线 | 精确的名字,或者一串名字 | Bash;用竖线或逗号连起来的 Edit 和 Write |
| 含有别的字符 | 正则表达式,不锚定首尾 | ^Notebook、mcp__memory__.* |
MCP 工具的名字是 mcp__服务器名__工具名,所以 mcp__github__.* 匹配某个服务器的全部工具。
再用 if 收窄
matcher 只能按工具名过滤。要按参数过滤,在处理器上加 if,写法和权限规则一样,比如 "Bash(git *)"、"Edit(*.ts)"。它在启动脚本之前就判断,不匹配的调用不会起进程。
实测:matcher 是 Bash、if 是 Bash(git *),让模型依次运行 echo one 和 git status --short,脚本只被调了一次,收到的是 git status --short。
文档给了一张容易想错的表,摘三行:
if |
命令 | 运行吗 |
|---|---|---|
Bash(git *) |
npm test && git push |
运行,每个子命令都会查 |
Bash(rm *) |
echo $(rm -rf /) |
运行,$() 和反引号里面的也查 |
Bash(rm *) |
echo $(date) |
不运行 |
五种处理器
type |
做什么 | 默认超时 |
|---|---|---|
command |
运行一条命令,输入从 stdin 进,结果看退出码和 stdout | 600 秒 |
http |
把输入 POST 到一个地址,结果看响应体 | 600 秒 |
mcp_tool |
调一个已配置的 MCP 服务器上的工具 | 600 秒 |
prompt |
把输入交给一个模型判断一次,模型返回放行还是不放行 | 30 秒 |
agent |
起一个能用 Read、Grep 这些工具的子代理来核实,实验性 | 60 秒 |
超时有几处例外:UserPromptSubmit、PreModelSwitch、PostModelSwitch 上前三种的默认值降到 30 秒,MessageDisplay 上是 10 秒,SessionEnd 的全部 hook 合起来只有 1.5 秒。
不是每个事件都支持全部五种:
- 五种都支持:
PreToolUse、PostToolUse、PostToolUseFailure、PostToolBatch、PermissionDenied、UserPromptSubmit、UserPromptExpansion、Stop、SubagentStop、TaskCreated、TaskCompleted、TeammateIdle。 PermissionRequest:不支持agent。SessionStart和Setup:只支持command和mcp_tool。- 其余的:不支持
prompt和agent。
命令的两种写法
- 不带
args:整条字符串交给 shell,能用管道、&&、变量展开。 - 带
args:command当作可执行文件直接启动,args的每一项原样作为一个参数,不经过 shell,不用操心引号。
脚本的路径用 ${CLAUDE_PROJECT_DIR}(会话开始时的项目根目录)或 ${CLAUDE_PLUGIN_ROOT}(插件的安装目录)来写。模型进了工作树或者执行了 cd,CLAUDE_PROJECT_DIR 不变,变的是输入里的 cwd。
长时间的任务可以加 "async": true 放到后台跑。后台的 hook 拦不了任何东西,输出在下一轮才送到;-p 模式结束时还没跑完的会被杀掉。
五、退出码和返回值
三种结果
| 脚本怎么结束 | 算什么 | 动作 |
|---|---|---|
| 退出码 0 | 成功 | 照常进行;stdout 里有合规的 JSON 就按字段处理 |
| 退出码 2 | 阻断 | 在能拦的事件上停下,stderr 是理由 |
| 其他退出码,或者起不来、JSON 不合规 | 非阻断的错误 | 照常进行,界面上有一行提示 |
第三行是最容易想错的:脚本崩了、退出码是 1,动作并不会被拦住。想让“脚本出错”也算拦截,给处理器加 "onFailure": "block",要 2.1.295 以上。超时同理:超时的 hook 默认不拦。
onFailure 对 Stop、SubagentStop、TaskCompleted、TeammateIdle 和后台的 hook 不起作用。
实测:同一条命令,六种 hook
让模型运行 echo hello,PreToolUse 上换六种脚本:
| 脚本做了什么 | 命令运行了吗 | 模型看到的 |
|---|---|---|
| 退出码 0,不输出 | 运行了 | — |
| 退出码 1,stderr 写“脚本自己出错了” | 运行了 | — |
| 退出码 2,stderr 写“不许运行 echo” | 没有 | 被 hook 拦了,理由是那句 stderr |
退出码 0,stdout 是 permissionDecision: "deny" 的 JSON |
没有 | 被 hook 拦了,理由是 JSON 里写的 |
退出码 1,加了 onFailure: "block" |
没有 | hook 以退出码 1 失败,调用被拦 |
退出码 0,JSON 里带 updatedInput |
运行的是改写后的命令 | 输出是 rewritten-by-hook,不是 hello |
最后一种的 JSON 长这样:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": { "command": "echo rewritten-by-hook" }
}
}
模型事后发现输出不对,自己推断“应该是某个 hook 改写了命令”。改写参数是悄悄发生的,用它的时候要想到模型会困惑。
退出码和 JSON 二选一
要么只用退出码,要么退出码 0 加 JSON。混着用的话:退出码 2 时 JSON 里写 allow 也放不过去;退出码 1 时 JSON 里写什么都按字段算,但配了 onFailure: "block" 就算失败。
stdout 里只能有那个 JSON 对象,前后多一行别的文字就不会被当成 JSON。additionalContext、systemMessage 和纯文本的 stdout 各自最多 10,000 个字符,超出的部分存成文件,只留一段预览。
通用的 JSON 字段
| 字段 | 作用 |
|---|---|
continue |
设成 false,hook 跑完之后 Claude 整个停下,优先于其他决定 |
stopReason |
continue 为 false 时给用户看的话 |
systemMessage |
给用户看的警告 |
terminalSequence |
让 Claude Code 替你发一个终端转义序列,比如桌面通知、窗口标题、响铃 |
suppressOutput |
接受但不起作用 |
各事件怎么表达决定
| 事件 | 用什么 | 关键字段 |
|---|---|---|
PreToolUse |
hookSpecificOutput |
permissionDecision:allow、deny、ask、defer;updatedInput 改写参数 |
PermissionRequest |
hookSpecificOutput |
decision.behavior:allow、deny |
PermissionDenied |
hookSpecificOutput |
retry: true 告诉模型可以重试 |
UserPromptSubmit、PostToolUse、PostToolBatch、Stop、SubagentStop、ConfigChange、PreCompact 等 |
顶层的 decision |
decision: "block" 加 reason |
PreModelSwitch |
两种都行 | permissionDecision:allow、deny、ask |
PostToolUse |
另有 | updatedToolOutput 替换工具的结果 |
MessageDisplay |
hookSpecificOutput |
displayContent 只换屏幕上显示的文字,模型看到的不变 |
SessionStart、SubagentStart、PostModelSwitch |
只能加上下文 | additionalContext |
Notification、SessionEnd、PostCompact、CwdChanged、FileChanged 等 |
没有 | 只用来做记录、清理这类事 |
UserPromptSubmit 改不了提示词本身,只能在旁边加上下文。
实测:加上下文,和不让结束
加上下文。 UserPromptSubmit 上的脚本返回:
{"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": "今天的暗号是 7391。"}}
问模型“今天的暗号是多少”,回答是 7391。适合放的是模型没法自己知道的东西:当前分支、部署目标、这个目录的特殊规矩。
不让结束。 Stop 上的脚本第一次返回 {"decision": "block", "reason": "还没完:再运行一次 echo second,然后才能结束。"},第二次放行。实测模型又跑了一条 echo second 才结束,脚本两次收到的 stop_hook_active 分别是 False 和 True。
写 Stop hook 一定要看 stop_hook_active:它是 True 说明这一次停下已经是被 hook 推着多干了一轮之后,再拦就可能没完没了。
六、容易踩的地方
- 信任提示默认选的是“退出”。 第一次在一个目录里开交互式会话,会问你信不信任这个文件夹,光标停在“No, exit”上,直接回车就退出了。接受之前,所有设置文件里的 hook 都不运行,包括你自己用户设置里的。
- 无界面模式不问就信任。 文档写明,
-p和 SDK 的会话不显示信任提示,把目录当成可信的,所以仓库里提交的 hook 会在你从没信任过的目录里运行。在 CI 里对不认识的仓库跑claude -p之前,先看一眼它的.claude/settings.json。 FileChanged认的是工作目录下的文件名。 matcher 按竖线拆开,每一段登记成工作目录里的一个文件名,写正则没有用。实测在项目根目录新建、修改.env都触发了;另一段会话里,模型先cd src,之后再改根目录的.env就没有触发。- 通知不是马上来的。 等批准的通知在提示出现大约 6 秒后才触发,空闲的通知要答完 60 秒、并且这期间你没打字。实测分别是 6 秒和 60 秒。要第一时间知道“它在等我批准”,挂
PermissionRequest。 - auto 模式下没有等到
PermissionRequest。 这台机器上不带--permission-mode跑-p,hook 收到的permission_mode是auto;一条没写进放行清单的touch命令直接执行了,没有出现权限请求。指定手动模式之后,同一条命令才出现PreToolUse→PermissionRequest,并且没有执行。 PermissionRequest上退出码 2 不管用。 要拒绝得返回decision.behavior: "deny"。想无条件拦某类调用,挂PreToolUse。PostToolUse拦不住已经发生的事。 工具已经跑完了,退出码 2 只是把 stderr 给模型看。- 挂
WorktreeCreate等于接管建工作树。 脚本必须把路径打印到 stdout,否则创建失败。
七、提示词型的 hook,走中转站能用
type: "prompt" 的 hook 不运行脚本,而是把输入交给一个模型判断,模型返回 {"ok": true} 或者 {"ok": false, "reason": "…"}。默认用的是 Claude Code 做后台功能的那个模型。
本站担心走中转站时这个默认模型调不通,专门测了:Stop 上挂一个提示词型 hook,要求“回答里没有 BANANA 这个词就不让结束”。问“只回答两个字:你好”,最后的回答是:
你好 BANANA
不指定 model 和指定成 Sonnet 5.5,两次结果一样,都生效了。
ok: false 之后发生什么,各事件不同:Stop 和 SubagentStop 上理由会交给模型、对话继续;PreToolUse 上调用被拒、默认这一轮直接结束,加 continueOnBlock: true 才把理由交回模型让它接着干。
提示词型 hook 每次都是一次模型调用,要花钱,也有延迟。能用脚本判断的,用脚本。
八、怎么查
/hooks:只读的浏览器,列出当前生效的每个 hook 和它来自哪个文件。- 临时全关:设置里写
"disableAllHooks": true。 - 调试日志:
claude --debug-file <路径>,hook 的执行细节在这个文件里。2.1.296 起,每个命令型 hook 跑完都会留一行带耗时的记录,找慢的 hook 很方便。本站这次没有开调试日志,这一条是文档的说法。
和其他几样东西的关系
- hook 是“一定会执行”的规矩;CLAUDE.md 是“希望它照做”的说明。怎么选见《skill、hook、子代理、MCP 该用哪个》。
- 插件里除了这种 hook,还能用 JavaScript 函数注册另一种,能在界面上画东西,那是 mod。
- 用 Agent SDK 写程序时,hook 是代码里的回调函数,见《权限和 hooks》。
花费
无界面的 22 次运行里有 21 次产生了费用,Claude Code 自己报的估算合计约 1.8 美元,单次多在 0.07 美元上下。两段交互式会话的费用没有记。这是估算,不是中转站实际扣的金额。
这次实测没有覆盖到的
- 6 个事件没有触发:
TeammateIdle(要代理团队)、Elicitation和ElicitationResult(要一个会中途提问的 MCP 服务器)、WorktreeCreate和WorktreeRemove(没有挂)、PermissionDenied。 PermissionDenied试过一次没触发到。 auto 模式下让它运行一条“下载脚本并交给 bash”的命令,分类器放行了,命令照常执行。这只是一次观察,不代表分类器的一般表现。http、mcp_tool、agent三种处理器没有测,后台运行(async)也没有测。这几处是按文档整理的。- 各事件的字段只列了实测到的,不保证是全部;没触发的事件没有列。完整的定义在官方的参考页。
- 超时、输出超过 10,000 字符、组织策略下的行为没有测。
- 只在 Linux 上、一个版本、通过一家中转站测过。