From 9510e204f5b744b3ca6a448429f5d50e2bec3ece Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Wed, 6 May 2026 17:53:59 -0700 Subject: [PATCH 1/2] Show plugin hooks in plugin details --- codex-rs/Cargo.lock | 1 + .../codex_app_server_protocol.schemas.json | 45 ++++++++++ .../codex_app_server_protocol.v2.schemas.json | 45 ++++++++++ .../schema/json/v2/PluginReadResponse.json | 56 ++++++++++++ .../schema/typescript/v2/PluginDetail.ts | 3 +- .../schema/typescript/v2/PluginHookSummary.ts | 7 ++ .../schema/typescript/v2/index.ts | 1 + .../src/protocol/v2/plugin.rs | 14 +++ codex-rs/app-server/README.md | 2 +- .../src/request_processors/plugins.rs | 15 ++++ .../app-server/tests/suite/v2/plugin_read.rs | 87 +++++++++++++++++++ codex-rs/core-plugins/Cargo.toml | 1 + codex-rs/core-plugins/src/manager.rs | 76 ++++++++++++++++ codex-rs/core-plugins/src/manager_tests.rs | 86 ++++++++++++++++++ codex-rs/hooks/src/config_rules.rs | 2 +- codex-rs/hooks/src/engine/discovery.rs | 22 +---- codex-rs/hooks/src/lib.rs | 28 ++++++ codex-rs/tui/src/chatwidget/plugins.rs | 29 +++++++ ...ests__plugin_detail_popup_installable.snap | 1 + ..._tests__plugin_detail_popup_installed.snap | 1 + codex-rs/tui/src/chatwidget/tests/helpers.rs | 22 +++++ .../chatwidget/tests/popups_and_settings.rs | 8 ++ 22 files changed, 530 insertions(+), 22 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginHookSummary.ts diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f78f27ffa347..0e6e2e928b22 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2570,6 +2570,7 @@ dependencies = [ "codex-core-skills", "codex-exec-server", "codex-git-utils", + "codex-hooks", "codex-login", "codex-model-provider", "codex-otel", 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 24b8ea9cc008..bf2cb4927d06 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 @@ -12128,6 +12128,12 @@ "null" ] }, + "hooks": { + "items": { + "$ref": "#/definitions/v2/PluginHookSummary" + }, + "type": "array" + }, "marketplaceName": { "type": "string" }, @@ -12159,6 +12165,7 @@ }, "required": [ "apps", + "hooks", "marketplaceName", "mcpServers", "skills", @@ -12166,6 +12173,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 76d81c729ec1..5b6c4428ccb0 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 @@ -8739,6 +8739,12 @@ "null" ] }, + "hooks": { + "items": { + "$ref": "#/definitions/PluginHookSummary" + }, + "type": "array" + }, "marketplaceName": { "type": "string" }, @@ -8770,6 +8776,7 @@ }, "required": [ "apps", + "hooks", "marketplaceName", "mcpServers", "skills", @@ -8777,6 +8784,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 b1ffd2f4aee3..f38b3e970b8a 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", @@ -75,6 +86,12 @@ "null" ] }, + "hooks": { + "items": { + "$ref": "#/definitions/PluginHookSummary" + }, + "type": "array" + }, "marketplaceName": { "type": "string" }, @@ -106,6 +123,7 @@ }, "required": [ "apps", + "hooks", "marketplaceName", "mcpServers", "skills", @@ -113,6 +131,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 e624d704e69d..1da7dae6868b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -274,6 +274,7 @@ export type { PlanDeltaNotification } from "./PlanDeltaNotification"; export type { PluginAuthPolicy } from "./PluginAuthPolicy"; export type { PluginAvailability } from "./PluginAvailability"; 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/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs index b2d5d85c299d..7acfaa32be03 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -552,10 +552,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, 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: serde_json::Value, + 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 ddc381795272..a0023c3d49e5 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -208,7 +208,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, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), 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 and hooks 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/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle. - `skills/changed` — notification emitted when watched local skill files change. - `app/list` — list available apps. diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index bb1ebb611155..db32af4d65e8 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -615,6 +615,20 @@ impl PluginRequestProcessor { &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, } @@ -1490,6 +1504,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 53a3b3d296d5..cc206f9edc31 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; @@ -778,6 +779,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( @@ -881,12 +883,44 @@ 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( codex_home.path().join("config.toml"), r#"[features] plugins = true +plugin_hooks = true [[skills.config]] name = "demo-plugin:thread-summarizer" @@ -894,6 +928,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")?; @@ -980,6 +1017,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/core-plugins/Cargo.toml b/codex-rs/core-plugins/Cargo.toml index db3059f2b087..352d6e571497 100644 --- a/codex-rs/core-plugins/Cargo.toml +++ b/codex-rs/core-plugins/Cargo.toml @@ -20,6 +20,7 @@ codex-config = { workspace = true } codex-core-skills = { workspace = true } codex-exec-server = { workspace = true } codex-git-utils = { workspace = true } +codex-hooks = { workspace = true } codex-login = { workspace = true } codex-model-provider = { workspace = true } codex-otel = { workspace = true } diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 2c974ef9662c..b8abd57b2b46 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -6,6 +6,7 @@ use crate::loader::configured_curated_plugin_ids_from_codex_home; use crate::loader::curated_plugin_cache_version; use crate::loader::installed_plugin_telemetry_metadata; use crate::loader::load_plugin_apps; +use crate::loader::load_plugin_hooks; use crate::loader::load_plugin_mcp_servers; use crate::loader::load_plugin_skills; use crate::loader::load_plugins_from_layer_stack; @@ -54,16 +55,21 @@ use codex_config::set_user_plugin_enabled; use codex_config::types::PluginConfig; use codex_config::version_for_toml; use codex_core_skills::SkillMetadata; +use codex_hooks::hook_key; +use codex_hooks::hook_states_from_stack; 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 codex_utils_plugins::PluginSkillRoot; +use serde_json::Value as JsonValue; use std::collections::HashMap; use std::collections::HashSet; use std::path::PathBuf; @@ -228,11 +234,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, @@ -1300,6 +1318,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( @@ -1357,6 +1376,17 @@ impl PluginsManager { ), ) .await; + let hooks = if config.plugin_hooks_enabled { + let plugin_data_root = self.store.plugin_data_root(&plugin_id); + let (hook_sources, _hook_load_warnings) = + load_plugin_hooks(&source_path, &plugin_id, &plugin_data_root, &manifest.paths); + summarize_plugin_hooks( + &hook_sources, + &hook_states_from_stack(Some(&config.config_layer_stack)), + ) + } else { + Vec::new() + }; let apps = load_plugin_apps(source_path.as_path()).await; let mut mcp_server_names = load_plugin_mcp_servers(source_path.as_path()) .await @@ -1377,6 +1407,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, @@ -1854,6 +1885,51 @@ impl PluginsManager { } } +fn summarize_plugin_hooks( + hook_sources: &[PluginHookSource], + hook_states: &HashMap, +) -> 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.clone().into_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: hook_states.get(&key).and_then(|state| state.enabled) + != Some(false), + 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-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 50d0d4625838..608cda8eefe6 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -25,6 +25,7 @@ use codex_config::McpServerConfig; use codex_config::McpServerToolConfig; use codex_config::types::McpServerTransportConfig; use codex_login::CodexAuth; +use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::Product; use codex_utils_absolute_path::test_support::PathBufExt; use pretty_assertions::assert_eq; @@ -1933,13 +1934,48 @@ 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] plugins = true +plugin_hooks = true [plugins."toolkit@debug"] enabled = true + +[hooks.state."toolkit@debug:hooks/hooks.json:pre_tool_use:0:0"] +enabled = false "#, ); @@ -1978,6 +2014,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/hooks/src/config_rules.rs b/codex-rs/hooks/src/config_rules.rs index 359c068ee24c..3f9c48df2b12 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 hook /// state. -pub(crate) fn hook_states_from_stack( +pub fn hook_states_from_stack( config_layer_stack: Option<&ConfigLayerStack>, ) -> HashMap { 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 f24da13bb435..fab682a5f098 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -416,13 +416,8 @@ fn append_matcher_groups( command.replace(&format!("${{{key}}}"), value) }); // 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 state = source.hook_states.get(&key); let enabled = hook_enabled(source.is_managed, state); let trusted_hash = hook_trusted_hash(source.is_managed, state); @@ -497,7 +492,7 @@ fn command_hook_hash( group.matcher = matcher.map(ToOwned::to_owned); group.hooks = vec![normalized_handler]; let identity = NormalizedHookIdentity { - event_name: hook_event_key_label(event_name), + event_name: crate::hook_event_key_label(event_name), group, }; let Ok(value) = TomlValue::try_from(identity) else { @@ -506,17 +501,6 @@ fn command_hook_hash( version_for_toml(&value) } -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_trust_status( is_managed: bool, current_hash: &str, diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index ad627b497a20..239d7f36d220 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -7,6 +7,9 @@ mod registry; mod schema; mod types; +use codex_protocol::protocol::HookEventName; + +pub use config_rules::hook_states_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] = [ @@ -61,3 +64,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 3bca3d5d8618..82ac8eba7ca0 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -1728,6 +1728,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)), @@ -2142,6 +2148,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..e55edcae89ef 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,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 (1), Stop (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..272ebb7c2af4 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,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 (1), Stop (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 b5a6dad82516..e6bbf8a0a0f0 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1505,6 +1505,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 { @@ -1526,6 +1527,27 @@ pub(super) fn plugins_test_detail( enabled: true, }) .collect(), + hooks: hooks + .iter() + .enumerate() + .flat_map(|(event_index, (event_name, handler_count))| { + (0..*handler_count).map(move |handler_index| { + codex_app_server_protocol::PluginHookSummary { + key: format!("plugin:{event_index}:{handler_index}"), + event_name: *event_name, + matcher: None, + enabled: true, + status_message: None, + definition: serde_json::json!({ + "type": "command", + "command": "echo plugin hook", + }), + display_order: i64::try_from(event_index + handler_index) + .expect("test hook display order should fit in i64"), + } + }) + }) + .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 00f1b3044bbf..cd6fddf5e8c6 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -656,6 +656,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, 1), + (codex_app_server_protocol::HookEventName::Stop, 2), + ], &[("Figma", true), ("Slack", false)], &["figma-mcp", "docs-mcp"], ), @@ -696,6 +700,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, 1), + (codex_app_server_protocol::HookEventName::Stop, 2), + ], &[("Figma", true), ("Slack", false)], &["figma-mcp", "docs-mcp"], ), From 2f741f43a1ce5c79b9aba56bcd9eb954e20d6762 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Wed, 6 May 2026 18:16:59 -0700 Subject: [PATCH 2/2] Simplify plugin hook summaries --- .../codex_app_server_protocol.schemas.json | 23 ---- .../codex_app_server_protocol.v2.schemas.json | 23 ---- .../schema/json/v2/PluginReadResponse.json | 23 ---- .../schema/typescript/v2/PluginHookSummary.ts | 3 +- .../src/protocol/v2/plugin.rs | 5 - codex-rs/app-server/README.md | 2 +- .../src/request_processors/plugins.rs | 5 - .../app-server/tests/suite/v2/plugin_read.rs | 33 ------ codex-rs/core-plugins/src/manager.rs | 66 ++---------- codex-rs/core-plugins/src/manager_tests.rs | 33 ------ codex-rs/hooks/src/declarations.rs | 100 ++++++++++++++++++ codex-rs/hooks/src/engine/discovery.rs | 5 +- codex-rs/hooks/src/lib.rs | 3 + codex-rs/tui/src/chatwidget/tests/helpers.rs | 9 -- 14 files changed, 117 insertions(+), 216 deletions(-) create mode 100644 codex-rs/hooks/src/declarations.rs 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 bf2cb4927d06..45459cca2ac5 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 @@ -12175,37 +12175,14 @@ }, "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" ], 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 5b6c4428ccb0..47f4f3f55834 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 @@ -8786,37 +8786,14 @@ }, "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" ], 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 f38b3e970b8a..dd0dab6bb18c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -133,37 +133,14 @@ }, "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" ], 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 1c11387c96d3..48046bbd7ad8 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginHookSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginHookSummary.ts @@ -1,7 +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 { 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, }; +export type PluginHookSummary = { key: string, eventName: HookEventName, }; 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 7acfaa32be03..53aa86101865 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -563,11 +563,6 @@ pub struct PluginDetail { pub struct PluginHookSummary { pub key: String, pub event_name: HookEventName, - pub matcher: Option, - pub enabled: bool, - pub status_message: Option, - pub definition: serde_json::Value, - 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 a0023c3d49e5..c10843ae201f 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -208,7 +208,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, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), 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/hooks/apps/MCP server names. Returned plugin skills and hooks 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; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**). - `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle. - `skills/changed` — notification emitted when watched local skill files change. - `app/list` — list available apps. diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index db32af4d65e8..3616d1ebbe5b 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -622,11 +622,6 @@ impl PluginRequestProcessor { .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, 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 cc206f9edc31..bbd7a09cf955 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -1023,47 +1023,14 @@ enabled = false 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, }, ] ); diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index b8abd57b2b46..adb8084cdc02 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -55,13 +55,11 @@ use codex_config::set_user_plugin_enabled; use codex_config::types::PluginConfig; use codex_config::version_for_toml; use codex_core_skills::SkillMetadata; -use codex_hooks::hook_key; -use codex_hooks::hook_states_from_stack; +use codex_hooks::plugin_hook_declarations; 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; @@ -69,7 +67,6 @@ use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::Product; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_plugins::PluginSkillRoot; -use serde_json::Value as JsonValue; use std::collections::HashMap; use std::collections::HashSet; use std::path::PathBuf; @@ -244,11 +241,6 @@ pub struct PluginDetail { 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)] @@ -1380,10 +1372,13 @@ impl PluginsManager { let plugin_data_root = self.store.plugin_data_root(&plugin_id); let (hook_sources, _hook_load_warnings) = load_plugin_hooks(&source_path, &plugin_id, &plugin_data_root, &manifest.paths); - summarize_plugin_hooks( - &hook_sources, - &hook_states_from_stack(Some(&config.config_layer_stack)), - ) + plugin_hook_declarations(&hook_sources) + .into_iter() + .map(|hook| PluginHookSummary { + key: hook.key, + event_name: hook.event_name, + }) + .collect() } else { Vec::new() }; @@ -1885,51 +1880,6 @@ impl PluginsManager { } } -fn summarize_plugin_hooks( - hook_sources: &[PluginHookSource], - hook_states: &HashMap, -) -> 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.clone().into_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: hook_states.get(&key).and_then(|state| state.enabled) - != Some(false), - 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-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 608cda8eefe6..06736a853ce6 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -2020,47 +2020,14 @@ enabled = false 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, }, ] ); diff --git a/codex-rs/hooks/src/declarations.rs b/codex-rs/hooks/src/declarations.rs new file mode 100644 index 000000000000..6c414eaf8195 --- /dev/null +++ b/codex-rs/hooks/src/declarations.rs @@ -0,0 +1,100 @@ +use codex_plugin::PluginHookSource; +use codex_protocol::protocol::HookEventName; + +/// Minimal declaration metadata for one bundled plugin hook handler. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginHookDeclaration { + pub key: String, + pub event_name: HookEventName, +} + +/// Return the hook handlers declared by plugin bundles without projecting live runtime state. +pub fn plugin_hook_declarations(hook_sources: &[PluginHookSource]) -> Vec { + let mut declarations = Vec::new(); + + for source in hook_sources { + let key_source = plugin_hook_key_source( + source.plugin_id.as_key().as_str(), + source.source_relative_path.as_str(), + ); + for (event_name, groups) in source.hooks.clone().into_matcher_groups() { + for (group_index, group) in groups.iter().enumerate() { + for (handler_index, _) in group.hooks.iter().enumerate() { + declarations.push(PluginHookDeclaration { + key: crate::hook_key(&key_source, event_name, group_index, handler_index), + event_name, + }); + } + } + } + } + + declarations +} + +pub(crate) fn plugin_hook_key_source(plugin_id: &str, source_relative_path: &str) -> String { + format!("{plugin_id}:{source_relative_path}") +} + +#[cfg(test)] +mod tests { + use codex_config::HookEventsToml; + use codex_config::HookHandlerConfig; + use codex_config::MatcherGroup; + use codex_plugin::PluginId; + use codex_utils_absolute_path::test_support::PathBufExt; + use codex_utils_absolute_path::test_support::test_path_buf; + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn lists_declared_plugin_handlers_with_persisted_hook_keys() { + let plugin_root = test_path_buf("/tmp/plugin").abs(); + let source_path = plugin_root.join("hooks/hooks.json"); + let declarations = plugin_hook_declarations(&[PluginHookSource { + plugin_id: PluginId::parse("demo@test").expect("plugin id"), + plugin_root: plugin_root.clone(), + plugin_data_root: plugin_root.join("data"), + source_path, + source_relative_path: "hooks/hooks.json".to_string(), + hooks: HookEventsToml { + pre_tool_use: vec![MatcherGroup { + matcher: None, + hooks: vec![ + HookHandlerConfig::Prompt {}, + HookHandlerConfig::Command { + command: "echo hi".to_string(), + timeout_sec: None, + r#async: false, + status_message: None, + }, + ], + }], + session_start: vec![MatcherGroup { + matcher: None, + hooks: vec![HookHandlerConfig::Agent {}], + }], + ..Default::default() + }, + }]); + + assert_eq!( + declarations, + vec![ + PluginHookDeclaration { + key: "demo@test:hooks/hooks.json:pre_tool_use:0:0".to_string(), + event_name: HookEventName::PreToolUse, + }, + PluginHookDeclaration { + key: "demo@test:hooks/hooks.json:pre_tool_use:0:1".to_string(), + event_name: HookEventName::PreToolUse, + }, + PluginHookDeclaration { + key: "demo@test:hooks/hooks.json:session_start:0:0".to_string(), + event_name: HookEventName::SessionStart, + }, + ] + ); + } +} diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index fab682a5f098..cc180325b65e 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -192,7 +192,10 @@ fn append_plugin_hook_sources( display_order, HookHandlerSource { path: &source_path, - key_source: format!("{plugin_id}:{source_relative_path}"), + key_source: crate::declarations::plugin_hook_key_source( + plugin_id.as_str(), + source_relative_path.as_str(), + ), source: HookSource::Plugin, is_managed: false, hook_states, diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index 239d7f36d220..19e9a402a07a 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -1,4 +1,5 @@ mod config_rules; +mod declarations; mod engine; pub(crate) mod events; mod legacy_notify; @@ -10,6 +11,8 @@ mod types; use codex_protocol::protocol::HookEventName; pub use config_rules::hook_states_from_stack; +pub use declarations::PluginHookDeclaration; +pub use declarations::plugin_hook_declarations; pub use engine::HookListEntry; /// Hook event names as they appear in hooks JSON and config files. pub const HOOK_EVENT_NAMES: [&str; 6] = [ diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index e6bbf8a0a0f0..9e69855c9101 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1535,15 +1535,6 @@ pub(super) fn plugins_test_detail( codex_app_server_protocol::PluginHookSummary { key: format!("plugin:{event_index}:{handler_index}"), event_name: *event_name, - matcher: None, - enabled: true, - status_message: None, - definition: serde_json::json!({ - "type": "command", - "command": "echo plugin hook", - }), - display_order: i64::try_from(event_index + handler_index) - .expect("test hook display order should fit in i64"), } }) })