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
@@ -76,6 +76,9 @@ use codex_app_server_protocol::GetConversationSummaryParams;
use codex_app_server_protocol::GetConversationSummaryResponse;
use codex_app_server_protocol::GitDiffToRemoteResponse;
use codex_app_server_protocol::GitInfo as ApiGitInfo;
use codex_app_server_protocol::HookMetadata;
use codex_app_server_protocol::HooksListParams;
use codex_app_server_protocol::HooksListResponse;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ListMcpServerStatusParams;
use codex_app_server_protocol::ListMcpServerStatusResponse;
@@ -233,6 +236,7 @@ use codex_chatgpt::connectors;
use codex_chatgpt::workspace_settings;
use codex_config::CloudRequirementsLoadError;
use codex_config::CloudRequirementsLoadErrorCode;
use codex_config::ConfigLayerStack;
use codex_config::loader::project_trust_key;
use codex_config::types::McpServerTransportConfig;
use codex_core::CodexThread;
@@ -719,6 +723,23 @@ impl CodexMessageProcessor {
.await
}
/// Resolve a caller-provided cwd into the absolute cwd and matching config layers
/// so list-style RPCs share the same per-cwd error handling.
async fn resolve_cwd_config(
&self,
cwd: &Path,
) -> Result<(AbsolutePathBuf, ConfigLayerStack), String> {
let cwd_abs =
AbsolutePathBuf::relative_to_current_dir(cwd).map_err(|err| err.to_string())?;
let config_layer_stack = self
.config_manager
.load_config_layers_for_cwd(cwd_abs.clone())
.await
.map_err(|err| err.to_string())?;
Ok((cwd_abs, config_layer_stack))
}
pub(crate) fn handle_config_mutation(&self) {
self.clear_plugin_related_caches();
}
@@ -1086,6 +1107,10 @@ impl CodexMessageProcessor {
self.skills_list(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::HooksList { request_id, params } => {
self.hooks_list(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::MarketplaceAdd { request_id, params } => {
self.marketplace_add(to_connection_request_id(request_id), params)
.await;
@@ -6248,43 +6273,24 @@ impl CodexMessageProcessor {
.map(|environment| environment.get_filesystem());
let mut data = Vec::new();
for cwd in cwds {
let (cwd_abs, config_layer_stack) = match self.resolve_cwd_config(&cwd).await {
Ok(resolved) => resolved,
Err(message) => {
let error_path = cwd.clone();
data.push(codex_app_server_protocol::SkillsListEntry {
cwd,
skills: Vec::new(),
errors: vec![codex_app_server_protocol::SkillErrorInfo {
path: error_path,
message,
}],
});
continue;
}
};
let extra_roots = extra_roots_by_cwd
.get(&cwd)
.map_or(&[][..], std::vec::Vec::as_slice);
let cwd_abs = match AbsolutePathBuf::relative_to_current_dir(cwd.as_path()) {
Ok(path) => path,
Err(err) => {
let error_path = cwd.clone();
data.push(codex_app_server_protocol::SkillsListEntry {
cwd,
skills: Vec::new(),
errors: vec![codex_app_server_protocol::SkillErrorInfo {
path: error_path,
message: err.to_string(),
}],
});
continue;
}
};
let config_layer_stack = match self
.config_manager
.load_config_layers_for_cwd(cwd_abs.clone())
.await
{
Ok(config_layer_stack) => config_layer_stack,
Err(err) => {
let error_path = cwd.clone();
data.push(codex_app_server_protocol::SkillsListEntry {
cwd,
skills: Vec::new(),
errors: vec![codex_app_server_protocol::SkillErrorInfo {
path: error_path,
message: err.to_string(),
}],
});
continue;
}
};
let effective_skill_roots = if workspace_codex_plugins_enabled {
plugins_manager
.effective_skill_roots_for_layer_stack(&config_layer_stack, &config)
@@ -6316,6 +6322,86 @@ impl CodexMessageProcessor {
}
Ok(SkillsListResponse { data })
}
async fn hooks_list(&self, request_id: ConnectionRequestId, params: HooksListParams) {
let result = self.hooks_list_response(params).await;
self.outgoing.send_result(request_id, result).await;
}
/// Handle `hooks/list` by resolving hooks for each requested cwd.
async fn hooks_list_response(
&self,
params: HooksListParams,
) -> Result<HooksListResponse, JSONRPCErrorError> {
let HooksListParams { cwds } = params;
let cwds = if cwds.is_empty() {
vec![self.config.cwd.to_path_buf()]
} else {
cwds
};
let auth = self.auth_manager.auth().await;
let plugins_manager = self.thread_manager.plugins_manager();
let mut data = Vec::new();
for cwd in cwds {
let config = match self
.config_manager
.load_for_cwd(
/*request_overrides*/ None,
ConfigOverrides::default(),
Some(cwd.clone()),
)
.await
{
Ok(config) => config,
Err(err) => {
let error_path = cwd.clone();
data.push(codex_app_server_protocol::HooksListEntry {
cwd,
hooks: Vec::new(),
warnings: Vec::new(),
errors: vec![codex_app_server_protocol::HookErrorInfo {
path: error_path,
message: err.to_string(),
}],
});
continue;
}
};
let workspace_codex_plugins_enabled = self
.workspace_codex_plugins_enabled(&config, auth.as_ref())
.await;
let plugins_enabled =
config.features.enabled(Feature::Plugins) && workspace_codex_plugins_enabled;
let plugin_outcome = if plugins_enabled && config.features.enabled(Feature::PluginHooks)
{
plugins_manager
.plugins_for_layer_stack(
&config.config_layer_stack,
&config,
/*plugin_hooks_feature_enabled*/ true,
)
.await
} else {
codex_core::plugins::PluginLoadOutcome::default()
};
let hooks = codex_hooks::list_hooks(codex_hooks::HooksConfig {
feature_enabled: config.features.enabled(Feature::CodexHooks),
config_layer_stack: Some(config.config_layer_stack),
plugin_hook_sources: plugin_outcome.effective_plugin_hook_sources(),
plugin_hook_load_warnings: plugin_outcome.effective_plugin_hook_warnings(),
..Default::default()
});
data.push(codex_app_server_protocol::HooksListEntry {
cwd,
hooks: hooks_to_info(&hooks.hooks),
warnings: hooks.warnings,
errors: Vec::new(),
});
}
Ok(HooksListResponse { data })
}
async fn marketplace_remove(
&self,
request_id: ConnectionRequestId,
@@ -8623,6 +8709,24 @@ fn skills_to_info(
.collect()
}
fn hooks_to_info(hooks: &[codex_hooks::HookListEntry]) -> Vec<HookMetadata> {
hooks
.iter()
.map(|hook| HookMetadata {
event_name: hook.event_name.into(),
handler_type: hook.handler_type.into(),
matcher: hook.matcher.clone(),
command: hook.command.clone(),
timeout_sec: hook.timeout_sec,
status_message: hook.status_message.clone(),
source_path: hook.source_path.clone(),
source: hook.source.into(),
plugin_id: hook.plugin_id.clone(),
display_order: hook.display_order,
})
.collect()
}
fn plugin_skills_to_info(
skills: &[codex_core::skills::SkillMetadata],
disabled_skill_paths: &std::collections::HashSet<AbsolutePathBuf>,