diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index a0f893b7f8..729da661a5 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 0fe34feff3..d669e830f2 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -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" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index 4e35f7aead..8725a44c77 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json index c3288ee0fd..dd2891da1a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json @@ -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" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookHandler.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookHandler.ts index 42b05cf8cc..76d5f1ecc6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookHandler.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookHandler.ts @@ -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", }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts index 94e3c30c92..d0d97c51ce 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 24d574a935..4c9c9e0868 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -484,7 +484,19 @@ pub enum ConfiguredHookHandler { }, #[serde(rename = "prompt")] #[ts(rename = "prompt")] - Prompt {}, + Prompt { + prompt: String, + model: Option, + #[serde(rename = "timeoutSec")] + #[ts(rename = "timeoutSec")] + timeout_sec: Option, + #[serde(rename = "statusMessage")] + #[ts(rename = "statusMessage")] + status_message: Option, + #[serde(rename = "continueOnBlock")] + #[ts(rename = "continueOnBlock")] + continue_on_block: bool, + }, #[serde(rename = "agent")] #[ts(rename = "agent")] Agent {}, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs index 9bd53b5d1a..26578ff4dd 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -514,6 +514,9 @@ pub struct HookMetadata { pub handler_type: HookHandlerType, pub matcher: Option, pub command: Option, + pub prompt: Option, + pub model: Option, + pub continue_on_block: Option, pub timeout_sec: u64, pub status_message: Option, pub source_path: AbsolutePathBuf, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 6280cf5204..afef87af4f 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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", diff --git a/codex-rs/app-server/src/request_processors/catalog_processor.rs b/codex-rs/app-server/src/request_processors/catalog_processor.rs index 6cb4385151..9ea890905e 100644 --- a/codex-rs/app-server/src/request_processors/catalog_processor.rs +++ b/codex-rs/app-server/src/request_processors/catalog_processor.rs @@ -70,6 +70,9 @@ fn hooks_to_info(hooks: &[codex_hooks::HookListEntry]) -> Vec { 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(), diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 01a80264b6..9a9ac09975 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -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 {}, } } diff --git a/codex-rs/app-server/tests/suite/v2/hooks_list.rs b/codex-rs/app-server/tests/suite/v2/hooks_list.rs index 35b19addd8..b365930088 100644 --- a/codex-rs/app-server/tests/suite/v2/hooks_list.rs +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -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, diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs index bd6e25f7ca..d117cc88cd 100644 --- a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -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::(); + 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::(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_prompt_hook_definition.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_prompt_hook_definition.snap new file mode 100644 index 0000000000..8f73fd7cbc --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_prompt_hook_definition.snap @@ -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 diff --git a/codex-rs/tui/src/startup_hooks_review.rs b/codex-rs/tui/src/startup_hooks_review.rs index 97c53f7144..060ead1b1d 100644 --- a/codex-rs/tui/src/startup_hooks_review.rs +++ b/codex-rs/tui/src/startup_hooks_review.rs @@ -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(),