mirror of
https://github.com/openai/codex.git
synced 2026-04-30 17:36:40 +00:00
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:
135
codex-rs/hooks/src/engine/command_runner.rs
Normal file
135
codex-rs/hooks/src/engine/command_runner.rs
Normal 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
|
||||
}
|
||||
}
|
||||
42
codex-rs/hooks/src/engine/config.rs
Normal file
42
codex-rs/hooks/src/engine/config.rs
Normal 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 {},
|
||||
}
|
||||
162
codex-rs/hooks/src/engine/discovery.rs
Normal file
162
codex-rs/hooks/src/engine/discovery.rs
Normal 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()
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
190
codex-rs/hooks/src/engine/dispatcher.rs
Normal file
190
codex-rs/hooks/src/engine/dispatcher.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
109
codex-rs/hooks/src/engine/mod.rs
Normal file
109
codex-rs/hooks/src/engine/mod.rs
Normal 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
|
||||
}
|
||||
}
|
||||
71
codex-rs/hooks/src/engine/output_parser.rs
Normal file
71
codex-rs/hooks/src/engine/output_parser.rs
Normal 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()
|
||||
}
|
||||
54
codex-rs/hooks/src/engine/schema_loader.rs
Normal file
54
codex-rs/hooks/src/engine/schema_loader.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user