返回 CodeWhale
HOOKS.md
根目录 / docs / zh_hans / HOOKS.md
1 # 钩子(Hooks)
2
3 > 英文原文:[HOOKS.md](../HOOKS.md)。
4 > 最后与英文同步日期(last synced with English revision):2026-09-29。
5
6 Hooks 会在 Codewhale **TUI** 到达生命周期节点时运行一条 shell 命令。它们是普通进程:通过环境变量接收上下文,其中一些会在 stdin 上收到 JSON 载荷,还有三个可以引导 Codewhale 接下来做什么。
7
8 本页是当前已实现内容的权威参考。与 `config.toml` 其余部分重叠的配置语法见 [CONFIGURATION.md](CONFIGURATION.md);本文件是逐事件的约定说明。
9
10 ## 适用范围
11
12 Hooks 会在交互式 TUI 和引擎回合循环中触发;Runtime API 也把这个回合循环放在桌面应用和 web 的背后驱动。
13
14 | 界面 | 是否触发 hooks |
15 | --- | --- |
16 | `codewhale` / `codew` 交互式 TUI | 是 |
17 | `codewhale exec`(无头一次性执行) | 需主动开启:`--hooks` 会触发 `tool_call_before` 和 `shell_env` |
18 | Runtime API 线程(桌面应用、web) | 是:`tool_call_before`、`shell_env`、`tool_call_after`、`on_error`;`GET /v1/hooks` 列出这一组 |
19 | `codewhale` CLI 分发器及其子命令 | 否 |
20 | app-server / ACP | 否 |
21 | `workflow` 工具和子智能体 *内部机制* | 否——但 TUI 会在它们周围触发 `subagent_spawn` / `subagent_complete` |
22 | 公共 API | 不存在 |
23
24 本仓库中的 `crates/hooks` event-sink crate 是一个无关的内部机制。它与这里描述的 hooks 不共享任何配置、事件名称或契约。
25
26 ### `codewhale exec --hooks`
27
28 无头运行默认不触发任何 hook——CI 任务不应仅仅因为存在某份配置,就开始呼叫值班轮换。`codewhale exec --hooks` 让这次运行主动开启 hooks。引擎侧的事件是 `tool_call_before`(退出码 2 仍会拒绝该调用;`ask` 按失败即关闭(fail-closed)处理,因为无头模式下没有任何东西可以弹出提示)和 `shell_env`。由 UI 驱动的事件,例如 `session_start`、`message_submit` 和 `turn_end`,不会触发——它们属于交互式外壳,而不是回合循环。Fleet worker 子进程从不触发操作员 hooks。与这个标志无关,`permissions.toml` 中有类型的规则本来就适用于 `exec`——这次运行驱动的是同一个回合循环,而 `deny` 在每种模式下都会拦截。
29
30 ## 快速开始
31
32 ```toml
33 # ~/.codewhale/config.toml
34 [hooks]
35 enabled = true
36
37 [[hooks.hooks]]
38 name = "announce"
39 event = "session_start"
40 command = "echo 'Codewhale session started'"
41 ```
42
43 在 TUI 中运行 `/hooks` 可以列出已配置的内容、全局开关是否开启,以及任何在加载时被拒绝的条目。运行 `/hooks events` 可查看事件名称。
44
45 ## 配置
46
47 ```toml
48 [hooks]
49 enabled = true # 全局开关;false 会抑制所有 hook
50 default_timeout_secs = 30 # 见下面的超时说明
51 working_dir = "/path/to/dir" # 默认:会话工作区
52
53 [[hooks.hooks]]
54 event = "tool_call_before" # 必填;下面 15 个名称之一
55 command = "~/.codewhale/hooks/gate.sh" # 必填;Unix 上是 `sh -c`,Windows 上是 `cmd /C`
56 name = "gate" # 可选;/hooks 和日志行中的标签
57 timeout_secs = 30 # 可选,默认 30
58 background = false # 可选;在 hook worker 内前台运行
59 continue_on_error = true # 可选,默认 true
60 condition = { type = "tool_name", name = "bash" } # 可选
61 ```
62
63 `timeout_secs` 说明(按实现陈述):当设置了 `[hooks].default_timeout_secs` 时,它会**覆盖**每个 hook 自己的 `timeout_secs`,而不仅仅是给省略该项的 hook 提供默认值。如果你希望各 hook 各自的超时生效,请保持不设置它。`/hooks list` 会显示运行时实际应用的超时,并在有覆盖生效时指明该覆盖。
64
65 `default_timeout_secs = 0` 会在**加载时被拒绝**。由于该值会替换每个 hook 自己的 `timeout_secs`,这里的零会让配置中的每个 hook 立即超时——包括 `tool_call_before` 门,从而拒绝每个匹配的工具调用。该覆盖会被忽略,各 hook 自己的 `timeout_secs` 生效,hooks 本身仍然会加载,拒绝情况由 `/hooks list` 在 *configuration problems* 下列出。每个 hook 自己的 `timeout_secs = 0` 也会被拒绝,但那只会丢弃写出它的那一个 hook。
66
67 Hooks 以工作区(或 `working_dir`)作为当前目录运行。
68
69 ### 超时
70
71 超时对**前台和后台 hooks 一视同仁**。超时发生时:
72
73 - hook 的整个进程组会被杀死——Unix 进程组、Windows Job Objects——因此会派生子进程的 hook 不会活过它的预算;
74 - 子进程随后被回收,所以通常不会留下脱离或僵死的进程;
75 - 前台 hook 的结果为 `success = false`、`exit_code = None`、空的 `stdout`/`stderr`,以及 `error = "Hook timed out after Ns"`;
76 - 后台 hook 的超时会在 `hooks` 目标下以 `warn` 级别记录日志。不会向调用方报告任何内容,因为调用方在提交 hook 的那一刻就已停止等待。
77
78 **终止是尽力而为的,且被保证的边界是 Codewhale 的,而非操作系统的。** kill 可能无法落地——Unix 上进程卡在不可中断状态,Windows 上受保护进程能扛过 `TerminateJobObject`——任何用户态程序都无法承诺更多。Codewhale 保证的是它停止等待:释放 containment handle(这会重新向 Unix 进程组发信号,并关闭随关闭即杀的 Windows Job Object),回收只有一个短暂的有界窗口。如果子进程仍无法确认已死,会以 `warn` 级别记录,前台结果也会如实说明——`error = "hook could not be reaped after its timeout"` 而不是更强的超时措辞。因此,超时的 hook 永远不会阻塞回合,但请把"已杀死"视为尽力而为,而非绝对保证。
79
80 ### 后台 hooks
81
82 `background = true` 描述的是真实的调度,而不只是一个配置标志。后台 hook 是**提交后绝不等待**的:
83
84 - 它以非阻塞方式进入固定的 32 项 supervisor 队列,由两个持续运行的 worker 消费并应用上述超时;队列饱和或 supervisor 丢失是一次失败的提交,任何一次调用都不会创建自己独立的分离 supervisor 线程;
85 - 它收到与该事件前台形式相同的环境变量和相同的 stdin JSON 载荷——载荷契约不变,变的只是引导能力;
86 - 它的 stdout 和 stderr 会被丢弃(`Stdio::null()`),因此它永远无法返回判定;
87 - 运行时交给调用方的 `HookResult` 会被标记为后台提交,且不携带退出码。引导代码读取 `observed_exit_code()`,对后台 hook 而言它是 `None`,因此后台 hook 永远无法 allow、deny、ask 或改写任何内容。
88
89 `shell_env` 完全忽略 `background`——它的 stdout *就是*契约,所以它总是前台运行。`/hooks list` 会将其报告为配置警告,并且不把该 hook 标注为 `[bg]`。
90
91 仅观察的 UI 事件通过非阻塞 `try_send` 提交到一个 32 项队列,由两个持续运行的 worker 消费。已配置的前台观察者仍会在某个 worker 内按配置顺序被等待,但终端事件循环从不等待它的进程,也从不按事件创建线程。队列饱和或分发器丢失会丢弃该观察者事件,并产生一个事件专属的错误 toast,不会被智能体普通的进度状态更新覆盖。引导事件保留其门或变换语义:fresh/queued `message_submit` 分发通过有界结果通道报告,同回合的引导在调用引擎引导路径之前于阻塞 worker 上执行变换,而 `tool_call_before` / `shell_env` 在引擎或工具 worker 上执行,而非终端事件循环。
92
93 ### hook 进程环境
94
95 hook 命令继承 Codewhale 进程的环境,外加该事件对应的 `DEEPSEEK_*` 变量。Codewhale 不会过滤这种继承,所以请像对待你在启动 Codewhale 的同一个 shell 中键入的任何命令那样对待 hook:那里导出的任何内容对它都可见。
96
97 `shell_env` hook 提供的命令则*不是*这样——参见 [`shell_env`](#shell_env) 中管辖**本地** `exec_shell` 的有界 allowlist,以及改配置为外部 sandbox 后端时会发生什么变化(后端拥有自己的基础环境,你的 `shell_env` 值会被传输给它)。
98
99 ### 条件
100
101 | 条件 | 匹配 | 支持于 |
102 | --- | --- | --- |
103 | `{ type = "always" }` | 每次调用(省略时的默认值也是它) | 每个事件 |
104 | `{ type = "tool_name", name = "bash" }` | 精确工具名;支持 `*` 通配,例如 `mcp__*`。shell 工具的写法 `bash`、`Bash` 和 `exec_shell` 互为别名:条件中写其中任何一个,都会匹配这三者 | `tool_call_before`、`tool_call_after`、`shell_env`、`on_error` |
105 | `{ type = "tool_category", category = "shell" }` | 工具类别 | `tool_call_before`、`tool_call_after`、`shell_env`、`on_error` |
106 | `{ type = "mode", mode = "plan" }` | 上下文的模式字符串,不区分大小写 | 除 `shell_env` 外的每个事件 |
107 | `{ type = "exit_code", code = 1 }` | 工具实际报告的退出码 | `tool_call_after`、`on_error` |
108 | `{ type = "all", conditions = [...] }` | 每个嵌套条件 | 每个事件 |
109 | `{ type = "any", conditions = [...] }` | 至少一个嵌套条件 | 每个事件 |
110
111 以下三条规则避免条件给出误导性的匹配结果:
112
113 - **`exit_code` 需要真实的退出码。** 它只在事件确实观察到进程退出码时匹配——`tool_call_after`,或工具失败时的 `on_error`,两种情况都针对 `bash` 这类由进程支撑的工具。以非零码退出的命令同样会报告它的退出码,尽管 `bash` 会把它作为一次失败的调用返回。超时或被杀死的命令通常没有退出码;`DEEPSEEK_TOOL_STATUS` 会说明是哪一种。不报告退出码的工具永远不会匹配 `exit_code` 条件;默认值、零或成功标志都不能满足该条件。该值是 64 位整数,因此 `3221225477`(`0xC0000005`)这样的 Windows 崩溃码也可以匹配。
114 - **支持工具作用域的 `on_error` hooks。** `on_error` 会因传输和容量错误*以及*工具失败而触发;工具失败的触发会携带工具名、调用 id、结果和报告的退出码。因此,`on_error` 上的 `tool_name` / `tool_category` / `exit_code` 条件是有效的配置。背后没有工具的 `on_error` 触发只是不匹配这样的条件——它在分发时被跳过,而不是在加载时被拒绝。
115 - **不支持的条件会在加载时被拒绝。** 引用其事件永远不会携带的上下文的条件永远无法匹配,带有这种条件的 hook 会静默失效——最危险的情形,是操作员以为已生效的 `deny` 拦截门实际上根本没有起作用。Codewhale 会在加载时丢弃这些 hooks,在 `hooks` tracing 目标下记录原因,并在 `/hooks list` 中显示为 `rejected:`。`all` / `any` 内的嵌套谓词也会被检查。带 `timeout_secs = 0` 或空 `command` 的 hook 也会以同样的方式被拒绝。拒绝是**逐条**的:一个坏 hook 永远不会连累另一个,即使两者共享同一个 `name` 或都未命名。
116
117 ### 项目本地 hooks
118
119 仓库可以附带 `<workspace>/.codewhale/hooks.toml`,使用相同的结构,但只有它的 `[[hooks]]` 条目会被合并——项目文件不能更改 `enabled`、`default_timeout_secs` 或 `working_dir`,这些始终来自你自己的配置。由于 hooks 是可执行配置,项目 hooks **只有**在工作区受信任、并且在用户自有配置中对该 hooks 文件的确切内容另行批准之后才会加载。用 `/hooks review` 检查其中的命令和摘要(digest),再用 `/hooks approve <digest>` 让这些字节在下一个会话生效。命令所调用的脚本也请一并审阅。文件一旦改动,就需要重新批准。`/hooks revoke` 会阻止之后以及已排队的启动;它不会停止已经在运行的命令。仅靠会话内的 `/trust on` 不会启用项目 hooks。已批准的项目 hooks 会追加在全局 hooks 之后,因此它们最后运行,并在 `updatedInput` 平局时胜出。格式错误的受信任项目文件会记录一条警告,Codewhale 只回退到全局 hooks。校验针对合并后的集合运行,因此被拒绝的项目 hook 与被拒绝的全局 hook 报告方式相同。
120
121 ## 15 个事件
122
123 | 事件 | 触发时机 | 引导 |
124 | --- | --- | --- |
125 | `session_start` | 一次,引擎就绪后、首次绘制前 | observer |
126 | `session_end` | 一次,优雅关闭时 | observer |
127 | `turn_end` | 回合完成且回合后状态更新后 | observer |
128 | `message_submit` | 在提交的消息到达历史或模型之前 | **可以替换或阻止文本** |
129 | `tool_call_before` | 每次工具调用执行之前 | **可以 allow / deny / ask、改写输入、添加上下文** |
130 | `tool_call_after` | 每个工具结果落定后,包括 transcript 不重绘的完成 | observer |
131 | `mode_change` | 每次应用的 Plan/Work/Operate 转换(`Act` 是 Work 的兼容别名) | observer |
132 | `on_error` | 传输、容量和认证错误,以及工具失败时 | observer |
133 | `subagent_spawn` | 子智能体启动时 | observer |
134 | `subagent_complete` | 子智能体完成、失败或被取消时 | observer |
135 | `shell_env` | 每次 `exec_shell` 调用之前 | **贡献环境变量** |
136 | `session_idle` | 会话在一个回合或一次等待之后回到空闲——没有未决的提示、审批或续跑 | observer |
137 | `session_error` | 一个回合以终止性失败结束时;智能体自行消化的瞬时工具失败不会触发它 | observer |
138 | `waiting_for_user` | 智能体开始等你时:审批提示打开、呈现了一个 `request_user_input` 问题,或者目标续跑在两次执行之间被挂起 | observer |
139 | `session_busy` | 空闲或等待中的会话开始或恢复工作时;启动时以及对同一状态的重复观察都保持静默 | observer |
140
141 `waiting_for_user` 的载荷带有 `reason`:`approval`、`user_input` 或 `goal_continuation`。三个状态事件都带有 `from`/`to` 转换字段;`session_idle` 在已知时还带有 `last_turn_status`,`session_error` 带有有界的终止性 `error` 文本。busy、idle 和 waiting 对应控制套接字的 `status` 动词已经发布的会话状态(`idle` / `in_progress` / `waiting`),因此 hook 与 supervisor 永远不会对会话正在做什么产生分歧。想要 opencode 那种针对错误告警的宽限期语义的 hook 作者,应当在 hook 内部去抖动——`session_error` 已经排除了被消化的瞬时失败,而一个失败后被操作员重试的回合,只有在重试同样以失败结束时才会再次触发。
142
143 ### “observer”到底意味着什么
144
145 Observer 意味着 Codewhale 会忽略 hook 的**结果**:stdout 被丢弃,非零退出被记录为警告,回合、工具结果、子智能体或错误都不会因它而改变。
146
147 Observer 并**不**意味着无副作用。observer hook 是以你的凭据运行的任意 shell 命令。它可以写文件、推送提交、呼叫值班轮换,或删除工作区。它唯一做不到的是改变 Codewhale 自己接下来要做的事。
148
149 引导 allowlist 恰好是三个事件——`message_submit`、`tool_call_before`、`shell_env`——并且由一个覆盖每个变体的测试断言,因此新事件默认是 observer。
150
151 ### 会话身份
152
153 同一个 TUI 会话中的每个事件携带相同的 `DEEPSEEK_SESSION_ID`。该 id 在启动时生成一次,形式为 `sess_xxxxxxxx`,并且能挺过工作区切换和添加项目 hooks 的信任决策——两者都会重新加载 hook 集,而不会开始新会话。引擎触发的 `tool_call_before` 与 UI 触发的事件报告相同的 id,因此工具记录可以与周围的会话记录关联。
154
155 `session_end` 在排队的启动默认写入被排空后、应用仍然存活时触发,因此它观察到的是落定的结束状态,而不是半拆除的状态。
156
157 ## 环境变量
158
159 每个 hook 都会收到这些变量中适用于其事件的那一部分。`DEEPSEEK_` 前缀为兼容改版前编写的 hooks 而保留。
160
161 | 变量 | 设置于 | 说明 |
162 | --- | --- | --- |
163 | `DEEPSEEK_SESSION_ID` | 除 `shell_env` 外的每个事件 | `sess_xxxxxxxx`,整个会话保持稳定 |
164 | `DEEPSEEK_WORKSPACE` | 除 `shell_env` 外的每个事件 | 工作区绝对路径 |
165 | `DEEPSEEK_MODEL` | 除 `shell_env` 外的每个事件 | 当前生效的模型 id |
166 | `DEEPSEEK_MODE` | 除 `shell_env` 外的每个事件 | 见下面的模式拼写说明 |
167 | `DEEPSEEK_TOTAL_TOKENS` | UI 触发的事件 | 触发时的会话 token 总量 |
168 | `DEEPSEEK_MESSAGE` | `message_submit`、`subagent_*` | 截断至 5 000 字节并带 `...[truncated]` 标记 |
169 | `DEEPSEEK_ERROR` | `on_error` | 错误消息,截断至 5 000 字节 |
170 | `DEEPSEEK_PREVIOUS_MODE` | `mode_change` | 变更前的模式标签 |
171 | `DEEPSEEK_TOOL_NAME` | `tool_call_before`、`tool_call_after`、`shell_env`、`on_error`(工具失败) | |
172 | `DEEPSEEK_TOOL_CALL_ID` | `tool_call_before`、`tool_call_after`、`on_error`(工具失败) | 引擎调用 id;关联一次调用的 before/after/error |
173 | `DEEPSEEK_TOOL_ARGS` | `tool_call_before`、`shell_env` | 工具输入 JSON 预览,上限 10 000 字节 |
174 | `DEEPSEEK_TOOL_RESULT` | `tool_call_after`、`on_error`(工具失败) | 截断至 10 000 字节 |
175 | `DEEPSEEK_TOOL_SUCCESS` | `tool_call_after`、`on_error`(工具失败) | `true` / `false` |
176 | `DEEPSEEK_TOOL_EXIT_CODE` | `tool_call_after` 和 `on_error` **当工具报告了退出码时** | 否则不存在——绝不合成;命令失败时同样设置;64 位,因此 `3221225477` 这样的 Windows 崩溃码能完好保留 |
177 | `DEEPSEEK_TOOL_STATUS` | `tool_call_after` 和 `on_error` **当 shell 工具报告了状态时** | `completed`、`failed`、`timed_out`、`killed` 或 `running`(已转入后台);其他工具不存在 |
178 | `DEEPSEEK_TOOL_EXECUTION_RECEIPT` | `tool_call_after` 和 `on_error` **仅限已结束的本地前台 shell 运行** | 完整 JSON,最多 32 KiB,否则不存在;见下方“执行回执” |
179 | `DEEPSEEK_SESSION_COST` | 提供成本时 | USD,六位小数 |
180
181 ### 执行回执
182
183 `DEEPSEEK_TOOL_EXECUTION_RECEIPT` 说明 shell 工具(`bash`、`Bash`、`exec_shell`)实际运行了什么。before-hook 的输入不等于实际执行的内容:`tool_call_before` hook 可以改写它。回执取自进程管理器在准入与改写之后启动进程时记录的内容。
184
185 ```json
186 {"schema_version":1,"command":"printf hello","cwd":"/absolute/workspace","state":"completed","scope":"local","exit_code":0,"stdout":"hello","stderr":"","stdout_truncated":false,"stderr_truncated":false,"output_kind":"separate"}
187 ```
188
189 | 字段 | 含义 |
190 | --- | --- |
191 | `command` | 交给 shell 的已准入命令源码,而不是 shell 可执行文件或其 argv 包装 |
192 | `cwd` | 进程启动时所在目录的规范绝对路径:解析符号链接,因此无论调用是否传入 `cwd`,同一目录只有一种写法;在启动前解析,并将同一路径交给操作系统 |
193 | `state` | 观察到退出(包括非零退出)为 `completed`;信号、kill、取消或超时为 `interrupted` |
194 | `scope` | schema 1 中始终为 `local` |
195 | `exit_code` | 观察到的整数,或 `null`;绝不根据 `state` 合成 |
196 | `stdout`、`stderr` | 工具已保留输出的预览,其中可能已经缺少进程输出的开头;过长的预览保留自身的首尾字节,中间以 `[receipt preview truncated]` 标记 |
197 | `stdout_truncated`、`stderr_truncated` | 工具自身的输出捕获或预览丢弃了字节时为 `true` |
198 | `output_kind` | `Bash` / `exec_shell` 为 `separate`;小写 `bash` 的 stdout 与 stderr 共用一个管道,为 `combined`——此时 `stdout` 是合并后的预览,`stderr` 为空 |
199
200 规则是保守的:
201
202 - **要么精确,要么不存在。** `command` 和 `cwd` 绝不截断。任一超过 8 KiB、包含 NUL,或目录是相对路径、非 UTF-8 或在启动前无法解析时,不导出回执。shell 工具无法观察到运行如何结束(操作系统的 wait 调用本身失败)时同样不导出:其状态未知,回执不做猜测。预览会缩短直到序列化后的 JSON 不超过 32 KiB;仍然放不下时不导出回执,而不是截断。
203 - **不存在不代表任何结果。** 既不意味着成功,也不意味着失败。应用当前调用的上下文前,会清除继承的 `DEEPSEEK_TOOL_EXECUTION_RECEIPT`。
204 - **范围。** 只有配置了 `tool_call_after` 或 `on_error` hook 时才会生成回执,且仅限已结束、基于管道、未沙箱化的本地前台运行。后台启动、转入 `/jobs` 的前台运行、PTY(`tty` / `combined_output`)与交互会话、OS 沙箱与外部后端执行、只读 shell 的加固 argv、Windows、任何平台上的 PowerShell(它会包装源码或通过临时脚本运行),以及执行前就被拒绝的调用都没有回执。
205 - **仅供 hook 使用。** 回执不写入持久化的 Runtime API 条目记录;该记录已包含工具输出。
206 - 失败的运行与成功的运行同样设置,因此 shell 调用失败时的 `on_error` 也带有它。其他变量均不变。
207
208 对于 `tool_call_after`,同一份执行证据还会作为带版本号的 JSON 文档通过 stdin 传递,前台和后台 hook 均会收到:
209
210 ```json
211 {"schema_version":1,"event":"tool_call_after","tool_name":"bash","session_id":"session-id","tool_call_id":"call-id","session_id_truncated":false,"tool_call_id_truncated":false,"tool_name_truncated":false,"execution_receipt":{"schema_version":1,"command":"printf hello","cwd":"/absolute/workspace","command_truncated":false,"cwd_truncated":false,"execution":"started","completion":"completed","exit_code":0,"stdout":"hello","stderr":"","stdout_truncated":false,"stderr_truncated":false,"output_mode":"combined"}}
212 ```
213
214 stdin 载荷不会改变上述环境变量中的回执。`completion` 表示观察到的终态:`completed`、`failed`、`killed` 或 `timed_out`;非零退出对应 `failed`。`exit_code` 保持为有符号 64 位整数或 `null`。`output_mode` 沿用现有回执的 `output_kind`,值为 `separate` 或 `combined`。输出合并时,空的 `stderr` 并不表示命令没有向 stderr 写入内容。
215
216 完整文档的上限为 64 KiB。关联标识符各自最多保留 1,024 个 UTF-8 字节,另加截断标记,并携带各自的截断标志;缺失的标识符为 `null`。shell 名称保持精确。执行的 `command` 和 `cwd` 仍遵守上述“要么精确,要么不存在”的规则,因此它们的截断标志始终为 `false`。输出截断标志同时反映捕获阶段与预览阶段丢弃的内容。
217
218 只有带有有效且已落定回执的原生 shell 调用才会生成这份 stdin 文档。不支持或未观察到结果的路径不会生成文档;缺失仍表示未知。hook 仍然只观察结果:其 stdout 不能允许、拒绝或改写已完成的调用,后台 hook 也不会被等待。`on_error` 继续只接收环境变量中的回执。
219
220 **模式拼写说明。** UI 触发的事件(`session_start`、`session_end`、`message_submit`、`tool_call_after`、`mode_change`、`on_error`、`turn_end`、`subagent_*`、`session_busy`、`session_idle`、`session_error`、`waiting_for_user`)会将 `DEEPSEEK_MODE` 设为 UI 标签——`ACT`、`PLAN`、`OPERATE`。`tool_call_before` 在引擎内部触发,并使用引擎自己的模式拼写(`Agent`、`Plan`、`Operate`)。`mode` 条件不区分大小写比较,因此 `{ type = "mode", mode = "plan" }` 两者都能匹配,但精确字符串匹配 `$DEEPSEEK_MODE` 的 hook 应同时接受两种拼写。
221
222 **`shell_env` 是受限的那个。** 它只接收 `DEEPSEEK_TOOL_NAME` 和 `DEEPSEEK_TOOL_ARGS`——没有会话 id、工作区、模型或模式。因此,`shell_env` hook 上的 `{ type = "mode", … }` 条件会在加载时被拒绝;请改用 `tool_name` 或 `tool_category` 来限定作用域。
223
224 ## 引导事件
225
226 ### `message_submit`
227
228 在 stdin 上接收 JSON,并可能改写或阻止提交的文本。
229
230 ```json
231 {
232 "event": "message_submit",
233 "text": "original user text",
234 "text_bytes": 18,
235 "text_original_bytes": 18,
236 "text_truncated": false,
237 "session_id": "sess_12345678",
238 "workspace": "/path/to/workspace",
239 "mode": "ACT",
240 "model": "deepseek-chat",
241 "total_tokens": 1234
242 }
243 ```
244
245 完整的序列化 stdin 文档上限为 32 KiB。`text` 是在包含 JSON 转义和有界元数据后能容纳的最大确定性 UTF-8 前缀。`text_original_bytes` 记录生产者的完整字节长度,`text_bytes` 记录保留的前缀,`text_truncated` 说明两者是否不同。同样的边界适用于即时输入、恢复的队列条目、合并的引导,以及先前 hook 产生的文本。
246
247 - 以退出码 `0` 打印带非空字符串的 `{"text": "..."}` 会替换文本
248 - 退出码 `0` 但 stdout 为空,或 JSON 中没有 `text`,文本保持不变
249 - `{"text": ""}` 或超过 32 000 字符的替换是无效 stdout,会被记录并忽略
250 - 退出码 `2` 会在进入历史或分发之前阻止提交;结构化的 `reason` 字段提供一条有界、脱敏的消息显示在 TUI 中。非结构化的 stdout/stderr/error 输出绝不会被复制进拒绝信息
251 - 其他非零退出遵循 `continue_on_error`:`true` 警告并继续,`false` 阻止提交
252 - `background = true` 使 hook 仅观察——它仍然会在 stdin 上收到这个有界载荷,但无法变换或阻止
253
254 多个 `message_submit` hooks 按配置顺序运行,每个都会看到前一个 hook 的输出。
255
256 ### `tool_call_before`
257
258 通过环境变量接收工具上下文,并可以退出码 `0` 在 stdout 上打印 JSON 判定:
259
260 ```json
261 {
262 "decision": "allow",
263 "reason": "human-readable explanation, used for deny",
264 "updatedInput": { "command": "ls -la" },
265 "additionalContext": "text appended to the tool result for the model"
266 }
267 ```
268
269 - `deny` 阻止该工具;模型会收到携带 `reason` 的权限拒绝结果
270 - `ask` 在 Ask 和 Auto-Review 中强制交互式审批提示。Full Access 不会打开工具审批提示,因此 `ask` 不会降级它
271 - `updatedInput` 必须是序列化后不超过 32 KiB 的对象,并替换工具输入;最后一个 hook 胜出
272 - `additionalContext` 以 `[hook context] ...` 追加到工具结果;多个 hooks 会拼接
273 - `reason` 和 `additionalContext` 在使用前有界并净化:每个字段上限 2 000 字符,一次工具调用拼接后的上下文上限 8 000,控制字符会被剥离(因此 hook stdout 无法重绘 TUI 或在 transcript 中伪造结构),被截断的值携带 `…[truncated]` 标记。因此,无论 hook 打印什么,它为回合上下文预算贡献的内容都是有界的
274 - 退出码 `2` 是遗留的硬拒绝,无论 stdout 是什么都胜出
275 - 空 stdout、非 JSON stdout 以及没有 `decision` 的 JSON 都意味着 allow
276 - 匹配 hooks 之间的优先级:无判定且 `continue_on_error = false` > deny > ask > allow
277 - `background = true` 的 hooks 会被提交且从不等待,因此它们没有判定,也无法引导;Codewhale 在为此事件配置了这样的 hook 时会记录一条警告
278
279 **无法作答的门不是许可。** 如果前台 `tool_call_before` hook 没有产生判定——它超时了、进程无法启动,或严格进程在没有显式 JSON 判定的情况下以非零退出——并且*那个 hook* 配置了 `continue_on_error = false`,则该工具调用会被拒绝。严格性从实际运行的 hook 读取,而非从事件读取:条件未匹配 `exec_shell` 调用的严格 `write_file` 门,对该调用是否继续没有发言权;宽容 hook 的超时也绝不会仅仅因为配置中存在其他严格 hook 就拒绝。无论哪种情况,每个无判定结果都会被记录。
280
281 拒绝消息只指名 hook 和原因,别无其他:hook 名被截断,细节被截断,控制字符被剥离,spawn 失败按错误种类(`NotFound`、`PermissionDenied`、…)报告,而不是回显命令行或解析后的解释器路径。
282
283 ### `shell_env`
284
285 在每次 `exec_shell` 之前同步运行,其 stdout 被解析为 `KEY=VALUE` 行。开头的 `export ` 会被剥离,`#` 注释行和空行会被跳过,值周围成对的单引号或双引号会被移除。后运行的 hooks 覆盖先运行的。用它来处理临时凭据、按 skill 调整 `PATH`,或短命 token。
286
287 `background` 对此事件被忽略:hook 总是前台运行,因为它的 stdout 就是契约。
288
289 shell 无法承载的条目会被丢弃,而不是放任其破坏工具调用:空名称;含空白、`=`、控制字符或 NUL 的名称;含 NUL 的值;超过 32 KiB 的值;以及单个 hook 累计输出超过 256 KiB 的任何内容。每次丢弃只按键名记录日志。`shell_env` hook 是普通进程,其 stdout 可以包含任何内容——"hook 打印了奇怪的东西"绝不能变成"`exec_shell` 调用中止了"。
290
291 **shell 命令最终确切得到什么——本地执行。** 当 `exec_shell` 在本地运行命令(默认情况)时,它不继承 Codewhale 的环境。它的环境按如下方式构建:
292
293 1. 一份净化的固定父变量 allowlist——`PATH`、`HOME`、`USER`、`LANG` 和其他 `LC_*`/locale 条目、`TERM`、`SHELL`、`TMPDIR`、`proxy` 相关变量、`NO_COLOR` 这类颜色/终端条目、`CARGO_HOME`/`RUSTUP_HOME`/`RUSTUP_TOOLCHAIN`、Windows 系统与 MSVC 工具链条目,以及其他平台相关的键(完整列表在 `crates/tui/src/child_env.rs`)——仅此而已。allowlist 之外的变量,包括任何看起来像密钥的内容,都会被丢弃;
294 2. 然后,你的 `shell_env` hooks 产生的 `KEY=VALUE` 对叠加应用在上面。这些是你配置的显式值,因此它们胜过 allowlist。
295
296 因此,`shell_env` hook 是把凭据送进一次本地 `exec_shell` 调用的受支持方式。启动 Codewhale 的终端中导出的环境变量里的密钥**不会**自行转发给本地 `exec_shell`。
297
298 **配置了外部 sandbox 后端时,上面的 allowlist 不是契约。** 如果 `exec_shell` 被路由到已配置的 sandbox/执行后端,Codewhale 根本不会构建进程环境:它把命令和你的 `shell_env` 值作为额外环境变量交给后端,**后端拥有自己的基础环境**。除了你的值之外还存在什么——镜像内置的变量、后端自己的注入、远程 runner 导出的任何内容——由该后端决定,而非由上面的列表决定。不要假定本地 allowlist 在那里适用。
299
300 披露说明,因为这对发出凭据的 hook 才是关键部分:**`shell_env` 值会被传输到已配置的后端。** 对远程或容器化后端而言,这意味着这些值会离开本机,并受该后端的日志记录、保留和访问控制约束。Codewhale 自己的审计日志仍然只记录键名,但这并不能说明后端会对这些值做什么。如果 `shell_env` hook 会输出密钥,请将其限定在你信任、可以托付该密钥的后端上——例如给 hook 加条件,或在这些 hooks 生效的会话中不配置外部后端。
301
302 解析出的**键名——绝不是值**——会写入 `~/.codewhale/audit.log`,以便事后对会话进行核对。失败或超时的 hook 不贡献任何变量,也不会中止 shell 调用。
303
304 ```toml
305 [[hooks.hooks]]
306 name = "aws-creds"
307 event = "shell_env"
308 command = "aws-vault export my-profile --format=env"
309 condition = { type = "tool_category", category = "shell" }
310 ```
311
312 ## 结构化 observer 载荷
313
314 `turn_end`、`subagent_spawn`、`subagent_complete`、`session_busy`、`session_idle`、`session_error` 和 `waiting_for_user` 除了环境变量外,还会在 stdin 上接收 JSON。它们的 stdout 被忽略。这些事件的后台形式会在 stdin 上收到相同的载荷。
315
316 `tool_call_after` 在原生 shell 调用结束且具有已记录的[执行回执](#执行回执)时,也会通过 stdin 接收 JSON,前台和后台形式均如此。其他工具调用没有 stdin 文档。
317
318 其余 observer 事件——`session_start`、`session_end`、`mode_change`、`on_error`——无论前台还是后台形式,都只接收环境变量,没有 stdin 载荷。
319
320 ### 会话状态转换
321
322 第一个观察到的状态会被静默记录,无论是 idle、busy 还是 waiting。重复同一状态不会发出任何事件。对于一个中途暂停等待用户输入、然后完成的回合,转换类 hooks 会按提交顺序收到这些载荷:
323
324 | 事件 | stdin JSON |
325 | --- | --- |
326 | `session_busy` | `{"from":"idle","to":"in_progress"}` |
327 | `waiting_for_user` | `{"from":"in_progress","to":"waiting","reason":"user_input"}` |
328 | `session_busy` | `{"from":"waiting","to":"in_progress"}` |
329 | `session_idle` | `{"from":"in_progress","to":"idle","last_turn_status":"completed"}` |
330
331 分发器有两个 worker,因此不保证命令的完成顺序。`session_error` 是一个独立的终止性失败事件,带的是 `status` 和 `error` 字段,而不是 `from` 和 `to`。
332
333 ### `turn_end`
334
335 在回合后状态、用量总计、成本核算、通知、回执和队列恢复都已更新之后、排队的后续分发之前触发——这样载荷可以报告排队数量,而 hook 无法改变接下来要发送的内容。
336
337 ```json
338 {
339 "event": "turn_end",
340 "session_id": "sess_12345678",
341 "workspace": "/path/to/workspace",
342 "mode": "ACT",
343 "created_at": "2026-07-12T10:30:00+00:00",
344 "model_backed": true,
345 "provider": "deepseek",
346 "billing_surface": null,
347 "model": "deepseek-chat",
348 "turn_id": "turn_12345678",
349 "status": "completed",
350 "error": null,
351 "duration_ms": 1834,
352 "usage": {
353 "input_tokens": 1200,
354 "output_tokens": 180,
355 "prompt_cache_hit_tokens": 900,
356 "prompt_cache_miss_tokens": 300,
357 "prompt_cache_write_tokens": 0,
358 "reasoning_tokens": null,
359 "reasoning_replay_tokens": null
360 },
361 "totals": {
362 "session_tokens": 1380,
363 "conversation_tokens": 1380,
364 "input_tokens": 1200,
365 "output_tokens": 180
366 },
367 "tool_count": 2,
368 "queued_message_count": 1,
369 "stop_hook_active": false
370 }
371 ```
372
373 `created_at` 锚定时间窗口定价。`provider` 和 `model` 标识模型支撑回合的有效路由。`billing_surface` 是对服务该回合的端点的一种可选、不含敏感信息的分类(已识别的 StepFun 路由会发出 `stepfun-payg` 或 `stepfun-plan`);原始 base URL 永远不会写入 hook 记录。仅 shell、手动压缩和 purge 完成没有对应的 `TurnStarted`,因此它们报告 `model_backed: false`、`null` provider 和合成的 `lifecycle_<uuid>` 回合 id。`stop_hook_active` 目前始终为 `false`;它为防重入保护预留了空间。
374
375 ### `subagent_spawn` / `subagent_complete`
376
377 ```json
378 {
379 "event": "subagent_complete",
380 "agent_id": "agent_1",
381 "session_id": "sess_12345678",
382 "workspace": "/path/to/workspace",
383 "mode": "ACT",
384 "model": "deepseek-chat",
385 "total_tokens": 1234,
386 "result_preview": "bounded preview of the result",
387 "result_truncated": false,
388 "status": "completed"
389 }
390 ```
391
392 `subagent_spawn` 改为携带 `prompt_preview` / `prompt_truncated`,且没有 `status`。两个载荷都有意设了界:预览被截断,而不是传送完整提示或结果。这些 hooks 仅观察——失败不会影响子智能体调度、提示或结果,`continue_on_error` 没有效果,因为后面匹配的 hooks 总是会运行。
393
394 ## 失败行为
395
396 - 非零退出会在 `hooks` tracing 目标下以 `warn` 级别记录日志,包含 hook 名、事件、退出码、时长和一个通用失败类别。原始 stdout/stderr/error 文本不会持久化在日志回执中。
397 - 对于 `execute` 路径的事件,`continue_on_error = false` 会停止该事件后续的 hooks;除 `tool_call_before`(见上文)外,它不会回滚触发它们的行为。
398 - 结构化 observer 事件(`turn_end`、`subagent_*`、`session_busy`、`session_idle`、`session_error`、`waiting_for_user`)总是继续到下一个匹配的 hook。
399 - Observer 事件使用有界的持久分发器。队列已满和分发器不可用的提交不会静默重试;TUI 会保留一条事件专属的错误 toast,与普通状态行分开。
400 - 超过超时的 hook,其整个进程组会被杀死,然后被回收,前台或后台皆然——尽力而为,回收等待有界;见[超时](#超时)。
401
402 ## 安全说明
403
404 - Hooks 是来自你自己配置的任意 shell 命令;请把 `~/.codewhale/config.toml` 当作可执行文件对待。
405 - 项目提供的 hooks,除了工作区信任之外,还需要在用户自有配置中对确切文件作出批准。
406 - hook 命令继承 Codewhale 自己的环境。本地 `exec_shell` 不会——见 [`shell_env`](#shell_env)。
407 - `shell_env` 审计记录只包含键名。这覆盖 Codewhale 自己的日志记录;配置了外部 sandbox 后端时,值本身会被传输到该后端,之后受其处理方式约束。
408 - 使用外部 sandbox 后端时,本地父变量 allowlist 不适用——后端拥有自己的基础环境。
409 - 载荷预览、工具参数/结果、错误消息、捕获的 stdout 和 stderr、替换消息和引导对象都有界,因此 hook 输入或输出不可能成为 transcript 的无界副本。
410 - Codewhale 在拒绝信息中持久化的任何内容都不会回显 stdin 载荷、hook 环境、原始 stdout/stderr/error、命令行或解析后的文件系统路径。`/hooks list` 显示净化后的单行命令预览,上限 60 字符;它不是逐字副本。结构化拒绝原因有界,并对类似路径、参数、命令和密钥的 token 脱敏,包括带引号或 `key=value` 的形式以及 `Authorization: Bearer …`。
411
411 lines MARKDOWN