diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 0b0620223e..92aad2d041 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 42375301b6..42038ba371 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 d285c309b5..2c75383f6b 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 de0cc6048c..165718d7af 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 4e35f7aead..54c75f363a 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 8684bf9ae5..11d6f28458 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 5b2750d780..8d6d82aa04 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 c3288ee0fd..fa71c18f4f 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 2b6bdc29ad..85d5c59306 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 477476289d..ae8a7f389f 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 1143bd017f..6d49d5f0c5 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 75a2c62664..2462b8b239 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 b8f47276a6..247b69e647 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 021e8043fa..5c1cf0c2b8 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 0eeb1aaca9..33d4bcb4f2 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 8e3e2ef810..3e2a6903d8 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 6f8f9c0143..6566f0459e 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 0000000000..c7d59c04dd --- /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 8e18cb935a..371f6a91b5 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 94aefd3285..10b6c1c09c 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 4cc50328e9..d1b5568c63 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 2fa453666c..48dac6cbe5 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 342e4d4b8f..3adbea420c 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 8a70249968..d35d3419fc 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 6fa467f9f4..5b33390734 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 9804397ebf..9ebe9797fc 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 0000000000..de87f36872 --- /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 cab28b64a1..ce48be5efb 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 ccf5c2c8e6..f7a6526295 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 859fc54069..ae1703eec1 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 655e283124..e5fbf42d2c 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 997eac139f..8f0934c044 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 5ec24462b9..eb5c727d31 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 0000000000..32a46b607d --- /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 0000000000..ab687fdcb5 --- /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 11300802d8..2bc0731d30 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 1f4e01aa59..8fdfaf9cf4 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 d90d8a3e52..3847cc9181 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 62c0e17ebd..c74fd25c80 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 bd6e25f7ca..e5d88fd0b5 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 eaf06f6c45..0677d09117 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 7dc71463dc..d719280b4d 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 c18657d8a1..78e2e90c6d 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 99922ac630..b52b4bf1ee 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 0a4cfd1060..a4bd15eaeb 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 51191ea103..379b6428ce 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",