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
This commit is contained in:
Abhinav
2026-07-17 19:58:56 +00:00
committed by copyberry
parent b9680065b0
commit 7bd44085e1
46 changed files with 930 additions and 14 deletions
+1
View File
@@ -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",
@@ -2042,6 +2042,7 @@
"preCompact",
"postCompact",
"sessionStart",
"sessionEnd",
"userPromptSubmit",
"subagentStart",
"subagentStop",
@@ -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"
@@ -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"
@@ -315,6 +315,13 @@
},
"type": "array"
},
"SessionEnd": {
"default": [],
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"SessionStart": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
@@ -13,6 +13,7 @@
"preCompact",
"postCompact",
"sessionStart",
"sessionEnd",
"userPromptSubmit",
"subagentStart",
"subagentStop",
@@ -13,6 +13,7 @@
"preCompact",
"postCompact",
"sessionStart",
"sessionEnd",
"userPromptSubmit",
"subagentStart",
"subagentStop",
@@ -28,6 +28,7 @@
"preCompact",
"postCompact",
"sessionStart",
"sessionEnd",
"userPromptSubmit",
"subagentStart",
"subagentStop",
@@ -116,6 +116,7 @@
"preCompact",
"postCompact",
"sessionStart",
"sessionEnd",
"userPromptSubmit",
"subagentStart",
"subagentStop",
@@ -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";
@@ -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<ConfiguredHookMatcherGroup>, PermissionRequest: Array<ConfiguredHookMatcherGroup>, PostToolUse: Array<ConfiguredHookMatcherGroup>, PreCompact: Array<ConfiguredHookMatcherGroup>, PostCompact: Array<ConfiguredHookMatcherGroup>, SessionStart: Array<ConfiguredHookMatcherGroup>, UserPromptSubmit: Array<ConfiguredHookMatcherGroup>, SubagentStart: Array<ConfiguredHookMatcherGroup>, SubagentStop: Array<ConfiguredHookMatcherGroup>, Stop: Array<ConfiguredHookMatcherGroup>, };
export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array<ConfiguredHookMatcherGroup>, PermissionRequest: Array<ConfiguredHookMatcherGroup>, PostToolUse: Array<ConfiguredHookMatcherGroup>, PreCompact: Array<ConfiguredHookMatcherGroup>, PostCompact: Array<ConfiguredHookMatcherGroup>, SessionStart: Array<ConfiguredHookMatcherGroup>, SessionEnd: Array<ConfiguredHookMatcherGroup>, UserPromptSubmit: Array<ConfiguredHookMatcherGroup>, SubagentStart: Array<ConfiguredHookMatcherGroup>, SubagentStop: Array<ConfiguredHookMatcherGroup>, Stop: Array<ConfiguredHookMatcherGroup>, };
@@ -441,6 +441,9 @@ pub struct ManagedHooksRequirements {
#[serde(rename = "SessionStart")]
#[ts(rename = "SessionStart")]
pub session_start: Vec<ConfiguredHookMatcherGroup>,
#[serde(rename = "SessionEnd", default)]
#[ts(rename = "SessionEnd")]
pub session_end: Vec<ConfiguredHookMatcherGroup>,
#[serde(rename = "UserPromptSubmit")]
#[ts(rename = "UserPromptSubmit")]
pub user_prompt_submit: Vec<ConfiguredHookMatcherGroup>,
@@ -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
}
);
+4 -2
View File
@@ -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" } }
@@ -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),
@@ -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<ExitStatus> {
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
@@ -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;
@@ -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::<Vec<_>>();
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<String> {
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::<ThreadStartResponse>(response)?.thread.id)
}
fn write_config_and_hook(codex_home: &Path, server_uri: &str) -> Result<std::path::PathBuf> {
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<Vec<Value>> {
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()
}
+8 -1
View File
@@ -46,6 +46,8 @@ pub struct HookEventsToml {
pub post_compact: Vec<MatcherGroup>,
#[serde(rename = "SessionStart", default)]
pub session_start: Vec<MatcherGroup>,
#[serde(rename = "SessionEnd", default)]
pub session_end: Vec<MatcherGroup>,
#[serde(rename = "UserPromptSubmit", default)]
pub user_prompt_submit: Vec<MatcherGroup>,
#[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<MatcherGroup>); 10] {
pub fn into_matcher_groups(self) -> [(HookEventName, Vec<MatcherGroup>); 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),
@@ -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);
+2 -2
View File
@@ -126,7 +126,7 @@ enum RawPluginManifestMcpServers {
enum RawPluginManifestHooks {
Path(String),
Paths(Vec<String>),
Inline(HooksFile),
Inline(Box<HooksFile>),
InlineList(Vec<HooksFile>),
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())
+7
View File
@@ -1310,6 +1310,13 @@
},
"type": "array"
},
"SessionEnd": {
"default": [],
"items": {
"$ref": "#/definitions/MatcherGroup"
},
"type": "array"
},
"SessionStart": {
"default": [],
"items": {
+33
View File
@@ -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<Session>) {
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<Session>,
turn_context: &Arc<TurnContext>,
@@ -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",
+2
View File
@@ -609,6 +609,8 @@ async fn shutdown_session_runtime(sess: &Arc<Session>) {
}
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) {
+132
View File
@@ -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(()));
@@ -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()
@@ -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"
}
+120 -2
View File
@@ -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<u64>,
source_path: &Path,
warnings: &mut Vec<String>,
) -> 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<_>>(),
vec![1, 3]
);
assert_eq!(
handlers
.iter()
.map(|handler| handler.matcher.as_deref())
.collect::<Vec<_>>(),
vec![Some("other"), Some("other")]
);
assert_eq!(
hook_entries
.iter()
.map(|entry| entry.timeout_sec)
.collect::<Vec<_>>(),
vec![1, 3]
);
assert_eq!(
hook_entries
.iter()
.map(|entry| entry.matcher.as_deref())
.collect::<Vec<_>>(),
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();
+5 -1
View File
@@ -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",
+11
View File
@@ -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<HookRunSummary> {
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;
@@ -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");
+5
View File
@@ -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$")
+1
View File
@@ -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;
+140
View File
@@ -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<PathBuf>,
}
#[derive(Debug, Default)]
pub struct SessionEndOutcome {
pub hook_events: Vec<HookCompletedEvent>,
}
pub(crate) fn preview(handlers: &[ConfiguredHandler]) -> Vec<HookRunSummary> {
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<String>,
) -> 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;
@@ -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<_>>(),
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(),
}
}
+8 -3
View File
@@ -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",
+10
View File
@@ -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<codex_protocol::protocol::HookRunSummary> {
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 {
+31
View File
@@ -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<String>,
@@ -646,6 +660,10 @@ pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> {
&generated_dir.join(SESSION_START_OUTPUT_FIXTURE),
schema_json::<SessionStartCommandOutputWire>()?,
)?;
write_schema(
&generated_dir.join(SESSION_END_INPUT_FIXTURE),
schema_json::<SessionEndCommandInput>()?,
)?;
write_schema(
&generated_dir.join(USER_PROMPT_SUBMIT_INPUT_FIXTURE),
schema_json::<UserPromptSubmitCommandInput>()?,
@@ -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,
+1
View File
@@ -1495,6 +1495,7 @@ pub enum HookEventName {
PreCompact,
PostCompact,
SessionStart,
SessionEnd,
UserPromptSubmit,
SubagentStart,
SubagentStop,
@@ -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",
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
@@ -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",