start of hooks engine (#13276)

(Experimental)

This PR adds a first MVP for hooks, with SessionStart and Stop

The core design is:

- hooks live in a dedicated engine under codex-rs/hooks
- each hook type has its own event-specific file
- hook execution is synchronous and blocks normal turn progression while
running
- matching hooks run in parallel, then their results are aggregated into
a normalized HookRunSummary

On the AppServer side, hooks are exposed as operational metadata rather
than transcript-native items:

- new live notifications: hook/started, hook/completed
- persisted/replayed hook results live on Turn.hookRuns
- we intentionally did not add hook-specific ThreadItem variants

Hooks messages are not persisted, they remain ephemeral. The context
changes they add are (they get appended to the user's prompt)
This commit is contained in:
Andrei Eternal
2026-03-09 21:11:31 -07:00
committed by GitHub
parent da616136cc
commit 244b2d53f4
73 changed files with 4791 additions and 483 deletions

View File

@@ -0,0 +1,2 @@
pub mod session_start;
pub mod stop;

View File

@@ -0,0 +1,393 @@
use std::path::PathBuf;
use codex_protocol::ThreadId;
use codex_protocol::protocol::HookCompletedEvent;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookOutputEntry;
use codex_protocol::protocol::HookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookRunSummary;
use crate::engine::CommandShell;
use crate::engine::ConfiguredHandler;
use crate::engine::command_runner::CommandRunResult;
use crate::engine::dispatcher;
use crate::engine::output_parser;
use crate::schema::SessionStartCommandInput;
#[derive(Debug, Clone, Copy)]
pub enum SessionStartSource {
Startup,
Resume,
}
impl SessionStartSource {
pub fn as_str(self) -> &'static str {
match self {
Self::Startup => "startup",
Self::Resume => "resume",
}
}
}
#[derive(Debug, Clone)]
pub struct SessionStartRequest {
pub session_id: ThreadId,
pub cwd: PathBuf,
pub transcript_path: Option<PathBuf>,
pub model: String,
pub permission_mode: String,
pub source: SessionStartSource,
}
#[derive(Debug)]
pub struct SessionStartOutcome {
pub hook_events: Vec<HookCompletedEvent>,
pub should_stop: bool,
pub stop_reason: Option<String>,
pub additional_context: Option<String>,
}
#[derive(Debug, PartialEq, Eq)]
struct SessionStartHandlerData {
should_stop: bool,
stop_reason: Option<String>,
additional_context_for_model: Option<String>,
}
pub(crate) fn preview(
handlers: &[ConfiguredHandler],
request: &SessionStartRequest,
) -> Vec<HookRunSummary> {
dispatcher::select_handlers(
handlers,
HookEventName::SessionStart,
Some(request.source.as_str()),
)
.into_iter()
.map(|handler| dispatcher::running_summary(&handler))
.collect()
}
pub(crate) async fn run(
handlers: &[ConfiguredHandler],
shell: &CommandShell,
request: SessionStartRequest,
turn_id: Option<String>,
) -> SessionStartOutcome {
let matched = dispatcher::select_handlers(
handlers,
HookEventName::SessionStart,
Some(request.source.as_str()),
);
if matched.is_empty() {
return SessionStartOutcome {
hook_events: Vec::new(),
should_stop: false,
stop_reason: None,
additional_context: None,
};
}
let input_json = match serde_json::to_string(&SessionStartCommandInput::new(
request.session_id.to_string(),
request.transcript_path.clone(),
request.cwd.display().to_string(),
request.model.clone(),
request.permission_mode.clone(),
request.source.as_str().to_string(),
)) {
Ok(input_json) => input_json,
Err(error) => {
return serialization_failure_outcome(
matched,
turn_id,
format!("failed to serialize session start hook input: {error}"),
);
}
};
let results = dispatcher::execute_handlers(
shell,
matched,
input_json,
request.cwd.as_path(),
turn_id,
parse_completed,
)
.await;
let should_stop = results.iter().any(|result| result.data.should_stop);
let stop_reason = results
.iter()
.find_map(|result| result.data.stop_reason.clone());
let additional_contexts = results
.iter()
.filter_map(|result| result.data.additional_context_for_model.clone())
.collect::<Vec<_>>();
SessionStartOutcome {
hook_events: results.into_iter().map(|result| result.completed).collect(),
should_stop,
stop_reason,
additional_context: join_text_chunks(additional_contexts),
}
}
fn parse_completed(
handler: &ConfiguredHandler,
run_result: CommandRunResult,
turn_id: Option<String>,
) -> dispatcher::ParsedHandler<SessionStartHandlerData> {
let mut entries = Vec::new();
let mut status = HookRunStatus::Completed;
let mut should_stop = false;
let mut stop_reason = None;
let mut additional_context_for_model = None;
match run_result.error.as_deref() {
Some(error) => {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: error.to_string(),
});
}
None => match run_result.exit_code {
Some(0) => {
let trimmed_stdout = run_result.stdout.trim();
if trimmed_stdout.is_empty() {
} else if let Some(parsed) = output_parser::parse_session_start(&run_result.stdout)
{
if let Some(system_message) = parsed.universal.system_message {
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Warning,
text: system_message,
});
}
if let Some(additional_context) = parsed.additional_context {
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Context,
text: additional_context.clone(),
});
if parsed.universal.continue_processing {
additional_context_for_model = Some(additional_context);
}
}
let _ = parsed.universal.suppress_output;
if !parsed.universal.continue_processing {
status = HookRunStatus::Stopped;
should_stop = true;
stop_reason = parsed.universal.stop_reason.clone();
if let Some(stop_reason_text) = parsed.universal.stop_reason {
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Stop,
text: stop_reason_text,
});
}
}
// Preserve plain-text context support without treating malformed JSON as context.
} else if trimmed_stdout.starts_with('{') || trimmed_stdout.starts_with('[') {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook returned invalid session start JSON output".to_string(),
});
} else {
let additional_context = trimmed_stdout.to_string();
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Context,
text: additional_context.clone(),
});
additional_context_for_model = Some(additional_context);
}
}
Some(exit_code) => {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: format!("hook exited with code {exit_code}"),
});
}
None => {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook exited without a status code".to_string(),
});
}
},
}
let completed = HookCompletedEvent {
turn_id,
run: dispatcher::completed_summary(handler, &run_result, status, entries),
};
dispatcher::ParsedHandler {
completed,
data: SessionStartHandlerData {
should_stop,
stop_reason,
additional_context_for_model,
},
}
}
fn join_text_chunks(chunks: Vec<String>) -> Option<String> {
if chunks.is_empty() {
None
} else {
Some(chunks.join("\n\n"))
}
}
fn serialization_failure_outcome(
handlers: Vec<ConfiguredHandler>,
turn_id: Option<String>,
error_message: String,
) -> SessionStartOutcome {
let hook_events = handlers
.into_iter()
.map(|handler| {
let mut run = dispatcher::running_summary(&handler);
run.status = HookRunStatus::Failed;
run.completed_at = Some(run.started_at);
run.duration_ms = Some(0);
run.entries = vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: error_message.clone(),
}];
HookCompletedEvent {
turn_id: turn_id.clone(),
run,
}
})
.collect();
SessionStartOutcome {
hook_events,
should_stop: false,
stop_reason: None,
additional_context: None,
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookOutputEntry;
use codex_protocol::protocol::HookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus;
use pretty_assertions::assert_eq;
use super::SessionStartHandlerData;
use super::parse_completed;
use crate::engine::ConfiguredHandler;
use crate::engine::command_runner::CommandRunResult;
#[test]
fn plain_stdout_becomes_model_context() {
let parsed = parse_completed(
&handler(),
run_result(Some(0), "hello from hook\n", ""),
None,
);
assert_eq!(
parsed.data,
SessionStartHandlerData {
should_stop: false,
stop_reason: None,
additional_context_for_model: Some("hello from hook".to_string()),
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Context,
text: "hello from hook".to_string(),
}]
);
}
#[test]
fn continue_false_keeps_context_out_of_model_input() {
let parsed = parse_completed(
&handler(),
run_result(
Some(0),
r#"{"continue":false,"stopReason":"pause","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"do not inject"}}"#,
"",
),
None,
);
assert_eq!(
parsed.data,
SessionStartHandlerData {
should_stop: true,
stop_reason: Some("pause".to_string()),
additional_context_for_model: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Stopped);
}
#[test]
fn invalid_json_like_stdout_fails_instead_of_becoming_model_context() {
let parsed = parse_completed(
&handler(),
run_result(
Some(0),
r#"{"hookSpecificOutput":{"hookEventName":"SessionStart""#,
"",
),
None,
);
assert_eq!(
parsed.data,
SessionStartHandlerData {
should_stop: false,
stop_reason: None,
additional_context_for_model: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook returned invalid session start JSON output".to_string(),
}]
);
}
fn handler() -> ConfiguredHandler {
ConfiguredHandler {
event_name: HookEventName::SessionStart,
matcher: None,
command: "echo hook".to_string(),
timeout_sec: 600,
status_message: None,
source_path: PathBuf::from("/tmp/hooks.json"),
display_order: 0,
}
}
fn run_result(exit_code: Option<i32>, stdout: &str, stderr: &str) -> CommandRunResult {
CommandRunResult {
started_at: 1,
completed_at: 2,
duration_ms: 1,
exit_code,
stdout: stdout.to_string(),
stderr: stderr.to_string(),
error: None,
}
}
}

View File

@@ -0,0 +1,495 @@
use std::path::PathBuf;
use codex_protocol::ThreadId;
use codex_protocol::protocol::HookCompletedEvent;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookOutputEntry;
use codex_protocol::protocol::HookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookRunSummary;
use crate::engine::CommandShell;
use crate::engine::ConfiguredHandler;
use crate::engine::command_runner::CommandRunResult;
use crate::engine::dispatcher;
use crate::engine::output_parser;
use crate::schema::StopCommandInput;
#[derive(Debug, Clone)]
pub struct StopRequest {
pub session_id: ThreadId,
pub turn_id: String,
pub cwd: PathBuf,
pub transcript_path: Option<PathBuf>,
pub model: String,
pub permission_mode: String,
pub stop_hook_active: bool,
pub last_assistant_message: Option<String>,
}
#[derive(Debug)]
pub struct StopOutcome {
pub hook_events: Vec<HookCompletedEvent>,
pub should_stop: bool,
pub stop_reason: Option<String>,
pub should_block: bool,
pub block_reason: Option<String>,
pub block_message_for_model: Option<String>,
}
#[derive(Debug, PartialEq, Eq)]
struct StopHandlerData {
should_stop: bool,
stop_reason: Option<String>,
should_block: bool,
block_reason: Option<String>,
block_message_for_model: Option<String>,
}
pub(crate) fn preview(
handlers: &[ConfiguredHandler],
_request: &StopRequest,
) -> Vec<HookRunSummary> {
dispatcher::select_handlers(handlers, HookEventName::Stop, None)
.into_iter()
.map(|handler| dispatcher::running_summary(&handler))
.collect()
}
pub(crate) async fn run(
handlers: &[ConfiguredHandler],
shell: &CommandShell,
request: StopRequest,
) -> StopOutcome {
let matched = dispatcher::select_handlers(handlers, HookEventName::Stop, None);
if matched.is_empty() {
return StopOutcome {
hook_events: Vec::new(),
should_stop: false,
stop_reason: None,
should_block: false,
block_reason: None,
block_message_for_model: None,
};
}
let input_json = match serde_json::to_string(&StopCommandInput::new(
request.session_id.to_string(),
request.transcript_path.clone(),
request.cwd.display().to_string(),
request.model.clone(),
request.permission_mode.clone(),
request.stop_hook_active,
request.last_assistant_message.clone(),
)) {
Ok(input_json) => input_json,
Err(error) => {
return serialization_failure_outcome(
matched,
Some(request.turn_id),
format!("failed to serialize stop hook input: {error}"),
);
}
};
let results = dispatcher::execute_handlers(
shell,
matched,
input_json,
request.cwd.as_path(),
Some(request.turn_id),
parse_completed,
)
.await;
let should_stop = results.iter().any(|result| result.data.should_stop);
let stop_reason = results
.iter()
.find_map(|result| result.data.stop_reason.clone());
let should_block = !should_stop && results.iter().any(|result| result.data.should_block);
let block_reason = if should_block {
results
.iter()
.find_map(|result| result.data.block_reason.clone())
} else {
None
};
let block_message_for_model = if should_block {
results
.iter()
.find_map(|result| result.data.block_message_for_model.clone())
} else {
None
};
StopOutcome {
hook_events: results.into_iter().map(|result| result.completed).collect(),
should_stop,
stop_reason,
should_block,
block_reason,
block_message_for_model,
}
}
fn parse_completed(
handler: &ConfiguredHandler,
run_result: CommandRunResult,
turn_id: Option<String>,
) -> dispatcher::ParsedHandler<StopHandlerData> {
let mut entries = Vec::new();
let mut status = HookRunStatus::Completed;
let mut should_stop = false;
let mut stop_reason = None;
let mut should_block = false;
let mut block_reason = None;
let mut block_message_for_model = None;
match run_result.error.as_deref() {
Some(error) => {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: error.to_string(),
});
}
None => match run_result.exit_code {
Some(0) => {
let trimmed_stdout = run_result.stdout.trim();
if trimmed_stdout.is_empty() {
} else if let Some(parsed) = output_parser::parse_stop(&run_result.stdout) {
if let Some(system_message) = parsed.universal.system_message {
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Warning,
text: system_message,
});
}
let _ = parsed.universal.suppress_output;
if !parsed.universal.continue_processing {
status = HookRunStatus::Stopped;
should_stop = true;
stop_reason = parsed.universal.stop_reason.clone();
if let Some(stop_reason_text) = parsed.universal.stop_reason {
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Stop,
text: stop_reason_text,
});
}
} else if parsed.should_block {
if let Some(reason) = parsed.reason.as_deref().and_then(trimmed_non_empty) {
status = HookRunStatus::Blocked;
should_block = true;
block_reason = Some(reason.clone());
block_message_for_model = Some(reason.clone());
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Feedback,
text: reason,
});
} else {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook returned decision \"block\" without a non-empty reason"
.to_string(),
});
}
}
} else {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook returned invalid stop hook JSON output".to_string(),
});
}
}
Some(2) => {
if let Some(reason) = trimmed_non_empty(&run_result.stderr) {
status = HookRunStatus::Blocked;
should_block = true;
block_reason = Some(reason.clone());
block_message_for_model = Some(reason.clone());
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Feedback,
text: reason,
});
} else {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook exited with code 2 without stderr feedback".to_string(),
});
}
}
Some(exit_code) => {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: format!("hook exited with code {exit_code}"),
});
}
None => {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook exited without a status code".to_string(),
});
}
},
}
let completed = HookCompletedEvent {
turn_id,
run: dispatcher::completed_summary(handler, &run_result, status, entries),
};
dispatcher::ParsedHandler {
completed,
data: StopHandlerData {
should_stop,
stop_reason,
should_block,
block_reason,
block_message_for_model,
},
}
}
fn trimmed_non_empty(text: &str) -> Option<String> {
let trimmed = text.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
None
}
fn serialization_failure_outcome(
handlers: Vec<ConfiguredHandler>,
turn_id: Option<String>,
error_message: String,
) -> StopOutcome {
let hook_events = handlers
.into_iter()
.map(|handler| {
let mut run = dispatcher::running_summary(&handler);
run.status = HookRunStatus::Failed;
run.completed_at = Some(run.started_at);
run.duration_ms = Some(0);
run.entries = vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: error_message.clone(),
}];
HookCompletedEvent {
turn_id: turn_id.clone(),
run,
}
})
.collect();
StopOutcome {
hook_events,
should_stop: false,
stop_reason: None,
should_block: false,
block_reason: None,
block_message_for_model: None,
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookOutputEntry;
use codex_protocol::protocol::HookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus;
use pretty_assertions::assert_eq;
use super::StopHandlerData;
use super::parse_completed;
use crate::engine::ConfiguredHandler;
use crate::engine::command_runner::CommandRunResult;
#[test]
fn continue_false_overrides_block_decision() {
let parsed = parse_completed(
&handler(),
run_result(
Some(0),
r#"{"continue":false,"stopReason":"done","decision":"block","reason":"keep going"}"#,
"",
),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
StopHandlerData {
should_stop: true,
stop_reason: Some("done".to_string()),
should_block: false,
block_reason: None,
block_message_for_model: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Stopped);
}
#[test]
fn exit_code_two_uses_stderr_feedback_only() {
let parsed = parse_completed(
&handler(),
run_result(Some(2), "ignored stdout", "retry with tests"),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
StopHandlerData {
should_stop: false,
stop_reason: None,
should_block: true,
block_reason: Some("retry with tests".to_string()),
block_message_for_model: Some("retry with tests".to_string()),
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);
}
#[test]
fn block_decision_without_reason_fails_instead_of_blocking() {
let parsed = parse_completed(
&handler(),
run_result(Some(0), r#"{"decision":"block"}"#, ""),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
StopHandlerData {
should_stop: false,
stop_reason: None,
should_block: false,
block_reason: None,
block_message_for_model: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook returned decision \"block\" without a non-empty reason".to_string(),
}]
);
}
#[test]
fn block_decision_with_blank_reason_fails_instead_of_blocking() {
let parsed = parse_completed(
&handler(),
run_result(Some(0), "{\"decision\":\"block\",\"reason\":\" \"}", ""),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
StopHandlerData {
should_stop: false,
stop_reason: None,
should_block: false,
block_reason: None,
block_message_for_model: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook returned decision \"block\" without a non-empty reason".to_string(),
}]
);
}
#[test]
fn exit_code_two_without_stderr_feedback_fails_instead_of_blocking() {
let parsed = parse_completed(
&handler(),
run_result(Some(2), "ignored stdout", " "),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
StopHandlerData {
should_stop: false,
stop_reason: None,
should_block: false,
block_reason: None,
block_message_for_model: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook exited with code 2 without stderr feedback".to_string(),
}]
);
}
#[test]
fn invalid_stdout_fails_instead_of_silently_nooping() {
let parsed = parse_completed(
&handler(),
run_result(Some(0), "not json", ""),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
StopHandlerData {
should_stop: false,
stop_reason: None,
should_block: false,
block_reason: None,
block_message_for_model: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook returned invalid stop hook JSON output".to_string(),
}]
);
}
fn handler() -> ConfiguredHandler {
ConfiguredHandler {
event_name: HookEventName::Stop,
matcher: None,
command: "echo hook".to_string(),
timeout_sec: 600,
status_message: None,
source_path: PathBuf::from("/tmp/hooks.json"),
display_order: 0,
}
}
fn run_result(exit_code: Option<i32>, stdout: &str, stderr: &str) -> CommandRunResult {
CommandRunResult {
started_at: 1,
completed_at: 2,
duration_ms: 1,
exit_code,
stdout: stdout.to_string(),
stderr: stderr.to_string(),
error: None,
}
}
}