diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index d107a5a367..87cb165ac2 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -990,6 +990,8 @@ fn analytics_hook_event_name(event_name: HookEventName) -> &'static str { HookEventName::PostCompact => "PostCompact", HookEventName::SessionStart => "SessionStart", HookEventName::UserPromptSubmit => "UserPromptSubmit", + HookEventName::SubagentStart => "SubagentStart", + HookEventName::SubagentStop => "SubagentStop", HookEventName::Stop => "Stop", } } diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 6af19f89e2..5da36e1d24 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1740,6 +1740,8 @@ "postCompact", "sessionStart", "userPromptSubmit", + "subagentStart", + "subagentStop", "stop" ], "type": "string" 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 6da572813f..79c466b5aa 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 @@ -9565,6 +9565,8 @@ "postCompact", "sessionStart", "userPromptSubmit", + "subagentStart", + "subagentStop", "stop" ], "type": "string" 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 e338f4151f..13f3ff9ed3 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 @@ -6065,6 +6065,8 @@ "postCompact", "sessionStart", "userPromptSubmit", + "subagentStart", + "subagentStop", "stop" ], "type": "string" 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 f63e6a5cfe..088da47484 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json @@ -14,6 +14,8 @@ "postCompact", "sessionStart", "userPromptSubmit", + "subagentStart", + "subagentStop", "stop" ], "type": "string" 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 f8eeecfe4d..38395cded9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json @@ -14,6 +14,8 @@ "postCompact", "sessionStart", "userPromptSubmit", + "subagentStart", + "subagentStop", "stop" ], "type": "string" 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 f2a7c80cf0..c58a5c767d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json @@ -29,6 +29,8 @@ "postCompact", "sessionStart", "userPromptSubmit", + "subagentStart", + "subagentStop", "stop" ], "type": "string" 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 0c9f897dc9..c9fe43c34f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -46,6 +46,8 @@ "postCompact", "sessionStart", "userPromptSubmit", + "subagentStart", + "subagentStop", "stop" ], "type": "string" 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 91c2def709..477476289d 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" | "stop"; +export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "userPromptSubmit" | "subagentStart" | "subagentStop" | "stop"; 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 4a07bd495b..6e6c1ef7d4 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, Stop + PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, UserPromptSubmit, SubagentStart, SubagentStop, Stop } ); diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs index 869761bdaa..eee66896af 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -45,6 +45,10 @@ pub struct HookEventsToml { pub session_start: Vec, #[serde(rename = "UserPromptSubmit", default)] pub user_prompt_submit: Vec, + #[serde(rename = "SubagentStart", default)] + pub subagent_start: Vec, + #[serde(rename = "SubagentStop", default)] + pub subagent_stop: Vec, #[serde(rename = "Stop", default)] pub stop: Vec, } @@ -59,6 +63,8 @@ impl HookEventsToml { post_compact, session_start, user_prompt_submit, + subagent_start, + subagent_stop, stop, } = self; pre_tool_use.is_empty() @@ -68,6 +74,8 @@ impl HookEventsToml { && post_compact.is_empty() && session_start.is_empty() && user_prompt_submit.is_empty() + && subagent_start.is_empty() + && subagent_stop.is_empty() && stop.is_empty() } @@ -80,6 +88,8 @@ impl HookEventsToml { post_compact, session_start, user_prompt_submit, + subagent_start, + subagent_stop, stop, } = self; [ @@ -90,6 +100,8 @@ impl HookEventsToml { post_compact, session_start, user_prompt_submit, + subagent_start, + subagent_stop, stop, ] .into_iter() @@ -98,7 +110,7 @@ impl HookEventsToml { .sum() } - pub fn into_matcher_groups(self) -> [(HookEventName, Vec); 8] { + pub fn into_matcher_groups(self) -> [(HookEventName, Vec); 10] { [ (HookEventName::PreToolUse, self.pre_tool_use), (HookEventName::PermissionRequest, self.permission_request), @@ -107,6 +119,8 @@ impl HookEventsToml { (HookEventName::PostCompact, self.post_compact), (HookEventName::SessionStart, self.session_start), (HookEventName::UserPromptSubmit, self.user_prompt_submit), + (HookEventName::SubagentStart, self.subagent_start), + (HookEventName::SubagentStop, self.subagent_stop), (HookEventName::Stop, self.stop), ] } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 560e87b058..2111ea9f92 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1091,6 +1091,20 @@ }, "type": "array" }, + "SubagentStart": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, + "SubagentStop": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, "UserPromptSubmit": { "default": [], "items": { diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index d973f40e45..a8198f051b 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -36,6 +36,7 @@ use codex_thread_store::ReadThreadParams; use serde::Serialize; use std::collections::HashMap; use std::collections::VecDeque; +use std::path::PathBuf; use std::sync::Arc; use std::sync::Weak; use tokio::sync::watch; @@ -162,6 +163,11 @@ impl AgentControl { self.session_id } + pub(crate) async fn thread_rollout_path(&self, thread_id: ThreadId) -> Option { + let state = self.upgrade().ok()?; + state.get_thread(thread_id).await.ok()?.rollout_path() + } + /// Spawn a new agent thread and submit the initial prompt. #[cfg(test)] pub(crate) async fn spawn_agent( diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 78fc24833e..4d572a4fe7 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -1,4 +1,5 @@ use std::future::Future; +use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -13,6 +14,7 @@ use codex_hooks::PostToolUseRequest; use codex_hooks::PreToolUseOutcome; use codex_hooks::PreToolUseRequest; use codex_hooks::SessionStartOutcome; +use codex_hooks::SubagentStartOutcome; use codex_hooks::UserPromptSubmitOutcome; use codex_hooks::UserPromptSubmitRequest; use codex_otel::HOOK_RUN_DURATION_METRIC; @@ -28,6 +30,8 @@ use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookRunSummary; use codex_protocol::protocol::HookSource; use codex_protocol::protocol::HookStartedEvent; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; use codex_protocol::user_input::UserInput; use serde_json::Value; @@ -65,6 +69,19 @@ pub(crate) enum PendingInputRecord { }, } +pub(crate) struct StopHookOutcome { + pub should_stop: bool, + pub should_block: bool, + pub continuation_fragments: Vec, +} + +struct SubagentHookMetadata { + agent_id: String, + agent_type: String, + parent_transcript_path: Option, + agent_transcript_path: Option, +} + struct ContextInjectingHookOutcome { hook_events: Vec, outcome: HookRuntimeOutcome, @@ -106,6 +123,22 @@ impl From for ContextInjectingHookOutcome { } } +impl From for ContextInjectingHookOutcome { + fn from(value: SubagentStartOutcome) -> Self { + let SubagentStartOutcome { + hook_events, + additional_contexts, + } = value; + Self { + hook_events, + outcome: HookRuntimeOutcome { + should_stop: false, + additional_contexts, + }, + } + } +} + pub(crate) async fn run_pending_session_start_hooks( sess: &Arc, turn_context: &Arc, @@ -136,6 +169,41 @@ pub(crate) async fn run_pending_session_start_hooks( .await } +pub(crate) async fn run_pending_subagent_start_hooks( + sess: &Arc, + turn_context: &Arc, +) { + if !sess.take_pending_subagent_start().await { + return; + } + + let Some(metadata) = subagent_hook_metadata(sess, turn_context).await else { + return; + }; + let request = codex_hooks::SubagentStartRequest { + session_id: sess.session_id().into(), + turn_id: turn_context.sub_id.clone(), + #[allow(deprecated)] + cwd: turn_context.cwd.clone(), + transcript_path: metadata.parent_transcript_path, + model: turn_context.model_info.slug.clone(), + permission_mode: hook_permission_mode(turn_context), + agent_id: metadata.agent_id, + agent_type: metadata.agent_type, + }; + let hooks = sess.hooks(); + let preview_runs = hooks.preview_subagent_start(&request); + run_context_injecting_hook( + sess, + turn_context, + preview_runs, + hooks.run_subagent_start(request), + ) + .await + .record_additional_contexts(sess, turn_context) + .await; +} + /// Runs matching `PreToolUse` hooks before a tool executes. /// /// `tool_name` is the canonical name serialized to hook stdin. Matcher aliases @@ -272,6 +340,54 @@ pub(crate) async fn run_post_tool_use_hooks( outcome } +pub(crate) async fn run_turn_stop_hooks( + sess: &Arc, + turn_context: &Arc, + stop_hook_active: bool, + last_assistant_message: Option, +) -> StopHookOutcome { + let hooks = sess.hooks(); + let outcome = if let Some(metadata) = subagent_hook_metadata(sess, turn_context).await { + let request = codex_hooks::SubagentStopRequest { + session_id: sess.session_id().into(), + turn_id: turn_context.sub_id.clone(), + #[allow(deprecated)] + cwd: turn_context.cwd.clone(), + transcript_path: metadata.parent_transcript_path, + model: turn_context.model_info.slug.clone(), + permission_mode: hook_permission_mode(turn_context), + stop_hook_active, + agent_id: metadata.agent_id, + agent_type: metadata.agent_type, + agent_transcript_path: metadata.agent_transcript_path, + last_assistant_message, + }; + emit_hook_started_events(sess, turn_context, hooks.preview_subagent_stop(&request)).await; + hooks.run_subagent_stop(request).await + } else { + let request = codex_hooks::StopRequest { + 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, + model: turn_context.model_info.slug.clone(), + permission_mode: hook_permission_mode(turn_context), + stop_hook_active, + last_assistant_message, + }; + emit_hook_started_events(sess, turn_context, hooks.preview_stop(&request)).await; + hooks.run_stop(request).await + }; + emit_hook_completed_events(sess, turn_context, outcome.hook_events).await; + + StopHookOutcome { + should_stop: outcome.should_stop, + should_block: outcome.should_block, + continuation_fragments: outcome.continuation_fragments, + } +} + pub(crate) async fn run_pre_compact_hooks( sess: &Arc, turn_context: &Arc, @@ -555,6 +671,8 @@ fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str); HookEventName::PostCompact => "PostCompact", HookEventName::SessionStart => "SessionStart", HookEventName::UserPromptSubmit => "UserPromptSubmit", + HookEventName::SubagentStart => "SubagentStart", + HookEventName::SubagentStop => "SubagentStop", HookEventName::Stop => "Stop", }; let hook_source = match run.source { @@ -595,6 +713,52 @@ fn hook_permission_mode(turn_context: &TurnContext) -> String { .to_string() } +async fn subagent_hook_metadata( + sess: &Arc, + turn_context: &TurnContext, +) -> Option { + let SessionSource::SubAgent(subagent_source) = &turn_context.session_source else { + return None; + }; + + let parent_thread_id = match subagent_source { + SubAgentSource::ThreadSpawn { + parent_thread_id, .. + } => Some(*parent_thread_id), + SubAgentSource::Review + | SubAgentSource::Compact + | SubAgentSource::MemoryConsolidation + | SubAgentSource::Other(_) => None, + }; + let parent_transcript_path = if let Some(parent_thread_id) = parent_thread_id { + sess.services + .agent_control + .thread_rollout_path(parent_thread_id) + .await + } else { + None + }; + + Some(SubagentHookMetadata { + agent_id: sess.thread_id().to_string(), + agent_type: subagent_hook_agent_type(subagent_source), + parent_transcript_path, + agent_transcript_path: sess.hook_transcript_path().await, + }) +} + +fn subagent_hook_agent_type(subagent_source: &SubAgentSource) -> String { + match subagent_source { + SubAgentSource::ThreadSpawn { agent_role, .. } => agent_role + .clone() + .unwrap_or_else(|| crate::agent::role::DEFAULT_ROLE_NAME.to_string()), + SubAgentSource::Review => "review".to_string(), + SubAgentSource::Compact => "compact".to_string(), + SubAgentSource::MemoryConsolidation => "memory_consolidation".to_string(), + SubAgentSource::Other(value) => value.clone(), + } +} + fn compaction_trigger_label(value: CompactionTrigger) -> &'static str { match value { CompactionTrigger::Manual => "manual", diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index fd3f31771d..ec631a2fb1 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -3363,6 +3363,11 @@ impl Session { state.take_pending_session_start_source() } + pub(crate) async fn take_pending_subagent_start(&self) -> bool { + let mut state = self.state.lock().await; + state.take_pending_subagent_start() + } + fn show_raw_agent_reasoning(&self) -> bool { self.services.show_raw_agent_reasoning } diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 5b1b8c83e4..0330f6d0f4 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1067,12 +1067,20 @@ impl Session { } InitialHistory::Cleared => codex_hooks::SessionStartSource::Clear, }; + let should_run_subagent_start = matches!( + (&session_configuration.session_source, &initial_history), + ( + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. }), + InitialHistory::New | InitialHistory::Forked(_) + ) + ); // record_initial_history can emit events. We record only after the SessionConfiguredEvent is emitted. sess.record_initial_history(initial_history).await; { let mut state = sess.state.lock().await; state.set_pending_session_start_source(Some(session_start_source)); + state.set_pending_subagent_start(should_run_subagent_start); } Ok(sess) diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index d910e03b21..eaccdd0525 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -21,11 +21,12 @@ use crate::connectors; use crate::context::ContextualUserFragment; use crate::feedback_tags; use crate::hook_runtime::PendingInputHookDisposition; -use crate::hook_runtime::emit_hook_completed_events; use crate::hook_runtime::inspect_pending_input; use crate::hook_runtime::record_additional_contexts; use crate::hook_runtime::record_pending_input; use crate::hook_runtime::run_pending_session_start_hooks; +use crate::hook_runtime::run_pending_subagent_start_hooks; +use crate::hook_runtime::run_turn_stop_hooks; use crate::hook_runtime::run_user_prompt_submit_hooks; use crate::injection::ToolMentionKind; use crate::injection::app_id_from_path; @@ -88,7 +89,6 @@ use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AgentMessageContentDeltaEvent; use codex_protocol::protocol::AgentReasoningSectionBreakEvent; -use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::EventMsg; @@ -305,6 +305,7 @@ pub(crate) async fn run_turn( if run_pending_session_start_hooks(&sess, &turn_context).await { return None; } + run_pending_subagent_start_hooks(&sess, &turn_context).await; let additional_contexts = if input.is_empty() { Vec::new() } else { @@ -385,6 +386,7 @@ pub(crate) async fn run_turn( if run_pending_session_start_hooks(&sess, &turn_context).await { break; } + run_pending_subagent_start_hooks(&sess, &turn_context).await; // Note that pending_input would be something like a message the user // submitted through the UI while the model was running. Though the UI @@ -514,39 +516,13 @@ pub(crate) async fn run_turn( if !needs_follow_up { last_agent_message = sampling_request_last_agent_message; - let stop_hook_permission_mode = match turn_context.approval_policy.value() { - AskForApproval::Never => "bypassPermissions", - AskForApproval::UnlessTrusted - | AskForApproval::OnFailure - | AskForApproval::OnRequest - | AskForApproval::Granular(_) => "default", - } - .to_string(); - let stop_request = codex_hooks::StopRequest { - 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, - model: turn_context.model_info.slug.clone(), - permission_mode: stop_hook_permission_mode, + let stop_outcome = run_turn_stop_hooks( + &sess, + &turn_context, stop_hook_active, - last_assistant_message: last_agent_message.clone(), - }; - let hooks = sess.hooks(); - for run in hooks.preview_stop(&stop_request) { - sess.send_event( - &turn_context, - EventMsg::HookStarted(codex_protocol::protocol::HookStartedEvent { - turn_id: Some(turn_context.sub_id.clone()), - run, - }), - ) - .await; - } - let stop_outcome = hooks.run_stop(stop_request).await; - emit_hook_completed_events(&sess, &turn_context, stop_outcome.hook_events) - .await; + last_agent_message.clone(), + ) + .await; if stop_outcome.should_block { if let Some(hook_prompt_message) = build_hook_prompt_message(&stop_outcome.continuation_fragments) diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 3bd4b8a26e..d6fd4f16d3 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -32,6 +32,7 @@ pub(crate) struct SessionState { pub(crate) startup_prewarm: Option, pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, + pending_subagent_start: bool, granted_permissions: Option, next_turn_is_first: bool, } @@ -51,6 +52,7 @@ impl SessionState { startup_prewarm: None, active_connector_selection: HashSet::new(), pending_session_start_source: None, + pending_subagent_start: false, granted_permissions: None, next_turn_is_first: true, } @@ -218,6 +220,16 @@ impl SessionState { self.pending_session_start_source.take() } + pub(crate) fn set_pending_subagent_start(&mut self, value: bool) { + self.pending_subagent_start = value; + } + + pub(crate) fn take_pending_subagent_start(&mut self) -> bool { + let pending = self.pending_subagent_start; + self.pending_subagent_start = false; + pending + } + pub(crate) fn record_granted_permissions(&mut self, permissions: AdditionalPermissionProfile) { self.granted_permissions = merge_permission_profiles(self.granted_permissions.as_ref(), Some(&permissions)); diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 3a0c37acc7..633b9f00be 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -4,6 +4,7 @@ use codex_core::config::AgentRoleConfig; use codex_features::Feature; use codex_protocol::ThreadId; use codex_protocol::openai_models::ReasoningEffort; +use core_test_support::hooks::trust_discovered_hooks; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -37,6 +38,8 @@ const REQUESTED_MODEL: &str = "gpt-5.4"; const REQUESTED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::Low; const ROLE_MODEL: &str = "gpt-5.4"; const ROLE_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::High; +const SUBAGENT_START_CONTEXT: &str = "subagent start context reaches child"; +const SUBAGENT_STOP_CONTINUATION: &str = "continue only the child"; fn body_contains(req: &wiremock::Request, text: &str) -> bool { let is_zstd = req @@ -110,6 +113,133 @@ fn write_home_skill(codex_home: &Path, dir: &str, name: &str, description: &str) Ok(()) } +fn write_subagent_lifecycle_hooks(home: &Path, stop_prompts: &[&str]) -> Result<()> { + let start_script_path = home.join("subagent_start_hook.py"); + let start_log_path = home.join("subagent_start_hook_log.jsonl"); + let start_script = format!( + r#"import json +from pathlib import Path +import sys + +log_path = Path(r"{start_log_path}") +payload = json.load(sys.stdin) +with log_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") +print(json.dumps({{"hookSpecificOutput": {{"hookEventName": "SubagentStart", "additionalContext": {SUBAGENT_START_CONTEXT:?}}}}})) +"#, + start_log_path = start_log_path.display(), + ); + + let subagent_stop_script_path = home.join("subagent_stop_hook.py"); + let subagent_stop_log_path = home.join("subagent_stop_hook_log.jsonl"); + let prompts_json = serde_json::to_string(stop_prompts)?; + let subagent_stop_script = format!( + r#"import json +from pathlib import Path +import sys + +log_path = Path(r"{subagent_stop_log_path}") +block_prompts = {prompts_json} + +payload = json.load(sys.stdin) +existing = [] +if log_path.exists(): + existing = [line for line in log_path.read_text(encoding="utf-8").splitlines() if line.strip()] + +with log_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") + +invocation_index = len(existing) +if invocation_index < len(block_prompts): + print(json.dumps({{"decision": "block", "reason": block_prompts[invocation_index]}})) +else: + print(json.dumps({{"systemMessage": f"subagent stop pass {{invocation_index + 1}} complete"}})) +"#, + subagent_stop_log_path = subagent_stop_log_path.display(), + prompts_json = prompts_json, + ); + + let stop_script_path = home.join("stop_hook.py"); + let stop_log_path = home.join("stop_hook_log.jsonl"); + let stop_script = format!( + r#"import json +from pathlib import Path +import sys + +log_path = Path(r"{stop_log_path}") +payload = json.load(sys.stdin) +with log_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") +print(json.dumps({{"systemMessage": "root stop complete"}})) +"#, + stop_log_path = stop_log_path.display(), + ); + + let hooks = serde_json::json!({ + "hooks": { + "SubagentStart": [{ + "matcher": "worker", + "hooks": [{ + "type": "command", + "command": format!("python3 {}", start_script_path.display()), + }] + }], + "SubagentStop": [{ + "matcher": "worker", + "hooks": [{ + "type": "command", + "command": format!("python3 {}", subagent_stop_script_path.display()), + }] + }], + "Stop": [{ + "hooks": [{ + "type": "command", + "command": format!("python3 {}", stop_script_path.display()), + }] + }] + } + }); + + fs::write(&start_script_path, start_script)?; + fs::write(&subagent_stop_script_path, subagent_stop_script)?; + fs::write(&stop_script_path, stop_script)?; + fs::write(home.join("hooks.json"), hooks.to_string())?; + Ok(()) +} + +fn read_hook_log(home: &Path, filename: &str) -> Result> { + let path = home.join(filename); + if !path.exists() { + return Ok(Vec::new()); + } + fs::read_to_string(path)? + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).map_err(Into::into)) + .collect() +} + +async fn wait_for_hook_log_entries( + home: &Path, + filename: &str, + expected_len: usize, +) -> Result> { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + let entries = read_hook_log(home, filename)?; + if entries.len() >= expected_len { + return Ok(entries); + } + if Instant::now() >= deadline { + anyhow::bail!( + "expected at least {expected_len} entries in {filename}, got {}", + entries.len() + ); + } + sleep(Duration::from_millis(10)).await; + } +} + async fn wait_for_spawned_thread_id(test: &TestCodex) -> Result { let deadline = Instant::now() + Duration::from_secs(2); loop { @@ -279,6 +409,197 @@ async fn spawn_child_and_capture_snapshot( .await) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn subagent_start_injects_context_once_for_child() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let spawn_args = serde_json::to_string(&json!({ + "message": CHILD_PROMPT, + "task_name": "child", + "agent_type": "worker", + }))?; + + mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, TURN_1_PROMPT), + sse(vec![ + ev_response_created("resp-turn1-1"), + ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + ev_completed("resp-turn1-1"), + ]), + ) + .await; + + let child_request_log = mount_sse_once_match( + &server, + |req: &wiremock::Request| { + body_contains(req, CHILD_PROMPT) + && body_contains(req, SUBAGENT_START_CONTEXT) + && !body_contains(req, SPAWN_CALL_ID) + }, + sse(vec![ + ev_response_created("resp-child-1"), + ev_assistant_message("msg-child-1", "child done"), + ev_completed("resp-child-1"), + ]), + ) + .await; + + let _turn1_followup = mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, SPAWN_CALL_ID), + sse(vec![ + ev_response_created("resp-turn1-2"), + ev_assistant_message("msg-turn1-2", "parent done"), + ev_completed("resp-turn1-2"), + ]), + ) + .await; + + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = write_subagent_lifecycle_hooks(home, &[]) { + panic!("failed to write subagent hook fixture: {error}"); + } + }) + .with_config(|config| { + trust_discovered_hooks(config); + config + .features + .enable(Feature::Collab) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + test.submit_turn(TURN_1_PROMPT).await?; + let child_requests = wait_for_requests(&child_request_log).await?; + assert_eq!(child_requests.len(), 1); + + let start_inputs = read_hook_log(test.codex_home_path(), "subagent_start_hook_log.jsonl")?; + assert_eq!(start_inputs.len(), 1); + assert_eq!(start_inputs[0]["agent_type"].as_str(), Some("worker")); + let spawned_id = wait_for_spawned_thread_id(&test).await?; + assert_eq!( + start_inputs[0]["agent_id"].as_str(), + Some(spawned_id.as_str()) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn subagent_stop_replaces_stop_for_child_and_can_continue_child() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let spawn_args = serde_json::to_string(&json!({ + "message": CHILD_PROMPT, + "task_name": "child", + "agent_type": "worker", + }))?; + + mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, TURN_1_PROMPT), + sse(vec![ + ev_response_created("resp-turn1-1"), + ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + ev_completed("resp-turn1-1"), + ]), + ) + .await; + + let first_child_request = mount_sse_once_match( + &server, + |req: &wiremock::Request| { + body_contains(req, CHILD_PROMPT) && !body_contains(req, SPAWN_CALL_ID) + }, + sse(vec![ + ev_response_created("resp-child-1"), + ev_assistant_message("msg-child-1", "child done first"), + ev_completed("resp-child-1"), + ]), + ) + .await; + let second_child_request = mount_sse_once_match( + &server, + |req: &wiremock::Request| { + body_contains(req, SUBAGENT_STOP_CONTINUATION) && !body_contains(req, SPAWN_CALL_ID) + }, + sse(vec![ + ev_response_created("resp-child-2"), + ev_assistant_message("msg-child-2", "child done final"), + ev_completed("resp-child-2"), + ]), + ) + .await; + + let _turn1_followup = mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, SPAWN_CALL_ID), + sse(vec![ + ev_response_created("resp-turn1-2"), + ev_assistant_message("msg-turn1-2", "parent done"), + ev_completed("resp-turn1-2"), + ]), + ) + .await; + + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = write_subagent_lifecycle_hooks(home, &[SUBAGENT_STOP_CONTINUATION]) + { + panic!("failed to write subagent hook fixture: {error}"); + } + }) + .with_config(|config| { + trust_discovered_hooks(config); + config + .features + .enable(Feature::Collab) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + test.submit_turn(TURN_1_PROMPT).await?; + let _ = wait_for_requests(&first_child_request).await?; + let _ = wait_for_requests(&second_child_request).await?; + + let subagent_stop_inputs = wait_for_hook_log_entries( + test.codex_home_path(), + "subagent_stop_hook_log.jsonl", + /*expected_len*/ 2, + ) + .await?; + assert_eq!(subagent_stop_inputs.len(), 2); + assert_eq!( + subagent_stop_inputs + .iter() + .map(|input| input["stop_hook_active"].as_bool()) + .collect::>(), + vec![Some(false), Some(true)] + ); + assert_eq!( + subagent_stop_inputs[0]["agent_type"].as_str(), + Some("worker") + ); + assert_eq!( + subagent_stop_inputs[0]["last_assistant_message"].as_str(), + Some("child done first") + ); + + let stop_inputs = read_hook_log(test.codex_home_path(), "stop_hook_log.jsonl")?; + assert!( + stop_inputs + .iter() + .all(|input| input["last_assistant_message"].as_str() != Some("child done first")), + "child completion should not invoke the normal Stop hook" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn subagent_notification_is_included_without_wait() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/hooks/schema/generated/permission-request.command.output.schema.json b/codex-rs/hooks/schema/generated/permission-request.command.output.schema.json index c89f80a3b0..7d63b56c96 100644 --- a/codex-rs/hooks/schema/generated/permission-request.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/permission-request.command.output.schema.json @@ -11,6 +11,8 @@ "PostCompact", "SessionStart", "UserPromptSubmit", + "SubagentStart", + "SubagentStop", "Stop" ], "type": "string" diff --git a/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json b/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json index 2b64c0ad00..964c3ba624 100644 --- a/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json @@ -17,6 +17,8 @@ "PostCompact", "SessionStart", "UserPromptSubmit", + "SubagentStart", + "SubagentStop", "Stop" ], "type": "string" diff --git a/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json b/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json index 3ab17b9f5c..a74abd14e8 100644 --- a/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json @@ -11,6 +11,8 @@ "PostCompact", "SessionStart", "UserPromptSubmit", + "SubagentStart", + "SubagentStop", "Stop" ], "type": "string" diff --git a/codex-rs/hooks/schema/generated/session-start.command.output.schema.json b/codex-rs/hooks/schema/generated/session-start.command.output.schema.json index 4d6ba033fe..e1abb88a52 100644 --- a/codex-rs/hooks/schema/generated/session-start.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/session-start.command.output.schema.json @@ -11,6 +11,8 @@ "PostCompact", "SessionStart", "UserPromptSubmit", + "SubagentStart", + "SubagentStop", "Stop" ], "type": "string" diff --git a/codex-rs/hooks/schema/generated/subagent-start.command.input.schema.json b/codex-rs/hooks/schema/generated/subagent-start.command.input.schema.json new file mode 100644 index 0000000000..2f6edd6038 --- /dev/null +++ b/codex-rs/hooks/schema/generated/subagent-start.command.input.schema.json @@ -0,0 +1,63 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "NullableString": { + "type": [ + "string", + "null" + ] + } + }, + "properties": { + "agent_id": { + "type": "string" + }, + "agent_type": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "hook_event_name": { + "const": "SubagentStart", + "type": "string" + }, + "model": { + "type": "string" + }, + "permission_mode": { + "enum": [ + "default", + "acceptEdits", + "plan", + "dontAsk", + "bypassPermissions" + ], + "type": "string" + }, + "session_id": { + "type": "string" + }, + "transcript_path": { + "$ref": "#/definitions/NullableString" + }, + "turn_id": { + "description": "Codex extension: expose the active turn id to internal turn-scoped hooks.", + "type": "string" + } + }, + "required": [ + "agent_id", + "agent_type", + "cwd", + "hook_event_name", + "model", + "permission_mode", + "session_id", + "transcript_path", + "turn_id" + ], + "title": "subagent-start.command.input", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/hooks/schema/generated/subagent-start.command.output.schema.json b/codex-rs/hooks/schema/generated/subagent-start.command.output.schema.json new file mode 100644 index 0000000000..395b184ae4 --- /dev/null +++ b/codex-rs/hooks/schema/generated/subagent-start.command.output.schema.json @@ -0,0 +1,65 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "HookEventNameWire": { + "enum": [ + "PreToolUse", + "PermissionRequest", + "PostToolUse", + "PreCompact", + "PostCompact", + "SessionStart", + "UserPromptSubmit", + "SubagentStart", + "SubagentStop", + "Stop" + ], + "type": "string" + }, + "SubagentStartHookSpecificOutputWire": { + "additionalProperties": false, + "properties": { + "additionalContext": { + "default": null, + "type": "string" + }, + "hookEventName": { + "$ref": "#/definitions/HookEventNameWire" + } + }, + "required": [ + "hookEventName" + ], + "type": "object" + } + }, + "properties": { + "continue": { + "default": true, + "type": "boolean" + }, + "hookSpecificOutput": { + "allOf": [ + { + "$ref": "#/definitions/SubagentStartHookSpecificOutputWire" + } + ], + "default": null + }, + "stopReason": { + "default": null, + "type": "string" + }, + "suppressOutput": { + "default": false, + "type": "boolean" + }, + "systemMessage": { + "default": null, + "type": "string" + } + }, + "title": "subagent-start.command.output", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/hooks/schema/generated/subagent-stop.command.input.schema.json b/codex-rs/hooks/schema/generated/subagent-stop.command.input.schema.json new file mode 100644 index 0000000000..19646db1d2 --- /dev/null +++ b/codex-rs/hooks/schema/generated/subagent-stop.command.input.schema.json @@ -0,0 +1,75 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "NullableString": { + "type": [ + "string", + "null" + ] + } + }, + "properties": { + "agent_id": { + "type": "string" + }, + "agent_transcript_path": { + "$ref": "#/definitions/NullableString" + }, + "agent_type": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "hook_event_name": { + "const": "SubagentStop", + "type": "string" + }, + "last_assistant_message": { + "$ref": "#/definitions/NullableString" + }, + "model": { + "type": "string" + }, + "permission_mode": { + "enum": [ + "default", + "acceptEdits", + "plan", + "dontAsk", + "bypassPermissions" + ], + "type": "string" + }, + "session_id": { + "type": "string" + }, + "stop_hook_active": { + "type": "boolean" + }, + "transcript_path": { + "$ref": "#/definitions/NullableString" + }, + "turn_id": { + "description": "Codex extension: expose the active turn id to internal turn-scoped hooks.", + "type": "string" + } + }, + "required": [ + "agent_id", + "agent_transcript_path", + "agent_type", + "cwd", + "hook_event_name", + "last_assistant_message", + "model", + "permission_mode", + "session_id", + "stop_hook_active", + "transcript_path", + "turn_id" + ], + "title": "subagent-stop.command.input", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/hooks/schema/generated/subagent-stop.command.output.schema.json b/codex-rs/hooks/schema/generated/subagent-stop.command.output.schema.json new file mode 100644 index 0000000000..0cb1dd2a57 --- /dev/null +++ b/codex-rs/hooks/schema/generated/subagent-stop.command.output.schema.json @@ -0,0 +1,45 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "BlockDecisionWire": { + "enum": [ + "block" + ], + "type": "string" + } + }, + "properties": { + "continue": { + "default": true, + "type": "boolean" + }, + "decision": { + "allOf": [ + { + "$ref": "#/definitions/BlockDecisionWire" + } + ], + "default": null + }, + "reason": { + "default": null, + "description": "Claude requires `reason` when `decision` is `block`; we enforce that semantic rule during output parsing rather than in the JSON schema.", + "type": "string" + }, + "stopReason": { + "default": null, + "type": "string" + }, + "suppressOutput": { + "default": false, + "type": "boolean" + }, + "systemMessage": { + "default": null, + "type": "string" + } + }, + "title": "subagent-stop.command.output", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json b/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json index 6baceb149f..61c530ce04 100644 --- a/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json @@ -17,6 +17,8 @@ "PostCompact", "SessionStart", "UserPromptSubmit", + "SubagentStart", + "SubagentStop", "Stop" ], "type": "string" diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index 7243fc6eab..50822bfc96 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -49,6 +49,8 @@ pub(crate) fn select_handlers_for_matcher_inputs( | HookEventName::PermissionRequest | HookEventName::PostToolUse | HookEventName::SessionStart + | HookEventName::SubagentStart + | HookEventName::SubagentStop | HookEventName::PreCompact | HookEventName::PostCompact => { if matcher_inputs.is_empty() { @@ -139,13 +141,14 @@ pub(crate) fn completed_summary( fn scope_for_event(event_name: HookEventName) -> HookScope { match event_name { - HookEventName::SessionStart => HookScope::Thread, + HookEventName::SessionStart | HookEventName::SubagentStart => HookScope::Thread, HookEventName::PreToolUse | HookEventName::PermissionRequest | HookEventName::PostToolUse | HookEventName::PreCompact | HookEventName::PostCompact | HookEventName::UserPromptSubmit + | HookEventName::SubagentStop | HookEventName::Stop => HookScope::Turn, } } diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 8a4a11a5d4..e33ae1e5b3 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -18,6 +18,9 @@ use crate::events::session_start::SessionStartOutcome; use crate::events::session_start::SessionStartRequest; use crate::events::stop::StopOutcome; use crate::events::stop::StopRequest; +use crate::events::subagent_start::SubagentStartOutcome; +use crate::events::subagent_start::SubagentStartRequest; +use crate::events::subagent_stop::SubagentStopRequest; use crate::events::user_prompt_submit::UserPromptSubmitOutcome; use crate::events::user_prompt_submit::UserPromptSubmitRequest; use crate::output_spill::HookOutputSpiller; @@ -70,6 +73,8 @@ impl ConfiguredHandler { codex_protocol::protocol::HookEventName::PostCompact => "post-compact", codex_protocol::protocol::HookEventName::SessionStart => "session-start", codex_protocol::protocol::HookEventName::UserPromptSubmit => "user-prompt-submit", + codex_protocol::protocol::HookEventName::SubagentStart => "subagent-start", + codex_protocol::protocol::HookEventName::SubagentStop => "subagent-stop", codex_protocol::protocol::HookEventName::Stop => "stop", } } @@ -250,6 +255,26 @@ impl ClaudeHooksEngine { outcome } + pub(crate) fn preview_subagent_start( + &self, + request: &SubagentStartRequest, + ) -> Vec { + crate::events::subagent_start::preview(&self.handlers, request) + } + + pub(crate) async fn run_subagent_start( + &self, + request: SubagentStartRequest, + ) -> SubagentStartOutcome { + let session_id = request.session_id; + let mut outcome = + crate::events::subagent_start::run(&self.handlers, &self.shell, request).await; + outcome.additional_contexts = self + .maybe_spill_texts(session_id, outcome.additional_contexts) + .await; + outcome + } + pub(crate) fn preview_stop(&self, request: &StopRequest) -> Vec { crate::events::stop::preview(&self.handlers, request) } @@ -263,6 +288,23 @@ impl ClaudeHooksEngine { outcome } + pub(crate) fn preview_subagent_stop( + &self, + request: &SubagentStopRequest, + ) -> Vec { + crate::events::subagent_stop::preview(&self.handlers, request) + } + + pub(crate) async fn run_subagent_stop(&self, request: SubagentStopRequest) -> StopOutcome { + let session_id = request.session_id; + let mut outcome = + crate::events::subagent_stop::run(&self.handlers, &self.shell, request).await; + outcome.continuation_fragments = self + .maybe_spill_prompt_fragments(session_id, outcome.continuation_fragments) + .await; + outcome + } + async fn maybe_spill_texts(&self, session_id: ThreadId, texts: Vec) -> Vec { self.output_spiller .maybe_spill_texts(session_id, texts) diff --git a/codex-rs/hooks/src/engine/output_parser.rs b/codex-rs/hooks/src/engine/output_parser.rs index 48ad1aff2d..a759d1b88f 100644 --- a/codex-rs/hooks/src/engine/output_parser.rs +++ b/codex-rs/hooks/src/engine/output_parser.rs @@ -10,6 +10,7 @@ pub(crate) struct UniversalOutput { pub(crate) struct SessionStartOutput { pub universal: UniversalOutput, pub additional_context: Option, + pub invalid_reason: Option, } #[derive(Debug, Clone)] @@ -86,6 +87,8 @@ use crate::schema::PreToolUseDecisionWire; use crate::schema::PreToolUsePermissionDecisionWire; use crate::schema::SessionStartCommandOutputWire; use crate::schema::StopCommandOutputWire; +use crate::schema::SubagentStartCommandOutputWire; +use crate::schema::SubagentStopCommandOutputWire; use crate::schema::UserPromptSubmitCommandOutputWire; pub(crate) fn parse_session_start(stdout: &str) -> Option { @@ -96,6 +99,24 @@ pub(crate) fn parse_session_start(stdout: &str) -> Option { Some(SessionStartOutput { universal: UniversalOutput::from(wire.universal), additional_context, + invalid_reason: None, + }) +} + +pub(crate) fn parse_subagent_start(stdout: &str) -> Option { + let wire: SubagentStartCommandOutputWire = parse_json(stdout)?; + let universal = UniversalOutput::from(wire.universal); + let invalid_reason = unsupported_subagent_start_universal(&universal); + let additional_context = if invalid_reason.is_none() { + wire.hook_specific_output + .and_then(|output| output.additional_context) + } else { + None + }; + Some(SessionStartOutput { + universal, + additional_context, + invalid_reason, }) } @@ -263,22 +284,46 @@ pub(crate) fn parse_user_prompt_submit(stdout: &str) -> Option Option { let wire: StopCommandOutputWire = parse_json(stdout)?; - let should_block = matches!(wire.decision, Some(BlockDecisionWire::Block)); + Some(stop_output_from_parts( + "Stop", + wire.decision, + wire.reason, + wire.universal, + )) +} + +pub(crate) fn parse_subagent_stop(stdout: &str) -> Option { + let wire: SubagentStopCommandOutputWire = parse_json(stdout)?; + Some(stop_output_from_parts( + "SubagentStop", + wire.decision, + wire.reason, + wire.universal, + )) +} + +fn stop_output_from_parts( + event_name: &str, + decision: Option, + reason: Option, + universal_wire: HookUniversalOutputWire, +) -> StopOutput { + let should_block = matches!(decision, Some(BlockDecisionWire::Block)); let invalid_block_reason = if should_block - && match wire.reason.as_deref() { + && match reason.as_deref() { Some(reason) => reason.trim().is_empty(), None => true, } { - Some(invalid_block_message("Stop")) + Some(invalid_block_message(event_name)) } else { None }; - Some(StopOutput { - universal: UniversalOutput::from(wire.universal), + StopOutput { + universal: UniversalOutput::from(universal_wire), should_block: should_block && invalid_block_reason.is_none(), - reason: wire.reason, + reason, invalid_block_reason, - }) + } } impl From for UniversalOutput { @@ -348,6 +393,18 @@ fn unsupported_post_tool_use_universal(universal: &UniversalOutput) -> Option Option { + if !universal.continue_processing { + Some("SubagentStart hook returned unsupported continue:false".to_string()) + } else if universal.stop_reason.is_some() { + Some("SubagentStart hook returned unsupported stopReason".to_string()) + } else if universal.suppress_output { + Some("SubagentStart hook returned unsupported suppressOutput".to_string()) + } else { + None + } +} + fn unsupported_permission_request_hook_specific_output( decision: Option<&PermissionRequestDecisionWire>, ) -> Option { diff --git a/codex-rs/hooks/src/engine/schema_loader.rs b/codex-rs/hooks/src/engine/schema_loader.rs index 704edea4ba..545146c7a4 100644 --- a/codex-rs/hooks/src/engine/schema_loader.rs +++ b/codex-rs/hooks/src/engine/schema_loader.rs @@ -18,6 +18,10 @@ pub(crate) struct GeneratedHookSchemas { pub session_start_command_output: Value, pub user_prompt_submit_command_input: Value, pub user_prompt_submit_command_output: Value, + pub subagent_start_command_input: Value, + pub subagent_start_command_output: Value, + pub subagent_stop_command_input: Value, + pub subagent_stop_command_output: Value, pub stop_command_input: Value, pub stop_command_output: Value, } @@ -81,6 +85,22 @@ pub(crate) fn generated_hook_schemas() -> &'static GeneratedHookSchemas { "user-prompt-submit.command.output", include_str!("../../schema/generated/user-prompt-submit.command.output.schema.json"), ), + subagent_start_command_input: parse_json_schema( + "subagent-start.command.input", + include_str!("../../schema/generated/subagent-start.command.input.schema.json"), + ), + subagent_start_command_output: parse_json_schema( + "subagent-start.command.output", + include_str!("../../schema/generated/subagent-start.command.output.schema.json"), + ), + subagent_stop_command_input: parse_json_schema( + "subagent-stop.command.input", + include_str!("../../schema/generated/subagent-stop.command.input.schema.json"), + ), + subagent_stop_command_output: parse_json_schema( + "subagent-stop.command.output", + include_str!("../../schema/generated/subagent-stop.command.output.schema.json"), + ), stop_command_input: parse_json_schema( "stop.command.input", include_str!("../../schema/generated/stop.command.input.schema.json"), @@ -120,6 +140,10 @@ mod tests { assert_eq!(schemas.session_start_command_output["type"], "object"); assert_eq!(schemas.user_prompt_submit_command_input["type"], "object"); assert_eq!(schemas.user_prompt_submit_command_output["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"); + assert_eq!(schemas.subagent_stop_command_output["type"], "object"); assert_eq!(schemas.stop_command_input["type"], "object"); assert_eq!(schemas.stop_command_output["type"], "object"); } diff --git a/codex-rs/hooks/src/events/common.rs b/codex-rs/hooks/src/events/common.rs index e79a139b79..c97129ebef 100644 --- a/codex-rs/hooks/src/events/common.rs +++ b/codex-rs/hooks/src/events/common.rs @@ -104,6 +104,8 @@ pub(crate) fn matcher_pattern_for_event( | HookEventName::PermissionRequest | HookEventName::PostToolUse | HookEventName::SessionStart + | HookEventName::SubagentStart + | HookEventName::SubagentStop | HookEventName::PreCompact | HookEventName::PostCompact => matcher, HookEventName::UserPromptSubmit | HookEventName::Stop => None, diff --git a/codex-rs/hooks/src/events/mod.rs b/codex-rs/hooks/src/events/mod.rs index 5ec24462b9..b8a41ced2c 100644 --- a/codex-rs/hooks/src/events/mod.rs +++ b/codex-rs/hooks/src/events/mod.rs @@ -5,4 +5,6 @@ pub mod post_tool_use; pub mod pre_tool_use; pub mod session_start; pub mod stop; +pub mod subagent_start; +pub mod subagent_stop; pub mod user_prompt_submit; diff --git a/codex-rs/hooks/src/events/stop.rs b/codex-rs/hooks/src/events/stop.rs index 3cd2d44270..1274511949 100644 --- a/codex-rs/hooks/src/events/stop.rs +++ b/codex-rs/hooks/src/events/stop.rs @@ -42,12 +42,12 @@ pub struct StopOutcome { } #[derive(Debug, Default, PartialEq, Eq)] -struct StopHandlerData { - should_stop: bool, - stop_reason: Option, - should_block: bool, - block_reason: Option, - continuation_fragments: Vec, +pub(super) struct StopHandlerData { + pub(super) should_stop: bool, + pub(super) stop_reason: Option, + pub(super) should_block: bool, + pub(super) block_reason: Option, + pub(super) continuation_fragments: Vec, } pub(crate) fn preview( @@ -121,11 +121,46 @@ pub(crate) async fn run( } } -fn parse_completed( +pub(super) fn parse_completed( handler: &ConfiguredHandler, run_result: CommandRunResult, turn_id: Option, ) -> dispatcher::ParsedHandler { + parse_stop_completed( + handler, + run_result, + turn_id, + "Stop", + output_parser::parse_stop, + ) +} + +pub(super) fn parse_subagent_stop_completed( + handler: &ConfiguredHandler, + run_result: CommandRunResult, + turn_id: Option, +) -> dispatcher::ParsedHandler { + parse_stop_completed( + handler, + run_result, + turn_id, + "SubagentStop", + output_parser::parse_subagent_stop, + ) +} + +fn parse_stop_completed( + handler: &ConfiguredHandler, + run_result: CommandRunResult, + turn_id: Option, + hook_name: &str, + parse_output: fn(&str) -> Option, +) -> dispatcher::ParsedHandler { + let invalid_json_hook_name = if hook_name == "Stop" { + "stop" + } else { + hook_name + }; let mut entries = Vec::new(); let mut status = HookRunStatus::Completed; let mut should_stop = false; @@ -146,7 +181,7 @@ fn parse_completed( Some(0) => { let trimmed_stdout = run_result.stdout.trim(); if trimmed_stdout.is_empty() { - } else if let Some(parsed) = output_parser::parse_stop(&run_result.stdout) { + } else if let Some(parsed) = parse_output(&run_result.stdout) { if let Some(system_message) = parsed.universal.system_message { entries.push(HookOutputEntry { kind: HookOutputEntryKind::Warning, @@ -186,9 +221,9 @@ fn parse_completed( status = HookRunStatus::Failed; entries.push(HookOutputEntry { kind: HookOutputEntryKind::Error, - text: - "Stop hook returned decision:block without a non-empty reason" - .to_string(), + text: format!( + "{hook_name} hook returned decision:block without a non-empty reason" + ), }); } } @@ -196,7 +231,9 @@ fn parse_completed( status = HookRunStatus::Failed; entries.push(HookOutputEntry { kind: HookOutputEntryKind::Error, - text: "hook returned invalid stop hook JSON output".to_string(), + text: format!( + "hook returned invalid {invalid_json_hook_name} hook JSON output" + ), }); } } @@ -214,9 +251,9 @@ fn parse_completed( status = HookRunStatus::Failed; entries.push(HookOutputEntry { kind: HookOutputEntryKind::Error, - text: - "Stop hook exited with code 2 but did not write a continuation prompt to stderr" - .to_string(), + text: format!( + "{hook_name} hook exited with code 2 but did not write a continuation prompt to stderr" + ), }); } } @@ -263,7 +300,7 @@ fn parse_completed( } } -fn aggregate_results<'a>( +pub(super) fn aggregate_results<'a>( results: impl IntoIterator, ) -> StopHandlerData { let results = results.into_iter().collect::>(); @@ -299,7 +336,7 @@ fn aggregate_results<'a>( } } -fn serialization_failure_outcome(hook_events: Vec) -> StopOutcome { +pub(super) fn serialization_failure_outcome(hook_events: Vec) -> StopOutcome { StopOutcome { hook_events, should_stop: false, diff --git a/codex-rs/hooks/src/events/subagent_start.rs b/codex-rs/hooks/src/events/subagent_start.rs new file mode 100644 index 0000000000..0edda8394e --- /dev/null +++ b/codex-rs/hooks/src/events/subagent_start.rs @@ -0,0 +1,204 @@ +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::engine::output_parser; +use crate::schema::NullableString; +use crate::schema::SubagentStartCommandInput; + +#[derive(Debug, Clone)] +pub struct SubagentStartRequest { + pub session_id: ThreadId, + pub turn_id: String, + pub cwd: AbsolutePathBuf, + pub transcript_path: Option, + pub model: String, + pub permission_mode: String, + pub agent_id: String, + pub agent_type: String, +} + +#[derive(Debug)] +pub struct SubagentStartOutcome { + pub hook_events: Vec, + pub additional_contexts: Vec, +} + +#[derive(Debug, PartialEq, Eq)] +struct SubagentStartHandlerData { + additional_contexts_for_model: Vec, +} + +pub(crate) fn preview( + handlers: &[ConfiguredHandler], + request: &SubagentStartRequest, +) -> Vec { + dispatcher::select_handlers( + handlers, + HookEventName::SubagentStart, + Some(request.agent_type.as_str()), + ) + .into_iter() + .map(|handler| dispatcher::running_summary(&handler)) + .collect() +} + +pub(crate) async fn run( + handlers: &[ConfiguredHandler], + shell: &CommandShell, + request: SubagentStartRequest, +) -> SubagentStartOutcome { + let matched = dispatcher::select_handlers( + handlers, + HookEventName::SubagentStart, + Some(request.agent_type.as_str()), + ); + if matched.is_empty() { + return SubagentStartOutcome { + hook_events: Vec::new(), + additional_contexts: Vec::new(), + }; + } + + let input_json = match serde_json::to_string(&SubagentStartCommandInput { + session_id: request.session_id.to_string(), + turn_id: request.turn_id.clone(), + transcript_path: NullableString::from_path(request.transcript_path), + cwd: request.cwd.display().to_string(), + hook_event_name: "SubagentStart".to_string(), + model: request.model, + permission_mode: request.permission_mode, + agent_id: request.agent_id, + agent_type: request.agent_type, + }) { + Ok(input_json) => input_json, + Err(error) => { + return serialization_failure_outcome(common::serialization_failure_hook_events( + matched, + Some(request.turn_id), + format!("failed to serialize subagent start hook input: {error}"), + )); + } + }; + + let results = dispatcher::execute_handlers( + shell, + matched, + input_json, + request.cwd.as_path(), + Some(request.turn_id), + parse_completed, + ) + .await; + + let additional_contexts = common::flatten_additional_contexts( + results + .iter() + .map(|result| result.data.additional_contexts_for_model.as_slice()), + ); + + SubagentStartOutcome { + hook_events: results.into_iter().map(|result| result.completed).collect(), + additional_contexts, + } +} + +fn parse_completed( + handler: &ConfiguredHandler, + run_result: CommandRunResult, + turn_id: Option, +) -> dispatcher::ParsedHandler { + let mut entries = Vec::new(); + let mut status = HookRunStatus::Completed; + let mut additional_contexts_for_model = Vec::new(); + + match run_result.error.as_deref() { + Some(error) => { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: error.to_string(), + }); + } + None => match run_result.exit_code { + Some(0) => { + let trimmed_stdout = run_result.stdout.trim(); + if trimmed_stdout.is_empty() { + } else if let Some(parsed) = output_parser::parse_subagent_start(&run_result.stdout) + { + if let Some(system_message) = parsed.universal.system_message { + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Warning, + text: system_message, + }); + } + if let Some(invalid_reason) = parsed.invalid_reason { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: invalid_reason, + }); + } else if let Some(additional_context) = parsed.additional_context { + common::append_additional_context( + &mut entries, + &mut additional_contexts_for_model, + additional_context, + ); + } + } else { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "hook returned invalid subagent start JSON output".to_string(), + }); + } + } + Some(exit_code) => { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: format!("hook exited with code {exit_code}"), + }); + } + None => { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "hook exited without a status code".to_string(), + }); + } + }, + } + + let completed = HookCompletedEvent { + turn_id, + run: dispatcher::completed_summary(handler, &run_result, status, entries), + }; + + dispatcher::ParsedHandler { + completed, + data: SubagentStartHandlerData { + additional_contexts_for_model, + }, + completion_order: 0, + } +} + +fn serialization_failure_outcome(hook_events: Vec) -> SubagentStartOutcome { + SubagentStartOutcome { + hook_events, + additional_contexts: Vec::new(), + } +} diff --git a/codex-rs/hooks/src/events/subagent_stop.rs b/codex-rs/hooks/src/events/subagent_stop.rs new file mode 100644 index 0000000000..7431612702 --- /dev/null +++ b/codex-rs/hooks/src/events/subagent_stop.rs @@ -0,0 +1,110 @@ +use std::path::PathBuf; + +use codex_protocol::ThreadId; +use codex_protocol::protocol::HookEventName; +use codex_protocol::protocol::HookRunSummary; +use codex_utils_absolute_path::AbsolutePathBuf; + +use super::common; +use super::stop; +use crate::engine::CommandShell; +use crate::engine::ConfiguredHandler; +use crate::engine::dispatcher; +use crate::schema::NullableString; +use crate::schema::SubagentStopCommandInput; + +#[derive(Debug, Clone)] +pub struct SubagentStopRequest { + pub session_id: ThreadId, + pub turn_id: String, + pub cwd: AbsolutePathBuf, + pub transcript_path: Option, + pub model: String, + pub permission_mode: String, + pub stop_hook_active: bool, + pub agent_id: String, + pub agent_type: String, + pub agent_transcript_path: Option, + pub last_assistant_message: Option, +} + +pub(crate) fn preview( + handlers: &[ConfiguredHandler], + request: &SubagentStopRequest, +) -> Vec { + dispatcher::select_handlers( + handlers, + HookEventName::SubagentStop, + Some(request.agent_type.as_str()), + ) + .into_iter() + .map(|handler| dispatcher::running_summary(&handler)) + .collect() +} + +pub(crate) async fn run( + handlers: &[ConfiguredHandler], + shell: &CommandShell, + request: SubagentStopRequest, +) -> stop::StopOutcome { + let matched = dispatcher::select_handlers( + handlers, + HookEventName::SubagentStop, + Some(request.agent_type.as_str()), + ); + if matched.is_empty() { + return stop::StopOutcome { + hook_events: Vec::new(), + should_stop: false, + stop_reason: None, + should_block: false, + block_reason: None, + continuation_fragments: Vec::new(), + }; + } + + let input_json = match serde_json::to_string(&SubagentStopCommandInput { + session_id: request.session_id.to_string(), + turn_id: request.turn_id.clone(), + transcript_path: NullableString::from_path(request.transcript_path), + cwd: request.cwd.display().to_string(), + hook_event_name: "SubagentStop".to_string(), + model: request.model, + permission_mode: request.permission_mode, + stop_hook_active: request.stop_hook_active, + agent_id: request.agent_id, + agent_type: request.agent_type, + agent_transcript_path: NullableString::from_path(request.agent_transcript_path), + last_assistant_message: NullableString::from_string(request.last_assistant_message), + }) { + Ok(input_json) => input_json, + Err(error) => { + return stop::serialization_failure_outcome(common::serialization_failure_hook_events( + matched, + Some(request.turn_id), + format!("failed to serialize subagent stop hook input: {error}"), + )); + } + }; + + let results = dispatcher::execute_handlers( + shell, + matched, + input_json, + request.cwd.as_path(), + Some(request.turn_id), + stop::parse_subagent_stop_completed, + ) + .await; + + let aggregate = stop::aggregate_results(results.iter().map(|result| &result.data)); + + stop::StopOutcome { + hook_events: results.into_iter().map(|result| result.completed).collect(), + should_stop: aggregate.should_stop, + stop_reason: aggregate.stop_reason, + should_block: aggregate.should_block, + block_reason: aggregate.block_reason, + continuation_fragments: aggregate.continuation_fragments, + } +} diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index ec59368d30..be77f5e324 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -15,7 +15,7 @@ pub use declarations::PluginHookDeclaration; pub use declarations::plugin_hook_declarations; pub use engine::HookListEntry; /// Hook event names as they appear in hooks JSON and config files. -pub const HOOK_EVENT_NAMES: [&str; 8] = [ +pub const HOOK_EVENT_NAMES: [&str; 10] = [ "PreToolUse", "PermissionRequest", "PostToolUse", @@ -23,6 +23,8 @@ pub const HOOK_EVENT_NAMES: [&str; 8] = [ "PostCompact", "SessionStart", "UserPromptSubmit", + "SubagentStart", + "SubagentStop", "Stop", ]; @@ -31,13 +33,15 @@ pub const HOOK_EVENT_NAMES: [&str; 8] = [ /// 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; 6] = [ +pub const HOOK_EVENT_NAMES_WITH_MATCHERS: [&str; 8] = [ "PreToolUse", "PermissionRequest", "PostToolUse", "PreCompact", "PostCompact", "SessionStart", + "SubagentStart", + "SubagentStop", ]; pub use events::compact::PostCompactRequest; @@ -56,6 +60,9 @@ pub use events::session_start::SessionStartRequest; pub use events::session_start::SessionStartSource; pub use events::stop::StopOutcome; pub use events::stop::StopRequest; +pub use events::subagent_start::SubagentStartOutcome; +pub use events::subagent_start::SubagentStartRequest; +pub use events::subagent_stop::SubagentStopRequest; pub use events::user_prompt_submit::UserPromptSubmitOutcome; pub use events::user_prompt_submit::UserPromptSubmitRequest; pub use legacy_notify::legacy_notify_json; @@ -83,6 +90,8 @@ pub fn hook_event_key_label(event_name: HookEventName) -> &'static str { HookEventName::PostCompact => "post_compact", HookEventName::SessionStart => "session_start", HookEventName::UserPromptSubmit => "user_prompt_submit", + HookEventName::SubagentStart => "subagent_start", + HookEventName::SubagentStop => "subagent_stop", HookEventName::Stop => "stop", } } diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index 1f4e01aa59..cc0b267d36 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -19,6 +19,9 @@ use crate::events::session_start::SessionStartOutcome; use crate::events::session_start::SessionStartRequest; use crate::events::stop::StopOutcome; use crate::events::stop::StopRequest; +use crate::events::subagent_start::SubagentStartOutcome; +use crate::events::subagent_start::SubagentStartRequest; +use crate::events::subagent_stop::SubagentStopRequest; use crate::events::user_prompt_submit::UserPromptSubmitOutcome; use crate::events::user_prompt_submit::UserPromptSubmitRequest; use crate::types::Hook; @@ -193,6 +196,17 @@ impl Hooks { self.engine.run_user_prompt_submit(request).await } + pub fn preview_subagent_start( + &self, + request: &SubagentStartRequest, + ) -> Vec { + self.engine.preview_subagent_start(request) + } + + pub async fn run_subagent_start(&self, request: SubagentStartRequest) -> SubagentStartOutcome { + self.engine.run_subagent_start(request).await + } + pub fn preview_stop( &self, request: &StopRequest, @@ -203,6 +217,17 @@ impl Hooks { pub async fn run_stop(&self, request: StopRequest) -> StopOutcome { self.engine.run_stop(request).await } + + pub fn preview_subagent_stop( + &self, + request: &SubagentStopRequest, + ) -> Vec { + self.engine.preview_subagent_stop(request) + } + + pub async fn run_subagent_stop(&self, request: SubagentStopRequest) -> StopOutcome { + self.engine.run_subagent_stop(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 d4f408d8c3..96386b481e 100644 --- a/codex-rs/hooks/src/schema.rs +++ b/codex-rs/hooks/src/schema.rs @@ -27,6 +27,10 @@ const SESSION_START_INPUT_FIXTURE: &str = "session-start.command.input.schema.js const SESSION_START_OUTPUT_FIXTURE: &str = "session-start.command.output.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"; +const SUBAGENT_START_OUTPUT_FIXTURE: &str = "subagent-start.command.output.schema.json"; +const SUBAGENT_STOP_INPUT_FIXTURE: &str = "subagent-stop.command.input.schema.json"; +const SUBAGENT_STOP_OUTPUT_FIXTURE: &str = "subagent-stop.command.output.schema.json"; const STOP_INPUT_FIXTURE: &str = "stop.command.input.schema.json"; const STOP_OUTPUT_FIXTURE: &str = "stop.command.output.schema.json"; @@ -87,6 +91,10 @@ pub(crate) enum HookEventNameWire { SessionStart, #[serde(rename = "UserPromptSubmit")] UserPromptSubmit, + #[serde(rename = "SubagentStart")] + SubagentStart, + #[serde(rename = "SubagentStop")] + SubagentStop, #[serde(rename = "Stop")] Stop, } @@ -345,6 +353,26 @@ pub(crate) struct SessionStartHookSpecificOutputWire { pub additional_context: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +#[schemars(rename = "subagent-start.command.output")] +pub(crate) struct SubagentStartCommandOutputWire { + #[serde(flatten)] + pub universal: HookUniversalOutputWire, + #[serde(default)] + pub hook_specific_output: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +pub(crate) struct SubagentStartHookSpecificOutputWire { + pub hook_event_name: HookEventNameWire, + #[serde(default)] + pub additional_context: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] #[serde(deny_unknown_fields)] @@ -384,6 +412,21 @@ pub(crate) struct StopCommandOutputWire { pub reason: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +#[schemars(rename = "subagent-stop.command.output")] +pub(crate) struct SubagentStopCommandOutputWire { + #[serde(flatten)] + pub universal: HookUniversalOutputWire, + #[serde(default)] + pub decision: Option, + /// Claude requires `reason` when `decision` is `block`; we enforce that + /// semantic rule during output parsing rather than in the JSON schema. + #[serde(default)] + pub reason: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] pub(crate) enum BlockDecisionWire { #[serde(rename = "block")] @@ -444,6 +487,24 @@ pub(crate) struct UserPromptSubmitCommandInput { pub prompt: String, } +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(rename = "subagent-start.command.input")] +pub(crate) struct SubagentStartCommandInput { + pub session_id: String, + /// Codex extension: expose the active turn id to internal turn-scoped hooks. + pub turn_id: String, + pub transcript_path: NullableString, + pub cwd: String, + #[schemars(schema_with = "subagent_start_hook_event_name_schema")] + pub hook_event_name: String, + pub model: String, + #[schemars(schema_with = "permission_mode_schema")] + pub permission_mode: String, + pub agent_id: String, + pub agent_type: String, +} + #[derive(Debug, Clone, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] #[schemars(rename = "stop.command.input")] @@ -462,6 +523,27 @@ pub(crate) struct StopCommandInput { pub last_assistant_message: NullableString, } +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(rename = "subagent-stop.command.input")] +pub(crate) struct SubagentStopCommandInput { + pub session_id: String, + /// Codex extension: expose the active turn id to internal turn-scoped hooks. + pub turn_id: String, + pub transcript_path: NullableString, + pub cwd: String, + #[schemars(schema_with = "subagent_stop_hook_event_name_schema")] + pub hook_event_name: String, + pub model: String, + #[schemars(schema_with = "permission_mode_schema")] + pub permission_mode: String, + pub stop_hook_active: bool, + pub agent_id: String, + pub agent_type: String, + pub agent_transcript_path: NullableString, + pub last_assistant_message: NullableString, +} + pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> { let generated_dir = schema_root.join(GENERATED_DIR); ensure_empty_dir(&generated_dir)?; @@ -522,6 +604,22 @@ pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> { &generated_dir.join(USER_PROMPT_SUBMIT_OUTPUT_FIXTURE), schema_json::()?, )?; + write_schema( + &generated_dir.join(SUBAGENT_START_INPUT_FIXTURE), + schema_json::()?, + )?; + write_schema( + &generated_dir.join(SUBAGENT_START_OUTPUT_FIXTURE), + schema_json::()?, + )?; + write_schema( + &generated_dir.join(SUBAGENT_STOP_INPUT_FIXTURE), + schema_json::()?, + )?; + write_schema( + &generated_dir.join(SUBAGENT_STOP_OUTPUT_FIXTURE), + schema_json::()?, + )?; write_schema( &generated_dir.join(STOP_INPUT_FIXTURE), schema_json::()?, @@ -613,6 +711,14 @@ fn user_prompt_submit_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Sche string_const_schema("UserPromptSubmit") } +fn subagent_start_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema { + string_const_schema("SubagentStart") +} + +fn subagent_stop_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema { + string_const_schema("SubagentStop") +} + fn stop_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema { string_const_schema("Stop") } @@ -683,7 +789,13 @@ mod tests { use super::SESSION_START_OUTPUT_FIXTURE; use super::STOP_INPUT_FIXTURE; use super::STOP_OUTPUT_FIXTURE; + use super::SUBAGENT_START_INPUT_FIXTURE; + use super::SUBAGENT_START_OUTPUT_FIXTURE; + use super::SUBAGENT_STOP_INPUT_FIXTURE; + use super::SUBAGENT_STOP_OUTPUT_FIXTURE; use super::StopCommandInput; + use super::SubagentStartCommandInput; + use super::SubagentStopCommandInput; use super::USER_PROMPT_SUBMIT_INPUT_FIXTURE; use super::USER_PROMPT_SUBMIT_OUTPUT_FIXTURE; use super::UserPromptSubmitCommandInput; @@ -737,6 +849,18 @@ mod tests { USER_PROMPT_SUBMIT_OUTPUT_FIXTURE => { include_str!("../schema/generated/user-prompt-submit.command.output.schema.json") } + SUBAGENT_START_INPUT_FIXTURE => { + include_str!("../schema/generated/subagent-start.command.input.schema.json") + } + SUBAGENT_START_OUTPUT_FIXTURE => { + include_str!("../schema/generated/subagent-start.command.output.schema.json") + } + SUBAGENT_STOP_INPUT_FIXTURE => { + include_str!("../schema/generated/subagent-stop.command.input.schema.json") + } + SUBAGENT_STOP_OUTPUT_FIXTURE => { + include_str!("../schema/generated/subagent-stop.command.output.schema.json") + } STOP_INPUT_FIXTURE => { include_str!("../schema/generated/stop.command.input.schema.json") } @@ -772,6 +896,10 @@ mod tests { SESSION_START_OUTPUT_FIXTURE, USER_PROMPT_SUBMIT_INPUT_FIXTURE, USER_PROMPT_SUBMIT_OUTPUT_FIXTURE, + SUBAGENT_START_INPUT_FIXTURE, + SUBAGENT_START_OUTPUT_FIXTURE, + SUBAGENT_STOP_INPUT_FIXTURE, + SUBAGENT_STOP_OUTPUT_FIXTURE, STOP_INPUT_FIXTURE, STOP_OUTPUT_FIXTURE, ] { @@ -818,6 +946,16 @@ mod tests { &schema_json::().expect("serialize stop input schema"), ) .expect("parse stop input schema"); + let subagent_start: Value = serde_json::from_slice( + &schema_json::() + .expect("serialize subagent start input schema"), + ) + .expect("parse subagent start input schema"); + let subagent_stop: Value = serde_json::from_slice( + &schema_json::() + .expect("serialize subagent stop input schema"), + ) + .expect("parse subagent stop input schema"); for schema in [ &pre_tool_use, @@ -826,6 +964,8 @@ mod tests { &pre_compact, &post_compact, &user_prompt_submit, + &subagent_start, + &subagent_stop, &stop, ] { assert_eq!(schema["properties"]["turn_id"]["type"], "string"); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 91fd02d858..1d6f1e51ff 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1458,6 +1458,8 @@ pub enum HookEventName { PostCompact, SessionStart, UserPromptSubmit, + SubagentStart, + SubagentStop, Stop, }