Add hooks/list app-server RPC (#19778)

## Why

We need a way to list the available hooks to expose via the TUI and App
so users can view and manage their hooks

## What

- Adds `hooks/list` for one or more `cwd` values that returns discovered
hook metadata

## Stack

1. openai/codex#19705
2. This PR - openai/codex#19778
3. openai/codex#19840
4. openai/codex#19882

## Review Notes

The generated schema files account for most of the raw diff, these files
have the core change:

- `hooks/src/engine/discovery.rs` builds the inventory entries during
hook discovery while leaving runtime handlers focused on execution.
- `app-server/src/codex_message_processor.rs` wires `hooks/list` into
the app-server flow for each requested `cwd`.
- `app-server-protocol/src/protocol/v2.rs` defines the new v2
request/response payloads exposed on the wire.

### Core Changes

`core/src/plugins/manager.rs` adds `plugins_for_layer_stack(...)` so
`skills/list` and `hooks/list`can resolve plugin state for each
requested `cwd`

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Abhinav
2026-04-29 16:39:57 -07:00
committed by GitHub
Unverified
parent 6eab7519b4
commit 8774229a89
28 changed files with 1405 additions and 193 deletions
+41 -9
View File
@@ -373,6 +373,7 @@ pub struct PluginsManager {
#[derive(Clone)]
struct CachedPluginLoadOutcome {
config_version: String,
plugin_hooks_enabled: bool,
outcome: PluginLoadOutcome,
}
@@ -443,9 +444,12 @@ impl PluginsManager {
return PluginLoadOutcome::default();
}
let plugin_hooks_enabled = config.features.enabled(Feature::PluginHooks);
let config_version = version_for_toml(&config.config_layer_stack.effective_config());
if !force_reload && let Some(outcome) = self.cached_enabled_outcome(&config_version) {
if !force_reload
&& let Some(outcome) =
self.cached_enabled_outcome(&config_version, plugin_hooks_enabled)
{
return outcome;
}
@@ -454,6 +458,7 @@ impl PluginsManager {
self.remote_installed_plugin_configs(config),
&self.store,
self.restriction_product,
plugin_hooks_enabled,
)
.await;
log_plugin_load_errors(&outcome);
@@ -463,6 +468,7 @@ impl PluginsManager {
};
*cache = Some(CachedPluginLoadOutcome {
config_version,
plugin_hooks_enabled,
outcome: outcome.clone(),
});
outcome
@@ -485,35 +491,61 @@ impl PluginsManager {
*cached_enabled_outcome = None;
}
/// Resolve plugin skill roots for a config layer stack without touching the plugins cache.
pub async fn effective_skill_roots_for_layer_stack(
/// Load plugins for a config layer stack without touching the plugins cache.
pub async fn plugins_for_layer_stack(
&self,
config_layer_stack: &ConfigLayerStack,
config: &Config,
) -> Vec<AbsolutePathBuf> {
plugin_hooks_feature_enabled: bool,
) -> PluginLoadOutcome {
if !config.features.enabled(Feature::Plugins) {
return Vec::new();
return PluginLoadOutcome::default();
}
load_plugins_from_layer_stack(
config_layer_stack,
self.remote_installed_plugin_configs(config),
&self.store,
self.restriction_product,
plugin_hooks_feature_enabled,
)
.await
}
/// Resolve plugin skill roots for a config layer stack without touching the plugins cache.
pub async fn effective_skill_roots_for_layer_stack(
&self,
config_layer_stack: &ConfigLayerStack,
config: &Config,
) -> Vec<AbsolutePathBuf> {
self.plugins_for_layer_stack(
config_layer_stack,
config,
config.features.enabled(Feature::PluginHooks),
)
.await
.effective_skill_roots()
}
fn cached_enabled_outcome(&self, config_version: &str) -> Option<PluginLoadOutcome> {
fn cached_enabled_outcome(
&self,
config_version: &str,
plugin_hooks_enabled: bool,
) -> Option<PluginLoadOutcome> {
match self.cached_enabled_outcome.read() {
Ok(cache) => cache
.as_ref()
.filter(|cached| cached.config_version == config_version)
.filter(|cached| {
cached.config_version == config_version
&& cached.plugin_hooks_enabled == plugin_hooks_enabled
})
.map(|cached| cached.outcome.clone()),
Err(err) => err
.into_inner()
.as_ref()
.filter(|cached| cached.config_version == config_version)
.filter(|cached| {
cached.config_version == config_version
&& cached.plugin_hooks_enabled == plugin_hooks_enabled
})
.map(|cached| cached.outcome.clone()),
}
}
@@ -97,6 +97,18 @@ fn run_git(repo: &Path, args: &[&str]) {
}
fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String {
plugin_config_toml_with_plugin_hooks(
enabled,
plugins_feature_enabled,
/*plugin_hooks_feature_enabled*/ false,
)
}
fn plugin_config_toml_with_plugin_hooks(
enabled: bool,
plugins_feature_enabled: bool,
plugin_hooks_feature_enabled: bool,
) -> String {
let mut root = toml::map::Map::new();
let mut features = toml::map::Map::new();
@@ -104,6 +116,10 @@ fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String {
"plugins".to_string(),
Value::Boolean(plugins_feature_enabled),
);
features.insert(
"plugin_hooks".to_string(),
Value::Boolean(plugin_hooks_feature_enabled),
);
root.insert("features".to_string(), Value::Table(features));
let mut plugin = toml::map::Map::new();
@@ -1067,6 +1083,61 @@ async fn load_plugins_returns_empty_when_feature_disabled() {
assert_eq!(outcome, PluginLoadOutcome::default());
}
#[tokio::test]
async fn plugins_for_config_reloads_when_plugin_hooks_enablement_changes() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
);
write_file(
&plugin_root.join("hooks/hooks.json"),
r#"{
"hooks": {
"PreToolUse": [
{
"hooks": [{ "type": "command", "command": "echo plugin hook" }]
}
]
}
}"#,
);
let manager = PluginsManager::new(codex_home.path().to_path_buf());
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
&plugin_config_toml_with_plugin_hooks(
/*enabled*/ true, /*plugins_feature_enabled*/ true,
/*plugin_hooks_feature_enabled*/ false,
),
);
let config_without_plugin_hooks = load_config(codex_home.path(), codex_home.path()).await;
let without_plugin_hooks = manager
.plugins_for_config(&config_without_plugin_hooks)
.await;
assert!(
without_plugin_hooks
.effective_plugin_hook_sources()
.is_empty()
);
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
&plugin_config_toml_with_plugin_hooks(
/*enabled*/ true, /*plugins_feature_enabled*/ true,
/*plugin_hooks_feature_enabled*/ true,
),
);
let config_with_plugin_hooks = load_config(codex_home.path(), codex_home.path()).await;
let with_plugin_hooks = manager.plugins_for_config(&config_with_plugin_hooks).await;
assert_eq!(with_plugin_hooks.effective_plugin_hook_sources().len(), 1);
}
#[tokio::test]
async fn load_plugins_rejects_invalid_plugin_keys() {
let codex_home = TempDir::new().unwrap();
@@ -3435,6 +3506,7 @@ async fn load_plugins_ignores_project_config_files() {
std::collections::HashMap::new(),
&PluginStore::new(codex_home.path().to_path_buf()),
Some(Product::Codex),
/*plugin_hooks_enabled*/ false,
)
.await;