mirror of
https://github.com/openai/codex.git
synced 2026-09-05 15:18:41 +00:00
Add prompt hook runtime
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String>,
|
||||
display_order: &mut i64,
|
||||
source: &HookHandlerSource<'_>,
|
||||
event_name: codex_protocol::protocol::HookEventName,
|
||||
event_name: HookEventName,
|
||||
groups: Vec<MatcherGroup>,
|
||||
) {
|
||||
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::<String>::new());
|
||||
assert_eq!(handlers.len(), 1);
|
||||
assert_eq!(
|
||||
handlers[0].command,
|
||||
handlers[0].command().expect("command handler"),
|
||||
if cfg!(windows) {
|
||||
"echo windows"
|
||||
} else {
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
pub(crate) async fn execute_handlers<T>(
|
||||
shell: &CommandShell,
|
||||
handlers: Vec<ConfiguredHandler>,
|
||||
input_json: String,
|
||||
cwd: &Path,
|
||||
turn_id: Option<String>,
|
||||
context: HandlerExecutionContext<'_>,
|
||||
parse: fn(&ConfiguredHandler, CommandRunResult, Option<String>) -> ParsedHandler<T>,
|
||||
) -> Vec<ParsedHandler<T>> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
pub command: String,
|
||||
pub timeout_sec: u64,
|
||||
pub kind: ConfiguredHandlerKind,
|
||||
pub status_message: Option<String>,
|
||||
pub source_path: AbsolutePathBuf,
|
||||
pub source: HookSource,
|
||||
@@ -51,6 +54,24 @@ pub(crate) struct ConfiguredHandler {
|
||||
pub env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
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<ConfiguredHandler>,
|
||||
warnings: Vec<String>,
|
||||
shell: CommandShell,
|
||||
prompt_hook_runner: Option<PromptHookRunner>,
|
||||
output_spiller: HookOutputSpiller,
|
||||
}
|
||||
|
||||
@@ -112,12 +156,14 @@ impl ClaudeHooksEngine {
|
||||
plugin_hook_sources: Vec<PluginHookSource>,
|
||||
plugin_hook_load_warnings: Vec<String>,
|
||||
shell: CommandShell,
|
||||
prompt_hook_runner: Option<PromptHookRunner>,
|
||||
) -> 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;
|
||||
|
||||
@@ -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<_>>(),
|
||||
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()]);
|
||||
|
||||
273
codex-rs/hooks/src/engine/prompt_runner.rs
Normal file
273
codex-rs/hooks/src/engine/prompt_runner.rs
Normal file
@@ -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<dyn Fn(PromptHookRequest) -> BoxFuture<'static, anyhow::Result<String>> + Send + Sync>,
|
||||
}
|
||||
|
||||
impl PromptHookRunner {
|
||||
pub fn new<F, Fut>(run: F) -> Self
|
||||
where
|
||||
F: Fn(PromptHookRequest) -> Fut + Send + Sync + 'static,
|
||||
Fut: Future<Output = anyhow::Result<String>> + Send + 'static,
|
||||
{
|
||||
Self {
|
||||
run: Arc::new(move |request| Box::pin(run(request))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(&self, request: PromptHookRequest) -> anyhow::Result<String> {
|
||||
(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<String>,
|
||||
}
|
||||
|
||||
/// 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<String, String> {
|
||||
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<String, String> {
|
||||
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<i32>,
|
||||
stdout: String,
|
||||
error: Option<String>,
|
||||
) -> 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;
|
||||
128
codex-rs/hooks/src/engine/prompt_runner_tests.rs
Normal file
128
codex-rs/hooks/src/engine/prompt_runner_tests.rs
Normal file
@@ -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<String>) -> 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(),
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] = [
|
||||
|
||||
@@ -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<String>,
|
||||
pub shell_program: Option<String>,
|
||||
pub shell_args: Vec<String>,
|
||||
pub prompt_hook_runner: Option<PromptHookRunner>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user