Compare commits

...

8 Commits

Author SHA1 Message Date
Abhinav Vedmala
2ae69e629f Share stop hook output parsing 2026-05-14 18:09:27 -07:00
Abhinav Vedmala
41f0d70a21 Simplify stop hook outcome routing 2026-05-14 18:01:52 -07:00
Abhinav Vedmala
c3845f50de Stabilize subagent start request assertion 2026-05-14 17:43:22 -07:00
Abhinav Vedmala
3d04c349d4 Document subagent lifecycle hook routing 2026-05-14 17:39:18 -07:00
Abhinav Vedmala
45bd1cfa07 Skip SessionStart for subagents 2026-05-14 17:38:42 -07:00
Abhinav Vedmala
bde5e187f2 Address subagent hook review feedback 2026-05-14 17:17:46 -07:00
Abhinav Vedmala
566c05b886 Fix subagent hook CI 2026-05-14 16:49:36 -07:00
Abhinav Vedmala
c59b6dcb1a Add subagent lifecycle hooks 2026-05-14 16:16:31 -07:00
61 changed files with 1572 additions and 79 deletions

View File

@@ -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",
}
}

View File

@@ -1740,6 +1740,8 @@
"postCompact",
"sessionStart",
"userPromptSubmit",
"subagentStart",
"subagentStop",
"stop"
],
"type": "string"

View File

@@ -9565,6 +9565,8 @@
"postCompact",
"sessionStart",
"userPromptSubmit",
"subagentStart",
"subagentStop",
"stop"
],
"type": "string"
@@ -10457,6 +10459,18 @@
},
"type": "array"
},
"SubagentStart": {
"items": {
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"SubagentStop": {
"items": {
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"UserPromptSubmit": {
"items": {
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
@@ -10484,6 +10498,8 @@
"PreToolUse",
"SessionStart",
"Stop",
"SubagentStart",
"SubagentStop",
"UserPromptSubmit"
],
"type": "object"

View File

@@ -6065,6 +6065,8 @@
"postCompact",
"sessionStart",
"userPromptSubmit",
"subagentStart",
"subagentStop",
"stop"
],
"type": "string"
@@ -7006,6 +7008,18 @@
},
"type": "array"
},
"SubagentStart": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"SubagentStop": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"UserPromptSubmit": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
@@ -7033,6 +7047,8 @@
"PreToolUse",
"SessionStart",
"Stop",
"SubagentStart",
"SubagentStop",
"UserPromptSubmit"
],
"type": "object"

View File

@@ -261,6 +261,18 @@
},
"type": "array"
},
"SubagentStart": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"SubagentStop": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
},
"type": "array"
},
"UserPromptSubmit": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
@@ -288,6 +300,8 @@
"PreToolUse",
"SessionStart",
"Stop",
"SubagentStart",
"SubagentStop",
"UserPromptSubmit"
],
"type": "object"

View File

@@ -14,6 +14,8 @@
"postCompact",
"sessionStart",
"userPromptSubmit",
"subagentStart",
"subagentStop",
"stop"
],
"type": "string"

View File

@@ -14,6 +14,8 @@
"postCompact",
"sessionStart",
"userPromptSubmit",
"subagentStart",
"subagentStop",
"stop"
],
"type": "string"

View File

@@ -29,6 +29,8 @@
"postCompact",
"sessionStart",
"userPromptSubmit",
"subagentStart",
"subagentStop",
"stop"
],
"type": "string"

View File

@@ -46,6 +46,8 @@
"postCompact",
"sessionStart",
"userPromptSubmit",
"subagentStart",
"subagentStop",
"stop"
],
"type": "string"

View File

@@ -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";

View File

@@ -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>, 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>, UserPromptSubmit: Array<ConfiguredHookMatcherGroup>, SubagentStart: Array<ConfiguredHookMatcherGroup>, SubagentStop: Array<ConfiguredHookMatcherGroup>, Stop: Array<ConfiguredHookMatcherGroup>, };

View File

@@ -392,6 +392,12 @@ pub struct ManagedHooksRequirements {
#[serde(rename = "UserPromptSubmit")]
#[ts(rename = "UserPromptSubmit")]
pub user_prompt_submit: Vec<ConfiguredHookMatcherGroup>,
#[serde(rename = "SubagentStart")]
#[ts(rename = "SubagentStart")]
pub subagent_start: Vec<ConfiguredHookMatcherGroup>,
#[serde(rename = "SubagentStop")]
#[ts(rename = "SubagentStop")]
pub subagent_stop: Vec<ConfiguredHookMatcherGroup>,
#[serde(rename = "Stop")]
#[ts(rename = "Stop")]
pub stop: Vec<ConfiguredHookMatcherGroup>,

View File

@@ -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
}
);

View File

@@ -422,7 +422,7 @@ async fn detect_repo_skips_hooks_when_only_unsupported_hooks_exist() {
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create external agent dir");
fs::write(
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","if":"Bash(rm *)","command":"echo blocked"}]}],"SubagentStart":[{"matcher":"worker","hooks":[{"type":"command","command":"echo started"}]}]}}"#,
r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","if":"Bash(rm *)","command":"echo blocked"}]}],"Notification":[{"matcher":"worker","hooks":[{"type":"command","command":"echo started"}]}]}}"#,
)
.expect("write hooks");

View File

@@ -475,6 +475,8 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed
post_compact,
session_start,
user_prompt_submit,
subagent_start,
subagent_stop,
stop,
} = hooks;
@@ -488,6 +490,8 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed
post_compact: map_hook_matcher_groups_to_api(post_compact),
session_start: map_hook_matcher_groups_to_api(session_start),
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),
stop: map_hook_matcher_groups_to_api(stop),
}
}

View File

@@ -45,6 +45,10 @@ pub struct HookEventsToml {
pub session_start: Vec<MatcherGroup>,
#[serde(rename = "UserPromptSubmit", default)]
pub user_prompt_submit: Vec<MatcherGroup>,
#[serde(rename = "SubagentStart", default)]
pub subagent_start: Vec<MatcherGroup>,
#[serde(rename = "SubagentStop", default)]
pub subagent_stop: Vec<MatcherGroup>,
#[serde(rename = "Stop", default)]
pub stop: Vec<MatcherGroup>,
}
@@ -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<MatcherGroup>); 8] {
pub fn into_matcher_groups(self) -> [(HookEventName, Vec<MatcherGroup>); 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),
]
}

View File

@@ -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": {

View File

@@ -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 rollout_path_for_thread(&self, thread_id: ThreadId) -> Option<PathBuf> {
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(

View File

@@ -1,4 +1,5 @@
use std::future::Future;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
@@ -13,6 +14,8 @@ use codex_hooks::PostToolUseRequest;
use codex_hooks::PreToolUseOutcome;
use codex_hooks::PreToolUseRequest;
use codex_hooks::SessionStartOutcome;
use codex_hooks::StopOutcome;
use codex_hooks::SubagentStartOutcome;
use codex_hooks::UserPromptSubmitOutcome;
use codex_hooks::UserPromptSubmitRequest;
use codex_otel::HOOK_RUN_DURATION_METRIC;
@@ -28,6 +31,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 +70,13 @@ pub(crate) enum PendingInputRecord {
},
}
struct SubagentHookMetadata {
agent_id: String,
agent_type: String,
parent_transcript_path: Option<PathBuf>,
agent_transcript_path: Option<PathBuf>,
}
struct ContextInjectingHookOutcome {
hook_events: Vec<HookCompletedEvent>,
outcome: HookRuntimeOutcome,
@@ -106,6 +118,22 @@ impl From<UserPromptSubmitOutcome> for ContextInjectingHookOutcome {
}
}
impl From<SubagentStartOutcome> 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<Session>,
turn_context: &Arc<TurnContext>,
@@ -136,6 +164,41 @@ pub(crate) async fn run_pending_session_start_hooks(
.await
}
pub(crate) async fn run_pending_subagent_start_hooks(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
) {
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 +335,50 @@ pub(crate) async fn run_post_tool_use_hooks(
outcome
}
pub(crate) async fn run_turn_stop_hooks(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
stop_hook_active: bool,
last_assistant_message: Option<String>,
) -> StopOutcome {
let hooks = sess.hooks();
let mut outcome = if let Some(metadata) = subagent_hook_metadata(sess, turn_context).await {
// Subagents use SubagentStop instead of generic Stop.
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, std::mem::take(&mut outcome.hook_events)).await;
outcome
}
pub(crate) async fn run_pre_compact_hooks(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
@@ -555,6 +662,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 +704,55 @@ fn hook_permission_mode(turn_context: &TurnContext) -> String {
.to_string()
}
async fn subagent_hook_metadata(
sess: &Arc<Session>,
turn_context: &TurnContext,
) -> Option<SubagentHookMetadata> {
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
.rollout_path_for_thread(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,
})
}
// Hook `agent_type` mirrors the spawn_agent `agent_type` argument. Internally,
// thread-spawned agents store that value as `agent_role`; omitted values use
// the default role, while system subagents expose fixed type labels.
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",

View File

@@ -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
}

View File

@@ -1067,12 +1067,25 @@ 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(_)
)
);
// Subagents use SubagentStart instead of generic SessionStart.
let should_run_session_start =
!matches!(session_configuration.session_source, SessionSource::SubAgent(_));
// 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_session_start_source(
should_run_session_start.then_some(session_start_source),
);
state.set_pending_subagent_start(should_run_subagent_start);
}
Ok(sess)

View File

@@ -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)

View File

@@ -32,6 +32,7 @@ pub(crate) struct SessionState {
pub(crate) startup_prewarm: Option<SessionStartupPrewarmHandle>,
pub(crate) active_connector_selection: HashSet<String>,
pub(crate) pending_session_start_source: Option<codex_hooks::SessionStartSource>,
pending_subagent_start: bool,
granted_permissions: Option<AdditionalPermissionProfile>,
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));

View File

@@ -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,156 @@ 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 session_start_script_path = home.join("session_start_hook.py");
let session_start_log_path = home.join("session_start_hook_log.jsonl");
let session_start_script = format!(
r#"import json
from pathlib import Path
import sys
log_path = Path(r"{session_start_log_path}")
payload = json.load(sys.stdin)
with log_path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(payload) + "\n")
"#,
session_start_log_path = session_start_log_path.display(),
);
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": {
"SessionStart": [{
"matcher": "startup",
"hooks": [{
"type": "command",
"command": format!("python3 {}", session_start_script_path.display()),
}]
}],
"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(&session_start_script_path, session_start_script)?;
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<Vec<serde_json::Value>> {
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<Vec<serde_json::Value>> {
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<String> {
let deadline = Instant::now() + Duration::from_secs(2);
loop {
@@ -143,6 +296,30 @@ async fn wait_for_requests(
}
}
async fn wait_for_matching_requests(
mock: &core_test_support::responses::ResponseMock,
predicate: impl Fn(&ResponsesRequest) -> bool,
) -> Result<Vec<ResponsesRequest>> {
let deadline = Instant::now() + Duration::from_secs(2);
loop {
let requests = mock
.requests()
.into_iter()
.filter(|request| predicate(request))
.collect::<Vec<_>>();
if !requests.is_empty() {
return Ok(requests);
}
if Instant::now() >= deadline {
anyhow::bail!(
"expected at least 1 matching request, got {}",
requests.len()
);
}
sleep(Duration::from_millis(10)).await;
}
}
async fn setup_turn_one_with_spawned_child(
server: &MockServer,
child_response_delay: Option<Duration>,
@@ -279,6 +456,211 @@ 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_matching_requests(&child_request_log, |request| {
request.body_contains_text(CHILD_PROMPT)
&& request.body_contains_text(SUBAGENT_START_CONTEXT)
&& !request.body_contains_text(SPAWN_CALL_ID)
})
.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())
);
let session_start_inputs =
read_hook_log(test.codex_home_path(), "session_start_hook_log.jsonl")?;
assert_eq!(session_start_inputs.len(), 1);
assert_eq!(session_start_inputs[0]["source"].as_str(), Some("startup"));
assert_ne!(
session_start_inputs[0]["session_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<_>>(),
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(()));

View File

@@ -41,7 +41,6 @@
"$ref": "#/definitions/NullableString"
},
"turn_id": {
"description": "Codex extension: expose the active turn id to internal turn-scoped hooks.",
"type": "string"
}
},

View File

@@ -11,6 +11,8 @@
"PostCompact",
"SessionStart",
"UserPromptSubmit",
"SubagentStart",
"SubagentStop",
"Stop"
],
"type": "string"

View File

@@ -34,7 +34,6 @@
"type": "string"
},
"turn_id": {
"description": "Codex extension: expose the active turn id to internal turn-scoped hooks.",
"type": "string"
}
},

View File

@@ -45,7 +45,6 @@
"$ref": "#/definitions/NullableString"
},
"turn_id": {
"description": "Codex extension: expose the active turn id to internal turn-scoped hooks.",
"type": "string"
}
},

View File

@@ -17,6 +17,8 @@
"PostCompact",
"SessionStart",
"UserPromptSubmit",
"SubagentStart",
"SubagentStop",
"Stop"
],
"type": "string"

View File

@@ -34,7 +34,6 @@
"type": "string"
},
"turn_id": {
"description": "Codex extension: expose the active turn id to internal turn-scoped hooks.",
"type": "string"
}
},

View File

@@ -44,7 +44,6 @@
"$ref": "#/definitions/NullableString"
},
"turn_id": {
"description": "Codex extension: expose the active turn id to internal turn-scoped hooks.",
"type": "string"
}
},

View File

@@ -11,6 +11,8 @@
"PostCompact",
"SessionStart",
"UserPromptSubmit",
"SubagentStart",
"SubagentStop",
"Stop"
],
"type": "string"

View File

@@ -11,6 +11,8 @@
"PostCompact",
"SessionStart",
"UserPromptSubmit",
"SubagentStart",
"SubagentStop",
"Stop"
],
"type": "string"

View File

@@ -43,7 +43,6 @@
"$ref": "#/definitions/NullableString"
},
"turn_id": {
"description": "Codex extension: expose the active turn id to internal turn-scoped hooks.",
"type": "string"
}
},

View File

@@ -24,7 +24,6 @@
},
"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": {

View File

@@ -0,0 +1,62 @@
{
"$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": {
"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"
}

View File

@@ -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"
}

View File

@@ -0,0 +1,74 @@
{
"$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": {
"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"
}

View File

@@ -0,0 +1,44 @@
{
"$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,
"type": "string"
},
"stopReason": {
"default": null,
"type": "string"
},
"suppressOutput": {
"default": false,
"type": "boolean"
},
"systemMessage": {
"default": null,
"type": "string"
}
},
"title": "subagent-stop.command.output",
"type": "object"
}

View File

@@ -40,7 +40,6 @@
"$ref": "#/definitions/NullableString"
},
"turn_id": {
"description": "Codex extension: expose the active turn id to internal turn-scoped hooks.",
"type": "string"
}
},

View File

@@ -17,6 +17,8 @@
"PostCompact",
"SessionStart",
"UserPromptSubmit",
"SubagentStart",
"SubagentStop",
"Stop"
],
"type": "string"

View File

@@ -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,
}
}

View File

@@ -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<HookRunSummary> {
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<HookRunSummary> {
crate::events::stop::preview(&self.handlers, request)
}
@@ -263,6 +288,23 @@ impl ClaudeHooksEngine {
outcome
}
pub(crate) fn preview_subagent_stop(
&self,
request: &SubagentStopRequest,
) -> Vec<HookRunSummary> {
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<String>) -> Vec<String> {
self.output_spiller
.maybe_spill_texts(session_id, texts)

View File

@@ -86,6 +86,7 @@ use crate::schema::PreToolUseDecisionWire;
use crate::schema::PreToolUsePermissionDecisionWire;
use crate::schema::SessionStartCommandOutputWire;
use crate::schema::StopCommandOutputWire;
use crate::schema::SubagentStartCommandOutputWire;
use crate::schema::UserPromptSubmitCommandOutputWire;
pub(crate) fn parse_session_start(stdout: &str) -> Option<SessionStartOutput> {
@@ -99,6 +100,17 @@ pub(crate) fn parse_session_start(stdout: &str) -> Option<SessionStartOutput> {
})
}
pub(crate) fn parse_subagent_start(stdout: &str) -> Option<SessionStartOutput> {
let wire: SubagentStartCommandOutputWire = parse_json(stdout)?;
let additional_context = wire
.hook_specific_output
.and_then(|output| output.additional_context);
Some(SessionStartOutput {
universal: UniversalOutput::from(wire.universal),
additional_context,
})
}
pub(crate) fn parse_pre_tool_use(stdout: &str) -> Option<PreToolUseOutput> {
let PreToolUseCommandOutputWire {
universal: universal_wire,
@@ -261,7 +273,7 @@ pub(crate) fn parse_user_prompt_submit(stdout: &str) -> Option<UserPromptSubmitO
})
}
pub(crate) fn parse_stop(stdout: &str) -> Option<StopOutput> {
pub(crate) fn parse_stop(stdout: &str, event_name: &str) -> Option<StopOutput> {
let wire: StopCommandOutputWire = parse_json(stdout)?;
let should_block = matches!(wire.decision, Some(BlockDecisionWire::Block));
let invalid_block_reason = if should_block
@@ -269,7 +281,7 @@ pub(crate) fn parse_stop(stdout: &str) -> Option<StopOutput> {
Some(reason) => reason.trim().is_empty(),
None => true,
} {
Some(invalid_block_message("Stop"))
Some(invalid_block_message(event_name))
} else {
None
};

View File

@@ -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");
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -42,12 +42,12 @@ pub struct StopOutcome {
}
#[derive(Debug, Default, PartialEq, Eq)]
struct StopHandlerData {
should_stop: bool,
stop_reason: Option<String>,
should_block: bool,
block_reason: Option<String>,
continuation_fragments: Vec<HookPromptFragment>,
pub(super) struct StopHandlerData {
pub(super) should_stop: bool,
pub(super) stop_reason: Option<String>,
pub(super) should_block: bool,
pub(super) block_reason: Option<String>,
pub(super) continuation_fragments: Vec<HookPromptFragment>,
}
pub(crate) fn preview(
@@ -121,11 +121,33 @@ pub(crate) async fn run(
}
}
fn parse_completed(
pub(super) fn parse_completed(
handler: &ConfiguredHandler,
run_result: CommandRunResult,
turn_id: Option<String>,
) -> dispatcher::ParsedHandler<StopHandlerData> {
parse_stop_completed(handler, run_result, turn_id, "Stop")
}
pub(super) fn parse_subagent_stop_completed(
handler: &ConfiguredHandler,
run_result: CommandRunResult,
turn_id: Option<String>,
) -> dispatcher::ParsedHandler<StopHandlerData> {
parse_stop_completed(handler, run_result, turn_id, "SubagentStop")
}
fn parse_stop_completed(
handler: &ConfiguredHandler,
run_result: CommandRunResult,
turn_id: Option<String>,
hook_name: &str,
) -> dispatcher::ParsedHandler<StopHandlerData> {
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 +168,9 @@ 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) =
output_parser::parse_stop(&run_result.stdout, hook_name)
{
if let Some(system_message) = parsed.universal.system_message {
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Warning,
@@ -186,9 +210,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 +220,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 +240,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 +289,7 @@ fn parse_completed(
}
}
fn aggregate_results<'a>(
pub(super) fn aggregate_results<'a>(
results: impl IntoIterator<Item = &'a StopHandlerData>,
) -> StopHandlerData {
let results = results.into_iter().collect::<Vec<_>>();
@@ -299,7 +325,7 @@ fn aggregate_results<'a>(
}
}
fn serialization_failure_outcome(hook_events: Vec<HookCompletedEvent>) -> StopOutcome {
pub(super) fn serialization_failure_outcome(hook_events: Vec<HookCompletedEvent>) -> StopOutcome {
StopOutcome {
hook_events,
should_stop: false,

View File

@@ -0,0 +1,198 @@
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<PathBuf>,
pub model: String,
pub permission_mode: String,
pub agent_id: String,
pub agent_type: String,
}
#[derive(Debug)]
pub struct SubagentStartOutcome {
pub hook_events: Vec<HookCompletedEvent>,
pub additional_contexts: Vec<String>,
}
#[derive(Debug, PartialEq, Eq)]
struct SubagentStartHandlerData {
additional_contexts_for_model: Vec<String>,
}
pub(crate) fn preview(
handlers: &[ConfiguredHandler],
request: &SubagentStartRequest,
) -> Vec<HookRunSummary> {
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<String>,
) -> dispatcher::ParsedHandler<SubagentStartHandlerData> {
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(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<HookCompletedEvent>) -> SubagentStartOutcome {
SubagentStartOutcome {
hook_events,
additional_contexts: Vec::new(),
}
}

View File

@@ -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<PathBuf>,
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<PathBuf>,
pub last_assistant_message: Option<String>,
}
pub(crate) fn preview(
handlers: &[ConfiguredHandler],
request: &SubagentStopRequest,
) -> Vec<HookRunSummary> {
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,
}
}

View File

@@ -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",
}
}

View File

@@ -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<codex_protocol::protocol::HookRunSummary> {
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<codex_protocol::protocol::HookRunSummary> {
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 {

View File

@@ -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,
}
@@ -241,7 +249,6 @@ pub(crate) enum PreToolUseDecisionWire {
#[schemars(rename = "pre-tool-use.command.input")]
pub(crate) struct PreToolUseCommandInput {
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,
@@ -260,7 +267,6 @@ pub(crate) struct PreToolUseCommandInput {
#[schemars(rename = "permission-request.command.input")]
pub(crate) struct PermissionRequestCommandInput {
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,
@@ -278,7 +284,6 @@ pub(crate) struct PermissionRequestCommandInput {
#[schemars(rename = "post-tool-use.command.input")]
pub(crate) struct PostToolUseCommandInput {
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,
@@ -298,7 +303,6 @@ pub(crate) struct PostToolUseCommandInput {
#[schemars(rename = "pre-compact.command.input")]
pub(crate) struct PreCompactCommandInput {
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,
@@ -314,7 +318,6 @@ pub(crate) struct PreCompactCommandInput {
#[schemars(rename = "post-compact.command.input")]
pub(crate) struct PostCompactCommandInput {
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,
@@ -345,6 +348,26 @@ pub(crate) struct SessionStartHookSpecificOutputWire {
pub additional_context: Option<String>,
}
#[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<SubagentStartHookSpecificOutputWire>,
}
#[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<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
@@ -378,8 +401,19 @@ pub(crate) struct StopCommandOutputWire {
pub universal: HookUniversalOutputWire,
#[serde(default)]
pub decision: Option<BlockDecisionWire>,
/// 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<String>,
}
#[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<BlockDecisionWire>,
#[serde(default)]
pub reason: Option<String>,
}
@@ -432,7 +466,6 @@ impl SessionStartCommandInput {
#[schemars(rename = "user-prompt-submit.command.input")]
pub(crate) struct UserPromptSubmitCommandInput {
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,
@@ -444,12 +477,28 @@ 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,
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")]
pub(crate) struct StopCommandInput {
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,
@@ -462,6 +511,26 @@ 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,
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 +591,22 @@ pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> {
&generated_dir.join(USER_PROMPT_SUBMIT_OUTPUT_FIXTURE),
schema_json::<UserPromptSubmitCommandOutputWire>()?,
)?;
write_schema(
&generated_dir.join(SUBAGENT_START_INPUT_FIXTURE),
schema_json::<SubagentStartCommandInput>()?,
)?;
write_schema(
&generated_dir.join(SUBAGENT_START_OUTPUT_FIXTURE),
schema_json::<SubagentStartCommandOutputWire>()?,
)?;
write_schema(
&generated_dir.join(SUBAGENT_STOP_INPUT_FIXTURE),
schema_json::<SubagentStopCommandInput>()?,
)?;
write_schema(
&generated_dir.join(SUBAGENT_STOP_OUTPUT_FIXTURE),
schema_json::<SubagentStopCommandOutputWire>()?,
)?;
write_schema(
&generated_dir.join(STOP_INPUT_FIXTURE),
schema_json::<StopCommandInput>()?,
@@ -613,6 +698,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 +776,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 +836,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 +883,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 +933,16 @@ mod tests {
&schema_json::<StopCommandInput>().expect("serialize stop input schema"),
)
.expect("parse stop input schema");
let subagent_start: Value = serde_json::from_slice(
&schema_json::<SubagentStartCommandInput>()
.expect("serialize subagent start input schema"),
)
.expect("parse subagent start input schema");
let subagent_stop: Value = serde_json::from_slice(
&schema_json::<SubagentStopCommandInput>()
.expect("serialize subagent stop input schema"),
)
.expect("parse subagent stop input schema");
for schema in [
&pre_tool_use,
@@ -826,6 +951,8 @@ mod tests {
&pre_compact,
&post_compact,
&user_prompt_submit,
&subagent_start,
&subagent_stop,
&stop,
] {
assert_eq!(schema["properties"]["turn_id"]["type"], "string");

View File

@@ -1458,6 +1458,8 @@ pub enum HookEventName {
PostCompact,
SessionStart,
UserPromptSubmit,
SubagentStart,
SubagentStop,
Stop,
}

View File

@@ -736,6 +736,8 @@ fn event_label(event_name: HookEventName) -> &'static str {
HookEventName::PostCompact => "PostCompact",
HookEventName::SessionStart => "SessionStart",
HookEventName::UserPromptSubmit => "UserPromptSubmit",
HookEventName::SubagentStart => "SubagentStart",
HookEventName::SubagentStop => "SubagentStop",
HookEventName::Stop => "Stop",
}
}
@@ -749,6 +751,8 @@ fn event_description(event_name: HookEventName) -> &'static str {
HookEventName::PostCompact => "After context compaction",
HookEventName::SessionStart => "When a new session starts",
HookEventName::UserPromptSubmit => "When the user submits a prompt",
HookEventName::SubagentStart => "When a subagent is created",
HookEventName::SubagentStop => "Right before a subagent ends its turn",
HookEventName::Stop => "Right before Codex ends its turn",
}
}

View File

@@ -14,6 +14,8 @@ expression: "render_lines(&view, 112)"
PostCompact 0 0 After context compaction
SessionStart 0 0 When a new session starts
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
Stop 0 0 Right before Codex ends its turn
Press enter to view hooks; esc to close

View File

@@ -18,6 +18,8 @@ expression: "render_lines(&view, 112)"
PostCompact 0 0 After context compaction
SessionStart 0 0 When a new session starts
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
Stop 0 0 Right before Codex ends its turn
Press enter to view hooks; esc to close

View File

@@ -16,6 +16,8 @@ expression: "render_lines(&view, 112)"
PostCompact 0 0 0 After context compaction
SessionStart 0 0 0 When a new session starts
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
Stop 0 0 0 Right before Codex ends its turn
Press t to trust all; enter to review hooks; esc to close

View File

@@ -17,6 +17,8 @@ expression: popup
PostCompact 0 0 After context compaction
SessionStart 0 0 When a new session starts
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
Stop 0 0 Right before Codex ends its turn
Press enter to view hooks; esc to close

View File

@@ -1572,6 +1572,8 @@ fn hook_event_label(event_name: codex_app_server_protocol::HookEventName) -> &'s
codex_app_server_protocol::HookEventName::PostCompact => "PostCompact",
codex_app_server_protocol::HookEventName::SessionStart => "SessionStart",
codex_app_server_protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit",
codex_app_server_protocol::HookEventName::SubagentStart => "SubagentStart",
codex_app_server_protocol::HookEventName::SubagentStop => "SubagentStop",
codex_app_server_protocol::HookEventName::Stop => "Stop",
}
}

View File

@@ -720,6 +720,8 @@ fn hook_event_label(event_name: HookEventName) -> &'static str {
HookEventName::PostCompact => "PostCompact",
HookEventName::SessionStart => "SessionStart",
HookEventName::UserPromptSubmit => "UserPromptSubmit",
HookEventName::SubagentStart => "SubagentStart",
HookEventName::SubagentStop => "SubagentStop",
HookEventName::Stop => "Stop",
}
}