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>
8.4 KiB
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
textas the replacement user text - treat exit code
2as an intentional block - run multiple submit hooks serially in config order
- keep existing env vars for compatibility
- keep
shell_envstdout 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::TurnEndwith event nameturn_end - fire from the UI's
EngineEvent::TurnCompletebranch 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_activefield in the payload, initiallyfalse, 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::SubagentSpawnwith event namesubagent_spawn - add
HookEvent::SubagentCompletewith event namesubagent_complete - fire from the existing
AgentSpawnedandAgentCompleteUI 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:
[[hooks.hooks]]
event = "message_submit"
command = "~/.deepseek/hooks/inject-memory.sh"
timeout_secs = 2
continue_on_error = true
Input payload on stdin:
{
"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:
{ "text": "replacement user text" }
Rules:
- exit
0with stdout JSON containingtext: stringreplaces the current text - exit
0with empty stdout leaves the current text unchanged - exit
0with JSON that does not containtextleaves the current text unchanged - exit
2blocks submission before the message is appended to history or sent to the engine - other non-zero exits follow
continue_on_errortrue: warn, keep the current text, continue later hooksfalse: stop later hooks and block submission with an error message
background = trueonmessage_submitremains 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
- Add structured submit outcome types in
crates/tui/src/hooks.rs:
pub enum MessageSubmitOutcome {
Unchanged,
Replaced(String),
Blocked { reason: String },
}
- Add a stdin-capable sync executor:
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.
- Add a
message_submittransform entrypoint:
pub fn execute_message_submit_transform(
&self,
context: &HookContext,
original_text: &str,
) -> MessageSubmitOutcome
This method should:
- filter configured
MessageSubmithooks 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
- Apply the transformed message in
dispatch_user_message:
- run the transform before
last_submitted_prompt, file mentions, history, andapi_messages - create a local mutable
QueuedMessageor replacement display text - if blocked, show a status message or toast and return without dispatch
- Update
/hooks events:
- keep
message_submitlisted - update description to say it can transform or block user text
- Update user-facing docs:
- document the stdin/stdout contract
- document exit code
2 - document that
shell_envstill usesKEY=VALUEstdout
3.3 Test plan
Unit tests in crates/tui/src/hooks.rs:
- parses stdout
{"text":"changed"}as replacement - empty stdout means unchanged
- JSON without
textmeans unchanged - malformed stdout means unchanged with warning semantics
- exit
2maps to blocked - multiple hooks apply transforms in order
- background
message_submithook cannot transform continue_on_error = falseblocks 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:
- Add a config hook that prepends
[hooked]to every submitted message. - Submit
hello. - Verify the transcript and model input use
[hooked] hello. - Replace the hook with one that exits
2. - Submit
hello. - Verify no turn starts and the TUI shows the block reason.
4. Shared payload conventions
All new structured hook payloads should include:
eventsession_idworkspacemodemodel
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_envkeeps its existing stdoutKEY=VALUEcontract.- Structured stdout is interpreted only by
message_submitin PR 1. Structured observer hooks such asturn_end,subagent_spawn, andsubagent_completereceive 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_envtests still prove the old stdout contract- the docs clearly mark
message_submitas the only mutable hook
PR 2 should be accepted only if:
turn_endfires afterTurnCompleteapp 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