Merge pull request #2434 from Hmbown/codex/harvest-2318-message-submit-hooks

feat(hooks): harvest message_submit transform hooks
This commit is contained in:
Hunter Bown
2026-05-31 10:02:34 -07:00
committed by GitHub
7 changed files with 1276 additions and 29 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ fn events() -> CommandResult {
(HookEvent::SessionEnd, "fires once on graceful shutdown"),
(
HookEvent::MessageSubmit,
"fires when the user submits a turn (before model dispatch)",
"fires before model dispatch; can transform or block submitted text",
),
(
HookEvent::ToolCallBefore,
+700 -21
View File
@@ -14,10 +14,12 @@
#[allow(unused_imports)]
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashMap;
use std::io::Read;
use std::io::{Read, Write};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use wait_timeout::ChildExt;
@@ -423,6 +425,55 @@ pub struct HookResult {
pub error: Option<String>,
}
/// Result of running mutable `message_submit` hooks.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MessageSubmitOutcome {
/// No hook changed the submitted text.
Unchanged { warning: Option<String> },
/// One or more hooks replaced the submitted text.
Replaced {
text: String,
warning: Option<String>,
},
/// A hook intentionally blocked the submission.
Blocked { reason: String },
}
impl MessageSubmitOutcome {
pub fn unchanged() -> Self {
Self::Unchanged { warning: None }
}
pub fn replaced(text: String) -> Self {
Self::Replaced {
text,
warning: None,
}
}
fn with_warning(self, warning: Option<String>) -> Self {
match self {
Self::Unchanged { .. } => Self::Unchanged { warning },
Self::Replaced { text, .. } => Self::Replaced { text, warning },
Self::Blocked { reason } => Self::Blocked { reason },
}
}
pub fn warning(&self) -> Option<&str> {
match self {
Self::Unchanged { warning } | Self::Replaced { warning, .. } => warning.as_deref(),
Self::Blocked { .. } => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum MessageSubmitStdout {
Unchanged,
Replaced(String),
Invalid(String),
}
/// Executor for running hooks
#[derive(Debug, Clone)]
pub struct HookExecutor {
@@ -500,6 +551,102 @@ impl HookExecutor {
self.config.enabled && self.config.hooks.iter().any(|h| h.event == event)
}
/// Run configured `message_submit` hooks as a mutable submit pipeline.
///
/// This is deliberately separate from [`Self::execute`]: most hook events
/// are observer-only, while `message_submit` has a narrow stdout JSON
/// contract that can replace or block the submitted text.
pub fn execute_message_submit_transform(
&self,
context: &HookContext,
original_text: &str,
) -> MessageSubmitOutcome {
if !self.config.enabled {
return MessageSubmitOutcome::unchanged();
}
let hooks = self.config.hooks_for_event(HookEvent::MessageSubmit);
if hooks.is_empty() {
return MessageSubmitOutcome::unchanged();
}
let mut current_text = original_text.to_string();
let mut warning = None;
for hook in hooks {
let hook_context = context.clone().with_message(&current_text);
if !self.matches_condition(hook, &hook_context) {
continue;
}
let env_vars = hook_context.to_env_vars();
if hook.background {
let _ = self.execute_background(hook, &env_vars);
continue;
}
let payload = message_submit_payload(&hook_context, &current_text);
let result = self.execute_sync_with_stdin(hook, &env_vars, &payload);
if result.exit_code == Some(2) {
return MessageSubmitOutcome::Blocked {
reason: message_submit_block_reason(
&result,
"message_submit hook blocked submission",
),
};
}
if !result.success {
let label = result.name.as_deref().unwrap_or("(unnamed)");
tracing::warn!(
target: "hooks",
hook = label,
event = "message_submit",
exit_code = ?result.exit_code,
duration_ms = result.duration.as_millis() as u64,
error = result.error.as_deref().unwrap_or(""),
stderr_head = %result.stderr.lines().next().unwrap_or(""),
"message_submit hook failed"
);
if hook.continue_on_error {
warning = message_submit_continue_warning(&result).or(warning);
continue;
}
return MessageSubmitOutcome::Blocked {
reason: message_submit_block_reason(
&result,
"message_submit hook failed and blocked submission",
),
};
}
match parse_message_submit_stdout(&result.stdout) {
MessageSubmitStdout::Unchanged => {}
MessageSubmitStdout::Replaced(text) => {
current_text = text;
}
MessageSubmitStdout::Invalid(reason) => {
tracing::warn!(
target: "hooks",
hook = result.name.as_deref().unwrap_or("(unnamed)"),
event = "message_submit",
reason = %reason,
"ignored invalid message_submit hook stdout"
);
}
}
}
if current_text == original_text {
MessageSubmitOutcome::unchanged().with_warning(warning)
} else {
MessageSubmitOutcome::replaced(current_text).with_warning(warning)
}
}
/// Run every `ShellEnv` hook for this context and merge their stdout
/// (`KEY=VALUE\n` lines) into a single env-var map. Used by the
/// `exec_shell` tool to inject ephemeral credentials, per-skill PATH
@@ -659,6 +806,28 @@ impl HookExecutor {
/// Execute a hook synchronously
fn execute_sync(&self, hook: &Hook, env_vars: &HashMap<String, String>) -> HookResult {
self.execute_sync_inner(hook, env_vars, None)
}
/// Execute a hook synchronously with a structured JSON stdin payload.
///
/// Used by mutable `message_submit` hooks. Existing observer hooks keep the
/// stdin-less [`Self::execute_sync`] path so their behavior is unchanged.
fn execute_sync_with_stdin(
&self,
hook: &Hook,
env_vars: &HashMap<String, String>,
stdin_json: &serde_json::Value,
) -> HookResult {
self.execute_sync_inner(hook, env_vars, Some(stdin_json))
}
fn execute_sync_inner(
&self,
hook: &Hook,
env_vars: &HashMap<String, String>,
stdin_json: Option<&serde_json::Value>,
) -> HookResult {
let started = Instant::now();
let working_dir = self
.config
@@ -672,13 +841,32 @@ impl HookExecutor {
.unwrap_or(hook.timeout_secs);
let timeout = Duration::from_secs(timeout_secs);
let mut child = match Self::build_shell_command(&hook.command)
let stdin_bytes = match stdin_json.map(serde_json::to_vec).transpose() {
Ok(bytes) => bytes,
Err(e) => {
return HookResult {
name: hook.name.clone(),
success: false,
exit_code: None,
stdout: String::new(),
stderr: String::new(),
duration: started.elapsed(),
error: Some(format!("Failed to encode hook stdin: {e}")),
};
}
};
let mut command = Self::build_shell_command(&hook.command);
command
.current_dir(&working_dir)
.envs(env_vars)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
.stderr(Stdio::piped());
if stdin_bytes.is_some() {
command.stdin(Stdio::piped());
}
let mut child = match command.spawn() {
Ok(child) => child,
Err(e) => {
return HookResult {
@@ -693,25 +881,29 @@ impl HookExecutor {
}
};
fn read_pipe(mut pipe: impl Read) -> String {
let mut buf = String::new();
let _ = pipe.read_to_string(&mut buf);
buf
}
let stdout_reader = child.stdout.take().map(spawn_pipe_reader);
let stderr_reader = child.stderr.take().map(spawn_pipe_reader);
let _stdin_writer = match (stdin_bytes, child.stdin.take()) {
(Some(bytes), Some(stdin)) => Some(spawn_stdin_writer(stdin, bytes)),
_ => None,
};
match child.wait_timeout(timeout) {
Ok(Some(status)) => HookResult {
name: hook.name.clone(),
success: status.success(),
exit_code: status.code(),
stdout: child.stdout.take().map(read_pipe).unwrap_or_default(),
stderr: child.stderr.take().map(read_pipe).unwrap_or_default(),
stdout: join_reader(stdout_reader),
stderr: join_reader(stderr_reader),
duration: started.elapsed(),
error: None,
},
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
// Do not join pipe threads on timeout: descendant processes can
// inherit pipe fds, and waiting for those threads would defeat
// the hook timeout we just enforced.
HookResult {
name: hook.name.clone(),
success: false,
@@ -722,15 +914,19 @@ impl HookExecutor {
error: Some(format!("Hook timed out after {timeout_secs}s")),
}
}
Err(e) => HookResult {
name: hook.name.clone(),
success: false,
exit_code: None,
stdout: String::new(),
stderr: String::new(),
duration: started.elapsed(),
error: Some(format!("Failed to wait for hook: {e}")),
},
Err(e) => {
let _ = child.kill();
let _ = child.wait();
HookResult {
name: hook.name.clone(),
success: false,
exit_code: None,
stdout: String::new(),
stderr: String::new(),
duration: started.elapsed(),
error: Some(format!("Failed to wait for hook: {e}")),
}
}
}
}
@@ -768,6 +964,115 @@ impl HookExecutor {
}
}
fn spawn_pipe_reader(mut pipe: impl Read + Send + 'static) -> JoinHandle<String> {
thread::spawn(move || {
let mut buf = String::new();
let _ = pipe.read_to_string(&mut buf);
buf
})
}
fn join_reader(reader: Option<JoinHandle<String>>) -> String {
reader
.and_then(|handle| handle.join().ok())
.unwrap_or_default()
}
fn spawn_stdin_writer(mut stdin: std::process::ChildStdin, mut bytes: Vec<u8>) -> JoinHandle<()> {
thread::spawn(move || {
bytes.push(b'\n');
let _ = stdin.write_all(&bytes);
let _ = stdin.flush();
})
}
fn message_submit_payload(context: &HookContext, text: &str) -> serde_json::Value {
json!({
"event": HookEvent::MessageSubmit.as_str(),
"text": text,
"session_id": context.session_id.as_deref(),
"workspace": context.workspace.as_ref().map(|path| path.display().to_string()),
"mode": context.mode.as_deref(),
"model": context.model.as_deref(),
"total_tokens": context.total_tokens,
})
}
fn parse_message_submit_stdout(stdout: &str) -> MessageSubmitStdout {
let trimmed = stdout.trim();
if trimmed.is_empty() {
return MessageSubmitStdout::Unchanged;
}
let value: serde_json::Value = match serde_json::from_str(trimmed) {
Ok(value) => value,
Err(e) => return MessageSubmitStdout::Invalid(format!("invalid JSON: {e}")),
};
let Some(object) = value.as_object() else {
return MessageSubmitStdout::Invalid("stdout JSON must be an object".to_string());
};
match object.get("text") {
Some(serde_json::Value::String(text)) if !text.is_empty() => {
MessageSubmitStdout::Replaced(text.clone())
}
Some(serde_json::Value::String(_)) => {
MessageSubmitStdout::Invalid("stdout `text` field must not be empty".to_string())
}
Some(_) => MessageSubmitStdout::Invalid("stdout `text` field must be a string".to_string()),
None => MessageSubmitStdout::Unchanged,
}
}
fn message_submit_continue_warning(result: &HookResult) -> Option<String> {
message_submit_stdout_reason(&result.stdout)
.or_else(|| first_non_empty_line(&result.stderr))
.or_else(|| first_non_empty_line(&result.stdout))
.or_else(|| result.error.as_deref().and_then(first_non_empty_line))
}
fn message_submit_block_reason(result: &HookResult, fallback: &str) -> String {
if let Some(reason) = message_submit_stdout_reason(&result.stdout) {
return reason;
}
if let Some(reason) = first_non_empty_line(&result.stderr) {
return reason;
}
if let Some(reason) = first_non_empty_line(&result.stdout) {
return reason;
}
if let Some(reason) = result.error.as_deref().and_then(first_non_empty_line) {
return reason;
}
fallback.to_string()
}
fn message_submit_stdout_reason(stdout: &str) -> Option<String> {
let value: serde_json::Value = serde_json::from_str(stdout.trim()).ok()?;
value
.get("reason")
.and_then(serde_json::Value::as_str)
.map(truncate_hook_message)
}
fn first_non_empty_line(text: &str) -> Option<String> {
text.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(truncate_hook_message)
}
fn truncate_hook_message(message: &str) -> String {
const MAX_CHARS: usize = 240;
let mut chars = message.chars();
let mut out: String = chars.by_ref().take(MAX_CHARS).collect();
if chars.next().is_some() {
out.push('…');
}
out
}
/// Parse `KEY=VALUE\n` lines from a `shell_env` hook's stdout into a map.
///
/// Tolerated: blank lines, leading whitespace, `#` comment lines (ignored),
@@ -850,6 +1155,66 @@ NOEQUAL line dropped
assert!(parsed.is_empty());
}
#[test]
fn parse_message_submit_stdout_replaces_text() {
assert_eq!(
super::parse_message_submit_stdout(r#"{"text":"changed"}"#),
MessageSubmitStdout::Replaced("changed".to_string())
);
}
#[test]
fn parse_message_submit_stdout_empty_is_unchanged() {
assert_eq!(
super::parse_message_submit_stdout(" \n\t "),
MessageSubmitStdout::Unchanged
);
}
#[test]
fn parse_message_submit_stdout_without_text_is_unchanged() {
assert_eq!(
super::parse_message_submit_stdout(r#"{"reason":"only used for blocks"}"#),
MessageSubmitStdout::Unchanged
);
}
#[test]
fn parse_message_submit_stdout_rejects_malformed_json() {
assert!(matches!(
super::parse_message_submit_stdout("not json"),
MessageSubmitStdout::Invalid(_)
));
}
#[test]
fn parse_message_submit_stdout_rejects_non_string_text() {
assert!(matches!(
super::parse_message_submit_stdout(r#"{"text":123}"#),
MessageSubmitStdout::Invalid(_)
));
}
#[test]
fn parse_message_submit_stdout_rejects_empty_text() {
assert_eq!(
super::parse_message_submit_stdout(r#"{"text":""}"#),
MessageSubmitStdout::Invalid("stdout `text` field must not be empty".to_string())
);
}
#[test]
fn parse_message_submit_stdout_rejects_non_object_json() {
assert!(matches!(
super::parse_message_submit_stdout(r#"["not", "an", "object"]"#),
MessageSubmitStdout::Invalid(_)
));
assert!(matches!(
super::parse_message_submit_stdout(r#""not an object""#),
MessageSubmitStdout::Invalid(_)
));
}
#[test]
fn test_hook_event_as_str() {
assert_eq!(HookEvent::SessionStart.as_str(), "session_start");
@@ -987,6 +1352,35 @@ NOEQUAL line dropped
);
}
#[cfg(not(windows))]
#[test]
fn message_submit_stdin_write_does_not_deadlock_when_hook_writes_first() {
let dir = tempfile::tempdir().expect("tempdir");
let command = write_hook_script(
&dir,
"write_before_read.sh",
r#"#!/bin/sh
dd if=/dev/zero bs=1024 count=256 2>/dev/null | tr '\000' x
dd if=/dev/zero bs=1024 count=256 2>/dev/null | tr '\000' e >&2
payload=$(cat)
printf '\ndone:%s\n' "${#payload}"
"#,
);
let hook = Hook::new(HookEvent::MessageSubmit, &command).with_timeout(5);
let executor = HookExecutor::new(HooksConfig::default(), dir.path().to_path_buf());
let env_vars = HashMap::new();
let payload = json!({
"event": "message_submit",
"text": "x".repeat(256 * 1024),
});
let result = executor.execute_sync_with_stdin(&hook, &env_vars, &payload);
assert!(result.success, "hook should complete: {result:?}");
assert!(result.stdout.contains("done:"), "stdout was drained");
assert!(result.stderr.len() >= 256 * 1024, "stderr was drained");
}
#[test]
fn test_executor_session_id() {
let executor = HookExecutor::new(HooksConfig::default(), PathBuf::from("."));
@@ -995,6 +1389,291 @@ NOEQUAL line dropped
assert_eq!(executor.session_id().len(), 13); // "sess_" + 8 chars
}
#[cfg(not(windows))]
fn write_hook_script(dir: &tempfile::TempDir, name: &str, content: &str) -> String {
let path = dir.path().join(name);
std::fs::write(&path, content).expect("write hook script");
format!("sh {}", path.display())
}
#[cfg(not(windows))]
fn submit_context(dir: &tempfile::TempDir) -> HookContext {
HookContext::new()
.with_session_id("sess_test")
.with_workspace(dir.path().to_path_buf())
.with_mode("agent")
.with_model("deepseek-test")
.with_tokens(42)
}
#[cfg(not(windows))]
#[test]
fn message_submit_transform_applies_hooks_in_order() {
let dir = tempfile::tempdir().expect("tempdir");
let first = write_hook_script(
&dir,
"first.sh",
r#"#!/bin/sh
printf '%s\n' '{"text":"first"}'
"#,
);
let second = write_hook_script(
&dir,
"second.sh",
r#"#!/bin/sh
payload=$(cat)
case "$payload" in
*'"text":"first"'*) printf '%s\n' '{"text":"first second"}' ;;
*) printf '%s\n' '{"text":"wrong"}' ;;
esac
"#,
);
let config = HooksConfig {
enabled: true,
hooks: vec![
Hook::new(HookEvent::MessageSubmit, &first),
Hook::new(HookEvent::MessageSubmit, &second),
],
working_dir: Some(dir.path().to_path_buf()),
..HooksConfig::default()
};
let executor = HookExecutor::new(config, dir.path().to_path_buf());
assert_eq!(
executor.execute_message_submit_transform(&submit_context(&dir), "original"),
MessageSubmitOutcome::replaced("first second".to_string())
);
}
#[cfg(not(windows))]
#[test]
fn message_submit_transform_exit_two_blocks_submission() {
let dir = tempfile::tempdir().expect("tempdir");
let command = write_hook_script(
&dir,
"block.sh",
r#"#!/bin/sh
printf '%s\n' '{"reason":"policy blocked this prompt"}'
exit 2
"#,
);
let config = HooksConfig {
enabled: true,
hooks: vec![Hook::new(HookEvent::MessageSubmit, &command)],
working_dir: Some(dir.path().to_path_buf()),
..HooksConfig::default()
};
let executor = HookExecutor::new(config, dir.path().to_path_buf());
assert_eq!(
executor.execute_message_submit_transform(&submit_context(&dir), "original"),
MessageSubmitOutcome::Blocked {
reason: "policy blocked this prompt".to_string()
}
);
}
#[cfg(not(windows))]
#[test]
fn background_message_submit_hook_is_observer_only() {
let dir = tempfile::tempdir().expect("tempdir");
let command = write_hook_script(
&dir,
"background.sh",
r#"#!/bin/sh
printf '%s\n' '{"text":"ignored"}'
"#,
);
let config = HooksConfig {
enabled: true,
hooks: vec![Hook::new(HookEvent::MessageSubmit, &command).background()],
working_dir: Some(dir.path().to_path_buf()),
..HooksConfig::default()
};
let executor = HookExecutor::new(config, dir.path().to_path_buf());
assert_eq!(
executor.execute_message_submit_transform(&submit_context(&dir), "original"),
MessageSubmitOutcome::unchanged()
);
}
#[test]
fn message_submit_transform_without_configured_hooks_is_unchanged() {
let executor = HookExecutor::new(HooksConfig::default(), PathBuf::from("."));
assert_eq!(
executor.execute_message_submit_transform(&HookContext::new(), "original"),
MessageSubmitOutcome::unchanged()
);
}
#[cfg(not(windows))]
#[test]
fn message_submit_transform_skips_non_matching_condition() {
let dir = tempfile::tempdir().expect("tempdir");
let command = write_hook_script(
&dir,
"replace.sh",
r#"#!/bin/sh
printf '%s\n' '{"text":"should not apply"}'
"#,
);
let hook =
Hook::new(HookEvent::MessageSubmit, &command).with_condition(HookCondition::Mode {
mode: "plan".into(),
});
let config = HooksConfig {
enabled: true,
hooks: vec![hook],
working_dir: Some(dir.path().to_path_buf()),
..HooksConfig::default()
};
let executor = HookExecutor::new(config, dir.path().to_path_buf());
assert_eq!(
executor.execute_message_submit_transform(&submit_context(&dir), "original"),
MessageSubmitOutcome::unchanged()
);
}
#[cfg(not(windows))]
#[test]
fn message_submit_continue_on_error_true_keeps_text_and_runs_later_hooks() {
let dir = tempfile::tempdir().expect("tempdir");
let failing = write_hook_script(
&dir,
"fail_continue.sh",
r#"#!/bin/sh
printf '%s\n' 'soft failure' >&2
exit 9
"#,
);
let replacing = write_hook_script(
&dir,
"replace_after_failure.sh",
r#"#!/bin/sh
printf '%s\n' '{"text":"recovered"}'
"#,
);
let config = HooksConfig {
enabled: true,
hooks: vec![
Hook::new(HookEvent::MessageSubmit, &failing),
Hook::new(HookEvent::MessageSubmit, &replacing),
],
working_dir: Some(dir.path().to_path_buf()),
..HooksConfig::default()
};
let executor = HookExecutor::new(config, dir.path().to_path_buf());
assert_eq!(
executor.execute_message_submit_transform(&submit_context(&dir), "original"),
MessageSubmitOutcome::replaced("recovered".to_string())
.with_warning(Some("soft failure".to_string()))
);
}
#[cfg(not(windows))]
#[test]
fn message_submit_timeout_continue_surfaces_warning_and_runs_later_hooks() {
let dir = tempfile::tempdir().expect("tempdir");
let slow = write_hook_script(
&dir,
"slow_continue.sh",
r#"#!/bin/sh
sleep 2
"#,
);
let replacing = write_hook_script(
&dir,
"replace_after_timeout.sh",
r#"#!/bin/sh
printf '%s\n' '{"text":"after timeout"}'
"#,
);
let mut slow_hook = Hook::new(HookEvent::MessageSubmit, &slow).with_timeout(1);
slow_hook.continue_on_error = true;
let config = HooksConfig {
enabled: true,
hooks: vec![slow_hook, Hook::new(HookEvent::MessageSubmit, &replacing)],
working_dir: Some(dir.path().to_path_buf()),
..HooksConfig::default()
};
let executor = HookExecutor::new(config, dir.path().to_path_buf());
assert_eq!(
executor.execute_message_submit_transform(&submit_context(&dir), "original"),
MessageSubmitOutcome::replaced("after timeout".to_string())
.with_warning(Some("Hook timed out after 1s".to_string()))
);
}
#[cfg(not(windows))]
#[test]
fn message_submit_invalid_stdout_keeps_text_and_runs_later_hooks() {
let dir = tempfile::tempdir().expect("tempdir");
let invalid = write_hook_script(
&dir,
"invalid_stdout.sh",
r#"#!/bin/sh
printf '%s\n' 'not json'
"#,
);
let replacing = write_hook_script(
&dir,
"replace_after_invalid.sh",
r#"#!/bin/sh
printf '%s\n' '{"text":"valid later"}'
"#,
);
let config = HooksConfig {
enabled: true,
hooks: vec![
Hook::new(HookEvent::MessageSubmit, &invalid),
Hook::new(HookEvent::MessageSubmit, &replacing),
],
working_dir: Some(dir.path().to_path_buf()),
..HooksConfig::default()
};
let executor = HookExecutor::new(config, dir.path().to_path_buf());
assert_eq!(
executor.execute_message_submit_transform(&submit_context(&dir), "original"),
MessageSubmitOutcome::replaced("valid later".to_string())
);
}
#[cfg(not(windows))]
#[test]
fn message_submit_continue_on_error_false_blocks_on_failure() {
let dir = tempfile::tempdir().expect("tempdir");
let command = write_hook_script(
&dir,
"fail.sh",
r#"#!/bin/sh
printf '%s\n' 'hard failure' >&2
exit 7
"#,
);
let mut hook = Hook::new(HookEvent::MessageSubmit, &command);
hook.continue_on_error = false;
let config = HooksConfig {
enabled: true,
hooks: vec![hook],
working_dir: Some(dir.path().to_path_buf()),
..HooksConfig::default()
};
let executor = HookExecutor::new(config, dir.path().to_path_buf());
assert_eq!(
executor.execute_message_submit_transform(&submit_context(&dir), "original"),
MessageSubmitOutcome::Blocked {
reason: "hard failure".to_string()
}
);
}
#[test]
fn has_hooks_for_event_fast_path_returns_false_for_empty_config() {
let executor = HookExecutor::disabled();
+23 -6
View File
@@ -4500,19 +4500,36 @@ async fn dispatch_user_message(
app: &mut App,
config: &Config,
engine_handle: &EngineHandle,
message: QueuedMessage,
mut message: QueuedMessage,
) -> Result<()> {
// #455 (observer-only): fire `message_submit` hooks before
// dispatch. Hooks see the user's display text via the
// `with_message` builder. Read-only — they can log, audit, or
// notify but cannot mutate the message that goes to the engine.
// #1364: run mutable `message_submit` hooks before dispatch. Hooks see the
// user's display text and may replace or block it before file mentions,
// skill wrapping, history, and model input are resolved.
// Fast-path skip when no hooks configured.
if app
.hooks
.has_hooks_for_event(crate::hooks::HookEvent::MessageSubmit)
{
let context = app.base_hook_context().with_message(&message.display);
let _ = app.execute_hooks(crate::hooks::HookEvent::MessageSubmit, &context);
let outcome = app
.hooks
.execute_message_submit_transform(&context, &message.display);
if let Some(warning) = outcome.warning() {
app.status_message = Some(warning.to_string());
}
match outcome {
crate::hooks::MessageSubmitOutcome::Unchanged { .. } => {}
crate::hooks::MessageSubmitOutcome::Replaced { text, .. } => {
message.display = text;
}
crate::hooks::MessageSubmitOutcome::Blocked { reason } => {
app.status_message = Some(reason);
app.is_loading = false;
app.dispatch_started_at = None;
app.runtime_turn_status = None;
return Ok(());
}
}
}
// Set immediately to prevent double-dispatch before TurnStarted event arrives.
+213
View File
@@ -2072,6 +2072,219 @@ async fn dispatch_user_message_failed_send_clears_loading_state() {
assert!(app.dispatch_started_at.is_none());
}
#[cfg(not(windows))]
fn write_message_submit_hook(dir: &TempDir, name: &str, body: &str) -> String {
let path = dir.path().join(name);
std::fs::write(&path, body).expect("write message_submit hook");
format!("sh {}", path.display())
}
#[cfg(not(windows))]
fn configure_single_message_submit_hook(app: &mut App, dir: &TempDir, command: String) {
configure_message_submit_hooks(app, dir, vec![command]);
}
#[cfg(not(windows))]
fn configure_message_submit_hooks(app: &mut App, dir: &TempDir, commands: Vec<String>) {
app.hooks = crate::hooks::HookExecutor::new(
crate::hooks::HooksConfig {
enabled: true,
hooks: commands
.iter()
.map(|command| {
crate::hooks::Hook::new(crate::hooks::HookEvent::MessageSubmit, command)
})
.collect(),
working_dir: Some(dir.path().to_path_buf()),
..crate::hooks::HooksConfig::default()
},
dir.path().to_path_buf(),
);
}
#[cfg(not(windows))]
#[tokio::test]
async fn dispatch_user_message_surfaces_continued_message_submit_timeout() {
let dir = TempDir::new().expect("tempdir");
let slow = write_message_submit_hook(
&dir,
"slow.sh",
r#"#!/bin/sh
sleep 2
"#,
);
let replacing = write_message_submit_hook(
&dir,
"replace.sh",
r#"#!/bin/sh
printf '%s\n' '{"text":"after timeout"}'
"#,
);
let mut app = create_test_app();
app.hooks = crate::hooks::HookExecutor::new(
crate::hooks::HooksConfig {
enabled: true,
hooks: vec![
crate::hooks::Hook::new(crate::hooks::HookEvent::MessageSubmit, &slow)
.with_timeout(1),
crate::hooks::Hook::new(crate::hooks::HookEvent::MessageSubmit, &replacing),
],
working_dir: Some(dir.path().to_path_buf()),
..crate::hooks::HooksConfig::default()
},
dir.path().to_path_buf(),
);
let mut engine = crate::core::engine::mock_engine_handle();
let config = Config::default();
dispatch_user_message(
&mut app,
&config,
&engine.handle,
QueuedMessage::new("hello".to_string(), None),
)
.await
.expect("dispatch user message");
assert_eq!(
app.status_message.as_deref(),
Some("Hook timed out after 1s")
);
match engine.rx_op.recv().await.expect("send message op") {
crate::core::ops::Op::SendMessage { content, .. } => {
assert_eq!(content, "after timeout");
}
other => panic!("expected SendMessage, got {other:?}"),
}
}
#[cfg(not(windows))]
#[tokio::test]
async fn dispatch_user_message_surfaces_continued_message_submit_stderr() {
let dir = TempDir::new().expect("tempdir");
let failing = write_message_submit_hook(
&dir,
"fail.sh",
r#"#!/bin/sh
printf '%s\n' 'soft failure' >&2
exit 9
"#,
);
let replacing = write_message_submit_hook(
&dir,
"replace.sh",
r#"#!/bin/sh
printf '%s\n' '{"text":"after soft failure"}'
"#,
);
let mut app = create_test_app();
configure_message_submit_hooks(&mut app, &dir, vec![failing, replacing]);
let mut engine = crate::core::engine::mock_engine_handle();
let config = Config::default();
dispatch_user_message(
&mut app,
&config,
&engine.handle,
QueuedMessage::new("hello".to_string(), None),
)
.await
.expect("dispatch user message");
assert_eq!(app.status_message.as_deref(), Some("soft failure"));
match engine.rx_op.recv().await.expect("send message op") {
crate::core::ops::Op::SendMessage { content, .. } => {
assert_eq!(content, "after soft failure");
}
other => panic!("expected SendMessage, got {other:?}"),
}
}
#[cfg(not(windows))]
#[tokio::test]
async fn dispatch_user_message_uses_transformed_message_submit_text() {
let dir = TempDir::new().expect("tempdir");
let command = write_message_submit_hook(
&dir,
"replace.sh",
r#"#!/bin/sh
printf '%s\n' '{"text":"[hooked] hello"}'
"#,
);
let mut app = create_test_app();
configure_single_message_submit_hook(&mut app, &dir, command);
let mut engine = crate::core::engine::mock_engine_handle();
let config = Config::default();
dispatch_user_message(
&mut app,
&config,
&engine.handle,
QueuedMessage::new("hello".to_string(), None),
)
.await
.expect("dispatch user message");
assert_eq!(app.last_submitted_prompt.as_deref(), Some("[hooked] hello"));
assert!(app.history.iter().any(|cell| matches!(
cell,
HistoryCell::User { content } if content == "[hooked] hello"
)));
assert_eq!(app.api_messages.len(), 1);
assert!(matches!(
&app.api_messages[0].content[0],
ContentBlock::Text { text, .. } if text == "[hooked] hello"
));
match engine.rx_op.recv().await.expect("send message op") {
crate::core::ops::Op::SendMessage { content, .. } => {
assert_eq!(content, "[hooked] hello");
}
other => panic!("expected SendMessage, got {other:?}"),
}
}
#[cfg(not(windows))]
#[tokio::test]
async fn dispatch_user_message_blocked_by_message_submit_hook_does_not_start_turn() {
let dir = TempDir::new().expect("tempdir");
let command = write_message_submit_hook(
&dir,
"block.sh",
r#"#!/bin/sh
printf '%s\n' '{"reason":"blocked by test hook"}'
exit 2
"#,
);
let mut app = create_test_app();
configure_single_message_submit_hook(&mut app, &dir, command);
let mut engine = crate::core::engine::mock_engine_handle();
let config = Config::default();
dispatch_user_message(
&mut app,
&config,
&engine.handle,
QueuedMessage::new("hello".to_string(), None),
)
.await
.expect("blocked submit is handled locally");
assert_eq!(app.status_message.as_deref(), Some("blocked by test hook"));
assert!(app.api_messages.is_empty());
assert!(
app.history
.iter()
.all(|cell| !matches!(cell, HistoryCell::User { .. }))
);
assert!(!app.is_loading);
assert!(app.dispatch_started_at.is_none());
assert!(app.runtime_turn_status.is_none());
assert!(
engine.rx_op.try_recv().is_err(),
"blocked submit must not send any engine operation"
);
}
#[test]
fn turn_liveness_watchdog_clears_stale_dispatch() {
let mut app = create_test_app();
+52
View File
@@ -376,6 +376,58 @@ obvious when hooks are globally suppressed. Hooks are
configured under `[[hooks.hooks]]` entries — see the existing
hook-system documentation for the full schema.
### Mutable `message_submit` hooks
`message_submit` hooks run before a submitted message is added to
history or sent to the model. Unlike observer-only lifecycle hooks,
non-background `message_submit` hooks can replace or block the
submitted text.
```toml
[[hooks.hooks]]
event = "message_submit"
command = "~/.codewhale/hooks/inject-context.sh"
timeout_secs = 2
continue_on_error = true
```
The hook receives JSON on stdin:
```json
{
"event": "message_submit",
"text": "original user text",
"session_id": "sess_12345678",
"workspace": "/path/to/workspace",
"mode": "agent",
"model": "deepseek-chat",
"total_tokens": 1234
}
```
If the hook exits `0` and prints JSON with a non-empty string `text` field,
that value replaces the submitted text:
```json
{ "text": "replacement user text" }
```
Exit `0` with empty stdout, or stdout JSON without `text`, leaves
the current text unchanged. A JSON `text` field must not be empty;
`{"text":""}` is treated as invalid stdout and ignored. Exit `2`
blocks the submission before the turn starts; a `reason` field,
stderr, or stdout can provide the status message shown in the TUI.
Other non-zero exits follow the hook's `continue_on_error` setting.
Timeouts and spawn failures are also surfaced as transient TUI status
messages when `continue_on_error = true` lets submission continue.
Multiple `message_submit` hooks run in config order, and each hook
receives the text produced by the previous hook. Hooks marked
`background = true` are observer-only and cannot transform or block
the message. Existing environment variables remain available.
`shell_env` hooks keep their existing `KEY=VALUE` stdout contract;
the JSON stdout contract applies only to `message_submit`.
### Composer stash (`/stash`, Ctrl+S)
Press **Ctrl+S** in the composer to park the current draft to
+276
View File
@@ -0,0 +1,276 @@
# 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`
### 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.
## 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
+11 -1
View File
@@ -184,6 +184,11 @@ command = "~/.codewhale/hooks/pre.sh" # / message_submit / mode_change /
<p className="mt-4 text-sm text-ink-soft">
<Link className="body-link" href="https://github.com/Hmbown/CodeWhale/blob/main/config.example.toml">config.example.toml</Link>
</p>
<p className="mt-3 text-sm text-ink-soft leading-[1.9]">
<code className="inline">message_submit</code> hooks run before a user message is sent to the model. A non-background hook can print
<code className="inline">{'{"text":"replacement"}'}</code> on stdout to replace the message; <code className="inline">text</code> must be non-empty. Exit with code <code className="inline">2</code> to block the submission.
<code className="inline">shell_env</code> keeps its existing <code className="inline">KEY=VALUE</code> stdout contract.
</p>
</section>
{/* MCP */}
@@ -434,6 +439,11 @@ command = "~/.codewhale/hooks/pre.sh" # / message_submit / mode_change /
<p className="mt-4 text-sm text-ink-soft">
Full reference: <Link className="body-link" href="https://github.com/Hmbown/CodeWhale/blob/main/config.example.toml">config.example.toml</Link>.
</p>
<p className="mt-3 text-sm text-ink-soft leading-relaxed">
<code className="inline">message_submit</code> hooks run before a user message is sent to the model. A non-background hook can print
<code className="inline">{'{"text":"replacement"}'}</code> on stdout to replace the message; <code className="inline">text</code> must be non-empty. Exit with code <code className="inline">2</code> to block the submission.
<code className="inline">shell_env</code> keeps its existing <code className="inline">KEY=VALUE</code> stdout contract.
</p>
</section>
<section id="mcp" className="scroll-mt-32">
@@ -547,4 +557,4 @@ command = "~/.codewhale/hooks/pre.sh" # / message_submit / mode_change /
)}
</>
);
}
}