# RFC: Hook Lifecycle Data Flow **Issue:** #1364 **Status:** Draft **Date:** 2026-05-28 ## 1. Problem CodeWhale already has lifecycle hooks and MCP support, but the current hook surface is mostly observer-only. This blocks portable extensions that need to participate in the agent data flow: - memory/context injection before a user message reaches the model - post-turn background analysis that prepares context for the next turn - sub-agent lifecycle visibility for orchestration and audit extensions The current `message_submit` event fires before dispatch, but its output is ignored. `TurnComplete`, `AgentSpawned`, and `AgentComplete` exist internally, but they are not exposed as configurable hook events. ## 2. PR split This issue should be implemented as three PRs. Each PR should be independently reviewable and should leave the hook system in a useful state. ### PR 1: Mutable `message_submit` Add a structured hook execution path for `message_submit` that can transform or block the user's submitted text before it is sent to the engine. Scope: - keep the existing `[[hooks.hooks]]` config shape - pass a JSON payload to the hook on stdin - interpret stdout JSON containing `text` as the replacement user text - treat exit code `2` as an intentional block - run multiple submit hooks serially in config order - keep existing env vars for compatibility - keep `shell_env` stdout parsing unchanged Non-goals: - no tool argument mutation - no global stdout JSON semantics for all hook events - no transcript or model response mutation ### PR 2: `turn_end` Expose the existing turn completion lifecycle as a hook event. Scope: - add `HookEvent::TurnEnd` with event name `turn_end` - fire from the UI's `EngineEvent::TurnComplete` branch after core app state, usage, cost, notifications, and receipt state have been updated - pass turn metadata on stdin as JSON - make failures non-blocking and warn-only - include a `stop_hook_active` field in the payload, initially `false`, so the contract can support re-entry protection later Non-goals: - no change to turn status - no blocking of user input - no transcript mutation from `turn_end` Implementation note for the v0.9 branch: the narrow #2578 harvest uses the shared structured observer path introduced for sub-agent lifecycle hooks. It fires before queued follow-up dispatch, after queue-recovery state is known, so the payload can report the queued-message count without letting a hook change what gets sent next. Stdout is ignored for `turn_end`; only `message_submit` has a stdout mutation contract. ### PR 3: Subagent lifecycle observer hooks Expose subagent start and completion as observer-only hook events. Scope: - add `HookEvent::SubagentSpawn` with event name `subagent_spawn` - add `HookEvent::SubagentComplete` with event name `subagent_complete` - fire from the existing `AgentSpawned` and `AgentComplete` UI branches - pass subagent metadata on stdin as JSON - make failures non-blocking and warn-only Non-goals: - no subagent spawn gating in the first version - no subagent prompt/result mutation - no changes to subagent scheduling ## 3. PR 1 detailed plan ### 3.1 Contract Configuration: ```toml [[hooks.hooks]] event = "message_submit" command = "~/.deepseek/hooks/inject-memory.sh" timeout_secs = 2 continue_on_error = true ``` Input payload on stdin: ```json { "event": "message_submit", "text": "original user text", "session_id": "sess_xxxx", "workspace": "/path/to/workspace", "mode": "agent", "model": "deepseek-chat", "total_tokens": 1234 } ``` Output payload on stdout: ```json { "text": "replacement user text" } ``` Rules: - exit `0` with stdout JSON containing `text: string` replaces the current text - exit `0` with empty stdout leaves the current text unchanged - exit `0` with JSON that does not contain `text` leaves the current text unchanged - exit `2` blocks submission before the message is appended to history or sent to the engine - other non-zero exits follow `continue_on_error` - `true`: warn, keep the current text, continue later hooks - `false`: stop later hooks and block submission with an error message - `background = true` on `message_submit` remains observer-only and cannot transform or block submission Multiple hooks: - hooks run in config order - each hook receives the latest transformed text - the final transformed text is the only text used by file mention expansion, skill wrapping, auto routing, history, and `api_messages` ### 3.2 Implementation steps 1. Add structured submit outcome types in `crates/tui/src/hooks.rs`: ```rust pub enum MessageSubmitOutcome { Unchanged, Replaced(String), Blocked { reason: String }, } ``` 2. Add a stdin-capable sync executor: ```rust fn execute_sync_with_stdin( &self, hook: &Hook, env_vars: &HashMap, stdin_json: &serde_json::Value, ) -> HookResult ``` This should reuse the existing timeout, working directory, stdout, stderr, and error handling behavior from `execute_sync`. 3. Add a `message_submit` transform entrypoint: ```rust pub fn execute_message_submit_transform( &self, context: &HookContext, original_text: &str, ) -> MessageSubmitOutcome ``` This method should: - filter configured `MessageSubmit` hooks through existing condition matching - build a JSON payload for each hook using the current text - run non-background hooks through `execute_sync_with_stdin` - run background hooks with the existing observer-only path - parse stdout JSON only for non-background hooks - return the final text or a block result 4. Apply the transformed message in `dispatch_user_message`: - run the transform before `last_submitted_prompt`, file mentions, history, and `api_messages` - create a local mutable `QueuedMessage` or replacement display text - if blocked, show a status message or toast and return without dispatch 5. Update `/hooks events`: - keep `message_submit` listed - update description to say it can transform or block user text 6. Update user-facing docs: - document the stdin/stdout contract - document exit code `2` - document that `shell_env` still uses `KEY=VALUE` stdout ### 3.3 Test plan Unit tests in `crates/tui/src/hooks.rs`: - parses stdout `{"text":"changed"}` as replacement - empty stdout means unchanged - JSON without `text` means unchanged - malformed stdout means unchanged with warning semantics - exit `2` maps to blocked - multiple hooks apply transforms in order - background `message_submit` hook cannot transform - `continue_on_error = false` blocks on non-zero failure TUI integration or focused dispatch tests: - transformed text is written to `api_messages` - transformed text is written to visible history - transformed text is used by file mention expansion - blocked submit does not append user history - blocked submit does not push an API message - blocked submit leaves loading state false Manual smoke test: 1. Add a config hook that prepends `[hooked] ` to every submitted message. 2. Submit `hello`. 3. Verify the transcript and model input use `[hooked] hello`. 4. Replace the hook with one that exits `2`. 5. Submit `hello`. 6. Verify no turn starts and the TUI shows the block reason. ## 4. Shared payload conventions All new structured hook payloads should include: - `event` - `session_id` - `workspace` - `mode` - `model` Event-specific payloads should add only fields that are stable and useful for extension authors. Avoid leaking secrets, full tool outputs, or unbounded transcript content in the first version. ## 5. Compatibility - Existing hook config remains valid. - Existing observer-only hooks keep working. - Existing env vars remain available. - `shell_env` keeps its existing stdout `KEY=VALUE` contract. - Structured stdout is interpreted only by `message_submit` in PR 1. Structured observer hooks such as `turn_end`, `subagent_spawn`, and `subagent_complete` receive JSON on stdin, but their stdout is ignored by the caller. ## 6. Review checkpoints PR 1 should be accepted only if: - submit mutation is covered by tests - submit blocking is covered by tests - the unchanged path preserves current behavior - `shell_env` tests still prove the old stdout contract - the docs clearly mark `message_submit` as the only mutable hook PR 2 should be accepted only if: - `turn_end` fires after `TurnComplete` app state updates - failure is warn-only - payload contains status and usage PR 3 should be accepted only if: - subagent hooks are observer-only - failures do not affect subagent lifecycle - payloads do not include unbounded or secret data