hooks: define UserInstructions execution contract

This commit is contained in:
Adam Perry
2026-07-06 21:19:28 +00:00
parent c976741124
commit 32d15fae79
33 changed files with 621 additions and 7 deletions
+1
View File
@@ -3201,6 +3201,7 @@ dependencies = [
"codex-protocol",
"codex-utils-absolute-path",
"codex-utils-output-truncation",
"codex-utils-path-uri",
"futures",
"pretty_assertions",
"regex",
+1
View File
@@ -1232,6 +1232,7 @@ fn analytics_hook_event_name(event_name: HookEventName) -> &'static str {
HookEventName::PreCompact => "PreCompact",
HookEventName::PostCompact => "PostCompact",
HookEventName::SessionStart => "SessionStart",
HookEventName::UserInstructions => "UserInstructions",
HookEventName::UserPromptSubmit => "UserPromptSubmit",
HookEventName::SubagentStart => "SubagentStart",
HookEventName::SubagentStop => "SubagentStop",
@@ -2005,6 +2005,7 @@
"preCompact",
"postCompact",
"sessionStart",
"userInstructions",
"userPromptSubmit",
"subagentStart",
"subagentStop",
@@ -10798,6 +10798,7 @@
"preCompact",
"postCompact",
"sessionStart",
"userInstructions",
"userPromptSubmit",
"subagentStart",
"subagentStop",
@@ -7149,6 +7149,7 @@
"preCompact",
"postCompact",
"sessionStart",
"userInstructions",
"userPromptSubmit",
"subagentStart",
"subagentStop",
@@ -13,6 +13,7 @@
"preCompact",
"postCompact",
"sessionStart",
"userInstructions",
"userPromptSubmit",
"subagentStart",
"subagentStop",
@@ -13,6 +13,7 @@
"preCompact",
"postCompact",
"sessionStart",
"userInstructions",
"userPromptSubmit",
"subagentStart",
"subagentStop",
@@ -28,6 +28,7 @@
"preCompact",
"postCompact",
"sessionStart",
"userInstructions",
"userPromptSubmit",
"subagentStart",
"subagentStop",
@@ -116,6 +116,7 @@
"preCompact",
"postCompact",
"sessionStart",
"userInstructions",
"userPromptSubmit",
"subagentStart",
"subagentStop",
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "userPromptSubmit" | "subagentStart" | "subagentStop" | "stop";
export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "userInstructions" | "userPromptSubmit" | "subagentStart" | "subagentStop" | "stop";
@@ -17,7 +17,7 @@ use ts_rs::TS;
v2_enum_from_core!(
pub enum HookEventName from CoreHookEventName {
PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, UserPromptSubmit, SubagentStart, SubagentStop, Stop
PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, UserInstructions, UserPromptSubmit, SubagentStart, SubagentStop, Stop
}
);
+1
View File
@@ -703,6 +703,7 @@ fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str);
HookEventName::PreCompact => "PreCompact",
HookEventName::PostCompact => "PostCompact",
HookEventName::SessionStart => "SessionStart",
HookEventName::UserInstructions => "UserInstructions",
HookEventName::UserPromptSubmit => "UserPromptSubmit",
HookEventName::SubagentStart => "SubagentStart",
HookEventName::SubagentStop => "SubagentStop",
+11 -1
View File
@@ -18,6 +18,7 @@ const EXTERNAL_AGENT_HOOKS_SUBDIR: &str = "hooks";
const EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR: &str = "hooks";
const COMMAND_SKILL_PREFIX: &str = "source-command";
const MAX_SKILL_NAME_LEN: usize = 64;
const CODEX_ONLY_HOOK_EVENT_NAME: &str = "UserInstructions";
#[derive(Debug)]
struct ParsedDocument {
@@ -542,7 +543,10 @@ fn append_convertible_hook_groups(
return;
};
for event_name in HOOK_EVENT_NAMES {
for &event_name in HOOK_EVENT_NAMES {
if event_name == CODEX_ONLY_HOOK_EVENT_NAME {
continue;
}
let Some(groups) = hooks_config.get(event_name).and_then(JsonValue::as_array) else {
continue;
};
@@ -1906,6 +1910,12 @@ Review carefully."""
"SubagentStart": [{
"matcher": "worker",
"hooks": [{"type": "prompt", "prompt": "check"}]
}],
"UserInstructions": [{
"hooks": [{
"type": "command",
"command": source_hook_command("instructions.py")
}]
}]
}
});
+1
View File
@@ -19,6 +19,7 @@ codex-config = { workspace = true }
codex-plugin = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-path-uri = { workspace = true }
codex-utils-output-truncation = { workspace = true }
futures = { workspace = true, features = ["alloc"] }
regex = { workspace = true }
@@ -0,0 +1,50 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"definitions": {
"NullableString": {
"type": [
"string",
"null"
]
}
},
"properties": {
"cwd": {
"type": "string"
},
"hook_event_name": {
"const": "UserInstructions",
"type": "string"
},
"model": {
"type": "string"
},
"permission_mode": {
"enum": [
"default",
"acceptEdits",
"plan",
"dontAsk",
"bypassPermissions"
],
"type": "string"
},
"session_id": {
"type": "string"
},
"transcript_path": {
"$ref": "#/definitions/NullableString"
}
},
"required": [
"cwd",
"hook_event_name",
"model",
"permission_mode",
"session_id",
"transcript_path"
],
"title": "user-instructions.command.input",
"type": "object"
}
+6 -2
View File
@@ -61,7 +61,9 @@ pub(crate) fn select_handlers_for_matcher_inputs(
.any(|input| matches_matcher(handler.matcher.as_deref(), Some(input)))
}
}
HookEventName::UserPromptSubmit | HookEventName::Stop => true,
HookEventName::UserInstructions
| HookEventName::UserPromptSubmit
| HookEventName::Stop => true,
})
.cloned()
.collect()
@@ -141,7 +143,9 @@ pub(crate) fn completed_summary(
fn scope_for_event(event_name: HookEventName) -> HookScope {
match event_name {
HookEventName::SessionStart | HookEventName::SubagentStart => HookScope::Thread,
HookEventName::SessionStart
| HookEventName::UserInstructions
| HookEventName::SubagentStart => HookScope::Thread,
HookEventName::PreToolUse
| HookEventName::PermissionRequest
| HookEventName::PostToolUse
+17
View File
@@ -18,6 +18,8 @@ use crate::events::session_start::SessionStartOutcome;
use crate::events::session_start::SessionStartRequest;
use crate::events::stop::StopOutcome;
use crate::events::stop::StopRequest;
use crate::events::user_instructions::UserInstructionsOutcome;
use crate::events::user_instructions::UserInstructionsRequest;
use crate::events::user_prompt_submit::UserPromptSubmitOutcome;
use crate::events::user_prompt_submit::UserPromptSubmitRequest;
use crate::output_spill::HookOutputSpiller;
@@ -69,6 +71,7 @@ impl ConfiguredHandler {
codex_protocol::protocol::HookEventName::PreCompact => "pre-compact",
codex_protocol::protocol::HookEventName::PostCompact => "post-compact",
codex_protocol::protocol::HookEventName::SessionStart => "session-start",
codex_protocol::protocol::HookEventName::UserInstructions => "user-instructions",
codex_protocol::protocol::HookEventName::UserPromptSubmit => "user-prompt-submit",
codex_protocol::protocol::HookEventName::SubagentStart => "subagent-start",
codex_protocol::protocol::HookEventName::SubagentStop => "subagent-stop",
@@ -152,6 +155,13 @@ impl ClaudeHooksEngine {
crate::events::pre_tool_use::preview(&self.handlers, request)
}
pub(crate) fn preview_user_instructions(
&self,
request: &UserInstructionsRequest,
) -> Vec<HookRunSummary> {
crate::events::user_instructions::preview(&self.handlers, request)
}
pub(crate) fn preview_permission_request(
&self,
request: &PermissionRequestRequest,
@@ -180,6 +190,13 @@ impl ClaudeHooksEngine {
outcome
}
pub(crate) async fn run_user_instructions(
&self,
request: UserInstructionsRequest,
) -> UserInstructionsOutcome {
crate::events::user_instructions::run(&self.handlers, &self.shell, request).await
}
pub(crate) async fn run_pre_tool_use(&self, request: PreToolUseRequest) -> PreToolUseOutcome {
let session_id = request.session_id;
let mut outcome =
@@ -16,6 +16,7 @@ pub(crate) struct GeneratedHookSchemas {
pub pre_compact_command_output: Value,
pub session_start_command_input: Value,
pub session_start_command_output: Value,
pub user_instructions_command_input: Value,
pub subagent_start_command_input: Value,
pub subagent_start_command_output: Value,
pub subagent_stop_command_input: Value,
@@ -77,6 +78,10 @@ pub(crate) fn generated_hook_schemas() -> &'static GeneratedHookSchemas {
"session-start.command.output",
include_str!("../../schema/generated/session-start.command.output.schema.json"),
),
user_instructions_command_input: parse_json_schema(
"user-instructions.command.input",
include_str!("../../schema/generated/user-instructions.command.input.schema.json"),
),
subagent_start_command_input: parse_json_schema(
"subagent-start.command.input",
include_str!("../../schema/generated/subagent-start.command.input.schema.json"),
@@ -138,6 +143,7 @@ mod tests {
assert_eq!(schemas.pre_compact_command_output["type"], "object");
assert_eq!(schemas.session_start_command_input["type"], "object");
assert_eq!(schemas.session_start_command_output["type"], "object");
assert_eq!(schemas.user_instructions_command_input["type"], "object");
assert_eq!(schemas.subagent_start_command_input["type"], "object");
assert_eq!(schemas.subagent_start_command_output["type"], "object");
assert_eq!(schemas.subagent_stop_command_input["type"], "object");
+7 -1
View File
@@ -115,7 +115,9 @@ pub(crate) fn matcher_pattern_for_event(
| HookEventName::SubagentStop
| HookEventName::PreCompact
| HookEventName::PostCompact => matcher,
HookEventName::UserPromptSubmit | HookEventName::Stop => None,
HookEventName::UserInstructions | HookEventName::UserPromptSubmit | HookEventName::Stop => {
None
}
}
}
@@ -262,6 +264,10 @@ mod tests {
matcher_pattern_for_event(HookEventName::Stop, Some("^done$")),
None
);
assert_eq!(
matcher_pattern_for_event(HookEventName::UserInstructions, Some("ignored")),
None
);
}
#[test]
+1
View File
@@ -5,4 +5,5 @@ pub mod post_tool_use;
pub mod pre_tool_use;
pub mod session_start;
pub mod stop;
pub mod user_instructions;
pub mod user_prompt_submit;
@@ -0,0 +1,203 @@
//! User-instruction hook execution.
//!
//! Unlike ordinary context-injecting hooks, successful stdout becomes the
//! thread's durable user-instruction snapshot. Exactly one active handler may
//! contribute that snapshot.
use std::path::PathBuf;
use codex_protocol::ThreadId;
use codex_protocol::protocol::HookCompletedEvent;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookOutputEntry;
use codex_protocol::protocol::HookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookRunSummary;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use super::common;
use crate::engine::CommandShell;
use crate::engine::ConfiguredHandler;
use crate::engine::command_runner::CommandRunResult;
use crate::engine::command_runner::run_command;
use crate::engine::dispatcher;
use crate::schema::NullableString;
use crate::schema::UserInstructionsCommandInput;
/// Input supplied to the active `UserInstructions` hook.
#[derive(Debug, Clone)]
pub struct UserInstructionsRequest {
pub session_id: ThreadId,
pub cwd: PathUri,
pub command_cwd: AbsolutePathBuf,
pub transcript_path: Option<PathBuf>,
pub model: String,
pub permission_mode: String,
}
/// User instructions returned by a hook and their runtime-owned provenance.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UserInstructionsResult {
pub text: String,
pub source_path: PathUri,
}
/// Result of resolving and running the active `UserInstructions` hook.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UserInstructionsOutcome {
pub hook_events: Vec<HookCompletedEvent>,
pub result: Option<UserInstructionsResult>,
pub warnings: Vec<String>,
}
pub(crate) fn preview(
handlers: &[ConfiguredHandler],
_request: &UserInstructionsRequest,
) -> Vec<HookRunSummary> {
let matched = dispatcher::select_handlers(
handlers,
HookEventName::UserInstructions,
/*matcher_input*/ None,
);
match matched.as_slice() {
[handler] => vec![dispatcher::running_summary(handler)],
[] | [_, _, ..] => Vec::new(),
}
}
pub(crate) async fn run(
handlers: &[ConfiguredHandler],
shell: &CommandShell,
request: UserInstructionsRequest,
) -> UserInstructionsOutcome {
let mut matched = dispatcher::select_handlers(
handlers,
HookEventName::UserInstructions,
/*matcher_input*/ None,
);
match matched.len() {
0 => return UserInstructionsOutcome::default(),
1 => {}
count => {
return UserInstructionsOutcome {
warnings: vec![format!(
"UserInstructions requires at most one active hook, but found {count}; skipping all UserInstructions hooks"
)],
..Default::default()
};
}
}
let Some(handler) = matched.pop() else {
return UserInstructionsOutcome::default();
};
let input = UserInstructionsCommandInput {
session_id: request.session_id.to_string(),
transcript_path: NullableString::from_path(request.transcript_path),
cwd: request.cwd.inferred_native_path_string(),
hook_event_name: "UserInstructions".to_string(),
model: request.model,
permission_mode: request.permission_mode,
};
let input_json = match serde_json::to_string(&input) {
Ok(input_json) => input_json,
Err(error) => {
let warning = format!("failed to serialize UserInstructions hook input: {error}");
return UserInstructionsOutcome {
hook_events: common::serialization_failure_hook_events(
vec![handler],
/*turn_id*/ None,
warning.clone(),
),
result: None,
warnings: vec![warning],
};
}
};
let run_result = run_command(shell, &handler, &input_json, request.command_cwd.as_path()).await;
parse_completed(&handler, run_result)
}
fn parse_completed(
handler: &ConfiguredHandler,
run_result: CommandRunResult,
) -> UserInstructionsOutcome {
let mut entries = Vec::new();
let mut status = HookRunStatus::Completed;
let mut text = None;
let mut warning = None;
match run_result.error.as_deref() {
Some(error) => {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: error.to_string(),
});
warning = Some(handler_warning(handler, &format!("failed: {error}")));
}
None => match run_result.exit_code {
Some(0) => {
let trimmed = run_result.stdout.trim();
if trimmed.is_empty() {
let message = "returned no instructions";
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Warning,
text: message.to_string(),
});
warning = Some(handler_warning(handler, message));
} else {
text = Some(trimmed.to_string());
}
}
Some(exit_code) => {
status = HookRunStatus::Failed;
let mut message = format!("hook exited with code {exit_code}");
if let Some(stderr) = common::trimmed_non_empty(&run_result.stderr) {
message.push_str(": ");
message.push_str(&stderr);
}
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: message.clone(),
});
warning = Some(handler_warning(handler, &format!("failed: {message}")));
}
None => {
status = HookRunStatus::Failed;
let message = "hook exited without a status code";
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: message.to_string(),
});
warning = Some(handler_warning(handler, &format!("failed: {message}")));
}
},
}
let result = text.map(|text| UserInstructionsResult {
text,
source_path: PathUri::from_abs_path(&handler.source_path),
});
UserInstructionsOutcome {
hook_events: vec![HookCompletedEvent {
turn_id: None,
run: dispatcher::completed_summary(handler, &run_result, status, entries),
}],
result,
warnings: warning.into_iter().collect(),
}
}
fn handler_warning(handler: &ConfiguredHandler, message: &str) -> String {
format!(
"UserInstructions hook from {} {message}",
PathUri::from_abs_path(&handler.source_path)
)
}
#[cfg(test)]
#[path = "user_instructions_tests.rs"]
mod tests;
@@ -0,0 +1,246 @@
use codex_protocol::ThreadId;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookOutputEntry;
use codex_protocol::protocol::HookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookScope;
use codex_protocol::protocol::HookSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
use super::UserInstructionsRequest;
use super::UserInstructionsResult;
use super::parse_completed;
use super::preview;
use super::run;
use crate::engine::CommandShell;
use crate::engine::ConfiguredHandler;
use crate::engine::command_runner::CommandRunResult;
#[test]
fn successful_stdout_is_trimmed_literal_text_without_output_entry() {
let parsed = parse_completed(
&handler(),
run_result(Some(0), " {\"literal\":true}\n", ""),
);
assert_eq!(
parsed.result,
Some(UserInstructionsResult {
text: "{\"literal\":true}".to_string(),
source_path: handler_source_uri(),
})
);
assert_eq!(parsed.warnings, Vec::<String>::new());
assert_eq!(parsed.hook_events[0].turn_id, None);
assert_eq!(parsed.hook_events[0].run.status, HookRunStatus::Completed);
assert_eq!(parsed.hook_events[0].run.entries, Vec::new());
}
#[test]
fn empty_stdout_warns_without_instructions() {
let parsed = parse_completed(&handler(), run_result(Some(0), " \n", ""));
assert_eq!(parsed.result, None);
assert_eq!(
parsed.warnings,
vec![format!(
"UserInstructions hook from {} returned no instructions",
handler_source_uri()
)]
);
assert_eq!(parsed.hook_events[0].run.status, HookRunStatus::Completed);
assert_eq!(
parsed.hook_events[0].run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Warning,
text: "returned no instructions".to_string(),
}]
);
}
#[test]
fn nonzero_exit_warns_and_includes_stderr() {
let parsed = parse_completed(
&handler(),
run_result(Some(7), "ignored", "provider unavailable\n"),
);
assert_eq!(parsed.result, None);
assert_eq!(
parsed.warnings,
vec![format!(
"UserInstructions hook from {} failed: hook exited with code 7: provider unavailable",
handler_source_uri()
)]
);
assert_eq!(parsed.hook_events[0].run.status, HookRunStatus::Failed);
assert_eq!(
parsed.hook_events[0].run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook exited with code 7: provider unavailable".to_string(),
}]
);
}
#[test]
fn timeout_warns_without_instructions() {
let mut result = run_result(/*exit_code*/ None, "ignored", "");
result.error = Some("hook timed out after 1s".to_string());
let parsed = parse_completed(&handler(), result);
assert_eq!(parsed.result, None);
assert_eq!(
parsed.warnings,
vec![format!(
"UserInstructions hook from {} failed: hook timed out after 1s",
handler_source_uri()
)]
);
assert_eq!(parsed.hook_events[0].run.status, HookRunStatus::Failed);
assert_eq!(
parsed.hook_events[0].run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook timed out after 1s".to_string(),
}]
);
}
#[tokio::test]
async fn exactly_one_handler_runs_and_returns_runtime_source() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let cwd = AbsolutePathBuf::try_from(temp_dir.path().to_path_buf()).expect("absolute temp dir");
let command = if cfg!(windows) {
"echo literal instructions"
} else {
"printf ' literal instructions '"
};
let handler = handler_with_command(command);
let request = request(cwd);
let preview_runs = preview(std::slice::from_ref(&handler), &request);
assert_eq!(preview_runs.len(), 1);
assert_eq!(preview_runs[0].scope, HookScope::Thread);
let outcome = run(
std::slice::from_ref(&handler),
&CommandShell {
program: String::new(),
args: Vec::new(),
},
request,
)
.await;
assert_eq!(outcome.warnings, Vec::<String>::new());
assert_eq!(outcome.hook_events.len(), 1);
assert_eq!(outcome.hook_events[0].run.entries, Vec::new());
assert_eq!(
outcome.result,
Some(super::UserInstructionsResult {
text: "literal instructions".to_string(),
source_path: PathUri::from_abs_path(&handler.source_path),
})
);
}
#[tokio::test]
async fn no_handlers_is_unconfigured() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let cwd = AbsolutePathBuf::try_from(temp_dir.path().to_path_buf()).expect("absolute temp dir");
let request = request(cwd);
assert!(preview(&[], &request).is_empty());
assert_eq!(
run(
&[],
&CommandShell {
program: String::new(),
args: Vec::new(),
},
request,
)
.await,
super::UserInstructionsOutcome::default()
);
}
#[tokio::test]
async fn multiple_handlers_warn_without_preview_or_execution() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let cwd = AbsolutePathBuf::try_from(temp_dir.path().to_path_buf()).expect("absolute temp dir");
let handlers = vec![handler(), handler()];
let request = request(cwd);
assert!(preview(&handlers, &request).is_empty());
let outcome = run(
&handlers,
&CommandShell {
program: String::new(),
args: Vec::new(),
},
request,
)
.await;
assert_eq!(outcome.result, None);
assert!(outcome.hook_events.is_empty());
assert_eq!(
outcome.warnings,
vec![
"UserInstructions requires at most one active hook, but found 2; skipping all UserInstructions hooks"
.to_string()
]
);
}
fn handler() -> ConfiguredHandler {
handler_with_command("echo hook")
}
fn handler_source_uri() -> PathUri {
PathUri::from_abs_path(&handler().source_path)
}
fn handler_with_command(command: &str) -> ConfiguredHandler {
ConfiguredHandler {
event_name: HookEventName::UserInstructions,
matcher: None,
command: command.to_string(),
timeout_sec: 600,
status_message: None,
source_path: test_path_buf("/tmp/hooks.json").abs(),
source: HookSource::User,
display_order: 0,
env: std::collections::HashMap::new(),
}
}
fn request(cwd: AbsolutePathBuf) -> UserInstructionsRequest {
UserInstructionsRequest {
session_id: ThreadId::new(),
cwd: PathUri::from_abs_path(&cwd),
command_cwd: cwd,
transcript_path: None,
model: "gpt-test".to_string(),
permission_mode: "default".to_string(),
}
}
fn run_result(exit_code: Option<i32>, stdout: &str, stderr: &str) -> CommandRunResult {
CommandRunResult {
started_at: 1,
completed_at: 2,
duration_ms: 1,
exit_code,
stdout: stdout.to_string(),
stderr: stderr.to_string(),
error: None,
}
}
+6 -1
View File
@@ -16,13 +16,14 @@ pub use declarations::plugin_hook_declarations;
pub use engine::HookListEntry;
pub use events::common::SubagentHookContext;
/// Hook event names as they appear in hooks JSON and config files.
pub const HOOK_EVENT_NAMES: [&str; 10] = [
pub const HOOK_EVENT_NAMES: &[&str] = &[
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"PreCompact",
"PostCompact",
"SessionStart",
"UserInstructions",
"UserPromptSubmit",
"SubagentStart",
"SubagentStop",
@@ -63,6 +64,9 @@ pub use events::session_start::StartHookTarget;
pub use events::stop::StopHookTarget;
pub use events::stop::StopOutcome;
pub use events::stop::StopRequest;
pub use events::user_instructions::UserInstructionsOutcome;
pub use events::user_instructions::UserInstructionsRequest;
pub use events::user_instructions::UserInstructionsResult;
pub use events::user_prompt_submit::UserPromptSubmitOutcome;
pub use events::user_prompt_submit::UserPromptSubmitRequest;
pub use legacy_notify::legacy_notify_json;
@@ -89,6 +93,7 @@ pub fn hook_event_key_label(event_name: HookEventName) -> &'static str {
HookEventName::PreCompact => "pre_compact",
HookEventName::PostCompact => "post_compact",
HookEventName::SessionStart => "session_start",
HookEventName::UserInstructions => "user_instructions",
HookEventName::UserPromptSubmit => "user_prompt_submit",
HookEventName::SubagentStart => "subagent_start",
HookEventName::SubagentStop => "subagent_stop",
+16
View File
@@ -19,6 +19,8 @@ use crate::events::session_start::SessionStartOutcome;
use crate::events::session_start::SessionStartRequest;
use crate::events::stop::StopOutcome;
use crate::events::stop::StopRequest;
use crate::events::user_instructions::UserInstructionsOutcome;
use crate::events::user_instructions::UserInstructionsRequest;
use crate::events::user_prompt_submit::UserPromptSubmitOutcome;
use crate::events::user_prompt_submit::UserPromptSubmitRequest;
use crate::types::Hook;
@@ -120,6 +122,13 @@ impl Hooks {
self.engine.preview_pre_tool_use(request)
}
pub fn preview_user_instructions(
&self,
request: &UserInstructionsRequest,
) -> Vec<codex_protocol::protocol::HookRunSummary> {
self.engine.preview_user_instructions(request)
}
pub fn preview_permission_request(
&self,
request: &PermissionRequestRequest,
@@ -142,6 +151,13 @@ impl Hooks {
self.engine.run_session_start(request, turn_id).await
}
pub async fn run_user_instructions(
&self,
request: UserInstructionsRequest,
) -> UserInstructionsOutcome {
self.engine.run_user_instructions(request).await
}
pub async fn run_pre_tool_use(&self, request: PreToolUseRequest) -> PreToolUseOutcome {
self.engine.run_pre_tool_use(request).await
}
+30
View File
@@ -27,6 +27,7 @@ const PRE_COMPACT_INPUT_FIXTURE: &str = "pre-compact.command.input.schema.json";
const PRE_COMPACT_OUTPUT_FIXTURE: &str = "pre-compact.command.output.schema.json";
const SESSION_START_INPUT_FIXTURE: &str = "session-start.command.input.schema.json";
const SESSION_START_OUTPUT_FIXTURE: &str = "session-start.command.output.schema.json";
const USER_INSTRUCTIONS_INPUT_FIXTURE: &str = "user-instructions.command.input.schema.json";
const USER_PROMPT_SUBMIT_INPUT_FIXTURE: &str = "user-prompt-submit.command.input.schema.json";
const USER_PROMPT_SUBMIT_OUTPUT_FIXTURE: &str = "user-prompt-submit.command.output.schema.json";
const SUBAGENT_START_INPUT_FIXTURE: &str = "subagent-start.command.input.schema.json";
@@ -109,6 +110,8 @@ pub(crate) enum HookEventNameWire {
PostCompact,
#[serde(rename = "SessionStart")]
SessionStart,
#[serde(rename = "UserInstructions")]
UserInstructions,
#[serde(rename = "UserPromptSubmit")]
UserPromptSubmit,
#[serde(rename = "SubagentStart")]
@@ -516,6 +519,20 @@ impl SessionStartCommandInput {
}
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(rename = "user-instructions.command.input")]
pub(crate) struct UserInstructionsCommandInput {
pub(crate) session_id: String,
pub(crate) transcript_path: NullableString,
pub(crate) cwd: String,
#[schemars(schema_with = "user_instructions_hook_event_name_schema")]
pub(crate) hook_event_name: String,
pub(crate) model: String,
#[schemars(schema_with = "permission_mode_schema")]
pub(crate) permission_mode: String,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(rename = "subagent-start.command.input")]
@@ -646,6 +663,10 @@ pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> {
&generated_dir.join(SESSION_START_OUTPUT_FIXTURE),
schema_json::<SessionStartCommandOutputWire>()?,
)?;
write_schema(
&generated_dir.join(USER_INSTRUCTIONS_INPUT_FIXTURE),
schema_json::<UserInstructionsCommandInput>()?,
)?;
write_schema(
&generated_dir.join(USER_PROMPT_SUBMIT_INPUT_FIXTURE),
schema_json::<UserPromptSubmitCommandInput>()?,
@@ -737,6 +758,10 @@ fn session_start_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("SessionStart")
}
fn user_instructions_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("UserInstructions")
}
fn post_tool_use_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("PostToolUse")
}
@@ -853,6 +878,7 @@ mod tests {
use super::SubagentStartCommandInput;
use super::SubagentStartCommandOutputWire;
use super::SubagentStopCommandInput;
use super::USER_INSTRUCTIONS_INPUT_FIXTURE;
use super::USER_PROMPT_SUBMIT_INPUT_FIXTURE;
use super::USER_PROMPT_SUBMIT_OUTPUT_FIXTURE;
use super::UserPromptSubmitCommandInput;
@@ -904,6 +930,9 @@ mod tests {
SESSION_START_OUTPUT_FIXTURE => {
include_str!("../schema/generated/session-start.command.output.schema.json")
}
USER_INSTRUCTIONS_INPUT_FIXTURE => {
include_str!("../schema/generated/user-instructions.command.input.schema.json")
}
USER_PROMPT_SUBMIT_INPUT_FIXTURE => {
include_str!("../schema/generated/user-prompt-submit.command.input.schema.json")
}
@@ -969,6 +998,7 @@ mod tests {
PRE_TOOL_USE_OUTPUT_FIXTURE,
SESSION_START_INPUT_FIXTURE,
SESSION_START_OUTPUT_FIXTURE,
USER_INSTRUCTIONS_INPUT_FIXTURE,
USER_PROMPT_SUBMIT_INPUT_FIXTURE,
USER_PROMPT_SUBMIT_OUTPUT_FIXTURE,
SUBAGENT_START_INPUT_FIXTURE,
+1
View File
@@ -1479,6 +1479,7 @@ pub enum HookEventName {
PreCompact,
PostCompact,
SessionStart,
UserInstructions,
UserPromptSubmit,
SubagentStart,
SubagentStop,
@@ -735,6 +735,7 @@ fn event_label(event_name: HookEventName) -> &'static str {
HookEventName::PreCompact => "PreCompact",
HookEventName::PostCompact => "PostCompact",
HookEventName::SessionStart => "SessionStart",
HookEventName::UserInstructions => "UserInstructions",
HookEventName::UserPromptSubmit => "UserPromptSubmit",
HookEventName::SubagentStart => "SubagentStart",
HookEventName::SubagentStop => "SubagentStop",
@@ -750,6 +751,7 @@ fn event_description(event_name: HookEventName) -> &'static str {
HookEventName::PreCompact => "Before context compaction",
HookEventName::PostCompact => "After context compaction",
HookEventName::SessionStart => "When a new session starts",
HookEventName::UserInstructions => "When user instructions are loaded",
HookEventName::UserPromptSubmit => "When the user submits a prompt",
HookEventName::SubagentStart => "When a subagent is created",
HookEventName::SubagentStop => "Right before a subagent ends its turn",
@@ -13,6 +13,7 @@ expression: "render_lines(&view, 112)"
PreCompact 0 0 Before context compaction
PostCompact 0 0 After context compaction
SessionStart 0 0 When a new session starts
UserInstructions 0 0 When user instructions are loaded
UserPromptSubmit 0 0 When the user submits a prompt
SubagentStart 0 0 When a subagent is created
SubagentStop 0 0 Right before a subagent ends its turn
@@ -17,6 +17,7 @@ expression: "render_lines(&view, 112)"
PreCompact 0 0 Before context compaction
PostCompact 0 0 After context compaction
SessionStart 0 0 When a new session starts
UserInstructions 0 0 When user instructions are loaded
UserPromptSubmit 0 0 When the user submits a prompt
SubagentStart 0 0 When a subagent is created
SubagentStop 0 0 Right before a subagent ends its turn
@@ -15,6 +15,7 @@ expression: "render_lines(&view, 112)"
PreCompact 0 0 0 Before context compaction
PostCompact 0 0 0 After context compaction
SessionStart 0 0 0 When a new session starts
UserInstructions 0 0 0 When user instructions are loaded
UserPromptSubmit 0 0 0 When the user submits a prompt
SubagentStart 0 0 0 When a subagent is created
SubagentStop 0 0 0 Right before a subagent ends its turn
@@ -16,6 +16,7 @@ expression: popup
PreCompact 0 0 Before context compaction
PostCompact 0 0 After context compaction
SessionStart 0 0 When a new session starts
UserInstructions 0 0 When user instructions are loaded
UserPromptSubmit 0 0 When the user submits a prompt
SubagentStart 0 0 When a subagent is created
SubagentStop 0 0 Right before a subagent ends its turn
@@ -1682,6 +1682,7 @@ fn hook_event_label(event_name: codex_app_server_protocol::HookEventName) -> &'s
codex_app_server_protocol::HookEventName::PreCompact => "PreCompact",
codex_app_server_protocol::HookEventName::PostCompact => "PostCompact",
codex_app_server_protocol::HookEventName::SessionStart => "SessionStart",
codex_app_server_protocol::HookEventName::UserInstructions => "UserInstructions",
codex_app_server_protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit",
codex_app_server_protocol::HookEventName::SubagentStart => "SubagentStart",
codex_app_server_protocol::HookEventName::SubagentStop => "SubagentStop",
@@ -819,6 +819,7 @@ fn hook_event_label(event_name: HookEventName) -> &'static str {
HookEventName::PreCompact => "PreCompact",
HookEventName::PostCompact => "PostCompact",
HookEventName::SessionStart => "SessionStart",
HookEventName::UserInstructions => "UserInstructions",
HookEventName::UserPromptSubmit => "UserPromptSubmit",
HookEventName::SubagentStart => "SubagentStart",
HookEventName::SubagentStop => "SubagentStop",