第11章:钩子系统 —— 27 个事件的治理层
约 13 分钟 · 更新于 2026-09-01
第11章:钩子系统 —— 27 个事件的治理层
src/utils/hooks.ts 有 5022 行,是全仓最大的单文件。它实现了 27 个事件点 × 4 种钩子类型,钩子能改写工具输入、做权限决定、阻止循环继续、往上下文里注入内容。本章讲这套治理层的能力边界,以及一条本仓亲身踩过的坑。
一、27 个事件
ts
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
[源码 src/entrypoints/sdk/coreTypes.ts:24]
(数组里是 27 项,上面按语义分了行。)
按覆盖的生命周期分组:
| 组 | 事件 | 覆盖什么 |
|---|
| 工具 | PreToolUse PostToolUse PostToolUseFailure | 一次工具调用的三个时刻 |
| 权限 | PermissionRequest PermissionDenied | 权限决策前后 |
| 轮次 | UserPromptSubmit Stop StopFailure | 用户提交、轮次结束、轮次失败结束 |
| 会话 | SessionStart SessionEnd Setup | 会话与初始化 |
| 子 agent | SubagentStart SubagentStop | 子 agent 生命周期 |
| 压缩 | PreCompact PostCompact | 压缩前后 |
| 任务/团队 | TaskCreated TaskCompleted TeammateIdle | 任务系统与多 agent |
| 交互 | Elicitation ElicitationResult Notification | MCP 的 elicitation 协议、通知 |
| 环境 | ConfigChange CwdChanged FileChanged InstructionsLoaded | 配置、工作目录、文件、CLAUDE.md 加载 |
| worktree | WorktreeCreate WorktreeRemove | git worktree 隔离 |
对照 Codex 的 6 个事件点,这是数量级的差别。但数量不是重点,覆盖面才是——注意 InstructionsLoaded(CLAUDE.md 被加载时)、CwdChanged(工作目录变了)、ConfigChange(配置变了)这几个:它们让钩子可以对运行时环境的变化做出反应,而不只是对 agent 的动作做出反应。
二、四种钩子类型
这是 Claude Code 钩子系统和其它 harness 差别最大的地方 [源码 src/schemas/hooks.ts:31-170]:
2.1 command —— 跑一条 shell 命令
ts
{
type: 'command',
command: string,
if?: string, // 权限规则语法的过滤条件
shell?: 'bash' | 'powershell' | …,
timeout?: number, // 秒
statusMessage?: string, // spinner 里显示的自定义文案
once?: boolean, // 跑一次就自我移除
async?: boolean, // 后台跑,不阻塞
asyncRewake?: boolean // 后台跑,退出码 2 时唤醒模型(隐含 async)
}
asyncRewake 很有意思:后台跑,如果失败了(退出码 2 = 阻塞错误)就把模型叫醒。这是"跑个 lint,出问题再说"这类场景的原生支持。
2.2 prompt —— 用一个 LLM 判断
ts
{
type: 'prompt',
prompt: string, // 'Use $ARGUMENTS placeholder for hook input JSON.'
model?: string, // 不指定就用 small fast model
if?: string,
timeout?: number,
…
}
钩子本身可以是一次模型调用。 输入 JSON 通过 $ARGUMENTS 占位符注入。
2.3 agent —— 一个完整的验证 agent
ts
{
type: 'agent',
prompt: string, // 'Prompt describing what to verify
// (e.g. "Verify that unit tests ran and passed.")'
model?: string, // 默认 Haiku
timeout?: number, // 默认 60 秒
…
}
注释叫它 Agentic verifier hook type。它不是判断一次,是跑一个能用工具的 agent 去验证。
这个 schema 上有一段很实在的事故注释:
ts
// DO NOT add .transform() here. This schema is used by parseSettingsFile,
// and updateSettingsForSource round-trips the parsed result through
// JSON.stringify — a transformed function value is silently dropped,
// deleting the user's prompt from settings.json (gh-24920, CC-79).
Zod 的 .transform() 把字符串变成了函数,然后配置回写时 JSON.stringify 把函数丢了——用户 settings.json 里的 prompt 凭空消失。 两个 issue 编号。
可迁移的判断 ㉓
用于配置文件解析的 schema 不要带 .transform(),除非你确认配置永远不会被程序回写。
这是个很容易踩的坑:解析时的便利转换,在"读 → 改一个字段 → 写回"的往返里会静默丢数据。判断方法很简单——问一句"这个 schema 解析出来的东西会不会被 stringify 回去"。
2.4 http —— POST 到一个 URL
ts
{
type: 'http',
url: string,
headers?: Record<string, string>,
allowedEnvVars?: string[],
…
}
headers 里可以用 $VAR_NAME 引用环境变量,但有个强制约束:
Only variables listed in allowedEnvVars will be interpolated. … Only variables listed here will be resolved; all other $VAR references are left as empty strings. Required for env var interpolation to work.
默认不插值任何环境变量,必须显式列出白名单。 否则一个恶意的(或粗心的)钩子配置就能把 $AWS_SECRET_ACCESS_KEY 发到任意 URL。
这是整个钩子系统里最重要的一处安全设计,而且它是 fail-closed 的:不写白名单 → 变量解析成空串 → 请求失败 → 用户去看文档,而不是悄悄泄漏。
三、if 条件:不匹配就不 spawn
ts
const IfConditionSchema = z.string().optional().describe(
'Permission rule syntax to filter when this hook runs (e.g., "Bash(git *)"). ' +
'Only runs if the tool call matches the pattern. Avoids spawning hooks for non-matching commands.')
[源码 src/schemas/hooks.ts:19]
用的是权限规则的语法(Bash(git *)、Read(*.ts)),复用第 10 章那套匹配器。
这就是第 8 章那个 preparePermissionMatcher 的用途:
ts
/**
* Prepare a matcher for hook `if` conditions (permission-rule patterns like
* "git *" from "Bash(git *)"). Called once per hook-input pair; any
* expensive parsing happens here. Returns a closure that is called per
* hook pattern.
*/
两阶段设计的原因:一个工具调用可能要匹配几十条钩子的 if,而 bash 命令的解析(拆 token、处理引号)很贵。所以解析一次,然后用返回的闭包去匹配每个模式。
注释里那句 "Avoids spawning hooks for non-matching commands" 点明了 if 的价值:不是"跑了再判断",是根本不启动进程。对于每次工具调用都会触发的钩子,这是数量级的差别。
四、钩子能返回什么:18 个字段
ts
export interface HookResult {
message?: HookResultMessage
systemMessage?: string
blockingError?: HookBlockingError
outcome: 'success' | 'blocking' | 'non_blocking_error' | 'cancelled'
preventContinuation?: boolean
stopReason?: string
permissionBehavior?: 'ask' | 'deny' | 'allow' | 'passthrough'
hookPermissionDecisionReason?: string
additionalContext?: string
initialUserMessage?: string
updatedInput?: Record<string, unknown>
updatedMCPToolOutput?: unknown
permissionRequestResult?: PermissionRequestResult
elicitationResponse?: ElicitationResponse
watchPaths?: string[]
elicitationResultResponse?: ElicitationResponse
retry?: boolean
hook: HookCommand | HookCallback | FunctionHook
}
[源码 src/utils/hooks.ts:338]
按能力分类:
| 能力 | 字段 | 影响 |
|---|
| 改写输入 | updatedInput | 工具拿到的参数变了 |
| 改写输出 | updatedMCPToolOutput | MCP 工具的结果变了 |
| 权限决定 | permissionBehavior + hookPermissionDecisionReason | 第 9 章那张真值表 |
| 阻断 | blockingError / preventContinuation / stopReason | 工具不执行 / 循环不继续 |
| 注入 | additionalContext / systemMessage / initialUserMessage | 往上下文里塞东西 |
| 交互 | elicitationResponse / permissionRequestResult | 代替用户回答 |
| 重试 | retry | 告诉模型可以再试一次 |
| 监听 | watchPaths | 声明要监视哪些路径 |
四种能力的组合让钩子成了真正的策略层,而不是通知机制。
outcome 那四个值是本章后半段的关键:success / blocking / non_blocking_error / cancelled。
4.1 钩子的输出协议:JSON 或纯文本
ts
function parseHookOutput(stdout: string) {
const trimmed = stdout.trim()
if (!trimmed.startsWith('{')) {
logForDebugging('Hook output does not start with {, treating as plain text')
return { plainText: stdout }
}
const result = validateHookJson(trimmed)
if ('json' in result) return result
// For command hooks, include the schema hint in the error message
const errorMessage = `${result.validationError}\n\nExpected schema:\n${jsonStringify({
continue: 'boolean (optional)',
suppressOutput: 'boolean (optional)',
stopReason: 'string (optional)',
…
})}`
}
[源码 src/utils/hooks.ts:398]
判别方式是"第一个非空字符是不是 {"。 简单粗暴但够用——钩子作者不需要声明自己输出什么格式。
JSON 校验失败时,错误消息里附上完整的期望 schema。又是第 9 章那条"错误消息要回答该怎么办"。
五、信任门:所有钩子都要先过
ts
/**
* Historical vulnerabilities that prompted this check:
* - SessionEnd hooks executing when user declines trust dialog
* - SubagentStop hooks executing when subagent completes before trust
*/
export function shouldSkipHookDueToTrust(): boolean {
// In non-interactive mode (SDK), trust is implicit - always execute
if (getIsNonInteractiveSession()) return false
// In interactive mode, ALL hooks require trust
return !checkHasTrustDialogAccepted()
}
[源码 src/utils/hooks.ts:286]
背景:你在一个陌生仓库里启动 Claude Code,它会问"信任这个目录里的文件吗?"。如果你选不信任,那么这个仓库 .claude/settings.json 里的钩子不应该执行——因为钩子就是任意代码执行。
注释列的两个历史漏洞很典型:
- SessionEnd:用户点了"不信任"→ 会话结束 → SessionEnd 钩子跑了
- SubagentStop:子 agent 在信任对话框弹出之前就跑完了 → SubagentStop 钩子跑了
两个漏洞的共同点是"生命周期事件在信任决定之前或之后发生"。 解法是一刀切:交互模式下所有钩子都过这道门。
而非交互模式(SDK)默认信任——因为那时候是程序在调用,没有"用户还没决定"这个状态。
六、失败语义:本仓踩过的那个坑
现在可以解释第 6 章那 2249 条 hook_non_blocking_error 了。
6.1 钩子的四种 outcome 对应四种附件
第 6 章列过钩子独占 10 个附件类型,其中最相关的四个:
| outcome | 附件类型 | 行为 |
|---|
| success | hook_success | 正常 |
| blocking | hook_blocking_error | 工具不执行,错误进上下文 |
| non_blocking_error | hook_non_blocking_error | 工具照常执行,错误进上下文 |
| cancelled | hook_cancelled | 被取消 |
non_blocking_error 的语义是"钩子自己出错了,但不影响工具"。 这是对的默认值——一个 lint 钩子挂了,不该让所有文件编辑都失败。
但"不影响工具"不等于"没有代价":每次都会往上下文里加一条附件。
6.2 本仓的实测
[实测] 本机 ~/.claude/projects/-home-chenkun-variFlight-work-VariFlightWork/ 下 48 个会话,hook_non_blocking_error 附件 2249 条——是所有附件类型里最多的一种,占全部附件的 64%。
来源是本仓 .claude/settings.json 里注册的四个 PowerShell 钩子,命令全部长这样:
text
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$CLAUDE_PROJECT_DIR/.claude/hooks/X.ps1"
错误信息 2249 条完全一致:
text
Failed with non-blocking status code: /bin/sh: 行 1: powershell.exe: 未找到命令
根因不是"Linux 没有 PowerShell"
本机 ~/.local/bin/pwsh 是装了的(PowerShell 7.6.5),缺的只是 Windows 那个可执行文件名 powershell.exe。四个 .ps1 拿到 Linux 的 pwsh 下逐个实测,全部 exit 0、行为正确,包括中文路径和中文内容。
所以这不是"脚本不跨平台",是可执行文件名写死了。这个区别决定了修法的难易——前者要重写四个脚本,后者只要改四个命令字符串。
各钩子的贡献很反直觉:
| 钩子 | matcher | 条数 | 为什么是这个量 |
|---|
| log-codescope.ps1 | Bash|PowerShell | 1472 | 每跑一条 bash 命令就失败一次 |
| inject-dir-claude-md.ps1 | Write|Edit|MultiEdit|Read|NotebookEdit | 334 | 每次读写文件 |
| sync-agents-md.ps1 | Write|Edit|MultiEdit | 244 | 每次写文件 |
| guard-windows-encoding.ps1 | Write|Edit|MultiEdit | 198 | 同上(有 timeout: 15,失败得更快) |
log-codescope 一个占了三分之二——它挂在 Bash 上,而 agent 跑 bash 的频率远高于写文件。"哪个钩子最费上下文"取决于 matcher 的触发频率,和这个钩子本身重不重要无关。
于是在 130 这台 Arch Linux 上:
text
每次 Bash / Read / Write / Edit
→ 触发对应的 PreToolUse / PostToolUse 钩子
→ powershell.exe 找不到
→ outcome: 'non_blocking_error'
→ 一条 hook_non_blocking_error 附件进上下文
→ 工具照常执行(功能没坏)
功能上完全正常,代价是每次工具调用都往上下文里倒一条垃圾。
而本仓 CLAUDE.md 里原本那条规则:
Rule (Linux/macOS): the PowerShell hook does not fire there — before the session's first Read/Write/Edit under such a sub-directory, Read that directory's CLAUDE.md chain (directory → project root) manually.
它解决的是功能缺失(Linux 上目录级 CLAUDE.md 不会被自动注入)。但它没有提到上下文开销这一面——而实测数据显示,这才是更大的那个代价。
后续:2026-08-25 已修复
四个命令改成运行时解析解释器:
sh
PS="$(command -v powershell.exe || command -v pwsh || true)"; [ -z "$PS" ] && exit 0; \
exec "$PS" -NoProfile -ExecutionPolicy Bypass -File "$CLAUDE_PROJECT_DIR/.claude/hooks/X.ps1"
Windows 走 powershell.exe(行为完全不变),Linux/macOS 走 pwsh,两个都没有则静默 exit 0——"没有解释器"从错误降级成了 no-op。改完之后 CLAUDE.md 里那条 Linux 手动读取规则也同步改成了"需要 PATH 上有 pwsh,没有才回退手动"。
顺带说明这套配置一直依赖 Git Bash:命令里的 "$CLAUDE_PROJECT_DIR/..." 是 POSIX 展开,在 PowerShell 里会展开成空——所以改造用 POSIX 语法没有引入新的平台假设。
可迁移的判断 ㉔
跨平台的钩子命令不要硬编码可执行文件名,改成运行时解析 + 找不到就静默 no-op。
三个要点:(a) 保留原平台的优先级(powershell.exe 在前,Windows 行为一字不变);(b) 找不到解释器时 exit 0 而不是让 && 链失败——"这台机器没装"不该被记成错误;(c) 但脚本自身失败仍要如实上报,所以用 exec 让退出码透传。
自查方法(第 6 章给过脚本):统计自己 transcript 里的附件分布。如果某一类附件的条数远超其它类型,那就是一个正在持续消耗上下文的问题——而且要按 matcher 频率而不是按"钩子重要性"去找元凶。
更一般的原则:"非阻塞失败"不等于"零成本失败"。 在一个上下文即预算的系统里,任何会重复产生记录的失败都是持续的成本。
6.3 修法:四个候选,为什么选第四个
| 方案 | 结果 |
|---|
| ① 加 if 条件把它限制在能跑的平台 | 走不通——if 用的是权限规则语法(匹配工具名和参数),不匹配平台 |
| ② 把 .ps1 改写成 Python / Node | 可行但过重:脚本本身没问题,实测在 pwsh 下全部正常,重写等于制造两份要同步的实现 |
| ③ 按平台分设置层(Windows 留 settings.json,Linux 写 settings.local.json) | 半可行:settings.local.json 的 hooks 和 settings.json 是合并不是覆盖,所以坏的那份还是会跑;要真生效得先从入库文件里删掉,Windows 那边就得各自重新配 |
| ④ 命令里运行时解析解释器 | 选它——四个字符串各改一次,Windows 行为一字不变,Linux 立刻生效 |
最终写法:
sh
PS="$(command -v powershell.exe || command -v pwsh || true)"; [ -z "$PS" ] && exit 0; \
exec "$PS" -NoProfile -ExecutionPolicy Bypass -File "$CLAUDE_PROJECT_DIR/.claude/hooks/X.ps1"
三处细节都是必要的:
- || true —— 两个都找不到时 command -v 返回非零,不吞掉的话整条命令就以失败告终,等于没改
- [ -z "$PS" ] && exit 0 —— 把"这台机器没装解释器"从错误降级成 no-op。这是本章的核心教训:不该被记成失败的事情就别让它失败
- exec —— 让脚本自己的退出码透传上去。降级的是"找不到解释器",不是"脚本跑挂了"——后者仍然要如实上报,否则就从"倒垃圾"变成了"藏错误"
[实测] 改完之后:sync-agents-md 在 Linux 上第一次成功触发并重新生成了 AGENTS.md,inject-dir-claude-md 正常注入了目录级 CLAUDE.md,两分多钟的密集 Edit/Bash 操作里新增 hook_non_blocking_error 为 0(同期按原速率本该产生十几条)。配置改动被 settings watcher 当场热加载,不需要重启会话。
七、超时与并发
ts
const TOOL_HOOK_EXECUTION_TIMEOUT_MS = 10 * 60 * 1000 // 10 分钟
const SESSION_END_HOOK_TIMEOUT_MS_DEFAULT = 1500 // 1.5 秒
[源码 src/utils/hooks.ts:166, 175]
工具钩子默认 10 分钟,会话结束钩子默认 1.5 秒。 差了 400 倍。
理由不难推:会话结束时用户已经在等着退出了,钩子跑太久就是卡住不让走。而工具钩子可能是在跑测试、跑构建。
SessionEnd 的超时还可以用环境变量覆盖(CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS)。
并发:第 9 章那个"用墙钟时间不是耗时之和"的注释已经说明了——同一个事件的多个钩子是并行跑的:
ts
// Emit PreToolUse summary immediately so it's visible while the tool executes.
// Use wall-clock time (not sum of individual durations) since hooks run in parallel.
八、钩子的输入:每个钩子都知道自己在哪
ts
export function createBaseHookInput(permissionMode?, sessionId?, agentInfo?) {
return {
session_id: resolvedSessionId,
transcript_path: getTranscriptPathForSession(resolvedSessionId),
cwd: getCwd(),
permission_mode: permissionMode,
agent_id: agentInfo?.agentId,
agent_type: resolvedAgentType,
}
}
[源码 src/utils/hooks.ts:301]
六个字段是所有钩子共有的。两个观察:
① transcript_path —— 钩子可以直接读整个对话记录。这是个很大的能力:一个钩子可以基于"这次会话到目前为止发生了什么"来做决定,而不只是基于当前这次工具调用。
第 2 章讲过 getTranscriptPathForSession 有个很长的注释解释它为什么要同时看 sessionProjectDir:
Without this, hooks get a transcript_path computed from originalCwd while the actual file was written to sessionProjectDir (set by switchActiveSession on resume/branch) — different directories, so the hook sees MISSING (gh-30217).
/resume 之后钩子拿到的路径指向一个不存在的文件。 一个 issue 编号。
② agent_id + agent_type ——
ts
// agent_type: subagent's type (from toolUseContext) takes precedence over
// the session's --agent flag. Hooks use agent_id presence to distinguish
// subagent calls from main-thread calls in a --agent session.
钩子靠 agent_id 是否存在来判断"这是主线程还是子 agent"。 因为在 --agent xxx 启动的会话里,主线程也有 agent_type,只有 agent_id 能区分。
九、钩子在整个系统里的位置
把前面几章串起来,钩子的干预点是这样的:
text
用户输入
↓ UserPromptSubmit ────────── 可改写输入 / 阻断
主循环开始
↓ SessionStart(首轮)
模型请求 ───────────────────── PostSampling(第 3 章,异步)
↓
工具调用
↓ PreToolUse ─────────────── 改输入 / 权限决定 / 阻断 / 注入上下文
↓ PermissionRequest ──────── 权限决定
↓ [权限系统]
↓ PermissionDenied ───────── 可要求重试
↓ [tool.call()]
↓ PostToolUse / PostToolUseFailure ── 改 MCP 输出 / 注入 / 阻断继续
↓
轮次结束
↓ Stop ───────────────────── 可以拒绝让轮次结束(塞一条继续指令)
↓ StopFailure(API 错误时)
↓
压缩
↓ PreCompact ─────────────── 可以追加压缩指令(mergeHookInstructions)
↓ PostCompact
↓
子 agent
↓ SubagentStart / SubagentStop
能改变控制流的有五个:UserPromptSubmit、PreToolUse、PostToolUse、Stop、PermissionRequest。
Stop 那个尤其强——第 3 章讲过,Stop 钩子返回阻塞错误会让主循环 continue,塞一条消息让模型接着干。这是"agent 自动接着干活"的机制来源。
而 PreCompact 能追加压缩指令 [源码 src/services/compact/compact.ts:376]:
ts
/**
* Merges user-supplied custom instructions with hook-provided instructions.
* User instructions come first; hook instructions are appended.
*/
export function mergeHookInstructions(userInstructions, hookInstructions)
一个项目可以配一个 PreCompact 钩子,让每次压缩都保留特定的东西(比如"重点保留 TypeScript 的类型改动")。第 7 章那份压缩提示词末尾就留了这个口子:
There may be additional summarization instructions provided in the included context. If so, remember to follow these instructions when creating the above summary.
十、动手复核
bash
cd claude-code-deep-dive/extracted-source
# 1. 27 个事件
sed -n '24,52p' src/entrypoints/sdk/coreTypes.ts
# 2. 四种钩子类型的完整 schema
sed -n '17,216p' src/schemas/hooks.ts
# 3. HookResult 的 18 个字段
sed -n '336,380p' src/utils/hooks.ts
# 4. 信任门与它的两个历史漏洞
sed -n '272,300p' src/utils/hooks.ts
# 5. 超时
grep -n 'TOOL_HOOK_EXECUTION_TIMEOUT_MS\s*=\|SESSION_END_HOOK_TIMEOUT_MS_DEFAULT\s*=' src/utils/hooks.ts
# 6. 输出协议(JSON or 纯文本)
sed -n '380,430p' src/utils/hooks.ts
# 7. 那条 .transform() 事故注释
grep -n -B3 -A10 'DO NOT add .transform' src/schemas/hooks.ts
# 8. 环境变量白名单(最重要的安全设计)
grep -n -A6 'allowedEnvVars' src/schemas/hooks.ts
本机侧 [实测] 自查你的钩子有没有在偷偷倒垃圾:
bash
D=~/.claude/projects/$(pwd | sed 's#/#-#g')
python3 -c "
import json, collections, glob, sys
c = collections.Counter()
for f in glob.glob(sys.argv[1] + '/*.jsonl'):
for line in open(f):
try: d = json.loads(line)
except: continue
if d.get('type') == 'attachment':
t = (d.get('attachment') or {}).get('type')
if t and t.startswith('hook_'): c[t] += 1
print(dict(c.most_common()))
" "$D"
hook_non_blocking_error 数字很大 = 有钩子在持续失败。
十一、总结
- 27 个事件,覆盖工具、权限、轮次、会话、子 agent、压缩、任务/团队、交互、环境、worktree 十组;对照 Codex 的 6 个是数量级差别
- 四种钩子类型:shell 命令、LLM prompt、完整的验证 agent、HTTP POST。钩子本身可以是一次模型调用甚至一个 agent
- if 条件用权限规则语法,复用同一套匹配器;两阶段匹配器(贵的解析做一次);不匹配就不启动进程
- 钩子能返回 18 个字段,覆盖改写输入、改写 MCP 输出、权限决定、阻断、注入上下文、代替用户交互、要求重试七类能力
- HTTP 钩子的环境变量必须显式白名单,否则解析成空串——fail-closed 的密钥防泄漏设计
- 信任门:交互模式下所有钩子都要先过信任对话框;两个历史漏洞都是"生命周期事件发生在信任决定之外"
- non_blocking_error 不阻塞工具,但每次都往上下文塞一条附件——[实测] 本仓在 Linux 上因四个 PowerShell 钩子硬编码 powershell.exe 产生了 2249 条,占全部附件的 64%;元凶按 matcher 触发频率排序(挂 Bash 的那个占三分之二),已于 2026-08-25 用运行时解析解释器修复
- 超时差 400 倍:工具钩子 10 分钟,会话结束钩子 1.5 秒
- 每个钩子都拿到 transcript_path,可以基于整个会话历史做决定;靠 agent_id 是否存在区分主线程和子 agent
- 五个事件能改变控制流:UserPromptSubmit / PreToolUse / PostToolUse / Stop / PermissionRequest;Stop 拒绝结束就是"自动接着干"的机制来源
- .transform() 不能用在配置 schema 上——回写时 JSON.stringify 会静默丢掉函数值,删掉用户的配置
下一章进入多 agent:fork 和 fresh subagent 到底差在哪,以及为什么 fork 便宜。
- 第10章-权限模型-六种模式与被外包的沙箱
- 第12章-Agent调度-fork与fresh两条路
- 第6章-附件与system-reminder-第二条注入通道 —— 那 2249 条附件的统计出处
- 第9章-工具执行链-一次调用要过多少道关 —— PreToolUse 的七种结果与权限真值表
- 第7章-上下文压缩-五层防线 —— PreCompact 追加压缩指令的落点
- Codex 教程第 13 章 —— 6 个钩子事件的对照