From ea653365429ba5ec7213d6ab5b365b0c711f2662 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 4 Mar 2026 21:58:08 +0000 Subject: [PATCH] feat(hooks): align lifecycle hooks with Claude-style naming and payloads --- codex-rs/core/config.schema.json | 30 +- codex-rs/core/src/codex.rs | 76 +-- codex-rs/core/src/config/mod.rs | 64 ++- .../core/src/tools/handlers/multi_agents.rs | 14 +- codex-rs/core/src/tools/registry.rs | 98 +++- codex-rs/hooks/Cargo.toml | 2 +- codex-rs/hooks/src/lib.rs | 3 +- codex-rs/hooks/src/registry.rs | 374 ++++++++++----- codex-rs/hooks/src/types.rs | 451 +++++++++++------- codex-rs/hooks/src/user_notification.rs | 1 + docs/config.md | 21 +- 11 files changed, 746 insertions(+), 388 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 93adcce5bc..d5f38c3e27 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -638,7 +638,19 @@ "additionalProperties": false, "description": "Lifecycle hook command config deserialized from `[hooks]` in config.toml.", "properties": { - "compaction": { + "post_tool_use": { + "items": { + "type": "string" + }, + "type": "array" + }, + "pre_compact": { + "items": { + "type": "string" + }, + "type": "array" + }, + "pre_tool_use": { "items": { "type": "string" }, @@ -656,7 +668,7 @@ }, "type": "array" }, - "subagent_end": { + "stop": { "items": { "type": "string" }, @@ -668,13 +680,13 @@ }, "type": "array" }, - "turn_end": { + "subagent_stop": { "items": { "type": "string" }, "type": "array" }, - "turn_start": { + "user_prompt_submit": { "items": { "type": "string" }, @@ -1966,13 +1978,15 @@ } ], "default": { - "compaction": null, + "post_tool_use": null, + "pre_compact": null, + "pre_tool_use": null, "session_end": null, "session_start": null, - "subagent_end": null, + "stop": null, "subagent_start": null, - "turn_end": null, - "turn_start": null + "subagent_stop": null, + "user_prompt_submit": null }, "description": "Optional external hook commands keyed by lifecycle event." }, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 63fdc21fda..afd51e4fc4 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1587,20 +1587,20 @@ impl Session { for event in events { sess.send_event_raw(event).await; } - let hook_session_ref = compute_hook_session_ref(sess.as_ref()).await; + let transcript_path = compute_hook_transcript_path(sess.as_ref()).await; dispatch_nonfatal_lifecycle_hook( sess.as_ref(), HookPayload { session_id: conversation_id, + transcript_path, cwd: session_configuration.cwd.clone(), - client: None, + client: session_configuration.app_server_client_name.clone(), triggered_at: chrono::Utc::now(), hook_event: HookEvent::SessionStart { event: HookEventLifecycle { - session_ref: hook_session_ref, previous_session_id: None, prompt: None, - response_message: None, + last_assistant_message: None, tool_use_id: None, tool_input: None, subagent_id: None, @@ -4642,13 +4642,11 @@ mod handlers { // Gracefully flush and shutdown rollout recorder on session end so tests // that inspect the rollout file do not race with the background writer. - let hook_session_ref = { - let rollout = sess.services.rollout.lock().await; - rollout - .as_ref() - .map(|recorder| recorder.rollout_path().display().to_string()) - .unwrap_or_else(|| sess.conversation_id.to_string()) + let client = { + let state = sess.state.lock().await; + state.session_configuration.app_server_client_name.clone() }; + let transcript_path = super::compute_hook_transcript_path(sess.as_ref()).await; let recorder_opt = { let mut guard = sess.services.rollout.lock().await; guard.take() @@ -4670,15 +4668,15 @@ mod handlers { sess.as_ref(), codex_hooks::HookPayload { session_id: sess.conversation_id, + transcript_path, cwd, - client: None, + client, triggered_at: chrono::Utc::now(), hook_event: codex_hooks::HookEvent::SessionEnd { event: codex_hooks::HookEventLifecycle { - session_ref: hook_session_ref, previous_session_id: None, prompt: None, - response_message: None, + last_assistant_message: None, tool_use_id: None, tool_input: None, subagent_id: None, @@ -4925,21 +4923,22 @@ fn hooks_config_from_config(config: &Config) -> HooksConfig { HooksConfig { legacy_notify_argv: config.notify.clone(), session_start_argv: config.hooks.session_start.clone(), - turn_start_argv: config.hooks.turn_start.clone(), - turn_end_argv: config.hooks.turn_end.clone(), - compaction_argv: config.hooks.compaction.clone(), + user_prompt_submit_argv: config.hooks.user_prompt_submit.clone(), + pre_tool_use_argv: config.hooks.pre_tool_use.clone(), + post_tool_use_argv: config.hooks.post_tool_use.clone(), + stop_argv: config.hooks.stop.clone(), + pre_compact_argv: config.hooks.pre_compact.clone(), session_end_argv: config.hooks.session_end.clone(), subagent_start_argv: config.hooks.subagent_start.clone(), - subagent_end_argv: config.hooks.subagent_end.clone(), + subagent_stop_argv: config.hooks.subagent_stop.clone(), } } -pub(crate) async fn compute_hook_session_ref(session: &Session) -> String { +pub(crate) async fn compute_hook_transcript_path(session: &Session) -> Option { let rollout = session.services.rollout.lock().await; rollout .as_ref() .map(|recorder| recorder.rollout_path().display().to_string()) - .unwrap_or_else(|| session.conversation_id.to_string()) } pub(crate) async fn dispatch_nonfatal_lifecycle_hook(session: &Session, hook_payload: HookPayload) { @@ -5011,20 +5010,20 @@ pub(crate) async fn run_turn( }) .collect::>() .join("\n"); - let hook_session_ref = compute_hook_session_ref(sess.as_ref()).await; + let transcript_path = compute_hook_transcript_path(sess.as_ref()).await; dispatch_nonfatal_lifecycle_hook( sess.as_ref(), HookPayload { session_id: sess.conversation_id, + transcript_path, cwd: turn_context.cwd.clone(), client: turn_context.app_server_client_name.clone(), triggered_at: chrono::Utc::now(), - hook_event: HookEvent::TurnStart { + hook_event: HookEvent::UserPromptSubmit { event: HookEventLifecycle { - session_ref: hook_session_ref, previous_session_id: None, prompt: (!prompt.is_empty()).then_some(prompt), - response_message: None, + last_assistant_message: None, tool_use_id: None, tool_input: None, subagent_id: None, @@ -5285,22 +5284,23 @@ pub(crate) async fn run_turn( } if !needs_follow_up { + let last_assistant_message = sampling_request_last_agent_message.clone(); last_agent_message = sampling_request_last_agent_message; let turn_end_prompt = sampling_request_input_messages.last().cloned(); - let hook_session_ref = compute_hook_session_ref(sess.as_ref()).await; + let transcript_path = compute_hook_transcript_path(sess.as_ref()).await; dispatch_nonfatal_lifecycle_hook( sess.as_ref(), HookPayload { session_id: sess.conversation_id, + transcript_path, cwd: turn_context.cwd.clone(), client: turn_context.app_server_client_name.clone(), triggered_at: chrono::Utc::now(), - hook_event: HookEvent::TurnEnd { + hook_event: HookEvent::Stop { event: HookEventLifecycle { - session_ref: hook_session_ref, previous_session_id: None, prompt: turn_end_prompt, - response_message: None, + last_assistant_message, tool_use_id: None, tool_input: None, subagent_id: None, @@ -5310,10 +5310,12 @@ pub(crate) async fn run_turn( }, ) .await; + let transcript_path = compute_hook_transcript_path(sess.as_ref()).await; let hook_outcomes = sess .hooks() .dispatch(HookPayload { session_id: sess.conversation_id, + transcript_path, cwd: turn_context.cwd.clone(), client: turn_context.app_server_client_name.clone(), triggered_at: chrono::Utc::now(), @@ -5493,20 +5495,20 @@ async fn run_auto_compact( ) .await?; } - let hook_session_ref = compute_hook_session_ref(sess.as_ref()).await; + let transcript_path = compute_hook_transcript_path(sess.as_ref()).await; dispatch_nonfatal_lifecycle_hook( sess.as_ref(), HookPayload { session_id: sess.conversation_id, + transcript_path, cwd: turn_context.cwd.clone(), client: turn_context.app_server_client_name.clone(), triggered_at: chrono::Utc::now(), - hook_event: HookEvent::Compaction { + hook_event: HookEvent::PreCompact { event: HookEventLifecycle { - session_ref: hook_session_ref, previous_session_id: None, prompt: Some(turn_context.compact_prompt().to_string()), - response_message: None, + last_assistant_message: None, tool_use_id: None, tool_input: None, subagent_id: None, @@ -8855,16 +8857,16 @@ mod tests { } #[tokio::test] - async fn compute_hook_session_ref_falls_back_to_conversation_id_without_rollout() { + async fn compute_hook_transcript_path_is_none_without_rollout() { let (session, _turn_context) = make_session_and_context().await; - let session_ref = compute_hook_session_ref(&session).await; + let transcript_path = compute_hook_transcript_path(&session).await; - assert_eq!(session_ref, session.conversation_id.to_string()); + assert_eq!(transcript_path, None); } #[tokio::test] - async fn compute_hook_session_ref_prefers_rollout_path_when_available() { + async fn compute_hook_transcript_path_prefers_rollout_path_when_available() { let (session, _turn_context) = make_session_and_context().await; let config = { let state = session.state.lock().await; @@ -8891,9 +8893,9 @@ mod tests { *rollout = Some(recorder); } - let session_ref = compute_hook_session_ref(&session).await; + let transcript_path = compute_hook_transcript_path(&session).await; - assert_eq!(session_ref, rollout_path); + assert_eq!(transcript_path, Some(rollout_path)); } pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 12a1114532..269faf88cb 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1014,14 +1014,17 @@ pub fn set_default_oss_provider(codex_home: &Path, provider: &str) -> std::io::R /// Lifecycle hook command config deserialized from `[hooks]` in config.toml. #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] #[schemars(deny_unknown_fields)] +#[serde(deny_unknown_fields)] pub struct HooksToml { pub session_start: Option>, - pub turn_start: Option>, - pub turn_end: Option>, - pub compaction: Option>, + pub user_prompt_submit: Option>, + pub pre_tool_use: Option>, + pub post_tool_use: Option>, + pub stop: Option>, + pub pre_compact: Option>, pub session_end: Option>, pub subagent_start: Option>, - pub subagent_end: Option>, + pub subagent_stop: Option>, } /// Base config deserialized from ~/.codex/config.toml. @@ -6685,6 +6688,59 @@ speaker = "Desk Speakers" } } +#[cfg(test)] +mod hooks_toml_tests { + use super::ConfigToml; + use super::HooksToml; + + #[test] + fn hooks_toml_uses_claude_style_hook_names() { + let toml = r#" + [hooks] + session_start = ["hooks", "SessionStart"] + user_prompt_submit = ["hooks", "UserPromptSubmit"] + pre_tool_use = ["hooks", "PreToolUse"] + post_tool_use = ["hooks", "PostToolUse"] + stop = ["hooks", "Stop"] + pre_compact = ["hooks", "PreCompact"] + session_end = ["hooks", "SessionEnd"] + subagent_start = ["hooks", "SubagentStart"] + subagent_stop = ["hooks", "SubagentStop"] + "#; + + let parsed: ConfigToml = + toml::from_str(toml).expect("TOML deserialization should succeed for hooks"); + + assert_eq!( + parsed.hooks, + HooksToml { + session_start: Some(vec!["hooks".to_string(), "SessionStart".to_string()]), + user_prompt_submit: Some(vec!["hooks".to_string(), "UserPromptSubmit".to_string()]), + pre_tool_use: Some(vec!["hooks".to_string(), "PreToolUse".to_string()]), + post_tool_use: Some(vec!["hooks".to_string(), "PostToolUse".to_string()]), + stop: Some(vec!["hooks".to_string(), "Stop".to_string()]), + pre_compact: Some(vec!["hooks".to_string(), "PreCompact".to_string()]), + session_end: Some(vec!["hooks".to_string(), "SessionEnd".to_string()]), + subagent_start: Some(vec!["hooks".to_string(), "SubagentStart".to_string()]), + subagent_stop: Some(vec!["hooks".to_string(), "SubagentStop".to_string()]), + } + ); + } + + #[test] + fn hooks_toml_rejects_unlanded_legacy_hook_names() { + let toml = r#" + [hooks] + turn_start = ["hooks", "turn_start"] + "#; + + let err = + toml::from_str::(toml).expect_err("legacy hook names should be rejected"); + + assert!(err.to_string().contains("turn_start")); + } +} + #[cfg(test)] mod notifications_tests { use crate::config::types::NotificationMethod; diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index b314303e8a..e840bcac77 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -9,7 +9,7 @@ use crate::agent::AgentStatus; use crate::agent::exceeds_thread_spawn_depth_limit; use crate::codex::Session; use crate::codex::TurnContext; -use crate::codex::compute_hook_session_ref; +use crate::codex::compute_hook_transcript_path; use crate::codex::dispatch_nonfatal_lifecycle_hook; use crate::config::Config; use crate::error::CodexErr; @@ -162,20 +162,20 @@ mod spawn { .into(), ) .await; - let hook_session_ref = compute_hook_session_ref(session.as_ref()).await; + let transcript_path = compute_hook_transcript_path(session.as_ref()).await; dispatch_nonfatal_lifecycle_hook( session.as_ref(), HookPayload { session_id: session.conversation_id, + transcript_path: transcript_path.clone(), cwd: turn.cwd.clone(), client: turn.app_server_client_name.clone(), triggered_at: chrono::Utc::now(), hook_event: HookEvent::SubagentStart { event: HookEventLifecycle { - session_ref: hook_session_ref.clone(), previous_session_id: None, prompt: prompt_for_hook.clone(), - response_message: None, + last_assistant_message: None, tool_use_id: Some(call_id.clone()), tool_input: Some(HookToolInput::Function { arguments: arguments.clone(), @@ -248,15 +248,15 @@ mod spawn { session.as_ref(), HookPayload { session_id: session.conversation_id, + transcript_path, cwd: turn.cwd.clone(), client: turn.app_server_client_name.clone(), triggered_at: chrono::Utc::now(), - hook_event: HookEvent::SubagentEnd { + hook_event: HookEvent::SubagentStop { event: HookEventLifecycle { - session_ref: hook_session_ref, previous_session_id: None, prompt: prompt_for_hook, - response_message: None, + last_assistant_message: None, tool_use_id: Some(call_id.clone()), tool_input: Some(HookToolInput::Function { arguments: arguments.clone(), diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 77d3c4150b..916d21f283 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -4,6 +4,7 @@ use std::time::Duration; use std::time::Instant; use crate::client_common::tools::ToolSpec; +use crate::codex::compute_hook_transcript_path; use crate::features::Feature; use crate::function_tool::FunctionCallError; use crate::memories::usage::emit_metric_for_tool_read; @@ -14,7 +15,8 @@ use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use async_trait::async_trait; use codex_hooks::HookEvent; -use codex_hooks::HookEventAfterToolUse; +use codex_hooks::HookEventPostToolUse; +use codex_hooks::HookEventPreToolUse; use codex_hooks::HookPayload; use codex_hooks::HookResult; use codex_hooks::HookToolInput; @@ -163,6 +165,9 @@ impl ToolRegistry { } let is_mutating = handler.is_mutating(&invocation).await; + if let Some(err) = dispatch_pre_tool_use_hook(&invocation, is_mutating).await { + return Err(err); + } let output_cell = tokio::sync::Mutex::new(None); let invocation_for_tool = invocation.clone(); @@ -204,7 +209,7 @@ impl ToolRegistry { Err(err) => (err.to_string(), false), }; emit_metric_for_tool_read(&invocation, success).await; - let hook_abort_error = dispatch_after_tool_use_hook(AfterToolUseHookDispatch { + let hook_abort_error = dispatch_post_tool_use_hook(PostToolUseHookDispatch { invocation: &invocation, output_preview, success, @@ -366,7 +371,7 @@ fn hook_tool_kind(tool_input: &HookToolInput) -> HookToolKind { } } -struct AfterToolUseHookDispatch<'a> { +struct PostToolUseHookDispatch<'a> { invocation: &'a ToolInvocation, output_preview: String, success: bool, @@ -375,22 +380,93 @@ struct AfterToolUseHookDispatch<'a> { mutating: bool, } -async fn dispatch_after_tool_use_hook( - dispatch: AfterToolUseHookDispatch<'_>, +async fn dispatch_pre_tool_use_hook( + invocation: &ToolInvocation, + mutating: bool, ) -> Option { - let AfterToolUseHookDispatch { invocation, .. } = dispatch; let session = invocation.session.as_ref(); let turn = invocation.turn.as_ref(); let tool_input = HookToolInput::from(&invocation.payload); + let transcript_path = compute_hook_transcript_path(session).await; let hook_outcomes = session .hooks() .dispatch(HookPayload { session_id: session.conversation_id, + transcript_path, cwd: turn.cwd.clone(), client: turn.app_server_client_name.clone(), triggered_at: chrono::Utc::now(), - hook_event: HookEvent::AfterToolUse { - event: HookEventAfterToolUse { + hook_event: HookEvent::PreToolUse { + event: HookEventPreToolUse { + turn_id: turn.sub_id.clone(), + call_id: invocation.call_id.clone(), + tool_name: invocation.tool_name.clone(), + tool_kind: hook_tool_kind(&tool_input), + tool_input, + mutating: Some(mutating), + sandbox: Some( + sandbox_tag( + &turn.sandbox_policy, + turn.windows_sandbox_level, + turn.features.enabled(Feature::UseLinuxSandboxBwrap), + ) + .to_string(), + ), + sandbox_policy: Some(sandbox_policy_tag(&turn.sandbox_policy).to_string()), + }, + }, + }) + .await; + + for hook_outcome in hook_outcomes { + let hook_name = hook_outcome.hook_name; + match hook_outcome.result { + HookResult::Success => {} + HookResult::FailedContinue(error) => { + warn!( + call_id = %invocation.call_id, + tool_name = %invocation.tool_name, + hook_name = %hook_name, + error = %error, + "pre_tool_use hook failed; continuing" + ); + } + HookResult::FailedAbort(error) => { + warn!( + call_id = %invocation.call_id, + tool_name = %invocation.tool_name, + hook_name = %hook_name, + error = %error, + "pre_tool_use hook failed; aborting operation" + ); + return Some(FunctionCallError::Fatal(format!( + "pre_tool_use hook '{hook_name}' failed and aborted operation: {error}" + ))); + } + } + } + + None +} + +async fn dispatch_post_tool_use_hook( + dispatch: PostToolUseHookDispatch<'_>, +) -> Option { + let PostToolUseHookDispatch { invocation, .. } = dispatch; + let session = invocation.session.as_ref(); + let turn = invocation.turn.as_ref(); + let tool_input = HookToolInput::from(&invocation.payload); + let transcript_path = compute_hook_transcript_path(session).await; + let hook_outcomes = session + .hooks() + .dispatch(HookPayload { + session_id: session.conversation_id, + transcript_path, + cwd: turn.cwd.clone(), + client: turn.app_server_client_name.clone(), + triggered_at: chrono::Utc::now(), + hook_event: HookEvent::PostToolUse { + event: HookEventPostToolUse { turn_id: turn.sub_id.clone(), call_id: invocation.call_id.clone(), tool_name: invocation.tool_name.clone(), @@ -423,7 +499,7 @@ async fn dispatch_after_tool_use_hook( tool_name = %invocation.tool_name, hook_name = %hook_name, error = %error, - "after_tool_use hook failed; continuing" + "post_tool_use hook failed; continuing" ); } HookResult::FailedAbort(error) => { @@ -432,10 +508,10 @@ async fn dispatch_after_tool_use_hook( tool_name = %invocation.tool_name, hook_name = %hook_name, error = %error, - "after_tool_use hook failed; aborting operation" + "post_tool_use hook failed; aborting operation" ); return Some(FunctionCallError::Fatal(format!( - "after_tool_use hook '{hook_name}' failed and aborted operation: {error}" + "post_tool_use hook '{hook_name}' failed and aborted operation: {error}" ))); } } diff --git a/codex-rs/hooks/Cargo.toml b/codex-rs/hooks/Cargo.toml index a49b1166f3..ad5687df47 100644 --- a/codex-rs/hooks/Cargo.toml +++ b/codex-rs/hooks/Cargo.toml @@ -18,7 +18,7 @@ codex-protocol = { workspace = true } futures = { workspace = true, features = ["alloc"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -tokio = { workspace = true, features = ["process"] } +tokio = { workspace = true, features = ["io-util", "process"] } [dev-dependencies] anyhow = { workspace = true } diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index 3629c0a75c..696e62f52f 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -8,8 +8,9 @@ pub use registry::command_from_argv; pub use types::Hook; pub use types::HookEvent; pub use types::HookEventAfterAgent; -pub use types::HookEventAfterToolUse; pub use types::HookEventLifecycle; +pub use types::HookEventPostToolUse; +pub use types::HookEventPreToolUse; pub use types::HookPayload; pub use types::HookResponse; pub use types::HookResult; diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index a04ff52f95..22cbef5ef0 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -1,6 +1,7 @@ use std::process::Stdio; use std::sync::Arc; +use tokio::io::AsyncWriteExt; use tokio::process::Command; use crate::types::Hook; @@ -13,25 +14,28 @@ use crate::types::HookResult; pub struct HooksConfig { pub legacy_notify_argv: Option>, pub session_start_argv: Option>, - pub turn_start_argv: Option>, - pub turn_end_argv: Option>, - pub compaction_argv: Option>, + pub user_prompt_submit_argv: Option>, + pub pre_tool_use_argv: Option>, + pub post_tool_use_argv: Option>, + pub stop_argv: Option>, + pub pre_compact_argv: Option>, pub session_end_argv: Option>, pub subagent_start_argv: Option>, - pub subagent_end_argv: Option>, + pub subagent_stop_argv: Option>, } #[derive(Clone)] pub struct Hooks { after_agent: Vec, - after_tool_use: Vec, + pre_tool_use: Vec, + post_tool_use: Vec, session_start: Vec, - turn_start: Vec, - turn_end: Vec, - compaction: Vec, + user_prompt_submit: Vec, + stop: Vec, + pre_compact: Vec, session_end: Vec, subagent_start: Vec, - subagent_end: Vec, + subagent_stop: Vec, } impl Default for Hooks { @@ -57,22 +61,34 @@ impl Hooks { .map(|argv| command_hook("session_start", argv)) .into_iter() .collect(); - let turn_start = config - .turn_start_argv + let user_prompt_submit = config + .user_prompt_submit_argv .filter(|argv| !argv.is_empty() && !argv[0].is_empty()) - .map(|argv| command_hook("turn_start", argv)) + .map(|argv| command_hook("user_prompt_submit", argv)) .into_iter() .collect(); - let turn_end = config - .turn_end_argv + let pre_tool_use = config + .pre_tool_use_argv .filter(|argv| !argv.is_empty() && !argv[0].is_empty()) - .map(|argv| command_hook("turn_end", argv)) + .map(|argv| command_hook("pre_tool_use", argv)) .into_iter() .collect(); - let compaction = config - .compaction_argv + let post_tool_use = config + .post_tool_use_argv .filter(|argv| !argv.is_empty() && !argv[0].is_empty()) - .map(|argv| command_hook("compaction", argv)) + .map(|argv| command_hook("post_tool_use", argv)) + .into_iter() + .collect(); + let stop = config + .stop_argv + .filter(|argv| !argv.is_empty() && !argv[0].is_empty()) + .map(|argv| command_hook("stop", argv)) + .into_iter() + .collect(); + let pre_compact = config + .pre_compact_argv + .filter(|argv| !argv.is_empty() && !argv[0].is_empty()) + .map(|argv| command_hook("pre_compact", argv)) .into_iter() .collect(); let session_end = config @@ -87,36 +103,38 @@ impl Hooks { .map(|argv| command_hook("subagent_start", argv)) .into_iter() .collect(); - let subagent_end = config - .subagent_end_argv + let subagent_stop = config + .subagent_stop_argv .filter(|argv| !argv.is_empty() && !argv[0].is_empty()) - .map(|argv| command_hook("subagent_end", argv)) + .map(|argv| command_hook("subagent_stop", argv)) .into_iter() .collect(); Self { after_agent, - after_tool_use: Vec::new(), + pre_tool_use, + post_tool_use, session_start, - turn_start, - turn_end, - compaction, + user_prompt_submit, + stop, + pre_compact, session_end, subagent_start, - subagent_end, + subagent_stop, } } fn hooks_for_event(&self, hook_event: &HookEvent) -> &[Hook] { match hook_event { HookEvent::AfterAgent { .. } => &self.after_agent, - HookEvent::AfterToolUse { .. } => &self.after_tool_use, + HookEvent::PreToolUse { .. } => &self.pre_tool_use, + HookEvent::PostToolUse { .. } => &self.post_tool_use, HookEvent::SessionStart { .. } => &self.session_start, - HookEvent::TurnStart { .. } => &self.turn_start, - HookEvent::TurnEnd { .. } => &self.turn_end, - HookEvent::Compaction { .. } => &self.compaction, + HookEvent::UserPromptSubmit { .. } => &self.user_prompt_submit, + HookEvent::Stop { .. } => &self.stop, + HookEvent::PreCompact { .. } => &self.pre_compact, HookEvent::SessionEnd { .. } => &self.session_end, HookEvent::SubagentStart { .. } => &self.subagent_start, - HookEvent::SubagentEnd { .. } => &self.subagent_end, + HookEvent::SubagentStop { .. } => &self.subagent_stop, } } @@ -162,13 +180,40 @@ fn command_hook(name: &str, argv: Vec) -> Hook { Ok(payload_json) => payload_json, Err(err) => return HookResult::FailedContinue(err.into()), }; - command.arg(payload_json); command - .stdin(Stdio::null()) + .stdin(Stdio::piped()) .stdout(Stdio::null()) - .stderr(Stdio::null()); - match command.spawn() { - Ok(_) => HookResult::Success, + .stderr(Stdio::piped()); + let mut child = match command.spawn() { + Ok(child) => child, + Err(err) => return HookResult::FailedContinue(err.into()), + }; + + if let Some(mut stdin) = child.stdin.take() + && let Err(err) = stdin.write_all(payload_json.as_bytes()).await + { + return HookResult::FailedContinue(Box::new(err)); + } + + match child.wait_with_output().await { + Ok(output) if output.status.success() => HookResult::Success, + Ok(output) => { + let code = output.status.code(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let message = if stderr.is_empty() { + match code { + Some(code) => format!("hook exited with status code {code}"), + None => "hook terminated without a status code".to_string(), + } + } else { + stderr + }; + if code == Some(2) && payload.hook_event.aborts_on_exit_code_two() { + HookResult::FailedAbort(std::io::Error::other(message).into()) + } else { + HookResult::FailedContinue(std::io::Error::other(message).into()) + } + } Err(err) => HookResult::FailedContinue(err.into()), } }) @@ -197,8 +242,9 @@ mod tests { use super::*; use crate::types::HookEventAfterAgent; - use crate::types::HookEventAfterToolUse; use crate::types::HookEventLifecycle; + use crate::types::HookEventPostToolUse; + use crate::types::HookEventPreToolUse; use crate::types::HookResult; use crate::types::HookToolInput; use crate::types::HookToolKind; @@ -209,6 +255,7 @@ mod tests { fn hook_payload(label: &str) -> HookPayload { HookPayload { session_id: ThreadId::new(), + transcript_path: None, cwd: PathBuf::from(CWD), client: None, triggered_at: Utc @@ -278,14 +325,15 @@ mod tests { fn after_tool_use_payload(label: &str) -> HookPayload { HookPayload { session_id: ThreadId::new(), + transcript_path: None, cwd: PathBuf::from(CWD), client: None, triggered_at: Utc .with_ymd_and_hms(2025, 1, 1, 0, 0, 0) .single() .expect("valid timestamp"), - hook_event: HookEvent::AfterToolUse { - event: HookEventAfterToolUse { + hook_event: HookEvent::PostToolUse { + event: HookEventPostToolUse { turn_id: format!("turn-{label}"), call_id: format!("call-{label}"), tool_name: "apply_patch".to_string(), @@ -308,6 +356,7 @@ mod tests { fn lifecycle_payload(event: HookEvent) -> HookPayload { HookPayload { session_id: ThreadId::new(), + transcript_path: Some("/tmp/rollout.jsonl".to_string()), cwd: PathBuf::from(CWD), client: Some("codex-tui".to_string()), triggered_at: Utc @@ -320,10 +369,9 @@ mod tests { fn sample_lifecycle_event() -> HookEventLifecycle { HookEventLifecycle { - session_ref: "session-ref".to_string(), previous_session_id: None, prompt: Some("hello".to_string()), - response_message: Some("done".to_string()), + last_assistant_message: Some("done".to_string()), tool_use_id: Some("toolu_123".to_string()), tool_input: Some(HookToolInput::Custom { input: "payload".to_string(), @@ -410,22 +458,26 @@ mod tests { fn hooks_new_wires_all_lifecycle_commands() { let hooks = Hooks::new(HooksConfig { session_start_argv: Some(vec!["hooks-cli".to_string()]), - turn_start_argv: Some(vec!["hooks-cli".to_string()]), - turn_end_argv: Some(vec!["hooks-cli".to_string()]), - compaction_argv: Some(vec!["hooks-cli".to_string()]), + user_prompt_submit_argv: Some(vec!["hooks-cli".to_string()]), + pre_tool_use_argv: Some(vec!["hooks-cli".to_string()]), + post_tool_use_argv: Some(vec!["hooks-cli".to_string()]), + stop_argv: Some(vec!["hooks-cli".to_string()]), + pre_compact_argv: Some(vec!["hooks-cli".to_string()]), session_end_argv: Some(vec!["hooks-cli".to_string()]), subagent_start_argv: Some(vec!["hooks-cli".to_string()]), - subagent_end_argv: Some(vec!["hooks-cli".to_string()]), + subagent_stop_argv: Some(vec!["hooks-cli".to_string()]), ..HooksConfig::default() }); assert_eq!(hooks.session_start.len(), 1); - assert_eq!(hooks.turn_start.len(), 1); - assert_eq!(hooks.turn_end.len(), 1); - assert_eq!(hooks.compaction.len(), 1); + assert_eq!(hooks.user_prompt_submit.len(), 1); + assert_eq!(hooks.pre_tool_use.len(), 1); + assert_eq!(hooks.post_tool_use.len(), 1); + assert_eq!(hooks.stop.len(), 1); + assert_eq!(hooks.pre_compact.len(), 1); assert_eq!(hooks.session_end.len(), 1); assert_eq!(hooks.subagent_start.len(), 1); - assert_eq!(hooks.subagent_end.len(), 1); + assert_eq!(hooks.subagent_stop.len(), 1); } #[tokio::test] @@ -493,7 +545,7 @@ mod tests { async fn dispatch_executes_after_tool_use_hooks() { let calls = Arc::new(AtomicUsize::new(0)); let hooks = Hooks { - after_tool_use: vec![counting_success_hook(&calls, "counting")], + post_tool_use: vec![counting_success_hook(&calls, "counting")], ..Hooks::default() }; @@ -515,10 +567,9 @@ mod tests { let outcomes = hooks .dispatch(lifecycle_payload(HookEvent::SessionStart { event: HookEventLifecycle { - session_ref: "session-ref".to_string(), previous_session_id: None, prompt: None, - response_message: None, + last_assistant_message: None, tool_use_id: None, tool_input: None, subagent_id: None, @@ -536,22 +587,29 @@ mod tests { async fn dispatch_routes_each_lifecycle_event_to_matching_hooks() { let session_start_calls = Arc::new(AtomicUsize::new(0)); let turn_start_calls = Arc::new(AtomicUsize::new(0)); - let turn_end_calls = Arc::new(AtomicUsize::new(0)); - let compaction_calls = Arc::new(AtomicUsize::new(0)); + let pre_tool_use_calls = Arc::new(AtomicUsize::new(0)); + let post_tool_use_calls = Arc::new(AtomicUsize::new(0)); + let stop_calls = Arc::new(AtomicUsize::new(0)); + let pre_compact_calls = Arc::new(AtomicUsize::new(0)); let session_end_calls = Arc::new(AtomicUsize::new(0)); let subagent_start_calls = Arc::new(AtomicUsize::new(0)); - let subagent_end_calls = Arc::new(AtomicUsize::new(0)); + let subagent_stop_calls = Arc::new(AtomicUsize::new(0)); let hooks = Hooks { session_start: vec![counting_success_hook(&session_start_calls, "session_start")], - turn_start: vec![counting_success_hook(&turn_start_calls, "turn_start")], - turn_end: vec![counting_success_hook(&turn_end_calls, "turn_end")], - compaction: vec![counting_success_hook(&compaction_calls, "compaction")], + user_prompt_submit: vec![counting_success_hook( + &turn_start_calls, + "user_prompt_submit", + )], + pre_tool_use: vec![counting_success_hook(&pre_tool_use_calls, "pre_tool_use")], + post_tool_use: vec![counting_success_hook(&post_tool_use_calls, "post_tool_use")], + stop: vec![counting_success_hook(&stop_calls, "stop")], + pre_compact: vec![counting_success_hook(&pre_compact_calls, "pre_compact")], session_end: vec![counting_success_hook(&session_end_calls, "session_end")], subagent_start: vec![counting_success_hook( &subagent_start_calls, "subagent_start", )], - subagent_end: vec![counting_success_hook(&subagent_end_calls, "subagent_end")], + subagent_stop: vec![counting_success_hook(&subagent_stop_calls, "subagent_stop")], ..Hooks::default() }; @@ -564,25 +622,48 @@ mod tests { Arc::clone(&session_start_calls), ), ( - "turn_start", - lifecycle_payload(HookEvent::TurnStart { + "user_prompt_submit", + lifecycle_payload(HookEvent::UserPromptSubmit { event: sample_lifecycle_event(), }), Arc::clone(&turn_start_calls), ), ( - "turn_end", - lifecycle_payload(HookEvent::TurnEnd { - event: sample_lifecycle_event(), + "pre_tool_use", + lifecycle_payload(HookEvent::PreToolUse { + event: HookEventPreToolUse { + turn_id: "turn-pre".to_string(), + call_id: "call-pre".to_string(), + tool_name: "apply_patch".to_string(), + tool_kind: HookToolKind::Custom, + tool_input: HookToolInput::Custom { + input: "*** Begin Patch".to_string(), + }, + mutating: Some(true), + sandbox: Some("none".to_string()), + sandbox_policy: Some("danger-full-access".to_string()), + }, }), - Arc::clone(&turn_end_calls), + Arc::clone(&pre_tool_use_calls), ), ( - "compaction", - lifecycle_payload(HookEvent::Compaction { + "post_tool_use", + after_tool_use_payload("post"), + Arc::clone(&post_tool_use_calls), + ), + ( + "stop", + lifecycle_payload(HookEvent::Stop { event: sample_lifecycle_event(), }), - Arc::clone(&compaction_calls), + Arc::clone(&stop_calls), + ), + ( + "pre_compact", + lifecycle_payload(HookEvent::PreCompact { + event: sample_lifecycle_event(), + }), + Arc::clone(&pre_compact_calls), ), ( "session_end", @@ -599,11 +680,11 @@ mod tests { Arc::clone(&subagent_start_calls), ), ( - "subagent_end", - lifecycle_payload(HookEvent::SubagentEnd { + "subagent_stop", + lifecycle_payload(HookEvent::SubagentStop { event: sample_lifecycle_event(), }), - Arc::clone(&subagent_end_calls), + Arc::clone(&subagent_stop_calls), ), ]; @@ -640,10 +721,10 @@ mod tests { async fn dispatch_returns_after_tool_use_failure_outcome() { let calls = Arc::new(AtomicUsize::new(0)); let hooks = Hooks { - after_tool_use: vec![failing_continue_hook( + post_tool_use: vec![failing_continue_hook( &calls, "failing", - "after_tool_use hook failed", + "post_tool_use hook failed", )], ..Hooks::default() }; @@ -657,38 +738,78 @@ mod tests { #[cfg(not(windows))] #[tokio::test] - async fn hook_executes_program_with_payload_argument_unix() -> Result<()> { + async fn pre_tool_use_exit_code_two_aborts_operation() { + let hooks = Hooks::new(HooksConfig { + pre_tool_use_argv: Some(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "exit 2".to_string(), + ]), + ..HooksConfig::default() + }); + + let outcomes = hooks + .dispatch(lifecycle_payload(HookEvent::PreToolUse { + event: HookEventPreToolUse { + turn_id: "turn-pre".to_string(), + call_id: "call-pre".to_string(), + tool_name: "apply_patch".to_string(), + tool_kind: HookToolKind::Custom, + tool_input: HookToolInput::Custom { + input: "*** Begin Patch".to_string(), + }, + mutating: Some(true), + sandbox: Some("none".to_string()), + sandbox_policy: Some("danger-full-access".to_string()), + }, + })) + .await; + + assert_eq!(outcomes.len(), 1); + assert!(matches!(outcomes[0].result, HookResult::FailedAbort(_))); + } + + #[cfg(not(windows))] + #[tokio::test] + async fn post_tool_use_exit_code_two_continues_operation() { + let hooks = Hooks::new(HooksConfig { + post_tool_use_argv: Some(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "exit 2".to_string(), + ]), + ..HooksConfig::default() + }); + + let outcomes = hooks + .dispatch(after_tool_use_payload("post-exit-two")) + .await; + + assert_eq!(outcomes.len(), 1); + assert!(matches!(outcomes[0].result, HookResult::FailedContinue(_))); + } + + #[cfg(not(windows))] + #[tokio::test] + async fn hook_executes_program_with_payload_on_stdin_unix() -> Result<()> { let temp_dir = tempdir()?; let payload_path = temp_dir.path().join("payload.json"); let payload_path_arg = payload_path.to_string_lossy().into_owned(); - let hook = Hook { - name: "write_payload".to_string(), - func: Arc::new(move |payload: &HookPayload| { - let payload_path_arg = payload_path_arg.clone(); - Box::pin(async move { - let json = to_string(payload).expect("serialize hook payload"); - let mut command = command_from_argv(&[ - "/bin/sh".to_string(), - "-c".to_string(), - "printf '%s' \"$2\" > \"$1\"".to_string(), - "sh".to_string(), - payload_path_arg, - json, - ]) - .expect("build command"); - command.status().await.expect("run hook command"); - HookResult::Success - }) - }), - }; - - let payload = hook_payload("4"); + let payload = lifecycle_payload(HookEvent::SessionStart { + event: sample_lifecycle_event(), + }); let expected = to_string(&payload)?; - let hooks = Hooks { - after_agent: vec![hook], - ..Hooks::default() - }; + let hooks = Hooks::new(HooksConfig { + session_start_argv: Some(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "cat > \"$1\"".to_string(), + "sh".to_string(), + payload_path_arg, + ]), + ..HooksConfig::default() + }); let outcomes = hooks.dispatch(payload).await; assert_eq!(outcomes.len(), 1); assert!(matches!(outcomes[0].result, HookResult::Success)); @@ -711,45 +832,34 @@ mod tests { #[cfg(windows)] #[tokio::test] - async fn hook_executes_program_with_payload_argument_windows() -> Result<()> { + async fn hook_executes_program_with_payload_on_stdin_windows() -> Result<()> { let temp_dir = tempdir()?; let payload_path = temp_dir.path().join("payload.json"); let payload_path_arg = payload_path.to_string_lossy().into_owned(); let script_path = temp_dir.path().join("write_payload.ps1"); - fs::write(&script_path, "[IO.File]::WriteAllText($args[0], $args[1])")?; + fs::write( + &script_path, + "$inputData = [Console]::In.ReadToEnd(); [IO.File]::WriteAllText($args[0], $inputData)", + )?; let script_path_arg = script_path.to_string_lossy().into_owned(); - let hook = Hook { - name: "write_payload".to_string(), - func: Arc::new(move |payload: &HookPayload| { - let payload_path_arg = payload_path_arg.clone(); - let script_path_arg = script_path_arg.clone(); - Box::pin(async move { - let json = to_string(payload).expect("serialize hook payload"); - let mut command = command_from_argv(&[ - "powershell.exe".to_string(), - "-NoLogo".to_string(), - "-NoProfile".to_string(), - "-ExecutionPolicy".to_string(), - "Bypass".to_string(), - "-File".to_string(), - script_path_arg, - payload_path_arg, - json, - ]) - .expect("build command"); - command.status().await.expect("run hook command"); - HookResult::Success - }) - }), - }; - - let payload = hook_payload("4"); + let payload = lifecycle_payload(HookEvent::SessionStart { + event: sample_lifecycle_event(), + }); let expected = to_string(&payload)?; - let hooks = Hooks { - after_agent: vec![hook], - ..Hooks::default() - }; + let hooks = Hooks::new(HooksConfig { + session_start_argv: Some(vec![ + "powershell.exe".to_string(), + "-NoLogo".to_string(), + "-NoProfile".to_string(), + "-ExecutionPolicy".to_string(), + "Bypass".to_string(), + "-File".to_string(), + script_path_arg, + payload_path_arg, + ]), + ..HooksConfig::default() + }); let outcomes = hooks.dispatch(payload).await; assert_eq!(outcomes.len(), 1); assert!(matches!(outcomes[0].result, HookResult::Success)); diff --git a/codex-rs/hooks/src/types.rs b/codex-rs/hooks/src/types.rs index 985c84ba44..47b35f329e 100644 --- a/codex-rs/hooks/src/types.rs +++ b/codex-rs/hooks/src/types.rs @@ -10,6 +10,9 @@ use codex_protocol::models::SandboxPermissions; use futures::future::BoxFuture; use serde::Serialize; use serde::Serializer; +use serde::ser::SerializeMap; +use serde_json::Map; +use serde_json::Value; pub type HookFn = Arc Fn(&'a HookPayload) -> BoxFuture<'a, HookResult> + Send + Sync>; @@ -61,14 +64,12 @@ impl Hook { } } -#[derive(Debug, Serialize, Clone)] -#[serde(rename_all = "snake_case")] +#[derive(Debug, Clone)] pub struct HookPayload { pub session_id: ThreadId, + pub transcript_path: Option, pub cwd: PathBuf, - #[serde(skip_serializing_if = "Option::is_none")] pub client: Option, - #[serde(serialize_with = "serialize_triggered_at")] pub triggered_at: DateTime, pub hook_event: HookEvent, } @@ -123,7 +124,23 @@ pub enum HookToolInput { #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "snake_case")] -pub struct HookEventAfterToolUse { +pub struct HookEventPreToolUse { + pub turn_id: String, + pub call_id: String, + pub tool_name: String, + pub tool_kind: HookToolKind, + pub tool_input: HookToolInput, + #[serde(skip_serializing_if = "Option::is_none")] + pub mutating: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox_policy: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub struct HookEventPostToolUse { pub turn_id: String, pub call_id: String, pub tool_name: String, @@ -141,13 +158,12 @@ pub struct HookEventAfterToolUse { #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "snake_case")] pub struct HookEventLifecycle { - pub session_ref: String, #[serde(skip_serializing_if = "Option::is_none")] pub previous_session_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub prompt: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub response_message: Option, + pub last_assistant_message: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tool_use_id: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -158,68 +174,99 @@ pub struct HookEventLifecycle { pub metadata: Option>, } -fn serialize_triggered_at(value: &DateTime, serializer: S) -> Result -where - S: Serializer, -{ - serializer.serialize_str(&value.to_rfc3339_opts(SecondsFormat::Secs, true)) -} - -#[derive(Debug, Clone, Serialize)] -#[serde(tag = "event_type", rename_all = "snake_case")] +#[derive(Debug, Clone)] pub enum HookEvent { - AfterAgent { - #[serde(flatten)] - event: HookEventAfterAgent, - }, - AfterToolUse { - #[serde(flatten)] - event: HookEventAfterToolUse, - }, - SessionStart { - #[serde(flatten)] - event: HookEventLifecycle, - }, - TurnStart { - #[serde(flatten)] - event: HookEventLifecycle, - }, - TurnEnd { - #[serde(flatten)] - event: HookEventLifecycle, - }, - Compaction { - #[serde(flatten)] - event: HookEventLifecycle, - }, - SessionEnd { - #[serde(flatten)] - event: HookEventLifecycle, - }, - SubagentStart { - #[serde(flatten)] - event: HookEventLifecycle, - }, - SubagentEnd { - #[serde(flatten)] - event: HookEventLifecycle, - }, + AfterAgent { event: HookEventAfterAgent }, + PreToolUse { event: HookEventPreToolUse }, + PostToolUse { event: HookEventPostToolUse }, + SessionStart { event: HookEventLifecycle }, + UserPromptSubmit { event: HookEventLifecycle }, + Stop { event: HookEventLifecycle }, + PreCompact { event: HookEventLifecycle }, + SessionEnd { event: HookEventLifecycle }, + SubagentStart { event: HookEventLifecycle }, + SubagentStop { event: HookEventLifecycle }, } impl HookEvent { pub fn name(&self) -> &'static str { match self { - Self::AfterAgent { .. } => "after_agent", - Self::AfterToolUse { .. } => "after_tool_use", - Self::SessionStart { .. } => "session_start", - Self::TurnStart { .. } => "turn_start", - Self::TurnEnd { .. } => "turn_end", - Self::Compaction { .. } => "compaction", - Self::SessionEnd { .. } => "session_end", - Self::SubagentStart { .. } => "subagent_start", - Self::SubagentEnd { .. } => "subagent_end", + Self::AfterAgent { .. } => "AfterAgent", + Self::PreToolUse { .. } => "PreToolUse", + Self::PostToolUse { .. } => "PostToolUse", + Self::SessionStart { .. } => "SessionStart", + Self::UserPromptSubmit { .. } => "UserPromptSubmit", + Self::Stop { .. } => "Stop", + Self::PreCompact { .. } => "PreCompact", + Self::SessionEnd { .. } => "SessionEnd", + Self::SubagentStart { .. } => "SubagentStart", + Self::SubagentStop { .. } => "SubagentStop", } } + + pub fn aborts_on_exit_code_two(&self) -> bool { + matches!( + self, + Self::PreToolUse { .. } + | Self::UserPromptSubmit { .. } + | Self::Stop { .. } + | Self::SubagentStop { .. } + ) + } + + fn fields(&self) -> Result, serde_json::Error> { + match self { + Self::AfterAgent { event } => serialize_object(event), + Self::PreToolUse { event } => serialize_object(event), + Self::PostToolUse { event } => serialize_object(event), + Self::SessionStart { event } => serialize_object(event), + Self::UserPromptSubmit { event } => serialize_object(event), + Self::Stop { event } => serialize_object(event), + Self::PreCompact { event } => serialize_object(event), + Self::SessionEnd { event } => serialize_object(event), + Self::SubagentStart { event } => serialize_object(event), + Self::SubagentStop { event } => serialize_object(event), + } + } +} + +impl Serialize for HookPayload { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut map = serializer.serialize_map(None)?; + map.serialize_entry("session_id", &self.session_id)?; + if let Some(transcript_path) = &self.transcript_path { + map.serialize_entry("transcript_path", transcript_path)?; + } + map.serialize_entry("cwd", &self.cwd)?; + if let Some(client) = &self.client { + map.serialize_entry("client", client)?; + } + map.serialize_entry( + "triggered_at", + &self.triggered_at.to_rfc3339_opts(SecondsFormat::Secs, true), + )?; + map.serialize_entry("hook_event_name", self.hook_event.name())?; + + let fields = self + .hook_event + .fields() + .map_err(serde::ser::Error::custom)?; + for (key, value) in fields { + map.serialize_entry(&key, &value)?; + } + + map.end() + } +} + +fn serialize_object(value: &T) -> Result, serde_json::Error> { + match serde_json::to_value(value)? { + Value::Object(object) => Ok(object), + _ => Ok(Map::new()), + } } #[cfg(test)] @@ -236,8 +283,9 @@ mod tests { use super::HookEvent; use super::HookEventAfterAgent; - use super::HookEventAfterToolUse; use super::HookEventLifecycle; + use super::HookEventPostToolUse; + use super::HookEventPreToolUse; use super::HookPayload; use super::HookToolInput; use super::HookToolInputLocalShell; @@ -251,10 +299,9 @@ mod tests { metadata.insert("phase".to_string(), "done".to_string()); HookEventLifecycle { - session_ref: "session-ref-1".to_string(), previous_session_id: Some(previous_session_id), prompt: Some("hello world".to_string()), - response_message: Some("done".to_string()), + last_assistant_message: Some("done".to_string()), tool_use_id: Some("toolu_123".to_string()), tool_input: Some(HookToolInput::Function { arguments: "{\"code\":\"cargo test\"}".to_string(), @@ -270,6 +317,7 @@ mod tests { let thread_id = ThreadId::new(); let payload = HookPayload { session_id, + transcript_path: Some("/tmp/rollout.jsonl".to_string()), cwd: PathBuf::from("tmp"), client: None, triggered_at: Utc @@ -289,33 +337,33 @@ mod tests { let actual = serde_json::to_value(payload).expect("serialize hook payload"); let expected = json!({ "session_id": session_id.to_string(), + "transcript_path": "/tmp/rollout.jsonl", "cwd": "tmp", "triggered_at": "2025-01-01T00:00:00Z", - "hook_event": { - "event_type": "after_agent", - "thread_id": thread_id.to_string(), - "turn_id": "turn-1", - "input_messages": ["hello"], - "last_assistant_message": "hi", - }, + "hook_event_name": "AfterAgent", + "thread_id": thread_id.to_string(), + "turn_id": "turn-1", + "input_messages": ["hello"], + "last_assistant_message": "hi", }); assert_eq!(actual, expected); } #[test] - fn after_tool_use_payload_serializes_stable_wire_shape() { + fn post_tool_use_payload_serializes_stable_wire_shape() { let session_id = ThreadId::new(); let payload = HookPayload { session_id, + transcript_path: Some("/tmp/rollout.jsonl".to_string()), cwd: PathBuf::from("tmp"), client: None, triggered_at: Utc .with_ymd_and_hms(2025, 1, 1, 0, 0, 0) .single() .expect("valid timestamp"), - hook_event: HookEvent::AfterToolUse { - event: HookEventAfterToolUse { + hook_event: HookEvent::PostToolUse { + event: HookEventPostToolUse { turn_id: "turn-2".to_string(), call_id: "call-1".to_string(), tool_name: "local_shell".to_string(), @@ -344,59 +392,68 @@ mod tests { let actual = serde_json::to_value(payload).expect("serialize hook payload"); let expected = json!({ "session_id": session_id.to_string(), + "transcript_path": "/tmp/rollout.jsonl", "cwd": "tmp", "triggered_at": "2025-01-01T00:00:00Z", - "hook_event": { - "event_type": "after_tool_use", - "turn_id": "turn-2", - "call_id": "call-1", - "tool_name": "local_shell", - "tool_kind": "local_shell", - "tool_input": { - "input_type": "local_shell", - "params": { - "command": ["cargo", "fmt"], - "workdir": "codex-rs", - "timeout_ms": 60000, - "sandbox_permissions": "use_default", - "justification": null, - "prefix_rule": null, - }, + "hook_event_name": "PostToolUse", + "turn_id": "turn-2", + "call_id": "call-1", + "tool_name": "local_shell", + "tool_kind": "local_shell", + "tool_input": { + "input_type": "local_shell", + "params": { + "command": ["cargo", "fmt"], + "workdir": "codex-rs", + "timeout_ms": 60000, + "sandbox_permissions": "use_default", + "justification": null, + "prefix_rule": null, }, - "executed": true, - "success": true, - "duration_ms": 42, - "mutating": true, - "sandbox": "none", - "sandbox_policy": "danger-full-access", - "output_preview": "ok", }, + "executed": true, + "success": true, + "duration_ms": 42, + "mutating": true, + "sandbox": "none", + "sandbox_policy": "danger-full-access", + "output_preview": "ok", }); assert_eq!(actual, expected); } #[test] - fn lifecycle_payload_serializes_stable_wire_shape() { + fn pre_tool_use_payload_serializes_stable_wire_shape() { let session_id = ThreadId::new(); let payload = HookPayload { session_id, + transcript_path: Some("/tmp/rollout.jsonl".to_string()), cwd: PathBuf::from("tmp"), client: Some("codex-tui".to_string()), triggered_at: Utc .with_ymd_and_hms(2025, 1, 1, 0, 0, 0) .single() .expect("valid timestamp"), - hook_event: HookEvent::TurnStart { - event: HookEventLifecycle { - session_ref: "session-ref-1".to_string(), - previous_session_id: None, - prompt: Some("hello world".to_string()), - response_message: None, - tool_use_id: None, - tool_input: None, - subagent_id: None, - metadata: None, + hook_event: HookEvent::PreToolUse { + event: HookEventPreToolUse { + turn_id: "turn-2".to_string(), + call_id: "call-1".to_string(), + tool_name: "local_shell".to_string(), + tool_kind: HookToolKind::LocalShell, + tool_input: HookToolInput::LocalShell { + params: HookToolInputLocalShell { + command: vec!["cargo".to_string(), "fmt".to_string()], + workdir: Some("codex-rs".to_string()), + timeout_ms: Some(60_000), + sandbox_permissions: Some(SandboxPermissions::UseDefault), + justification: None, + prefix_rule: None, + }, + }, + mutating: Some(true), + sandbox: Some("none".to_string()), + sandbox_policy: Some("danger-full-access".to_string()), }, }, }; @@ -404,14 +461,29 @@ mod tests { let actual = serde_json::to_value(payload).expect("serialize hook payload"); let expected = json!({ "session_id": session_id.to_string(), + "transcript_path": "/tmp/rollout.jsonl", "cwd": "tmp", "client": "codex-tui", "triggered_at": "2025-01-01T00:00:00Z", - "hook_event": { - "event_type": "turn_start", - "session_ref": "session-ref-1", - "prompt": "hello world", + "hook_event_name": "PreToolUse", + "turn_id": "turn-2", + "call_id": "call-1", + "tool_name": "local_shell", + "tool_kind": "local_shell", + "tool_input": { + "input_type": "local_shell", + "params": { + "command": ["cargo", "fmt"], + "workdir": "codex-rs", + "timeout_ms": 60000, + "sandbox_permissions": "use_default", + "justification": null, + "prefix_rule": null, + }, }, + "mutating": true, + "sandbox": "none", + "sandbox_policy": "danger-full-access", }); assert_eq!(actual, expected); @@ -424,6 +496,7 @@ mod tests { let subagent_id = ThreadId::new(); let base_payload = |hook_event: HookEvent| HookPayload { session_id, + transcript_path: Some("/tmp/rollout.jsonl".to_string()), cwd: PathBuf::from("tmp"), client: Some("codex-tui".to_string()), triggered_at: Utc @@ -435,36 +508,34 @@ mod tests { let cases = vec![ ( - "session_start", + "SessionStart", base_payload(HookEvent::SessionStart { event: sample_lifecycle_event(previous_session_id, subagent_id), }), json!({ "session_id": session_id.to_string(), + "transcript_path": "/tmp/rollout.jsonl", "cwd": "tmp", "client": "codex-tui", "triggered_at": "2025-01-01T00:00:00Z", - "hook_event": { - "event_type": "session_start", - "session_ref": "session-ref-1", - "previous_session_id": previous_session_id.to_string(), - "prompt": "hello world", - "response_message": "done", - "tool_use_id": "toolu_123", - "tool_input": { - "input_type": "function", - "arguments": "{\"code\":\"cargo test\"}", - }, - "subagent_id": subagent_id.to_string(), - "metadata": { - "phase": "done", - }, + "hook_event_name": "SessionStart", + "previous_session_id": previous_session_id.to_string(), + "prompt": "hello world", + "last_assistant_message": "done", + "tool_use_id": "toolu_123", + "tool_input": { + "input_type": "function", + "arguments": "{\"code\":\"cargo test\"}", + }, + "subagent_id": subagent_id.to_string(), + "metadata": { + "phase": "done", }, }), ), ( - "turn_end", - base_payload(HookEvent::TurnEnd { + "UserPromptSubmit", + base_payload(HookEvent::UserPromptSubmit { event: HookEventLifecycle { previous_session_id: None, subagent_id: None, @@ -474,25 +545,49 @@ mod tests { }), json!({ "session_id": session_id.to_string(), + "transcript_path": "/tmp/rollout.jsonl", "cwd": "tmp", "client": "codex-tui", "triggered_at": "2025-01-01T00:00:00Z", - "hook_event": { - "event_type": "turn_end", - "session_ref": "session-ref-1", - "prompt": "hello world", - "response_message": "done", - "tool_use_id": "toolu_123", - "tool_input": { - "input_type": "function", - "arguments": "{\"code\":\"cargo test\"}", - }, + "hook_event_name": "UserPromptSubmit", + "prompt": "hello world", + "last_assistant_message": "done", + "tool_use_id": "toolu_123", + "tool_input": { + "input_type": "function", + "arguments": "{\"code\":\"cargo test\"}", }, }), ), ( - "compaction", - base_payload(HookEvent::Compaction { + "Stop", + base_payload(HookEvent::Stop { + event: HookEventLifecycle { + previous_session_id: None, + subagent_id: None, + metadata: None, + ..sample_lifecycle_event(previous_session_id, subagent_id) + }, + }), + json!({ + "session_id": session_id.to_string(), + "transcript_path": "/tmp/rollout.jsonl", + "cwd": "tmp", + "client": "codex-tui", + "triggered_at": "2025-01-01T00:00:00Z", + "hook_event_name": "Stop", + "prompt": "hello world", + "last_assistant_message": "done", + "tool_use_id": "toolu_123", + "tool_input": { + "input_type": "function", + "arguments": "{\"code\":\"cargo test\"}", + }, + }), + ), + ( + "PreCompact", + base_payload(HookEvent::PreCompact { event: HookEventLifecycle { prompt: None, tool_input: None, @@ -502,27 +597,25 @@ mod tests { }), json!({ "session_id": session_id.to_string(), + "transcript_path": "/tmp/rollout.jsonl", "cwd": "tmp", "client": "codex-tui", "triggered_at": "2025-01-01T00:00:00Z", - "hook_event": { - "event_type": "compaction", - "session_ref": "session-ref-1", - "previous_session_id": previous_session_id.to_string(), - "response_message": "done", - "tool_use_id": "toolu_123", - "metadata": { - "phase": "done", - }, + "hook_event_name": "PreCompact", + "previous_session_id": previous_session_id.to_string(), + "last_assistant_message": "done", + "tool_use_id": "toolu_123", + "metadata": { + "phase": "done", }, }), ), ( - "session_end", + "SessionEnd", base_payload(HookEvent::SessionEnd { event: HookEventLifecycle { prompt: None, - response_message: None, + last_assistant_message: None, tool_use_id: None, tool_input: None, subagent_id: None, @@ -532,22 +625,20 @@ mod tests { }), json!({ "session_id": session_id.to_string(), + "transcript_path": "/tmp/rollout.jsonl", "cwd": "tmp", "client": "codex-tui", "triggered_at": "2025-01-01T00:00:00Z", - "hook_event": { - "event_type": "session_end", - "session_ref": "session-ref-1", - "previous_session_id": previous_session_id.to_string(), - }, + "hook_event_name": "SessionEnd", + "previous_session_id": previous_session_id.to_string(), }), ), ( - "subagent_start", + "SubagentStart", base_payload(HookEvent::SubagentStart { event: HookEventLifecycle { previous_session_id: None, - response_message: None, + last_assistant_message: None, subagent_id: None, metadata: None, ..sample_lifecycle_event(previous_session_id, subagent_id) @@ -555,47 +646,43 @@ mod tests { }), json!({ "session_id": session_id.to_string(), + "transcript_path": "/tmp/rollout.jsonl", "cwd": "tmp", "client": "codex-tui", "triggered_at": "2025-01-01T00:00:00Z", - "hook_event": { - "event_type": "subagent_start", - "session_ref": "session-ref-1", - "prompt": "hello world", - "tool_use_id": "toolu_123", - "tool_input": { - "input_type": "function", - "arguments": "{\"code\":\"cargo test\"}", - }, + "hook_event_name": "SubagentStart", + "prompt": "hello world", + "tool_use_id": "toolu_123", + "tool_input": { + "input_type": "function", + "arguments": "{\"code\":\"cargo test\"}", }, }), ), ( - "subagent_end", - base_payload(HookEvent::SubagentEnd { + "SubagentStop", + base_payload(HookEvent::SubagentStop { event: HookEventLifecycle { previous_session_id: None, - response_message: None, + last_assistant_message: None, metadata: None, ..sample_lifecycle_event(previous_session_id, subagent_id) }, }), json!({ "session_id": session_id.to_string(), + "transcript_path": "/tmp/rollout.jsonl", "cwd": "tmp", "client": "codex-tui", "triggered_at": "2025-01-01T00:00:00Z", - "hook_event": { - "event_type": "subagent_end", - "session_ref": "session-ref-1", - "prompt": "hello world", - "tool_use_id": "toolu_123", - "tool_input": { - "input_type": "function", - "arguments": "{\"code\":\"cargo test\"}", - }, - "subagent_id": subagent_id.to_string(), + "hook_event_name": "SubagentStop", + "prompt": "hello world", + "tool_use_id": "toolu_123", + "tool_input": { + "input_type": "function", + "arguments": "{\"code\":\"cargo test\"}", }, + "subagent_id": subagent_id.to_string(), }), ), ]; diff --git a/codex-rs/hooks/src/user_notification.rs b/codex-rs/hooks/src/user_notification.rs index caeca59b50..f185d273c7 100644 --- a/codex-rs/hooks/src/user_notification.rs +++ b/codex-rs/hooks/src/user_notification.rs @@ -121,6 +121,7 @@ mod tests { fn legacy_notify_json_matches_historical_wire_shape() -> Result<()> { let payload = HookPayload { session_id: ThreadId::new(), + transcript_path: None, cwd: std::path::Path::new("/Users/example/project").to_path_buf(), client: Some("codex-tui".to_string()), triggered_at: chrono::Utc::now(), diff --git a/docs/config.md b/docs/config.md index e8723d8c95..bae6bbc71b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -33,15 +33,26 @@ Codex can run command hooks for lifecycle events via `[hooks]` in `~/.codex/conf ```toml [hooks] session_start = ["my-hook", "session_start"] -turn_start = ["my-hook", "turn_start"] -turn_end = ["my-hook", "turn_end"] -compaction = ["my-hook", "compaction"] +user_prompt_submit = ["my-hook", "user_prompt_submit"] +pre_tool_use = ["my-hook", "pre_tool_use"] +post_tool_use = ["my-hook", "post_tool_use"] +stop = ["my-hook", "stop"] +pre_compact = ["my-hook", "pre_compact"] session_end = ["my-hook", "session_end"] subagent_start = ["my-hook", "subagent_start"] -subagent_end = ["my-hook", "subagent_end"] +subagent_stop = ["my-hook", "subagent_stop"] ``` -Each command receives one extra final argv argument with JSON describing the hook payload. +Each command receives JSON on stdin describing the hook payload. + +To align with the Claude-compatible event naming, the hook names are: + +- `turn_start` becomes `user_prompt_submit` +- `turn_end` becomes `stop` +- `compaction` becomes `pre_compact` +- `subagent_end` becomes `subagent_stop` + +These older names never landed on `main`, so they are not accepted as aliases. ## JSON Schema