From 8720b7bdce451c2f805894501317579b7c667c39 Mon Sep 17 00:00:00 2001 From: Abhinav Date: Thu, 16 Apr 2026 12:43:16 -0700 Subject: [PATCH] Add codex_hook_run analytics event (#17996) # Why Add product analytics for hook handler executions so we can understand which hooks are running, where they came from, and whether they completed, failed, stopped, or blocked work. # What - add the new `codex_hook_run` analytics event and payload plumbing in `codex-rs/analytics` - emit hook-run analytics from the shared hook completion path in `codex-rs/core` - classify hook source from the loaded hook path as `system`, `user`, `project`, or `unknown` ``` { "event_type": "codex_hook_run", "event_params": { "thread_id": "string", "turn_id": "string", "model_slug": "string", "hook_name": "string, // any HookEventName "hook_source": "system | user | project | unknown", "status": "completed | failed | stopped | blocked" } } ``` --------- Co-authored-by: Codex --- .../analytics/src/analytics_client_tests.rs | 141 ++++++++++ codex-rs/analytics/src/client.rs | 8 + codex-rs/analytics/src/events.rs | 66 +++++ codex-rs/analytics/src/facts.rs | 15 + codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 14 + .../schema/json/ServerNotification.json | 21 ++ .../codex_app_server_protocol.schemas.json | 21 ++ .../codex_app_server_protocol.v2.schemas.json | 21 ++ .../json/v2/HookCompletedNotification.json | 21 ++ .../json/v2/HookStartedNotification.json | 21 ++ .../schema/typescript/v2/HookRunSummary.ts | 3 +- .../schema/typescript/v2/HookSource.ts | 5 + .../schema/typescript/v2/index.ts | 1 + .../app-server-protocol/src/protocol/v2.rs | 21 ++ codex-rs/config/src/lib.rs | 2 + codex-rs/core/src/codex.rs | 7 +- codex-rs/core/src/hook_runtime.rs | 105 ++++++- codex-rs/hooks/src/engine/discovery.rs | 264 +++++++++++------- codex-rs/hooks/src/engine/dispatcher.rs | 4 + codex-rs/hooks/src/engine/mod.rs | 2 + codex-rs/hooks/src/events/post_tool_use.rs | 1 + codex-rs/hooks/src/events/pre_tool_use.rs | 1 + codex-rs/hooks/src/events/session_start.rs | 1 + codex-rs/hooks/src/events/stop.rs | 1 + .../hooks/src/events/user_prompt_submit.rs | 1 + codex-rs/protocol/src/protocol.rs | 16 ++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/chatwidget/tests/helpers.rs | 2 + .../src/chatwidget/tests/status_and_layout.rs | 5 + codex-rs/tui/src/history_cell/hook_cell.rs | 1 + 32 files changed, 682 insertions(+), 114 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/HookSource.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index eb4667415..4b13b2819 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -4,6 +4,7 @@ use crate::events::CodexAppMentionedEventRequest; use crate::events::CodexAppServerClientMetadata; use crate::events::CodexAppUsedEventRequest; use crate::events::CodexCompactionEventRequest; +use crate::events::CodexHookRunEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; @@ -12,6 +13,7 @@ use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; +use crate::events::codex_hook_run_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; @@ -28,6 +30,8 @@ use crate::facts::CompactionStatus; use crate::facts::CompactionStrategy; use crate::facts::CompactionTrigger; use crate::facts::CustomAnalyticsFact; +use crate::facts::HookRunFact; +use crate::facts::HookRunInput; use crate::facts::InputError; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -81,6 +85,9 @@ use codex_plugin::PluginTelemetryMetadata; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::HookEventName; +use codex_protocol::protocol::HookRunStatus; +use codex_protocol::protocol::HookSource; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; @@ -1282,6 +1289,109 @@ fn plugin_management_event_serializes_expected_shape() { ); } +#[test] +fn hook_run_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::HookRun(CodexHookRunEventRequest { + event_type: "codex_hook_run", + event_params: codex_hook_run_metadata( + &tracking, + HookRunFact { + event_name: HookEventName::PreToolUse, + hook_source: HookSource::User, + status: HookRunStatus::Completed, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize hook run event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_hook_run", + "event_params": { + "thread_id": "thread-3", + "turn_id": "turn-3", + "model_slug": "gpt-5", + "hook_name": "PreToolUse", + "hook_source": "user", + "status": "completed" + } + }) + ); +} + +#[test] +fn hook_run_metadata_maps_sources_and_statuses() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }; + + let system = serde_json::to_value(codex_hook_run_metadata( + &tracking, + HookRunFact { + event_name: HookEventName::SessionStart, + hook_source: HookSource::System, + status: HookRunStatus::Completed, + }, + )) + .expect("serialize system hook"); + let project = serde_json::to_value(codex_hook_run_metadata( + &tracking, + HookRunFact { + event_name: HookEventName::Stop, + hook_source: HookSource::Project, + status: HookRunStatus::Blocked, + }, + )) + .expect("serialize project hook"); + let unknown = serde_json::to_value(codex_hook_run_metadata( + &tracking, + HookRunFact { + event_name: HookEventName::UserPromptSubmit, + hook_source: HookSource::Unknown, + status: HookRunStatus::Failed, + }, + )) + .expect("serialize unknown hook"); + + assert_eq!(system["hook_source"], "system"); + assert_eq!(system["status"], "completed"); + assert_eq!(project["hook_source"], "project"); + assert_eq!(project["status"], "blocked"); + assert_eq!(unknown["hook_source"], "unknown"); + assert_eq!(unknown["status"], "failed"); +} + +#[test] +fn hook_run_metadata_maps_stopped_status() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }; + + let stopped = serde_json::to_value(codex_hook_run_metadata( + &tracking, + HookRunFact { + event_name: HookEventName::Stop, + hook_source: HookSource::User, + status: HookRunStatus::Stopped, + }, + )) + .expect("serialize stopped hook"); + + assert_eq!(stopped["hook_source"], "user"); + assert_eq!(stopped["status"], "stopped"); +} + #[test] fn plugin_used_dedupe_is_keyed_by_turn_and_plugin() { let (sender, _receiver) = mpsc::channel(1); @@ -1359,6 +1469,37 @@ async fn reducer_ingests_skill_invoked_fact() { ); } +#[tokio::test] +async fn reducer_ingests_hook_run_fact() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::HookRun(HookRunInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + hook: HookRunFact { + event_name: HookEventName::PostToolUse, + hook_source: HookSource::Unknown, + status: HookRunStatus::Failed, + }, + })), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!(payload.as_array().expect("events array").len(), 1); + assert_eq!(payload[0]["event_type"], "codex_hook_run"); + assert_eq!(payload[0]["event_params"]["hook_name"], "PostToolUse"); + assert_eq!(payload[0]["event_params"]["hook_source"], "unknown"); + assert_eq!(payload[0]["event_params"]["status"], "failed"); +} + #[tokio::test] async fn reducer_ingests_app_and_plugin_facts() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 5ba60e3ef..1a4b5defe 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -9,6 +9,8 @@ use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CustomAnalyticsFact; +use crate::facts::HookRunFact; +use crate::facts::HookRunInput; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::SkillInvocation; @@ -191,6 +193,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_hook_run(&self, tracking: TrackEventsContext, hook: HookRunFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::HookRun( + HookRunInput { tracking, hook }, + ))); + } + pub fn track_plugin_used(&self, tracking: TrackEventsContext, plugin: PluginTelemetryMetadata) { if !self.queue.should_enqueue_plugin_used(&tracking, &plugin) { return; diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index eb312da14..d3aa5263d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,5 +1,6 @@ use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; +use crate::facts::HookRunFact; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; @@ -15,6 +16,9 @@ use codex_plugin::PluginTelemetryMetadata; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxPermissions; +use codex_protocol::protocol::HookEventName; +use codex_protocol::protocol::HookRunStatus; +use codex_protocol::protocol::HookSource; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -39,6 +43,7 @@ pub(crate) enum TrackEventRequest { GuardianReview(Box), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + HookRun(CodexHookRunEventRequest), Compaction(Box), TurnEvent(Box), TurnSteer(CodexTurnSteerEventRequest), @@ -300,6 +305,22 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexHookRunMetadata { + pub(crate) thread_id: Option, + pub(crate) turn_id: Option, + pub(crate) model_slug: Option, + pub(crate) hook_name: Option, + pub(crate) hook_source: Option<&'static str>, + pub(crate) status: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexHookRunEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexHookRunMetadata, +} + #[derive(Serialize)] pub(crate) struct CodexCompactionEventParams { pub(crate) thread_id: String, @@ -529,6 +550,43 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_hook_run_metadata( + tracking: &TrackEventsContext, + hook: HookRunFact, +) -> CodexHookRunMetadata { + CodexHookRunMetadata { + thread_id: Some(tracking.thread_id.clone()), + turn_id: Some(tracking.turn_id.clone()), + model_slug: Some(tracking.model_slug.clone()), + hook_name: Some(analytics_hook_event_name(hook.event_name).to_owned()), + hook_source: Some(analytics_hook_source(hook.hook_source)), + status: Some(analytics_hook_status(hook.status)), + } +} + +fn analytics_hook_event_name(event_name: HookEventName) -> &'static str { + match event_name { + HookEventName::PreToolUse => "PreToolUse", + HookEventName::PostToolUse => "PostToolUse", + HookEventName::SessionStart => "SessionStart", + HookEventName::UserPromptSubmit => "UserPromptSubmit", + HookEventName::Stop => "Stop", + } +} + +fn analytics_hook_source(source: HookSource) -> &'static str { + match source { + HookSource::System => "system", + HookSource::User => "user", + HookSource::Project => "project", + HookSource::Mdm => "mdm", + HookSource::SessionFlags => "session_flags", + HookSource::LegacyManagedConfigFile => "legacy_managed_config_file", + HookSource::LegacyManagedConfigMdm => "legacy_managed_config_mdm", + HookSource::Unknown => "unknown", + } +} + pub(crate) fn current_runtime_metadata() -> CodexRuntimeMetadata { let os_info = os_info::get(); CodexRuntimeMetadata { @@ -586,3 +644,11 @@ pub(crate) fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Opt _ => None, } } + +fn analytics_hook_status(status: HookRunStatus) -> HookRunStatus { + match status { + // Running is unexpected here and normalized defensively. + HookRunStatus::Running => HookRunStatus::Failed, + other => other, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 5590fbfa6..1d371acb1 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -15,6 +15,9 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::HookEventName; +use codex_protocol::protocol::HookRunStatus; +use codex_protocol::protocol::HookSource; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; @@ -298,6 +301,7 @@ pub(crate) enum CustomAnalyticsFact { SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), + HookRun(HookRunInput), PluginUsed(PluginUsedInput), PluginStateChanged(PluginStateChangedInput), } @@ -317,6 +321,17 @@ pub(crate) struct AppUsedInput { pub app: AppInvocation, } +pub(crate) struct HookRunInput { + pub tracking: TrackEventsContext, + pub hook: HookRunFact, +} + +pub struct HookRunFact { + pub event_name: HookEventName, + pub hook_source: HookSource, + pub status: HookRunStatus, +} + pub(crate) struct PluginUsedInput { pub tracking: TrackEventsContext, pub plugin: PluginTelemetryMetadata, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 1a1a12315..03f485a1c 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -29,6 +29,7 @@ pub use facts::CompactionReason; pub use facts::CompactionStatus; pub use facts::CompactionStrategy; pub use facts::CompactionTrigger; +pub use facts::HookRunFact; pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index a5e5165bd..a6ce3fc83 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -3,6 +3,7 @@ use crate::events::CodexAppMentionedEventRequest; use crate::events::CodexAppServerClientMetadata; use crate::events::CodexAppUsedEventRequest; use crate::events::CodexCompactionEventRequest; +use crate::events::CodexHookRunEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; @@ -20,6 +21,7 @@ use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_compaction_event_params; +use crate::events::codex_hook_run_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::plugin_state_event_type; @@ -32,6 +34,7 @@ use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::HookRunInput; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; @@ -217,6 +220,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::AppUsed(input) => { self.ingest_app_used(input, out); } + CustomAnalyticsFact::HookRun(input) => { + self.ingest_hook_run(input, out); + } CustomAnalyticsFact::PluginUsed(input) => { self.ingest_plugin_used(input, out); } @@ -442,6 +448,14 @@ impl AnalyticsReducer { })); } + fn ingest_hook_run(&mut self, input: HookRunInput, out: &mut Vec) { + let HookRunInput { tracking, hook } = input; + out.push(TrackEventRequest::HookRun(CodexHookRunEventRequest { + event_type: "codex_hook_run", + event_params: codex_hook_run_metadata(&tracking, hook), + })); + } + fn ingest_plugin_used(&mut self, input: PluginUsedInput, out: &mut Vec) { let PluginUsedInput { tracking, plugin } = input; out.push(TrackEventRequest::PluginUsed(CodexPluginUsedEventRequest { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b84fbe9a0..d02a28e37 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1517,6 +1517,14 @@ "scope": { "$ref": "#/definitions/HookScope" }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/HookSource" + } + ], + "default": "unknown" + }, "sourcePath": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -1555,6 +1563,19 @@ ], "type": "string" }, + "HookSource": { + "enum": [ + "system", + "user", + "project", + "mdm", + "sessionFlags", + "legacyManagedConfigFile", + "legacyManagedConfigMdm", + "unknown" + ], + "type": "string" + }, "HookStartedNotification": { "properties": { "run": { 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 943b8480f..e87fa1792 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 @@ -8597,6 +8597,14 @@ "scope": { "$ref": "#/definitions/v2/HookScope" }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/v2/HookSource" + } + ], + "default": "unknown" + }, "sourcePath": { "$ref": "#/definitions/v2/AbsolutePathBuf" }, @@ -8635,6 +8643,19 @@ ], "type": "string" }, + "HookSource": { + "enum": [ + "system", + "user", + "project", + "mdm", + "sessionFlags", + "legacyManagedConfigFile", + "legacyManagedConfigMdm", + "unknown" + ], + "type": "string" + }, "HookStartedNotification": { "$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 1717dfb39..8254ad012 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 @@ -5325,6 +5325,14 @@ "scope": { "$ref": "#/definitions/HookScope" }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/HookSource" + } + ], + "default": "unknown" + }, "sourcePath": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -5363,6 +5371,19 @@ ], "type": "string" }, + "HookSource": { + "enum": [ + "system", + "user", + "project", + "mdm", + "sessionFlags", + "legacyManagedConfigFile", + "legacyManagedConfigMdm", + "unknown" + ], + "type": "string" + }, "HookStartedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json index 2faa347f8..4f444961a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json @@ -106,6 +106,14 @@ "scope": { "$ref": "#/definitions/HookScope" }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/HookSource" + } + ], + "default": "unknown" + }, "sourcePath": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -143,6 +151,19 @@ "turn" ], "type": "string" + }, + "HookSource": { + "enum": [ + "system", + "user", + "project", + "mdm", + "sessionFlags", + "legacyManagedConfigFile", + "legacyManagedConfigMdm", + "unknown" + ], + "type": "string" } }, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json index 496175d01..6d439c95c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json @@ -106,6 +106,14 @@ "scope": { "$ref": "#/definitions/HookScope" }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/HookSource" + } + ], + "default": "unknown" + }, "sourcePath": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -143,6 +151,19 @@ "turn" ], "type": "string" + }, + "HookSource": { + "enum": [ + "system", + "user", + "project", + "mdm", + "sessionFlags", + "legacyManagedConfigFile", + "legacyManagedConfigMdm", + "unknown" + ], + "type": "string" } }, "properties": { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookRunSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookRunSummary.ts index f6d4b7537..75ab780b9 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookRunSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookRunSummary.ts @@ -8,5 +8,6 @@ import type { HookHandlerType } from "./HookHandlerType"; import type { HookOutputEntry } from "./HookOutputEntry"; import type { HookRunStatus } from "./HookRunStatus"; import type { HookScope } from "./HookScope"; +import type { HookSource } from "./HookSource"; -export type HookRunSummary = { id: string, eventName: HookEventName, handlerType: HookHandlerType, executionMode: HookExecutionMode, scope: HookScope, sourcePath: AbsolutePathBuf, displayOrder: bigint, status: HookRunStatus, statusMessage: string | null, startedAt: bigint, completedAt: bigint | null, durationMs: bigint | null, entries: Array, }; +export type HookRunSummary = { id: string, eventName: HookEventName, handlerType: HookHandlerType, executionMode: HookExecutionMode, scope: HookScope, sourcePath: AbsolutePathBuf, source: HookSource, displayOrder: bigint, status: HookRunStatus, statusMessage: string | null, startedAt: bigint, completedAt: bigint | null, durationMs: bigint | null, entries: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookSource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookSource.ts new file mode 100644 index 000000000..7edf61f91 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookSource.ts @@ -0,0 +1,5 @@ +// 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. + +export type HookSource = "system" | "user" | "project" | "mdm" | "sessionFlags" | "legacyManagedConfigFile" | "legacyManagedConfigMdm" | "unknown"; 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 d432530b4..69cec75e7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -139,6 +139,7 @@ export type { HookPromptFragment } from "./HookPromptFragment"; export type { HookRunStatus } from "./HookRunStatus"; export type { HookRunSummary } from "./HookRunSummary"; export type { HookScope } from "./HookScope"; +export type { HookSource } from "./HookSource"; export type { HookStartedNotification } from "./HookStartedNotification"; export type { ItemCompletedNotification } from "./ItemCompletedNotification"; export type { ItemGuardianApprovalReviewCompletedNotification } from "./ItemGuardianApprovalReviewCompletedNotification"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 155c67e7b..517354932 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -65,6 +65,7 @@ use codex_protocol::protocol::HookOutputEntryKind as CoreHookOutputEntryKind; use codex_protocol::protocol::HookRunStatus as CoreHookRunStatus; use codex_protocol::protocol::HookRunSummary as CoreHookRunSummary; use codex_protocol::protocol::HookScope as CoreHookScope; +use codex_protocol::protocol::HookSource as CoreHookSource; use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason; use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; use codex_protocol::protocol::NonSteerableTurnKind as CoreNonSteerableTurnKind; @@ -402,6 +403,23 @@ v2_enum_from_core!( } ); +v2_enum_from_core!( + pub enum HookSource from CoreHookSource { + System, + User, + Project, + Mdm, + SessionFlags, + LegacyManagedConfigFile, + LegacyManagedConfigMdm, + Unknown, + } +); + +fn default_hook_source() -> HookSource { + HookSource::Unknown +} + v2_enum_from_core!( pub enum HookRunStatus from CoreHookRunStatus { Running, Completed, Failed, Blocked, Stopped @@ -449,6 +467,8 @@ pub struct HookRunSummary { pub execution_mode: HookExecutionMode, pub scope: HookScope, pub source_path: AbsolutePathBuf, + #[serde(default = "default_hook_source")] + pub source: HookSource, pub display_order: i64, pub status: HookRunStatus, pub status_message: Option, @@ -467,6 +487,7 @@ impl From for HookRunSummary { execution_mode: value.execution_mode.into(), scope: value.scope.into(), source_path: value.source_path, + source: value.source.into(), display_order: value.display_order, status: value.status.into(), status_message: value.status_message, diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index ffed56198..66a6fc22f 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -86,3 +86,5 @@ pub use state::ConfigLayerEntry; pub use state::ConfigLayerStack; pub use state::ConfigLayerStackOrdering; pub use state::LoaderOverrides; + +pub use codex_app_server_protocol::ConfigLayerSource; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 42f165bbf..844d585be 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -24,6 +24,7 @@ use crate::compact_remote::run_inline_remote_auto_compact_task; use crate::config::ManagedFeatures; use crate::connectors; use crate::exec_policy::ExecPolicyManager; +use crate::hook_runtime::emit_hook_completed_events; use crate::installation_id::resolve_installation_id; use crate::mcp_tool_exposure::build_mcp_tool_exposure; use crate::parse_turn_item; @@ -6601,10 +6602,8 @@ pub(crate) async fn run_turn( .await; } let stop_outcome = sess.hooks().run_stop(stop_request).await; - for completed in stop_outcome.hook_events { - sess.send_event(&turn_context, EventMsg::HookCompleted(completed)) - .await; - } + emit_hook_completed_events(&sess, &turn_context, stop_outcome.hook_events) + .await; if stop_outcome.should_block { if let Some(hook_prompt_message) = build_hook_prompt_message(&stop_outcome.continuation_fragments) diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index d2d672fcd..58cac101b 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -1,6 +1,8 @@ use std::future::Future; use std::sync::Arc; +use codex_analytics::HookRunFact; +use codex_analytics::build_track_events_context; use codex_hooks::PostToolUseOutcome; use codex_hooks::PostToolUseRequest; use codex_hooks::PreToolUseOutcome; @@ -316,17 +318,52 @@ async fn emit_hook_started_events( } } -async fn emit_hook_completed_events( +pub(crate) async fn emit_hook_completed_events( sess: &Arc, turn_context: &Arc, completed_events: Vec, ) { for completed in completed_events { + track_hook_completed_analytics(sess, turn_context, &completed); sess.send_event(turn_context, EventMsg::HookCompleted(completed)) .await; } } +fn track_hook_completed_analytics( + sess: &Arc, + turn_context: &Arc, + completed: &HookCompletedEvent, +) { + let (tracking, hook) = + hook_run_analytics_payload(sess.conversation_id.to_string(), turn_context, completed); + sess.services + .analytics_events_client + .track_hook_run(tracking, hook); +} + +fn hook_run_analytics_payload( + thread_id: String, + turn_context: &TurnContext, + completed: &HookCompletedEvent, +) -> (codex_analytics::TrackEventsContext, HookRunFact) { + ( + build_track_events_context( + turn_context.model_info.slug.clone(), + thread_id, + completed + .turn_id + .clone() + .unwrap_or_else(|| turn_context.sub_id.clone()), + ), + HookRunFact { + event_name: completed.run.event_name, + hook_source: completed.run.source, + status: completed.run.status, + }, + ) +} + fn hook_permission_mode(turn_context: &TurnContext) -> String { match turn_context.approval_policy.value() { AskForApproval::Never => "bypassPermissions", @@ -341,9 +378,21 @@ fn hook_permission_mode(turn_context: &TurnContext) -> String { #[cfg(test)] mod tests { use codex_protocol::models::ContentItem; + use codex_protocol::protocol::HookEventName; + use codex_protocol::protocol::HookExecutionMode; + use codex_protocol::protocol::HookHandlerType; + use codex_protocol::protocol::HookRunStatus; + use codex_protocol::protocol::HookScope; + use codex_protocol::protocol::HookSource; use pretty_assertions::assert_eq; use super::additional_context_messages; + use super::hook_run_analytics_payload; + use crate::codex::make_session_and_context; + use codex_protocol::protocol::HookCompletedEvent; + use codex_protocol::protocol::HookRunSummary; + use codex_utils_absolute_path::test_support::PathBufExt; + use codex_utils_absolute_path::test_support::test_path_buf; #[test] fn additional_context_messages_stay_separate_and_ordered() { @@ -378,4 +427,58 @@ mod tests { ], ); } + + #[tokio::test] + async fn hook_run_analytics_payload_uses_completed_turn_id() { + let (_session, turn_context) = make_session_and_context().await; + let completed = HookCompletedEvent { + turn_id: Some("turn-from-hook".to_string()), + run: sample_hook_run(HookRunStatus::Blocked, HookSource::Project), + }; + + let (tracking, hook) = + hook_run_analytics_payload("thread-123".to_string(), &turn_context, &completed); + + assert_eq!(tracking.thread_id, "thread-123"); + assert_eq!(tracking.turn_id, "turn-from-hook"); + assert_eq!(tracking.model_slug, turn_context.model_info.slug); + assert_eq!(hook.event_name, HookEventName::Stop); + assert_eq!(hook.hook_source, HookSource::Project); + assert_eq!(hook.status, HookRunStatus::Blocked); + } + + #[tokio::test] + async fn hook_run_analytics_payload_falls_back_to_turn_context_id() { + let (_session, turn_context) = make_session_and_context().await; + let completed = HookCompletedEvent { + turn_id: None, + run: sample_hook_run(HookRunStatus::Failed, HookSource::Unknown), + }; + + let (tracking, hook) = + hook_run_analytics_payload("thread-123".to_string(), &turn_context, &completed); + + assert_eq!(tracking.turn_id, turn_context.sub_id); + assert_eq!(hook.hook_source, HookSource::Unknown); + assert_eq!(hook.status, HookRunStatus::Failed); + } + + fn sample_hook_run(status: HookRunStatus, source: HookSource) -> HookRunSummary { + HookRunSummary { + id: "stop:0:/tmp/hooks.json".to_string(), + event_name: HookEventName::Stop, + handler_type: HookHandlerType::Command, + execution_mode: HookExecutionMode::Sync, + scope: HookScope::Turn, + source_path: test_path_buf("/tmp/hooks.json").abs(), + source, + display_order: 0, + status, + status_message: None, + started_at: 10, + completed_at: Some(37), + duration_ms: Some(27), + entries: Vec::new(), + } + } } diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index 5505d8e91..682328e64 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -9,6 +9,8 @@ use super::config::HooksFile; use super::config::MatcherGroup; use crate::events::common::matcher_pattern_for_event; use crate::events::common::validate_matcher_pattern; +use codex_config::ConfigLayerSource; +use codex_protocol::protocol::HookSource; pub(crate) struct DiscoveryResult { pub handlers: Vec, @@ -93,6 +95,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - &mut warnings, &mut display_order, &source_path, + hook_source_for_config_layer_source(&layer.name), event_name, groups, ); @@ -102,95 +105,94 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - DiscoveryResult { handlers, warnings } } -fn append_group_handlers( - handlers: &mut Vec, - warnings: &mut Vec, - display_order: &mut i64, - source_path: &AbsolutePathBuf, - event_name: codex_protocol::protocol::HookEventName, - matcher: Option<&str>, - group_handlers: Vec, -) { - if let Some(matcher) = matcher - && let Err(err) = validate_matcher_pattern(matcher) - { - warnings.push(format!( - "invalid matcher {matcher:?} in {}: {err}", - source_path.display() - )); - return; - } - - for handler in group_handlers { - match handler { - HookHandlerConfig::Command { - command, - timeout_sec, - r#async, - status_message, - } => { - if r#async { - warnings.push(format!( - "skipping async hook in {}: async hooks are not supported yet", - source_path.display() - )); - continue; - } - if command.trim().is_empty() { - warnings.push(format!( - "skipping empty hook command in {}", - source_path.display() - )); - continue; - } - let timeout_sec = timeout_sec.unwrap_or(600).max(1); - handlers.push(ConfiguredHandler { - event_name, - matcher: matcher.map(ToOwned::to_owned), - command, - timeout_sec, - status_message, - source_path: source_path.clone(), - display_order: *display_order, - }); - *display_order += 1; - } - HookHandlerConfig::Prompt {} => warnings.push(format!( - "skipping prompt hook in {}: prompt hooks are not supported yet", - source_path.display() - )), - HookHandlerConfig::Agent {} => warnings.push(format!( - "skipping agent hook in {}: agent hooks are not supported yet", - source_path.display() - )), - } - } -} - fn append_matcher_groups( handlers: &mut Vec, warnings: &mut Vec, display_order: &mut i64, source_path: &AbsolutePathBuf, + source: HookSource, event_name: codex_protocol::protocol::HookEventName, groups: Vec, ) { for group in groups { - append_group_handlers( - handlers, - warnings, - display_order, - source_path, - event_name, - matcher_pattern_for_event(event_name, group.matcher.as_deref()), - group.hooks, - ); + let matcher = matcher_pattern_for_event(event_name, group.matcher.as_deref()); + if let Some(matcher) = matcher + && let Err(err) = validate_matcher_pattern(matcher) + { + warnings.push(format!( + "invalid matcher {matcher:?} in {}: {err}", + source_path.display() + )); + continue; + } + + for handler in group.hooks { + match handler { + HookHandlerConfig::Command { + command, + timeout_sec, + r#async, + status_message, + } => { + if r#async { + warnings.push(format!( + "skipping async hook in {}: async hooks are not supported yet", + source_path.display() + )); + continue; + } + if command.trim().is_empty() { + warnings.push(format!( + "skipping empty hook command in {}", + source_path.display() + )); + continue; + } + let timeout_sec = timeout_sec.unwrap_or(600).max(1); + handlers.push(ConfiguredHandler { + event_name, + matcher: matcher.map(ToOwned::to_owned), + command, + timeout_sec, + status_message, + source_path: source_path.clone(), + source, + display_order: *display_order, + }); + *display_order += 1; + } + HookHandlerConfig::Prompt {} => warnings.push(format!( + "skipping prompt hook in {}: prompt hooks are not supported yet", + source_path.display() + )), + HookHandlerConfig::Agent {} => warnings.push(format!( + "skipping agent hook in {}: agent hooks are not supported yet", + source_path.display() + )), + } + } + } +} + +fn hook_source_for_config_layer_source(source: &ConfigLayerSource) -> HookSource { + match source { + ConfigLayerSource::System { .. } => HookSource::System, + ConfigLayerSource::User { .. } => HookSource::User, + ConfigLayerSource::Project { .. } => HookSource::Project, + ConfigLayerSource::Mdm { .. } => HookSource::Mdm, + ConfigLayerSource::SessionFlags => HookSource::SessionFlags, + ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => { + HookSource::LegacyManagedConfigFile + } + ConfigLayerSource::LegacyManagedConfigTomlFromMdm => HookSource::LegacyManagedConfigMdm, } } #[cfg(test)] mod tests { + use codex_config::ConfigLayerSource; use codex_protocol::protocol::HookEventName; + use codex_protocol::protocol::HookSource; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; @@ -198,32 +200,43 @@ mod tests { use super::ConfiguredHandler; use super::HookHandlerConfig; - use super::append_group_handlers; - use crate::events::common::matcher_pattern_for_event; + use super::MatcherGroup; + use super::append_matcher_groups; fn source_path() -> AbsolutePathBuf { test_path_buf("/tmp/hooks.json").abs() } + fn hook_source() -> HookSource { + HookSource::User + } + + fn command_group(matcher: Option<&str>) -> MatcherGroup { + MatcherGroup { + matcher: matcher.map(str::to_string), + hooks: vec![HookHandlerConfig::Command { + command: "echo hello".to_string(), + timeout_sec: None, + r#async: false, + status_message: None, + }], + } + } + #[test] fn user_prompt_submit_ignores_invalid_matcher_during_discovery() { let mut handlers = Vec::new(); let mut warnings = Vec::new(); let mut display_order = 0; - append_group_handlers( + append_matcher_groups( &mut handlers, &mut warnings, &mut display_order, &source_path(), + hook_source(), HookEventName::UserPromptSubmit, - matcher_pattern_for_event(HookEventName::UserPromptSubmit, Some("[")), - vec![HookHandlerConfig::Command { - command: "echo hello".to_string(), - timeout_sec: None, - r#async: false, - status_message: None, - }], + vec![command_group(Some("["))], ); assert_eq!(warnings, Vec::::new()); @@ -236,6 +249,7 @@ mod tests { timeout_sec: 600, status_message: None, source_path: source_path(), + source: hook_source(), display_order: 0, }] ); @@ -247,19 +261,14 @@ mod tests { let mut warnings = Vec::new(); let mut display_order = 0; - append_group_handlers( + append_matcher_groups( &mut handlers, &mut warnings, &mut display_order, &source_path(), + hook_source(), HookEventName::PreToolUse, - matcher_pattern_for_event(HookEventName::PreToolUse, Some("^Bash$")), - vec![HookHandlerConfig::Command { - command: "echo hello".to_string(), - timeout_sec: None, - r#async: false, - status_message: None, - }], + vec![command_group(Some("^Bash$"))], ); assert_eq!(warnings, Vec::::new()); @@ -272,6 +281,7 @@ mod tests { timeout_sec: 600, status_message: None, source_path: source_path(), + source: hook_source(), display_order: 0, }] ); @@ -283,19 +293,14 @@ mod tests { let mut warnings = Vec::new(); let mut display_order = 0; - append_group_handlers( + append_matcher_groups( &mut handlers, &mut warnings, &mut display_order, &source_path(), + hook_source(), HookEventName::PreToolUse, - matcher_pattern_for_event(HookEventName::PreToolUse, Some("*")), - vec![HookHandlerConfig::Command { - command: "echo hello".to_string(), - timeout_sec: None, - r#async: false, - status_message: None, - }], + vec![command_group(Some("*"))], ); assert_eq!(warnings, Vec::::new()); @@ -309,19 +314,14 @@ mod tests { let mut warnings = Vec::new(); let mut display_order = 0; - append_group_handlers( + append_matcher_groups( &mut handlers, &mut warnings, &mut display_order, &source_path(), + hook_source(), HookEventName::PostToolUse, - matcher_pattern_for_event(HookEventName::PostToolUse, Some("Edit|Write")), - vec![HookHandlerConfig::Command { - command: "echo hello".to_string(), - timeout_sec: None, - r#async: false, - status_message: None, - }], + vec![command_group(Some("Edit|Write"))], ); assert_eq!(warnings, Vec::::new()); @@ -329,4 +329,52 @@ mod tests { assert_eq!(handlers[0].event_name, HookEventName::PostToolUse); assert_eq!(handlers[0].matcher.as_deref(), Some("Edit|Write")); } + + #[test] + fn hook_source_for_config_layer_source_discards_source_details() { + let config_file = test_path_buf("/tmp/.codex/config.toml").abs(); + let dot_codex_folder = test_path_buf("/tmp/worktree/.codex").abs(); + + assert_eq!( + super::hook_source_for_config_layer_source(&ConfigLayerSource::System { + file: config_file.clone(), + }), + HookSource::System, + ); + assert_eq!( + super::hook_source_for_config_layer_source(&ConfigLayerSource::User { + file: config_file.clone(), + }), + HookSource::User, + ); + assert_eq!( + super::hook_source_for_config_layer_source(&ConfigLayerSource::Project { + dot_codex_folder + }), + HookSource::Project, + ); + assert_eq!( + super::hook_source_for_config_layer_source(&ConfigLayerSource::Mdm { + domain: "com.openai.codex".to_string(), + key: "config".to_string(), + }), + HookSource::Mdm, + ); + assert_eq!( + super::hook_source_for_config_layer_source(&ConfigLayerSource::SessionFlags), + HookSource::SessionFlags, + ); + assert_eq!( + super::hook_source_for_config_layer_source( + &ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: config_file }, + ), + HookSource::LegacyManagedConfigFile, + ); + assert_eq!( + super::hook_source_for_config_layer_source( + &ConfigLayerSource::LegacyManagedConfigTomlFromMdm, + ), + HookSource::LegacyManagedConfigMdm, + ); + } } diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index e3dedf54b..fbb2a1843 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -50,6 +50,7 @@ pub(crate) fn running_summary(handler: &ConfiguredHandler) -> HookRunSummary { execution_mode: HookExecutionMode::Sync, scope: scope_for_event(handler.event_name), source_path: handler.source_path.clone(), + source: handler.source, display_order: handler.display_order, status: HookRunStatus::Running, status_message: handler.status_message.clone(), @@ -95,6 +96,7 @@ pub(crate) fn completed_summary( execution_mode: HookExecutionMode::Sync, scope: scope_for_event(handler.event_name), source_path: handler.source_path.clone(), + source: handler.source, display_order: handler.display_order, status, status_message: handler.status_message.clone(), @@ -118,6 +120,7 @@ fn scope_for_event(event_name: HookEventName) -> HookScope { #[cfg(test)] mod tests { use codex_protocol::protocol::HookEventName; + use codex_protocol::protocol::HookSource; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; @@ -137,6 +140,7 @@ mod tests { timeout_sec: 5, status_message: None, source_path: test_path_buf("/tmp/hooks.json").abs(), + source: HookSource::User, display_order, } } diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index f3992e8ef..9bc23705d 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -7,6 +7,7 @@ pub(crate) mod schema_loader; use codex_config::ConfigLayerStack; use codex_protocol::protocol::HookRunSummary; +use codex_protocol::protocol::HookSource; use codex_utils_absolute_path::AbsolutePathBuf; use crate::events::post_tool_use::PostToolUseOutcome; @@ -34,6 +35,7 @@ pub(crate) struct ConfiguredHandler { pub timeout_sec: u64, pub status_message: Option, pub source_path: AbsolutePathBuf, + pub source: HookSource, pub display_order: i64, } diff --git a/codex-rs/hooks/src/events/post_tool_use.rs b/codex-rs/hooks/src/events/post_tool_use.rs index 056956705..f7f40d279 100644 --- a/codex-rs/hooks/src/events/post_tool_use.rs +++ b/codex-rs/hooks/src/events/post_tool_use.rs @@ -525,6 +525,7 @@ mod tests { timeout_sec: 5, status_message: Some("running post tool use hook".to_string()), source_path: test_path_buf("/tmp/hooks.json").abs(), + source: codex_protocol::protocol::HookSource::User, display_order: 0, } } diff --git a/codex-rs/hooks/src/events/pre_tool_use.rs b/codex-rs/hooks/src/events/pre_tool_use.rs index e188207c8..f90abc281 100644 --- a/codex-rs/hooks/src/events/pre_tool_use.rs +++ b/codex-rs/hooks/src/events/pre_tool_use.rs @@ -514,6 +514,7 @@ mod tests { timeout_sec: 5, status_message: None, source_path: test_path_buf("/tmp/hooks.json").abs(), + source: codex_protocol::protocol::HookSource::User, display_order: 0, } } diff --git a/codex-rs/hooks/src/events/session_start.rs b/codex-rs/hooks/src/events/session_start.rs index ea396a223..3c51426fa 100644 --- a/codex-rs/hooks/src/events/session_start.rs +++ b/codex-rs/hooks/src/events/session_start.rs @@ -361,6 +361,7 @@ mod tests { timeout_sec: 600, status_message: None, source_path: test_path_buf("/tmp/hooks.json").abs(), + source: codex_protocol::protocol::HookSource::User, display_order: 0, } } diff --git a/codex-rs/hooks/src/events/stop.rs b/codex-rs/hooks/src/events/stop.rs index 478cdbdd8..5fe55568a 100644 --- a/codex-rs/hooks/src/events/stop.rs +++ b/codex-rs/hooks/src/events/stop.rs @@ -528,6 +528,7 @@ mod tests { timeout_sec: 600, status_message: None, source_path: test_path_buf("/tmp/hooks.json").abs(), + source: codex_protocol::protocol::HookSource::User, display_order: 0, } } diff --git a/codex-rs/hooks/src/events/user_prompt_submit.rs b/codex-rs/hooks/src/events/user_prompt_submit.rs index 875d56bd3..9aaff9a7d 100644 --- a/codex-rs/hooks/src/events/user_prompt_submit.rs +++ b/codex-rs/hooks/src/events/user_prompt_submit.rs @@ -419,6 +419,7 @@ mod tests { timeout_sec: 5, status_message: None, source_path: test_path_buf("/tmp/hooks.json").abs(), + source: codex_protocol::protocol::HookSource::User, display_order: 0, } } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 8aea3544d..d9c63370d 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1635,6 +1635,20 @@ pub enum HookScope { Turn, } +#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum HookSource { + System, + User, + Project, + Mdm, + SessionFlags, + LegacyManagedConfigFile, + LegacyManagedConfigMdm, + #[default] + Unknown, +} + #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum HookRunStatus { @@ -1671,6 +1685,8 @@ pub struct HookRunSummary { pub execution_mode: HookExecutionMode, pub scope: HookScope, pub source_path: AbsolutePathBuf, + #[serde(default)] + pub source: HookSource, pub display_order: i64, pub status: HookRunStatus, pub status_message: Option, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 1db3dbc7e..ab20b2214 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -9892,6 +9892,7 @@ guardian_approval = true execution_mode: AppServerHookExecutionMode::Sync, scope: AppServerHookScope::Turn, source_path: test_path_buf("/tmp/hooks.json").abs(), + source: codex_app_server_protocol::HookSource::User, display_order: 0, status: AppServerHookRunStatus::Running, status_message: Some("checking go-workflow input policy".to_string()), @@ -9914,6 +9915,7 @@ guardian_approval = true execution_mode: AppServerHookExecutionMode::Sync, scope: AppServerHookScope::Turn, source_path: test_path_buf("/tmp/hooks.json").abs(), + source: codex_app_server_protocol::HookSource::User, display_order: 0, status: AppServerHookRunStatus::Stopped, status_message: Some("checking go-workflow input policy".to_string()), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f2697d907..7cfcd56e1 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1329,6 +1329,7 @@ fn hook_run_summary_from_notification( execution_mode: run.execution_mode.to_core(), scope: run.scope.to_core(), source_path: run.source_path, + source: run.source.to_core(), display_order: run.display_order, status: run.status.to_core(), status_message: run.status_message, diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index c0f881e2b..9e365afa7 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1001,6 +1001,7 @@ pub(super) async fn assert_hook_events_snapshot( execution_mode: codex_protocol::protocol::HookExecutionMode::Sync, scope: codex_protocol::protocol::HookScope::Turn, source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(), + source: codex_protocol::protocol::HookSource::User, display_order: 0, status: codex_protocol::protocol::HookRunStatus::Running, status_message: Some(status_message.to_string()), @@ -1035,6 +1036,7 @@ pub(super) async fn assert_hook_events_snapshot( execution_mode: codex_protocol::protocol::HookExecutionMode::Sync, scope: codex_protocol::protocol::HookScope::Turn, source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(), + source: codex_protocol::protocol::HookSource::User, display_order: 0, status: codex_protocol::protocol::HookRunStatus::Completed, status_message: Some(status_message.to_string()), diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 75ca9d1fb..a31b93bb4 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1425,6 +1425,7 @@ async fn user_prompt_submit_app_server_hook_notifications_render_snapshot() { execution_mode: AppServerHookExecutionMode::Sync, scope: AppServerHookScope::Turn, source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(), + source: codex_app_server_protocol::HookSource::User, display_order: 0, status: AppServerHookRunStatus::Running, status_message: Some("checking go-workflow input policy".to_string()), @@ -1447,6 +1448,7 @@ async fn user_prompt_submit_app_server_hook_notifications_render_snapshot() { execution_mode: AppServerHookExecutionMode::Sync, scope: AppServerHookScope::Turn, source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(), + source: codex_app_server_protocol::HookSource::User, display_order: 0, status: AppServerHookRunStatus::Stopped, status_message: Some("checking go-workflow input policy".to_string()), @@ -1517,6 +1519,7 @@ async fn completed_hook_with_no_entries_stays_out_of_history() { execution_mode: codex_protocol::protocol::HookExecutionMode::Sync, scope: codex_protocol::protocol::HookScope::Turn, source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(), + source: codex_protocol::protocol::HookSource::User, display_order: 0, status: codex_protocol::protocol::HookRunStatus::Running, status_message: None, @@ -1542,6 +1545,7 @@ async fn completed_hook_with_no_entries_stays_out_of_history() { execution_mode: codex_protocol::protocol::HookExecutionMode::Sync, scope: codex_protocol::protocol::HookScope::Turn, source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(), + source: codex_protocol::protocol::HookSource::User, display_order: 0, status: codex_protocol::protocol::HookRunStatus::Completed, status_message: None, @@ -2017,6 +2021,7 @@ fn hook_run_summary( execution_mode: codex_protocol::protocol::HookExecutionMode::Sync, scope: codex_protocol::protocol::HookScope::Turn, source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(), + source: codex_protocol::protocol::HookSource::User, display_order: 0, status, status_message: status_message.map(str::to_string), diff --git a/codex-rs/tui/src/history_cell/hook_cell.rs b/codex-rs/tui/src/history_cell/hook_cell.rs index a1207186c..50642aa0f 100644 --- a/codex-rs/tui/src/history_cell/hook_cell.rs +++ b/codex-rs/tui/src/history_cell/hook_cell.rs @@ -768,6 +768,7 @@ mod tests { execution_mode: codex_protocol::protocol::HookExecutionMode::Sync, scope: codex_protocol::protocol::HookScope::Turn, source_path: test_path_buf("/tmp/hooks.json").abs(), + source: codex_protocol::protocol::HookSource::User, display_order: 0, status: HookRunStatus::Running, status_message: Some("checking output policy".to_string()),