1use std::path::PathBuf;
2
3use codex_protocol::ThreadId;
4use codex_protocol::protocol::HookCompletedEvent;
5use codex_protocol::protocol::HookEventName;
6use codex_protocol::protocol::HookOutputEntry;
7use codex_protocol::protocol::HookOutputEntryKind;
8use codex_protocol::protocol::HookRunStatus;
9use codex_protocol::protocol::HookRunSummary;
10use codex_utils_absolute_path::AbsolutePathBuf;
11
12use super::common;
13use crate::engine::CommandShell;
14use crate::engine::ConfiguredHandler;
15use crate::engine::command_runner::CommandRunResult;
16use crate::engine::dispatcher;
17use crate::engine::output_parser;
18use crate::output_spill::AdditionalContext;
19use crate::output_spill::HookOutputSpiller;
20use crate::schema::NullableString;
21use crate::schema::SessionStartCommandInput;
22use crate::schema::SubagentStartCommandInput;
23
24#[derive(Debug, Clone, Copy)]
25pub enum SessionStartSource {
26 Startup,
27 Resume,
28 Clear,
29 Compact,
30}
31
32impl SessionStartSource {
33 pub fn as_str(self) -> &'static str {
34 match self {
35 Self::Startup => "startup",
36 Self::Resume => "resume",
37 Self::Clear => "clear",
38 Self::Compact => "compact",
39 }
40 }
41}
42
43#[derive(Debug, Clone)]
44pub struct SessionStartRequest {
45 pub session_id: ThreadId,
46 pub cwd: AbsolutePathBuf,
47 pub transcript_path: Option<PathBuf>,
48 pub model: String,
49 pub permission_mode: String,
50 pub target: StartHookTarget,
51}
52
53#[derive(Debug, Clone)]
54pub enum StartHookTarget {
55 SessionStart {
56 source: SessionStartSource,
57 },
58 SubagentStart {
59 turn_id: String,
60 agent_id: String,
61 agent_type: String,
62 },
63}
64
65impl StartHookTarget {
66 fn event_name(&self) -> HookEventName {
67 match self {
68 Self::SessionStart { .. } => HookEventName::SessionStart,
69 Self::SubagentStart { .. } => HookEventName::SubagentStart,
70 }
71 }
72
73 fn matcher_input(&self) -> &str {
74 match self {
75 Self::SessionStart { source } => source.as_str(),
76 Self::SubagentStart { agent_type, .. } => agent_type.as_str(),
77 }
78 }
79}
80
81#[derive(Debug)]
82pub struct SessionStartOutcome {
83 pub hook_events: Vec<HookCompletedEvent>,
84 pub should_stop: bool,
85 pub stop_reason: Option<String>,
86 pub additional_contexts: Vec<String>,
87}
88
89#[derive(Debug, PartialEq, Eq)]
90struct SessionStartHandlerData {
91 should_stop: bool,
92 stop_reason: Option<String>,
93 additional_contexts_for_model: Vec<AdditionalContext>,
94}
95
96pub(crate) fn preview(
97 handlers: &[ConfiguredHandler],
98 request: &SessionStartRequest,
99) -> Vec<HookRunSummary> {
100 dispatcher::select_handlers(
101 handlers,
102 request.target.event_name(),
103 Some(request.target.matcher_input()),
104 )
105 .into_iter()
106 .map(|handler| dispatcher::running_summary(&handler))
107 .collect()
108}
109
110pub(crate) async fn run(
111 handlers: &[ConfiguredHandler],
112 shell: &CommandShell,
113 output_spiller: &HookOutputSpiller,
114 request: SessionStartRequest,
115 turn_id: Option<String>,
116) -> SessionStartOutcome {
117 let session_id = request.session_id;
118 let matched = dispatcher::select_handlers(
119 handlers,
120 request.target.event_name(),
121 Some(request.target.matcher_input()),
122 );
123 if matched.is_empty() {
124 return SessionStartOutcome {
125 hook_events: Vec::new(),
126 should_stop: false,
127 stop_reason: None,
128 additional_contexts: Vec::new(),
129 };
130 }
131
132 let (input_json, turn_id) = match request.target {
133 StartHookTarget::SessionStart { source } => {
134 let input_json = match serde_json::to_string(&SessionStartCommandInput::new(
135 request.session_id.to_string(),
136 request.transcript_path.clone(),
137 request.cwd.display().to_string(),
138 request.model.clone(),
139 request.permission_mode.clone(),
140 source.as_str().to_string(),
141 )) {
142 Ok(input_json) => input_json,
143 Err(error) => {
144 return serialization_failure_outcome(
145 common::serialization_failure_hook_events(
146 matched,
147 turn_id,
148 format!("failed to serialize session start hook input: {error}"),
149 ),
150 );
151 }
152 };
153 (input_json, turn_id)
154 }
155 StartHookTarget::SubagentStart {
156 turn_id: subagent_turn_id,
157 agent_id,
158 agent_type,
159 } => {
160 let input = SubagentStartCommandInput {
161 session_id: request.session_id.to_string(),
162 turn_id: subagent_turn_id.clone(),
163 transcript_path: NullableString::from_path(request.transcript_path.clone()),
164 cwd: request.cwd.display().to_string(),
165 hook_event_name: "SubagentStart".to_string(),
166 model: request.model.clone(),
167 permission_mode: request.permission_mode.clone(),
168 agent_id,
169 agent_type,
170 };
171 let input_json = match serde_json::to_string(&input) {
172 Ok(input_json) => input_json,
173 Err(error) => {
174 return serialization_failure_outcome(
175 common::serialization_failure_hook_events(
176 matched,
177 Some(subagent_turn_id),
178 format!("failed to serialize subagent start hook input: {error}"),
179 ),
180 );
181 }
182 };
183 (input_json, Some(subagent_turn_id))
184 }
185 };
186
187 let results = dispatcher::execute_handlers(
188 shell,
189 matched,
190 input_json,
191 request.cwd.as_path(),
192 turn_id,
193 parse_completed,
194 )
195 .await;
196
197 let should_stop = results.iter().any(|result| result.data.should_stop);
198 let stop_reason = results
199 .iter()
200 .find_map(|result| result.data.stop_reason.clone());
201 let additional_contexts = common::flatten_additional_contexts(
202 results
203 .iter()
204 .map(|result| result.data.additional_contexts_for_model.as_slice()),
205 );
206 let additional_contexts = output_spiller
207 .maybe_spill_additional_contexts(session_id, additional_contexts)
208 .await;
209
210 SessionStartOutcome {
211 hook_events: results.into_iter().map(|result| result.completed).collect(),
212 should_stop,
213 stop_reason,
214 additional_contexts,
215 }
216}
217
218fn parse_completed(
225 handler: &ConfiguredHandler,
226 run_result: CommandRunResult,
227 turn_id: Option<String>,
228) -> dispatcher::ParsedHandler<SessionStartHandlerData> {
229 let mut entries = Vec::new();
230 let mut status = HookRunStatus::Completed;
231 let mut should_stop = false;
232 let mut stop_reason = None;
233 let mut additional_contexts_for_model = Vec::new();
234
235 match run_result.error.as_deref() {
236 Some(error) => {
237 status = HookRunStatus::Failed;
238 entries.push(HookOutputEntry {
239 kind: HookOutputEntryKind::Error,
240 text: error.to_string(),
241 });
242 }
243 None => match run_result.exit_code {
244 Some(0) => {
245 let trimmed_stdout = run_result.stdout.trim();
246 if trimmed_stdout.is_empty() {
247 } else if let Some(parsed) = match handler.event_name {
248 HookEventName::SessionStart => {
249 output_parser::parse_session_start(&run_result.stdout)
250 }
251 HookEventName::SubagentStart => {
252 output_parser::parse_subagent_start(&run_result.stdout)
253 }
254 event_name => {
255 panic!("expected start hook event, got {event_name:?}")
256 }
257 } {
258 if let Some(system_message) = parsed.universal.system_message {
259 entries.push(HookOutputEntry {
260 kind: HookOutputEntryKind::Warning,
261 text: system_message,
262 });
263 }
264 if let Some(additional_context) = parsed.additional_context {
265 common::append_additional_context(
266 &mut entries,
267 &mut additional_contexts_for_model,
268 handler,
269 additional_context,
270 );
271 }
272 let _ = parsed.universal.suppress_output;
273 if handler.event_name == HookEventName::SessionStart
274 && !parsed.universal.continue_processing
275 {
276 status = HookRunStatus::Stopped;
277 should_stop = true;
278 stop_reason = parsed.universal.stop_reason.clone();
279 if let Some(stop_reason_text) = parsed.universal.stop_reason {
280 entries.push(HookOutputEntry {
281 kind: HookOutputEntryKind::Stop,
282 text: stop_reason_text,
283 });
284 }
285 }
286 } else if output_parser::looks_like_json(&run_result.stdout) {
287 status = HookRunStatus::Failed;
288 entries.push(HookOutputEntry {
289 kind: HookOutputEntryKind::Error,
290 text: match handler.event_name {
291 HookEventName::SessionStart => {
292 "hook returned invalid session start JSON output"
293 }
294 HookEventName::SubagentStart => {
295 "hook returned invalid subagent start JSON output"
296 }
297 event_name => {
298 panic!("expected start hook event, got {event_name:?}")
299 }
300 }
301 .to_string(),
302 });
303 } else {
304 let additional_context = trimmed_stdout.to_string();
305 common::append_additional_context(
306 &mut entries,
307 &mut additional_contexts_for_model,
308 handler,
309 additional_context,
310 );
311 }
312 }
313 Some(exit_code) => {
314 status = HookRunStatus::Failed;
315 entries.push(HookOutputEntry {
316 kind: HookOutputEntryKind::Error,
317 text: format!("hook exited with code {exit_code}"),
318 });
319 }
320 None => {
321 status = HookRunStatus::Failed;
322 entries.push(HookOutputEntry {
323 kind: HookOutputEntryKind::Error,
324 text: "hook exited without a status code".to_string(),
325 });
326 }
327 },
328 }
329
330 let completed = HookCompletedEvent {
331 turn_id,
332 run: dispatcher::completed_summary(handler, &run_result, status, entries),
333 };
334
335 dispatcher::ParsedHandler {
336 completed,
337 data: SessionStartHandlerData {
338 should_stop,
339 stop_reason,
340 additional_contexts_for_model,
341 },
342 completion_order: 0,
343 }
344}
345
346fn serialization_failure_outcome(hook_events: Vec<HookCompletedEvent>) -> SessionStartOutcome {
347 SessionStartOutcome {
348 hook_events,
349 should_stop: false,
350 stop_reason: None,
351 additional_contexts: Vec::new(),
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use codex_protocol::protocol::HookEventName;
358 use codex_protocol::protocol::HookOutputEntry;
359 use codex_protocol::protocol::HookOutputEntryKind;
360 use codex_protocol::protocol::HookRunStatus;
361 use codex_utils_absolute_path::test_support::PathBufExt;
362 use codex_utils_absolute_path::test_support::test_path_buf;
363 use pretty_assertions::assert_eq;
364
365 use super::SessionStartHandlerData;
366 use super::parse_completed;
367 use crate::engine::ConfiguredHandler;
368 use crate::engine::command_runner::CommandRunResult;
369 use crate::output_spill::AdditionalContext;
370 use crate::output_spill::AdditionalContextLimit;
371
372 #[test]
373 fn plain_stdout_becomes_model_context() {
374 let mut handler = handler();
375 handler.additional_context_limit = AdditionalContextLimit::from_config(Some(7));
376 let parsed = parse_completed(
377 &handler,
378 run_result(Some(0), "hello from hook\n", ""),
379 None,
380 );
381
382 assert_eq!(
383 parsed.data,
384 SessionStartHandlerData {
385 should_stop: false,
386 stop_reason: None,
387 additional_contexts_for_model: vec![AdditionalContext {
388 text: "hello from hook".to_string(),
389 limit: AdditionalContextLimit::from_config(Some(7)),
390 }],
391 }
392 );
393 assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
394 assert_eq!(
395 parsed.completed.run.entries,
396 vec![HookOutputEntry {
397 kind: HookOutputEntryKind::Context,
398 text: "hello from hook".to_string(),
399 }]
400 );
401 }
402
403 #[test]
404 fn continue_false_preserves_context_for_later_turns() {
405 let parsed = parse_completed(
406 &handler(),
407 run_result(
408 Some(0),
409 r#"{"continue":false,"stopReason":"pause","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"do not inject"}}"#,
410 "",
411 ),
412 None,
413 );
414
415 assert_eq!(
416 parsed.data,
417 SessionStartHandlerData {
418 should_stop: true,
419 stop_reason: Some("pause".to_string()),
420 additional_contexts_for_model: vec![AdditionalContext {
421 text: "do not inject".to_string(),
422 limit: Default::default(),
423 }],
424 }
425 );
426 assert_eq!(parsed.completed.run.status, HookRunStatus::Stopped);
427 assert_eq!(
428 parsed.completed.run.entries,
429 vec![
430 HookOutputEntry {
431 kind: HookOutputEntryKind::Context,
432 text: "do not inject".to_string(),
433 },
434 HookOutputEntry {
435 kind: HookOutputEntryKind::Stop,
436 text: "pause".to_string(),
437 },
438 ]
439 );
440 }
441
442 #[test]
443 fn invalid_json_like_stdout_fails_instead_of_becoming_model_context() {
444 let parsed = parse_completed(
445 &handler(),
446 run_result(
447 Some(0),
448 r#"{"hookSpecificOutput":{"hookEventName":"SessionStart""#,
449 "",
450 ),
451 None,
452 );
453
454 assert_eq!(
455 parsed.data,
456 SessionStartHandlerData {
457 should_stop: false,
458 stop_reason: None,
459 additional_contexts_for_model: Vec::new(),
460 }
461 );
462 assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
463 assert_eq!(
464 parsed.completed.run.entries,
465 vec![HookOutputEntry {
466 kind: HookOutputEntryKind::Error,
467 text: "hook returned invalid session start JSON output".to_string(),
468 }]
469 );
470 }
471
472 #[test]
473 fn subagent_start_plain_stdout_becomes_model_context() {
474 let mut handler = handler_for(HookEventName::SubagentStart);
475 handler.additional_context_limit = AdditionalContextLimit::from_config(Some(4_096));
476 let parsed = parse_completed(
477 &handler,
478 run_result(Some(0), "hello from subagent hook\n", ""),
479 Some("turn-1".to_string()),
480 );
481
482 assert_eq!(
483 parsed.data,
484 SessionStartHandlerData {
485 should_stop: false,
486 stop_reason: None,
487 additional_contexts_for_model: vec![AdditionalContext {
488 text: "hello from subagent hook".to_string(),
489 limit: AdditionalContextLimit::from_config(Some(4_096)),
490 }],
491 }
492 );
493 assert_eq!(parsed.completed.turn_id.as_deref(), Some("turn-1"));
494 assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
495 assert_eq!(
496 parsed.completed.run.entries,
497 vec![HookOutputEntry {
498 kind: HookOutputEntryKind::Context,
499 text: "hello from subagent hook".to_string(),
500 }]
501 );
502 }
503
504 #[test]
505 fn subagent_start_continue_false_is_ignored() {
506 let parsed = parse_completed(
507 &handler_for(HookEventName::SubagentStart),
508 run_result(
509 Some(0),
510 r#"{"continue":false,"stopReason":"skip child","hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"child context"}}"#,
511 "",
512 ),
513 Some("turn-1".to_string()),
514 );
515
516 assert_eq!(
517 parsed.data,
518 SessionStartHandlerData {
519 should_stop: false,
520 stop_reason: None,
521 additional_contexts_for_model: vec![AdditionalContext {
522 text: "child context".to_string(),
523 limit: Default::default(),
524 }],
525 }
526 );
527 assert_eq!(parsed.completed.turn_id.as_deref(), Some("turn-1"));
528 assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
529 assert_eq!(
530 parsed.completed.run.entries,
531 vec![HookOutputEntry {
532 kind: HookOutputEntryKind::Context,
533 text: "child context".to_string(),
534 }]
535 );
536 }
537
538 fn handler() -> ConfiguredHandler {
539 handler_for(HookEventName::SessionStart)
540 }
541
542 fn handler_for(event_name: HookEventName) -> ConfiguredHandler {
543 ConfiguredHandler {
544 event_name,
545 matcher: None,
546 command: "echo hook".to_string(),
547 timeout_sec: 600,
548 status_message: None,
549 additional_context_limit: Default::default(),
550 source_path: test_path_buf("/tmp/hooks.json").abs(),
551 source: codex_protocol::protocol::HookSource::User,
552 display_order: 0,
553 env: std::collections::HashMap::new(),
554 }
555 }
556
557 fn run_result(exit_code: Option<i32>, stdout: &str, stderr: &str) -> CommandRunResult {
558 CommandRunResult {
559 started_at: 1,
560 completed_at: 2,
561 duration_ms: 1,
562 exit_code,
563 stdout: stdout.to_string(),
564 stderr: stderr.to_string(),
565 error: None,
566 }
567 }
568}