From d546866b457aa4fc46342c42e2b6bfc05aa31aa6 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Wed, 3 Jun 2026 16:15:09 -0700 Subject: [PATCH] Add prompt hook runtime --- codex-rs/core/src/session/mod.rs | 1 + codex-rs/hooks/src/engine/command_runner.rs | 16 +- codex-rs/hooks/src/engine/discovery.rs | 33 ++- codex-rs/hooks/src/engine/dispatcher.rs | 50 +++- codex-rs/hooks/src/engine/mod.rs | 94 +++++- codex-rs/hooks/src/engine/mod_tests.rs | 26 +- codex-rs/hooks/src/engine/prompt_runner.rs | 273 ++++++++++++++++++ .../hooks/src/engine/prompt_runner_tests.rs | 128 ++++++++ codex-rs/hooks/src/events/compact.rs | 27 +- .../hooks/src/events/permission_request.rs | 12 +- codex-rs/hooks/src/events/post_tool_use.rs | 19 +- codex-rs/hooks/src/events/pre_tool_use.rs | 19 +- codex-rs/hooks/src/events/session_start.rs | 17 +- codex-rs/hooks/src/events/stop.rs | 19 +- .../hooks/src/events/user_prompt_submit.rs | 19 +- codex-rs/hooks/src/lib.rs | 2 + codex-rs/hooks/src/registry.rs | 3 + codex-rs/hooks/src/schema.rs | 19 ++ 18 files changed, 699 insertions(+), 78 deletions(-) create mode 100644 codex-rs/hooks/src/engine/prompt_runner.rs create mode 100644 codex-rs/hooks/src/engine/prompt_runner_tests.rs diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 5a92c9c825..a30a7e8a13 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -3399,6 +3399,7 @@ async fn build_hooks_for_config( plugin_hook_load_warnings, shell_program: Some(hook_shell_program), shell_args: hook_shell_argv, + prompt_hook_runner: None, }) } diff --git a/codex-rs/hooks/src/engine/command_runner.rs b/codex-rs/hooks/src/engine/command_runner.rs index 7366d4ec51..8f3c77bd1f 100644 --- a/codex-rs/hooks/src/engine/command_runner.rs +++ b/codex-rs/hooks/src/engine/command_runner.rs @@ -9,6 +9,7 @@ use tokio::time::timeout; use super::CommandShell; use super::ConfiguredHandler; +use super::ConfiguredHandlerKind; #[derive(Debug)] pub(crate) struct CommandRunResult { @@ -68,7 +69,7 @@ pub(crate) async fn run_command( }; } - let timeout_duration = Duration::from_secs(handler.timeout_sec); + let timeout_duration = Duration::from_secs(handler.timeout_sec()); match timeout(timeout_duration, child.wait_with_output()).await { Ok(Ok(output)) => CommandRunResult { started_at, @@ -95,22 +96,29 @@ pub(crate) async fn run_command( exit_code: None, stdout: String::new(), stderr: String::new(), - error: Some(format!("hook timed out after {}s", handler.timeout_sec)), + error: Some(format!("hook timed out after {}s", handler.timeout_sec())), }, } } fn build_command(shell: &CommandShell, handler: &ConfiguredHandler) -> Command { + let ConfiguredHandlerKind::Command { + command: command_text, + .. + } = &handler.kind + else { + panic!("prompt handler cannot run as a command hook"); + }; let mut command = if shell.program.is_empty() { default_shell_command() } else { Command::new(&shell.program) }; if shell.program.is_empty() { - command.arg(&handler.command); + command.arg(command_text); } else { command.args(&shell.args); - command.arg(&handler.command); + command.arg(command_text); } command.envs(&handler.env); command diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index 92d17fbabd..8b7d6093b2 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -22,10 +22,12 @@ use serde::Deserialize; use serde::Serialize; use super::ConfiguredHandler; +use super::ConfiguredHandlerKind; use super::HookListEntry; use crate::config_rules::hook_states_from_stack; use crate::events::common::matcher_pattern_for_event; use crate::events::common::validate_matcher_pattern; +use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookHandlerType; use codex_protocol::protocol::HookSource; use codex_protocol::protocol::HookTrustStatus; @@ -437,7 +439,7 @@ fn append_matcher_groups( warnings: &mut Vec, display_order: &mut i64, source: &HookHandlerSource<'_>, - event_name: codex_protocol::protocol::HookEventName, + event_name: HookEventName, groups: Vec, ) { for (group_index, group) in groups.into_iter().enumerate() { @@ -487,12 +489,10 @@ fn append_matcher_groups( r#async, status_message: status_message.clone(), }; - let current_hash = - command_hook_hash(event_name, matcher, &group, normalized_handler); + let current_hash = hook_hash(event_name, matcher, &group, normalized_handler); let command = source.env.iter().fold(command, |command, (key, value)| { command.replace(&format!("${{{key}}}"), value) }); - // TODO(abhinav): replace this positional suffix with a durable hook id. let key = crate::hook_key(&source.key_source, event_name, group_index, handler_index); let state = source.hook_states.get(&key); @@ -527,8 +527,10 @@ fn append_matcher_groups( handlers.push(ConfiguredHandler { event_name, matcher: matcher.map(ToOwned::to_owned), - command, - timeout_sec, + kind: ConfiguredHandlerKind::Command { + command, + timeout_sec, + }, status_message, source_path: source.path.clone(), source: source.source, @@ -560,8 +562,8 @@ struct NormalizedHookIdentity { group: MatcherGroup, } -fn command_hook_hash( - event_name: codex_protocol::protocol::HookEventName, +fn hook_hash( + event_name: HookEventName, matcher: Option<&str>, group: &MatcherGroup, normalized_handler: HookHandlerConfig, @@ -658,6 +660,7 @@ mod tests { use pretty_assertions::assert_eq; use super::ConfiguredHandler; + use super::ConfiguredHandlerKind; use super::append_matcher_groups; use codex_config::HookHandlerConfig; use codex_config::HookStateToml; @@ -778,8 +781,10 @@ mod tests { vec![ConfiguredHandler { event_name: HookEventName::UserPromptSubmit, matcher: None, - command: "echo hello".to_string(), - timeout_sec: 600, + kind: ConfiguredHandlerKind::Command { + command: "echo hello".to_string(), + timeout_sec: 600, + }, status_message: None, source_path: source_path.clone(), source: hook_source(), @@ -813,8 +818,10 @@ mod tests { vec![ConfiguredHandler { event_name: HookEventName::PreToolUse, matcher: Some("^Bash$".to_string()), - command: "echo hello".to_string(), - timeout_sec: 600, + kind: ConfiguredHandlerKind::Command { + command: "echo hello".to_string(), + timeout_sec: 600, + }, status_message: None, source_path: source_path.clone(), source: hook_source(), @@ -1000,7 +1007,7 @@ mod tests { assert_eq!(warnings, Vec::::new()); assert_eq!(handlers.len(), 1); assert_eq!( - handlers[0].command, + handlers[0].command().expect("command handler"), if cfg!(windows) { "echo windows" } else { diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index 50822bfc96..ad86a8ac37 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -6,15 +6,17 @@ use futures::stream::FuturesUnordered; use codex_protocol::protocol::HookCompletedEvent; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookExecutionMode; -use codex_protocol::protocol::HookHandlerType; use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookRunSummary; use codex_protocol::protocol::HookScope; use super::CommandShell; use super::ConfiguredHandler; +use super::ConfiguredHandlerKind; use super::command_runner::CommandRunResult; use super::command_runner::run_command; +use super::prompt_runner::PromptHookRunner; +use super::prompt_runner::run_prompt; use crate::events::common::matches_matcher; #[derive(Debug)] @@ -71,7 +73,7 @@ pub(crate) fn running_summary(handler: &ConfiguredHandler) -> HookRunSummary { HookRunSummary { id: handler.run_id(), event_name: handler.event_name, - handler_type: HookHandlerType::Command, + handler_type: handler.handler_type(), execution_mode: HookExecutionMode::Sync, scope: scope_for_event(handler.event_name), source_path: handler.source_path.clone(), @@ -86,20 +88,41 @@ pub(crate) fn running_summary(handler: &ConfiguredHandler) -> HookRunSummary { } } +pub(crate) struct HandlerExecutionContext<'a> { + pub shell: &'a CommandShell, + pub prompt_runner: Option<&'a PromptHookRunner>, + pub cwd: &'a Path, + pub default_model: String, + pub turn_id: Option, +} + pub(crate) async fn execute_handlers( - shell: &CommandShell, handlers: Vec, input_json: String, - cwd: &Path, - turn_id: Option, + context: HandlerExecutionContext<'_>, parse: fn(&ConfiguredHandler, CommandRunResult, Option) -> ParsedHandler, ) -> Vec> { + let HandlerExecutionContext { + shell, + prompt_runner, + cwd, + default_model, + turn_id, + } = context; let mut pending = FuturesUnordered::new(); for (configured_order, handler) in handlers.into_iter().enumerate() { let input_json = input_json.clone(); + let default_model = default_model.clone(); let turn_id = turn_id.clone(); pending.push(async move { - let result = run_command(shell, &handler, &input_json, cwd).await; + let result = match &handler.kind { + ConfiguredHandlerKind::Command { .. } => { + run_command(shell, &handler, &input_json, cwd).await + } + ConfiguredHandlerKind::Prompt { .. } => { + run_prompt(prompt_runner, &handler, &input_json, default_model).await + } + }; (configured_order, parse(&handler, result, turn_id)) }); } @@ -124,7 +147,7 @@ pub(crate) fn completed_summary( HookRunSummary { id: handler.run_id(), event_name: handler.event_name, - handler_type: HookHandlerType::Command, + handler_type: handler.handler_type(), execution_mode: HookExecutionMode::Sync, scope: scope_for_event(handler.event_name), source_path: handler.source_path.clone(), @@ -161,6 +184,7 @@ mod tests { use codex_utils_absolute_path::test_support::test_path_buf; use super::ConfiguredHandler; + use super::ConfiguredHandlerKind; use super::select_handlers; use super::select_handlers_for_matcher_inputs; @@ -173,8 +197,10 @@ mod tests { ConfiguredHandler { event_name, matcher: matcher.map(str::to_owned), - command: command.to_string(), - timeout_sec: 5, + kind: ConfiguredHandlerKind::Command { + command: command.to_string(), + timeout_sec: 5, + }, status_message: None, source_path: test_path_buf("/tmp/hooks.json").abs(), source: HookSource::User, @@ -440,8 +466,8 @@ mod tests { let selected = select_handlers(&handlers, HookEventName::Stop, /*matcher_input*/ None); assert_eq!(selected.len(), 3); - assert_eq!(selected[0].command, "first"); - assert_eq!(selected[1].command, "second"); - assert_eq!(selected[2].command, "third"); + assert_eq!(selected[0].command().expect("command handler"), "first"); + assert_eq!(selected[1].command().expect("command handler"), "second"); + assert_eq!(selected[2].command().expect("command handler"), "third"); } } diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 859fc54069..2b2ce0d3ed 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod command_runner; pub(crate) mod discovery; pub(crate) mod dispatcher; pub(crate) mod output_parser; +pub(crate) mod prompt_runner; pub(crate) mod schema_loader; use crate::events::compact::PostCompactRequest; @@ -32,6 +33,9 @@ use codex_protocol::protocol::HookTrustStatus; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; +pub use prompt_runner::PromptHookRequest; +pub use prompt_runner::PromptHookRunner; + #[derive(Debug, Clone)] pub(crate) struct CommandShell { pub program: String, @@ -42,8 +46,7 @@ pub(crate) struct CommandShell { pub(crate) struct ConfiguredHandler { pub event_name: codex_protocol::protocol::HookEventName, pub matcher: Option, - pub command: String, - pub timeout_sec: u64, + pub kind: ConfiguredHandlerKind, pub status_message: Option, pub source_path: AbsolutePathBuf, pub source: HookSource, @@ -51,6 +54,24 @@ pub(crate) struct ConfiguredHandler { pub env: HashMap, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ConfiguredHandlerKind { + Command { + command: String, + timeout_sec: u64, + }, + #[allow( + dead_code, + reason = "constructed by prompt-hook discovery in the follow-up" + )] + Prompt { + prompt: String, + model: Option, + timeout_sec: u64, + continue_on_block: bool, + }, +} + impl ConfiguredHandler { pub fn run_id(&self) -> String { format!( @@ -75,6 +96,28 @@ impl ConfiguredHandler { codex_protocol::protocol::HookEventName::Stop => "stop", } } + + pub(crate) fn handler_type(&self) -> HookHandlerType { + match &self.kind { + ConfiguredHandlerKind::Command { .. } => HookHandlerType::Command, + ConfiguredHandlerKind::Prompt { .. } => HookHandlerType::Prompt, + } + } + + pub(crate) fn timeout_sec(&self) -> u64 { + match &self.kind { + ConfiguredHandlerKind::Command { timeout_sec, .. } + | ConfiguredHandlerKind::Prompt { timeout_sec, .. } => *timeout_sec, + } + } + + #[cfg(test)] + pub(crate) fn command(&self) -> Option<&str> { + match &self.kind { + ConfiguredHandlerKind::Command { command, .. } => Some(command.as_str()), + ConfiguredHandlerKind::Prompt { .. } => None, + } + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -101,6 +144,7 @@ pub(crate) struct ClaudeHooksEngine { handlers: Vec, warnings: Vec, shell: CommandShell, + prompt_hook_runner: Option, output_spiller: HookOutputSpiller, } @@ -112,12 +156,14 @@ impl ClaudeHooksEngine { plugin_hook_sources: Vec, plugin_hook_load_warnings: Vec, shell: CommandShell, + prompt_hook_runner: Option, ) -> Self { if !enabled { return Self { handlers: Vec::new(), warnings: Vec::new(), shell, + prompt_hook_runner, output_spiller: HookOutputSpiller::new(), }; } @@ -133,6 +179,7 @@ impl ClaudeHooksEngine { handlers: discovered.handlers, warnings: discovered.warnings, shell, + prompt_hook_runner, output_spiller: HookOutputSpiller::new(), } } @@ -182,8 +229,13 @@ 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, request).await; + let mut outcome = crate::events::pre_tool_use::run( + &self.handlers, + &self.shell, + self.prompt_hook_runner.as_ref(), + request, + ) + .await; outcome.additional_contexts = self .maybe_spill_texts(session_id, outcome.additional_contexts) .await; @@ -194,7 +246,13 @@ impl ClaudeHooksEngine { &self, request: PermissionRequestRequest, ) -> PermissionRequestOutcome { - crate::events::permission_request::run(&self.handlers, &self.shell, request).await + crate::events::permission_request::run( + &self.handlers, + &self.shell, + self.prompt_hook_runner.as_ref(), + request, + ) + .await } pub(crate) async fn run_post_tool_use( @@ -202,8 +260,13 @@ impl ClaudeHooksEngine { request: PostToolUseRequest, ) -> PostToolUseOutcome { let session_id = request.session_id; - let mut outcome = - crate::events::post_tool_use::run(&self.handlers, &self.shell, request).await; + let mut outcome = crate::events::post_tool_use::run( + &self.handlers, + &self.shell, + self.prompt_hook_runner.as_ref(), + request, + ) + .await; outcome.additional_contexts = self .maybe_spill_texts(session_id, outcome.additional_contexts) .await; @@ -244,8 +307,13 @@ impl ClaudeHooksEngine { request: UserPromptSubmitRequest, ) -> UserPromptSubmitOutcome { let session_id = request.session_id; - let mut outcome = - crate::events::user_prompt_submit::run(&self.handlers, &self.shell, request).await; + let mut outcome = crate::events::user_prompt_submit::run( + &self.handlers, + &self.shell, + self.prompt_hook_runner.as_ref(), + request, + ) + .await; outcome.additional_contexts = self .maybe_spill_texts(session_id, outcome.additional_contexts) .await; @@ -258,7 +326,13 @@ impl ClaudeHooksEngine { 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, request).await; + let mut outcome = crate::events::stop::run( + &self.handlers, + &self.shell, + self.prompt_hook_runner.as_ref(), + 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 4b7697b34f..b47031b20b 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -204,6 +204,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: program: String::new(), args: Vec::new(), }, + /*prompt_hook_runner*/ None, ); assert!(engine.warnings().is_empty()); @@ -221,6 +222,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: plugin_hook_load_warnings: Vec::new(), shell_program: None, shell_args: Vec::new(), + prompt_hook_runner: None, }); assert!(listed.hooks[0].is_managed); let cwd = cwd(); @@ -310,6 +312,7 @@ async fn requirements_managed_hooks_execute_windows_command_override() { program: String::new(), args: Vec::new(), }, + /*prompt_hook_runner*/ None, ); let outcome = engine @@ -389,6 +392,7 @@ fn unknown_requirement_source_hooks_stay_managed() { program: String::new(), args: Vec::new(), }, + /*prompt_hook_runner*/ None, ); assert_eq!(engine.handlers.len(), 1); @@ -471,6 +475,7 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { program: String::new(), args: Vec::new(), }, + /*prompt_hook_runner*/ None, ); assert_eq!(engine.handlers.len(), 1); @@ -537,6 +542,7 @@ fn user_disablement_does_not_filter_managed_layer_hooks() { program: String::new(), args: Vec::new(), }, + /*prompt_hook_runner*/ None, ); assert_eq!(engine.handlers.len(), 1); @@ -698,6 +704,7 @@ fn requirements_managed_hooks_load_when_managed_dir_is_missing() { program: String::new(), args: Vec::new(), }, + /*prompt_hook_runner*/ None, ); assert!(engine.warnings().is_empty()); @@ -716,7 +723,10 @@ fn requirements_managed_hooks_load_when_managed_dir_is_missing() { tool_input: serde_json::json!({ "command": "echo hello" }), }); assert_eq!(preview.len(), 1); - assert_eq!(engine.handlers[0].command, "echo hi"); + assert_eq!( + engine.handlers[0].command().expect("command handler"), + "echo hi" + ); assert_eq!( engine.handlers[0].source_path, AbsolutePathBuf::try_from(missing_dir).expect("absolute missing dir") @@ -754,6 +764,7 @@ fn allow_managed_hooks_only_false_keeps_unmanaged_hooks() { program: String::new(), args: Vec::new(), }, + /*prompt_hook_runner*/ None, ); assert!(engine.warnings().is_empty()); @@ -808,6 +819,7 @@ fn allow_managed_hooks_only_in_config_toml_does_not_enable_policy() { program: String::new(), args: Vec::new(), }, + /*prompt_hook_runner*/ None, ); assert!(engine.warnings().is_empty()); @@ -878,6 +890,7 @@ fn allow_managed_hooks_only_skips_unmanaged_json_and_toml_hooks() { program: String::new(), args: Vec::new(), }, + /*prompt_hook_runner*/ None, ); assert!(engine.handlers.is_empty()); @@ -917,6 +930,7 @@ fn allow_managed_hooks_only_skips_unmanaged_plugin_hooks() { program: String::new(), args: Vec::new(), }, + /*prompt_hook_runner*/ None, ); assert!(engine.handlers.is_empty()); @@ -989,6 +1003,7 @@ fn allow_managed_hooks_only_keeps_managed_requirement_and_config_layer_hooks() { program: String::new(), args: Vec::new(), }, + /*prompt_hook_runner*/ None, ); assert!(engine.warnings().is_empty()); @@ -996,7 +1011,7 @@ fn allow_managed_hooks_only_keeps_managed_requirement_and_config_layer_hooks() { engine .handlers .iter() - .map(|handler| handler.command.as_str()) + .map(|handler| handler.command().expect("command handler")) .collect::>(), vec![ "python3 /tmp/requirements-hook.py", @@ -1099,6 +1114,7 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() { program: String::new(), args: Vec::new(), }, + /*prompt_hook_runner*/ None, ); assert!(engine.warnings().iter().any(|warning| { @@ -1192,6 +1208,7 @@ print(json.dumps({ program: String::new(), args: Vec::new(), }, + /*prompt_hook_runner*/ None, ); let preview = engine.preview_pre_tool_use(&PreToolUseRequest { @@ -1219,6 +1236,7 @@ print(json.dumps({ plugin_hook_load_warnings: Vec::new(), shell_program: None, shell_args: Vec::new(), + prompt_hook_runner: None, }); assert_eq!( listed.hooks[0].plugin_id.as_deref(), @@ -1311,10 +1329,11 @@ fn plugin_hook_sources_expand_plugin_placeholders() { program: String::new(), args: Vec::new(), }, + /*prompt_hook_runner*/ None, ); assert_eq!( - engine.handlers[0].command, + engine.handlers[0].command().expect("command handler"), format!( "run {} {} {} {}", plugin_root.display(), @@ -1355,6 +1374,7 @@ fn plugin_hook_load_warnings_are_startup_warnings() { program: String::new(), args: Vec::new(), }, + /*prompt_hook_runner*/ None, ); assert_eq!(engine.warnings(), &["failed plugin hook".to_string()]); diff --git a/codex-rs/hooks/src/engine/prompt_runner.rs b/codex-rs/hooks/src/engine/prompt_runner.rs new file mode 100644 index 0000000000..5e4a62d645 --- /dev/null +++ b/codex-rs/hooks/src/engine/prompt_runner.rs @@ -0,0 +1,273 @@ +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; + +use codex_protocol::protocol::HookEventName; +use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_output_truncation::approx_token_count; +use codex_utils_output_truncation::truncate_text; +use futures::future::BoxFuture; +use serde::Deserialize; +use serde_json::json; +use tokio::time::timeout; + +use super::ConfiguredHandler; +use super::ConfiguredHandlerKind; +use super::command_runner::CommandRunResult; +use crate::schema::hook_event_wire_name; + +const PROMPT_ARGUMENTS_PLACEHOLDER: &str = "$ARGUMENTS"; +const PROMPT_HOOK_INPUT_TOKEN_LIMIT: usize = 10_000; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PromptHookRequest { + pub prompt: String, + pub model: String, +} + +#[derive(Clone)] +pub struct PromptHookRunner { + run: Arc BoxFuture<'static, anyhow::Result> + Send + Sync>, +} + +impl PromptHookRunner { + pub fn new(run: F) -> Self + where + F: Fn(PromptHookRequest) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + Self { + run: Arc::new(move |request| Box::pin(run(request))), + } + } + + async fn run(&self, request: PromptHookRequest) -> anyhow::Result { + (self.run)(request).await + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PromptHookBehavior { + Unsupported, + Block, + Noop, + FeedbackOrStop, +} + +pub(crate) fn prompt_hook_behavior(event_name: HookEventName) -> PromptHookBehavior { + match event_name { + // These events already use decision:block as the user-visible "try + // again with this feedback" path: pre-action hooks block before the + // action runs, while Stop/SubagentStop feed the reason into the next + // model turn. + HookEventName::PreToolUse + | HookEventName::UserPromptSubmit + | HookEventName::Stop + | HookEventName::SubagentStop => PromptHookBehavior::Block, + // Claude treats PermissionRequest ok:false as advisory only. Preserve + // that parity: record the reason, but let normal approval flow continue. + HookEventName::PermissionRequest => PromptHookBehavior::Noop, + // PostToolUse runs after the tool succeeded, so ok:false is conditional: + // continueOnBlock feeds the reason back to the model, otherwise it stops + // the current turn. + HookEventName::PostToolUse => PromptHookBehavior::FeedbackOrStop, + // Claude does not support prompt hooks for these lifecycle events. + // Keeping them explicit makes new events choose semantics deliberately. + HookEventName::SessionStart + | HookEventName::SubagentStart + | HookEventName::PreCompact + | HookEventName::PostCompact => PromptHookBehavior::Unsupported, + } +} + +#[derive(Deserialize)] +struct PromptHookOutput { + ok: bool, + #[serde(default)] + reason: Option, +} + +/// Execute a model-backed prompt hook and adapt its response into the same +/// synthetic stdout shape that command hooks already parse. The hook prompt is +/// rendered with `$ARGUMENTS` replacement when present, otherwise the hook input +/// JSON is appended. The model must return `{ ok, reason }`; this function then +/// maps `ok:false` through the per-event behavior table so block/no-op/feedback +/// semantics stay centralized. +pub(crate) async fn run_prompt( + runner: Option<&PromptHookRunner>, + handler: &ConfiguredHandler, + input_json: &str, + default_model: String, +) -> CommandRunResult { + let started_at = chrono::Utc::now().timestamp(); + let started = Instant::now(); + + let ConfiguredHandlerKind::Prompt { + prompt, + model, + timeout_sec, + continue_on_block, + } = &handler.kind + else { + return prompt_run_result( + started_at, + started, + /*exit_code*/ None, + String::new(), + Some("command handler cannot run as a prompt hook".to_string()), + ); + }; + let Some(runner) = runner else { + return prompt_run_result( + started_at, + started, + /*exit_code*/ None, + String::new(), + Some("prompt hook cannot run because no prompt runner is configured".to_string()), + ); + }; + + let request = PromptHookRequest { + prompt: render_prompt(prompt, input_json), + model: model.clone().unwrap_or(default_model), + }; + + let run = timeout(Duration::from_secs(*timeout_sec), runner.run(request)).await; + match run { + Ok(Ok(output)) => { + match prompt_output_to_command_stdout(handler.event_name, *continue_on_block, &output) { + Ok(stdout) => { + prompt_run_result(started_at, started, Some(0), stdout, /*error*/ None) + } + Err(error) => { + prompt_run_result( + started_at, + started, + /*exit_code*/ None, + String::new(), + Some(error), + ) + } + } + } + Ok(Err(error)) => prompt_run_result( + started_at, + started, + /*exit_code*/ None, + String::new(), + Some(error.to_string()), + ), + Err(_) => prompt_run_result( + started_at, + started, + /*exit_code*/ None, + String::new(), + Some(format!("prompt hook timed out after {timeout_sec}s")), + ), + } +} + +fn render_prompt(prompt: &str, input_json: &str) -> String { + let rendered = if prompt.contains(PROMPT_ARGUMENTS_PLACEHOLDER) { + prompt.replace(PROMPT_ARGUMENTS_PLACEHOLDER, input_json) + } else { + format!("{prompt}\n\n{input_json}") + }; + let mut truncation_budget = PROMPT_HOOK_INPUT_TOKEN_LIMIT; + loop { + let candidate = truncate_text(&rendered, TruncationPolicy::Tokens(truncation_budget)); + let candidate_tokens = approx_token_count(&candidate); + if candidate_tokens <= PROMPT_HOOK_INPUT_TOKEN_LIMIT { + return candidate; + } + truncation_budget = truncation_budget.saturating_sub( + candidate_tokens + .saturating_sub(PROMPT_HOOK_INPUT_TOKEN_LIMIT) + .max(1), + ); + } +} + +fn prompt_output_to_command_stdout( + event_name: HookEventName, + continue_on_block: bool, + output: &str, +) -> Result { + let output: PromptHookOutput = serde_json::from_str(output.trim()) + .map_err(|err| format!("prompt hook returned invalid JSON output: {err}"))?; + if output.ok { + return Ok("{}".to_string()); + } + + let Some(reason) = output + .reason + .as_deref() + .map(str::trim) + .filter(|reason| !reason.is_empty()) + else { + return Err("prompt hook returned ok:false without a non-empty reason".to_string()); + }; + + prompt_block_output(event_name, continue_on_block, reason.to_string()) +} + +fn prompt_block_output( + event_name: HookEventName, + continue_on_block: bool, + reason: String, +) -> Result { + let value = match prompt_hook_behavior(event_name) { + PromptHookBehavior::Block => json!({ + "decision": "block", + "reason": reason, + }), + PromptHookBehavior::Noop => json!({ + "systemMessage": reason, + }), + PromptHookBehavior::FeedbackOrStop => { + if continue_on_block { + json!({ + "decision": "block", + "reason": reason, + }) + } else { + json!({ + "continue": false, + "stopReason": reason, + "decision": "block", + "reason": reason, + }) + } + } + PromptHookBehavior::Unsupported => { + return Err(format!( + "prompt hooks are not supported for {}", + hook_event_wire_name(event_name) + )); + } + }; + serde_json::to_string(&value).map_err(|err| err.to_string()) +} + +fn prompt_run_result( + started_at: i64, + started: Instant, + exit_code: Option, + stdout: String, + error: Option, +) -> CommandRunResult { + CommandRunResult { + started_at, + completed_at: chrono::Utc::now().timestamp(), + duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX), + exit_code, + stdout, + stderr: String::new(), + error, + } +} + +#[cfg(test)] +#[path = "prompt_runner_tests.rs"] +mod tests; diff --git a/codex-rs/hooks/src/engine/prompt_runner_tests.rs b/codex-rs/hooks/src/engine/prompt_runner_tests.rs new file mode 100644 index 0000000000..5403bbb8f4 --- /dev/null +++ b/codex-rs/hooks/src/engine/prompt_runner_tests.rs @@ -0,0 +1,128 @@ +use pretty_assertions::assert_eq; + +use super::*; + +#[tokio::test] +async fn prompt_hook_without_runner_returns_error() { + let result = run_prompt( + /*runner*/ None, + &prompt_handler(/*model*/ None), + r#"{"hook_event_name":"Stop"}"#, + "gpt-thread".to_string(), + ) + .await; + + assert_eq!(result.exit_code, None); + assert_eq!( + result.error, + Some("prompt hook cannot run because no prompt runner is configured".to_string()) + ); +} + +#[test] +fn render_prompt_replaces_arguments_placeholder() { + assert_eq!( + render_prompt("Check: $ARGUMENTS", r#"{"event":"Stop"}"#), + r#"Check: {"event":"Stop"}"# + ); +} + +#[test] +fn render_prompt_appends_arguments_without_placeholder() { + assert_eq!( + render_prompt("Check the turn.", r#"{"event":"Stop"}"#), + "Check the turn.\n\n{\"event\":\"Stop\"}" + ); +} + +#[test] +fn render_prompt_caps_model_input() { + let rendered = render_prompt("$ARGUMENTS", &"word ".repeat(20_000)); + + assert!(codex_utils_output_truncation::approx_token_count(&rendered) <= 10_000); + assert!(rendered.contains("tokens truncated")); +} + +#[test] +fn stop_ok_false_becomes_block_decision() { + assert_json_eq( + prompt_output_to_command_stdout( + HookEventName::Stop, + /*continue_on_block*/ false, + r#"{"ok":false,"reason":"mention tests"}"#, + ) + .expect("prompt output"), + json!({ + "decision": "block", + "reason": "mention tests", + }), + ); +} + +#[test] +fn permission_request_ok_false_records_reason_without_decision() { + assert_json_eq( + prompt_output_to_command_stdout( + HookEventName::PermissionRequest, + /*continue_on_block*/ false, + r#"{"ok":false,"reason":"looks suspicious"}"#, + ) + .expect("prompt output"), + json!({ + "systemMessage": "looks suspicious", + }), + ); +} + +#[test] +fn post_tool_use_ok_false_honors_continue_on_block() { + assert_json_eq( + prompt_output_to_command_stdout( + HookEventName::PostToolUse, + /*continue_on_block*/ true, + r#"{"ok":false,"reason":"summarize the command output"}"#, + ) + .expect("prompt output"), + json!({ + "decision": "block", + "reason": "summarize the command output", + }), + ); + assert_json_eq( + prompt_output_to_command_stdout( + HookEventName::PostToolUse, + /*continue_on_block*/ false, + r#"{"ok":false,"reason":"stop here"}"#, + ) + .expect("prompt output"), + json!({ + "continue": false, + "decision": "block", + "reason": "stop here", + "stopReason": "stop here", + }), + ); +} + +fn assert_json_eq(actual: String, expected: serde_json::Value) { + let actual: serde_json::Value = serde_json::from_str(&actual).expect("json output"); + assert_eq!(actual, expected); +} + +fn prompt_handler(model: Option) -> ConfiguredHandler { + ConfiguredHandler { + event_name: HookEventName::Stop, + matcher: None, + kind: ConfiguredHandlerKind::Prompt { + prompt: "Check: $ARGUMENTS".to_string(), + model, + timeout_sec: 30, + continue_on_block: true, + }, + status_message: None, + source_path: codex_utils_absolute_path::AbsolutePathBuf::current_dir().expect("cwd"), + source: codex_protocol::protocol::HookSource::User, + display_order: 0, + env: std::collections::HashMap::new(), + } +} diff --git a/codex-rs/hooks/src/events/compact.rs b/codex-rs/hooks/src/events/compact.rs index cb3080219a..c752f7e0f2 100644 --- a/codex-rs/hooks/src/events/compact.rs +++ b/codex-rs/hooks/src/events/compact.rs @@ -103,11 +103,15 @@ pub(crate) async fn run_pre( }; let results = dispatcher::execute_handlers( - shell, matched, input_json, - request.cwd.as_path(), - Some(request.turn_id), + dispatcher::HandlerExecutionContext { + shell, + prompt_runner: None, + cwd: request.cwd.as_path(), + default_model: request.model.clone(), + turn_id: Some(request.turn_id.clone()), + }, parse_pre_completed, ) .await; @@ -185,11 +189,15 @@ pub(crate) async fn run_post( }; let results = dispatcher::execute_handlers( - shell, matched, input_json, - request.cwd.as_path(), - Some(request.turn_id), + dispatcher::HandlerExecutionContext { + shell, + prompt_runner: None, + cwd: request.cwd.as_path(), + default_model: request.model.clone(), + turn_id: Some(request.turn_id.clone()), + }, parse_post_completed, ) .await; @@ -432,6 +440,7 @@ mod tests { use super::post_command_input_json; use super::pre_command_input_json; use crate::engine::ConfiguredHandler; + use crate::engine::ConfiguredHandlerKind; use crate::engine::command_runner::CommandRunResult; #[test] @@ -597,8 +606,10 @@ mod tests { ConfiguredHandler { event_name, matcher: None, - command: "python3 compact_hook.py".to_string(), - timeout_sec: 5, + kind: ConfiguredHandlerKind::Command { + command: "python3 compact_hook.py".to_string(), + timeout_sec: 5, + }, status_message: Some("running compact hook".to_string()), source_path: test_path_buf("/tmp/hooks.json").abs(), source: codex_protocol::protocol::HookSource::User, diff --git a/codex-rs/hooks/src/events/permission_request.rs b/codex-rs/hooks/src/events/permission_request.rs index db7970f02d..ea7d01263b 100644 --- a/codex-rs/hooks/src/events/permission_request.rs +++ b/codex-rs/hooks/src/events/permission_request.rs @@ -21,6 +21,7 @@ use crate::engine::ConfiguredHandler; use crate::engine::command_runner::CommandRunResult; use crate::engine::dispatcher; use crate::engine::output_parser; +use crate::engine::prompt_runner::PromptHookRunner; use crate::schema::PermissionRequestCommandInput; use crate::schema::SubagentCommandInputFields; use codex_protocol::ThreadId; @@ -87,6 +88,7 @@ pub(crate) fn preview( pub(crate) async fn run( handlers: &[ConfiguredHandler], shell: &CommandShell, + prompt_runner: Option<&PromptHookRunner>, request: PermissionRequestRequest, ) -> PermissionRequestOutcome { let matcher_inputs = common::matcher_inputs(&request.tool_name, &request.matcher_aliases); @@ -119,11 +121,15 @@ pub(crate) async fn run( }; let results = dispatcher::execute_handlers( - shell, matched, input_json, - request.cwd.as_path(), - Some(request.turn_id.clone()), + dispatcher::HandlerExecutionContext { + shell, + prompt_runner, + cwd: request.cwd.as_path(), + default_model: request.model.clone(), + turn_id: Some(request.turn_id.clone()), + }, parse_completed, ) .await; diff --git a/codex-rs/hooks/src/events/post_tool_use.rs b/codex-rs/hooks/src/events/post_tool_use.rs index f096de0110..9dcd3fc771 100644 --- a/codex-rs/hooks/src/events/post_tool_use.rs +++ b/codex-rs/hooks/src/events/post_tool_use.rs @@ -16,6 +16,7 @@ use crate::engine::ConfiguredHandler; use crate::engine::command_runner::CommandRunResult; use crate::engine::dispatcher; use crate::engine::output_parser; +use crate::engine::prompt_runner::PromptHookRunner; use crate::schema::PostToolUseCommandInput; use crate::schema::SubagentCommandInputFields; @@ -72,6 +73,7 @@ pub(crate) fn preview( pub(crate) async fn run( handlers: &[ConfiguredHandler], shell: &CommandShell, + prompt_runner: Option<&PromptHookRunner>, request: PostToolUseRequest, ) -> PostToolUseOutcome { let matcher_inputs = common::matcher_inputs(&request.tool_name, &request.matcher_aliases); @@ -104,11 +106,15 @@ pub(crate) async fn run( }; let results = dispatcher::execute_handlers( - shell, matched, input_json, - request.cwd.as_path(), - Some(request.turn_id.clone()), + dispatcher::HandlerExecutionContext { + shell, + prompt_runner, + cwd: request.cwd.as_path(), + default_model: request.model.clone(), + turn_id: Some(request.turn_id.clone()), + }, parse_completed, ) .await; @@ -334,6 +340,7 @@ mod tests { use super::parse_completed; use super::preview; use crate::engine::ConfiguredHandler; + use crate::engine::ConfiguredHandlerKind; use crate::engine::command_runner::CommandRunResult; use crate::events::common; @@ -550,8 +557,10 @@ mod tests { ConfiguredHandler { event_name: HookEventName::PostToolUse, matcher: Some("^Bash$".to_string()), - command: "python3 post_tool_use_hook.py".to_string(), - timeout_sec: 5, + kind: ConfiguredHandlerKind::Command { + command: "python3 post_tool_use_hook.py".to_string(), + timeout_sec: 5, + }, status_message: Some("running post tool use hook".to_string()), source_path: test_path_buf("/tmp/hooks.json").abs(), source: codex_protocol::protocol::HookSource::User, diff --git a/codex-rs/hooks/src/events/pre_tool_use.rs b/codex-rs/hooks/src/events/pre_tool_use.rs index b3579aba82..dd267564fc 100644 --- a/codex-rs/hooks/src/events/pre_tool_use.rs +++ b/codex-rs/hooks/src/events/pre_tool_use.rs @@ -16,6 +16,7 @@ use crate::engine::ConfiguredHandler; use crate::engine::command_runner::CommandRunResult; use crate::engine::dispatcher; use crate::engine::output_parser; +use crate::engine::prompt_runner::PromptHookRunner; use crate::schema::PreToolUseCommandInput; use crate::schema::SubagentCommandInputFields; @@ -71,6 +72,7 @@ pub(crate) fn preview( pub(crate) async fn run( handlers: &[ConfiguredHandler], shell: &CommandShell, + prompt_runner: Option<&PromptHookRunner>, request: PreToolUseRequest, ) -> PreToolUseOutcome { let matcher_inputs = common::matcher_inputs(&request.tool_name, &request.matcher_aliases); @@ -103,11 +105,15 @@ pub(crate) async fn run( }; let results = dispatcher::execute_handlers( - shell, matched, input_json, - request.cwd.as_path(), - Some(request.turn_id.clone()), + dispatcher::HandlerExecutionContext { + shell, + prompt_runner, + cwd: request.cwd.as_path(), + default_model: request.model.clone(), + turn_id: Some(request.turn_id.clone()), + }, parse_completed, ) .await; @@ -329,6 +335,7 @@ mod tests { use super::parse_completed; use super::preview; use crate::engine::ConfiguredHandler; + use crate::engine::ConfiguredHandlerKind; use crate::engine::command_runner::CommandRunResult; use crate::events::common; @@ -742,8 +749,10 @@ mod tests { ConfiguredHandler { event_name: HookEventName::PreToolUse, matcher: Some("^Bash$".to_string()), - command: "echo hook".to_string(), - timeout_sec: 5, + kind: ConfiguredHandlerKind::Command { + command: "echo hook".to_string(), + timeout_sec: 5, + }, status_message: None, source_path: test_path_buf("/tmp/hooks.json").abs(), source: codex_protocol::protocol::HookSource::User, diff --git a/codex-rs/hooks/src/events/session_start.rs b/codex-rs/hooks/src/events/session_start.rs index bd1aa2096f..4c08e71072 100644 --- a/codex-rs/hooks/src/events/session_start.rs +++ b/codex-rs/hooks/src/events/session_start.rs @@ -181,11 +181,15 @@ pub(crate) async fn run( }; let results = dispatcher::execute_handlers( - shell, matched, input_json, - request.cwd.as_path(), - turn_id, + dispatcher::HandlerExecutionContext { + shell, + prompt_runner: None, + cwd: request.cwd.as_path(), + default_model: request.model.clone(), + turn_id, + }, parse_completed, ) .await; @@ -356,6 +360,7 @@ mod tests { use super::SessionStartHandlerData; use super::parse_completed; use crate::engine::ConfiguredHandler; + use crate::engine::ConfiguredHandlerKind; use crate::engine::command_runner::CommandRunResult; #[test] @@ -516,8 +521,10 @@ mod tests { ConfiguredHandler { event_name, matcher: None, - command: "echo hook".to_string(), - timeout_sec: 600, + kind: ConfiguredHandlerKind::Command { + command: "echo hook".to_string(), + timeout_sec: 600, + }, status_message: None, source_path: test_path_buf("/tmp/hooks.json").abs(), source: codex_protocol::protocol::HookSource::User, diff --git a/codex-rs/hooks/src/events/stop.rs b/codex-rs/hooks/src/events/stop.rs index 24920c569b..7c273b8990 100644 --- a/codex-rs/hooks/src/events/stop.rs +++ b/codex-rs/hooks/src/events/stop.rs @@ -16,6 +16,7 @@ use crate::engine::ConfiguredHandler; use crate::engine::command_runner::CommandRunResult; use crate::engine::dispatcher; use crate::engine::output_parser; +use crate::engine::prompt_runner::PromptHookRunner; use crate::schema::NullableString; use crate::schema::StopCommandInput; use crate::schema::SubagentStopCommandInput; @@ -95,6 +96,7 @@ pub(crate) fn preview( pub(crate) async fn run( handlers: &[ConfiguredHandler], shell: &CommandShell, + prompt_runner: Option<&PromptHookRunner>, request: StopRequest, ) -> StopOutcome { let matched = dispatcher::select_handlers( @@ -178,11 +180,15 @@ pub(crate) async fn run( }; let results = dispatcher::execute_handlers( - shell, matched, input_json, - request.cwd.as_path(), - Some(request.turn_id), + dispatcher::HandlerExecutionContext { + shell, + prompt_runner, + cwd: request.cwd.as_path(), + default_model: request.model.clone(), + turn_id: Some(request.turn_id.clone()), + }, parse_completed, ) .await; @@ -433,6 +439,7 @@ mod tests { use super::aggregate_results; use super::parse_completed; use crate::engine::ConfiguredHandler; + use crate::engine::ConfiguredHandlerKind; use crate::engine::command_runner::CommandRunResult; #[test] @@ -632,8 +639,10 @@ mod tests { ConfiguredHandler { event_name: HookEventName::Stop, matcher: None, - command: "echo hook".to_string(), - timeout_sec: 600, + kind: ConfiguredHandlerKind::Command { + command: "echo hook".to_string(), + timeout_sec: 600, + }, status_message: None, source_path: test_path_buf("/tmp/hooks.json").abs(), source: codex_protocol::protocol::HookSource::User, diff --git a/codex-rs/hooks/src/events/user_prompt_submit.rs b/codex-rs/hooks/src/events/user_prompt_submit.rs index 2934bd3523..6733218a51 100644 --- a/codex-rs/hooks/src/events/user_prompt_submit.rs +++ b/codex-rs/hooks/src/events/user_prompt_submit.rs @@ -15,6 +15,7 @@ use crate::engine::ConfiguredHandler; use crate::engine::command_runner::CommandRunResult; use crate::engine::dispatcher; use crate::engine::output_parser; +use crate::engine::prompt_runner::PromptHookRunner; use crate::schema::NullableString; use crate::schema::SubagentCommandInputFields; use crate::schema::UserPromptSubmitCommandInput; @@ -63,6 +64,7 @@ pub(crate) fn preview( pub(crate) async fn run( handlers: &[ConfiguredHandler], shell: &CommandShell, + prompt_runner: Option<&PromptHookRunner>, request: UserPromptSubmitRequest, ) -> UserPromptSubmitOutcome { let matched = dispatcher::select_handlers( @@ -103,11 +105,15 @@ pub(crate) async fn run( }; let results = dispatcher::execute_handlers( - shell, matched, input_json, - request.cwd.as_path(), - Some(request.turn_id), + dispatcher::HandlerExecutionContext { + shell, + prompt_runner, + cwd: request.cwd.as_path(), + default_model: request.model.clone(), + turn_id: Some(request.turn_id.clone()), + }, parse_completed, ) .await; @@ -286,6 +292,7 @@ mod tests { use super::UserPromptSubmitHandlerData; use super::parse_completed; use crate::engine::ConfiguredHandler; + use crate::engine::ConfiguredHandlerKind; use crate::engine::command_runner::CommandRunResult; #[test] @@ -421,8 +428,10 @@ mod tests { ConfiguredHandler { event_name: HookEventName::UserPromptSubmit, matcher: None, - command: "echo hook".to_string(), - timeout_sec: 5, + kind: ConfiguredHandlerKind::Command { + command: "echo hook".to_string(), + timeout_sec: 5, + }, status_message: None, source_path: test_path_buf("/tmp/hooks.json").abs(), source: codex_protocol::protocol::HookSource::User, diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index 11300802d8..17e4fd8e1b 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -14,6 +14,8 @@ pub use config_rules::hook_states_from_stack; pub use declarations::PluginHookDeclaration; pub use declarations::plugin_hook_declarations; pub use engine::HookListEntry; +pub use engine::PromptHookRequest; +pub use engine::PromptHookRunner; pub use events::common::SubagentHookContext; /// Hook event names as they appear in hooks JSON and config files. pub const HOOK_EVENT_NAMES: [&str; 10] = [ diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index 1f4e01aa59..b47094590a 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -5,6 +5,7 @@ use tokio::process::Command; use crate::engine::ClaudeHooksEngine; use crate::engine::CommandShell; use crate::engine::HookListEntry; +use crate::engine::PromptHookRunner; use crate::events::compact::PostCompactRequest; use crate::events::compact::PreCompactOutcome; use crate::events::compact::PreCompactRequest; @@ -36,6 +37,7 @@ pub struct HooksConfig { pub plugin_hook_load_warnings: Vec, pub shell_program: Option, pub shell_args: Vec, + pub prompt_hook_runner: Option, } #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -74,6 +76,7 @@ impl Hooks { program: config.shell_program.unwrap_or_default(), args: config.shell_args, }, + config.prompt_hook_runner, ); Self { after_agent, diff --git a/codex-rs/hooks/src/schema.rs b/codex-rs/hooks/src/schema.rs index d90d8a3e52..e2bfe64689 100644 --- a/codex-rs/hooks/src/schema.rs +++ b/codex-rs/hooks/src/schema.rs @@ -1,3 +1,4 @@ +use codex_protocol::protocol::HookEventName; use schemars::JsonSchema; use schemars::r#gen::SchemaGenerator; use schemars::r#gen::SchemaSettings; @@ -119,6 +120,24 @@ pub(crate) enum HookEventNameWire { Stop, } +/// Wire spelling for hook event names as they appear in hook config, schema +/// fixtures, and user-visible hook warnings. Keep this beside +/// `HookEventNameWire` so schema and display labels change together. +pub(crate) fn hook_event_wire_name(event_name: HookEventName) -> &'static str { + match event_name { + HookEventName::PreToolUse => "PreToolUse", + HookEventName::PermissionRequest => "PermissionRequest", + HookEventName::PostToolUse => "PostToolUse", + HookEventName::PreCompact => "PreCompact", + HookEventName::PostCompact => "PostCompact", + HookEventName::SessionStart => "SessionStart", + HookEventName::UserPromptSubmit => "UserPromptSubmit", + HookEventName::SubagentStart => "SubagentStart", + HookEventName::SubagentStop => "SubagentStop", + HookEventName::Stop => "Stop", + } +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] #[serde(deny_unknown_fields)]