feat(hooks): align lifecycle hooks with Claude-style naming and payloads

This commit is contained in:
Peter Steinberger
2026-03-04 21:58:08 +00:00
parent 86d6d7742a
commit ea65336542
11 changed files with 746 additions and 388 deletions
+22 -8
View File
@@ -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."
},
+39 -37
View File
@@ -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<String> {
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::<Vec<_>>()
.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(
+60 -4
View File
@@ -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<Vec<String>>,
pub turn_start: Option<Vec<String>>,
pub turn_end: Option<Vec<String>>,
pub compaction: Option<Vec<String>>,
pub user_prompt_submit: Option<Vec<String>>,
pub pre_tool_use: Option<Vec<String>>,
pub post_tool_use: Option<Vec<String>>,
pub stop: Option<Vec<String>>,
pub pre_compact: Option<Vec<String>>,
pub session_end: Option<Vec<String>>,
pub subagent_start: Option<Vec<String>>,
pub subagent_end: Option<Vec<String>>,
pub subagent_stop: Option<Vec<String>>,
}
/// 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::<ConfigToml>(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;
@@ -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(),
+87 -11
View File
@@ -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<FunctionCallError> {
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<FunctionCallError> {
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}"
)));
}
}
+1 -1
View File
@@ -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 }
+2 -1
View File
@@ -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;
+242 -132
View File
@@ -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<Vec<String>>,
pub session_start_argv: Option<Vec<String>>,
pub turn_start_argv: Option<Vec<String>>,
pub turn_end_argv: Option<Vec<String>>,
pub compaction_argv: Option<Vec<String>>,
pub user_prompt_submit_argv: Option<Vec<String>>,
pub pre_tool_use_argv: Option<Vec<String>>,
pub post_tool_use_argv: Option<Vec<String>>,
pub stop_argv: Option<Vec<String>>,
pub pre_compact_argv: Option<Vec<String>>,
pub session_end_argv: Option<Vec<String>>,
pub subagent_start_argv: Option<Vec<String>>,
pub subagent_end_argv: Option<Vec<String>>,
pub subagent_stop_argv: Option<Vec<String>>,
}
#[derive(Clone)]
pub struct Hooks {
after_agent: Vec<Hook>,
after_tool_use: Vec<Hook>,
pre_tool_use: Vec<Hook>,
post_tool_use: Vec<Hook>,
session_start: Vec<Hook>,
turn_start: Vec<Hook>,
turn_end: Vec<Hook>,
compaction: Vec<Hook>,
user_prompt_submit: Vec<Hook>,
stop: Vec<Hook>,
pre_compact: Vec<Hook>,
session_end: Vec<Hook>,
subagent_start: Vec<Hook>,
subagent_end: Vec<Hook>,
subagent_stop: Vec<Hook>,
}
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<String>) -> 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));
+269 -182
View File
@@ -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<dyn for<'a> 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<String>,
pub cwd: PathBuf,
#[serde(skip_serializing_if = "Option::is_none")]
pub client: Option<String>,
#[serde(serialize_with = "serialize_triggered_at")]
pub triggered_at: DateTime<Utc>,
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<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox_policy: Option<String>,
}
#[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<ThreadId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_message: Option<String>,
pub last_assistant_message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_use_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -158,68 +174,99 @@ pub struct HookEventLifecycle {
pub metadata: Option<HashMap<String, String>>,
}
fn serialize_triggered_at<S>(value: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
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<Map<String, Value>, 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<T: Serialize>(value: &T) -> Result<Map<String, Value>, 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(),
}),
),
];
+1
View File
@@ -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(),
+16 -5
View File
@@ -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