Add prompt hook runtime

This commit is contained in:
Abhinav Vedmala
2026-06-03 16:15:09 -07:00
parent d36a3ead3c
commit d546866b45
18 changed files with 699 additions and 78 deletions

View File

@@ -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,
})
}

View File

@@ -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

View File

@@ -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 {

View File

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

View File

@@ -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;

View File

@@ -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()]);

View 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;

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

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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] = [

View File

@@ -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,

View File

@@ -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)]