Files
codewhale/docs/rfcs/1364-hooks-lifecycle.md
T
Hunter B 0d66ef34d1 feat(hooks): add turn_end observer hook
Harvested the narrow Rust/docs slice of PR #2578 by @AresNing for #1364. The event uses the maintained structured observer path: JSON stdin, stdout ignored, warn-only failures, and no ability to block or mutate the turn.

The hook fires after post-turn app state, usage totals, cost, notification, receipt, and queue-recovery state are updated, before queued follow-up dispatch. Docs, RFC notes, /hooks discovery, and v0.9 tracking now describe the observer-only contract.

Co-authored-by: AresNing <49557311+AresNing@users.noreply.github.com>
2026-06-04 00:09:01 -07:00

286 lines
8.4 KiB
Markdown

# 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<String, String>,
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