Add /hooks browser for lifecycle hooks (#19882)

## Why

`hooks/list` and `hooks/config/write` give us read/write access to hooks
and their state. This hooks up the TUI as a client so users can inspect
and manage that state directly.

## What

- add a two-page `/hooks` browser in the TUI: an event overview with
installed/active counts, followed by a per-event handler page with
toggle controls and detail rendering
- thread managed-state metadata through hook discovery and `hooks/list`
so the UI can label admin-managed hooks and suppress toggles for them
- persist hook toggles through the existing config-write path and add
snapshot coverage for the event list, handler list, managed-hook, and
empty states

## Stack

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

## Reviewer Notes

- Main UI logic is in
`codex-rs/tui/src/bottom_pane/hooks_browser_view.rs`; most of the diff
is the new view plus its snapshot coverage
- Request / write plumbing for opening the browser and persisting
toggles is in `codex-rs/tui/src/app/background_requests.rs` and
`codex-rs/tui/src/chatwidget/hooks.rs`
- Outside the TUI, the only behavioral change in this PR is threading
`is_managed` through hook discovery and `hooks/list` so managed hooks
render as non-toggleable
- The `codex-rs/tui/src/status/snapshots/` churn is unrelated merge
fallout from the stacked base branch's newer permission-label rendering

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Abhinav
2026-04-30 11:58:27 -07:00
committed by GitHub
Unverified
parent 719431da6e
commit 93d53f655b
25 changed files with 1465 additions and 4 deletions
+24 -1
View File
@@ -122,6 +122,16 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
assert!(engine.warnings().is_empty());
assert_eq!(engine.handlers.len(), 1);
assert!(engine.handlers[0].source.is_managed());
let listed = crate::list_hooks(crate::HooksConfig {
legacy_notify_argv: None,
feature_enabled: true,
config_layer_stack: Some(config_layer_stack.clone()),
plugin_hook_sources: Vec::new(),
plugin_hook_load_warnings: Vec::new(),
shell_program: None,
shell_args: Vec::new(),
});
assert!(listed.hooks[0].is_managed);
let cwd = cwd();
let preview = engine.preview_pre_tool_use(&PreToolUseRequest {
session_id: ThreadId::new(),
@@ -560,7 +570,7 @@ print(json.dumps({
let engine = ClaudeHooksEngine::new(
/*enabled*/ true,
/*config_layer_stack*/ None,
plugin_hook_sources,
plugin_hook_sources.clone(),
Vec::new(),
CommandShell {
program: String::new(),
@@ -583,6 +593,19 @@ print(json.dumps({
assert_eq!(preview.len(), 1);
assert_eq!(preview[0].source, HookSource::Plugin);
assert_eq!(preview[0].source_path, source_path);
let listed = crate::list_hooks(crate::HooksConfig {
legacy_notify_argv: None,
feature_enabled: true,
config_layer_stack: None,
plugin_hook_sources,
plugin_hook_load_warnings: Vec::new(),
shell_program: None,
shell_args: Vec::new(),
});
assert_eq!(
listed.hooks[0].plugin_id.as_deref(),
Some("demo-plugin@test-marketplace")
);
let outcome = engine
.run_pre_tool_use(PreToolUseRequest {
+3 -1
View File
@@ -11,6 +11,8 @@ use std::path::PathBuf;
use std::str::FromStr;
use std::time::Duration;
use strum_macros::EnumIter;
use crate::AgentPath;
use crate::ThreadId;
use crate::approvals::ElicitationRequestEvent;
@@ -1529,7 +1531,7 @@ pub enum EventMsg {
CollabResumeEnd(CollabResumeEndEvent),
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS, EnumIter)]
#[serde(rename_all = "snake_case")]
pub enum HookEventName {
PreToolUse,
+7
View File
@@ -84,12 +84,15 @@ use codex_app_server_protocol::AddCreditsNudgeCreditType;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigLayerSource;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ConfigWriteResponse;
use codex_app_server_protocol::FeedbackUploadParams;
use codex_app_server_protocol::FeedbackUploadResponse;
use codex_app_server_protocol::GetAccountRateLimitsResponse;
use codex_app_server_protocol::HooksListParams;
use codex_app_server_protocol::HooksListResponse;
use codex_app_server_protocol::ListMcpServerStatusParams;
use codex_app_server_protocol::ListMcpServerStatusResponse;
#[cfg(test)]
@@ -496,6 +499,9 @@ pub(crate) struct App {
// overwrite a newer toggle, even if the plugin is toggled from different
// cwd contexts.
pending_plugin_enabled_writes: HashMap<String, Option<bool>>,
// Serialize hook enablement writes per hook so stale completions cannot
// persist an older toggle after a newer one.
pending_hook_enabled_writes: HashMap<String, Option<bool>>,
}
fn active_turn_not_steerable_turn_error(error: &TypedRequestError) -> Option<AppServerTurnError> {
@@ -858,6 +864,7 @@ See the Codex keymap documentation for supported actions and examples."
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
pending_plugin_enabled_writes: HashMap::new(),
pending_hook_enabled_writes: HashMap::new(),
};
if let Some(started) = initial_started_thread {
app.enqueue_primary_thread_session(started.session, started.turns)
@@ -95,6 +95,17 @@ impl App {
});
}
pub(super) fn fetch_hooks_list(&mut self, app_server: &AppServerSession, cwd: PathBuf) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result = fetch_hooks_list(request_handle, cwd.clone())
.await
.map_err(|err| err.to_string());
app_event_tx.send(AppEvent::HooksLoaded { cwd, result });
});
}
pub(super) fn fetch_plugin_detail(
&mut self,
app_server: &AppServerSession,
@@ -250,6 +261,43 @@ impl App {
});
}
pub(super) fn set_hook_enabled(
&mut self,
app_server: &AppServerSession,
key: String,
enabled: bool,
) {
if let Some(queued_enabled) = self.pending_hook_enabled_writes.get_mut(&key) {
*queued_enabled = Some(enabled);
return;
}
self.pending_hook_enabled_writes.insert(key.clone(), None);
self.spawn_hook_enabled_write(app_server, key, enabled);
}
pub(super) fn spawn_hook_enabled_write(
&mut self,
app_server: &AppServerSession,
key: String,
enabled: bool,
) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let key_for_event = key.clone();
let result = write_hook_enabled(request_handle, key, enabled)
.await
.map(|_| ())
.map_err(|err| format!("Failed to update hook config: {err}"));
app_event_tx.send(AppEvent::HookEnabledSet {
key: key_for_event,
enabled,
result,
});
});
}
pub(super) fn refresh_plugin_mentions(&mut self) {
let config = self.config.clone();
let app_event_tx = self.app_event_tx.clone();
@@ -542,6 +590,20 @@ pub(super) async fn fetch_plugins_list(
Ok(response)
}
pub(super) async fn fetch_hooks_list(
request_handle: AppServerRequestHandle,
cwd: PathBuf,
) -> Result<HooksListResponse> {
let request_id = RequestId::String(format!("hooks-list-{}", Uuid::new_v4()));
request_handle
.request_typed(ClientRequest::HooksList {
request_id,
params: HooksListParams { cwds: vec![cwd] },
})
.await
.wrap_err("hooks/list failed in TUI")
}
const CLI_HIDDEN_PLUGIN_MARKETPLACES: &[&str] = &["openai-bundled"];
pub(super) fn hide_cli_only_plugin_marketplaces(response: &mut PluginListResponse) {
@@ -676,6 +738,34 @@ pub(super) async fn write_plugin_enabled(
.wrap_err("config/value/write failed while updating plugin enablement in TUI")
}
pub(super) async fn write_hook_enabled(
request_handle: AppServerRequestHandle,
key: String,
enabled: bool,
) -> Result<ConfigWriteResponse> {
let request_id = RequestId::String(format!("hooks-config-write-{}", Uuid::new_v4()));
request_handle
.request_typed(ClientRequest::ConfigBatchWrite {
request_id,
params: ConfigBatchWriteParams {
edits: vec![codex_app_server_protocol::ConfigEdit {
key_path: "hooks.state".to_string(),
value: serde_json::json!({
key: {
"enabled": enabled,
}
}),
merge_strategy: MergeStrategy::Upsert,
}],
file_path: None,
expected_version: None,
reload_user_config: true,
},
})
.await
.wrap_err("config/batchWrite failed while updating hook enablement in TUI")
}
pub(super) fn build_feedback_upload_params(
origin_thread_id: Option<ThreadId>,
rollout_path: Option<PathBuf>,
+33
View File
@@ -384,6 +384,9 @@ impl App {
AppEvent::FetchPluginsList { cwd } => {
self.fetch_plugins_list(app_server, cwd);
}
AppEvent::FetchHooksList { cwd } => {
self.fetch_hooks_list(app_server, cwd);
}
AppEvent::OpenMarketplaceAddPrompt => {
self.chat_widget.open_marketplace_add_prompt();
}
@@ -426,6 +429,9 @@ impl App {
AppEvent::PluginsLoaded { cwd, result } => {
self.chat_widget.on_plugins_loaded(cwd, result);
}
AppEvent::HooksLoaded { cwd, result } => {
self.chat_widget.on_hooks_loaded(cwd, result);
}
AppEvent::FetchMarketplaceAdd { cwd, source } => {
self.fetch_marketplace_add(app_server, cwd, source);
}
@@ -1654,6 +1660,33 @@ impl App {
}
}
}
AppEvent::SetHookEnabled { key, enabled } => {
self.set_hook_enabled(app_server, key, enabled);
}
AppEvent::HookEnabledSet {
key,
enabled,
result,
} => {
let queued_enabled = self
.pending_hook_enabled_writes
.get_mut(&key)
.and_then(Option::take);
let should_apply_result = if let Some(queued_enabled) = queued_enabled
&& (result.is_err() || queued_enabled != enabled)
{
self.spawn_hook_enabled_write(app_server, key.clone(), queued_enabled);
false
} else {
true
};
if should_apply_result {
self.pending_hook_enabled_writes.remove(&key);
if let Err(err) = result {
self.chat_widget.add_error_message(err);
}
}
}
AppEvent::OpenPermissionsPopup => {
self.chat_widget.open_permissions_popup();
}
+1
View File
@@ -59,6 +59,7 @@ pub(super) async fn make_test_app() -> App {
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
pending_plugin_enabled_writes: HashMap::new(),
pending_hook_enabled_writes: HashMap::new(),
}
}
+2
View File
@@ -3770,6 +3770,7 @@ async fn make_test_app() -> App {
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
pending_plugin_enabled_writes: HashMap::new(),
pending_hook_enabled_writes: HashMap::new(),
}
}
@@ -3830,6 +3831,7 @@ async fn make_test_app_with_channels() -> (
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
pending_plugin_enabled_writes: HashMap::new(),
pending_hook_enabled_writes: HashMap::new(),
},
rx,
op_rx,
+24
View File
@@ -290,12 +290,23 @@ pub(crate) enum AppEvent {
cwd: PathBuf,
},
/// Fetch lifecycle hook inventory for the provided working directory.
FetchHooksList {
cwd: PathBuf,
},
/// Result of fetching plugin marketplace state.
PluginsLoaded {
cwd: PathBuf,
result: Result<PluginListResponse, String>,
},
/// Result of fetching lifecycle hook inventory.
HooksLoaded {
cwd: PathBuf,
result: Result<codex_app_server_protocol::HooksListResponse, String>,
},
/// Open the prompt for adding a marketplace source.
OpenMarketplaceAddPrompt,
@@ -714,6 +725,19 @@ pub(crate) enum AppEvent {
enabled: bool,
},
/// Enable or disable a hook by stable hook key.
SetHookEnabled {
key: String,
enabled: bool,
},
/// Result of persisting hook enabled state.
HookEnabledSet {
key: String,
enabled: bool,
result: Result<(), String>,
},
/// Notify that the manage skills popup was closed.
ManageSkillsClosed,
File diff suppressed because it is too large Load Diff
+2
View File
@@ -110,6 +110,7 @@ pub(crate) use list_selection_view::popup_content_width;
pub(crate) use list_selection_view::side_by_side_layout_widths;
pub(crate) use memories_settings_view::MemoriesSettingsView;
mod feedback_view;
mod hooks_browser_view;
pub(crate) use feedback_view::FeedbackAudience;
pub(crate) use feedback_view::feedback_classification;
pub(crate) use feedback_view::feedback_disabled_params;
@@ -136,6 +137,7 @@ mod selection_tabs;
mod textarea;
mod unified_exec_footer;
pub(crate) use feedback_view::FeedbackNoteView;
pub(crate) use hooks_browser_view::HooksBrowserView;
pub(crate) use selection_tabs::SelectionTab;
/// How long the "press again to quit" hint stays visible.
@@ -0,0 +1,19 @@
---
source: tui/src/bottom_pane/hooks_browser_view.rs
expression: "render_lines(&view, 44)"
---
PreToolUse hooks
Turn hooks on or off. Your changes are s
[x] Hook 1
Event PreToolUse
Matcher Bash
Source User config - /tmp/h.json
Command one two three four five six
seven eight nine ten eleven
twelve thirteen fourteen…
Timeout 30s
Press space or enter to toggle; esc to go
@@ -0,0 +1,11 @@
---
source: tui/src/bottom_pane/hooks_browser_view.rs
expression: "render_lines(&view, 112)"
---
PermissionRequest hooks
Turn hooks on or off. Your changes are saved automatically.
No hooks installed for this event.
Press esc to go back
@@ -0,0 +1,17 @@
---
source: tui/src/bottom_pane/hooks_browser_view.rs
expression: "render_lines(&view, 112)"
---
Hooks
Lifecycle hooks from config and enabled plugins.
Event Installed Active Description
PreToolUse 2 1 Before a tool executes
PermissionRequest 1 1 When permission is requested
PostToolUse 0 0 After a tool executes
SessionStart 0 0 When a new session starts
UserPromptSubmit 0 0 When the user submits a prompt
Stop 0 0 Right before Codex ends its turn
Press enter to view hooks; esc to close
@@ -0,0 +1,21 @@
---
source: tui/src/bottom_pane/hooks_browser_view.rs
expression: "render_lines(&view, 112)"
---
Hooks
Lifecycle hooks from config and enabled plugins.
Issues
⚠ skipped invalid matcher for PreToolUse
■ /tmp/hooks.json: failed to parse hooks config
Event Installed Active Description
PreToolUse 0 0 Before a tool executes
PermissionRequest 0 0 When permission is requested
PostToolUse 0 0 After a tool executes
SessionStart 0 0 When a new session starts
UserPromptSubmit 0 0 When the user submits a prompt
Stop 0 0 Right before Codex ends its turn
Press enter to view hooks; esc to close
@@ -0,0 +1,18 @@
---
source: tui/src/bottom_pane/hooks_browser_view.rs
expression: "render_lines(&view, 112)"
---
PreToolUse hooks
Turn hooks on or off. Your changes are saved automatically.
[x] Hook 1
[ ] Hook 2
Event PreToolUse
Matcher Bash
Source Plugin - superpowers@openai-curated
Command ${CODEX_PLUGIN_ROOT}/hooks/pre-tool-use-check.sh
Timeout 30s
Press space or enter to toggle; esc to go back
@@ -0,0 +1,17 @@
---
source: tui/src/bottom_pane/hooks_browser_view.rs
expression: "render_lines(&view, 112)"
---
PermissionRequest hooks
Turn hooks on or off. Your changes are saved automatically.
[x] Hook 1
Event PermissionRequest
Matcher Bash
Source Admin config
Command /enterprise/hooks/permission-check.sh
Timeout 30s
Managed hooks are always on; press esc to go back
@@ -0,0 +1,24 @@
---
source: tui/src/bottom_pane/hooks_browser_view.rs
expression: "render_lines(&view, 112)"
---
PreToolUse hooks
Turn hooks on or off. Your changes are saved automatically.
[x] Hook 2
[x] Hook 3
[x] Hook 4
[x] Hook 5
[x] Hook 6
[x] Hook 7
[x] Hook 8
[x] Hook 9
Event PreToolUse
Matcher Bash
Source User config - /tmp/hooks.json
Command /tmp/hook-8.sh
Timeout 30s
Press space or enter to toggle; esc to go back
@@ -0,0 +1,18 @@
---
source: tui/src/bottom_pane/hooks_browser_view.rs
expression: "render_lines(&view, 112)"
---
PreToolUse hooks
Turn hooks on or off. Your changes are saved automatically.
[x] Hook 1
[x] Hook 2
Event PreToolUse
Matcher Bash
Source Admin config
Command /enterprise/hooks/pre-tool-use-2.sh
Timeout 30s
Managed hooks are always on; press esc to go back
+1
View File
@@ -327,6 +327,7 @@ mod mcp_startup;
use self::mcp_startup::McpStartupStatus;
mod session_header;
use self::session_header::SessionHeader;
mod hooks;
mod skills;
mod slash_dispatch;
use self::skills::collect_tool_mentions;
+43
View File
@@ -0,0 +1,43 @@
use std::path::PathBuf;
use super::ChatWidget;
use crate::app_event::AppEvent;
use crate::bottom_pane::HooksBrowserView;
use codex_app_server_protocol::HooksListResponse;
impl ChatWidget {
pub(crate) fn add_hooks_output(&mut self) {
self.app_event_tx.send(AppEvent::FetchHooksList {
cwd: self.config.cwd.to_path_buf(),
});
}
pub(crate) fn on_hooks_loaded(
&mut self,
cwd: PathBuf,
result: Result<HooksListResponse, String>,
) {
if self.config.cwd.as_path() != cwd.as_path() {
return;
}
match result {
Ok(response) => {
let (hooks, warnings, errors) = response
.data
.into_iter()
.find(|entry| entry.cwd.as_path() == cwd.as_path())
.map(|entry| (entry.hooks, entry.warnings, entry.errors))
.unwrap_or_default();
self.bottom_pane.show_view(Box::new(HooksBrowserView::new(
hooks,
warnings,
errors,
self.app_event_tx.clone(),
)));
self.request_redraw();
}
Err(err) => self.add_error_message(format!("Failed to load hooks: {err}")),
}
}
}
@@ -345,6 +345,9 @@ impl ChatWidget {
SlashCommand::Skills => {
self.open_skills_menu();
}
SlashCommand::Hooks => {
self.add_hooks_output();
}
SlashCommand::Status => {
if self.should_prefetch_rate_limits() {
let request_id = self.next_status_refresh_request_id;
@@ -877,6 +880,7 @@ impl ChatWidget {
| SlashCommand::Logout
| SlashCommand::Mention
| SlashCommand::Skills
| SlashCommand::Hooks
| SlashCommand::Title
| SlashCommand::Statusline
| SlashCommand::Theme => QueueDrain::Stop,
@@ -0,0 +1,20 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Hooks
Lifecycle hooks from config and enabled plugins.
Issues
⚠ skipped invalid matcher for PreToolUse
■ /tmp/hooks.json: failed to parse hooks config
Event Installed Active Description
PreToolUse 0 0 Before a tool executes
PermissionRequest 0 0 When permission is requested
PostToolUse 0 0 After a tool executes
SessionStart 0 0 When a new session starts
UserPromptSubmit 0 0 When the user submits a prompt
Stop 0 0 Right before Codex ends its turn
Press enter to view hooks; esc to close
+8 -2
View File
@@ -35,12 +35,18 @@ pub(super) fn truncated_path_variants(path: &str) -> Vec<String> {
pub(super) fn normalize_snapshot_paths(text: impl Into<String>) -> String {
let mut text = text.into();
for unix_path in ["/tmp/project", "/tmp/hooks.json"] {
let platform_path = test_path_display(unix_path);
if platform_path != unix_path {
text = text.replace(&platform_path, unix_path);
}
}
let platform_test_cwd = test_path_display("/tmp/project");
if platform_test_cwd == "/tmp/project" {
text
} else {
text = text.replace(&platform_test_cwd, "/tmp/project");
for platform_prefix in truncated_path_variants(&platform_test_cwd)
.into_iter()
.rev()
@@ -1,5 +1,8 @@
use super::*;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::HookErrorInfo;
use codex_app_server_protocol::HooksListEntry;
use codex_app_server_protocol::HooksListResponse;
use codex_app_server_protocol::MarketplaceRemoveResponse;
use codex_features::Stage;
use pretty_assertions::assert_eq;
@@ -104,6 +107,30 @@ async fn plugins_popup_loading_state_snapshot() {
assert_chatwidget_snapshot!("plugins_popup_loading_state", popup);
}
#[tokio::test]
async fn hooks_popup_shows_list_diagnostics() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let cwd = chat.config.cwd.clone();
chat.on_hooks_loaded(
cwd.to_path_buf(),
Ok(HooksListResponse {
data: vec![HooksListEntry {
cwd: cwd.to_path_buf(),
hooks: Vec::new(),
warnings: vec!["skipped invalid matcher for PreToolUse".to_string()],
errors: vec![HookErrorInfo {
path: test_path_buf("/tmp/hooks.json"),
message: "failed to parse hooks config".to_string(),
}],
}],
}),
);
let popup = normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 112));
assert_chatwidget_snapshot!("hooks_popup_shows_list_diagnostics", popup);
}
#[tokio::test]
async fn plugins_popup_snapshot_shows_all_marketplaces_and_sorts_installed_then_name() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
+3
View File
@@ -26,6 +26,7 @@ pub enum SlashCommand {
AutoReview,
Memories,
Skills,
Hooks,
Review,
Rename,
New,
@@ -91,6 +92,7 @@ impl SlashCommand {
SlashCommand::Diff => "show git diff (including untracked files)",
SlashCommand::Mention => "mention a file",
SlashCommand::Skills => "use skills to improve how Codex performs specific tasks",
SlashCommand::Hooks => "view and manage lifecycle hooks",
SlashCommand::Status => "show current session configuration and token usage",
SlashCommand::DebugConfig => "show config layers and requirement sources for debugging",
SlashCommand::Title => "configure which items appear in the terminal title",
@@ -191,6 +193,7 @@ impl SlashCommand {
| SlashCommand::Rename
| SlashCommand::Mention
| SlashCommand::Skills
| SlashCommand::Hooks
| SlashCommand::Status
| SlashCommand::DebugConfig
| SlashCommand::Ps