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 b2521489c28f..a1a980fd2e67 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 @@ -11781,6 +11781,12 @@ "null" ] }, + "hooks": { + "items": { + "$ref": "#/definitions/v2/PluginHookSummary" + }, + "type": "array" + }, "marketplaceName": { "type": "string" }, @@ -11812,6 +11818,7 @@ }, "required": [ "apps", + "hooks", "marketplaceName", "mcpServers", "skills", @@ -11819,6 +11826,44 @@ ], "type": "object" }, + "PluginHookSummary": { + "properties": { + "definition": true, + "displayOrder": { + "format": "int64", + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "eventName": { + "$ref": "#/definitions/v2/HookEventName" + }, + "key": { + "type": "string" + }, + "matcher": { + "type": [ + "string", + "null" + ] + }, + "statusMessage": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "definition", + "displayOrder", + "enabled", + "eventName", + "key" + ], + "type": "object" + }, "PluginInstallParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { 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 b3b1f098d3b7..b60c690c588e 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 @@ -8435,6 +8435,12 @@ "null" ] }, + "hooks": { + "items": { + "$ref": "#/definitions/PluginHookSummary" + }, + "type": "array" + }, "marketplaceName": { "type": "string" }, @@ -8466,6 +8472,7 @@ }, "required": [ "apps", + "hooks", "marketplaceName", "mcpServers", "skills", @@ -8473,6 +8480,44 @@ ], "type": "object" }, + "PluginHookSummary": { + "properties": { + "definition": true, + "displayOrder": { + "format": "int64", + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "eventName": { + "$ref": "#/definitions/HookEventName" + }, + "key": { + "type": "string" + }, + "matcher": { + "type": [ + "string", + "null" + ] + }, + "statusMessage": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "definition", + "displayOrder", + "enabled", + "eventName", + "key" + ], + "type": "object" + }, "PluginInstallParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index 6e04ef74b00f..b4f2dcc8eb24 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -37,6 +37,17 @@ ], "type": "object" }, + "HookEventName": { + "enum": [ + "preToolUse", + "permissionRequest", + "postToolUse", + "sessionStart", + "userPromptSubmit", + "stop" + ], + "type": "string" + }, "PluginAuthPolicy": { "enum": [ "ON_INSTALL", @@ -58,6 +69,12 @@ "null" ] }, + "hooks": { + "items": { + "$ref": "#/definitions/PluginHookSummary" + }, + "type": "array" + }, "marketplaceName": { "type": "string" }, @@ -89,6 +106,7 @@ }, "required": [ "apps", + "hooks", "marketplaceName", "mcpServers", "skills", @@ -96,6 +114,44 @@ ], "type": "object" }, + "PluginHookSummary": { + "properties": { + "definition": true, + "displayOrder": { + "format": "int64", + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "eventName": { + "$ref": "#/definitions/HookEventName" + }, + "key": { + "type": "string" + }, + "matcher": { + "type": [ + "string", + "null" + ] + }, + "statusMessage": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "definition", + "displayOrder", + "enabled", + "eventName", + "key" + ], + "type": "object" + }, "PluginInstallPolicy": { "enum": [ "NOT_AVAILABLE", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts index eb0f38caa6a1..64836c87f7cc 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts @@ -3,7 +3,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { AppSummary } from "./AppSummary"; +import type { PluginHookSummary } from "./PluginHookSummary"; import type { PluginSummary } from "./PluginSummary"; import type { SkillSummary } from "./SkillSummary"; -export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, description: string | null, skills: Array, apps: Array, mcpServers: Array, }; +export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, description: string | null, skills: Array, hooks: Array, apps: Array, mcpServers: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginHookSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginHookSummary.ts new file mode 100644 index 000000000000..1c11387c96d3 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginHookSummary.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "../serde_json/JsonValue"; +import type { HookEventName } from "./HookEventName"; + +export type PluginHookSummary = { key: string, eventName: HookEventName, matcher: string | null, enabled: boolean, statusMessage: string | null, definition: JsonValue, displayOrder: bigint, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index c3bad69e7926..875a0c46ce68 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -267,6 +267,7 @@ export type { PermissionsRequestApprovalResponse } from "./PermissionsRequestApp export type { PlanDeltaNotification } from "./PlanDeltaNotification"; export type { PluginAuthPolicy } from "./PluginAuthPolicy"; export type { PluginDetail } from "./PluginDetail"; +export type { PluginHookSummary } from "./PluginHookSummary"; export type { PluginInstallParams } from "./PluginInstallParams"; export type { PluginInstallPolicy } from "./PluginInstallPolicy"; export type { PluginInstallResponse } from "./PluginInstallResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 30ee3323eabe..af3e7f709c69 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4678,10 +4678,24 @@ pub struct PluginDetail { pub summary: PluginSummary, pub description: Option, pub skills: Vec, + pub hooks: Vec, pub apps: Vec, pub mcp_servers: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginHookSummary { + pub key: String, + pub event_name: HookEventName, + pub matcher: Option, + pub enabled: bool, + pub status_message: Option, + pub definition: JsonValue, + pub display_order: i64, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 7a5ce38624b4..67711492bec9 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -201,7 +201,7 @@ Example with notification opt-out: - `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists. - `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors. - `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**). -- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**). +- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/hooks/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering; plugin hooks are returned one row per bundled handler so clients can render handler-level details directly. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**). - `skills/changed` — notification emitted when watched local skill files change. - `app/list` — list available apps. - `device/key/create` — create or load a controller-local device signing key for an account/client binding. This local-key API is available only over local transports such as stdio and in-process; remote transports reject it. Hardware-backed providers are the target protection class; an OS-protected non-extractable fallback is allowed only with `protectionPolicy: "allow_os_protected_nonextractable"` and returns the reported `protectionClass`. diff --git a/codex-rs/app-server/src/codex_message_processor/plugins.rs b/codex-rs/app-server/src/codex_message_processor/plugins.rs index db8ade8c673f..33a7aaac87f7 100644 --- a/codex-rs/app-server/src/codex_message_processor/plugins.rs +++ b/codex-rs/app-server/src/codex_message_processor/plugins.rs @@ -243,6 +243,20 @@ impl CodexMessageProcessor { &visible_skills, &outcome.plugin.disabled_skill_paths, ), + hooks: outcome + .plugin + .hooks + .into_iter() + .map(|hook| codex_app_server_protocol::PluginHookSummary { + key: hook.key, + event_name: hook.event_name.into(), + matcher: hook.matcher, + enabled: hook.enabled, + status_message: hook.status_message, + definition: hook.definition, + display_order: hook.display_order, + }) + .collect(), apps: app_summaries, mcp_servers: outcome.plugin.mcp_server_names, } @@ -753,6 +767,7 @@ fn remote_plugin_detail_to_info( enabled: skill.enabled, }) .collect(), + hooks: Vec::new(), apps, mcp_servers: Vec::new(), } diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 753468345ffe..9e043bf75551 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -17,6 +17,7 @@ use axum::http::Uri; use axum::http::header::AUTHORIZATION; use axum::routing::get; use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::HookEventName; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallPolicy; @@ -487,6 +488,7 @@ async fn plugin_read_returns_plugin_details_with_bundle_contents() -> Result<()> std::fs::create_dir_all(repo_root.path().join(".git"))?; std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::create_dir_all(plugin_root.join("hooks"))?; std::fs::create_dir_all(plugin_root.join("skills/thread-summarizer"))?; std::fs::create_dir_all(plugin_root.join("skills/chatgpt-only"))?; std::fs::write( @@ -589,6 +591,37 @@ description: Visible only for ChatGPT "command": "demo-server" } } +}"#, + )?; + std::fs::write( + plugin_root.join("hooks/hooks.json"), + r#"{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "echo startup" + } + ] + } + ], + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "echo first" + }, + { + "type": "command", + "command": "echo second" + } + ] + } + ] + } }"#, )?; std::fs::write( @@ -602,6 +635,9 @@ enabled = false [plugins."demo-plugin@codex-curated"] enabled = true + +[hooks.state."demo-plugin@codex-curated:hooks/hooks.json:pre_tool_use:0:0"] +enabled = false "#, )?; write_installed_plugin(&codex_home, "codex-curated", "demo-plugin")?; @@ -684,6 +720,56 @@ enabled = true "Summarize email threads" ); assert!(!response.plugin.skills[0].enabled); + assert_eq!( + response.plugin.hooks, + vec![ + codex_app_server_protocol::PluginHookSummary { + key: "demo-plugin@codex-curated:hooks/hooks.json:pre_tool_use:0:0".to_string(), + event_name: HookEventName::PreToolUse, + matcher: None, + enabled: false, + status_message: None, + definition: json!({ + "type": "command", + "command": "echo first", + "timeout": null, + "async": false, + "statusMessage": null, + }), + display_order: 0, + }, + codex_app_server_protocol::PluginHookSummary { + key: "demo-plugin@codex-curated:hooks/hooks.json:pre_tool_use:0:1".to_string(), + event_name: HookEventName::PreToolUse, + matcher: None, + enabled: true, + status_message: None, + definition: json!({ + "type": "command", + "command": "echo second", + "timeout": null, + "async": false, + "statusMessage": null, + }), + display_order: 1, + }, + codex_app_server_protocol::PluginHookSummary { + key: "demo-plugin@codex-curated:hooks/hooks.json:session_start:0:0".to_string(), + event_name: HookEventName::SessionStart, + matcher: None, + enabled: true, + status_message: None, + definition: json!({ + "type": "command", + "command": "echo startup", + "timeout": null, + "async": false, + "statusMessage": null, + }), + display_order: 2, + }, + ] + ); assert_eq!(response.plugin.apps.len(), 1); assert_eq!(response.plugin.apps[0].id, "gmail"); assert_eq!(response.plugin.apps[0].name, "gmail"); diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs index d947ebb86782..c4db4473977b 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -94,6 +94,17 @@ impl HookEventsToml { (HookEventName::Stop, self.stop), ] } + + pub fn matcher_groups(&self) -> [(HookEventName, &[MatcherGroup]); 6] { + [ + (HookEventName::PreToolUse, &self.pre_tool_use), + (HookEventName::PermissionRequest, &self.permission_request), + (HookEventName::PostToolUse, &self.post_tool_use), + (HookEventName::SessionStart, &self.session_start), + (HookEventName::UserPromptSubmit, &self.user_prompt_submit), + (HookEventName::Stop, &self.stop), + ] + } } #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index fc86e4f0dca6..420d88456a1a 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -13,6 +13,7 @@ use codex_core_plugins::loader::configured_curated_plugin_ids_from_codex_home; use codex_core_plugins::loader::curated_plugin_cache_version; use codex_core_plugins::loader::installed_plugin_telemetry_metadata; use codex_core_plugins::loader::load_plugin_apps; +use codex_core_plugins::loader::load_plugin_hooks; use codex_core_plugins::loader::load_plugin_mcp_servers; use codex_core_plugins::loader::load_plugin_skills; use codex_core_plugins::loader::load_plugins_from_layer_stack; @@ -50,15 +51,20 @@ use codex_core_plugins::store::PluginInstallResult as StorePluginInstallResult; use codex_core_plugins::store::PluginStore; use codex_core_plugins::store::PluginStoreError; use codex_features::Feature; +use codex_hooks::disabled_hook_keys_from_stack; +use codex_hooks::hook_key; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; +use codex_plugin::PluginHookSource; use codex_plugin::PluginId; use codex_plugin::PluginIdError; use codex_plugin::prompt_safe_plugin_description; +use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::Product; use codex_utils_absolute_path::AbsolutePathBuf; +use serde_json::Value as JsonValue; use std::collections::HashMap; use std::collections::HashSet; use std::path::PathBuf; @@ -172,11 +178,23 @@ pub struct PluginDetail { pub enabled: bool, pub skills: Vec, pub disabled_skill_paths: HashSet, + pub hooks: Vec, pub apps: Vec, pub mcp_server_names: Vec, pub details_unavailable_reason: Option, } +#[derive(Debug, Clone, PartialEq)] +pub struct PluginHookSummary { + pub key: String, + pub event_name: HookEventName, + pub matcher: Option, + pub enabled: bool, + pub status_message: Option, + pub definition: JsonValue, + pub display_order: i64, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PluginDetailsUnavailableReason { InstallRequiredForRemoteSource, @@ -1062,6 +1080,7 @@ impl PluginsManager { enabled: plugin.enabled, skills: Vec::new(), disabled_skill_paths: HashSet::new(), + hooks: Vec::new(), apps: Vec::new(), mcp_server_names: Vec::new(), details_unavailable_reason: Some( @@ -1118,6 +1137,11 @@ impl PluginsManager { ), ) .await; + let plugin_data_root = self.store.plugin_data_root(&plugin_id); + let disabled_hook_keys = disabled_hook_keys_from_stack(Some(&config.config_layer_stack)); + let (hook_sources, _hook_load_warnings) = + load_plugin_hooks(&source_path, &plugin_id, &plugin_data_root, &manifest.paths); + let hooks = summarize_plugin_hooks(&hook_sources, &disabled_hook_keys); let apps = load_plugin_apps(source_path.as_path()).await; let mut mcp_server_names = load_plugin_mcp_servers(source_path.as_path()) .await @@ -1137,6 +1161,7 @@ impl PluginsManager { enabled: plugin.enabled, skills: resolved_skills.skills, disabled_skill_paths: resolved_skills.disabled_skill_paths, + hooks, apps, mcp_server_names, details_unavailable_reason: None, @@ -1488,6 +1513,50 @@ impl PluginsManager { } } +fn summarize_plugin_hooks( + hook_sources: &[PluginHookSource], + disabled_hook_keys: &HashSet, +) -> Vec { + let mut hooks = Vec::new(); + let mut display_order = 0_i64; + + for source in hook_sources { + let key_source = format!( + "{}:{}", + source.plugin_id.as_key(), + source.source_relative_path + ); + for (event_name, groups) in source.hooks.matcher_groups() { + for (group_index, group) in groups.iter().enumerate() { + for (handler_index, handler) in group.hooks.iter().enumerate() { + let key = hook_key(&key_source, event_name, group_index, handler_index); + hooks.push(PluginHookSummary { + enabled: !disabled_hook_keys.contains(&key), + key, + event_name, + matcher: group.matcher.clone(), + status_message: plugin_hook_status_message(handler), + definition: serde_json::to_value(handler).unwrap_or(JsonValue::Null), + display_order, + }); + display_order += 1; + } + } + } + } + + hooks +} + +fn plugin_hook_status_message(handler: &codex_config::HookHandlerConfig) -> Option { + match handler { + codex_config::HookHandlerConfig::Command { status_message, .. } => status_message.clone(), + codex_config::HookHandlerConfig::Prompt {} | codex_config::HookHandlerConfig::Agent {} => { + None + } + } +} + fn remote_plugin_install_required_description(source: &MarketplacePluginSource) -> String { let source_description = match source { MarketplacePluginSource::Git { diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs index d1d6f4820114..459c15bc39c1 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -1806,6 +1806,37 @@ async fn read_plugin_for_config_installed_git_source_reads_from_cache_without_cl &cached_plugin_root.join(".mcp.json"), r#"{"mcpServers":{"toolkit":{"command":"toolkit-mcp"}}}"#, ); + write_file( + &cached_plugin_root.join("hooks/hooks.json"), + r#"{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "echo startup" + } + ] + } + ], + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "echo first" + }, + { + "type": "command", + "command": "echo second" + } + ] + } + ] + } +}"#, + ); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] @@ -1813,6 +1844,9 @@ plugins = true [plugins."toolkit@debug"] enabled = true + +[hooks.state."toolkit@debug:hooks/hooks.json:pre_tool_use:0:0"] +enabled = false "#, ); @@ -1851,6 +1885,56 @@ enabled = true outcome.plugin.apps, vec![AppConnectorId("connector_calendar".to_string())] ); + assert_eq!( + outcome.plugin.hooks, + vec![ + PluginHookSummary { + key: "toolkit@debug:hooks/hooks.json:pre_tool_use:0:0".to_string(), + event_name: HookEventName::PreToolUse, + matcher: None, + enabled: false, + status_message: None, + definition: serde_json::json!({ + "type": "command", + "command": "echo first", + "timeout": null, + "async": false, + "statusMessage": null, + }), + display_order: 0, + }, + PluginHookSummary { + key: "toolkit@debug:hooks/hooks.json:pre_tool_use:0:1".to_string(), + event_name: HookEventName::PreToolUse, + matcher: None, + enabled: true, + status_message: None, + definition: serde_json::json!({ + "type": "command", + "command": "echo second", + "timeout": null, + "async": false, + "statusMessage": null, + }), + display_order: 1, + }, + PluginHookSummary { + key: "toolkit@debug:hooks/hooks.json:session_start:0:0".to_string(), + event_name: HookEventName::SessionStart, + matcher: None, + enabled: true, + status_message: None, + definition: serde_json::json!({ + "type": "command", + "command": "echo startup", + "timeout": null, + "async": false, + "statusMessage": null, + }), + display_order: 2, + }, + ] + ); assert_eq!(outcome.plugin.mcp_server_names, vec!["toolkit".to_string()]); assert!( !tmp.path() diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index ff3183557a02..0c49b54033dd 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -29,6 +29,7 @@ pub use manager::ConfiguredMarketplaceListOutcome; pub use manager::ConfiguredMarketplacePlugin; pub use manager::PluginDetail; pub use manager::PluginDetailsUnavailableReason; +pub use manager::PluginHookSummary; pub use manager::PluginInstallError; pub use manager::PluginInstallOutcome; pub use manager::PluginInstallRequest; diff --git a/codex-rs/hooks/src/config_rules.rs b/codex-rs/hooks/src/config_rules.rs index b9fa8715041c..cbb37f64f131 100644 --- a/codex-rs/hooks/src/config_rules.rs +++ b/codex-rs/hooks/src/config_rules.rs @@ -13,7 +13,7 @@ use codex_config::TomlValue; /// disabled layers, to match the skills config behavior. Project, managed, and /// plugin layers can discover hooks, but they do not get to write user /// enablement state. -pub(crate) fn disabled_hook_keys_from_stack( +pub fn disabled_hook_keys_from_stack( config_layer_stack: Option<&ConfigLayerStack>, ) -> HashSet { let Some(config_layer_stack) = config_layer_stack else { diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index 8f2667295239..de629a0f526d 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -402,13 +402,8 @@ fn append_matcher_groups( }); let timeout_sec = timeout_sec.unwrap_or(600).max(1); // TODO(abhinav): replace this positional suffix with a durable hook id. - let key = format!( - "{}:{}:{}:{}", - source.key_source, - hook_event_key_label(event_name), - group_index, - handler_index - ); + let key = + crate::hook_key(&source.key_source, event_name, group_index, handler_index); let enabled = source.source.is_managed() || !source.disabled_hook_keys.contains(&key); hook_entries.push(HookListEntry { @@ -454,17 +449,6 @@ fn append_matcher_groups( } } -fn hook_event_key_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str { - match event_name { - codex_protocol::protocol::HookEventName::PreToolUse => "pre_tool_use", - codex_protocol::protocol::HookEventName::PermissionRequest => "permission_request", - codex_protocol::protocol::HookEventName::PostToolUse => "post_tool_use", - codex_protocol::protocol::HookEventName::SessionStart => "session_start", - codex_protocol::protocol::HookEventName::UserPromptSubmit => "user_prompt_submit", - codex_protocol::protocol::HookEventName::Stop => "stop", - } -} - fn hook_source_for_config_layer_source(source: &ConfigLayerSource) -> HookSource { match source { ConfigLayerSource::System { .. } => HookSource::System, diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index 4e16969a5877..d77596f956e9 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -6,6 +6,9 @@ mod registry; mod schema; mod types; +use codex_protocol::protocol::HookEventName; + +pub use config_rules::disabled_hook_keys_from_stack; pub use engine::HookListEntry; /// Hook event names as they appear in hooks JSON and config files. pub const HOOK_EVENT_NAMES: [&str; 6] = [ @@ -60,3 +63,28 @@ pub use types::HookResult; pub use types::HookToolInput; pub use types::HookToolInputLocalShell; pub use types::HookToolKind; + +/// Returns the hook event label used in persisted hook-state keys. +pub fn hook_event_key_label(event_name: HookEventName) -> &'static str { + match event_name { + HookEventName::PreToolUse => "pre_tool_use", + HookEventName::PermissionRequest => "permission_request", + HookEventName::PostToolUse => "post_tool_use", + HookEventName::SessionStart => "session_start", + HookEventName::UserPromptSubmit => "user_prompt_submit", + HookEventName::Stop => "stop", + } +} + +/// Builds the persisted config-state key for one discovered hook handler. +pub fn hook_key( + key_source: &str, + event_name: HookEventName, + group_index: usize, + handler_index: usize, +) -> String { + format!( + "{key_source}:{}:{group_index}:{handler_index}", + hook_event_key_label(event_name) + ) +} diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index b6b5242f0b9b..620d70260747 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -1261,6 +1261,12 @@ impl ChatWidget { is_disabled: true, ..Default::default() }); + items.push(SelectionItem { + name: "Hooks".to_string(), + description: Some(plugin_hook_summary(plugin)), + is_disabled: true, + ..Default::default() + }); items.push(SelectionItem { name: "Apps".to_string(), description: Some(plugin_app_summary(plugin)), @@ -1637,6 +1643,29 @@ fn plugin_app_summary(plugin: &PluginDetail) -> String { } } +fn plugin_hook_summary(plugin: &PluginDetail) -> String { + if plugin.hooks.is_empty() { + "No plugin hooks.".to_string() + } else { + let mut event_counts = Vec::<(codex_app_server_protocol::HookEventName, usize)>::new(); + for hook in &plugin.hooks { + if let Some((_, handler_count)) = event_counts + .iter_mut() + .find(|(event_name, _)| *event_name == hook.event_name) + { + *handler_count += 1; + } else { + event_counts.push((hook.event_name, 1)); + } + } + event_counts + .into_iter() + .map(|(event_name, handler_count)| format!("{event_name:?} ({handler_count})")) + .collect::>() + .join(", ") + } +} + fn plugin_mcp_summary(plugin: &PluginDetail) -> String { if plugin.mcp_servers.is_empty() { "No plugin MCP servers.".to_string() diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap index b9e5683c46db..efc06f6be394 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: popup +source: tui/src/chatwidget/tests/popups_and_settings.rs +expression: strip_osc8_for_snapshot(&popup) --- Plugins Figma · Can be installed · ChatGPT Marketplace @@ -11,6 +11,7 @@ expression: popup › 1. Back to plugins Return to the plugin list. 2. Install plugin Install this plugin now. Skills design-review, extract-copy + Hooks PreToolUse (2), SessionStart (1) Apps Figma, Slack MCP Servers figma-mcp, docs-mcp diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap index 71ae46d78dbc..2a0583293e5a 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: popup +source: tui/src/chatwidget/tests/popups_and_settings.rs +expression: strip_osc8_for_snapshot(&popup) --- Plugins Figma · Installed · ChatGPT Marketplace @@ -9,6 +9,7 @@ expression: popup › 1. Back to plugins Return to the plugin list. 2. Uninstall plugin Remove this plugin now. Skills design-review, extract-copy + Hooks PreToolUse (2), SessionStart (1) Apps Figma, Slack MCP Servers figma-mcp, docs-mcp diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index d26a503a89c4..751e99da1b84 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -968,6 +968,7 @@ pub(super) fn plugins_test_detail( summary: PluginSummary, description: Option<&str>, skills: &[&str], + hooks: &[(codex_app_server_protocol::HookEventName, usize)], apps: &[(&str, bool)], mcp_servers: &[&str], ) -> PluginDetail { @@ -989,6 +990,23 @@ pub(super) fn plugins_test_detail( enabled: true, }) .collect(), + hooks: hooks + .iter() + .flat_map(|(event_name, handler_count)| { + (0..*handler_count).map(move |index| codex_app_server_protocol::PluginHookSummary { + key: format!("test:{event_name:?}:0:{index}"), + event_name: *event_name, + matcher: None, + enabled: true, + status_message: None, + definition: serde_json::json!({ + "type": "command", + "command": format!("echo {index}"), + }), + display_order: i64::try_from(index).unwrap_or(i64::MAX), + }) + }) + .collect(), apps: apps .iter() .map(|(name, needs_auth)| AppSummary { diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index f3ba9629f2b9..8c4fe38f22cc 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -401,6 +401,10 @@ async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summa summary, Some("Turn Figma files into implementation context."), &["design-review", "extract-copy"], + &[ + (codex_app_server_protocol::HookEventName::PreToolUse, 2), + (codex_app_server_protocol::HookEventName::SessionStart, 1), + ], &[("Figma", true), ("Slack", false)], &["figma-mcp", "docs-mcp"], ), @@ -441,6 +445,10 @@ async fn plugin_detail_popup_hides_disclosure_for_installed_plugins() { summary, Some("Turn Figma files into implementation context."), &["design-review", "extract-copy"], + &[ + (codex_app_server_protocol::HookEventName::PreToolUse, 2), + (codex_app_server_protocol::HookEventName::SessionStart, 1), + ], &[("Figma", true), ("Slack", false)], &["figma-mcp", "docs-mcp"], ),