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,135 @@
use std::path::Path;
use std::process::Stdio;
use std::time::Duration;
use std::time::Instant;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tokio::time::timeout;
use super::CommandShell;
use super::ConfiguredHandler;
#[derive(Debug)]
pub(crate) struct CommandRunResult {
pub started_at: i64,
pub completed_at: i64,
pub duration_ms: i64,
pub exit_code: Option<i32>,
pub stdout: String,
pub stderr: String,
pub error: Option<String>,
}
pub(crate) async fn run_command(
shell: &CommandShell,
handler: &ConfiguredHandler,
input_json: &str,
cwd: &Path,
) -> CommandRunResult {
let started_at = chrono::Utc::now().timestamp();
let started = Instant::now();
let mut command = build_command(shell, handler);
command
.current_dir(cwd)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = match command.spawn() {
Ok(child) => child,
Err(err) => {
return CommandRunResult {
started_at,
completed_at: chrono::Utc::now().timestamp(),
duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX),
exit_code: None,
stdout: String::new(),
stderr: String::new(),
error: Some(err.to_string()),
};
}
};
if let Some(mut stdin) = child.stdin.take()
&& let Err(err) = stdin.write_all(input_json.as_bytes()).await
{
let _ = child.kill().await;
return CommandRunResult {
started_at,
completed_at: chrono::Utc::now().timestamp(),
duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX),
exit_code: None,
stdout: String::new(),
stderr: String::new(),
error: Some(format!("failed to write hook stdin: {err}")),
};
}
let timeout_duration = Duration::from_secs(handler.timeout_sec);
match timeout(timeout_duration, child.wait_with_output()).await {
Ok(Ok(output)) => CommandRunResult {
started_at,
completed_at: chrono::Utc::now().timestamp(),
duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX),
exit_code: output.status.code(),
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
error: None,
},
Ok(Err(err)) => CommandRunResult {
started_at,
completed_at: chrono::Utc::now().timestamp(),
duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX),
exit_code: None,
stdout: String::new(),
stderr: String::new(),
error: Some(err.to_string()),
},
Err(_) => CommandRunResult {
started_at,
completed_at: chrono::Utc::now().timestamp(),
duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX),
exit_code: None,
stdout: String::new(),
stderr: String::new(),
error: Some(format!("hook timed out after {}s", handler.timeout_sec)),
},
}
}
fn build_command(shell: &CommandShell, handler: &ConfiguredHandler) -> Command {
let mut command = if shell.program.is_empty() {
default_shell_command()
} else {
Command::new(&shell.program)
};
if shell.program.is_empty() {
command.arg(&handler.command);
command
} else {
command.args(&shell.args);
command.arg(&handler.command);
command
}
}
fn default_shell_command() -> Command {
#[cfg(windows)]
{
let comspec = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string());
let mut command = Command::new(comspec);
command.arg("/C");
command
}
#[cfg(not(windows))]
{
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
let mut command = Command::new(shell);
command.arg("-lc");
command
}
}

View File

@@ -0,0 +1,42 @@
use serde::Deserialize;
#[derive(Debug, Default, Deserialize)]
pub(crate) struct HooksFile {
#[serde(default)]
pub hooks: HookEvents,
}
#[derive(Debug, Default, Deserialize)]
pub(crate) struct HookEvents {
#[serde(rename = "SessionStart", default)]
pub session_start: Vec<MatcherGroup>,
#[serde(rename = "Stop", default)]
pub stop: Vec<MatcherGroup>,
}
#[derive(Debug, Default, Deserialize)]
pub(crate) struct MatcherGroup {
#[serde(default)]
pub matcher: Option<String>,
#[serde(default)]
pub hooks: Vec<HookHandlerConfig>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type")]
pub(crate) enum HookHandlerConfig {
#[serde(rename = "command")]
Command {
command: String,
#[serde(default, rename = "timeout", alias = "timeoutSec")]
timeout_sec: Option<u64>,
#[serde(default)]
r#async: bool,
#[serde(default, rename = "statusMessage")]
status_message: Option<String>,
},
#[serde(rename = "prompt")]
Prompt {},
#[serde(rename = "agent")]
Agent {},
}

View File

@@ -0,0 +1,162 @@
use std::fs;
use std::path::Path;
use codex_config::ConfigLayerStack;
use codex_config::ConfigLayerStackOrdering;
use regex::Regex;
use super::ConfiguredHandler;
use super::config::HookHandlerConfig;
use super::config::HooksFile;
pub(crate) struct DiscoveryResult {
pub handlers: Vec<ConfiguredHandler>,
pub warnings: Vec<String>,
}
pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -> DiscoveryResult {
let Some(config_layer_stack) = config_layer_stack else {
return DiscoveryResult {
handlers: Vec::new(),
warnings: Vec::new(),
};
};
let mut handlers = Vec::new();
let mut warnings = Vec::new();
let mut display_order = 0_i64;
for layer in
config_layer_stack.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false)
{
let Some(folder) = layer.config_folder() else {
continue;
};
let source_path = match folder.join("hooks.json") {
Ok(source_path) => source_path,
Err(err) => {
warnings.push(format!(
"failed to resolve hooks config path from {}: {err}",
folder.display()
));
continue;
}
};
if !source_path.as_path().is_file() {
continue;
}
let contents = match fs::read_to_string(source_path.as_path()) {
Ok(contents) => contents,
Err(err) => {
warnings.push(format!(
"failed to read hooks config {}: {err}",
source_path.display()
));
continue;
}
};
let parsed: HooksFile = match serde_json::from_str(&contents) {
Ok(parsed) => parsed,
Err(err) => {
warnings.push(format!(
"failed to parse hooks config {}: {err}",
source_path.display()
));
continue;
}
};
for group in parsed.hooks.session_start {
append_group_handlers(
&mut handlers,
&mut warnings,
&mut display_order,
source_path.as_path(),
codex_protocol::protocol::HookEventName::SessionStart,
group.matcher.as_deref(),
group.hooks,
);
}
for group in parsed.hooks.stop {
append_group_handlers(
&mut handlers,
&mut warnings,
&mut display_order,
source_path.as_path(),
codex_protocol::protocol::HookEventName::Stop,
None,
group.hooks,
);
}
}
DiscoveryResult { handlers, warnings }
}
fn append_group_handlers(
handlers: &mut Vec<ConfiguredHandler>,
warnings: &mut Vec<String>,
display_order: &mut i64,
source_path: &Path,
event_name: codex_protocol::protocol::HookEventName,
matcher: Option<&str>,
group_handlers: Vec<HookHandlerConfig>,
) {
if let Some(matcher) = matcher
&& let Err(err) = Regex::new(matcher)
{
warnings.push(format!(
"invalid matcher {matcher:?} in {}: {err}",
source_path.display()
));
return;
}
for handler in group_handlers {
match handler {
HookHandlerConfig::Command {
command,
timeout_sec,
r#async,
status_message,
} => {
if r#async {
warnings.push(format!(
"skipping async hook in {}: async hooks are not supported yet",
source_path.display()
));
continue;
}
if command.trim().is_empty() {
warnings.push(format!(
"skipping empty hook command in {}",
source_path.display()
));
continue;
}
let timeout_sec = timeout_sec.unwrap_or(600).max(1);
handlers.push(ConfiguredHandler {
event_name,
matcher: matcher.map(ToOwned::to_owned),
command,
timeout_sec,
status_message,
source_path: source_path.to_path_buf(),
display_order: *display_order,
});
*display_order += 1;
}
HookHandlerConfig::Prompt {} => warnings.push(format!(
"skipping prompt hook in {}: prompt hooks are not supported yet",
source_path.display()
)),
HookHandlerConfig::Agent {} => warnings.push(format!(
"skipping agent hook in {}: agent hooks are not supported yet",
source_path.display()
)),
}
}
}

View File

@@ -0,0 +1,190 @@
use std::path::Path;
use futures::future::join_all;
use codex_protocol::protocol::HookCompletedEvent;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookExecutionMode;
use codex_protocol::protocol::HookHandlerType;
use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookRunSummary;
use codex_protocol::protocol::HookScope;
use super::CommandShell;
use super::ConfiguredHandler;
use super::command_runner::CommandRunResult;
use super::command_runner::run_command;
#[derive(Debug)]
pub(crate) struct ParsedHandler<T> {
pub completed: HookCompletedEvent,
pub data: T,
}
pub(crate) fn select_handlers(
handlers: &[ConfiguredHandler],
event_name: HookEventName,
session_start_source: Option<&str>,
) -> Vec<ConfiguredHandler> {
handlers
.iter()
.filter(|handler| handler.event_name == event_name)
.filter(|handler| match event_name {
HookEventName::SessionStart => match (&handler.matcher, session_start_source) {
(Some(matcher), Some(source)) => regex::Regex::new(matcher)
.map(|regex| regex.is_match(source))
.unwrap_or(false),
(None, _) => true,
_ => false,
},
HookEventName::Stop => true,
})
.cloned()
.collect()
}
pub(crate) fn running_summary(handler: &ConfiguredHandler) -> HookRunSummary {
HookRunSummary {
id: handler.run_id(),
event_name: handler.event_name,
handler_type: HookHandlerType::Command,
execution_mode: HookExecutionMode::Sync,
scope: scope_for_event(handler.event_name),
source_path: handler.source_path.clone(),
display_order: handler.display_order,
status: HookRunStatus::Running,
status_message: handler.status_message.clone(),
started_at: chrono::Utc::now().timestamp(),
completed_at: None,
duration_ms: None,
entries: Vec::new(),
}
}
pub(crate) async fn execute_handlers<T>(
shell: &CommandShell,
handlers: Vec<ConfiguredHandler>,
input_json: String,
cwd: &Path,
turn_id: Option<String>,
parse: fn(&ConfiguredHandler, CommandRunResult, Option<String>) -> ParsedHandler<T>,
) -> Vec<ParsedHandler<T>> {
let results = join_all(
handlers
.iter()
.map(|handler| run_command(shell, handler, &input_json, cwd)),
)
.await;
handlers
.into_iter()
.zip(results)
.map(|(handler, result)| parse(&handler, result, turn_id.clone()))
.collect()
}
pub(crate) fn completed_summary(
handler: &ConfiguredHandler,
run_result: &CommandRunResult,
status: HookRunStatus,
entries: Vec<codex_protocol::protocol::HookOutputEntry>,
) -> HookRunSummary {
HookRunSummary {
id: handler.run_id(),
event_name: handler.event_name,
handler_type: HookHandlerType::Command,
execution_mode: HookExecutionMode::Sync,
scope: scope_for_event(handler.event_name),
source_path: handler.source_path.clone(),
display_order: handler.display_order,
status,
status_message: handler.status_message.clone(),
started_at: run_result.started_at,
completed_at: Some(run_result.completed_at),
duration_ms: Some(run_result.duration_ms),
entries,
}
}
fn scope_for_event(event_name: HookEventName) -> HookScope {
match event_name {
HookEventName::SessionStart => HookScope::Thread,
HookEventName::Stop => HookScope::Turn,
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use codex_protocol::protocol::HookEventName;
use super::ConfiguredHandler;
use super::select_handlers;
fn make_handler(
event_name: HookEventName,
matcher: Option<&str>,
command: &str,
display_order: i64,
) -> ConfiguredHandler {
ConfiguredHandler {
event_name,
matcher: matcher.map(str::to_owned),
command: command.to_string(),
timeout_sec: 5,
status_message: None,
source_path: PathBuf::from("/tmp/hooks.json"),
display_order,
}
}
#[test]
fn select_handlers_keeps_duplicate_stop_handlers() {
let handlers = vec![
make_handler(HookEventName::Stop, None, "echo same", 0),
make_handler(HookEventName::Stop, None, "echo same", 1),
];
let selected = select_handlers(&handlers, HookEventName::Stop, None);
assert_eq!(selected.len(), 2);
assert_eq!(selected[0].display_order, 0);
assert_eq!(selected[1].display_order, 1);
}
#[test]
fn select_handlers_keeps_overlapping_session_start_matchers() {
let handlers = vec![
make_handler(HookEventName::SessionStart, Some("start.*"), "echo same", 0),
make_handler(
HookEventName::SessionStart,
Some("^startup$"),
"echo same",
1,
),
];
let selected = select_handlers(&handlers, HookEventName::SessionStart, Some("startup"));
assert_eq!(selected.len(), 2);
assert_eq!(selected[0].display_order, 0);
assert_eq!(selected[1].display_order, 1);
}
#[test]
fn select_handlers_preserves_declaration_order() {
let handlers = vec![
make_handler(HookEventName::Stop, None, "first", 0),
make_handler(HookEventName::Stop, None, "second", 1),
make_handler(HookEventName::Stop, None, "third", 2),
];
let selected = select_handlers(&handlers, HookEventName::Stop, None);
assert_eq!(selected.len(), 3);
assert_eq!(selected[0].command, "first");
assert_eq!(selected[1].command, "second");
assert_eq!(selected[2].command, "third");
}
}

View File

@@ -0,0 +1,109 @@
pub(crate) mod command_runner;
pub(crate) mod config;
pub(crate) mod discovery;
pub(crate) mod dispatcher;
pub(crate) mod output_parser;
pub(crate) mod schema_loader;
use std::path::PathBuf;
use codex_config::ConfigLayerStack;
use codex_protocol::protocol::HookRunSummary;
use crate::events::session_start::SessionStartOutcome;
use crate::events::session_start::SessionStartRequest;
use crate::events::stop::StopOutcome;
use crate::events::stop::StopRequest;
#[derive(Debug, Clone)]
pub(crate) struct CommandShell {
pub program: String,
pub args: Vec<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct ConfiguredHandler {
pub event_name: codex_protocol::protocol::HookEventName,
pub matcher: Option<String>,
pub command: String,
pub timeout_sec: u64,
pub status_message: Option<String>,
pub source_path: PathBuf,
pub display_order: i64,
}
impl ConfiguredHandler {
pub fn run_id(&self) -> String {
format!(
"{}:{}:{}",
self.event_name_label(),
self.display_order,
self.source_path.display()
)
}
fn event_name_label(&self) -> &'static str {
match self.event_name {
codex_protocol::protocol::HookEventName::SessionStart => "session-start",
codex_protocol::protocol::HookEventName::Stop => "stop",
}
}
}
#[derive(Clone)]
pub(crate) struct ClaudeHooksEngine {
handlers: Vec<ConfiguredHandler>,
warnings: Vec<String>,
shell: CommandShell,
}
impl ClaudeHooksEngine {
pub(crate) fn new(
enabled: bool,
config_layer_stack: Option<&ConfigLayerStack>,
shell: CommandShell,
) -> Self {
if !enabled {
return Self {
handlers: Vec::new(),
warnings: Vec::new(),
shell,
};
}
let _ = schema_loader::generated_hook_schemas();
let discovered = discovery::discover_handlers(config_layer_stack);
Self {
handlers: discovered.handlers,
warnings: discovered.warnings,
shell,
}
}
pub(crate) fn warnings(&self) -> &[String] {
&self.warnings
}
pub(crate) fn preview_session_start(
&self,
request: &SessionStartRequest,
) -> Vec<HookRunSummary> {
crate::events::session_start::preview(&self.handlers, request)
}
pub(crate) async fn run_session_start(
&self,
request: SessionStartRequest,
turn_id: Option<String>,
) -> SessionStartOutcome {
crate::events::session_start::run(&self.handlers, &self.shell, request, turn_id).await
}
pub(crate) fn preview_stop(&self, request: &StopRequest) -> Vec<HookRunSummary> {
crate::events::stop::preview(&self.handlers, request)
}
pub(crate) async fn run_stop(&self, request: StopRequest) -> StopOutcome {
crate::events::stop::run(&self.handlers, &self.shell, request).await
}
}

View File

@@ -0,0 +1,71 @@
#[derive(Debug, Clone)]
pub(crate) struct UniversalOutput {
pub continue_processing: bool,
pub stop_reason: Option<String>,
pub suppress_output: bool,
pub system_message: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct SessionStartOutput {
pub universal: UniversalOutput,
pub additional_context: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct StopOutput {
pub universal: UniversalOutput,
pub should_block: bool,
pub reason: Option<String>,
}
use crate::schema::HookUniversalOutputWire;
use crate::schema::SessionStartCommandOutputWire;
use crate::schema::StopCommandOutputWire;
use crate::schema::StopDecisionWire;
pub(crate) fn parse_session_start(stdout: &str) -> Option<SessionStartOutput> {
let wire: SessionStartCommandOutputWire = parse_json(stdout)?;
let additional_context = wire
.hook_specific_output
.and_then(|output| output.additional_context);
Some(SessionStartOutput {
universal: UniversalOutput::from(wire.universal),
additional_context,
})
}
pub(crate) fn parse_stop(stdout: &str) -> Option<StopOutput> {
let wire: StopCommandOutputWire = parse_json(stdout)?;
Some(StopOutput {
universal: UniversalOutput::from(wire.universal),
should_block: matches!(wire.decision, Some(StopDecisionWire::Block)),
reason: wire.reason,
})
}
impl From<HookUniversalOutputWire> for UniversalOutput {
fn from(value: HookUniversalOutputWire) -> Self {
Self {
continue_processing: value.r#continue,
stop_reason: value.stop_reason,
suppress_output: value.suppress_output,
system_message: value.system_message,
}
}
}
fn parse_json<T>(stdout: &str) -> Option<T>
where
T: for<'de> serde::Deserialize<'de>,
{
let trimmed = stdout.trim();
if trimmed.is_empty() {
return None;
}
let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
if !value.is_object() {
return None;
}
serde_json::from_value(value).ok()
}

View File

@@ -0,0 +1,54 @@
use std::sync::OnceLock;
use serde_json::Value;
#[allow(dead_code)]
pub(crate) struct GeneratedHookSchemas {
pub session_start_command_input: Value,
pub session_start_command_output: Value,
pub stop_command_input: Value,
pub stop_command_output: Value,
}
pub(crate) fn generated_hook_schemas() -> &'static GeneratedHookSchemas {
static SCHEMAS: OnceLock<GeneratedHookSchemas> = OnceLock::new();
SCHEMAS.get_or_init(|| GeneratedHookSchemas {
session_start_command_input: parse_json_schema(
"session-start.command.input",
include_str!("../../schema/generated/session-start.command.input.schema.json"),
),
session_start_command_output: parse_json_schema(
"session-start.command.output",
include_str!("../../schema/generated/session-start.command.output.schema.json"),
),
stop_command_input: parse_json_schema(
"stop.command.input",
include_str!("../../schema/generated/stop.command.input.schema.json"),
),
stop_command_output: parse_json_schema(
"stop.command.output",
include_str!("../../schema/generated/stop.command.output.schema.json"),
),
})
}
fn parse_json_schema(name: &str, schema: &str) -> Value {
serde_json::from_str(schema)
.unwrap_or_else(|err| panic!("invalid generated hooks schema {name}: {err}"))
}
#[cfg(test)]
mod tests {
use super::generated_hook_schemas;
use pretty_assertions::assert_eq;
#[test]
fn loads_generated_hook_schemas() {
let schemas = generated_hook_schemas();
assert_eq!(schemas.session_start_command_input["type"], "object");
assert_eq!(schemas.session_start_command_output["type"], "object");
assert_eq!(schemas.stop_command_input["type"], "object");
assert_eq!(schemas.stop_command_output["type"], "object");
}
}