From 218e45801afa0ab3a4c8bc6334d6bbf2d1e33925 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 8 Jun 2026 23:01:00 -0700 Subject: [PATCH] Simplify async hook dispatch --- codex-rs/hooks/src/engine/async_output.rs | 112 ++++--- .../hooks/src/engine/async_output_tests.rs | 28 +- codex-rs/hooks/src/engine/dispatcher.rs | 280 ++++++++++-------- codex-rs/hooks/src/engine/mod.rs | 72 ++--- codex-rs/hooks/src/engine/mod_tests.rs | 48 ++- codex-rs/hooks/src/events/compact.rs | 133 +++------ .../hooks/src/events/permission_request.rs | 63 ++-- codex-rs/hooks/src/events/post_tool_use.rs | 99 +++---- codex-rs/hooks/src/events/pre_tool_use.rs | 99 +++---- codex-rs/hooks/src/events/session_start.rs | 77 ++--- codex-rs/hooks/src/events/stop.rs | 78 ++--- .../hooks/src/events/user_prompt_submit.rs | 57 +--- 12 files changed, 505 insertions(+), 641 deletions(-) diff --git a/codex-rs/hooks/src/engine/async_output.rs b/codex-rs/hooks/src/engine/async_output.rs index 9da1bcefc5..6cda4c52cc 100644 --- a/codex-rs/hooks/src/engine/async_output.rs +++ b/codex-rs/hooks/src/engine/async_output.rs @@ -6,6 +6,7 @@ use std::sync::MutexGuard; use codex_protocol::protocol::HookEventName; use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_output_truncation::approx_bytes_for_tokens; use codex_utils_output_truncation::approx_token_count; use codex_utils_output_truncation::formatted_truncate_text; use codex_utils_output_truncation::truncate_text; @@ -46,15 +47,13 @@ struct AsyncCommandState { tasks: TaskTracker, } -/// A rendered snapshot of the oldest deliverable completions in the queue. +/// Single-use marker for the completions ready before a real user turn began. /// -/// Preparing a batch does not consume it. The caller commits it only after the -/// synchronous `UserPromptSubmit` lane accepts the turn, so a blocked prompt -/// cannot lose completed async output. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct AsyncOutputBatch { +/// Delivery is single-consumer, and producers only append between capturing and +/// flushing a boundary. Therefore the marked prefix cannot move or shrink. +#[derive(Debug)] +pub(super) struct AsyncOutputBoundary { completion_count: usize, - text: String, } impl AsyncCommandRuntime { @@ -122,36 +121,66 @@ impl AsyncCommandRuntime { .push_back(AsyncCommandCompletion { event_name, text }); } - /// Prepares the oldest contiguous queue prefix that fits the per-turn flush budget. - /// - /// The selected completions remain queued until [`Self::commit`] is called. - /// Later completions stay in FIFO order for a subsequent real user turn. - pub(crate) fn prepare_batch(&self) -> Option { - let pending = self.lock_pending(); - let mut selected = Vec::new(); - for completion in pending.iter() { - selected.push(completion.clone()); - // Measure the fully rendered developer injection so wrapper overhead - // counts toward the flush budget. The first item that does not fit, - // and every item after it, remains queued. - let text = render_batch(&selected); - if approx_token_count(&text) > ASYNC_HOOK_FLUSH_TOKEN_LIMIT { - selected.pop(); - break; - } + /// Captures the queue boundary before the current user turn can spawn more hooks. + pub(super) fn ready_boundary(&self) -> AsyncOutputBoundary { + AsyncOutputBoundary { + completion_count: self.lock_pending().len(), } - (!selected.is_empty()).then(|| AsyncOutputBatch { - completion_count: selected.len(), - text: render_batch(&selected), - }) } - /// Consumes a prepared prefix and returns its merged developer-context payload. - pub(crate) fn commit(&self, batch: AsyncOutputBatch) -> String { - // Producers only append, so completions arriving after preparation cannot - // disturb the prefix identified by `completion_count`. - self.lock_pending().drain(..batch.completion_count); - batch.text + /// Flushes the bounded FIFO prefix that was ready at `boundary`. + /// + /// Callers invoke this only after the real user turn is accepted. Completions + /// appended after the boundary, including hooks fired by the current turn, + /// remain queued for a later turn. + pub(super) fn flush_through(&self, boundary: AsyncOutputBoundary) -> Option { + let mut pending = self.lock_pending(); + debug_assert!( + boundary.completion_count <= pending.len(), + "async output can only be appended between boundary capture and flush" + ); + // Keep release builds resilient if the single-consumer contract is ever + // violated rather than allowing an out-of-bounds prefix. + let eligible_count = boundary.completion_count.min(pending.len()); + let max_bytes = approx_bytes_for_tokens(ASYNC_HOOK_FLUSH_TOKEN_LIMIT); + let closing_tag = "\n"; + let mut completion_count = 0; + let mut text = String::from("\n"); + + for completion in pending.iter().take(eligible_count) { + let rendered = format!( + "\n{}\n", + completion.event_name, completion.text + ); + let separator_len = usize::from(completion_count > 0); + + // Include the outer closing tag in the budget. The first completion + // that does not fit, and every completion after it, stays queued. + if text + .len() + .saturating_add(separator_len) + .saturating_add(rendered.len()) + .saturating_add(closing_tag.len()) + > max_bytes + { + break; + } + if completion_count > 0 { + text.push('\n'); + } + text.push_str(&rendered); + completion_count += 1; + } + + if completion_count == 0 { + return None; + } + + text.push_str(closing_tag); + // Producers only append, so arrivals after the boundary cannot disturb + // the prefix selected above. + pending.drain(..completion_count); + Some(text) } /// Cancels in-flight handlers, closes the tracker for waiting, and joins its tasks. @@ -170,21 +199,6 @@ impl AsyncCommandRuntime { } } -/// Renders several completed firings as one ordered developer-context injection. -fn render_batch(completions: &[AsyncCommandCompletion]) -> String { - let outputs = completions - .iter() - .map(|completion| { - format!( - "\n{}\n", - completion.event_name, completion.text - ) - }) - .collect::>() - .join("\n"); - format!("\n{outputs}\n") -} - /// Converts a command result into informational text suitable for later delivery. /// /// Successful output contributes only event-supported informational content. diff --git a/codex-rs/hooks/src/engine/async_output_tests.rs b/codex-rs/hooks/src/engine/async_output_tests.rs index f390db13c8..697da131c1 100644 --- a/codex-rs/hooks/src/engine/async_output_tests.rs +++ b/codex-rs/hooks/src/engine/async_output_tests.rs @@ -78,22 +78,31 @@ fn async_output_surfaces_parse_and_runtime_failures() { } #[test] -fn queue_preserves_duplicate_completions_until_commit() { +fn queue_preserves_duplicate_completions_and_holds_arrivals_after_boundary() { let runtime = AsyncCommandRuntime::default(); runtime.push(HookEventName::PreToolUse, "same".to_string()); runtime.push(HookEventName::PostToolUse, "same".to_string()); runtime.push(HookEventName::Stop, "last".to_string()); - let batch = runtime.prepare_batch().expect("queued output batch"); - assert_eq!(runtime.prepare_batch(), Some(batch.clone())); - let text = runtime.commit(batch); + let boundary = runtime.ready_boundary(); + runtime.push(HookEventName::SessionStart, "next-turn".to_string()); + + let text = runtime + .flush_through(boundary) + .expect("queued async output"); assert_eq!(text.matches("same").count(), 2); assert!( ["PreToolUse", "PostToolUse", "last"] .map(|needle| text.find(needle).expect("queued output")) .is_sorted() ); - assert!(runtime.prepare_batch().is_none()); + assert!(!text.contains("next-turn")); + + let text = runtime + .flush_through(runtime.ready_boundary()) + .expect("completion after boundary"); + assert!(text.contains("next-turn")); + assert!(runtime.flush_through(runtime.ready_boundary()).is_none()); } #[test] @@ -114,9 +123,10 @@ fn queue_bounds_items_and_flushes_a_contiguous_prefix() { let mut completed = 0; loop { - let batch = runtime.prepare_batch().expect("bounded output batch"); - completed += batch.completion_count; - let text = runtime.commit(batch); + let text = runtime + .flush_through(runtime.ready_boundary()) + .expect("bounded async output"); + completed += text.matches(" { pub completion_order: usize, } -pub(crate) fn select_handlers( - handlers: &[ConfiguredHandler], - event_name: HookEventName, - matcher_input: Option<&str>, -) -> Vec { - let matcher_inputs = matcher_input.into_iter().collect::>(); - select_handlers_for_matcher_inputs(handlers, event_name, &matcher_inputs) -} - -pub(crate) fn select_sync_handlers( - handlers: &[ConfiguredHandler], - event_name: HookEventName, - matcher_input: Option<&str>, -) -> Vec { - select_handlers(handlers, event_name, matcher_input) - .into_iter() - .filter(|handler| !handler.r#async) - .collect() -} - -pub(crate) fn select_handlers_for_matcher_inputs( +fn select_handlers_for_matcher_inputs( handlers: &[ConfiguredHandler], event_name: HookEventName, matcher_inputs: &[&str], @@ -79,18 +59,7 @@ pub(crate) fn select_handlers_for_matcher_inputs( .collect() } -pub(crate) fn select_sync_handlers_for_matcher_inputs( - handlers: &[ConfiguredHandler], - event_name: HookEventName, - matcher_inputs: &[&str], -) -> Vec { - select_handlers_for_matcher_inputs(handlers, event_name, matcher_inputs) - .into_iter() - .filter(|handler| !handler.r#async) - .collect() -} - -pub(crate) fn running_summary(handler: &ConfiguredHandler) -> HookRunSummary { +fn running_summary(handler: &ConfiguredHandler) -> HookRunSummary { HookRunSummary { id: handler.run_id(), event_name: handler.event_name, @@ -109,49 +78,86 @@ pub(crate) fn running_summary(handler: &ConfiguredHandler) -> HookRunSummary { } } -pub(crate) async fn execute_handlers( - shell: &CommandShell, - async_runtime: &AsyncCommandRuntime, - handlers: Vec, - input_json: Result, - cwd: &Path, - turn_id: Option, - parse: fn(&ConfiguredHandler, CommandRunResult, Option) -> ParsedHandler, -) -> Vec> { - let (handlers, asynchronous) = handlers - .into_iter() - .partition::, _>(|handler| !handler.r#async); - for handler in asynchronous { - async_runtime.spawn_handler( - shell.clone(), - handler, - input_json.clone(), - cwd.to_path_buf(), - ); +impl ClaudeHooksEngine { + pub(crate) fn preview_commands( + &self, + event_name: HookEventName, + matcher_inputs: &[&str], + ) -> Vec { + select_handlers_for_matcher_inputs(&self.handlers, event_name, matcher_inputs) + .into_iter() + .filter(|handler| !handler.r#async) + .map(|handler| running_summary(&handler)) + .collect() } - let mut pending = FuturesUnordered::new(); - for (configured_order, handler) in handlers.into_iter().enumerate() { - let input_json = input_json.clone(); - let turn_id = turn_id.clone(); - pending.push(async move { - let result = match input_json { - Ok(input_json) => run_command(shell, &handler, &input_json, cwd).await, - Err(error) => CommandRunResult::failed(error), - }; - (configured_order, parse(&handler, result, turn_id)) - }); - } + pub(crate) async fn execute_commands( + &self, + event_name: HookEventName, + matcher_inputs: &[&str], + input: &I, + cwd: &Path, + turn_id: Option, + parse: fn(&ConfiguredHandler, CommandRunResult, Option) -> ParsedHandler, + ) -> Vec> + where + I: Serialize + ?Sized, + { + let handlers = + select_handlers_for_matcher_inputs(&self.handlers, event_name, matcher_inputs); + if handlers.is_empty() { + return Vec::new(); + } - let mut completed = Vec::new(); - let mut completion_order = 0; - while let Some((configured_order, mut parsed)) = pending.next().await { - parsed.completion_order = completion_order; - completion_order += 1; - completed.push((configured_order, parsed)); + let input_label = match event_name { + HookEventName::PreToolUse => "pre tool use", + HookEventName::PermissionRequest => "permission request", + HookEventName::PostToolUse => "post tool use", + HookEventName::PreCompact => "pre compact", + HookEventName::PostCompact => "post compact", + HookEventName::SessionStart => "session start", + HookEventName::UserPromptSubmit => "user prompt submit", + HookEventName::SubagentStart => "subagent start", + HookEventName::SubagentStop => "subagent stop", + HookEventName::Stop => "stop", + }; + let input_json = serde_json::to_string(input) + .map_err(|error| format!("failed to serialize {input_label} hook input: {error}")); + let (handlers, asynchronous) = handlers + .into_iter() + .partition::, _>(|handler| !handler.r#async); + for handler in asynchronous { + self.async_runtime.spawn_handler( + self.shell.clone(), + handler, + input_json.clone(), + cwd.to_path_buf(), + ); + } + + let mut pending = FuturesUnordered::new(); + for (configured_order, handler) in handlers.into_iter().enumerate() { + let input_json = input_json.clone(); + let turn_id = turn_id.clone(); + pending.push(async move { + let result = match input_json { + Ok(input_json) => run_command(&self.shell, &handler, &input_json, cwd).await, + Err(error) => CommandRunResult::failed(error), + }; + (configured_order, parse(&handler, result, turn_id)) + }); + } + + let mut completed = Vec::new(); + let mut completion_order = 0; + while let Some((configured_order, mut parsed)) = pending.next().await { + parsed.completion_order = completion_order; + completion_order += 1; + completed.push((configured_order, parsed)); + } + completed.sort_by_key(|(configured_order, _)| *configured_order); + completed.into_iter().map(|(_, parsed)| parsed).collect() } - completed.sort_by_key(|(configured_order, _)| *configured_order); - completed.into_iter().map(|(_, parsed)| parsed).collect() } pub(crate) fn completed_summary( @@ -202,16 +208,19 @@ mod tests { use codex_protocol::protocol::HookSource; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; + use serde::Serialize; + use serde::Serializer; + use serde::ser::Error as _; + use super::ClaudeHooksEngine; use super::CommandRunResult; - use super::CommandShell; use super::ConfiguredHandler; use super::ParsedHandler; use super::completed_summary; - use super::execute_handlers; - use super::select_handlers; use super::select_handlers_for_matcher_inputs; + use crate::engine::CommandShell; use crate::engine::async_output::AsyncCommandRuntime; + use crate::output_spill::HookOutputSpiller; fn make_handler( event_name: HookEventName, @@ -233,6 +242,22 @@ mod tests { } } + fn engine_with_handlers( + handlers: Vec, + async_runtime: AsyncCommandRuntime, + ) -> ClaudeHooksEngine { + ClaudeHooksEngine { + handlers, + warnings: Vec::new(), + shell: CommandShell { + program: String::new(), + args: Vec::new(), + }, + async_runtime, + output_spiller: HookOutputSpiller::new(), + } + } + #[test] fn select_handlers_keeps_duplicate_stop_handlers() { let handlers = vec![ @@ -250,7 +275,7 @@ mod tests { ), ]; - let selected = select_handlers(&handlers, HookEventName::Stop, /*matcher_input*/ None); + let selected = select_handlers_for_matcher_inputs(&handlers, HookEventName::Stop, &[]); assert_eq!(selected.len(), 2); assert_eq!(selected[0].display_order, 0); @@ -274,7 +299,11 @@ mod tests { ), ]; - let selected = select_handlers(&handlers, HookEventName::SessionStart, Some("startup")); + let selected = select_handlers_for_matcher_inputs( + &handlers, + HookEventName::SessionStart, + &["startup"], + ); assert_eq!(selected.len(), 2); assert_eq!(selected[0].display_order, 0); @@ -298,7 +327,8 @@ mod tests { ), ]; - let selected = select_handlers(&handlers, HookEventName::PreCompact, Some("manual")); + let selected = + select_handlers_for_matcher_inputs(&handlers, HookEventName::PreCompact, &["manual"]); assert_eq!(selected.len(), 1); assert_eq!(selected[0].display_order, 0); @@ -321,7 +351,8 @@ mod tests { ), ]; - let selected = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash")); + let selected = + select_handlers_for_matcher_inputs(&handlers, HookEventName::PreToolUse, &["Bash"]); assert_eq!(selected.len(), 1); assert_eq!(selected[0].display_order, 0); @@ -344,7 +375,8 @@ mod tests { ), ]; - let selected = select_handlers(&handlers, HookEventName::PostToolUse, Some("Bash")); + let selected = + select_handlers_for_matcher_inputs(&handlers, HookEventName::PostToolUse, &["Bash"]); assert_eq!(selected.len(), 1); assert_eq!(selected[0].display_order, 0); @@ -367,7 +399,8 @@ mod tests { ), ]; - let selected = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash")); + let selected = + select_handlers_for_matcher_inputs(&handlers, HookEventName::PreToolUse, &["Bash"]); assert_eq!(selected.len(), 1); assert_eq!(selected[0].display_order, 0); @@ -382,9 +415,12 @@ mod tests { /*display_order*/ 0, )]; - let selected_edit = select_handlers(&handlers, HookEventName::PreToolUse, Some("Edit")); - let selected_write = select_handlers(&handlers, HookEventName::PreToolUse, Some("Write")); - let selected_bash = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash")); + let selected_edit = + select_handlers_for_matcher_inputs(&handlers, HookEventName::PreToolUse, &["Edit"]); + let selected_write = + select_handlers_for_matcher_inputs(&handlers, HookEventName::PreToolUse, &["Write"]); + let selected_bash = + select_handlers_for_matcher_inputs(&handlers, HookEventName::PreToolUse, &["Bash"]); assert_eq!(selected_edit.len(), 1); assert_eq!(selected_write.len(), 1); @@ -453,11 +489,8 @@ mod tests { ), ]; - let selected = select_handlers( - &handlers, - HookEventName::UserPromptSubmit, - /*matcher_input*/ None, - ); + let selected = + select_handlers_for_matcher_inputs(&handlers, HookEventName::UserPromptSubmit, &[]); assert_eq!(selected.len(), 2); assert_eq!(selected[0].display_order, 0); @@ -488,12 +521,9 @@ mod tests { ]; handlers[1].r#async = true; - let selected = select_handlers(&handlers, HookEventName::Stop, /*matcher_input*/ None); - let synchronous = super::select_sync_handlers( - &handlers, - HookEventName::Stop, - /*matcher_input*/ None, - ); + let selected = select_handlers_for_matcher_inputs(&handlers, HookEventName::Stop, &[]); + let engine = engine_with_handlers(handlers, AsyncCommandRuntime::default()); + let synchronous = engine.preview_commands(HookEventName::Stop, &[]); assert_eq!(selected.len(), 3); assert_eq!(selected[0].command, "first"); @@ -502,9 +532,9 @@ mod tests { assert_eq!( synchronous .iter() - .map(|handler| handler.command.as_str()) + .map(|run| run.display_order) .collect::>(), - vec!["first", "third"], + vec![0, 2], ); } @@ -521,41 +551,55 @@ mod tests { asynchronous.display_order = 1; asynchronous.r#async = true; let runtime = AsyncCommandRuntime::default(); + let engine = engine_with_handlers(vec![synchronous, asynchronous], runtime.clone()); let cwd = test_path_buf("/tmp").abs(); - let results = execute_handlers( - &CommandShell { - program: String::new(), - args: Vec::new(), - }, - &runtime, - vec![synchronous, asynchronous], - Err("serialize failed".to_string()), - cwd.as_path(), - Some("turn-1".to_string()), - parse_failure, - ) - .await; + let results = engine + .execute_commands( + HookEventName::PreToolUse, + &["Bash"], + &SerializationFailure, + cwd.as_path(), + Some("turn-1".to_string()), + parse_failure, + ) + .await; assert_eq!(results.len(), 1); assert_eq!(results[0].completed.run.status, HookRunStatus::Failed); - assert_eq!(results[0].data, "serialize failed"); - let batch = tokio::time::timeout(Duration::from_secs(1), async { + assert_eq!( + results[0].data, + "failed to serialize pre tool use hook input: serialize failed" + ); + let output = tokio::time::timeout(Duration::from_secs(1), async { loop { - if let Some(batch) = runtime.prepare_batch() { - break batch; + let boundary = runtime.ready_boundary(); + if let Some(output) = runtime.flush_through(boundary) { + break output; } tokio::task::yield_now().await; } }) .await .expect("async serialization failure completion"); - let output = runtime.commit(batch); - assert!(output.contains("Async hook failed to run: serialize failed")); + assert!(output.contains( + "Async hook failed to run: failed to serialize pre tool use hook input: serialize failed" + )); assert!(output.contains("event=\"PreToolUse\"")); runtime.shutdown().await; } + struct SerializationFailure; + + impl Serialize for SerializationFailure { + fn serialize(&self, _serializer: S) -> Result + where + S: Serializer, + { + Err(S::Error::custom("serialize failed")) + } + } + fn parse_failure( handler: &ConfiguredHandler, run_result: CommandRunResult, diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 857d6db559..10ec2ca30c 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -196,25 +196,25 @@ impl ClaudeHooksEngine { &self, request: &SessionStartRequest, ) -> Vec { - crate::events::session_start::preview(&self.handlers, request) + crate::events::session_start::preview(self, request) } pub(crate) fn preview_pre_tool_use(&self, request: &PreToolUseRequest) -> Vec { - crate::events::pre_tool_use::preview(&self.handlers, request) + crate::events::pre_tool_use::preview(self, request) } pub(crate) fn preview_permission_request( &self, request: &PermissionRequestRequest, ) -> Vec { - crate::events::permission_request::preview(&self.handlers, request) + crate::events::permission_request::preview(self, request) } pub(crate) fn preview_post_tool_use( &self, request: &PostToolUseRequest, ) -> Vec { - crate::events::post_tool_use::preview(&self.handlers, request) + crate::events::post_tool_use::preview(self, request) } pub(crate) async fn run_session_start( @@ -223,14 +223,7 @@ impl ClaudeHooksEngine { turn_id: Option, ) -> SessionStartOutcome { let session_id = request.session_id; - let mut outcome = crate::events::session_start::run( - &self.handlers, - &self.shell, - &self.async_runtime, - request, - turn_id, - ) - .await; + let mut outcome = crate::events::session_start::run(self, request, turn_id).await; outcome.additional_contexts = self .maybe_spill_texts(session_id, outcome.additional_contexts) .await; @@ -239,13 +232,7 @@ impl ClaudeHooksEngine { pub(crate) async fn run_pre_tool_use(&self, request: PreToolUseRequest) -> PreToolUseOutcome { let session_id = request.session_id; - let mut outcome = crate::events::pre_tool_use::run( - &self.handlers, - &self.shell, - &self.async_runtime, - request, - ) - .await; + let mut outcome = crate::events::pre_tool_use::run(self, request).await; outcome.additional_contexts = self .maybe_spill_texts(session_id, outcome.additional_contexts) .await; @@ -256,13 +243,7 @@ impl ClaudeHooksEngine { &self, request: PermissionRequestRequest, ) -> PermissionRequestOutcome { - crate::events::permission_request::run( - &self.handlers, - &self.shell, - &self.async_runtime, - request, - ) - .await + crate::events::permission_request::run(self, request).await } pub(crate) async fn run_post_tool_use( @@ -270,13 +251,7 @@ impl ClaudeHooksEngine { request: PostToolUseRequest, ) -> PostToolUseOutcome { let session_id = request.session_id; - let mut outcome = crate::events::post_tool_use::run( - &self.handlers, - &self.shell, - &self.async_runtime, - request, - ) - .await; + let mut outcome = crate::events::post_tool_use::run(self, request).await; outcome.additional_contexts = self .maybe_spill_texts(session_id, outcome.additional_contexts) .await; @@ -287,31 +262,29 @@ impl ClaudeHooksEngine { } pub(crate) fn preview_pre_compact(&self, request: &PreCompactRequest) -> Vec { - crate::events::compact::preview_pre(&self.handlers, request) + crate::events::compact::preview_pre(self, request) } pub(crate) async fn run_pre_compact(&self, request: PreCompactRequest) -> PreCompactOutcome { - crate::events::compact::run_pre(&self.handlers, &self.shell, &self.async_runtime, request) - .await + crate::events::compact::run_pre(self, request).await } pub(crate) fn preview_post_compact(&self, request: &PostCompactRequest) -> Vec { - crate::events::compact::preview_post(&self.handlers, request) + crate::events::compact::preview_post(self, request) } pub(crate) async fn run_post_compact( &self, request: PostCompactRequest, ) -> StatelessHookOutcome { - crate::events::compact::run_post(&self.handlers, &self.shell, &self.async_runtime, request) - .await + crate::events::compact::run_post(self, request).await } pub(crate) fn preview_user_prompt_submit( &self, request: &UserPromptSubmitRequest, ) -> Vec { - crate::events::user_prompt_submit::preview(&self.handlers, request) + crate::events::user_prompt_submit::preview(self, request) } pub(crate) async fn run_user_prompt_submit( @@ -319,35 +292,26 @@ impl ClaudeHooksEngine { request: UserPromptSubmitRequest, ) -> UserPromptSubmitOutcome { let session_id = request.session_id; - let async_output_batch = self.async_runtime.prepare_batch(); - let mut outcome = crate::events::user_prompt_submit::run( - &self.handlers, - &self.shell, - &self.async_runtime, - request, - ) - .await; + let async_output_boundary = self.async_runtime.ready_boundary(); + let mut outcome = crate::events::user_prompt_submit::run(self, request).await; outcome.additional_contexts = self .maybe_spill_texts(session_id, outcome.additional_contexts) .await; if !outcome.should_stop - && let Some(batch) = async_output_batch + && let Some(text) = self.async_runtime.flush_through(async_output_boundary) { - let text = self.async_runtime.commit(batch); outcome.additional_contexts.insert(0, text); } outcome } pub(crate) fn preview_stop(&self, request: &StopRequest) -> Vec { - crate::events::stop::preview(&self.handlers, request) + crate::events::stop::preview(self, request) } pub(crate) async fn run_stop(&self, request: StopRequest) -> StopOutcome { let session_id = request.session_id; - let mut outcome = - crate::events::stop::run(&self.handlers, &self.shell, &self.async_runtime, request) - .await; + let mut outcome = crate::events::stop::run(self, request).await; outcome.continuation_fragments = self .maybe_spill_prompt_fragments(session_id, outcome.continuation_fragments) .await; diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index 9408c8c81f..a880b0cf23 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -32,6 +32,7 @@ use tempfile::tempdir; use super::ClaudeHooksEngine; use super::CommandShell; use super::ConfiguredHandler; +use crate::events::post_tool_use::PostToolUseRequest; use crate::events::pre_tool_use::PreToolUseRequest; use crate::events::user_prompt_submit::UserPromptSubmitRequest; @@ -66,6 +67,45 @@ fn user_prompt_request() -> UserPromptSubmitRequest { } } +#[test] +fn post_tool_use_preview_id_includes_tool_use_id() { + let source_path = cwd(); + let mut engine = disabled_engine(); + engine.handlers.push(ConfiguredHandler { + event_name: HookEventName::PostToolUse, + matcher: Some("^Bash$".to_string()), + command: "echo hook".to_string(), + timeout_sec: 5, + r#async: false, + status_message: None, + source_path: source_path.clone(), + source: HookSource::User, + display_order: 0, + env: HashMap::new(), + }); + + let preview = engine.preview_post_tool_use(&PostToolUseRequest { + session_id: ThreadId::new(), + turn_id: "turn-1".to_string(), + subagent: None, + cwd: cwd(), + transcript_path: None, + model: "gpt-test".to_string(), + permission_mode: "default".to_string(), + tool_name: "Bash".to_string(), + matcher_aliases: Vec::new(), + tool_use_id: "tool-call-456".to_string(), + tool_input: serde_json::json!({ "command": "echo hello" }), + tool_response: serde_json::json!({ "output": "hello" }), + }); + + assert_eq!(preview.len(), 1); + assert_eq!( + preview[0].id, + format!("post-tool-use:0:{}:tool-call-456", source_path.display()) + ); +} + fn managed_hooks_for_current_platform( managed_dir: impl AsRef, hooks: HookEventsToml, @@ -1272,6 +1312,7 @@ print(json.dumps({ .await; assert_eq!(outcome.hook_events.len(), 1); + assert_eq!(outcome.hook_events[0].run.id, preview[0].id); assert_eq!(outcome.hook_events[0].run.source, HookSource::Plugin); assert_eq!( outcome.hook_events[0].run.status, @@ -1415,7 +1456,8 @@ async fn reconfiguration_preserves_pending_async_output() { assert_eq!(outcome.hook_events, Vec::new()); assert_eq!(outcome.additional_contexts.len(), 1); assert!(outcome.additional_contexts[0].contains("queued context")); - assert!(reconfigured.async_runtime.prepare_batch().is_none()); + let boundary = reconfigured.async_runtime.ready_boundary(); + assert!(reconfigured.async_runtime.flush_through(boundary).is_none()); } #[tokio::test] @@ -1444,11 +1486,11 @@ async fn blocked_prompt_preserves_pending_async_output() { let blocked = engine.run_user_prompt_submit(request.clone()).await; assert!(blocked.should_stop); - assert!(engine.async_runtime.prepare_batch().is_some()); engine.handlers.clear(); let accepted = engine.run_user_prompt_submit(request).await; assert!(!accepted.should_stop); assert!(accepted.additional_contexts[0].contains("queued context")); - assert!(engine.async_runtime.prepare_batch().is_none()); + let boundary = engine.async_runtime.ready_boundary(); + assert!(engine.async_runtime.flush_through(boundary).is_none()); } diff --git a/codex-rs/hooks/src/events/compact.rs b/codex-rs/hooks/src/events/compact.rs index 4f4b914f40..f550938612 100644 --- a/codex-rs/hooks/src/events/compact.rs +++ b/codex-rs/hooks/src/events/compact.rs @@ -10,9 +10,8 @@ use codex_protocol::protocol::HookRunSummary; use codex_utils_absolute_path::AbsolutePathBuf; use super::common; -use crate::engine::CommandShell; +use crate::engine::ClaudeHooksEngine; use crate::engine::ConfiguredHandler; -use crate::engine::async_output::AsyncCommandRuntime; use crate::engine::command_runner::CommandRunResult; use crate::engine::dispatcher; use crate::engine::output_parser; @@ -57,51 +56,27 @@ pub struct PreCompactOutcome { } pub(crate) fn preview_pre( - handlers: &[ConfiguredHandler], + engine: &ClaudeHooksEngine, request: &PreCompactRequest, ) -> Vec { - dispatcher::select_sync_handlers( - handlers, - HookEventName::PreCompact, - Some(request.trigger.as_str()), - ) - .into_iter() - .map(|handler| dispatcher::running_summary(&handler)) - .collect() + engine.preview_commands(HookEventName::PreCompact, &[request.trigger.as_str()]) } pub(crate) async fn run_pre( - handlers: &[ConfiguredHandler], - shell: &CommandShell, - async_runtime: &AsyncCommandRuntime, + engine: &ClaudeHooksEngine, request: PreCompactRequest, ) -> PreCompactOutcome { - let matched = dispatcher::select_handlers( - handlers, - HookEventName::PreCompact, - Some(request.trigger.as_str()), - ); - if matched.is_empty() { - return PreCompactOutcome { - hook_events: Vec::new(), - should_stop: false, - stop_reason: None, - }; - } - - let input_json = pre_command_input_json(&request) - .map_err(|error| format!("failed to serialize pre compact hook input: {error}")); - - let results = dispatcher::execute_handlers( - shell, - async_runtime, - matched, - input_json, - request.cwd.as_path(), - Some(request.turn_id), - parse_pre_completed, - ) - .await; + let input = pre_command_input(&request); + let results = engine + .execute_commands( + HookEventName::PreCompact, + &[request.trigger.as_str()], + &input, + request.cwd.as_path(), + Some(request.turn_id), + parse_pre_completed, + ) + .await; let should_stop = results.iter().any(|result| result.data.should_stop); let stop_reason = results .iter() @@ -113,9 +88,9 @@ pub(crate) async fn run_pre( } } -fn pre_command_input_json(request: &PreCompactRequest) -> Result { +fn pre_command_input(request: &PreCompactRequest) -> PreCompactCommandInput { let subagent = SubagentCommandInputFields::from(request.subagent.as_ref()); - serde_json::to_string(&PreCompactCommandInput { + PreCompactCommandInput { session_id: request.session_id.to_string(), turn_id: request.turn_id.clone(), agent_id: subagent.agent_id, @@ -125,55 +100,31 @@ fn pre_command_input_json(request: &PreCompactRequest) -> Result Vec { - dispatcher::select_sync_handlers( - handlers, - HookEventName::PostCompact, - Some(request.trigger.as_str()), - ) - .into_iter() - .map(|handler| dispatcher::running_summary(&handler)) - .collect() + engine.preview_commands(HookEventName::PostCompact, &[request.trigger.as_str()]) } pub(crate) async fn run_post( - handlers: &[ConfiguredHandler], - shell: &CommandShell, - async_runtime: &AsyncCommandRuntime, + engine: &ClaudeHooksEngine, request: PostCompactRequest, ) -> StatelessHookOutcome { - let matched = dispatcher::select_handlers( - handlers, - HookEventName::PostCompact, - Some(request.trigger.as_str()), - ); - if matched.is_empty() { - return StatelessHookOutcome { - hook_events: Vec::new(), - should_stop: false, - stop_reason: None, - }; - } - - let input_json = post_command_input_json(&request) - .map_err(|error| format!("failed to serialize post compact hook input: {error}")); - - let results = dispatcher::execute_handlers( - shell, - async_runtime, - matched, - input_json, - request.cwd.as_path(), - Some(request.turn_id), - parse_post_completed, - ) - .await; + let input = post_command_input(&request); + let results = engine + .execute_commands( + HookEventName::PostCompact, + &[request.trigger.as_str()], + &input, + request.cwd.as_path(), + Some(request.turn_id), + parse_post_completed, + ) + .await; let should_stop = results.iter().any(|result| result.data.should_stop); let stop_reason = results .iter() @@ -185,9 +136,9 @@ pub(crate) async fn run_post( } } -fn post_command_input_json(request: &PostCompactRequest) -> Result { +fn post_command_input(request: &PostCompactRequest) -> PostCompactCommandInput { let subagent = SubagentCommandInputFields::from(request.subagent.as_ref()); - serde_json::to_string(&PostCompactCommandInput { + PostCompactCommandInput { session_id: request.session_id.to_string(), turn_id: request.turn_id.clone(), agent_id: subagent.agent_id, @@ -197,7 +148,7 @@ fn post_command_input_json(request: &PostCompactRequest) -> Result Vec { let matcher_inputs = common::matcher_inputs(&request.tool_name, &request.matcher_aliases); - dispatcher::select_sync_handlers_for_matcher_inputs( - handlers, - HookEventName::PermissionRequest, - &matcher_inputs, - ) - .into_iter() - .map(|handler| { - common::hook_run_for_tool_use( - dispatcher::running_summary(&handler), - &request.run_id_suffix, - ) - }) - .collect() + engine + .preview_commands(HookEventName::PermissionRequest, &matcher_inputs) + .into_iter() + .map(|run| common::hook_run_for_tool_use(run, &request.run_id_suffix)) + .collect() } pub(crate) async fn run( - handlers: &[ConfiguredHandler], - shell: &CommandShell, - async_runtime: &AsyncCommandRuntime, + engine: &ClaudeHooksEngine, request: PermissionRequestRequest, ) -> PermissionRequestOutcome { let matcher_inputs = common::matcher_inputs(&request.tool_name, &request.matcher_aliases); - let matched = dispatcher::select_handlers_for_matcher_inputs( - handlers, - HookEventName::PermissionRequest, - &matcher_inputs, - ); - if matched.is_empty() { - return PermissionRequestOutcome { - hook_events: Vec::new(), - decision: None, - }; - } - - let input_json = serde_json::to_string(&build_command_input(&request)) - .map_err(|error| format!("failed to serialize permission request hook input: {error}")); - - let results = dispatcher::execute_handlers( - shell, - async_runtime, - matched, - input_json, - request.cwd.as_path(), - Some(request.turn_id.clone()), - parse_completed, - ) - .await; + let input = build_command_input(&request); + let results = engine + .execute_commands( + HookEventName::PermissionRequest, + &matcher_inputs, + &input, + request.cwd.as_path(), + Some(request.turn_id.clone()), + parse_completed, + ) + .await; // Preserve the most specific matching allow, but treat any deny as final so // broader policy layers cannot accidentally overrule a more specific block. diff --git a/codex-rs/hooks/src/events/post_tool_use.rs b/codex-rs/hooks/src/events/post_tool_use.rs index d93c99c76a..0ca3e6e6c7 100644 --- a/codex-rs/hooks/src/events/post_tool_use.rs +++ b/codex-rs/hooks/src/events/post_tool_use.rs @@ -11,9 +11,8 @@ use codex_utils_absolute_path::AbsolutePathBuf; use serde_json::Value; use super::common; -use crate::engine::CommandShell; +use crate::engine::ClaudeHooksEngine; use crate::engine::ConfiguredHandler; -use crate::engine::async_output::AsyncCommandRuntime; use crate::engine::command_runner::CommandRunResult; use crate::engine::dispatcher; use crate::engine::output_parser; @@ -54,57 +53,33 @@ struct PostToolUseHandlerData { } pub(crate) fn preview( - handlers: &[ConfiguredHandler], + engine: &ClaudeHooksEngine, request: &PostToolUseRequest, ) -> Vec { let matcher_inputs = common::matcher_inputs(&request.tool_name, &request.matcher_aliases); - dispatcher::select_sync_handlers_for_matcher_inputs( - handlers, - HookEventName::PostToolUse, - &matcher_inputs, - ) - .into_iter() - .map(|handler| { - common::hook_run_for_tool_use(dispatcher::running_summary(&handler), &request.tool_use_id) - }) - .collect() + engine + .preview_commands(HookEventName::PostToolUse, &matcher_inputs) + .into_iter() + .map(|run| common::hook_run_for_tool_use(run, &request.tool_use_id)) + .collect() } pub(crate) async fn run( - handlers: &[ConfiguredHandler], - shell: &CommandShell, - async_runtime: &AsyncCommandRuntime, + engine: &ClaudeHooksEngine, request: PostToolUseRequest, ) -> PostToolUseOutcome { let matcher_inputs = common::matcher_inputs(&request.tool_name, &request.matcher_aliases); - let matched = dispatcher::select_handlers_for_matcher_inputs( - handlers, - HookEventName::PostToolUse, - &matcher_inputs, - ); - if matched.is_empty() { - return PostToolUseOutcome { - hook_events: Vec::new(), - should_stop: false, - stop_reason: None, - additional_contexts: Vec::new(), - feedback_message: None, - }; - } - - let input_json = command_input_json(&request) - .map_err(|error| format!("failed to serialize post tool use hook input: {error}")); - - let results = dispatcher::execute_handlers( - shell, - async_runtime, - matched, - input_json, - request.cwd.as_path(), - Some(request.turn_id.clone()), - parse_completed, - ) - .await; + let input = command_input(&request); + let results = engine + .execute_commands( + HookEventName::PostToolUse, + &matcher_inputs, + &input, + request.cwd.as_path(), + Some(request.turn_id.clone()), + parse_completed, + ) + .await; let additional_contexts = common::flatten_additional_contexts( results @@ -136,15 +111,15 @@ pub(crate) async fn run( } } -/// Serializes command stdin for a selected `PostToolUse` hook. +/// Builds command stdin for a selected `PostToolUse` hook. /// /// Handler selection may include internal matcher aliases, but hook stdin keeps /// the canonical `tool_name` for logs and for consumers that pair pre/post /// events across processes. Shell-like tools pass `{ "command": ... }` as /// `tool_input`; MCP tools pass their resolved JSON arguments. -fn command_input_json(request: &PostToolUseRequest) -> Result { +fn command_input(request: &PostToolUseRequest) -> PostToolUseCommandInput { let subagent = SubagentCommandInputFields::from(request.subagent.as_ref()); - serde_json::to_string(&PostToolUseCommandInput { + PostToolUseCommandInput { session_id: request.session_id.to_string(), turn_id: request.turn_id.clone(), agent_id: subagent.agent_id, @@ -158,7 +133,7 @@ fn command_input_json(request: &PostToolUseRequest) -> Result ConfiguredHandler { diff --git a/codex-rs/hooks/src/events/pre_tool_use.rs b/codex-rs/hooks/src/events/pre_tool_use.rs index a88fc28ef7..ce017918d3 100644 --- a/codex-rs/hooks/src/events/pre_tool_use.rs +++ b/codex-rs/hooks/src/events/pre_tool_use.rs @@ -11,9 +11,8 @@ use codex_utils_absolute_path::AbsolutePathBuf; use serde_json::Value; use super::common; -use crate::engine::CommandShell; +use crate::engine::ClaudeHooksEngine; use crate::engine::ConfiguredHandler; -use crate::engine::async_output::AsyncCommandRuntime; use crate::engine::command_runner::CommandRunResult; use crate::engine::dispatcher; use crate::engine::output_parser; @@ -53,57 +52,33 @@ struct PreToolUseHandlerData { } pub(crate) fn preview( - handlers: &[ConfiguredHandler], + engine: &ClaudeHooksEngine, request: &PreToolUseRequest, ) -> Vec { let matcher_inputs = common::matcher_inputs(&request.tool_name, &request.matcher_aliases); - dispatcher::select_sync_handlers_for_matcher_inputs( - handlers, - HookEventName::PreToolUse, - &matcher_inputs, - ) - .into_iter() - .map(|handler| { - common::hook_run_for_tool_use(dispatcher::running_summary(&handler), &request.tool_use_id) - }) - .collect() + engine + .preview_commands(HookEventName::PreToolUse, &matcher_inputs) + .into_iter() + .map(|run| common::hook_run_for_tool_use(run, &request.tool_use_id)) + .collect() } pub(crate) async fn run( - handlers: &[ConfiguredHandler], - shell: &CommandShell, - async_runtime: &AsyncCommandRuntime, + engine: &ClaudeHooksEngine, request: PreToolUseRequest, ) -> PreToolUseOutcome { let matcher_inputs = common::matcher_inputs(&request.tool_name, &request.matcher_aliases); - let matched = dispatcher::select_handlers_for_matcher_inputs( - handlers, - HookEventName::PreToolUse, - &matcher_inputs, - ); - if matched.is_empty() { - return PreToolUseOutcome { - hook_events: Vec::new(), - should_block: false, - block_reason: None, - additional_contexts: Vec::new(), - updated_input: None, - }; - } - - let input_json = command_input_json(&request) - .map_err(|error| format!("failed to serialize pre tool use hook input: {error}")); - - let results = dispatcher::execute_handlers( - shell, - async_runtime, - matched, - input_json, - request.cwd.as_path(), - Some(request.turn_id.clone()), - parse_completed, - ) - .await; + let input = command_input(&request); + let results = engine + .execute_commands( + HookEventName::PreToolUse, + &matcher_inputs, + &input, + request.cwd.as_path(), + Some(request.turn_id.clone()), + parse_completed, + ) + .await; let should_block = results.iter().any(|result| result.data.should_block); let block_reason = results @@ -154,15 +129,15 @@ fn latest_updated_input( .map(|(_, updated_input)| updated_input) } -/// Serializes command stdin for a selected `PreToolUse` hook. +/// Builds command stdin for a selected `PreToolUse` hook. /// /// Handler selection may include internal matcher aliases, but hook stdin keeps /// the canonical `tool_name` so audit logs and downstream policy decisions stay /// stable. Shell-like tools pass `{ "command": ... }` as `tool_input`; MCP /// tools pass their resolved JSON arguments. -fn command_input_json(request: &PreToolUseRequest) -> Result { +fn command_input(request: &PreToolUseRequest) -> PreToolUseCommandInput { let subagent = SubagentCommandInputFields::from(request.subagent.as_ref()); - serde_json::to_string(&PreToolUseCommandInput { + PreToolUseCommandInput { session_id: request.session_id.to_string(), turn_id: request.turn_id.clone(), agent_id: subagent.agent_id, @@ -175,7 +150,7 @@ fn command_input_json(request: &PreToolUseRequest) -> Result ConfiguredHandler { diff --git a/codex-rs/hooks/src/events/session_start.rs b/codex-rs/hooks/src/events/session_start.rs index da9596fec9..fe426efb0a 100644 --- a/codex-rs/hooks/src/events/session_start.rs +++ b/codex-rs/hooks/src/events/session_start.rs @@ -10,9 +10,8 @@ use codex_protocol::protocol::HookRunSummary; use codex_utils_absolute_path::AbsolutePathBuf; use super::common; -use crate::engine::CommandShell; +use crate::engine::ClaudeHooksEngine; use crate::engine::ConfiguredHandler; -use crate::engine::async_output::AsyncCommandRuntime; use crate::engine::command_runner::CommandRunResult; use crate::engine::dispatcher; use crate::engine::output_parser; @@ -93,53 +92,41 @@ struct SessionStartHandlerData { } pub(crate) fn preview( - handlers: &[ConfiguredHandler], + engine: &ClaudeHooksEngine, request: &SessionStartRequest, ) -> Vec { - dispatcher::select_sync_handlers( - handlers, + engine.preview_commands( request.target.event_name(), - Some(request.target.matcher_input()), + &[request.target.matcher_input()], ) - .into_iter() - .map(|handler| dispatcher::running_summary(&handler)) - .collect() } pub(crate) async fn run( - handlers: &[ConfiguredHandler], - shell: &CommandShell, - async_runtime: &AsyncCommandRuntime, + engine: &ClaudeHooksEngine, request: SessionStartRequest, turn_id: Option, ) -> SessionStartOutcome { - let matched = dispatcher::select_handlers( - handlers, - request.target.event_name(), - Some(request.target.matcher_input()), - ); - if matched.is_empty() { - return SessionStartOutcome { - hook_events: Vec::new(), - should_stop: false, - stop_reason: None, - additional_contexts: Vec::new(), - }; - } - - let (input_json, turn_id) = match request.target { - StartHookTarget::SessionStart { source } => ( - serde_json::to_string(&SessionStartCommandInput::new( + let results = match request.target { + StartHookTarget::SessionStart { source } => { + let input = SessionStartCommandInput::new( request.session_id.to_string(), request.transcript_path.clone(), request.cwd.display().to_string(), request.model.clone(), request.permission_mode.clone(), source.as_str().to_string(), - )) - .map_err(|error| format!("failed to serialize session start hook input: {error}")), - turn_id, - ), + ); + engine + .execute_commands( + HookEventName::SessionStart, + &[source.as_str()], + &input, + request.cwd.as_path(), + turn_id, + parse_completed, + ) + .await + } StartHookTarget::SubagentStart { turn_id: subagent_turn_id, agent_id, @@ -156,23 +143,19 @@ pub(crate) async fn run( agent_id, agent_type, }; - let input_json = serde_json::to_string(&input) - .map_err(|error| format!("failed to serialize subagent start hook input: {error}")); - (input_json, Some(subagent_turn_id)) + engine + .execute_commands( + HookEventName::SubagentStart, + &[input.agent_type.as_str()], + &input, + request.cwd.as_path(), + Some(subagent_turn_id), + parse_completed, + ) + .await } }; - let results = dispatcher::execute_handlers( - shell, - async_runtime, - matched, - input_json, - request.cwd.as_path(), - turn_id, - parse_completed, - ) - .await; - let should_stop = results.iter().any(|result| result.data.should_stop); let stop_reason = results .iter() diff --git a/codex-rs/hooks/src/events/stop.rs b/codex-rs/hooks/src/events/stop.rs index 031954184c..e271fd7679 100644 --- a/codex-rs/hooks/src/events/stop.rs +++ b/codex-rs/hooks/src/events/stop.rs @@ -11,9 +11,8 @@ use codex_protocol::protocol::HookRunSummary; use codex_utils_absolute_path::AbsolutePathBuf; use super::common; -use crate::engine::CommandShell; +use crate::engine::ClaudeHooksEngine; use crate::engine::ConfiguredHandler; -use crate::engine::async_output::AsyncCommandRuntime; use crate::engine::command_runner::CommandRunResult; use crate::engine::dispatcher; use crate::engine::output_parser; @@ -79,43 +78,13 @@ struct StopHandlerData { continuation_fragments: Vec, } -pub(crate) fn preview( - handlers: &[ConfiguredHandler], - request: &StopRequest, -) -> Vec { - dispatcher::select_sync_handlers( - handlers, - request.target.event_name(), - request.target.matcher_input(), - ) - .into_iter() - .map(|handler| dispatcher::running_summary(&handler)) - .collect() +pub(crate) fn preview(engine: &ClaudeHooksEngine, request: &StopRequest) -> Vec { + let matcher_input = request.target.matcher_input(); + engine.preview_commands(request.target.event_name(), matcher_input.as_slice()) } -pub(crate) async fn run( - handlers: &[ConfiguredHandler], - shell: &CommandShell, - async_runtime: &AsyncCommandRuntime, - request: StopRequest, -) -> StopOutcome { - let matched = dispatcher::select_handlers( - handlers, - request.target.event_name(), - request.target.matcher_input(), - ); - if matched.is_empty() { - return StopOutcome { - hook_events: Vec::new(), - should_stop: false, - stop_reason: None, - should_block: false, - block_reason: None, - continuation_fragments: Vec::new(), - }; - } - - let input_json = match request.target { +pub(crate) async fn run(engine: &ClaudeHooksEngine, request: StopRequest) -> StopOutcome { + let results = match request.target { StopHookTarget::Stop => { let input = StopCommandInput { session_id: request.session_id.to_string(), @@ -130,8 +99,16 @@ pub(crate) async fn run( request.last_assistant_message.clone(), ), }; - serde_json::to_string(&input) - .map_err(|error| format!("failed to serialize stop hook input: {error}")) + engine + .execute_commands( + HookEventName::Stop, + &[], + &input, + request.cwd.as_path(), + Some(request.turn_id), + parse_completed, + ) + .await } StopHookTarget::SubagentStop { agent_id, @@ -154,22 +131,19 @@ pub(crate) async fn run( request.last_assistant_message.clone(), ), }; - serde_json::to_string(&input) - .map_err(|error| format!("failed to serialize subagent stop hook input: {error}")) + engine + .execute_commands( + HookEventName::SubagentStop, + &[input.agent_type.as_str()], + &input, + request.cwd.as_path(), + Some(request.turn_id), + parse_completed, + ) + .await } }; - let results = dispatcher::execute_handlers( - shell, - async_runtime, - matched, - input_json, - request.cwd.as_path(), - Some(request.turn_id), - parse_completed, - ) - .await; - let aggregate = aggregate_results(results.iter().map(|result| &result.data)); StopOutcome { diff --git a/codex-rs/hooks/src/events/user_prompt_submit.rs b/codex-rs/hooks/src/events/user_prompt_submit.rs index 9199a85025..e9f58438cf 100644 --- a/codex-rs/hooks/src/events/user_prompt_submit.rs +++ b/codex-rs/hooks/src/events/user_prompt_submit.rs @@ -10,9 +10,8 @@ use codex_protocol::protocol::HookRunSummary; use codex_utils_absolute_path::AbsolutePathBuf; use super::common; -use crate::engine::CommandShell; +use crate::engine::ClaudeHooksEngine; use crate::engine::ConfiguredHandler; -use crate::engine::async_output::AsyncCommandRuntime; use crate::engine::command_runner::CommandRunResult; use crate::engine::dispatcher; use crate::engine::output_parser; @@ -48,41 +47,18 @@ struct UserPromptSubmitHandlerData { } pub(crate) fn preview( - handlers: &[ConfiguredHandler], + engine: &ClaudeHooksEngine, _request: &UserPromptSubmitRequest, ) -> Vec { - dispatcher::select_sync_handlers( - handlers, - HookEventName::UserPromptSubmit, - /*matcher_input*/ None, - ) - .into_iter() - .map(|handler| dispatcher::running_summary(&handler)) - .collect() + engine.preview_commands(HookEventName::UserPromptSubmit, &[]) } pub(crate) async fn run( - handlers: &[ConfiguredHandler], - shell: &CommandShell, - async_runtime: &AsyncCommandRuntime, + engine: &ClaudeHooksEngine, request: UserPromptSubmitRequest, ) -> UserPromptSubmitOutcome { - let matched = dispatcher::select_handlers( - handlers, - HookEventName::UserPromptSubmit, - /*matcher_input*/ None, - ); - if matched.is_empty() { - return UserPromptSubmitOutcome { - hook_events: Vec::new(), - should_stop: false, - stop_reason: None, - additional_contexts: Vec::new(), - }; - } - let subagent = SubagentCommandInputFields::from(request.subagent.as_ref()); - let input_json = serde_json::to_string(&UserPromptSubmitCommandInput { + let input = UserPromptSubmitCommandInput { session_id: request.session_id.to_string(), turn_id: request.turn_id.clone(), agent_id: subagent.agent_id, @@ -93,19 +69,18 @@ pub(crate) async fn run( model: request.model.clone(), permission_mode: request.permission_mode.clone(), prompt: request.prompt.clone(), - }) - .map_err(|error| format!("failed to serialize user prompt submit hook input: {error}")); + }; - let results = dispatcher::execute_handlers( - shell, - async_runtime, - matched, - input_json, - request.cwd.as_path(), - Some(request.turn_id), - parse_completed, - ) - .await; + let results = engine + .execute_commands( + HookEventName::UserPromptSubmit, + &[], + &input, + request.cwd.as_path(), + Some(request.turn_id), + parse_completed, + ) + .await; let should_stop = results.iter().any(|result| result.data.should_stop); let stop_reason = results