Skip to main content

codex_hooks/events/
session_start.rs

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
218/// Interprets completed `SessionStart` and `SubagentStart` hook runs.
219///
220/// The two events have different input payloads but share most output
221/// handling: hook JSON can emit warnings/context, invalid JSON-looking stdout
222/// fails, and plain stdout becomes model context. Only `SessionStart` honors
223/// `continue:false`; `SubagentStart` stays context-injection-only.
224fn 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            /*turn_id*/ 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            /*turn_id*/ 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            /*turn_id*/ 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            /*turn_id*/ 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            /*turn_id*/ 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}