diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 2367cb5c2ad1..29178947be81 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2569,6 +2569,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 06ccdb48b263..4b2c232b109e 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 @@ -12144,6 +12144,12 @@ "null" ] }, + "hooks": { + "items": { + "$ref": "#/definitions/v2/PluginHookSummary" + }, + "type": "array" + }, "marketplaceName": { "type": "string" }, @@ -12175,6 +12181,7 @@ }, "required": [ "apps", + "hooks", "marketplaceName", "mcpServers", "skills", @@ -12182,6 +12189,21 @@ ], "type": "object" }, + "PluginHookSummary": { + "properties": { + "eventName": { + "$ref": "#/definitions/v2/HookEventName" + }, + "key": { + "type": "string" + } + }, + "required": [ + "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 852cc2489d08..baec025e23a5 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 @@ -8755,6 +8755,12 @@ "null" ] }, + "hooks": { + "items": { + "$ref": "#/definitions/PluginHookSummary" + }, + "type": "array" + }, "marketplaceName": { "type": "string" }, @@ -8786,6 +8792,7 @@ }, "required": [ "apps", + "hooks", "marketplaceName", "mcpServers", "skills", @@ -8793,6 +8800,21 @@ ], "type": "object" }, + "PluginHookSummary": { + "properties": { + "eventName": { + "$ref": "#/definitions/HookEventName" + }, + "key": { + "type": "string" + } + }, + "required": [ + "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..b3ec8dd6ec2f 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,19 @@ ], "type": "object" }, + "HookEventName": { + "enum": [ + "preToolUse", + "permissionRequest", + "postToolUse", + "preCompact", + "postCompact", + "sessionStart", + "userPromptSubmit", + "stop" + ], + "type": "string" + }, "PluginAuthPolicy": { "enum": [ "ON_INSTALL", @@ -75,6 +88,12 @@ "null" ] }, + "hooks": { + "items": { + "$ref": "#/definitions/PluginHookSummary" + }, + "type": "array" + }, "marketplaceName": { "type": "string" }, @@ -106,6 +125,7 @@ }, "required": [ "apps", + "hooks", "marketplaceName", "mcpServers", "skills", @@ -113,6 +133,21 @@ ], "type": "object" }, + "PluginHookSummary": { + "properties": { + "eventName": { + "$ref": "#/definitions/HookEventName" + }, + "key": { + "type": "string" + } + }, + "required": [ + "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..48046bbd7ad8 --- /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 = { key: string, eventName: HookEventName, }; 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..53aa86101865 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,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, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginHookSummary { + pub key: String, + pub event_name: HookEventName, +} + #[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..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/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; 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 bb1ebb611155..3616d1ebbe5b 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -615,6 +615,15 @@ 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(), + }) + .collect(), apps: app_summaries, mcp_servers: outcome.plugin.mcp_server_names, } @@ -1490,6 +1499,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..bbd7a09cf955 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,23 @@ 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, + }, + codex_app_server_protocol::PluginHookSummary { + key: "demo-plugin@codex-curated:hooks/hooks.json:pre_tool_use:0:1".to_string(), + event_name: HookEventName::PreToolUse, + }, + codex_app_server_protocol::PluginHookSummary { + key: "demo-plugin@codex-curated:hooks/hooks.json:session_start:0:0".to_string(), + event_name: HookEventName::SessionStart, + }, + ] + ); 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..adb8084cdc02 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,6 +55,7 @@ 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::plugin_hook_declarations; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_plugin::AppConnectorId; @@ -61,6 +63,7 @@ use codex_plugin::PluginCapabilitySummary; 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; @@ -228,11 +231,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)] +pub struct PluginHookSummary { + pub key: String, + pub event_name: HookEventName, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PluginDetailsUnavailableReason { InstallRequiredForRemoteSource, @@ -1300,6 +1310,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 +1368,20 @@ 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); + plugin_hook_declarations(&hook_sources) + .into_iter() + .map(|hook| PluginHookSummary { + key: hook.key, + event_name: hook.event_name, + }) + .collect() + } 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 +1402,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, diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 50d0d4625838..06736a853ce6 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,23 @@ 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, + }, + PluginHookSummary { + key: "toolkit@debug:hooks/hooks.json:pre_tool_use:0:1".to_string(), + event_name: HookEventName::PreToolUse, + }, + PluginHookSummary { + key: "toolkit@debug:hooks/hooks.json:session_start:0:0".to_string(), + event_name: HookEventName::SessionStart, + }, + ] + ); 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/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 ed4322fb588f..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, @@ -416,13 +419,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 +495,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,19 +504,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::PreCompact => "pre_compact", - codex_protocol::protocol::HookEventName::PostCompact => "post_compact", - 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 6c4c672bcac5..7faa845077f7 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; @@ -7,6 +8,11 @@ mod registry; mod schema; 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; 8] = [ @@ -70,3 +76,30 @@ 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::PreCompact => "pre_compact", + HookEventName::PostCompact => "post_compact", + 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 56fe0b5b5db4..14f856bee53e 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,18 @@ 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, + } + }) + }) + .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"], ),