mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08: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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user