use serde::{Deserialize, Serialize};
use snafu::{ResultExt, Snafu};
pub const LIFECYCLE_EVENTS: &[&str] = &[
"SessionStart",
"SubagentStart",
"SubagentStop",
"UserPromptSubmit",
"Stop",
];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LifecycleObservation {
pub session_id: String,
pub transcript_path: Option<String>,
pub cwd: String,
pub model: Option<String>,
pub event: LifecycleEvent,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
#[non_exhaustive]
pub enum LifecycleEvent {
SessionStart {
source: SessionStartSource,
},
UserPromptSubmit {
turn_id: String,
},
Stop {
turn_id: String,
},
SubagentStart {
turn_id: String,
agent_id: String,
agent_type: String,
},
SubagentStop {
turn_id: String,
agent_id: String,
agent_type: String,
agent_transcript_path: Option<String>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum SessionStartSource {
Startup,
Resume,
Clear,
Compact,
}
#[derive(Deserialize)]
#[serde(tag = "hook_event_name")]
enum HookInput {
SessionStart {
session_id: String,
transcript_path: Option<String>,
cwd: String,
model: Option<String>,
source: SessionStartSource,
},
UserPromptSubmit {
session_id: String,
transcript_path: Option<String>,
cwd: String,
model: Option<String>,
turn_id: String,
},
Stop {
session_id: String,
transcript_path: Option<String>,
cwd: String,
model: Option<String>,
turn_id: String,
},
SubagentStart {
session_id: String,
transcript_path: Option<String>,
cwd: String,
model: Option<String>,
turn_id: String,
agent_id: String,
agent_type: String,
},
SubagentStop {
session_id: String,
transcript_path: Option<String>,
cwd: String,
model: Option<String>,
turn_id: String,
agent_id: String,
agent_type: String,
agent_transcript_path: Option<String>,
},
}
pub fn parse_observation(input: &[u8]) -> Result<LifecycleObservation, ParseObservationError> {
let parsed: HookInput =
serde_json::from_slice(input).context(parse_observation_error::ParseSnafu)?;
Ok(match parsed {
HookInput::SessionStart {
session_id,
transcript_path,
cwd,
model,
source,
} => LifecycleObservation {
session_id,
transcript_path,
cwd,
model,
event: LifecycleEvent::SessionStart { source },
},
HookInput::UserPromptSubmit {
session_id,
transcript_path,
cwd,
model,
turn_id,
} => LifecycleObservation {
session_id,
transcript_path,
cwd,
model,
event: LifecycleEvent::UserPromptSubmit { turn_id },
},
HookInput::Stop {
session_id,
transcript_path,
cwd,
model,
turn_id,
} => LifecycleObservation {
session_id,
transcript_path,
cwd,
model,
event: LifecycleEvent::Stop { turn_id },
},
HookInput::SubagentStart {
session_id,
transcript_path,
cwd,
model,
turn_id,
agent_id,
agent_type,
} => LifecycleObservation {
session_id,
transcript_path,
cwd,
model,
event: LifecycleEvent::SubagentStart {
turn_id,
agent_id,
agent_type,
},
},
HookInput::SubagentStop {
session_id,
transcript_path,
cwd,
model,
turn_id,
agent_id,
agent_type,
agent_transcript_path,
} => LifecycleObservation {
session_id,
transcript_path,
cwd,
model,
event: LifecycleEvent::SubagentStop {
turn_id,
agent_id,
agent_type,
agent_transcript_path,
},
},
})
}
#[derive(Debug, Snafu)]
#[snafu(module)]
#[non_exhaustive]
pub enum ParseObservationError {
#[snafu(display("hook input is not a recognised Codex lifecycle event"))]
Parse {
source: serde_json::Error,
},
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn a_session_start_parses_with_its_source() {
let input = serde_json::json!({
"hook_event_name": "SessionStart",
"session_id": "session/opaque-🧭",
"transcript_path": "/tmp/codex sessions/opaque.jsonl",
"cwd": "/tmp/work tree",
"model": "gpt-5-codex",
"source": "startup"
})
.to_string();
let observation = parse_observation(input.as_bytes()).unwrap();
assert_eq!(observation.session_id, "session/opaque-🧭");
assert_eq!(
observation.transcript_path.as_deref(),
Some("/tmp/codex sessions/opaque.jsonl")
);
assert_eq!(observation.cwd, "/tmp/work tree");
assert_eq!(observation.model.as_deref(), Some("gpt-5-codex"));
assert_eq!(
observation.event,
LifecycleEvent::SessionStart {
source: SessionStartSource::Startup
}
);
}
#[test]
fn a_prompt_submit_keeps_the_turn_id_and_discards_the_prompt() {
let input = serde_json::json!({
"hook_event_name": "UserPromptSubmit",
"session_id": "session/opaque-🧭",
"transcript_path": "/tmp/codex sessions/opaque.jsonl",
"cwd": "/tmp/work tree",
"model": "gpt-5-codex",
"turn_id": "turn/opaque-🧵",
"prompt": "prompt-secret-must-never-escape"
})
.to_string();
let observation = parse_observation(input.as_bytes()).unwrap();
assert_eq!(
observation.event,
LifecycleEvent::UserPromptSubmit {
turn_id: "turn/opaque-🧵".to_owned()
}
);
let retained = serde_json::to_string(&observation).unwrap();
assert!(!retained.contains("prompt-secret-must-never-escape"));
}
#[test]
fn a_subagent_stop_keeps_root_and_child_identity_and_nothing_else() {
let input = serde_json::json!({
"hook_event_name": "SubagentStop",
"session_id": "parent/opaque-🧭",
"transcript_path": "/tmp/codex sessions/parent.jsonl",
"cwd": "/tmp/work tree",
"model": "gpt-5-codex",
"turn_id": "turn/opaque-🧵",
"agent_id": "agent/opaque-🛡️",
"agent_type": "guardian/custom",
"agent_transcript_path": "/tmp/codex children/agent.jsonl",
"last_assistant_message": "sensitive assistant output must disappear",
"arbitrary": {"nested": "sensitive arbitrary value"}
})
.to_string();
let observation = parse_observation(input.as_bytes()).unwrap();
assert_eq!(observation.session_id, "parent/opaque-🧭");
assert_eq!(
observation.event,
LifecycleEvent::SubagentStop {
turn_id: "turn/opaque-🧵".to_owned(),
agent_id: "agent/opaque-🛡️".to_owned(),
agent_type: "guardian/custom".to_owned(),
agent_transcript_path: Some("/tmp/codex children/agent.jsonl".to_owned()),
}
);
let retained = serde_json::to_string(&observation).unwrap();
assert!(!retained.contains("sensitive assistant output"));
assert!(!retained.contains("sensitive arbitrary value"));
}
#[test]
fn a_subagent_stop_accepts_a_null_child_transcript_path() {
let input = serde_json::json!({
"hook_event_name": "SubagentStop",
"session_id": "parent/opaque-🧭",
"cwd": "/tmp/work tree",
"turn_id": "turn/opaque-🧵",
"agent_id": "agent/opaque-🛡️",
"agent_type": "guardian/custom",
"agent_transcript_path": null
})
.to_string();
let observation = parse_observation(input.as_bytes()).unwrap();
let LifecycleEvent::SubagentStop {
agent_transcript_path,
..
} = observation.event
else {
panic!("expected a subagent stop");
};
assert_eq!(agent_transcript_path, None);
assert_eq!(observation.transcript_path, None);
assert_eq!(observation.model, None);
}
#[test]
fn a_subagent_start_keeps_the_parent_session_and_exact_child_fields() {
let input = serde_json::json!({
"hook_event_name": "SubagentStart",
"session_id": "parent/opaque-🧭",
"cwd": "/tmp/work tree",
"turn_id": "turn/opaque-🧵",
"agent_id": "agent/opaque-🛡️",
"agent_type": "guardian/custom",
"permission_mode": "default"
})
.to_string();
let observation = parse_observation(input.as_bytes()).unwrap();
assert_eq!(observation.session_id, "parent/opaque-🧭");
assert_eq!(
observation.event,
LifecycleEvent::SubagentStart {
turn_id: "turn/opaque-🧵".to_owned(),
agent_id: "agent/opaque-🛡️".to_owned(),
agent_type: "guardian/custom".to_owned(),
}
);
}
#[test]
fn unknown_events_and_sources_are_refused() {
let unknown_event = serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "session/opaque",
"cwd": "/tmp"
})
.to_string();
assert!(parse_observation(unknown_event.as_bytes()).is_err());
let unknown_source = serde_json::json!({
"hook_event_name": "SessionStart",
"session_id": "session/opaque",
"cwd": "/tmp",
"source": "telepathy"
})
.to_string();
assert!(parse_observation(unknown_source.as_bytes()).is_err());
assert!(parse_observation(b"not json at all").is_err());
}
#[test]
fn the_lifecycle_event_list_matches_the_parsed_events() {
for event in LIFECYCLE_EVENTS {
let mut payload = serde_json::json!({
"hook_event_name": event,
"session_id": "s",
"cwd": "/tmp",
"turn_id": "t",
"agent_id": "a",
"agent_type": "kind",
});
if *event == "SessionStart" {
payload["source"] = serde_json::json!("startup");
}
assert!(
parse_observation(payload.to_string().as_bytes()).is_ok(),
"{event} is listed but not parseable"
);
}
assert_eq!(LIFECYCLE_EVENTS.len(), 5);
}
#[test]
fn an_observation_survives_a_round_trip_through_json() {
for event in LIFECYCLE_EVENTS {
let mut payload = serde_json::json!({
"hook_event_name": event,
"session_id": "s",
"transcript_path": "/tmp/rollout.jsonl",
"cwd": "/tmp",
"model": "gpt-5",
"turn_id": "t",
"agent_id": "a",
"agent_type": "kind",
"agent_transcript_path": "/tmp/child.jsonl",
});
if *event == "SessionStart" {
payload["source"] = serde_json::json!("compact");
}
let observation = parse_observation(payload.to_string().as_bytes()).unwrap();
let wire = serde_json::to_string(&observation).unwrap();
let returned: LifecycleObservation = serde_json::from_str(&wire).unwrap();
assert_eq!(returned, observation, "{event} did not survive the trip");
}
}
}