From 7bd44085e1650c406533745348e3c54f072ce5f6 Mon Sep 17 00:00:00 2001 From: Abhinav Date: Fri, 17 Jul 2026 19:53:35 +0000 Subject: [PATCH] Add SessionEnd hooks for thread teardown (#33895) ## What changed - Add the `SessionEnd` hook event to hook configuration, protocol schemas, analytics, and the hooks browser. - Run matching hooks for root threads during shutdown, including app-server archive, delete, idle unload, and graceful shutdown. Flush the transcript first and provide the session ID, transcript path, working directory, and `reason: "other"`. - Keep teardown bounded: hook output is advisory, the default timeout is one second, configured timeouts are capped at three seconds, and async hooks run synchronously with a warning. ## Testing - Cover transcript availability, matcher selection, timeout normalization, ignored control output, subagent exclusion, archive/delete ordering, and graceful shutdown of multiple loaded threads. GitOrigin-RevId: 5d19a658677a137caf836ed5042dcb43f5eb6d1a --- codex-rs/analytics/src/events.rs | 1 + .../schema/json/ServerNotification.json | 1 + .../codex_app_server_protocol.schemas.json | 8 + .../codex_app_server_protocol.v2.schemas.json | 8 + .../v2/ConfigRequirementsReadResponse.json | 7 + .../json/v2/HookCompletedNotification.json | 1 + .../json/v2/HookStartedNotification.json | 1 + .../schema/json/v2/HooksListResponse.json | 1 + .../schema/json/v2/PluginReadResponse.json | 1 + .../schema/typescript/v2/HookEventName.ts | 2 +- .../typescript/v2/ManagedHooksRequirements.ts | 2 +- .../src/protocol/v2/config.rs | 3 + .../src/protocol/v2/hook.rs | 2 +- codex-rs/app-server/README.md | 6 +- .../request_processors/config_processor.rs | 2 + .../tests/common/test_app_server.rs | 6 + codex-rs/app-server/tests/suite/v2/mod.rs | 2 + .../app-server/tests/suite/v2/session_end.rs | 222 ++++++++++++++++++ codex-rs/config/src/hook_config.rs | 9 +- .../config/src/requirements_layers/hooks.rs | 2 + codex-rs/core-plugins/src/manifest.rs | 4 +- codex-rs/core/config.schema.json | 7 + codex-rs/core/src/hook_runtime.rs | 33 +++ codex-rs/core/src/session/handlers.rs | 2 + codex-rs/core/tests/suite/hooks.rs | 132 +++++++++++ .../external-agent-migration/src/lib_tests.rs | 14 ++ .../session-end.command.input.schema.json | 40 ++++ codex-rs/hooks/src/engine/discovery.rs | 122 +++++++++- codex-rs/hooks/src/engine/dispatcher.rs | 6 +- codex-rs/hooks/src/engine/mod.rs | 11 + codex-rs/hooks/src/engine/schema_loader.rs | 6 + codex-rs/hooks/src/events/common.rs | 5 + codex-rs/hooks/src/events/mod.rs | 1 + codex-rs/hooks/src/events/session_end.rs | 140 +++++++++++ .../hooks/src/events/session_end_tests.rs | 73 ++++++ codex-rs/hooks/src/lib.rs | 11 +- codex-rs/hooks/src/registry.rs | 10 + codex-rs/hooks/src/schema.rs | 31 +++ codex-rs/protocol/src/protocol.rs | 1 + .../tui/src/bottom_pane/hooks_browser_view.rs | 2 + ...ser_view__tests__hooks_browser_events.snap | 1 + ...sts__hooks_browser_events_with_issues.snap | 1 + ...oks_browser_events_with_review_column.snap | 1 + ...s__hooks_popup_shows_list_diagnostics.snap | 1 + codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + codex-rs/tui/src/history_cell/hook_cell.rs | 1 + 46 files changed, 930 insertions(+), 14 deletions(-) create mode 100644 codex-rs/app-server/tests/suite/v2/session_end.rs create mode 100644 codex-rs/hooks/schema/generated/session-end.command.input.schema.json create mode 100644 codex-rs/hooks/src/events/session_end.rs create mode 100644 codex-rs/hooks/src/events/session_end_tests.rs diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 0b0620223e87..92aad2d04169 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1254,6 +1254,7 @@ fn analytics_hook_event_name(event_name: HookEventName) -> &'static str { HookEventName::PreCompact => "PreCompact", HookEventName::PostCompact => "PostCompact", HookEventName::SessionStart => "SessionStart", + HookEventName::SessionEnd => "SessionEnd", HookEventName::UserPromptSubmit => "UserPromptSubmit", HookEventName::SubagentStart => "SubagentStart", HookEventName::SubagentStop => "SubagentStop", diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 42375301b66e..42038ba371d6 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2042,6 +2042,7 @@ "preCompact", "postCompact", "sessionStart", + "sessionEnd", "userPromptSubmit", "subagentStart", "subagentStop", 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 d285c309b56a..2c75383f6b80 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 @@ -11118,6 +11118,7 @@ "preCompact", "postCompact", "sessionStart", + "sessionEnd", "userPromptSubmit", "subagentStart", "subagentStop", @@ -12114,6 +12115,13 @@ }, "type": "array" }, + "SessionEnd": { + "default": [], + "items": { + "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "SessionStart": { "items": { "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" 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 de0cc6048c74..165718d7afb6 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 @@ -7422,6 +7422,7 @@ "preCompact", "postCompact", "sessionStart", + "sessionEnd", "userPromptSubmit", "subagentStart", "subagentStop", @@ -8471,6 +8472,13 @@ }, "type": "array" }, + "SessionEnd": { + "default": [], + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "SessionStart": { "items": { "$ref": "#/definitions/ConfiguredHookMatcherGroup" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index 4e35f7aeadb0..54c75f363adf 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -315,6 +315,13 @@ }, "type": "array" }, + "SessionEnd": { + "default": [], + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "SessionStart": { "items": { "$ref": "#/definitions/ConfiguredHookMatcherGroup" 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 8684bf9ae59c..11d6f2845884 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json @@ -13,6 +13,7 @@ "preCompact", "postCompact", "sessionStart", + "sessionEnd", "userPromptSubmit", "subagentStart", "subagentStop", 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 5b2750d78085..8d6d82aa0476 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json @@ -13,6 +13,7 @@ "preCompact", "postCompact", "sessionStart", + "sessionEnd", "userPromptSubmit", "subagentStart", "subagentStop", diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json index c3288ee0fdac..fa71c18f4fa9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json @@ -28,6 +28,7 @@ "preCompact", "postCompact", "sessionStart", + "sessionEnd", "userPromptSubmit", "subagentStart", "subagentStop", 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 2b6bdc29add3..85d5c5930672 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -116,6 +116,7 @@ "preCompact", "postCompact", "sessionStart", + "sessionEnd", "userPromptSubmit", "subagentStart", "subagentStop", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts index 477476289db7..ae8a7f389f8a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "userPromptSubmit" | "subagentStart" | "subagentStop" | "stop"; +export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "sessionEnd" | "userPromptSubmit" | "subagentStart" | "subagentStop" | "stop"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts index 1143bd017f21..6d49d5f0c5bb 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ConfiguredHookMatcherGroup } from "./ConfiguredHookMatcherGroup"; -export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array, PermissionRequest: Array, PostToolUse: Array, PreCompact: Array, PostCompact: Array, SessionStart: Array, UserPromptSubmit: Array, SubagentStart: Array, SubagentStop: Array, Stop: Array, }; +export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array, PermissionRequest: Array, PostToolUse: Array, PreCompact: Array, PostCompact: Array, SessionStart: Array, SessionEnd: Array, UserPromptSubmit: Array, SubagentStart: Array, SubagentStop: Array, Stop: Array, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 75a2c6266422..2462b8b23973 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -441,6 +441,9 @@ pub struct ManagedHooksRequirements { #[serde(rename = "SessionStart")] #[ts(rename = "SessionStart")] pub session_start: Vec, + #[serde(rename = "SessionEnd", default)] + #[ts(rename = "SessionEnd")] + pub session_end: Vec, #[serde(rename = "UserPromptSubmit")] #[ts(rename = "UserPromptSubmit")] pub user_prompt_submit: Vec, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/hook.rs b/codex-rs/app-server-protocol/src/protocol/v2/hook.rs index b8f47276a697..247b69e647e7 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/hook.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/hook.rs @@ -17,7 +17,7 @@ use ts_rs::TS; v2_enum_from_core!( pub enum HookEventName from CoreHookEventName { - PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, UserPromptSubmit, SubagentStart, SubagentStop, Stop + PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, SessionEnd, UserPromptSubmit, SubagentStart, SubagentStop, Stop } ); diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 021e8043faf8..5c1cf0c2b899 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -159,7 +159,7 @@ Example with notification opt-out: - `thread/status/changed` — notification emitted when a loaded thread’s status changes (`threadId` + new `status`). - `thread/archive` — move a thread’s rollout file into the archived directory and attempt to move any spawned descendant thread rollout files; returns `{}` on success and emits `thread/archived` for each archived thread. - `thread/delete` — hard-delete an active or archived thread and any spawned descendant threads; returns `{}` on success and emits `thread/deleted` for each deleted thread. -- `thread/unsubscribe` — unsubscribe this connection from thread turn/item events. If this was the last subscriber, the server keeps the thread loaded and unloads it only after it has had no subscribers and no thread activity for 30 minutes, then emits `thread/closed`. +- `thread/unsubscribe` — unsubscribe this connection from thread turn/item events. If this was the last subscriber, the server keeps the thread loaded and unloads it only after it has had no subscribers and no thread activity for 30 minutes, runs `SessionEnd` hooks, then emits `thread/closed`. - `thread/name/set` — set or update a thread’s user-facing name for either a loaded thread or a persisted rollout; returns `{}` on success and emits `thread/name/updated` to initialized, opted-in clients. Thread names are not required to be unique; name lookups resolve to the most recently updated thread. - `thread/unarchive` — move an archived rollout file back into the sessions directory; returns the restored `thread` on success and emits `thread/unarchived`. - `thread/compact/start` — trigger conversation history compaction for a thread; returns `{}` immediately while progress streams through standard turn/item notifications. @@ -470,7 +470,9 @@ Enable `capabilities.experimentalApi` during initialization, then use `thread/li - `notSubscribed` when the connection was not subscribed to that thread. - `notLoaded` when the thread is not loaded. -If this was the last subscriber, the server does not unload the thread immediately. It unloads the thread after the thread has had no subscribers and no thread activity for 30 minutes, then emits `thread/closed` and a `thread/status/changed` transition to `notLoaded`. +If this was the last subscriber, the server does not unload the thread immediately. It unloads the thread after the thread has had no subscribers and no thread activity for 30 minutes, runs `SessionEnd` hooks, then emits `thread/closed` and a `thread/status/changed` transition to `notLoaded`. + +`SessionEnd` also runs before archive, delete, and graceful app-server shutdown. It runs only for root threads, not `ThreadSpawn` children or internal subagents. Hooks are advisory: their output cannot block teardown. The default timeout is one second, configured timeouts are capped at three seconds, `async: true` runs synchronously with a configuration warning, and the hook input always reports `reason: "other"`. `SessionEnd` matchers are evaluated against that reason. ```json { "method": "thread/unsubscribe", "id": 22, "params": { "threadId": "thr_123" } } diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 0eeb1aaca98a..33d4bcb4f2a5 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -408,6 +408,7 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed pre_compact, post_compact, session_start, + session_end, user_prompt_submit, subagent_start, subagent_stop, @@ -423,6 +424,7 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed pre_compact: map_hook_matcher_groups_to_api(pre_compact), post_compact: map_hook_matcher_groups_to_api(post_compact), session_start: map_hook_matcher_groups_to_api(session_start), + session_end: map_hook_matcher_groups_to_api(session_end), user_prompt_submit: map_hook_matcher_groups_to_api(user_prompt_submit), subagent_start: map_hook_matcher_groups_to_api(subagent_start), subagent_stop: map_hook_matcher_groups_to_api(subagent_stop), diff --git a/codex-rs/app-server/tests/common/test_app_server.rs b/codex-rs/app-server/tests/common/test_app_server.rs index 8e3e2ef81083..3e2a6903d862 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -176,6 +176,12 @@ impl TestAppServer { self.process.wait().await } + /// Closes stdio and waits for app-server's graceful thread teardown to finish. + pub async fn shutdown_gracefully(&mut self) -> std::io::Result { + drop(self.stdin.take()); + self.process.wait().await + } + /// Returns the automatically selected test environment retained by this server. /// /// Tests can use the environment to arrange target-native filesystem fixtures before starting diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 6f8f9c014392..6566f0459e90 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -65,6 +65,8 @@ mod safety_check_downgrade; #[cfg(not(target_os = "windows"))] mod selected_capability_stack; mod selected_environment; +#[cfg(not(target_os = "windows"))] +mod session_end; mod skills_list; mod sleep; mod thread_archive; diff --git a/codex-rs/app-server/tests/suite/v2/session_end.rs b/codex-rs/app-server/tests/suite/v2/session_end.rs new file mode 100644 index 000000000000..c7d59c04dd3c --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/session_end.rs @@ -0,0 +1,222 @@ +use std::collections::HashMap; +use std::path::Path; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use app_test_support::TestAppServer; +use app_test_support::create_mock_responses_server_repeating_assistant; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadArchiveParams; +use codex_app_server_protocol::ThreadArchiveResponse; +use codex_app_server_protocol::ThreadDeleteParams; +use codex_app_server_protocol::ThreadDeleteResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use tempfile::TempDir; +use tokio::time::timeout; + +const READ_TIMEOUT: Duration = Duration::from_secs(20); + +#[tokio::test] +async fn archive_runs_session_end_before_moving_transcript() -> Result<()> { + run_removal_session_end_test("archive").await +} + +#[tokio::test] +async fn delete_runs_session_end_before_removing_transcript() -> Result<()> { + run_removal_session_end_test("delete").await +} + +async fn run_removal_session_end_test(operation: &str) -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("persisted answer").await; + let codex_home = TempDir::new()?; + let log_path = write_config_and_hook(codex_home.path(), &server.uri())?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; + timeout(READ_TIMEOUT, app_server.initialize()).await??; + let thread_id = start_thread(&mut app_server).await?; + + let turn_id = app_server + .send_turn_start_request(TurnStartParams { + thread_id: thread_id.clone(), + input: vec![UserInput::Text { + text: "persist this before removal".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let response = timeout( + READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + let _: TurnStartResponse = to_response(response)?; + timeout( + READ_TIMEOUT, + app_server.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + if operation == "archive" { + let request_id = app_server + .send_thread_archive_request(ThreadArchiveParams { + thread_id: thread_id.clone(), + }) + .await?; + let response: JSONRPCResponse = timeout( + READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let _: ThreadArchiveResponse = to_response(response)?; + } else { + let request_id = app_server + .send_thread_delete_request(ThreadDeleteParams { + thread_id: thread_id.clone(), + }) + .await?; + let response: JSONRPCResponse = timeout( + READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let _: ThreadDeleteResponse = to_response(response)?; + } + + let payloads = read_hook_log(&log_path)?; + assert_eq!(payloads.len(), 1); + assert_eq!(payloads[0]["session_id"], thread_id); + assert_eq!(payloads[0]["hook_event_name"], "SessionEnd"); + assert_eq!(payloads[0]["reason"], "other"); + assert_eq!(payloads[0]["transcript_exists"], true); + let transcript = payloads[0]["transcript_text"] + .as_str() + .expect("session end transcript text"); + assert!(transcript.contains("persist this before removal")); + assert!(transcript.contains("persisted answer")); + Ok(()) +} + +#[tokio::test] +async fn app_server_shutdown_runs_session_end_for_all_loaded_threads() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + let log_path = write_config_and_hook(codex_home.path(), &server.uri())?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; + timeout(READ_TIMEOUT, app_server.initialize()).await??; + let first = start_thread(&mut app_server).await?; + let second = start_thread(&mut app_server).await?; + + let status = timeout(READ_TIMEOUT, app_server.shutdown_gracefully()).await??; + assert!(status.success(), "app-server did not exit successfully"); + + let mut actual = read_hook_log(&log_path)? + .into_iter() + .map(|payload| { + ( + payload["session_id"].as_str().unwrap().to_string(), + payload["reason"].as_str().unwrap().to_string(), + ) + }) + .collect::>(); + actual.sort(); + let mut expected = vec![(first, "other".to_string()), (second, "other".to_string())]; + expected.sort(); + assert_eq!(actual, expected); + Ok(()) +} + +async fn start_thread(app_server: &mut TestAppServer) -> Result { + let request_id = app_server + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + config: Some(HashMap::from([( + "bypass_hook_trust".to_string(), + json!(true), + )])), + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + Ok(to_response::(response)?.thread.id) +} + +fn write_config_and_hook(codex_home: &Path, server_uri: &str) -> Result { + let log_path = codex_home.join("session-end.jsonl"); + let script_path = codex_home.join("session-end.py"); + std::fs::write( + &script_path, + format!( + r#"import json +from pathlib import Path +import sys + +payload = json.load(sys.stdin) +transcript_path = payload.get("transcript_path") +transcript = Path(transcript_path) if transcript_path else None +payload["transcript_exists"] = bool(transcript and transcript.exists()) +payload["transcript_text"] = transcript.read_text(encoding="utf-8") if transcript and transcript.exists() else "" +with Path(r"{}").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") +"#, + log_path.display() + ), + )?; + std::fs::write( + codex_home.join("config.toml"), + format!( + r#"model = "mock-model" +approval_policy = "never" +sandbox_mode = "danger-full-access" +model_provider = "mock_provider" + +[features] +hooks = true + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 + +[[hooks.SessionEnd]] +matcher = "other" + +[[hooks.SessionEnd.hooks]] +type = "command" +command = "python3 {script_path}" +timeout = 3 +"#, + script_path = script_path.display(), + ), + )?; + Ok(log_path) +} + +fn read_hook_log(log_path: &Path) -> Result> { + std::fs::read_to_string(log_path) + .with_context(|| format!("read SessionEnd log {}", log_path.display()))? + .lines() + .map(|line| serde_json::from_str(line).context("parse SessionEnd log line")) + .collect() +} diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs index 8e18cb935a4f..371f6a91b5c4 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -46,6 +46,8 @@ pub struct HookEventsToml { pub post_compact: Vec, #[serde(rename = "SessionStart", default)] pub session_start: Vec, + #[serde(rename = "SessionEnd", default)] + pub session_end: Vec, #[serde(rename = "UserPromptSubmit", default)] pub user_prompt_submit: Vec, #[serde(rename = "SubagentStart", default)] @@ -65,6 +67,7 @@ impl HookEventsToml { pre_compact, post_compact, session_start, + session_end, user_prompt_submit, subagent_start, subagent_stop, @@ -76,6 +79,7 @@ impl HookEventsToml { && pre_compact.is_empty() && post_compact.is_empty() && session_start.is_empty() + && session_end.is_empty() && user_prompt_submit.is_empty() && subagent_start.is_empty() && subagent_stop.is_empty() @@ -90,6 +94,7 @@ impl HookEventsToml { pre_compact, post_compact, session_start, + session_end, user_prompt_submit, subagent_start, subagent_stop, @@ -102,6 +107,7 @@ impl HookEventsToml { pre_compact, post_compact, session_start, + session_end, user_prompt_submit, subagent_start, subagent_stop, @@ -113,7 +119,7 @@ impl HookEventsToml { .sum() } - pub fn into_matcher_groups(self) -> [(HookEventName, Vec); 10] { + pub fn into_matcher_groups(self) -> [(HookEventName, Vec); 11] { [ (HookEventName::PreToolUse, self.pre_tool_use), (HookEventName::PermissionRequest, self.permission_request), @@ -121,6 +127,7 @@ impl HookEventsToml { (HookEventName::PreCompact, self.pre_compact), (HookEventName::PostCompact, self.post_compact), (HookEventName::SessionStart, self.session_start), + (HookEventName::SessionEnd, self.session_end), (HookEventName::UserPromptSubmit, self.user_prompt_submit), (HookEventName::SubagentStart, self.subagent_start), (HookEventName::SubagentStop, self.subagent_stop), diff --git a/codex-rs/config/src/requirements_layers/hooks.rs b/codex-rs/config/src/requirements_layers/hooks.rs index 94aefd328529..10b6c1c09cb3 100644 --- a/codex-rs/config/src/requirements_layers/hooks.rs +++ b/codex-rs/config/src/requirements_layers/hooks.rs @@ -210,6 +210,7 @@ fn append_hook_events(existing: &mut HookEventsToml, incoming: HookEventsToml) - pre_compact, post_compact, session_start, + session_end, user_prompt_submit, subagent_start, subagent_stop, @@ -223,6 +224,7 @@ fn append_hook_events(existing: &mut HookEventsToml, incoming: HookEventsToml) - changed |= append_vec(&mut existing.pre_compact, pre_compact); changed |= append_vec(&mut existing.post_compact, post_compact); changed |= append_vec(&mut existing.session_start, session_start); + changed |= append_vec(&mut existing.session_end, session_end); changed |= append_vec(&mut existing.user_prompt_submit, user_prompt_submit); changed |= append_vec(&mut existing.subagent_start, subagent_start); changed |= append_vec(&mut existing.subagent_stop, subagent_stop); diff --git a/codex-rs/core-plugins/src/manifest.rs b/codex-rs/core-plugins/src/manifest.rs index 4cc50328e9a3..d1b5568c6312 100644 --- a/codex-rs/core-plugins/src/manifest.rs +++ b/codex-rs/core-plugins/src/manifest.rs @@ -126,7 +126,7 @@ enum RawPluginManifestMcpServers { enum RawPluginManifestHooks { Path(String), Paths(Vec), - Inline(HooksFile), + Inline(Box), InlineList(Vec), Invalid(JsonValue), } @@ -311,7 +311,7 @@ fn resolve_manifest_hooks( } RawPluginManifestHooks::Inline(hooks) => { Some(codex_plugin::manifest::PluginManifestHooks::Inline(vec![ - hooks, + *hooks, ])) } RawPluginManifestHooks::InlineList(hooks) => (!hooks.is_empty()) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 2fa453666ccb..48dac6cbe535 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1310,6 +1310,13 @@ }, "type": "array" }, + "SessionEnd": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, "SessionStart": { "default": [], "items": { diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 342e4d4b8f44..3adbea420c1c 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -365,6 +365,38 @@ pub(crate) async fn run_turn_stop_hooks( outcome } +#[instrument(level = "trace", skip_all)] +pub(crate) async fn run_session_end_hooks(sess: &Arc) { + let hooks = sess.hooks(); + let preview_runs = hooks.preview_session_end(); + if preview_runs.is_empty() { + return; + } + + let turn_context = sess.new_default_turn().await; + + // SessionEnd is root-only; ThreadSpawn uses SubagentStart/SubagentStop and other subagents + // are internal implementation details. + if matches!(&turn_context.session_source, SessionSource::SubAgent(_)) { + return; + } + + let request = codex_hooks::SessionEndRequest { + session_id: sess.session_id().into(), + turn_id: turn_context.sub_id.clone(), + #[allow(deprecated)] + cwd: turn_context.cwd.clone(), + transcript_path: sess.hook_transcript_path().await, + }; + if let Err(err) = sess.flush_rollout().await { + tracing::warn!("failed to flush transcript before SessionEnd hook: {err}"); + } + emit_hook_started_events(sess, &turn_context, preview_runs).await; + + let outcome = hooks.run_session_end(request).await; + emit_hook_completed_events(sess, &turn_context, outcome.hook_events).await; +} + pub(crate) async fn run_pre_compact_hooks( sess: &Arc, turn_context: &Arc, @@ -703,6 +735,7 @@ fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str); HookEventName::PreCompact => "PreCompact", HookEventName::PostCompact => "PostCompact", HookEventName::SessionStart => "SessionStart", + HookEventName::SessionEnd => "SessionEnd", HookEventName::UserPromptSubmit => "UserPromptSubmit", HookEventName::SubagentStart => "SubagentStart", HookEventName::SubagentStop => "SubagentStop", diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 8a702499682b..d35d3419fcdc 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -609,6 +609,8 @@ async fn shutdown_session_runtime(sess: &Arc) { } sess.services.mcp_runtime.shutdown().await; sess.guardian_review_session.shutdown().await; + + crate::hook_runtime::run_session_end_hooks(sess).await; } async fn emit_thread_stop_lifecycle(sess: &Session) { diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index 6fa467f9f4e8..5b3339073480 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -3,6 +3,7 @@ use std::path::Path; use anyhow::Context; use anyhow::Result; +use codex_core::StartThreadOptions; use codex_core::config::Config; use codex_core::config::Constrained; use codex_features::Feature; @@ -18,9 +19,12 @@ use codex_protocol::models::ResponseItem; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsResponse; @@ -176,6 +180,41 @@ else: Ok(()) } +fn write_session_end_hook(home: &Path) -> Result<()> { + let script_path = home.join("session_end_hook.py"); + let log_path = home.join("session_end_hook_log.jsonl"); + let script = format!( + r#"import json +from pathlib import Path +import sys + +payload = json.load(sys.stdin) +transcript = Path(payload["transcript_path"]) +payload["transcript_exists"] = transcript.exists() +payload["transcript_text"] = transcript.read_text(encoding="utf-8") if transcript.exists() else "" +with Path(r"{log_path}").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") +print(json.dumps({{"continue": False, "decision": "block", "reason": "ignored"}})) +"#, + log_path = log_path.display(), + ); + let hooks = serde_json::json!({ + "hooks": { + "SessionEnd": [{ + "matcher": "other", + "hooks": [{ + "type": "command", + "command": format!("python3 {}", script_path.display()), + }] + }] + } + }); + + fs::write(&script_path, script).context("write session end hook script")?; + fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?; + Ok(()) +} + fn write_parallel_stop_hooks(home: &Path, prompts: &[&str]) -> Result<()> { let hook_entries = prompts .iter() @@ -1220,6 +1259,99 @@ async fn session_start_hook_sees_materialized_transcript_path() -> Result<()> { Ok(()) } +#[tokio::test] +async fn session_end_flushes_transcript_and_ignores_control_output() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let _response = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "persisted answer"), + ev_completed("resp-1"), + ]), + ) + .await; + let mut builder = test_codex() + .with_pre_build_hook(|home| { + write_session_end_hook(home).expect("write session end hook fixture"); + }) + .with_config(trust_discovered_hooks); + let test = builder.build(&server).await?; + + test.submit_turn("persist this before shutdown").await?; + test.codex.shutdown_and_wait().await?; + + let inputs = read_hook_inputs_from_log( + test.codex_home_path() + .join("session_end_hook_log.jsonl") + .as_path(), + )?; + assert_eq!(inputs.len(), 1); + assert_eq!(inputs[0]["hook_event_name"], "SessionEnd"); + assert_eq!(inputs[0]["reason"], "other"); + assert_eq!(inputs[0]["transcript_exists"], true); + let transcript = inputs[0]["transcript_text"] + .as_str() + .expect("session end transcript text"); + assert!(transcript.contains("persist this before shutdown")); + assert!(transcript.contains("persisted answer")); + Ok(()) +} + +#[tokio::test] +async fn session_end_skips_subagents() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex() + .with_pre_build_hook(|home| { + write_session_end_hook(home).expect("write session end hook fixture"); + }) + .with_config(trust_discovered_hooks); + let test = builder.build(&server).await?; + for source in [ + SubAgentSource::Review, + SubAgentSource::ThreadSpawn { + parent_thread_id: test.session_configured.thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }, + ] { + let subagent = test + .thread_manager + .start_thread_with_options(StartThreadOptions { + config: test.config.clone(), + allow_provider_model_fallback: false, + initial_history: InitialHistory::New, + history_mode: None, + session_source: Some(SessionSource::SubAgent(source)), + thread_source: None, + dynamic_tools: Vec::new(), + metrics_service_name: None, + parent_trace: None, + environments: Vec::new(), + thread_extension_init: Default::default(), + supports_openai_form_elicitation: false, + }) + .await?; + + subagent.thread.shutdown_and_wait().await?; + } + + assert!( + !test + .codex_home_path() + .join("session_end_hook_log.jsonl") + .exists(), + "subagents must not run SessionEnd hooks" + ); + Ok(()) +} + #[tokio::test] async fn session_start_runs_before_user_prompt_submit_on_first_turn() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/external-agent-migration/src/lib_tests.rs b/codex-rs/external-agent-migration/src/lib_tests.rs index 9804397ebf59..9ebe9797fc89 100644 --- a/codex-rs/external-agent-migration/src/lib_tests.rs +++ b/codex-rs/external-agent-migration/src/lib_tests.rs @@ -449,6 +449,13 @@ fn hook_migration_ignores_unsupported_handlers() { "command": source_hook_command("approve.py") }] }], + "SessionEnd": [{ + "matcher": "clear", + "hooks": [{ + "type": "command", + "command": source_hook_command("cleanup.py") + }] + }], "SubagentStart": [{ "matcher": "worker", "hooks": [{"type": "prompt", "prompt": "check"}] @@ -472,6 +479,13 @@ fn hook_migration_ignores_unsupported_handlers() { "type": "command", "command": migrated_hook_command("approve.py") }] + }], + "SessionEnd": [{ + "matcher": "clear", + "hooks": [{ + "type": "command", + "command": migrated_hook_command("cleanup.py") + }] }] }) .as_object() diff --git a/codex-rs/hooks/schema/generated/session-end.command.input.schema.json b/codex-rs/hooks/schema/generated/session-end.command.input.schema.json new file mode 100644 index 000000000000..de87f3687263 --- /dev/null +++ b/codex-rs/hooks/schema/generated/session-end.command.input.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "NullableString": { + "type": [ + "string", + "null" + ] + } + }, + "properties": { + "cwd": { + "type": "string" + }, + "hook_event_name": { + "const": "SessionEnd", + "type": "string" + }, + "reason": { + "const": "other", + "type": "string" + }, + "session_id": { + "type": "string" + }, + "transcript_path": { + "$ref": "#/definitions/NullableString" + } + }, + "required": [ + "cwd", + "hook_event_name", + "reason", + "session_id", + "transcript_path" + ], + "title": "session-end.command.input", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index cab28b64a15d..ce48be5efb23 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -27,6 +27,8 @@ use super::HookListEntry; use crate::config_rules::hook_states_from_stack; use crate::events::common::matcher_pattern_for_event; use crate::events::common::validate_matcher_pattern; +use crate::events::session_end::SESSION_END_DEFAULT_TIMEOUT_SEC; +use crate::events::session_end::SESSION_END_MAX_TIMEOUT_SEC; use codex_protocol::protocol::HookHandlerType; use codex_protocol::protocol::HookSource; use codex_protocol::protocol::HookTrustStatus; @@ -472,7 +474,8 @@ fn append_matcher_groups( } else { command }; - if r#async { + if r#async && event_name != codex_protocol::protocol::HookEventName::SessionEnd + { warnings.push(format!( "skipping async hook in {}: async hooks are not supported yet", source.path.display() @@ -486,7 +489,18 @@ fn append_matcher_groups( )); continue; } - let timeout_sec = timeout_sec.unwrap_or(600).max(1); + let timeout_sec = normalize_command_hook( + event_name, + timeout_sec, + source.path.as_path(), + warnings, + ); + if r#async { + warnings.push(format!( + "running async SessionEnd hook synchronously in {}", + source.path.display() + )); + } let normalized_handler = HookHandlerConfig::Command { command: command.clone(), command_windows: None, @@ -558,6 +572,30 @@ fn append_matcher_groups( } } +/// Normalizes command-hook timeouts. SessionEnd defaults to one second and is capped at three +/// seconds; all other command hooks keep the standard ten-minute default. +fn normalize_command_hook( + event_name: codex_protocol::protocol::HookEventName, + timeout_sec: Option, + source_path: &Path, + warnings: &mut Vec, +) -> u64 { + if event_name != codex_protocol::protocol::HookEventName::SessionEnd { + return timeout_sec.unwrap_or(600).max(1); + } + + let max_timeout_sec = SESSION_END_MAX_TIMEOUT_SEC; + if timeout_sec.is_some_and(|timeout_sec| timeout_sec > max_timeout_sec) { + warnings.push(format!( + "clamping SessionEnd hook timeout to {max_timeout_sec}s in {}", + source_path.display() + )); + } + timeout_sec + .unwrap_or(SESSION_END_DEFAULT_TIMEOUT_SEC) + .clamp(1, max_timeout_sec) +} + /// Hash a normalized, config-derived identity instead of source text so equivalent /// hooks from config TOML and hooks.json converge on the same trust identity. #[derive(Serialize)] @@ -831,6 +869,86 @@ mod tests { ); } + #[test] + fn session_end_normalizes_timeout() { + let mut handlers = Vec::new(); + let mut hook_entries = Vec::new(); + let mut warnings = Vec::new(); + let mut display_order = 0; + let source_path = source_path(); + let hook_states = std::collections::HashMap::new(); + + append_matcher_groups( + &mut handlers, + &mut hook_entries, + &mut warnings, + &mut display_order, + &hook_handler_source(&source_path, &hook_states), + HookEventName::SessionEnd, + vec![MatcherGroup { + matcher: Some("other".to_string()), + hooks: vec![ + HookHandlerConfig::Command { + command: "echo default".to_string(), + command_windows: None, + timeout_sec: None, + r#async: false, + status_message: None, + }, + HookHandlerConfig::Command { + command: "echo clamped".to_string(), + command_windows: None, + timeout_sec: Some(600), + r#async: true, + status_message: None, + }, + ], + }], + ); + + assert_eq!( + handlers + .iter() + .map(|handler| handler.timeout_sec) + .collect::>(), + vec![1, 3] + ); + assert_eq!( + handlers + .iter() + .map(|handler| handler.matcher.as_deref()) + .collect::>(), + vec![Some("other"), Some("other")] + ); + assert_eq!( + hook_entries + .iter() + .map(|entry| entry.timeout_sec) + .collect::>(), + vec![1, 3] + ); + assert_eq!( + hook_entries + .iter() + .map(|entry| entry.matcher.as_deref()) + .collect::>(), + vec![Some("other"), Some("other")] + ); + assert_eq!( + warnings, + vec![ + format!( + "clamping SessionEnd hook timeout to 3s in {}", + source_path.display() + ), + format!( + "running async SessionEnd hook synchronously in {}", + source_path.display() + ), + ] + ); + } + #[test] fn bypass_hook_trust_allows_enabled_untrusted_handlers() { let mut handlers = Vec::new(); diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index ccf5c2c8e679..f7a652629593 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -49,6 +49,7 @@ pub(crate) fn select_handlers_for_matcher_inputs( | HookEventName::PermissionRequest | HookEventName::PostToolUse | HookEventName::SessionStart + | HookEventName::SessionEnd | HookEventName::SubagentStart | HookEventName::SubagentStop | HookEventName::PreCompact @@ -141,7 +142,9 @@ pub(crate) fn completed_summary( pub(crate) fn scope_for_event(event_name: HookEventName) -> HookScope { match event_name { - HookEventName::SessionStart | HookEventName::SubagentStart => HookScope::Thread, + HookEventName::SessionStart | HookEventName::SessionEnd | HookEventName::SubagentStart => { + HookScope::Thread + } HookEventName::PreToolUse | HookEventName::PermissionRequest | HookEventName::PostToolUse @@ -161,6 +164,7 @@ pub(crate) fn hook_event_name_label(event_name: HookEventName) -> &'static str { HookEventName::PreCompact => "PreCompact", HookEventName::PostCompact => "PostCompact", HookEventName::SessionStart => "SessionStart", + HookEventName::SessionEnd => "SessionEnd", HookEventName::UserPromptSubmit => "UserPromptSubmit", HookEventName::SubagentStart => "SubagentStart", HookEventName::SubagentStop => "SubagentStop", diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 859fc5406950..ae1703eec127 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -14,6 +14,8 @@ use crate::events::post_tool_use::PostToolUseOutcome; use crate::events::post_tool_use::PostToolUseRequest; use crate::events::pre_tool_use::PreToolUseOutcome; use crate::events::pre_tool_use::PreToolUseRequest; +use crate::events::session_end::SessionEndOutcome; +use crate::events::session_end::SessionEndRequest; use crate::events::session_start::SessionStartOutcome; use crate::events::session_start::SessionStartRequest; use crate::events::stop::StopOutcome; @@ -69,6 +71,7 @@ impl ConfiguredHandler { 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::SessionEnd => "session-end", codex_protocol::protocol::HookEventName::UserPromptSubmit => "user-prompt-submit", codex_protocol::protocol::HookEventName::SubagentStart => "subagent-start", codex_protocol::protocol::HookEventName::SubagentStop => "subagent-stop", @@ -256,6 +259,14 @@ impl ClaudeHooksEngine { crate::events::stop::preview(&self.handlers, request) } + pub(crate) fn preview_session_end(&self) -> Vec { + crate::events::session_end::preview(&self.handlers) + } + + pub(crate) async fn run_session_end(&self, request: SessionEndRequest) -> SessionEndOutcome { + crate::events::session_end::run(&self.handlers, &self.shell, request).await + } + pub(crate) async fn run_stop(&self, request: StopRequest) -> StopOutcome { let session_id = request.session_id; let mut outcome = crate::events::stop::run(&self.handlers, &self.shell, request).await; diff --git a/codex-rs/hooks/src/engine/schema_loader.rs b/codex-rs/hooks/src/engine/schema_loader.rs index 655e28312471..e5fbf42d2cf3 100644 --- a/codex-rs/hooks/src/engine/schema_loader.rs +++ b/codex-rs/hooks/src/engine/schema_loader.rs @@ -16,6 +16,7 @@ pub(crate) struct GeneratedHookSchemas { pub pre_compact_command_output: Value, pub session_start_command_input: Value, pub session_start_command_output: Value, + pub session_end_command_input: Value, pub subagent_start_command_input: Value, pub subagent_start_command_output: Value, pub subagent_stop_command_input: Value, @@ -77,6 +78,10 @@ pub(crate) fn generated_hook_schemas() -> &'static GeneratedHookSchemas { "session-start.command.output", include_str!("../../schema/generated/session-start.command.output.schema.json"), ), + session_end_command_input: parse_json_schema( + "session-end.command.input", + include_str!("../../schema/generated/session-end.command.input.schema.json"), + ), subagent_start_command_input: parse_json_schema( "subagent-start.command.input", include_str!("../../schema/generated/subagent-start.command.input.schema.json"), @@ -138,6 +143,7 @@ mod tests { assert_eq!(schemas.pre_compact_command_output["type"], "object"); assert_eq!(schemas.session_start_command_input["type"], "object"); assert_eq!(schemas.session_start_command_output["type"], "object"); + assert_eq!(schemas.session_end_command_input["type"], "object"); assert_eq!(schemas.subagent_start_command_input["type"], "object"); assert_eq!(schemas.subagent_start_command_output["type"], "object"); assert_eq!(schemas.subagent_stop_command_input["type"], "object"); diff --git a/codex-rs/hooks/src/events/common.rs b/codex-rs/hooks/src/events/common.rs index 997eac139f4c..8f0934c044c5 100644 --- a/codex-rs/hooks/src/events/common.rs +++ b/codex-rs/hooks/src/events/common.rs @@ -111,6 +111,7 @@ pub(crate) fn matcher_pattern_for_event( | HookEventName::PermissionRequest | HookEventName::PostToolUse | HookEventName::SessionStart + | HookEventName::SessionEnd | HookEventName::SubagentStart | HookEventName::SubagentStop | HookEventName::PreCompact @@ -278,6 +279,10 @@ mod tests { matcher_pattern_for_event(HookEventName::SessionStart, Some("startup|resume")), Some("startup|resume") ); + assert_eq!( + matcher_pattern_for_event(HookEventName::SessionEnd, Some("clear|other")), + Some("clear|other") + ); assert_eq!( matcher_pattern_for_event(HookEventName::PreCompact, Some("^auto$")), Some("^auto$") diff --git a/codex-rs/hooks/src/events/mod.rs b/codex-rs/hooks/src/events/mod.rs index 5ec24462b93b..eb5c727d3179 100644 --- a/codex-rs/hooks/src/events/mod.rs +++ b/codex-rs/hooks/src/events/mod.rs @@ -3,6 +3,7 @@ pub mod compact; pub mod permission_request; pub mod post_tool_use; pub mod pre_tool_use; +pub mod session_end; pub mod session_start; pub mod stop; pub mod user_prompt_submit; diff --git a/codex-rs/hooks/src/events/session_end.rs b/codex-rs/hooks/src/events/session_end.rs new file mode 100644 index 000000000000..32a46b607df9 --- /dev/null +++ b/codex-rs/hooks/src/events/session_end.rs @@ -0,0 +1,140 @@ +use std::path::PathBuf; + +use codex_protocol::ThreadId; +use codex_protocol::protocol::HookCompletedEvent; +use codex_protocol::protocol::HookEventName; +use codex_protocol::protocol::HookOutputEntry; +use codex_protocol::protocol::HookOutputEntryKind; +use codex_protocol::protocol::HookRunStatus; +use codex_protocol::protocol::HookRunSummary; +use codex_utils_absolute_path::AbsolutePathBuf; + +use super::common; +use crate::engine::CommandShell; +use crate::engine::ConfiguredHandler; +use crate::engine::command_runner::CommandRunResult; +use crate::engine::dispatcher; +use crate::schema::NullableString; +use crate::schema::SessionEndCommandInput; + +pub(crate) const SESSION_END_DEFAULT_TIMEOUT_SEC: u64 = 1; +/// Keep below app-server's in-process `SHUTDOWN_TIMEOUT`: SessionEnd runs during +/// teardown and must leave headroom within the existing five-second bound. +pub(crate) const SESSION_END_MAX_TIMEOUT_SEC: u64 = 3; +const SESSION_END_REASON: &str = "other"; + +#[derive(Debug, Clone)] +pub struct SessionEndRequest { + pub session_id: ThreadId, + pub turn_id: String, + pub cwd: AbsolutePathBuf, + pub transcript_path: Option, +} + +#[derive(Debug, Default)] +pub struct SessionEndOutcome { + pub hook_events: Vec, +} + +pub(crate) fn preview(handlers: &[ConfiguredHandler]) -> Vec { + dispatcher::select_handlers( + handlers, + HookEventName::SessionEnd, + Some(SESSION_END_REASON), + ) + .into_iter() + .map(|handler| dispatcher::running_summary(&handler)) + .collect() +} + +pub(crate) async fn run( + handlers: &[ConfiguredHandler], + shell: &CommandShell, + request: SessionEndRequest, +) -> SessionEndOutcome { + let matched = dispatcher::select_handlers( + handlers, + HookEventName::SessionEnd, + Some(SESSION_END_REASON), + ); + if matched.is_empty() { + return SessionEndOutcome::default(); + } + + let input_json = match serde_json::to_string(&SessionEndCommandInput { + session_id: request.session_id.to_string(), + transcript_path: NullableString::from_path(request.transcript_path.clone()), + cwd: request.cwd.display().to_string(), + hook_event_name: "SessionEnd".to_string(), + reason: SESSION_END_REASON.to_string(), + }) { + Ok(input_json) => input_json, + Err(error) => { + return SessionEndOutcome { + hook_events: common::serialization_failure_hook_events( + matched, + Some(request.turn_id.clone()), + format!("failed to serialize session end hook input: {error}"), + ), + }; + } + }; + + let results = dispatcher::execute_handlers( + shell, + matched, + input_json, + request.cwd.as_path(), + Some(request.turn_id), + parse_completed, + ) + .await; + SessionEndOutcome { + hook_events: results.into_iter().map(|result| result.completed).collect(), + } +} + +fn parse_completed( + handler: &ConfiguredHandler, + run_result: CommandRunResult, + turn_id: Option, +) -> dispatcher::ParsedHandler<()> { + let (status, entries) = match (run_result.error.as_deref(), run_result.exit_code) { + (Some(error), _) => ( + HookRunStatus::Failed, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: error.to_string(), + }], + ), + (None, Some(0)) => (HookRunStatus::Completed, Vec::new()), + (None, Some(code)) => ( + HookRunStatus::Failed, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: common::trimmed_non_empty(&run_result.stderr) + .unwrap_or_else(|| format!("hook exited with code {code}")), + }], + ), + (None, None) => ( + HookRunStatus::Failed, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "hook process terminated without an exit code".to_string(), + }], + ), + }; + + dispatcher::ParsedHandler { + completed: HookCompletedEvent { + turn_id, + run: dispatcher::completed_summary(handler, &run_result, status, entries), + }, + data: (), + completion_order: 0, + } +} + +#[cfg(test)] +#[path = "session_end_tests.rs"] +mod tests; diff --git a/codex-rs/hooks/src/events/session_end_tests.rs b/codex-rs/hooks/src/events/session_end_tests.rs new file mode 100644 index 000000000000..ab687fdcb521 --- /dev/null +++ b/codex-rs/hooks/src/events/session_end_tests.rs @@ -0,0 +1,73 @@ +use std::collections::HashMap; + +use codex_protocol::protocol::HookEventName; +use codex_protocol::protocol::HookRunStatus; +use codex_protocol::protocol::HookSource; +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::parse_completed; +use super::preview; +use crate::engine::ConfiguredHandler; +use crate::engine::command_runner::CommandRunResult; + +#[test] +fn session_end_matches_other_reason() { + let selected = preview(&[ + ConfiguredHandler { + display_order: 0, + ..handler(Some("clear")) + }, + ConfiguredHandler { + display_order: 1, + ..handler(Some("other")) + }, + ConfiguredHandler { + display_order: 2, + ..handler(/*matcher*/ None) + }, + ]); + + assert_eq!( + selected + .iter() + .map(|run| run.display_order) + .collect::>(), + vec![1, 2] + ); +} + +#[test] +fn session_end_ignores_successful_output() { + let completed = parse_completed( + &handler(/*matcher*/ None), + CommandRunResult { + started_at: 1, + completed_at: 2, + duration_ms: 1, + exit_code: Some(0), + stdout: r#"{"continue":false,"decision":"block","reason":"ignored"}"#.to_string(), + stderr: String::new(), + error: None, + }, + /*turn_id*/ None, + ); + + assert_eq!(completed.completed.run.status, HookRunStatus::Completed); + assert_eq!(completed.completed.run.entries, Vec::new()); +} + +fn handler(matcher: Option<&str>) -> ConfiguredHandler { + ConfiguredHandler { + event_name: HookEventName::SessionEnd, + matcher: matcher.map(str::to_string), + command: "echo hook".to_string(), + timeout_sec: 2, + status_message: None, + source_path: test_path_buf("/tmp/hooks.json").abs(), + source: HookSource::User, + display_order: 0, + env: HashMap::new(), + } +} diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index 11300802d86e..2bc0731d30de 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -16,13 +16,14 @@ pub use declarations::plugin_hook_declarations; pub use engine::HookListEntry; pub use events::common::SubagentHookContext; /// Hook event names as they appear in hooks JSON and config files. -pub const HOOK_EVENT_NAMES: [&str; 10] = [ +pub const HOOK_EVENT_NAMES: [&str; 11] = [ "PreToolUse", "PermissionRequest", "PostToolUse", "PreCompact", "PostCompact", "SessionStart", + "SessionEnd", "UserPromptSubmit", "SubagentStart", "SubagentStop", @@ -33,14 +34,15 @@ pub const HOOK_EVENT_NAMES: [&str; 10] = [ /// /// Other events can appear in hooks JSON, but Codex ignores their matcher /// fields because those events do not dispatch against a tool, compaction -/// trigger, or session-start source. -pub const HOOK_EVENT_NAMES_WITH_MATCHERS: [&str; 8] = [ +/// trigger, session-start source, or session-end reason. +pub const HOOK_EVENT_NAMES_WITH_MATCHERS: [&str; 9] = [ "PreToolUse", "PermissionRequest", "PostToolUse", "PreCompact", "PostCompact", "SessionStart", + "SessionEnd", "SubagentStart", "SubagentStop", ]; @@ -56,6 +58,8 @@ pub use events::post_tool_use::PostToolUseOutcome; pub use events::post_tool_use::PostToolUseRequest; pub use events::pre_tool_use::PreToolUseOutcome; pub use events::pre_tool_use::PreToolUseRequest; +pub use events::session_end::SessionEndOutcome; +pub use events::session_end::SessionEndRequest; pub use events::session_start::SessionStartOutcome; pub use events::session_start::SessionStartRequest; pub use events::session_start::SessionStartSource; @@ -89,6 +93,7 @@ pub fn hook_event_key_label(event_name: HookEventName) -> &'static str { HookEventName::PreCompact => "pre_compact", HookEventName::PostCompact => "post_compact", HookEventName::SessionStart => "session_start", + HookEventName::SessionEnd => "session_end", HookEventName::UserPromptSubmit => "user_prompt_submit", HookEventName::SubagentStart => "subagent_start", HookEventName::SubagentStop => "subagent_stop", diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index 1f4e01aa5950..8fdfaf9cf4b0 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -15,6 +15,8 @@ use crate::events::post_tool_use::PostToolUseOutcome; use crate::events::post_tool_use::PostToolUseRequest; use crate::events::pre_tool_use::PreToolUseOutcome; use crate::events::pre_tool_use::PreToolUseRequest; +use crate::events::session_end::SessionEndOutcome; +use crate::events::session_end::SessionEndRequest; use crate::events::session_start::SessionStartOutcome; use crate::events::session_start::SessionStartRequest; use crate::events::stop::StopOutcome; @@ -203,6 +205,14 @@ impl Hooks { pub async fn run_stop(&self, request: StopRequest) -> StopOutcome { self.engine.run_stop(request).await } + + pub fn preview_session_end(&self) -> Vec { + self.engine.preview_session_end() + } + + pub async fn run_session_end(&self, request: SessionEndRequest) -> SessionEndOutcome { + self.engine.run_session_end(request).await + } } pub fn list_hooks(config: HooksConfig) -> HookListOutcome { diff --git a/codex-rs/hooks/src/schema.rs b/codex-rs/hooks/src/schema.rs index d90d8a3e5253..3847cc91818a 100644 --- a/codex-rs/hooks/src/schema.rs +++ b/codex-rs/hooks/src/schema.rs @@ -27,6 +27,7 @@ const PRE_COMPACT_INPUT_FIXTURE: &str = "pre-compact.command.input.schema.json"; const PRE_COMPACT_OUTPUT_FIXTURE: &str = "pre-compact.command.output.schema.json"; const SESSION_START_INPUT_FIXTURE: &str = "session-start.command.input.schema.json"; const SESSION_START_OUTPUT_FIXTURE: &str = "session-start.command.output.schema.json"; +const SESSION_END_INPUT_FIXTURE: &str = "session-end.command.input.schema.json"; const USER_PROMPT_SUBMIT_INPUT_FIXTURE: &str = "user-prompt-submit.command.input.schema.json"; const USER_PROMPT_SUBMIT_OUTPUT_FIXTURE: &str = "user-prompt-submit.command.output.schema.json"; const SUBAGENT_START_INPUT_FIXTURE: &str = "subagent-start.command.input.schema.json"; @@ -495,6 +496,19 @@ pub(crate) struct SessionStartCommandInput { pub source: String, } +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(rename = "session-end.command.input")] +pub(crate) struct SessionEndCommandInput { + pub session_id: String, + pub transcript_path: NullableString, + pub cwd: String, + #[schemars(schema_with = "session_end_hook_event_name_schema")] + pub hook_event_name: String, + #[schemars(schema_with = "session_end_reason_schema")] + pub reason: String, +} + impl SessionStartCommandInput { pub(crate) fn new( session_id: impl Into, @@ -646,6 +660,10 @@ pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> { &generated_dir.join(SESSION_START_OUTPUT_FIXTURE), schema_json::()?, )?; + write_schema( + &generated_dir.join(SESSION_END_INPUT_FIXTURE), + schema_json::()?, + )?; write_schema( &generated_dir.join(USER_PROMPT_SUBMIT_INPUT_FIXTURE), schema_json::()?, @@ -737,6 +755,14 @@ fn session_start_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema { string_const_schema("SessionStart") } +fn session_end_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema { + string_const_schema("SessionEnd") +} + +fn session_end_reason_schema(_gen: &mut SchemaGenerator) -> Schema { + string_const_schema("other") +} + fn post_tool_use_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema { string_const_schema("PostToolUse") } @@ -839,6 +865,7 @@ mod tests { use super::PreCompactCommandInput; use super::PreToolUseCommandInput; use super::PreToolUseCommandOutputWire; + use super::SESSION_END_INPUT_FIXTURE; use super::SESSION_START_INPUT_FIXTURE; use super::SESSION_START_OUTPUT_FIXTURE; use super::STOP_INPUT_FIXTURE; @@ -904,6 +931,9 @@ mod tests { SESSION_START_OUTPUT_FIXTURE => { include_str!("../schema/generated/session-start.command.output.schema.json") } + SESSION_END_INPUT_FIXTURE => { + include_str!("../schema/generated/session-end.command.input.schema.json") + } USER_PROMPT_SUBMIT_INPUT_FIXTURE => { include_str!("../schema/generated/user-prompt-submit.command.input.schema.json") } @@ -969,6 +999,7 @@ mod tests { PRE_TOOL_USE_OUTPUT_FIXTURE, SESSION_START_INPUT_FIXTURE, SESSION_START_OUTPUT_FIXTURE, + SESSION_END_INPUT_FIXTURE, USER_PROMPT_SUBMIT_INPUT_FIXTURE, USER_PROMPT_SUBMIT_OUTPUT_FIXTURE, SUBAGENT_START_INPUT_FIXTURE, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 62c0e17ebd38..c74fd25c8034 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1495,6 +1495,7 @@ pub enum HookEventName { PreCompact, PostCompact, SessionStart, + SessionEnd, UserPromptSubmit, SubagentStart, SubagentStop, diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs index bd6e25f7ca80..e5d88fd0b541 100644 --- a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -735,6 +735,7 @@ fn event_label(event_name: HookEventName) -> &'static str { HookEventName::PreCompact => "PreCompact", HookEventName::PostCompact => "PostCompact", HookEventName::SessionStart => "SessionStart", + HookEventName::SessionEnd => "SessionEnd", HookEventName::UserPromptSubmit => "UserPromptSubmit", HookEventName::SubagentStart => "SubagentStart", HookEventName::SubagentStop => "SubagentStop", @@ -750,6 +751,7 @@ fn event_description(event_name: HookEventName) -> &'static str { HookEventName::PreCompact => "Before context compaction", HookEventName::PostCompact => "After context compaction", HookEventName::SessionStart => "When a new session starts", + HookEventName::SessionEnd => "Right before a session ends", HookEventName::UserPromptSubmit => "When the user submits a prompt", HookEventName::SubagentStart => "When a subagent is created", HookEventName::SubagentStop => "Right before a subagent ends its turn", diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap index eaf06f6c45ec..0677d09117a8 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap @@ -13,6 +13,7 @@ expression: "render_lines(&view, 112)" PreCompact 0 0 Before context compaction PostCompact 0 0 After context compaction SessionStart 0 0 When a new session starts + SessionEnd 0 0 Right before a session ends UserPromptSubmit 0 0 When the user submits a prompt SubagentStart 0 0 When a subagent is created SubagentStop 0 0 Right before a subagent ends its turn diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap index 7dc71463dc17..d719280b4d1a 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap @@ -17,6 +17,7 @@ expression: "render_lines(&view, 112)" PreCompact 0 0 Before context compaction PostCompact 0 0 After context compaction SessionStart 0 0 When a new session starts + SessionEnd 0 0 Right before a session ends UserPromptSubmit 0 0 When the user submits a prompt SubagentStart 0 0 When a subagent is created SubagentStop 0 0 Right before a subagent ends its turn diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap index c18657d8a172..78e2e90c6dcb 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap @@ -15,6 +15,7 @@ expression: "render_lines(&view, 112)" PreCompact 0 0 0 Before context compaction PostCompact 0 0 0 After context compaction SessionStart 0 0 0 When a new session starts + SessionEnd 0 0 0 Right before a session ends UserPromptSubmit 0 0 0 When the user submits a prompt SubagentStart 0 0 0 When a subagent is created SubagentStop 0 0 0 Right before a subagent ends its turn diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap index 99922ac63049..b52b4bf1eedc 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap @@ -16,6 +16,7 @@ expression: popup PreCompact 0 0 Before context compaction PostCompact 0 0 After context compaction SessionStart 0 0 When a new session starts + SessionEnd 0 0 Right before a session ends UserPromptSubmit 0 0 When the user submits a prompt SubagentStart 0 0 When a subagent is created SubagentStop 0 0 Right before a subagent ends its turn diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 0a4cfd106049..a4bd15eaeb86 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1686,6 +1686,7 @@ fn hook_event_label(event_name: codex_app_server_protocol::HookEventName) -> &'s codex_app_server_protocol::HookEventName::PreCompact => "PreCompact", codex_app_server_protocol::HookEventName::PostCompact => "PostCompact", codex_app_server_protocol::HookEventName::SessionStart => "SessionStart", + codex_app_server_protocol::HookEventName::SessionEnd => "SessionEnd", codex_app_server_protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit", codex_app_server_protocol::HookEventName::SubagentStart => "SubagentStart", codex_app_server_protocol::HookEventName::SubagentStop => "SubagentStop", diff --git a/codex-rs/tui/src/history_cell/hook_cell.rs b/codex-rs/tui/src/history_cell/hook_cell.rs index 51191ea10313..379b6428ce3e 100644 --- a/codex-rs/tui/src/history_cell/hook_cell.rs +++ b/codex-rs/tui/src/history_cell/hook_cell.rs @@ -819,6 +819,7 @@ fn hook_event_label(event_name: HookEventName) -> &'static str { HookEventName::PreCompact => "PreCompact", HookEventName::PostCompact => "PostCompact", HookEventName::SessionStart => "SessionStart", + HookEventName::SessionEnd => "SessionEnd", HookEventName::UserPromptSubmit => "UserPromptSubmit", HookEventName::SubagentStart => "SubagentStart", HookEventName::SubagentStop => "SubagentStop",