From 9b2bcc2bf522527da16341464ff7f9f796375c96 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 27 Apr 2026 11:37:58 -0700 Subject: [PATCH 1/4] Show plugin hooks in plugin details --- .../codex_app_server_protocol.schemas.json | 24 +++++++++ .../codex_app_server_protocol.v2.schemas.json | 24 +++++++++ .../schema/json/v2/PluginReadResponse.json | 35 +++++++++++++ .../schema/typescript/v2/PluginDetail.ts | 3 +- .../schema/typescript/v2/PluginHookSummary.ts | 6 +++ .../schema/typescript/v2/index.ts | 1 + .../app-server-protocol/src/protocol/v2.rs | 9 ++++ .../src/codex_message_processor/plugins.rs | 10 ++++ .../app-server/tests/suite/v2/plugin_read.rs | 44 ++++++++++++++++ codex-rs/core/src/plugins/manager.rs | 50 +++++++++++++++++++ codex-rs/core/src/plugins/manager_tests.rs | 44 ++++++++++++++++ codex-rs/core/src/plugins/mod.rs | 1 + codex-rs/tui/src/chatwidget/plugins.rs | 37 ++++++++++++++ ...ests__plugin_detail_popup_installable.snap | 5 +- ..._tests__plugin_detail_popup_installed.snap | 5 +- codex-rs/tui/src/chatwidget/tests/helpers.rs | 10 ++++ .../chatwidget/tests/popups_and_settings.rs | 8 +++ 17 files changed, 311 insertions(+), 5 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginHookSummary.ts 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 404f68194438..b520e00a8f23 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 @@ -11527,6 +11527,12 @@ "null" ] }, + "hooks": { + "items": { + "$ref": "#/definitions/v2/PluginHookSummary" + }, + "type": "array" + }, "marketplaceName": { "type": "string" }, @@ -11558,6 +11564,7 @@ }, "required": [ "apps", + "hooks", "marketplaceName", "mcpServers", "skills", @@ -11565,6 +11572,23 @@ ], "type": "object" }, + "PluginHookSummary": { + "properties": { + "eventName": { + "$ref": "#/definitions/v2/HookEventName" + }, + "handlerCount": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "eventName", + "handlerCount" + ], + "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 83f58895664c..b2bbcbe8e73d 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 @@ -8201,6 +8201,12 @@ "null" ] }, + "hooks": { + "items": { + "$ref": "#/definitions/PluginHookSummary" + }, + "type": "array" + }, "marketplaceName": { "type": "string" }, @@ -8232,6 +8238,7 @@ }, "required": [ "apps", + "hooks", "marketplaceName", "mcpServers", "skills", @@ -8239,6 +8246,23 @@ ], "type": "object" }, + "PluginHookSummary": { + "properties": { + "eventName": { + "$ref": "#/definitions/HookEventName" + }, + "handlerCount": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "eventName", + "handlerCount" + ], + "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..a7335dc7e45b 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,23 @@ ], "type": "object" }, + "PluginHookSummary": { + "properties": { + "eventName": { + "$ref": "#/definitions/HookEventName" + }, + "handlerCount": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "eventName", + "handlerCount" + ], + "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..3c5be4de4a4e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginHookSummary.ts @@ -0,0 +1,6 @@ +// 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 { HookEventName } from "./HookEventName"; + +export type PluginHookSummary = { eventName: HookEventName, handlerCount: number, }; 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 0e43b5a4b7c7..cb31ca40284c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -257,6 +257,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 c0a76f1b7900..dd2ddf01752c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4524,10 +4524,19 @@ 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 event_name: HookEventName, + pub handler_count: usize, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] 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 8f0f4dea9a8f..56f8161aea49 100644 --- a/codex-rs/app-server/src/codex_message_processor/plugins.rs +++ b/codex-rs/app-server/src/codex_message_processor/plugins.rs @@ -279,6 +279,15 @@ impl CodexMessageProcessor { &visible_skills, &outcome.plugin.disabled_skill_paths, ), + hooks: outcome + .plugin + .hooks + .into_iter() + .map(|hook| codex_app_server_protocol::PluginHookSummary { + event_name: hook.event_name.into(), + handler_count: hook.handler_count, + }) + .collect(), apps: app_summaries, mcp_servers: outcome.plugin.mcp_server_names, } @@ -815,6 +824,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 5360c381d87d..1bc4c731c4b4 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; @@ -482,6 +483,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( @@ -584,6 +586,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( @@ -679,6 +712,17 @@ enabled = true "Summarize email threads" ); assert!(!response.plugin.skills[0].enabled); + assert_eq!(response.plugin.hooks.len(), 2); + assert_eq!( + response.plugin.hooks[0].event_name, + HookEventName::SessionStart + ); + assert_eq!(response.plugin.hooks[0].handler_count, 1); + assert_eq!( + response.plugin.hooks[1].event_name, + HookEventName::PreToolUse + ); + assert_eq!(response.plugin.hooks[1].handler_count, 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/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index 77265ece75af..51cc56d90344 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; @@ -54,9 +55,11 @@ 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 std::collections::HashMap; @@ -172,11 +175,18 @@ 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, Eq)] +pub struct PluginHookSummary { + pub event_name: HookEventName, + pub handler_count: usize, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PluginDetailsUnavailableReason { InstallRequiredForRemoteSource, @@ -1043,6 +1053,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( @@ -1099,6 +1110,11 @@ impl PluginsManager { ), ) .await; + let hooks = summarize_plugin_hooks(&load_plugin_hooks( + &source_path, + &plugin_id, + &manifest.paths, + )); let apps = load_plugin_apps(source_path.as_path()).await; let mut mcp_server_names = load_plugin_mcp_servers(source_path.as_path()) .await @@ -1118,6 +1134,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, @@ -1469,6 +1486,39 @@ impl PluginsManager { } } +fn summarize_plugin_hooks(hook_sources: &[PluginHookSource]) -> Vec { + let handler_count = |groups: &[codex_config::MatcherGroup]| { + groups.iter().map(|group| group.hooks.len()).sum::() + }; + let mut counts = [ + (HookEventName::SessionStart, 0), + (HookEventName::UserPromptSubmit, 0), + (HookEventName::PreToolUse, 0), + (HookEventName::PermissionRequest, 0), + (HookEventName::PostToolUse, 0), + (HookEventName::Stop, 0), + ]; + + for source in hook_sources { + counts[0].1 += handler_count(&source.hooks.session_start); + counts[1].1 += handler_count(&source.hooks.user_prompt_submit); + counts[2].1 += handler_count(&source.hooks.pre_tool_use); + counts[3].1 += handler_count(&source.hooks.permission_request); + counts[4].1 += handler_count(&source.hooks.post_tool_use); + counts[5].1 += handler_count(&source.hooks.stop); + } + + counts + .into_iter() + .filter_map(|(event_name, handler_count)| { + (handler_count > 0).then_some(PluginHookSummary { + event_name, + handler_count, + }) + }) + .collect() +} + 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 4e794506c010..5df40173e61e 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -1732,6 +1732,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] @@ -1777,6 +1808,19 @@ enabled = true outcome.plugin.apps, vec![AppConnectorId("connector_calendar".to_string())] ); + assert_eq!( + outcome.plugin.hooks, + vec![ + PluginHookSummary { + event_name: HookEventName::SessionStart, + handler_count: 1, + }, + PluginHookSummary { + event_name: HookEventName::PreToolUse, + handler_count: 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/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index ae6b08c07d1e..41321cf38366 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -17,6 +17,7 @@ use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::shimmer::shimmer_spans; use crate::tui::FrameRequester; +use codex_app_server_protocol::HookEventName; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInstallResponse; @@ -1049,6 +1050,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)), @@ -1396,6 +1403,36 @@ 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 { + plugin + .hooks + .iter() + .map(|hook| { + format!( + "{} ({})", + plugin_hook_event_label(hook.event_name), + hook.handler_count + ) + }) + .collect::>() + .join(", ") + } +} + +fn plugin_hook_event_label(event_name: HookEventName) -> &'static str { + match event_name { + HookEventName::PreToolUse => "PreToolUse", + HookEventName::PermissionRequest => "PermissionRequest", + HookEventName::PostToolUse => "PostToolUse", + HookEventName::SessionStart => "SessionStart", + HookEventName::UserPromptSubmit => "UserPromptSubmit", + HookEventName::Stop => "Stop", + } +} + 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..e54c67efe77e 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 SessionStart (1), PreToolUse (2) 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..aff87d0f6417 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 SessionStart (1), PreToolUse (2) 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 ca10aeec3224..4711c025f080 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -962,6 +962,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 { @@ -983,6 +984,15 @@ pub(super) fn plugins_test_detail( enabled: true, }) .collect(), + hooks: hooks + .iter() + .map( + |(event_name, handler_count)| codex_app_server_protocol::PluginHookSummary { + event_name: *event_name, + handler_count: *handler_count, + }, + ) + .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 3977d9d0eec2..b8320b72941d 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -190,6 +190,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::SessionStart, 1), + (codex_app_server_protocol::HookEventName::PreToolUse, 2), + ], &[("Figma", true), ("Slack", false)], &["figma-mcp", "docs-mcp"], ), @@ -230,6 +234,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::SessionStart, 1), + (codex_app_server_protocol::HookEventName::PreToolUse, 2), + ], &[("Figma", true), ("Slack", false)], &["figma-mcp", "docs-mcp"], ), From 62e9dabe6302a176af3ded6f95c9eeac593a9c4c Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 27 Apr 2026 12:06:31 -0700 Subject: [PATCH 2/4] Reuse hook event iteration for plugin summaries --- .../app-server/tests/suite/v2/plugin_read.rs | 8 +++--- codex-rs/config/src/hook_config.rs | 11 ++++++++ codex-rs/core/src/plugins/manager.rs | 26 +++++++------------ codex-rs/core/src/plugins/manager_tests.rs | 8 +++--- codex-rs/tui/src/chatwidget/plugins.rs | 20 +------------- ...ests__plugin_detail_popup_installable.snap | 2 +- ..._tests__plugin_detail_popup_installed.snap | 2 +- .../chatwidget/tests/popups_and_settings.rs | 4 +-- 8 files changed, 34 insertions(+), 47 deletions(-) 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 1bc4c731c4b4..a6c8ff4154dd 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -715,14 +715,14 @@ enabled = true assert_eq!(response.plugin.hooks.len(), 2); assert_eq!( response.plugin.hooks[0].event_name, - HookEventName::SessionStart + HookEventName::PreToolUse ); - assert_eq!(response.plugin.hooks[0].handler_count, 1); + assert_eq!(response.plugin.hooks[0].handler_count, 2); assert_eq!( response.plugin.hooks[1].event_name, - HookEventName::PreToolUse + HookEventName::SessionStart ); - assert_eq!(response.plugin.hooks[1].handler_count, 2); + assert_eq!(response.plugin.hooks[1].handler_count, 1); 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 8a5c73d6b9ba..57e8763708b1 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -79,6 +79,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 51cc56d90344..19f5c479870b 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -1487,25 +1487,19 @@ impl PluginsManager { } fn summarize_plugin_hooks(hook_sources: &[PluginHookSource]) -> Vec { - let handler_count = |groups: &[codex_config::MatcherGroup]| { - groups.iter().map(|group| group.hooks.len()).sum::() + let Some(first_source) = hook_sources.first() else { + return Vec::new(); }; - let mut counts = [ - (HookEventName::SessionStart, 0), - (HookEventName::UserPromptSubmit, 0), - (HookEventName::PreToolUse, 0), - (HookEventName::PermissionRequest, 0), - (HookEventName::PostToolUse, 0), - (HookEventName::Stop, 0), - ]; + let mut counts = first_source + .hooks + .matcher_groups() + .map(|(event_name, _)| (event_name, 0)); for source in hook_sources { - counts[0].1 += handler_count(&source.hooks.session_start); - counts[1].1 += handler_count(&source.hooks.user_prompt_submit); - counts[2].1 += handler_count(&source.hooks.pre_tool_use); - counts[3].1 += handler_count(&source.hooks.permission_request); - counts[4].1 += handler_count(&source.hooks.post_tool_use); - counts[5].1 += handler_count(&source.hooks.stop); + for ((_, count), (_, groups)) in counts.iter_mut().zip(source.hooks.matcher_groups()) { + let handler_count = groups.iter().map(|group| group.hooks.len()).sum::(); + *count += handler_count; + } } counts diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs index 5df40173e61e..11ba5148f91e 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -1811,14 +1811,14 @@ enabled = true assert_eq!( outcome.plugin.hooks, vec![ - PluginHookSummary { - event_name: HookEventName::SessionStart, - handler_count: 1, - }, PluginHookSummary { event_name: HookEventName::PreToolUse, handler_count: 2, }, + PluginHookSummary { + event_name: HookEventName::SessionStart, + handler_count: 1, + }, ] ); assert_eq!(outcome.plugin.mcp_server_names, vec!["toolkit".to_string()]); diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 41321cf38366..b5ee756b64c8 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -17,7 +17,6 @@ use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::shimmer::shimmer_spans; use crate::tui::FrameRequester; -use codex_app_server_protocol::HookEventName; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInstallResponse; @@ -1410,29 +1409,12 @@ fn plugin_hook_summary(plugin: &PluginDetail) -> String { plugin .hooks .iter() - .map(|hook| { - format!( - "{} ({})", - plugin_hook_event_label(hook.event_name), - hook.handler_count - ) - }) + .map(|hook| format!("{:?} ({})", hook.event_name, hook.handler_count)) .collect::>() .join(", ") } } -fn plugin_hook_event_label(event_name: HookEventName) -> &'static str { - match event_name { - HookEventName::PreToolUse => "PreToolUse", - HookEventName::PermissionRequest => "PermissionRequest", - HookEventName::PostToolUse => "PostToolUse", - HookEventName::SessionStart => "SessionStart", - HookEventName::UserPromptSubmit => "UserPromptSubmit", - HookEventName::Stop => "Stop", - } -} - 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 e54c67efe77e..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 @@ -11,7 +11,7 @@ expression: strip_osc8_for_snapshot(&popup) › 1. Back to plugins Return to the plugin list. 2. Install plugin Install this plugin now. Skills design-review, extract-copy - Hooks SessionStart (1), PreToolUse (2) + 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 aff87d0f6417..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 @@ -9,7 +9,7 @@ expression: strip_osc8_for_snapshot(&popup) › 1. Back to plugins Return to the plugin list. 2. Uninstall plugin Remove this plugin now. Skills design-review, extract-copy - Hooks SessionStart (1), PreToolUse (2) + Hooks PreToolUse (2), SessionStart (1) Apps Figma, Slack MCP Servers figma-mcp, docs-mcp 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 b8320b72941d..46b43bb836b4 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -191,8 +191,8 @@ async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summa Some("Turn Figma files into implementation context."), &["design-review", "extract-copy"], &[ - (codex_app_server_protocol::HookEventName::SessionStart, 1), (codex_app_server_protocol::HookEventName::PreToolUse, 2), + (codex_app_server_protocol::HookEventName::SessionStart, 1), ], &[("Figma", true), ("Slack", false)], &["figma-mcp", "docs-mcp"], @@ -235,8 +235,8 @@ async fn plugin_detail_popup_hides_disclosure_for_installed_plugins() { Some("Turn Figma files into implementation context."), &["design-review", "extract-copy"], &[ - (codex_app_server_protocol::HookEventName::SessionStart, 1), (codex_app_server_protocol::HookEventName::PreToolUse, 2), + (codex_app_server_protocol::HookEventName::SessionStart, 1), ], &[("Figma", true), ("Slack", false)], &["figma-mcp", "docs-mcp"], From 68e62c47cda47c5bfd85210ee6e14d53e6e41c04 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Tue, 28 Apr 2026 21:23:38 -0700 Subject: [PATCH 3/4] Expand plugin hook summaries per handler --- .../codex_app_server_protocol.schemas.json | 31 +++++-- .../codex_app_server_protocol.v2.schemas.json | 31 +++++-- .../schema/json/v2/PluginReadResponse.json | 31 +++++-- .../schema/typescript/v2/PluginHookSummary.ts | 3 +- .../app-server-protocol/src/protocol/v2.rs | 7 +- codex-rs/app-server/README.md | 2 +- .../src/codex_message_processor/plugins.rs | 7 +- .../app-server/tests/suite/v2/plugin_read.rs | 57 ++++++++++-- codex-rs/core/src/plugins/manager.rs | 89 ++++++++++++++----- codex-rs/core/src/plugins/manager_tests.rs | 41 ++++++++- codex-rs/tui/src/chatwidget/plugins.rs | 18 +++- codex-rs/tui/src/chatwidget/tests/helpers.rs | 18 ++-- 12 files changed, 275 insertions(+), 60 deletions(-) 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 a4a678a888f7..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 @@ -11828,18 +11828,39 @@ }, "PluginHookSummary": { "properties": { + "definition": true, + "displayOrder": { + "format": "int64", + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, "eventName": { "$ref": "#/definitions/v2/HookEventName" }, - "handlerCount": { - "format": "uint", - "minimum": 0.0, - "type": "integer" + "key": { + "type": "string" + }, + "matcher": { + "type": [ + "string", + "null" + ] + }, + "statusMessage": { + "type": [ + "string", + "null" + ] } }, "required": [ + "definition", + "displayOrder", + "enabled", "eventName", - "handlerCount" + "key" ], "type": "object" }, 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 45b964ff7c28..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 @@ -8482,18 +8482,39 @@ }, "PluginHookSummary": { "properties": { + "definition": true, + "displayOrder": { + "format": "int64", + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, "eventName": { "$ref": "#/definitions/HookEventName" }, - "handlerCount": { - "format": "uint", - "minimum": 0.0, - "type": "integer" + "key": { + "type": "string" + }, + "matcher": { + "type": [ + "string", + "null" + ] + }, + "statusMessage": { + "type": [ + "string", + "null" + ] } }, "required": [ + "definition", + "displayOrder", + "enabled", "eventName", - "handlerCount" + "key" ], "type": "object" }, 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 a7335dc7e45b..b4f2dcc8eb24 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -116,18 +116,39 @@ }, "PluginHookSummary": { "properties": { + "definition": true, + "displayOrder": { + "format": "int64", + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, "eventName": { "$ref": "#/definitions/HookEventName" }, - "handlerCount": { - "format": "uint", - "minimum": 0.0, - "type": "integer" + "key": { + "type": "string" + }, + "matcher": { + "type": [ + "string", + "null" + ] + }, + "statusMessage": { + "type": [ + "string", + "null" + ] } }, "required": [ + "definition", + "displayOrder", + "enabled", "eventName", - "handlerCount" + "key" ], "type": "object" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginHookSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginHookSummary.ts index 3c5be4de4a4e..1c11387c96d3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginHookSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginHookSummary.ts @@ -1,6 +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 = { eventName: HookEventName, handlerCount: number, }; +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/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index eea80b743e76..af3e7f709c69 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4687,8 +4687,13 @@ pub struct PluginDetail { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct PluginHookSummary { + pub key: String, pub event_name: HookEventName, - pub handler_count: usize, + 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)] 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 21417ba3e9ab..33a7aaac87f7 100644 --- a/codex-rs/app-server/src/codex_message_processor/plugins.rs +++ b/codex-rs/app-server/src/codex_message_processor/plugins.rs @@ -248,8 +248,13 @@ impl CodexMessageProcessor { .hooks .into_iter() .map(|hook| codex_app_server_protocol::PluginHookSummary { + key: hook.key, event_name: hook.event_name.into(), - handler_count: hook.handler_count, + matcher: hook.matcher, + enabled: hook.enabled, + status_message: hook.status_message, + definition: hook.definition, + display_order: hook.display_order, }) .collect(), apps: app_summaries, 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 3cd0d17bcc84..65cdcea80ef8 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -717,17 +717,56 @@ enabled = true "Summarize email threads" ); assert!(!response.plugin.skills[0].enabled); - assert_eq!(response.plugin.hooks.len(), 2); assert_eq!( - response.plugin.hooks[0].event_name, - HookEventName::PreToolUse - ); - assert_eq!(response.plugin.hooks[0].handler_count, 2); - assert_eq!( - response.plugin.hooks[1].event_name, - HookEventName::SessionStart + response.plugin.hooks, + vec![ + codex_app_server_protocol::PluginHookSummary { + key: "hooks/hooks.json:PreToolUse:0:0".to_string(), + event_name: HookEventName::PreToolUse, + matcher: None, + enabled: true, + 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: "hooks/hooks.json:PreToolUse: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: "hooks/hooks.json:SessionStart: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.hooks[1].handler_count, 1); 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/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index ad65268c4efe..f04be2461aca 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -62,6 +62,7 @@ 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; @@ -181,10 +182,15 @@ pub struct PluginDetail { pub details_unavailable_reason: Option, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct PluginHookSummary { + pub key: String, pub event_name: HookEventName, - pub handler_count: usize, + pub matcher: Option, + pub enabled: bool, + pub status_message: Option, + pub definition: JsonValue, + pub display_order: i64, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1506,30 +1512,71 @@ impl PluginsManager { } fn summarize_plugin_hooks(hook_sources: &[PluginHookSource]) -> Vec { - let Some(first_source) = hook_sources.first() else { - return Vec::new(); - }; - let mut counts = first_source - .hooks - .matcher_groups() - .map(|(event_name, _)| (event_name, 0)); + let mut hooks = Vec::new(); + let mut display_order = 0_i64; for source in hook_sources { - for ((_, count), (_, groups)) in counts.iter_mut().zip(source.hooks.matcher_groups()) { - let handler_count = groups.iter().map(|group| group.hooks.len()).sum::(); - *count += handler_count; + 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() { + hooks.push(PluginHookSummary { + key: plugin_hook_key( + &source.source_relative_path, + event_name, + group_index, + handler_index, + ), + event_name, + matcher: group.matcher.clone(), + // Plugin hooks do not yet have handler-level config overrides in this + // branch, so every bundled handler is currently enabled by default. + enabled: true, + status_message: plugin_hook_status_message(handler), + definition: serde_json::to_value(handler).unwrap_or(JsonValue::Null), + display_order, + }); + display_order += 1; + } + } } } - counts - .into_iter() - .filter_map(|(event_name, handler_count)| { - (handler_count > 0).then_some(PluginHookSummary { - event_name, - handler_count, - }) - }) - .collect() + hooks +} + +fn plugin_hook_key( + source_relative_path: &str, + event_name: HookEventName, + group_index: usize, + handler_index: usize, +) -> String { + format!( + "{}:{}:{}:{}", + source_relative_path, + plugin_hook_event_label(event_name), + group_index, + handler_index + ) +} + +fn plugin_hook_event_label(event_name: HookEventName) -> &'static str { + match event_name { + HookEventName::PreToolUse => "PreToolUse", + HookEventName::PermissionRequest => "PermissionRequest", + HookEventName::PostToolUse => "PostToolUse", + HookEventName::SessionStart => "SessionStart", + HookEventName::UserPromptSubmit => "UserPromptSubmit", + HookEventName::Stop => "Stop", + } +} + +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 { diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs index 7b39c4e20b8a..7a0a51081ffe 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -1886,12 +1886,49 @@ enabled = true outcome.plugin.hooks, vec![ PluginHookSummary { + key: "hooks/hooks.json:PreToolUse:0:0".to_string(), event_name: HookEventName::PreToolUse, - handler_count: 2, + matcher: None, + enabled: true, + status_message: None, + definition: serde_json::json!({ + "type": "command", + "command": "echo first", + "timeout": null, + "async": false, + "statusMessage": null, + }), + display_order: 0, + }, + PluginHookSummary { + key: "hooks/hooks.json:PreToolUse: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: "hooks/hooks.json:SessionStart:0:0".to_string(), event_name: HookEventName::SessionStart, - handler_count: 1, + 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, }, ] ); diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 44078469cf1f..620d70260747 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -1647,10 +1647,20 @@ fn plugin_hook_summary(plugin: &PluginDetail) -> String { if plugin.hooks.is_empty() { "No plugin hooks.".to_string() } else { - plugin - .hooks - .iter() - .map(|hook| format!("{:?} ({})", hook.event_name, hook.handler_count)) + 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(", ") } diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index bb40802a5e77..751e99da1b84 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -992,12 +992,20 @@ pub(super) fn plugins_test_detail( .collect(), hooks: hooks .iter() - .map( - |(event_name, handler_count)| codex_app_server_protocol::PluginHookSummary { + .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, - handler_count: *handler_count, - }, - ) + 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() From a5d74f01e3a11fa9a9f35579c00e3405e22c0af3 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Wed, 29 Apr 2026 11:40:03 -0700 Subject: [PATCH 4/4] Reuse hook key helpers for plugin summaries --- .../app-server/tests/suite/v2/plugin_read.rs | 11 ++-- codex-rs/core/src/plugins/manager.rs | 60 ++++++------------- codex-rs/core/src/plugins/manager_tests.rs | 11 ++-- codex-rs/hooks/src/config_rules.rs | 2 +- codex-rs/hooks/src/engine/discovery.rs | 20 +------ codex-rs/hooks/src/lib.rs | 28 +++++++++ 6 files changed, 64 insertions(+), 68 deletions(-) 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 65cdcea80ef8..9e043bf75551 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -635,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")?; @@ -721,10 +724,10 @@ enabled = true response.plugin.hooks, vec![ codex_app_server_protocol::PluginHookSummary { - key: "hooks/hooks.json:PreToolUse:0:0".to_string(), + key: "demo-plugin@codex-curated:hooks/hooks.json:pre_tool_use:0:0".to_string(), event_name: HookEventName::PreToolUse, matcher: None, - enabled: true, + enabled: false, status_message: None, definition: json!({ "type": "command", @@ -736,7 +739,7 @@ enabled = true display_order: 0, }, codex_app_server_protocol::PluginHookSummary { - key: "hooks/hooks.json:PreToolUse:0:1".to_string(), + key: "demo-plugin@codex-curated:hooks/hooks.json:pre_tool_use:0:1".to_string(), event_name: HookEventName::PreToolUse, matcher: None, enabled: true, @@ -751,7 +754,7 @@ enabled = true display_order: 1, }, codex_app_server_protocol::PluginHookSummary { - key: "hooks/hooks.json:SessionStart:0:0".to_string(), + key: "demo-plugin@codex-curated:hooks/hooks.json:session_start:0:0".to_string(), event_name: HookEventName::SessionStart, matcher: None, enabled: true, diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index f04be2461aca..420d88456a1a 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -51,6 +51,8 @@ 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; @@ -1135,11 +1137,11 @@ impl PluginsManager { ), ) .await; - let hooks = summarize_plugin_hooks(&load_plugin_hooks( - &source_path, - &plugin_id, - &manifest.paths, - )); + 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 @@ -1511,26 +1513,28 @@ impl PluginsManager { } } -fn summarize_plugin_hooks(hook_sources: &[PluginHookSource]) -> Vec { +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 { - key: plugin_hook_key( - &source.source_relative_path, - event_name, - group_index, - handler_index, - ), + enabled: !disabled_hook_keys.contains(&key), + key, event_name, matcher: group.matcher.clone(), - // Plugin hooks do not yet have handler-level config overrides in this - // branch, so every bundled handler is currently enabled by default. - enabled: true, status_message: plugin_hook_status_message(handler), definition: serde_json::to_value(handler).unwrap_or(JsonValue::Null), display_order, @@ -1544,32 +1548,6 @@ fn summarize_plugin_hooks(hook_sources: &[PluginHookSource]) -> Vec String { - format!( - "{}:{}:{}:{}", - source_relative_path, - plugin_hook_event_label(event_name), - group_index, - handler_index - ) -} - -fn plugin_hook_event_label(event_name: HookEventName) -> &'static str { - match event_name { - HookEventName::PreToolUse => "PreToolUse", - HookEventName::PermissionRequest => "PermissionRequest", - HookEventName::PostToolUse => "PostToolUse", - HookEventName::SessionStart => "SessionStart", - HookEventName::UserPromptSubmit => "UserPromptSubmit", - HookEventName::Stop => "Stop", - } -} - fn plugin_hook_status_message(handler: &codex_config::HookHandlerConfig) -> Option { match handler { codex_config::HookHandlerConfig::Command { status_message, .. } => status_message.clone(), diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs index 7a0a51081ffe..459c15bc39c1 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -1844,6 +1844,9 @@ plugins = true [plugins."toolkit@debug"] enabled = true + +[hooks.state."toolkit@debug:hooks/hooks.json:pre_tool_use:0:0"] +enabled = false "#, ); @@ -1886,10 +1889,10 @@ enabled = true outcome.plugin.hooks, vec![ PluginHookSummary { - key: "hooks/hooks.json:PreToolUse:0:0".to_string(), + key: "toolkit@debug:hooks/hooks.json:pre_tool_use:0:0".to_string(), event_name: HookEventName::PreToolUse, matcher: None, - enabled: true, + enabled: false, status_message: None, definition: serde_json::json!({ "type": "command", @@ -1901,7 +1904,7 @@ enabled = true display_order: 0, }, PluginHookSummary { - key: "hooks/hooks.json:PreToolUse:0:1".to_string(), + key: "toolkit@debug:hooks/hooks.json:pre_tool_use:0:1".to_string(), event_name: HookEventName::PreToolUse, matcher: None, enabled: true, @@ -1916,7 +1919,7 @@ enabled = true display_order: 1, }, PluginHookSummary { - key: "hooks/hooks.json:SessionStart:0:0".to_string(), + key: "toolkit@debug:hooks/hooks.json:session_start:0:0".to_string(), event_name: HookEventName::SessionStart, matcher: None, enabled: true, 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) + ) +}