返回 CodeWhale
1364-hooks-lifecycle.md
根目录 / docs / rfcs / 1364-hooks-lifecycle.md
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
340 lines MARKDOWN