Compare commits

...

3 Commits

Author SHA1 Message Date
Peter Steinberger
ea65336542 feat(hooks): align lifecycle hooks with Claude-style naming and payloads 2026-03-04 21:58:08 +00:00
Peter Steinberger
86d6d7742a test(hooks): cover lifecycle hook routing and payload variants 2026-03-04 21:58:07 +00:00
Peter Steinberger
60f646db30 feat(hooks): add lifecycle hook dispatch and transcript paths 2026-03-04 21:58:07 +00:00
11 changed files with 1494 additions and 145 deletions

View File

@@ -634,6 +634,67 @@
}
]
},
"HooksToml": {
"additionalProperties": false,
"description": "Lifecycle hook command config deserialized from `[hooks]` in config.toml.",
"properties": {
"post_tool_use": {
"items": {
"type": "string"
},
"type": "array"
},
"pre_compact": {
"items": {
"type": "string"
},
"type": "array"
},
"pre_tool_use": {
"items": {
"type": "string"
},
"type": "array"
},
"session_end": {
"items": {
"type": "string"
},
"type": "array"
},
"session_start": {
"items": {
"type": "string"
},
"type": "array"
},
"stop": {
"items": {
"type": "string"
},
"type": "array"
},
"subagent_start": {
"items": {
"type": "string"
},
"type": "array"
},
"subagent_stop": {
"items": {
"type": "string"
},
"type": "array"
},
"user_prompt_submit": {
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "object"
},
"MemoriesToml": {
"additionalProperties": false,
"description": "Memories settings loaded from config.toml.",
@@ -1910,6 +1971,25 @@
"default": null,
"description": "Settings that govern if and what will be written to `~/.codex/history.jsonl`."
},
"hooks": {
"allOf": [
{
"$ref": "#/definitions/HooksToml"
}
],
"default": {
"post_tool_use": null,
"pre_compact": null,
"pre_tool_use": null,
"session_end": null,
"session_start": null,
"stop": null,
"subagent_start": null,
"subagent_stop": null,
"user_prompt_submit": null
},
"description": "Optional external hook commands keyed by lifecycle event."
},
"instructions": {
"description": "System instructions.",
"type": "string"

View File

@@ -57,6 +57,7 @@ use chrono::Local;
use chrono::Utc;
use codex_hooks::HookEvent;
use codex_hooks::HookEventAfterAgent;
use codex_hooks::HookEventLifecycle;
use codex_hooks::HookPayload;
use codex_hooks::HookResult;
use codex_hooks::Hooks;
@@ -1506,9 +1507,7 @@ impl Session {
Arc::clone(&config),
Arc::clone(&auth_manager),
),
hooks: Hooks::new(HooksConfig {
legacy_notify_argv: config.notify.clone(),
}),
hooks: Hooks::new(hooks_config_from_config(config.as_ref())),
rollout: Mutex::new(rollout_recorder),
user_shell: Arc::new(default_shell),
shell_snapshot_tx,
@@ -1588,6 +1587,29 @@ impl Session {
for event in events {
sess.send_event_raw(event).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: session_configuration.app_server_client_name.clone(),
triggered_at: chrono::Utc::now(),
hook_event: HookEvent::SessionStart {
event: HookEventLifecycle {
previous_session_id: None,
prompt: None,
last_assistant_message: None,
tool_use_id: None,
tool_input: None,
subagent_id: None,
metadata: None,
},
},
},
)
.await;
// Start the watcher after SessionConfigured so it cannot emit earlier events.
sess.start_file_watcher_listener();
@@ -4613,9 +4635,18 @@ mod handlers {
i64::try_from(turn_count).unwrap_or(0),
&[],
);
let cwd = {
let state = sess.state.lock().await;
state.session_configuration.cwd.clone()
};
// Gracefully flush and shutdown rollout recorder on session end so tests
// that inspect the rollout file do not race with the background writer.
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()
@@ -4633,6 +4664,28 @@ mod handlers {
};
sess.send_event_raw(event).await;
}
super::dispatch_nonfatal_lifecycle_hook(
sess.as_ref(),
codex_hooks::HookPayload {
session_id: sess.conversation_id,
transcript_path,
cwd,
client,
triggered_at: chrono::Utc::now(),
hook_event: codex_hooks::HookEvent::SessionEnd {
event: codex_hooks::HookEventLifecycle {
previous_session_id: None,
prompt: None,
last_assistant_message: None,
tool_use_id: None,
tool_input: None,
subagent_id: None,
metadata: None,
},
},
},
)
.await;
let event = Event {
id: sub_id,
@@ -4866,6 +4919,55 @@ fn errors_to_info(errors: &[SkillError]) -> Vec<SkillErrorInfo> {
.collect()
}
fn hooks_config_from_config(config: &Config) -> HooksConfig {
HooksConfig {
legacy_notify_argv: config.notify.clone(),
session_start_argv: config.hooks.session_start.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_stop_argv: config.hooks.subagent_stop.clone(),
}
}
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())
}
pub(crate) async fn dispatch_nonfatal_lifecycle_hook(session: &Session, hook_payload: HookPayload) {
let hook_event = hook_payload.hook_event.name();
let hook_outcomes = session.hooks().dispatch(hook_payload).await;
for hook_outcome in hook_outcomes {
let hook_name = hook_outcome.hook_name;
match hook_outcome.result {
HookResult::Success => {}
HookResult::FailedContinue(error) => {
warn!(
hook_event = %hook_event,
hook_name = %hook_name,
error = %error,
"lifecycle hook failed; continuing"
);
}
HookResult::FailedAbort(error) => {
warn!(
hook_event = %hook_event,
hook_name = %hook_name,
error = %error,
"lifecycle hook failed with abort; continuing"
);
}
}
}
}
/// Takes a user message as input and runs a loop where, at each sampling request, the model
/// replies with either:
///
@@ -4900,6 +5002,37 @@ pub(crate) async fn run_turn(
collaboration_mode_kind: turn_context.collaboration_mode.mode,
});
sess.send_event(&turn_context, event).await;
let prompt = input
.iter()
.filter_map(|item| match item {
UserInput::Text { text, .. } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
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::UserPromptSubmit {
event: HookEventLifecycle {
previous_session_id: None,
prompt: (!prompt.is_empty()).then_some(prompt),
last_assistant_message: None,
tool_use_id: None,
tool_input: None,
subagent_id: None,
metadata: None,
},
},
},
)
.await;
// TODO(ccunningham): Pre-turn compaction runs before context updates and the
// new user message are recorded. Estimate pending incoming items (context
// diffs/full reinjection + user input) and trigger compaction preemptively
@@ -5151,11 +5284,38 @@ 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 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::Stop {
event: HookEventLifecycle {
previous_session_id: None,
prompt: turn_end_prompt,
last_assistant_message,
tool_use_id: None,
tool_input: None,
subagent_id: None,
metadata: None,
},
},
},
)
.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(),
@@ -5335,6 +5495,29 @@ async fn run_auto_compact(
)
.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::PreCompact {
event: HookEventLifecycle {
previous_session_id: None,
prompt: Some(turn_context.compact_prompt().to_string()),
last_assistant_message: None,
tool_use_id: None,
tool_input: None,
subagent_id: None,
metadata: None,
},
},
},
)
.await;
Ok(())
}
@@ -8426,9 +8609,7 @@ mod tests {
Arc::clone(&config),
Arc::clone(&auth_manager),
),
hooks: Hooks::new(HooksConfig {
legacy_notify_argv: config.notify.clone(),
}),
hooks: Hooks::new(hooks_config_from_config(config.as_ref())),
rollout: Mutex::new(None),
user_shell: Arc::new(default_user_shell()),
shell_snapshot_tx: watch::channel(None).0,
@@ -8675,6 +8856,48 @@ mod tests {
);
}
#[tokio::test]
async fn compute_hook_transcript_path_is_none_without_rollout() {
let (session, _turn_context) = make_session_and_context().await;
let transcript_path = compute_hook_transcript_path(&session).await;
assert_eq!(transcript_path, None);
}
#[tokio::test]
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;
Arc::clone(&state.session_configuration.original_config_do_not_use)
};
let recorder = RolloutRecorder::new(
config.as_ref(),
RolloutRecorderParams::new(
session.conversation_id,
None,
SessionSource::Exec,
BaseInstructions::default(),
Vec::new(),
EventPersistenceMode::Limited,
),
None,
None,
)
.await
.expect("create rollout recorder");
let rollout_path = recorder.rollout_path().display().to_string();
{
let mut rollout = session.services.rollout.lock().await;
*rollout = Some(recorder);
}
let transcript_path = compute_hook_transcript_path(&session).await;
assert_eq!(transcript_path, Some(rollout_path));
}
pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
dynamic_tools: Vec<DynamicToolSpec>,
) -> (
@@ -8775,9 +8998,7 @@ mod tests {
Arc::clone(&config),
Arc::clone(&auth_manager),
),
hooks: Hooks::new(HooksConfig {
legacy_notify_argv: config.notify.clone(),
}),
hooks: Hooks::new(hooks_config_from_config(config.as_ref())),
rollout: Mutex::new(None),
user_shell: Arc::new(default_user_shell()),
shell_snapshot_tx: watch::channel(None).0,

View File

@@ -273,6 +273,9 @@ pub struct Config {
/// If unset the feature is disabled.
pub notify: Option<Vec<String>>,
/// Optional external hook commands keyed by lifecycle event.
pub hooks: HooksToml,
/// TUI notifications preference. When set, the TUI will send terminal notifications on
/// approvals and turn completions when not focused.
pub tui_notifications: Notifications,
@@ -1008,6 +1011,22 @@ pub fn set_default_oss_provider(codex_home: &Path, provider: &str) -> std::io::R
.map_err(|err| std::io::Error::other(format!("failed to persist config.toml: {err}")))
}
/// 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 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_stop: Option<Vec<String>>,
}
/// Base config deserialized from ~/.codex/config.toml.
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)]
#[schemars(deny_unknown_fields)]
@@ -1056,6 +1075,10 @@ pub struct ConfigToml {
#[serde(default)]
pub notify: Option<Vec<String>>,
/// Optional external hook commands keyed by lifecycle event.
#[serde(default)]
pub hooks: HooksToml,
/// System instructions.
pub instructions: Option<String>,
@@ -2157,6 +2180,7 @@ impl Config {
enforce_residency: enforce_residency.value,
did_user_set_custom_approval_policy_or_sandbox_mode,
notify: cfg.notify,
hooks: cfg.hooks,
user_instructions,
base_instructions,
personality,
@@ -5198,6 +5222,7 @@ model_verbosity = "high"
did_user_set_custom_approval_policy_or_sandbox_mode: true,
user_instructions: None,
notify: None,
hooks: HooksToml::default(),
cwd: fixture.cwd(),
cli_auth_credentials_store_mode: Default::default(),
mcp_servers: Constrained::allow_any(HashMap::new()),
@@ -5328,6 +5353,7 @@ model_verbosity = "high"
did_user_set_custom_approval_policy_or_sandbox_mode: true,
user_instructions: None,
notify: None,
hooks: HooksToml::default(),
cwd: fixture.cwd(),
cli_auth_credentials_store_mode: Default::default(),
mcp_servers: Constrained::allow_any(HashMap::new()),
@@ -5456,6 +5482,7 @@ model_verbosity = "high"
did_user_set_custom_approval_policy_or_sandbox_mode: true,
user_instructions: None,
notify: None,
hooks: HooksToml::default(),
cwd: fixture.cwd(),
cli_auth_credentials_store_mode: Default::default(),
mcp_servers: Constrained::allow_any(HashMap::new()),
@@ -5570,6 +5597,7 @@ model_verbosity = "high"
did_user_set_custom_approval_policy_or_sandbox_mode: true,
user_instructions: None,
notify: None,
hooks: HooksToml::default(),
cwd: fixture.cwd(),
cli_auth_credentials_store_mode: Default::default(),
mcp_servers: Constrained::allow_any(HashMap::new()),
@@ -6660,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;

View File

@@ -9,6 +9,8 @@ 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_transcript_path;
use crate::codex::dispatch_nonfatal_lifecycle_hook;
use crate::config::Config;
use crate::error::CodexErr;
use crate::features::Feature;
@@ -20,6 +22,10 @@ use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use async_trait::async_trait;
use codex_hooks::HookEvent;
use codex_hooks::HookEventLifecycle;
use codex_hooks::HookPayload;
use codex_hooks::HookToolInput;
use codex_protocol::ThreadId;
use codex_protocol::models::BaseInstructions;
use codex_protocol::models::FunctionCallOutputBody;
@@ -136,6 +142,7 @@ mod spawn {
.filter(|role| !role.is_empty());
let input_items = parse_collab_input(args.message, args.items)?;
let prompt = input_preview(&input_items);
let prompt_for_hook = (!prompt.is_empty()).then_some(prompt.clone());
let session_source = turn.session_source.clone();
let child_depth = next_thread_spawn_depth(&session_source);
let max_depth = turn.config.agent_max_depth;
@@ -155,6 +162,31 @@ mod spawn {
.into(),
)
.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 {
previous_session_id: None,
prompt: prompt_for_hook.clone(),
last_assistant_message: None,
tool_use_id: Some(call_id.clone()),
tool_input: Some(HookToolInput::Function {
arguments: arguments.clone(),
}),
subagent_id: None,
metadata: None,
},
},
},
)
.await;
let mut config =
build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?;
apply_role_to_config(&mut config, role_name)
@@ -201,7 +233,7 @@ mod spawn {
.send_event(
&turn,
CollabAgentSpawnEndEvent {
call_id,
call_id: call_id.clone(),
sender_thread_id: session.conversation_id,
new_thread_id,
new_agent_nickname,
@@ -212,6 +244,30 @@ mod spawn {
.into(),
)
.await;
dispatch_nonfatal_lifecycle_hook(
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::SubagentStop {
event: HookEventLifecycle {
previous_session_id: None,
prompt: prompt_for_hook,
last_assistant_message: None,
tool_use_id: Some(call_id.clone()),
tool_input: Some(HookToolInput::Function {
arguments: arguments.clone(),
}),
subagent_id: new_thread_id,
metadata: None,
},
},
},
)
.await;
let new_thread_id = result?;
let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME);
turn.otel_manager

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}"
)));
}
}

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 }

View File

@@ -8,7 +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;

View File

@@ -1,19 +1,41 @@
use std::process::Stdio;
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use crate::types::Hook;
use crate::types::HookEvent;
use crate::types::HookPayload;
use crate::types::HookResponse;
use crate::types::HookResult;
#[derive(Default, Clone)]
pub struct HooksConfig {
pub legacy_notify_argv: Option<Vec<String>>,
pub session_start_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_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>,
user_prompt_submit: Vec<Hook>,
stop: Vec<Hook>,
pre_compact: Vec<Hook>,
session_end: Vec<Hook>,
subagent_start: Vec<Hook>,
subagent_stop: Vec<Hook>,
}
impl Default for Hooks {
@@ -32,16 +54,87 @@ impl Hooks {
.map(crate::notify_hook)
.into_iter()
.collect();
let session_start = config
.session_start_argv
.filter(|argv| !argv.is_empty() && !argv[0].is_empty())
.map(|argv| command_hook("session_start", argv))
.into_iter()
.collect();
let user_prompt_submit = config
.user_prompt_submit_argv
.filter(|argv| !argv.is_empty() && !argv[0].is_empty())
.map(|argv| command_hook("user_prompt_submit", argv))
.into_iter()
.collect();
let pre_tool_use = config
.pre_tool_use_argv
.filter(|argv| !argv.is_empty() && !argv[0].is_empty())
.map(|argv| command_hook("pre_tool_use", argv))
.into_iter()
.collect();
let post_tool_use = config
.post_tool_use_argv
.filter(|argv| !argv.is_empty() && !argv[0].is_empty())
.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
.session_end_argv
.filter(|argv| !argv.is_empty() && !argv[0].is_empty())
.map(|argv| command_hook("session_end", argv))
.into_iter()
.collect();
let subagent_start = config
.subagent_start_argv
.filter(|argv| !argv.is_empty() && !argv[0].is_empty())
.map(|argv| command_hook("subagent_start", argv))
.into_iter()
.collect();
let subagent_stop = config
.subagent_stop_argv
.filter(|argv| !argv.is_empty() && !argv[0].is_empty())
.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,
user_prompt_submit,
stop,
pre_compact,
session_end,
subagent_start,
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::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::SubagentStop { .. } => &self.subagent_stop,
}
}
@@ -71,6 +164,63 @@ pub fn command_from_argv(argv: &[String]) -> Option<Command> {
Some(command)
}
fn command_hook(name: &str, argv: Vec<String>) -> Hook {
let hook_name = name.to_string();
let argv = Arc::new(argv);
Hook {
name: hook_name,
func: Arc::new(move |payload: &HookPayload| {
let argv = Arc::clone(&argv);
Box::pin(async move {
let mut command = match command_from_argv(&argv) {
Some(command) => command,
None => return HookResult::Success,
};
let payload_json = match serde_json::to_string(payload) {
Ok(payload_json) => payload_json,
Err(err) => return HookResult::FailedContinue(err.into()),
};
command
.stdin(Stdio::piped())
.stdout(Stdio::null())
.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()),
}
})
}),
}
}
#[cfg(test)]
mod tests {
use std::fs;
@@ -92,7 +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;
@@ -103,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
@@ -172,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(),
@@ -199,6 +353,34 @@ 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
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp"),
hook_event: event,
}
}
fn sample_lifecycle_event() -> HookEventLifecycle {
HookEventLifecycle {
previous_session_id: None,
prompt: Some("hello".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(),
}),
subagent_id: Some(ThreadId::new()),
metadata: None,
}
}
#[test]
fn command_from_argv_returns_none_for_empty_args() {
assert!(command_from_argv(&[]).is_none());
@@ -231,6 +413,7 @@ mod tests {
assert!(
Hooks::new(HooksConfig {
legacy_notify_argv: Some(vec![]),
..HooksConfig::default()
})
.after_agent
.is_empty()
@@ -238,6 +421,7 @@ mod tests {
assert!(
Hooks::new(HooksConfig {
legacy_notify_argv: Some(vec!["".to_string()]),
..HooksConfig::default()
})
.after_agent
.is_empty()
@@ -245,11 +429,55 @@ mod tests {
assert_eq!(
Hooks::new(HooksConfig {
legacy_notify_argv: Some(vec!["notify-send".to_string()]),
..HooksConfig::default()
})
.after_agent
.len(),
1
);
assert!(
Hooks::new(HooksConfig {
session_start_argv: Some(vec!["".to_string()]),
..HooksConfig::default()
})
.session_start
.is_empty()
);
assert_eq!(
Hooks::new(HooksConfig {
session_start_argv: Some(vec!["hooks-cli".to_string()]),
..HooksConfig::default()
})
.session_start
.len(),
1
);
}
#[test]
fn hooks_new_wires_all_lifecycle_commands() {
let hooks = Hooks::new(HooksConfig {
session_start_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_stop_argv: Some(vec!["hooks-cli".to_string()]),
..HooksConfig::default()
});
assert_eq!(hooks.session_start.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_stop.len(), 1);
}
#[tokio::test]
@@ -317,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()
};
@@ -328,6 +556,147 @@ mod tests {
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn dispatch_executes_lifecycle_hooks() {
let calls = Arc::new(AtomicUsize::new(0));
let hooks = Hooks {
session_start: vec![counting_success_hook(&calls, "counting")],
..Hooks::default()
};
let outcomes = hooks
.dispatch(lifecycle_payload(HookEvent::SessionStart {
event: HookEventLifecycle {
previous_session_id: None,
prompt: None,
last_assistant_message: None,
tool_use_id: None,
tool_input: None,
subagent_id: None,
metadata: None,
},
}))
.await;
assert_eq!(outcomes.len(), 1);
assert_eq!(outcomes[0].hook_name, "counting");
assert!(matches!(outcomes[0].result, HookResult::Success));
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
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 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_stop_calls = Arc::new(AtomicUsize::new(0));
let hooks = Hooks {
session_start: vec![counting_success_hook(&session_start_calls, "session_start")],
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_stop: vec![counting_success_hook(&subagent_stop_calls, "subagent_stop")],
..Hooks::default()
};
let cases = vec![
(
"session_start",
lifecycle_payload(HookEvent::SessionStart {
event: sample_lifecycle_event(),
}),
Arc::clone(&session_start_calls),
),
(
"user_prompt_submit",
lifecycle_payload(HookEvent::UserPromptSubmit {
event: sample_lifecycle_event(),
}),
Arc::clone(&turn_start_calls),
),
(
"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(&pre_tool_use_calls),
),
(
"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(&stop_calls),
),
(
"pre_compact",
lifecycle_payload(HookEvent::PreCompact {
event: sample_lifecycle_event(),
}),
Arc::clone(&pre_compact_calls),
),
(
"session_end",
lifecycle_payload(HookEvent::SessionEnd {
event: sample_lifecycle_event(),
}),
Arc::clone(&session_end_calls),
),
(
"subagent_start",
lifecycle_payload(HookEvent::SubagentStart {
event: sample_lifecycle_event(),
}),
Arc::clone(&subagent_start_calls),
),
(
"subagent_stop",
lifecycle_payload(HookEvent::SubagentStop {
event: sample_lifecycle_event(),
}),
Arc::clone(&subagent_stop_calls),
),
];
for (hook_name, payload, counter) in cases {
let outcomes = hooks.dispatch(payload).await;
assert_eq!(outcomes.len(), 1);
assert_eq!(outcomes[0].hook_name, hook_name);
assert!(matches!(outcomes[0].result, HookResult::Success));
assert_eq!(counter.load(Ordering::SeqCst), 1);
}
}
#[tokio::test]
async fn dispatch_continues_after_continueable_failure() {
let calls = Arc::new(AtomicUsize::new(0));
@@ -352,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()
};
@@ -369,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));
@@ -423,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));

View File

@@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
@@ -9,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>;
@@ -60,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,
}
@@ -122,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,
@@ -137,28 +155,123 @@ pub struct HookEventAfterToolUse {
pub output_preview: 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, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct HookEventLifecycle {
#[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 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")]
pub tool_input: Option<HookToolInput>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subagent_id: Option<ThreadId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<HashMap<String, String>>,
}
#[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,
},
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 { .. } => "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)]
mod tests {
use std::collections::HashMap;
use std::path::PathBuf;
use chrono::TimeZone;
@@ -170,18 +283,41 @@ 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;
use super::HookToolKind;
fn sample_lifecycle_event(
previous_session_id: ThreadId,
subagent_id: ThreadId,
) -> HookEventLifecycle {
let mut metadata = HashMap::new();
metadata.insert("phase".to_string(), "done".to_string());
HookEventLifecycle {
previous_session_id: Some(previous_session_id),
prompt: Some("hello world".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(),
}),
subagent_id: Some(subagent_id),
metadata: Some(metadata),
}
}
#[test]
fn hook_payload_serializes_stable_wire_shape() {
let session_id = ThreadId::new();
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
@@ -201,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(),
@@ -256,35 +392,305 @@ 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 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::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()),
},
},
};
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_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);
}
#[test]
fn all_lifecycle_payloads_serialize_stable_wire_shape() {
let session_id = ThreadId::new();
let previous_session_id = ThreadId::new();
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
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp"),
hook_event,
};
let cases = vec![
(
"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_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",
},
}),
),
(
"UserPromptSubmit",
base_payload(HookEvent::UserPromptSubmit {
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": "UserPromptSubmit",
"prompt": "hello world",
"last_assistant_message": "done",
"tool_use_id": "toolu_123",
"tool_input": {
"input_type": "function",
"arguments": "{\"code\":\"cargo test\"}",
},
}),
),
(
"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,
subagent_id: 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": "PreCompact",
"previous_session_id": previous_session_id.to_string(),
"last_assistant_message": "done",
"tool_use_id": "toolu_123",
"metadata": {
"phase": "done",
},
}),
),
(
"SessionEnd",
base_payload(HookEvent::SessionEnd {
event: HookEventLifecycle {
prompt: None,
last_assistant_message: None,
tool_use_id: None,
tool_input: 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": "SessionEnd",
"previous_session_id": previous_session_id.to_string(),
}),
),
(
"SubagentStart",
base_payload(HookEvent::SubagentStart {
event: HookEventLifecycle {
previous_session_id: None,
last_assistant_message: 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": "SubagentStart",
"prompt": "hello world",
"tool_use_id": "toolu_123",
"tool_input": {
"input_type": "function",
"arguments": "{\"code\":\"cargo test\"}",
},
}),
),
(
"SubagentStop",
base_payload(HookEvent::SubagentStop {
event: HookEventLifecycle {
previous_session_id: 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_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(),
}),
),
];
for (event_name, payload, expected) in cases {
assert_eq!(payload.hook_event.name(), event_name);
let actual = serde_json::to_value(payload).expect("serialize lifecycle payload");
assert_eq!(actual, expected, "lifecycle event {event_name}");
}
}
}

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(),

View File

@@ -26,6 +26,34 @@ Codex can run a notification hook when the agent finishes a turn. See the config
When Codex knows which client started the turn, the legacy notify JSON payload also includes a top-level `client` field. The TUI reports `codex-tui`, and the app server reports the `clientInfo.name` value from `initialize`.
## Lifecycle Hooks
Codex can run command hooks for lifecycle events via `[hooks]` in `~/.codex/config.toml`:
```toml
[hooks]
session_start = ["my-hook", "session_start"]
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_stop = ["my-hook", "subagent_stop"]
```
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
The generated JSON Schema for `config.toml` lives at `codex-rs/core/config.schema.json`.