diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index fc78f5b2fd..f45a42253a 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -44,6 +44,7 @@ use async_channel::Receiver; use async_channel::Sender; use codex_hooks::HookEvent; use codex_hooks::HookEventAfterAgent; +use codex_hooks::HookEventSessionStart; use codex_hooks::HookPayload; use codex_hooks::Hooks; use codex_hooks::HooksConfig; @@ -1327,6 +1328,16 @@ impl Session { Arc::clone(&config), &session_configuration.session_source, ); + sess.hooks() + .dispatch(HookPayload { + session_id: sess.conversation_id, + cwd: session_configuration.cwd.clone(), + triggered_at: chrono::Utc::now(), + hook_event: HookEvent::SessionStart { + event: HookEventSessionStart {}, + }, + }) + .await; Ok(sess) } diff --git a/codex-rs/hooks/README.md b/codex-rs/hooks/README.md new file mode 100644 index 0000000000..c1ad846cdc --- /dev/null +++ b/codex-rs/hooks/README.md @@ -0,0 +1,22 @@ +# Hooks + +Hooks are arbitrary programs which run at various deterministic, pre-defined points in the Codex lifecycle. + +Hooks implementation is in progress (as of 2025-02-10). + +## TODO + +- Allow hooks to return errors which halt execution of subsequent hooks. +- Add a /hooks slash command to list and debug hooks. +- Implement the following hooks: + - SessionStart + - SessionEnd + - BeforeAgent + - BeforeTool +- Add Hooks to config.toml + +## Done + +- Hooks for: + - AfterAgent + - AfterTool diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index aa7dd58acd..b10bc47bf1 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -9,6 +9,7 @@ pub use types::Hook; pub use types::HookEvent; pub use types::HookEventAfterAgent; pub use types::HookEventAfterToolUse; +pub use types::HookEventSessionStart; pub use types::HookOutcome; pub use types::HookPayload; pub use types::HookToolInput; diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index 9228bdddd4..db46fe588c 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -12,6 +12,7 @@ pub struct HooksConfig { #[derive(Clone)] pub struct Hooks { + session_start: Vec, after_agent: Vec, after_tool_use: Vec, } @@ -33,6 +34,7 @@ impl Hooks { .into_iter() .collect(); Self { + session_start: Vec::new(), after_agent, after_tool_use: Vec::new(), } @@ -40,6 +42,7 @@ impl Hooks { fn hooks_for_event(&self, hook_event: &HookEvent) -> &[Hook] { match hook_event { + HookEvent::SessionStart { .. } => &self.session_start, HookEvent::AfterAgent { .. } => &self.after_agent, HookEvent::AfterToolUse { .. } => &self.after_tool_use, } @@ -88,6 +91,7 @@ mod tests { use super::*; use crate::types::HookEventAfterAgent; use crate::types::HookEventAfterToolUse; + use crate::types::HookEventSessionStart; use crate::types::HookToolInput; use crate::types::HookToolKind; @@ -155,6 +159,20 @@ mod tests { } } + fn session_start_payload() -> HookPayload { + HookPayload { + session_id: ThreadId::new(), + cwd: PathBuf::from(CWD), + triggered_at: Utc + .with_ymd_and_hms(2025, 1, 1, 0, 0, 0) + .single() + .expect("valid timestamp"), + hook_event: HookEvent::SessionStart { + event: HookEventSessionStart {}, + }, + } + } + #[test] fn command_from_argv_returns_none_for_empty_args() { assert!(command_from_argv(&[]).is_none()); @@ -269,6 +287,18 @@ mod tests { assert_eq!(calls.load(Ordering::SeqCst), 1); } + #[tokio::test] + async fn dispatch_executes_session_start_hooks() { + let calls = Arc::new(AtomicUsize::new(0)); + let hooks = Hooks { + session_start: vec![counting_hook(&calls, HookOutcome::Continue)], + ..Hooks::default() + }; + + hooks.dispatch(session_start_payload()).await; + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + #[cfg(not(windows))] #[tokio::test] async fn hook_executes_program_with_payload_argument_unix() -> Result<()> { diff --git a/codex-rs/hooks/src/types.rs b/codex-rs/hooks/src/types.rs index a1588b8974..0609a2d15a 100644 --- a/codex-rs/hooks/src/types.rs +++ b/codex-rs/hooks/src/types.rs @@ -50,6 +50,10 @@ pub struct HookEventAfterAgent { pub last_assistant_message: Option, } +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub struct HookEventSessionStart {} + #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum HookToolKind { @@ -116,6 +120,10 @@ where #[derive(Debug, Clone, Serialize)] #[serde(tag = "event_type", rename_all = "snake_case")] pub enum HookEvent { + SessionStart { + #[serde(flatten)] + event: HookEventSessionStart, + }, AfterAgent { #[serde(flatten)] event: HookEventAfterAgent, @@ -147,6 +155,7 @@ mod tests { use super::HookEvent; use super::HookEventAfterAgent; use super::HookEventAfterToolUse; + use super::HookEventSessionStart; use super::HookPayload; use super::HookToolInput; use super::HookToolInputLocalShell; @@ -190,6 +199,34 @@ mod tests { assert_eq!(actual, expected); } + #[test] + fn session_start_payload_serializes_stable_wire_shape() { + let session_id = ThreadId::new(); + let payload = HookPayload { + session_id, + cwd: PathBuf::from("tmp"), + triggered_at: Utc + .with_ymd_and_hms(2025, 1, 1, 0, 0, 0) + .single() + .expect("valid timestamp"), + hook_event: HookEvent::SessionStart { + event: HookEventSessionStart {}, + }, + }; + + let actual = serde_json::to_value(payload).expect("serialize hook payload"); + let expected = json!({ + "session_id": session_id.to_string(), + "cwd": "tmp", + "triggered_at": "2025-01-01T00:00:00Z", + "hook_event": { + "event_type": "session_start", + }, + }); + + assert_eq!(actual, expected); + } + #[test] fn after_tool_use_payload_serializes_stable_wire_shape() { let session_id = ThreadId::new();