Expose prompt hooks to clients

This commit is contained in:
Abhinav Vedmala
2026-07-09 18:35:42 -07:00
parent 9d14b483ec
commit 4bd15dec58
15 changed files with 369 additions and 32 deletions
@@ -8609,6 +8609,32 @@
},
{
"properties": {
"continueOnBlock": {
"type": "boolean"
},
"model": {
"type": [
"string",
"null"
]
},
"prompt": {
"type": "string"
},
"statusMessage": {
"type": [
"string",
"null"
]
},
"timeoutSec": {
"format": "uint64",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"type": {
"enum": [
"prompt"
@@ -8618,6 +8644,8 @@
}
},
"required": [
"continueOnBlock",
"prompt",
"type"
],
"title": "PromptConfiguredHookHandler",
@@ -10836,6 +10864,12 @@
"null"
]
},
"continueOnBlock": {
"type": [
"boolean",
"null"
]
},
"currentHash": {
"type": "string"
},
@@ -10864,12 +10898,24 @@
"null"
]
},
"model": {
"type": [
"string",
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"prompt": {
"type": [
"string",
"null"
]
},
"source": {
"$ref": "#/definitions/v2/HookSource"
},
@@ -4849,6 +4849,32 @@
},
{
"properties": {
"continueOnBlock": {
"type": "boolean"
},
"model": {
"type": [
"string",
"null"
]
},
"prompt": {
"type": "string"
},
"statusMessage": {
"type": [
"string",
"null"
]
},
"timeoutSec": {
"format": "uint64",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"type": {
"enum": [
"prompt"
@@ -4858,6 +4884,8 @@
}
},
"required": [
"continueOnBlock",
"prompt",
"type"
],
"title": "PromptConfiguredHookHandler",
@@ -7187,6 +7215,12 @@
"null"
]
},
"continueOnBlock": {
"type": [
"boolean",
"null"
]
},
"currentHash": {
"type": "string"
},
@@ -7215,12 +7249,24 @@
"null"
]
},
"model": {
"type": [
"string",
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"prompt": {
"type": [
"string",
"null"
]
},
"source": {
"$ref": "#/definitions/HookSource"
},
@@ -231,6 +231,32 @@
},
{
"properties": {
"continueOnBlock": {
"type": "boolean"
},
"model": {
"type": [
"string",
"null"
]
},
"prompt": {
"type": "string"
},
"statusMessage": {
"type": [
"string",
"null"
]
},
"timeoutSec": {
"format": "uint64",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"type": {
"enum": [
"prompt"
@@ -240,6 +266,8 @@
}
},
"required": [
"continueOnBlock",
"prompt",
"type"
],
"title": "PromptConfiguredHookHandler",
@@ -51,6 +51,12 @@
"null"
]
},
"continueOnBlock": {
"type": [
"boolean",
"null"
]
},
"currentHash": {
"type": "string"
},
@@ -79,12 +85,24 @@
"null"
]
},
"model": {
"type": [
"string",
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"prompt": {
"type": [
"string",
"null"
]
},
"source": {
"$ref": "#/definitions/HookSource"
},
@@ -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 ConfiguredHookHandler = { "type": "command", command: string, commandWindows: string | null, timeoutSec: bigint | null, async: boolean, statusMessage: string | null, } | { "type": "prompt", } | { "type": "agent", };
export type ConfiguredHookHandler = { "type": "command", command: string, commandWindows: string | null, timeoutSec: bigint | null, async: boolean, statusMessage: string | null, } | { "type": "prompt", prompt: string, model: string | null, timeoutSec: bigint | null, statusMessage: string | null, continueOnBlock: boolean, } | { "type": "agent", };
@@ -7,4 +7,4 @@ import type { HookHandlerType } from "./HookHandlerType";
import type { HookSource } from "./HookSource";
import type { HookTrustStatus } from "./HookTrustStatus";
export type HookMetadata = { key: string, eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, enabled: boolean, isManaged: boolean, currentHash: string, trustStatus: HookTrustStatus, };
export type HookMetadata = { key: string, eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, prompt: string | null, model: string | null, continueOnBlock: boolean | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, enabled: boolean, isManaged: boolean, currentHash: string, trustStatus: HookTrustStatus, };
@@ -484,7 +484,19 @@ pub enum ConfiguredHookHandler {
},
#[serde(rename = "prompt")]
#[ts(rename = "prompt")]
Prompt {},
Prompt {
prompt: String,
model: Option<String>,
#[serde(rename = "timeoutSec")]
#[ts(rename = "timeoutSec")]
timeout_sec: Option<u64>,
#[serde(rename = "statusMessage")]
#[ts(rename = "statusMessage")]
status_message: Option<String>,
#[serde(rename = "continueOnBlock")]
#[ts(rename = "continueOnBlock")]
continue_on_block: bool,
},
#[serde(rename = "agent")]
#[ts(rename = "agent")]
Agent {},
@@ -514,6 +514,9 @@ pub struct HookMetadata {
pub handler_type: HookHandlerType,
pub matcher: Option<String>,
pub command: Option<String>,
pub prompt: Option<String>,
pub model: Option<String>,
pub continue_on_block: Option<bool>,
pub timeout_sec: u64,
pub status_message: Option<String>,
pub source_path: AbsolutePathBuf,
+3
View File
@@ -1724,6 +1724,8 @@ Hooks are returned even when disabled so clients can render and re-enable them.
For unmanaged hooks, `currentHash` and `trustStatus` describe whether the current definition is first-seen, approved, or changed since approval. Only trusted unmanaged hooks become runnable. Hook keys combine the source identity with a trailing event/group/handler selector that is currently positional.
Command hooks expose their definition in `command`, while prompt hooks expose it in `prompt`. The unused field is `null`.
```json
{
"method": "hooks/list",
@@ -1747,6 +1749,7 @@ For unmanaged hooks, `currentHash` and `trustStatus` describe whether the curren
"isManaged": false,
"matcher": "Bash",
"command": "python3 /Users/me/hook.py",
"prompt": null,
"timeoutSec": 5,
"statusMessage": "running hook",
"sourcePath": "/Users/me/.codex/config.toml",
@@ -70,6 +70,9 @@ fn hooks_to_info(hooks: &[codex_hooks::HookListEntry]) -> Vec<HookMetadata> {
handler_type: hook.handler_type.into(),
matcher: hook.matcher.clone(),
command: hook.command.clone(),
prompt: hook.prompt.clone(),
model: hook.model.clone(),
continue_on_block: hook.continue_on_block,
timeout_sec: hook.timeout_sec,
status_message: hook.status_message.clone(),
source_path: hook.source_path.clone(),
@@ -465,7 +465,19 @@ fn map_hook_handler_to_api(handler: CoreHookHandlerConfig) -> ConfiguredHookHand
r#async,
status_message,
},
CoreHookHandlerConfig::Prompt { .. } => ConfiguredHookHandler::Prompt {},
CoreHookHandlerConfig::Prompt {
prompt,
model,
timeout_sec,
status_message,
continue_on_block,
} => ConfiguredHookHandler::Prompt {
prompt,
model,
timeout_sec,
status_message,
continue_on_block,
},
CoreHookHandlerConfig::Agent {} => ConfiguredHookHandler::Agent {},
}
}
+105 -24
View File
@@ -67,6 +67,34 @@ fn command_hook_hash(
codex_config::version_for_toml(&value)
}
fn prompt_hook_hash(
event_name: &'static str,
matcher: Option<&str>,
prompt: &str,
model: Option<&str>,
timeout_sec: u64,
status_message: Option<&str>,
continue_on_block: bool,
) -> String {
let identity = NormalizedHookIdentity {
event_name,
group: codex_config::MatcherGroup {
matcher: matcher.map(ToOwned::to_owned),
hooks: vec![codex_config::HookHandlerConfig::Prompt {
prompt: prompt.to_string(),
model: model.map(ToOwned::to_owned),
timeout_sec: Some(timeout_sec),
status_message: status_message.map(ToOwned::to_owned),
continue_on_block,
}],
},
};
let Ok(value) = codex_config::TomlValue::try_from(identity) else {
unreachable!("normalized hook identity should serialize to TOML");
};
codex_config::version_for_toml(&value)
}
fn write_user_hook_config(codex_home: &std::path::Path) -> Result<()> {
std::fs::write(
codex_home.join("config.toml"),
@@ -166,6 +194,9 @@ async fn hooks_list_shows_discovered_hook() -> Result<()> {
handler_type: HookHandlerType::Command,
matcher: Some("Bash".to_string()),
command: Some("python3 /tmp/listed-hook.py".to_string()),
prompt: None,
model: None,
continue_on_block: None,
timeout_sec: 5,
status_message: Some("running listed hook".to_string()),
source_path: config_path,
@@ -191,7 +222,7 @@ async fn hooks_list_shows_discovered_hook() -> Result<()> {
}
#[tokio::test]
async fn hooks_list_shows_discovered_plugin_hook() -> Result<()> {
async fn hooks_list_shows_discovered_plugin_hooks() -> Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
write_plugin_hook_config(
@@ -210,6 +241,20 @@ async fn hooks_list_shows_discovered_plugin_hook() -> Result<()> {
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Reject prompts that mention secrets: $ARGUMENTS",
"model": "gpt-5-mini",
"timeout": 30,
"statusMessage": "checking prompt",
"continueOnBlock": true
}
]
}
]
}
}"#,
@@ -242,29 +287,62 @@ async fn hooks_list_shows_discovered_plugin_hook() -> Result<()> {
data,
vec![HooksListEntry {
cwd: cwd.path().to_path_buf(),
hooks: vec![HookMetadata {
key: "demo@test:hooks/hooks.json:pre_tool_use:0:0".to_string(),
event_name: HookEventName::PreToolUse,
handler_type: HookHandlerType::Command,
matcher: Some("Bash".to_string()),
command: Some("echo plugin hook".to_string()),
timeout_sec: 7,
status_message: Some("running plugin hook".to_string()),
source_path: plugin_hooks_path,
source: HookSource::Plugin,
plugin_id: Some("demo@test".to_string()),
display_order: 0,
enabled: true,
is_managed: false,
current_hash: command_hook_hash(
"pre_tool_use",
Some("Bash"),
"echo plugin hook",
/*timeout_sec*/ 7,
Some("running plugin hook"),
),
trust_status: HookTrustStatus::Untrusted,
}],
hooks: vec![
HookMetadata {
key: "demo@test:hooks/hooks.json:pre_tool_use:0:0".to_string(),
event_name: HookEventName::PreToolUse,
handler_type: HookHandlerType::Command,
matcher: Some("Bash".to_string()),
command: Some("echo plugin hook".to_string()),
prompt: None,
model: None,
continue_on_block: None,
timeout_sec: 7,
status_message: Some("running plugin hook".to_string()),
source_path: plugin_hooks_path.clone(),
source: HookSource::Plugin,
plugin_id: Some("demo@test".to_string()),
display_order: 0,
enabled: true,
is_managed: false,
current_hash: command_hook_hash(
"pre_tool_use",
Some("Bash"),
"echo plugin hook",
/*timeout_sec*/ 7,
Some("running plugin hook"),
),
trust_status: HookTrustStatus::Untrusted,
},
HookMetadata {
key: "demo@test:hooks/hooks.json:user_prompt_submit:0:0".to_string(),
event_name: HookEventName::UserPromptSubmit,
handler_type: HookHandlerType::Prompt,
matcher: None,
command: None,
prompt: Some("Reject prompts that mention secrets: $ARGUMENTS".to_string()),
model: Some("gpt-5-mini".to_string()),
continue_on_block: Some(true),
timeout_sec: 30,
status_message: Some("checking prompt".to_string()),
source_path: plugin_hooks_path,
source: HookSource::Plugin,
plugin_id: Some("demo@test".to_string()),
display_order: 1,
enabled: true,
is_managed: false,
current_hash: prompt_hook_hash(
"user_prompt_submit",
/*matcher*/ None,
"Reject prompts that mention secrets: $ARGUMENTS",
Some("gpt-5-mini"),
/*timeout_sec*/ 30,
Some("checking prompt"),
/*continue_on_block*/ true,
),
trust_status: HookTrustStatus::Untrusted,
},
],
warnings: Vec::new(),
errors: Vec::new(),
}]
@@ -462,6 +540,9 @@ timeout = 5
handler_type: HookHandlerType::Command,
matcher: Some("Bash".to_string()),
command: Some("echo project hook".to_string()),
prompt: None,
model: None,
continue_on_block: None,
timeout_sec: 5,
status_message: None,
source_path: project_config_path,
@@ -1,4 +1,5 @@
use codex_app_server_protocol::HookEventName;
use codex_app_server_protocol::HookHandlerType;
use codex_app_server_protocol::HookMetadata;
use codex_app_server_protocol::HookSource;
use codex_app_server_protocol::HookTrustStatus;
@@ -38,7 +39,7 @@ use crate::style::accent_style;
const EVENT_COLUMN_WIDTH: usize = 22;
const COUNT_COLUMN_WIDTH: usize = 12;
const MAX_COMMAND_DETAIL_LINES: usize = 3;
const MAX_HANDLER_DEFINITION_DETAIL_LINES: usize = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HooksBrowserPage {
@@ -485,12 +486,27 @@ impl HooksBrowserView {
width,
/*max_lines*/ None,
));
let (definition_label, definition) = hook_definition(hook);
lines.extend(detail_wrapped_lines(
"Command",
hook.command.as_deref().unwrap_or("-"),
definition_label,
definition,
width,
Some(MAX_COMMAND_DETAIL_LINES),
Some(MAX_HANDLER_DEFINITION_DETAIL_LINES),
));
if hook.handler_type == HookHandlerType::Prompt {
lines.push(detail_line(
"Model",
hook.model.as_deref().unwrap_or("default"),
));
lines.push(detail_line(
"On block",
if hook.continue_on_block.unwrap_or(false) {
"continue"
} else {
"stop"
},
));
}
lines.push(detail_line("Timeout", &format!("{}s", hook.timeout_sec)));
lines.push(detail_line("Trust", hook_trust_label(hook.trust_status)));
lines
@@ -727,6 +743,14 @@ fn hook_trust_label(status: HookTrustStatus) -> &'static str {
}
}
fn hook_definition(hook: &HookMetadata) -> (&'static str, &str) {
match hook.handler_type {
HookHandlerType::Command => ("Command", hook.command.as_deref().unwrap_or("-")),
HookHandlerType::Prompt => ("Prompt", hook.prompt.as_deref().unwrap_or("-")),
HookHandlerType::Agent => ("Agent", "-"),
}
}
fn event_label(event_name: HookEventName) -> &'static str {
match event_name {
HookEventName::PreToolUse => "PreToolUse",
@@ -932,6 +956,9 @@ mod tests {
is_managed,
matcher: Some("Bash".to_string()),
command: Some(command.to_string()),
prompt: None,
model: None,
continue_on_block: None,
timeout_sec: 30,
status_message: None,
source_path: test_path_buf("/tmp/hooks.json").abs(),
@@ -1267,6 +1294,41 @@ mod tests {
);
}
#[test]
fn renders_prompt_hook_definition() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let mut prompt_hook = hook(
"path:prompt-hook",
HookEventName::PostToolUse,
HookSource::User,
/*plugin_id*/ None,
/*command*/ "",
/*enabled*/ true,
/*is_managed*/ false,
/*display_order*/ 0,
);
prompt_hook.handler_type = HookHandlerType::Prompt;
prompt_hook.command = None;
prompt_hook.prompt = Some("Reject prompts that mention secrets: $ARGUMENTS".to_string());
prompt_hook.model = Some("gpt-5.1".to_string());
prompt_hook.continue_on_block = Some(true);
let mut view = HooksBrowserView::new(
vec![prompt_hook],
Vec::new(),
Vec::new(),
AppEventSender::new(tx_raw),
);
view.state.selected_idx = view
.event_rows()
.iter()
.position(|row| row.event_name == HookEventName::PostToolUse);
view.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_snapshot!(
"hooks_browser_prompt_hook_definition",
render_lines(&view, /*width*/ 112)
);
}
#[test]
fn renders_empty_handler_browser_message() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
@@ -0,0 +1,20 @@
---
source: tui/src/bottom_pane/hooks_browser_view.rs
expression: "render_lines(&view, 112)"
---
PostToolUse hooks
Turn hooks on or off. Your changes are saved automatically.
[x] Hook 1
Event PostToolUse
Matcher Bash
Source User config - /tmp/hooks.json
Prompt Reject prompts that mention secrets: $ARGUMENTS
Model gpt-5.1
On block continue
Timeout 30s
Trust Trusted
Press space or enter to toggle; esc to go back
+3
View File
@@ -309,6 +309,9 @@ mod tests {
is_managed: false,
matcher: Some("Bash".to_string()),
command: Some("/tmp/hook.sh".to_string()),
prompt: None,
model: None,
continue_on_block: None,
timeout_sec: 30,
status_message: None,
source_path: test_path_buf("/tmp/hooks.json").abs(),