| 1 | # RFC: Hook Lifecycle Data Flow |
| 2 | |
| 3 | **Issue:** #1364 |
| 4 | **Status:** Implemented (all three PRs landed) |
| 5 | **Date:** 2026-05-28 |
| 6 | **Reference:** the shipped contract lives in [docs/HOOKS.md](../HOOKS.md); |
| 7 | this RFC is kept as the design record. Where the two disagree, `docs/HOOKS.md` |
| 8 | is correct and this document is history. |
| 9 | |
| 10 | **Scope:** everything below landed as a **TUI runtime** feature. No hook in |
| 11 | this RFC fires from `codewhale exec`, the CLI subcommands, the app-server / |
| 12 | ACP surfaces, or the `workflow` tool. The RFC's use of "CodeWhale" as the |
| 13 | actor should be read as "the Codewhale TUI" throughout. |
| 14 | |
| 15 | ## 0. Implementation status |
| 16 | |
| 17 | Verified against `crates/tui/src/hooks/` and its call sites: |
| 18 | |
| 19 | | Item | Status | Where | |
| 20 | | --- | --- | --- | |
| 21 | | PR 1 — mutable `message_submit` | implemented | `HookExecutor::execute_message_submit_transform`, called from `dispatch_user_message_with_recovery` | |
| 22 | | PR 2 — `turn_end` | implemented | `HookEvent::TurnEnd`, `turn_end_payload`, fired by `execute_turn_end_observer_hook` | |
| 23 | | PR 3 — subagent observers | implemented | `HookEvent::SubagentSpawn` / `SubagentComplete`, fired by `execute_subagent_observer_hook` | |
| 24 | |
| 25 | Two deliberate deltas from the text below, both shipped: |
| 26 | |
| 27 | - **`tool_call_before` also became mutable** (#3026), which this RFC listed as a |
| 28 | non-goal for PR 1. It carries its own stdout JSON contract |
| 29 | (`decision` / `reason` / `updatedInput` / `additionalContext`) plus the legacy |
| 30 | exit-code-`2` hard deny. `message_submit` is therefore no longer the only |
| 31 | event with a stdout contract; `shell_env`'s `KEY=VALUE` contract also predates |
| 32 | both. |
| 33 | - **`turn_end` observers are not gated by `continue_on_error`.** The structured |
| 34 | observer path runs every matching hook regardless, because an observer that |
| 35 | fails must not suppress the observers behind it. |
| 36 | |
| 37 | Still unimplemented from the text below: nothing in scope. `stop_hook_active` is |
| 38 | emitted as `false` and the re-entry protection it reserves room for has not been |
| 39 | built. |
| 40 | |
| 41 | Four contract points hardened after this RFC, all documented in |
| 42 | `docs/HOOKS.md`: |
| 43 | |
| 44 | - **One session identity.** The hook session id is minted once per TUI launch |
| 45 | and survives a workspace switch or a trust decision that adds project hooks. |
| 46 | `tool_call_before` reports that id rather than the engine's own session id, |
| 47 | and `mode_change` carries it at all (it previously carried no session id). |
| 48 | - **Timeouts bind background hooks too.** A background hook is supervised on |
| 49 | its own thread; on expiry its process group is killed and reaped. |
| 50 | - **`background` describes scheduling, not capability.** A background hook |
| 51 | receives the same stdin payload and environment as the foreground form; it |
| 52 | is simply never awaited, so its `HookResult` is flagged as a submission and |
| 53 | carries no exit code. |
| 54 | - **Conditions that can never match are rejected at load** rather than left |
| 55 | silently inert — per entry, so a broken hook does not take a same-named or |
| 56 | likewise-unnamed sibling with it — and a `tool_call_before` gate that returns |
| 57 | no verdict does not read as permission when *that gate* declared |
| 58 | `continue_on_error = false`. Strictness rides on the hook result, so it |
| 59 | applies only to the hooks whose conditions matched the call in question. |
| 60 | |
| 61 | ## 1. Problem |
| 62 | |
| 63 | CodeWhale already has lifecycle hooks and MCP support, but the current hook |
| 64 | surface is mostly observer-only. This blocks portable extensions that need to |
| 65 | participate in the agent data flow: |
| 66 | |
| 67 | - memory/context injection before a user message reaches the model |
| 68 | - post-turn background analysis that prepares context for the next turn |
| 69 | - sub-agent lifecycle visibility for orchestration and audit extensions |
| 70 | |
| 71 | The current `message_submit` event fires before dispatch, but its output is |
| 72 | ignored. `TurnComplete`, `AgentSpawned`, and `AgentComplete` exist internally, |
| 73 | but they are not exposed as configurable hook events. |
| 74 | |
| 75 | ## 2. PR split |
| 76 | |
| 77 | This issue should be implemented as three PRs. Each PR should be independently |
| 78 | reviewable and should leave the hook system in a useful state. |
| 79 | |
| 80 | ### PR 1: Mutable `message_submit` |
| 81 | |
| 82 | Add a structured hook execution path for `message_submit` that can transform or |
| 83 | block the user's submitted text before it is sent to the engine. |
| 84 | |
| 85 | Scope: |
| 86 | |
| 87 | - keep the existing `[[hooks.hooks]]` config shape |
| 88 | - pass a JSON payload to the hook on stdin |
| 89 | - interpret stdout JSON containing `text` as the replacement user text |
| 90 | - treat exit code `2` as an intentional block |
| 91 | - run multiple submit hooks serially in config order |
| 92 | - keep existing env vars for compatibility |
| 93 | - keep `shell_env` stdout parsing unchanged |
| 94 | |
| 95 | Non-goals: |
| 96 | |
| 97 | - no tool argument mutation |
| 98 | - no global stdout JSON semantics for all hook events |
| 99 | - no transcript or model response mutation |
| 100 | |
| 101 | ### PR 2: `turn_end` |
| 102 | |
| 103 | Expose the existing turn completion lifecycle as a hook event. |
| 104 | |
| 105 | Scope: |
| 106 | |
| 107 | - add `HookEvent::TurnEnd` with event name `turn_end` |
| 108 | - fire from the UI's `EngineEvent::TurnComplete` branch after core app state, |
| 109 | usage, cost, notifications, and receipt state have been updated |
| 110 | - pass turn metadata on stdin as JSON |
| 111 | - make failures non-blocking and warn-only |
| 112 | - include a `stop_hook_active` field in the payload, initially `false`, so the |
| 113 | contract can support re-entry protection later |
| 114 | |
| 115 | Non-goals: |
| 116 | |
| 117 | - no change to turn status |
| 118 | - no blocking of user input |
| 119 | - no transcript mutation from `turn_end` |
| 120 | |
| 121 | Implementation note for the v0.9 branch: the narrow #2578 harvest uses the |
| 122 | shared structured observer path introduced for sub-agent lifecycle hooks. It |
| 123 | fires before queued follow-up dispatch, after queue-recovery state is known, so |
| 124 | the payload can report the queued-message count without letting a hook change |
| 125 | what gets sent next. Stdout is ignored for `turn_end`; only `message_submit` |
| 126 | has a stdout mutation contract. |
| 127 | |
| 128 | ### PR 3: Subagent lifecycle observer hooks |
| 129 | |
| 130 | Expose subagent start and completion as observer-only hook events. |
| 131 | |
| 132 | Scope: |
| 133 | |
| 134 | - add `HookEvent::SubagentSpawn` with event name `subagent_spawn` |
| 135 | - add `HookEvent::SubagentComplete` with event name `subagent_complete` |
| 136 | - fire from the existing `AgentSpawned` and `AgentComplete` UI branches |
| 137 | - pass subagent metadata on stdin as JSON |
| 138 | - make failures non-blocking and warn-only |
| 139 | |
| 140 | Non-goals: |
| 141 | |
| 142 | - no subagent spawn gating in the first version |
| 143 | - no subagent prompt/result mutation |
| 144 | - no changes to subagent scheduling |
| 145 | |
| 146 | ## 3. PR 1 detailed plan |
| 147 | |
| 148 | ### 3.1 Contract |
| 149 | |
| 150 | Configuration: |
| 151 | |
| 152 | ```toml |
| 153 | [[hooks.hooks]] |
| 154 | event = "message_submit" |
| 155 | command = "~/.deepseek/hooks/inject-memory.sh" |
| 156 | timeout_secs = 2 |
| 157 | continue_on_error = true |
| 158 | ``` |
| 159 | |
| 160 | Input payload on stdin: |
| 161 | |
| 162 | ```json |
| 163 | { |
| 164 | "event": "message_submit", |
| 165 | "text": "original user text", |
| 166 | "session_id": "sess_xxxx", |
| 167 | "workspace": "/path/to/workspace", |
| 168 | "mode": "agent", |
| 169 | "model": "deepseek-chat", |
| 170 | "total_tokens": 1234 |
| 171 | } |
| 172 | ``` |
| 173 | |
| 174 | Output payload on stdout: |
| 175 | |
| 176 | ```json |
| 177 | { "text": "replacement user text" } |
| 178 | ``` |
| 179 | |
| 180 | Rules: |
| 181 | |
| 182 | - exit `0` with stdout JSON containing `text: string` replaces the current text |
| 183 | - exit `0` with empty stdout leaves the current text unchanged |
| 184 | - exit `0` with JSON that does not contain `text` leaves the current text |
| 185 | unchanged |
| 186 | - exit `2` blocks submission before the message is appended to history or sent |
| 187 | to the engine |
| 188 | - other non-zero exits follow `continue_on_error` |
| 189 | - `true`: warn, keep the current text, continue later hooks |
| 190 | - `false`: stop later hooks and block submission with an error message |
| 191 | - `background = true` on `message_submit` remains observer-only and cannot |
| 192 | transform or block submission |
| 193 | |
| 194 | Multiple hooks: |
| 195 | |
| 196 | - hooks run in config order |
| 197 | - each hook receives the latest transformed text |
| 198 | - the final transformed text is the only text used by file mention expansion, |
| 199 | skill wrapping, auto routing, history, and `api_messages` |
| 200 | |
| 201 | ### 3.2 Implementation steps |
| 202 | |
| 203 | 1. Add structured submit outcome types in `crates/tui/src/hooks.rs`: |
| 204 | |
| 205 | ```rust |
| 206 | pub enum MessageSubmitOutcome { |
| 207 | Unchanged, |
| 208 | Replaced(String), |
| 209 | Blocked { reason: String }, |
| 210 | } |
| 211 | ``` |
| 212 | |
| 213 | 2. Add a stdin-capable sync executor: |
| 214 | |
| 215 | ```rust |
| 216 | fn execute_sync_with_stdin( |
| 217 | &self, |
| 218 | hook: &Hook, |
| 219 | env_vars: &HashMap<String, String>, |
| 220 | stdin_json: &serde_json::Value, |
| 221 | ) -> HookResult |
| 222 | ``` |
| 223 | |
| 224 | This should reuse the existing timeout, working directory, stdout, stderr, and |
| 225 | error handling behavior from `execute_sync`. |
| 226 | |
| 227 | 3. Add a `message_submit` transform entrypoint: |
| 228 | |
| 229 | ```rust |
| 230 | pub fn execute_message_submit_transform( |
| 231 | &self, |
| 232 | context: &HookContext, |
| 233 | original_text: &str, |
| 234 | ) -> MessageSubmitOutcome |
| 235 | ``` |
| 236 | |
| 237 | This method should: |
| 238 | |
| 239 | - filter configured `MessageSubmit` hooks through existing condition matching |
| 240 | - build a JSON payload for each hook using the current text |
| 241 | - run non-background hooks through `execute_sync_with_stdin` |
| 242 | - run background hooks with the existing observer-only path |
| 243 | - parse stdout JSON only for non-background hooks |
| 244 | - return the final text or a block result |
| 245 | |
| 246 | 4. Apply the transformed message in `dispatch_user_message`: |
| 247 | |
| 248 | - run the transform before `last_submitted_prompt`, file mentions, history, and |
| 249 | `api_messages` |
| 250 | - create a local mutable `QueuedMessage` or replacement display text |
| 251 | - if blocked, show a status message or toast and return without dispatch |
| 252 | |
| 253 | 5. Update `/hooks events`: |
| 254 | |
| 255 | - keep `message_submit` listed |
| 256 | - update description to say it can transform or block user text |
| 257 | |
| 258 | 6. Update user-facing docs: |
| 259 | |
| 260 | - document the stdin/stdout contract |
| 261 | - document exit code `2` |
| 262 | - document that `shell_env` still uses `KEY=VALUE` stdout |
| 263 | |
| 264 | ### 3.3 Test plan |
| 265 | |
| 266 | Unit tests in `crates/tui/src/hooks.rs`: |
| 267 | |
| 268 | - parses stdout `{"text":"changed"}` as replacement |
| 269 | - empty stdout means unchanged |
| 270 | - JSON without `text` means unchanged |
| 271 | - malformed stdout means unchanged with warning semantics |
| 272 | - exit `2` maps to blocked |
| 273 | - multiple hooks apply transforms in order |
| 274 | - background `message_submit` hook cannot transform |
| 275 | - `continue_on_error = false` blocks on non-zero failure |
| 276 | |
| 277 | TUI integration or focused dispatch tests: |
| 278 | |
| 279 | - transformed text is written to `api_messages` |
| 280 | - transformed text is written to visible history |
| 281 | - transformed text is used by file mention expansion |
| 282 | - blocked submit does not append user history |
| 283 | - blocked submit does not push an API message |
| 284 | - blocked submit leaves loading state false |
| 285 | |
| 286 | Manual smoke test: |
| 287 | |
| 288 | 1. Add a config hook that prepends `[hooked] ` to every submitted message. |
| 289 | 2. Submit `hello`. |
| 290 | 3. Verify the transcript and model input use `[hooked] hello`. |
| 291 | 4. Replace the hook with one that exits `2`. |
| 292 | 5. Submit `hello`. |
| 293 | 6. Verify no turn starts and the TUI shows the block reason. |
| 294 | |
| 295 | ## 4. Shared payload conventions |
| 296 | |
| 297 | All new structured hook payloads should include: |
| 298 | |
| 299 | - `event` |
| 300 | - `session_id` |
| 301 | - `workspace` |
| 302 | - `mode` |
| 303 | - `model` |
| 304 | |
| 305 | Event-specific payloads should add only fields that are stable and useful for |
| 306 | extension authors. Avoid leaking secrets, full tool outputs, or unbounded |
| 307 | transcript content in the first version. |
| 308 | |
| 309 | ## 5. Compatibility |
| 310 | |
| 311 | - Existing hook config remains valid. |
| 312 | - Existing observer-only hooks keep working. |
| 313 | - Existing env vars remain available. |
| 314 | - `shell_env` keeps its existing stdout `KEY=VALUE` contract. |
| 315 | - Structured stdout is interpreted only by `message_submit` in PR 1. Structured |
| 316 | observer hooks such as `turn_end`, `subagent_spawn`, and `subagent_complete` |
| 317 | receive JSON on stdin, but their stdout is ignored by the caller. |
| 318 | |
| 319 | ## 6. Review checkpoints |
| 320 | |
| 321 | PR 1 should be accepted only if: |
| 322 | |
| 323 | - submit mutation is covered by tests |
| 324 | - submit blocking is covered by tests |
| 325 | - the unchanged path preserves current behavior |
| 326 | - `shell_env` tests still prove the old stdout contract |
| 327 | - the docs clearly mark `message_submit` as the only mutable hook |
| 328 | |
| 329 | PR 2 should be accepted only if: |
| 330 | |
| 331 | - `turn_end` fires after `TurnComplete` app state updates |
| 332 | - failure is warn-only |
| 333 | - payload contains status and usage |
| 334 | |
| 335 | PR 3 should be accepted only if: |
| 336 | |
| 337 | - subagent hooks are observer-only |
| 338 | - failures do not affect subagent lifecycle |
| 339 | - payloads do not include unbounded or secret data |
| 340 |