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