返回 CodeWhale
HOOKS.md
根目录 / docs / HOOKS.md
1 # Hooks
2 > 阅读简体中文版:[zh_hans/HOOKS.md](zh_hans/HOOKS.md)
3
4 Hooks run a shell command when the Codewhale **TUI** reaches a lifecycle
5 point. They are plain processes: they receive context through environment
6 variables, some receive a JSON payload on stdin, and three of them can steer
7 what Codewhale does next.
8
9 This page is the authoritative reference for what is implemented today.
10 Configuration syntax that overlaps with the rest of `config.toml` lives in
11 [CONFIGURATION.md](CONFIGURATION.md); this file is the event-by-event
12 contract.
13
14 ## Scope
15
16 Hooks are a **TUI runtime feature**. Every firing point lives in the
17 interactive TUI and in the engine turn loop it drives.
18
19 | Surface | Fires hooks |
20 | --- | --- |
21 | `codewhale` / `codew` interactive TUI | yes |
22 | `codewhale exec` (headless one-shot) | opt-in: `--hooks` fires `tool_call_before` and `shell_env` |
23 | the `codewhale` CLI dispatcher and its subcommands | no |
24 | app-server / ACP | no |
25 | the `workflow` tool and sub-agent *internals* | no — but the TUI fires `subagent_spawn` / `subagent_complete` around them |
26 | public API | there is none |
27
28 The `crates/hooks` event-sink crate in this repository is an unrelated
29 internal mechanism. It shares no configuration, no event names, and no
30 contract with the hooks described here.
31
32 ### `codewhale exec --hooks`
33
34 Headless runs fire no hooks by default — a CI job should not start paging an
35 on-call rotation merely because a config exists. `codewhale exec --hooks`
36 opts the run in. The engine-side events are `tool_call_before` (exit code 2
37 still denies the call; `ask` resolves fail-closed because nothing can prompt
38 headlessly) and `shell_env`. UI-driven events such as `session_start`,
39 `message_submit`, and `turn_end` do not fire — they live in the interactive
40 shell, not the turn loop. Fleet worker subprocesses never fire operator
41 hooks. Independently of this flag, `permissions.toml` typed rules already
42 apply to `exec` — the run drives the same turn loop, and a `deny` blocks in
43 every mode.
44
45 ## Quick start
46
47 ```toml
48 # ~/.codewhale/config.toml
49 [hooks]
50 enabled = true
51
52 [[hooks.hooks]]
53 name = "announce"
54 event = "session_start"
55 command = "echo 'Codewhale session started'"
56 ```
57
58 Run `/hooks` in the TUI to list what is configured, whether the global switch
59 is on, and any entry that was rejected at load. Run `/hooks events` for the
60 event names.
61
62 ## Configuration
63
64 ```toml
65 [hooks]
66 enabled = true # global switch; false suppresses every hook
67 default_timeout_secs = 30 # see the timeout note below
68 working_dir = "/path/to/dir" # default: the session workspace
69
70 [[hooks.hooks]]
71 event = "tool_call_before" # required; one of the 11 names below
72 command = "~/.codewhale/hooks/gate.sh" # required; `sh -c` on Unix, `cmd /C` on Windows
73 name = "gate" # optional label for /hooks and log lines
74 timeout_secs = 30 # optional, default 30
75 background = false # optional; foreground inside the hook worker
76 continue_on_error = true # optional, default true
77 condition = { type = "tool_name", name = "exec_shell" } # optional
78 ```
79
80 `timeout_secs` note, stated as implemented: when `[hooks].default_timeout_secs`
81 is set it **overrides** every hook's own `timeout_secs`, it does not merely
82 supply a default for hooks that omit one. Leave it unset if you want per-hook
83 timeouts to apply. `/hooks list` shows the timeout the runtime will actually
84 apply, and names the override when one is in force.
85
86 `default_timeout_secs = 0` is **rejected at load**. Because the value replaces
87 every hook's own `timeout_secs`, a zero there would expire every hook in the
88 config immediately — including a `tool_call_before` gate, which then denies
89 every matching tool call. The override is ignored, per-hook `timeout_secs`
90 applies, the hooks themselves still load, and the rejection is reported by
91 `/hooks list` under *configuration problems*. Per-hook `timeout_secs = 0` is
92 rejected too, but that only drops the one hook that wrote it.
93
94 Hooks run with the workspace (or `working_dir`) as the current directory.
95
96 ### Timeouts
97
98 The timeout applies to **foreground and background hooks alike**. When it
99 expires:
100
101 - the hook's whole process group is killed — Unix process groups, Windows Job
102 Objects — so a hook that spawns children does not outlive its budget;
103 - the child is then reaped, so nothing is normally left detached or zombied;
104 - a foreground hook's result is `success = false`, `exit_code = None`, empty
105 `stdout`/`stderr`, and `error = "Hook timed out after Ns"`;
106 - a background hook's timeout is logged at `warn` under the `hooks` target.
107 Nothing is reported to the caller, because the caller stopped waiting the
108 moment it submitted the hook.
109
110 **Termination is best-effort, and the bound that is guaranteed is Codewhale's,
111 not the OS's.** The kill can fail to land — a process wedged in an
112 uninterruptible state on Unix, a `TerminateJobObject` a protected process
113 survives on Windows — and no user-space program can promise otherwise. What
114 Codewhale does guarantee is that it stops waiting: the containment handle is
115 released (which re-signals the Unix process group and closes the kill-on-close
116 Windows Job Object) and the reap gets one short bounded window. If the child
117 still cannot be confirmed dead, that is logged at `warn` and the foreground
118 result says so — `error = "hook could not be reaped after its timeout"` rather
119 than the stronger timeout wording. So a timed-out hook never blocks the turn,
120 but treat "killed" as best-effort rather than absolute.
121
122 ### Background hooks
123
124 `background = true` describes real scheduling, not just a config flag. A
125 background hook is **submitted, never awaited**:
126
127 - it is enqueued without blocking into a fixed 32-entry supervisor queue,
128 drained by two persistent workers that apply the timeout above; saturation
129 or supervisor loss is a failed submission, and no invocation creates its
130 own detached supervisor thread;
131 - it receives the same environment variables and the same stdin JSON payload
132 as the foreground form of that event — the payload contract does not change,
133 only the steering does;
134 - its stdout and stderr are discarded (`Stdio::null()`), so it can never
135 return a verdict;
136 - the `HookResult` the runtime hands its caller is flagged as a background
137 submission and carries no exit code. Steering code reads
138 `observed_exit_code()`, which is `None` for a background hook, so a
139 background hook can never allow, deny, ask, or rewrite anything.
140
141 `shell_env` ignores `background` entirely — its stdout *is* the contract, so
142 it always runs in the foreground. `/hooks list` reports that as a
143 configuration warning, and does not label the hook `[bg]`.
144
145 Observer-only UI events are submitted with non-blocking `try_send` to one
146 32-entry queue drained by two persistent workers. A configured foreground
147 observer is still awaited in config order inside a worker, but the terminal
148 event loop never waits on its process and never creates a thread per event.
149 Queue saturation or dispatcher loss drops that observer event and produces an
150 event-specific error toast that survives an agent's ordinary progress-status
151 update. Steering events retain their gate or transform semantics:
152 fresh/queued `message_submit` dispatch reports through a bounded result
153 channel, same-turn steering runs that transform on the blocking worker before
154 calling the engine steer path, and `tool_call_before` / `shell_env` execute on
155 the engine or tool worker rather than the terminal event loop.
156
157 ### The hook process environment
158
159 A hook command inherits the environment of the Codewhale process, plus the
160 `DEEPSEEK_*` variables for its event. Codewhale does not filter that
161 inheritance, so treat a hook exactly as you would treat any command you type
162 in the same shell that launched Codewhale: whatever is exported there is
163 visible to it.
164
165 This is *not* true of the command a `shell_env` hook feeds — see
166 [`shell_env`](#shell_env) for the bounded allowlist that governs a **local**
167 `exec_shell`, and for what changes when an external sandbox backend is
168 configured instead (the backend owns its base environment, and your
169 `shell_env` values are transmitted to it).
170
171 ### Conditions
172
173 | Condition | Matches | Supported on |
174 | --- | --- | --- |
175 | `{ type = "always" }` | every invocation (also the default when omitted) | every event |
176 | `{ type = "tool_name", name = "exec_shell" }` | exact tool name; `*` globs are supported, e.g. `mcp__*` | `tool_call_before`, `tool_call_after`, `shell_env`, `on_error` |
177 | `{ type = "tool_category", category = "shell" }` | tool category | `tool_call_before`, `tool_call_after`, `shell_env`, `on_error` |
178 | `{ type = "mode", mode = "plan" }` | the context's mode string, case-insensitive | every event **except** `shell_env` |
179 | `{ type = "exit_code", code = 1 }` | the exit code the tool actually reported | `tool_call_after`, `on_error` |
180 | `{ type = "all", conditions = [...] }` | every nested condition | every event |
181 | `{ type = "any", conditions = [...] }` | at least one nested condition | every event |
182
183 Three rules keep conditions from lying:
184
185 - **`exit_code` needs a real exit code.** It matches only when the event
186 actually observed a process exit code — `tool_call_after`, or `on_error` for
187 a tool failure, in both cases for a process-backed tool such as `exec_shell`.
188 A tool that reports no exit code never matches an `exit_code` condition; the
189 condition is not satisfied by a default, a zero, or a success flag. The value
190 is a 64-bit integer, so a Windows crash code such as `3221225477`
191 (`0xC0000005`) is matchable.
192 - **Tool-scoped `on_error` hooks are supported.** `on_error` fires for
193 transport and capacity errors *and* for tool failures; the tool-failure
194 firing carries the tool name, call id, result, and reported exit code. A
195 `tool_name` / `tool_category` / `exit_code` condition on `on_error` is
196 therefore a valid configuration. An `on_error` firing with no tool behind it
197 simply does not match such a condition — it is skipped at dispatch, not
198 rejected at load.
199 - **Unsupported conditions are rejected at load.** A condition that references
200 context its event never carries can never match, and a hook wearing one is
201 silently inert — the dangerous form of that is a `deny` gate the operator
202 believes is armed. Codewhale drops those hooks at load, logs the reason
203 under the `hooks` tracing target, and shows them in `/hooks list` as
204 `rejected:`. Nested predicates inside `all` / `any` are checked too. A hook
205 with `timeout_secs = 0` or an empty `command` is rejected the same way.
206 Rejection is **per entry**: a broken hook never takes another one with it,
207 even when the two share a `name` or are both unnamed.
208
209 ### Project-local hooks
210
211 A repository may ship `<workspace>/.codewhale/hooks.toml` using the same shape,
212 but only its `[[hooks]]` entries are merged — a project file cannot change
213 `enabled`, `default_timeout_secs`, or `working_dir`, which always come from your
214 own config. Because hooks are executable configuration, project hooks load
215 **only** after both workspace trust and separate approval of the exact hooks file
216 in user-owned config. Use `/hooks review` to inspect the commands and digest,
217 then `/hooks approve <digest>` to enable those bytes on the next session. Review
218 any scripts the commands call too. A file change requires another approval.
219 `/hooks revoke` blocks future and queued launches; it does not stop commands
220 already running. Session `/trust on` alone does not enable project hooks.
221 Approved project hooks are appended
222 after global hooks, so they run last and win `updatedInput` ties. A malformed
223 trusted project file logs a warning and Codewhale falls back to global hooks
224 only. Validation runs over the merged set, so a rejected project hook is
225 reported the same way a rejected global one is.
226
227 ## The 15 events
228
229 | Event | Fires | Steering |
230 | --- | --- | --- |
231 | `session_start` | once, after the engine is up and before the first draw | observer |
232 | `session_end` | once, on graceful shutdown | observer |
233 | `turn_end` | after a turn completes and post-turn state is updated | observer |
234 | `message_submit` | before a submitted message reaches history or the model | **can replace or block the text** |
235 | `tool_call_before` | before each tool call executes | **can allow / deny / ask, rewrite input, add context** |
236 | `tool_call_after` | after each tool result settles, including completions the transcript does not redraw | observer |
237 | `mode_change` | on every applied Plan/Work/Operate transition (`Act` is a compatibility alias for Work) | observer |
238 | `on_error` | on transport, capacity, and auth errors, and on tool failures | observer |
239 | `subagent_spawn` | when a sub-agent starts | observer |
240 | `subagent_complete` | when a sub-agent completes, fails, or is cancelled | observer |
241 | `shell_env` | immediately before each `exec_shell` invocation | **contributes environment variables** |
242 | `session_idle` | when the session settles back to idle after a turn or a wait — no prompt, approval, or continuation outstanding | observer |
243 | `session_error` | when a turn ends in a terminal failure; transient tool failures the agent absorbs never fire it | observer |
244 | `waiting_for_user` | when the agent starts waiting on you: an approval prompt opens, a `request_user_input` question is presented, or a goal continuation is parked between passes | observer |
245 | `session_busy` | when an idle or waiting session begins or resumes work; startup and repeated observations of the same state stay silent | observer |
246
247 `waiting_for_user`'s payload carries `reason`: `approval`, `user_input`, or
248 `goal_continuation`. All three state events carry `from`/`to` transition fields;
249 `session_idle` also carries `last_turn_status` when known, and `session_error` carries
250 the bounded terminal `error` text. Busy, idle, and waiting map onto the session
251 states the control socket's `status` verb already publishes
252 (`idle` / `in_progress` / `waiting`), so a hook and a supervisor never
253 disagree about what the session is doing. Hook authors that want opencode's
254 grace-period semantics for error alerts should debounce inside the hook —
255 `session_error` already excludes absorbed, transient failures, and a turn
256 that fails and is retried by the operator fires again only if the retry also
257 ends failed.
258
259 ### What "observer" means, exactly
260
261 Observer means Codewhale ignores the hook's **result**: stdout is discarded, a
262 non-zero exit is logged as a warning, and nothing about the turn, the tool
263 result, the sub-agent, or the error changes because of it.
264
265 Observer does **not** mean side-effect-free. An observer hook is an arbitrary
266 shell command running with your credentials. It can write files, push commits,
267 page an on-call rotation, or delete the workspace. The only thing it cannot do
268 is change what Codewhale itself does next.
269
270 The steering allowlist is exactly three events — `message_submit`,
271 `tool_call_before`, `shell_env` — and it is asserted by a test over every
272 variant, so a new event defaults to observer.
273
274 ### Session identity
275
276 Every event in one TUI session carries the same `DEEPSEEK_SESSION_ID`. The id
277 is minted once at launch, in the form `sess_xxxxxxxx`, and it survives a
278 workspace switch and a trust decision that adds project hooks — both reload
279 the hook set without starting a new session. Engine-fired `tool_call_before`
280 reports the same id as the UI-fired events, so tool records correlate with the
281 session records around them.
282
283 `session_end` fires after the queued startup-default writes have been drained
284 and while the app is still live, so it observes the settled end state rather
285 than a half-torn-down one.
286
287 ## Environment variables
288
289 Every hook receives the subset of these that applies to its event. The
290 `DEEPSEEK_` prefix is retained for compatibility with hooks written before the
291 rebrand.
292
293 | Variable | Set for | Notes |
294 | --- | --- | --- |
295 | `DEEPSEEK_SESSION_ID` | every event except `shell_env` | `sess_xxxxxxxx`, stable for the whole session |
296 | `DEEPSEEK_WORKSPACE` | every event except `shell_env` | absolute workspace path |
297 | `DEEPSEEK_MODEL` | every event except `shell_env` | active model id |
298 | `DEEPSEEK_MODE` | every event except `shell_env` | see the mode-spelling note below |
299 | `DEEPSEEK_TOTAL_TOKENS` | UI-fired events | session token total at fire time |
300 | `DEEPSEEK_MESSAGE` | `message_submit`, `subagent_*` | truncated at 5 000 bytes with a `...[truncated]` marker |
301 | `DEEPSEEK_ERROR` | `on_error` | error message, truncated at 5 000 bytes |
302 | `DEEPSEEK_PREVIOUS_MODE` | `mode_change` | mode label before the change |
303 | `DEEPSEEK_TOOL_NAME` | `tool_call_before`, `tool_call_after`, `shell_env`, `on_error` (tool failures) | |
304 | `DEEPSEEK_TOOL_CALL_ID` | `tool_call_before`, `tool_call_after`, `on_error` (tool failures) | engine call id; correlates before/after/error for one call |
305 | `DEEPSEEK_TOOL_ARGS` | `tool_call_before`, `shell_env` | tool input JSON preview, capped at 10 000 bytes |
306 | `DEEPSEEK_TOOL_RESULT` | `tool_call_after`, `on_error` (tool failures) | truncated at 10 000 bytes |
307 | `DEEPSEEK_TOOL_SUCCESS` | `tool_call_after`, `on_error` (tool failures) | `true` / `false` |
308 | `DEEPSEEK_TOOL_EXIT_CODE` | `tool_call_after` and `on_error` **when the tool reported one** | absent otherwise — never synthesized; 64-bit, so Windows crash codes such as `3221225477` survive |
309 | `DEEPSEEK_SESSION_COST` | when cost is supplied | USD, six decimal places |
310
311 **Mode-spelling note.** UI-fired events (`session_start`, `session_end`,
312 `message_submit`, `tool_call_after`, `mode_change`, `on_error`, `turn_end`,
313 `subagent_*`, `session_busy`, `session_idle`, `session_error`, `waiting_for_user`)
314 set `DEEPSEEK_MODE` to the UI label — `ACT`, `PLAN`, `OPERATE`.
315 `tool_call_before` fires inside the engine and uses the engine's own mode
316 spelling (`Agent`, `Plan`, `Operate`). `mode` conditions compare
317 case-insensitively, so `{ type = "mode", mode = "plan" }` matches both, but a
318 hook that string-matches `$DEEPSEEK_MODE` exactly should accept both spellings.
319
320 **`shell_env` is the narrow one.** It receives only `DEEPSEEK_TOOL_NAME` and
321 `DEEPSEEK_TOOL_ARGS` — no session id, workspace, model, or mode. A
322 `{ type = "mode", … }` condition on a `shell_env` hook is therefore rejected at
323 load; scope those with `tool_name` or `tool_category` instead.
324
325 ## Steering events
326
327 ### `message_submit`
328
329 Receives JSON on stdin and may rewrite or block the submitted text.
330
331 ```json
332 {
333 "event": "message_submit",
334 "text": "original user text",
335 "text_bytes": 18,
336 "text_original_bytes": 18,
337 "text_truncated": false,
338 "session_id": "sess_12345678",
339 "workspace": "/path/to/workspace",
340 "mode": "ACT",
341 "model": "deepseek-chat",
342 "total_tokens": 1234
343 }
344 ```
345
346 The complete serialized stdin document is capped at 32 KiB. `text` is the
347 largest deterministic UTF-8 prefix that fits after JSON escaping and bounded
348 metadata are included. `text_original_bytes` records the producer's full byte
349 length, `text_bytes` records the retained prefix, and `text_truncated` states
350 whether they differ. This same boundary applies to immediate input, restored
351 queue entries, merged steers, and text produced by an earlier hook.
352
353 - exit `0` printing `{"text": "..."}` with a non-empty string replaces the text
354 - exit `0` with empty stdout, or JSON without `text`, leaves the text unchanged
355 - `{"text": ""}` or a replacement over 32 000 characters is invalid stdout,
356 logged and ignored
357 - exit `2` blocks the submission before history or dispatch; a structured
358 `reason` field supplies a bounded, redacted message shown in the TUI.
359 Unstructured stdout/stderr/error output is never copied into the denial
360 - other non-zero exits follow `continue_on_error`: `true` warns and continues,
361 `false` blocks the submission
362 - `background = true` makes the hook observer-only — it still receives this
363 bounded payload on stdin, but it cannot transform or block
364
365 Multiple `message_submit` hooks run in config order and each sees the previous
366 hook's output.
367
368 ### `tool_call_before`
369
370 Receives the tool context in environment variables and may print a JSON
371 decision on stdout with exit `0`:
372
373 ```json
374 {
375 "decision": "allow",
376 "reason": "human-readable explanation, used for deny",
377 "updatedInput": { "command": "ls -la" },
378 "additionalContext": "text appended to the tool result for the model"
379 }
380 ```
381
382 - `deny` blocks the tool; the model gets a permission-denied result carrying
383 `reason`
384 - `ask` forces the interactive approval prompt in Ask and Auto-Review. Full
385 Access does not open tool-approval prompts, so `ask` does not downgrade it
386 - `updatedInput` must be an object no larger than 32 KiB serialized and
387 replaces the tool input; last hook wins
388 - `additionalContext` is appended to the tool result as `[hook context] ...`;
389 multiple hooks concatenate
390 - `reason` and `additionalContext` are bounded and sanitized before use: each
391 field is capped at 2 000 characters, the concatenated context for one tool
392 call is capped at 8 000, control characters are stripped (so hook stdout
393 cannot repaint the TUI or forge structure in the transcript), and a clipped
394 value carries a `…[truncated]` marker. What a hook adds to the turn's context
395 budget is therefore bounded no matter what it prints
396 - exit `2` is a legacy hard deny and wins regardless of stdout
397 - empty stdout, non-JSON stdout, and JSON without `decision` all mean allow
398 - precedence across matching hooks: no-verdict-with-`continue_on_error = false`
399 > deny > ask > allow
400 - `background = true` hooks are submitted and never awaited, so they have no
401 verdict and cannot steer; Codewhale logs a warning when one is configured
402 for this event
403
404 **A gate that could not answer is not permission.** If a foreground
405 `tool_call_before` hook produces no verdict — it timed out, the process could
406 not be started, or a strict process exited non-zero without an explicit JSON
407 decision — and *that hook* is configured with
408 `continue_on_error = false`, the tool call is denied. Strictness is read off
409 the hook that actually ran, not off the event: a strict `write_file` gate whose
410 condition did not match an `exec_shell` call has no say in whether that call
411 proceeds, and a lenient hook's timeout never denies just because some other
412 strict hook exists in config. Every no-verdict outcome is logged either way.
413
414 The denial message names the hook and the reason and nothing else: the hook
415 name is truncated, the detail is truncated, control characters are stripped,
416 and a spawn failure is reported by error kind (`NotFound`,
417 `PermissionDenied`, …) rather than by echoing the command line or the resolved
418 interpreter path.
419
420 ### `shell_env`
421
422 Runs synchronously before each `exec_shell` and its stdout is parsed as
423 `KEY=VALUE` lines. A leading `export ` is stripped, `#` comment lines and blank
424 lines are skipped, and a matching pair of surrounding single or double quotes is
425 removed from the value. Later hooks override earlier ones. Use it for ephemeral
426 credentials, per-skill `PATH` adjustments, or short-lived tokens.
427
428 `background` is ignored for this event: the hook always runs in the foreground
429 because its stdout is the contract.
430
431 An entry a shell cannot carry is dropped rather than allowed to break the tool
432 call: an empty name, a name containing whitespace, `=`, a control character, or
433 a NUL; a value containing a NUL; a value over 32 KiB; and anything past 256 KiB
434 of accumulated output from one hook. Each drop is logged by key name only. A
435 `shell_env` hook is an ordinary process whose stdout can contain anything —
436 "the hook printed something odd" must never become "the `exec_shell` call
437 aborted".
438
439 **Exactly what the shell command ends up with — local execution.** When
440 `exec_shell` runs the command locally (the default), it does not inherit
441 Codewhale's ambient environment. Its environment is built as:
442
443 1. a sanitized fixed allowlist of parent variables — `PATH`, `HOME`, `USER`,
444 `LANG` and the other `LC_*`/locale entries, `TERM`, `SHELL`, `TMPDIR`,
445 proxy variables, color/terminal entries such as `NO_COLOR`, `CARGO_HOME`/`RUSTUP_HOME`/`RUSTUP_TOOLCHAIN`, the Windows system and MSVC toolchain entries, and other platform keys (the full list lives in `crates/tui/src/child_env.rs`) — and nothing else. Variables
446 outside that allowlist, including anything that looks like a secret, are
447 dropped;
448 2. then the `KEY=VALUE` pairs your `shell_env` hooks produced, applied on top.
449 These are explicit values you configured, so they win over the allowlist.
450
451 So a `shell_env` hook is the supported way to get a credential into one local
452 `exec_shell` invocation. Ambient secrets exported in the terminal that launched
453 Codewhale are **not** forwarded to a local `exec_shell` on their own.
454
455 **With an external sandbox backend configured, the allowlist above is not the
456 contract.** If `exec_shell` is routed to a configured sandbox/execution
457 backend, Codewhale does not construct the process environment at all: it hands
458 the command and your `shell_env` values to the backend as extra environment
459 variables, and the **backend owns its own base environment**. What is present
460 besides your values — an image's baked-in variables, the backend's own
461 injections, whatever a remote runner exports — is determined by that backend,
462 not by the list above. Do not assume the local allowlist applies there.
463
464 Disclosure, because it is the part that matters for a hook that emits
465 credentials: **`shell_env` values are transmitted to the configured backend.**
466 For a remote or containerized backend that means the values leave this machine
467 and are subject to that backend's logging, retention, and access controls.
468 Codewhale's own audit log still records key names only, but that says nothing
469 about what the backend does with the values. If a `shell_env` hook emits a
470 secret, scope it to a backend you trust with that secret — for example by
471 conditioning the hook, or by not configuring an external backend for sessions
472 where those hooks are active.
473
474 Resolved **key names — never values** — are written to `~/.codewhale/audit.log`
475 so a session can be reconciled afterwards. A hook that fails or times out
476 contributes no variables and does not abort the shell call.
477
478 ```toml
479 [[hooks.hooks]]
480 name = "aws-creds"
481 event = "shell_env"
482 command = "aws-vault export my-profile --format=env"
483 condition = { type = "tool_category", category = "shell" }
484 ```
485
486 ## Structured observer payloads
487
488 `turn_end`, `subagent_spawn`, `subagent_complete`, `session_busy`, `session_idle`,
489 `session_error`, and `waiting_for_user` receive JSON on stdin in addition to the
490 environment variables. Their stdout is ignored. Background forms of these
491 events receive the same payload on stdin.
492
493 The remaining observer events — `session_start`, `session_end`,
494 `tool_call_after`, `mode_change`, `on_error` — receive environment variables
495 only, with no stdin payload, in both foreground and background form.
496
497 ### Session state transitions
498
499 The first observed state is recorded silently, whether idle, busy, or waiting.
500 Repeating the same state emits nothing. For a turn that pauses for user input
501 and then completes, the transition hooks receive these payloads in submission
502 order:
503
504 | Event | JSON stdin |
505 | --- | --- |
506 | `session_busy` | `{"from":"idle","to":"in_progress"}` |
507 | `waiting_for_user` | `{"from":"in_progress","to":"waiting","reason":"user_input"}` |
508 | `session_busy` | `{"from":"waiting","to":"in_progress"}` |
509 | `session_idle` | `{"from":"in_progress","to":"idle","last_turn_status":"completed"}` |
510
511 The dispatcher has two workers, so command completion order is not guaranteed.
512 `session_error` is a separate terminal-failure event, with `status` and `error`
513 fields rather than `from` and `to`.
514
515 ### `turn_end`
516
517 Fires after post-turn state, usage totals, cost accounting, notifications,
518 receipts, and queue recovery have been updated, and before queued follow-up
519 dispatch — so the payload can report the queued count without a hook being able
520 to change what is sent next.
521
522 ```json
523 {
524 "event": "turn_end",
525 "session_id": "sess_12345678",
526 "workspace": "/path/to/workspace",
527 "mode": "ACT",
528 "created_at": "2026-07-12T10:30:00+00:00",
529 "model_backed": true,
530 "provider": "deepseek",
531 "billing_surface": null,
532 "model": "deepseek-chat",
533 "turn_id": "turn_12345678",
534 "status": "completed",
535 "error": null,
536 "duration_ms": 1834,
537 "usage": {
538 "input_tokens": 1200,
539 "output_tokens": 180,
540 "prompt_cache_hit_tokens": 900,
541 "prompt_cache_miss_tokens": 300,
542 "prompt_cache_write_tokens": 0,
543 "reasoning_tokens": null,
544 "reasoning_replay_tokens": null
545 },
546 "totals": {
547 "session_tokens": 1380,
548 "conversation_tokens": 1380,
549 "input_tokens": 1200,
550 "output_tokens": 180
551 },
552 "tool_count": 2,
553 "queued_message_count": 1,
554 "stop_hook_active": false
555 }
556 ```
557
558 `created_at` anchors time-window pricing. `provider` and `model` identify the
559 effective route for model-backed turns. `billing_surface` is an optional,
560 non-secret classification of the endpoint that served the turn (recognized
561 StepFun routes emit `stepfun-payg` or `stepfun-plan`); the raw base URL is never
562 written to hook records. Shell-only, manual-compaction, and purge completions
563 have no matching `TurnStarted`, so they report `model_backed: false`, a `null`
564 provider, and a synthetic `lifecycle_<uuid>` turn id. `stop_hook_active` is
565 always `false` today; it reserves room for re-entry protection.
566
567 ### `subagent_spawn` / `subagent_complete`
568
569 ```json
570 {
571 "event": "subagent_complete",
572 "agent_id": "agent_1",
573 "session_id": "sess_12345678",
574 "workspace": "/path/to/workspace",
575 "mode": "ACT",
576 "model": "deepseek-chat",
577 "total_tokens": 1234,
578 "result_preview": "bounded preview of the result",
579 "result_truncated": false,
580 "status": "completed"
581 }
582 ```
583
584 `subagent_spawn` carries `prompt_preview` / `prompt_truncated` instead, and no
585 `status`. Both payloads are bounded on purpose: previews are truncated rather
586 than shipping full prompts or results. These hooks are observer-only — failures
587 do not affect sub-agent scheduling, prompts, or results, and `continue_on_error`
588 has no effect because later matching hooks always run.
589
590 ## Failure behavior
591
592 - A non-zero exit is logged at `warn` under the `hooks` tracing target with the
593 hook name, event, exit code, duration, and a generic failure category. Raw
594 stdout/stderr/error text is not persisted in the log receipt.
595 - For `execute`-path events, `continue_on_error = false` stops later hooks for
596 that event; except on `tool_call_before` (above) it does not roll back the
597 action that fired them.
598 - Structured observer events (`turn_end`, `subagent_*`, `session_busy`,
599 `session_idle`, `session_error`, `waiting_for_user`) always continue to the
600 next matching hook.
601 - Observer events use a bounded persistent dispatcher. Queue-full and
602 dispatcher-unavailable submissions are not retried silently; the TUI keeps
603 an event-specific error toast separate from the ordinary status line.
604 - A hook that exceeds its timeout has its whole process group killed and is
605 then reaped, foreground or background — best-effort, with a bounded reap
606 wait; see [Timeouts](#timeouts).
607
608 ## Security notes
609
610 - Hooks are arbitrary shell commands from your own config; treat
611 `~/.codewhale/config.toml` as executable.
612 - Project-supplied hooks require exact-file approval in addition to workspace trust in
613 user-owned config.
614 - Hook commands inherit Codewhale's own environment. A local `exec_shell` does
615 not — see [`shell_env`](#shell_env).
616 - `shell_env` audit records contain key names only. That covers Codewhale's own
617 logging; with an external sandbox backend configured, the values themselves
618 are transmitted to that backend and are then subject to its handling.
619 - With an external sandbox backend, the local parent-variable allowlist does
620 not apply — the backend owns its base environment.
621 - Payload previews, tool arguments/results, error messages, captured stdout and
622 stderr, replacement messages, and steering objects are bounded so hook input
623 or output cannot become an unbounded copy of the transcript.
624 - Nothing Codewhale persists in a denial echoes the stdin payload, hook
625 environment, raw stdout/stderr/error, command line, or a resolved filesystem
626 path. `/hooks list` shows a sanitized, single-line command preview capped at
627 60 characters; it is not a verbatim copy. Structured denial reasons are
628 bounded and redact path-, argument-, command-, and secret-like tokens,
629 including quoted or `key=value` forms and `Authorization: Bearer …`.
630
630 lines MARKDOWN