use std::io::BufRead;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use http::HeaderMap;
use serde::Deserialize;
use time::OffsetDateTime;
use tracing::warn;
pub const CODEX_ROLLOUT_ID_HEADERS: &[&str] = &[
tapes_capture::envelope::CODEX_THREAD_ID_HEADER,
tapes_capture::envelope::CODEX_SESSION_ID_HEADER,
];
#[must_use]
pub fn rollout_id(headers: &HeaderMap) -> Option<&str> {
CODEX_ROLLOUT_ID_HEADERS.iter().find_map(|name| {
headers
.get(*name)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodexSessionFile {
pub session_id: String,
pub root_session_id: Option<String>,
pub parent_thread_id: Option<String>,
pub subagent_kind: Option<String>,
pub timestamp: OffsetDateTime,
pub modified_at: Option<OffsetDateTime>,
pub cwd: Option<String>,
pub originator: Option<String>,
pub cli_version: Option<String>,
pub source: Option<String>,
pub thread_source: Option<String>,
pub model_provider: Option<String>,
pub path: PathBuf,
}
impl CodexSessionFile {
#[must_use]
pub fn has_model_provider(&self, provider: &str) -> bool {
self.model_provider.as_deref() == Some(provider)
}
}
#[derive(Deserialize)]
struct JsonlRow {
#[serde(rename = "type")]
row_type: String,
payload: Option<SessionMetaPayload>,
}
#[derive(Deserialize)]
struct SessionMetaPayload {
id: String,
session_id: Option<String>,
parent_thread_id: Option<String>,
timestamp: String,
cwd: Option<String>,
originator: Option<String>,
cli_version: Option<String>,
source: Option<serde_json::Value>,
thread_source: Option<String>,
model_provider: Option<String>,
}
pub fn read(path: &Path) -> Option<CodexSessionFile> {
let file = std::fs::File::open(path).ok()?;
let reader = std::io::BufReader::new(file);
for line in reader.lines().map_while(Result::ok) {
if line.trim().is_empty() {
continue;
}
let row = match serde_json::from_str::<JsonlRow>(&line) {
Ok(row) => row,
Err(err) => {
warn!(
path = %path.display(),
error = %err,
"codex-session: could not parse jsonl row",
);
continue;
}
};
if row.row_type != "session_meta" {
continue;
}
let payload = row.payload?;
let timestamp = match OffsetDateTime::parse(
&payload.timestamp,
&time::format_description::well_known::Rfc3339,
) {
Ok(ts) => ts,
Err(err) => {
warn!(
path = %path.display(),
error = %err,
"codex-session: could not parse session timestamp",
);
return None;
}
};
let subagent_kind = payload.source.as_ref().and_then(subagent_kind_from_source);
return Some(CodexSessionFile {
session_id: payload.id,
root_session_id: payload.session_id,
parent_thread_id: payload.parent_thread_id,
subagent_kind,
timestamp,
modified_at: modified_at(path),
cwd: payload.cwd,
originator: payload.originator,
cli_version: payload.cli_version,
source: payload.source.and_then(metadata_value_to_string),
thread_source: payload.thread_source,
model_provider: payload.model_provider,
path: path.to_path_buf(),
});
}
None
}
fn subagent_kind_from_source(source: &serde_json::Value) -> Option<String> {
let subagent = source.get("subagent")?;
if let Some(kind) = subagent.as_str() {
return Some(kind.to_owned());
}
if subagent
.get("thread_spawn")
.is_some_and(serde_json::Value::is_object)
{
return Some("thread_spawn".to_owned());
}
["other", "agent_type", "agent_role", "type"]
.into_iter()
.find_map(|key| subagent.get(key).and_then(serde_json::Value::as_str))
.map(str::to_owned)
}
fn modified_at(path: &Path) -> Option<OffsetDateTime> {
let modified = std::fs::metadata(path).ok()?.modified().ok()?;
Some(system_time_to_offset(modified))
}
fn system_time_to_offset(t: SystemTime) -> OffsetDateTime {
t.into()
}
#[must_use]
pub fn default_sessions_dir() -> Option<PathBuf> {
if let Some(home) = std::env::var_os("CODEX_HOME").filter(|v| !v.is_empty()) {
return Some(PathBuf::from(home).join("sessions"));
}
dirs::home_dir().map(|h| h.join(".codex").join("sessions"))
}
fn metadata_value_to_string(value: serde_json::Value) -> Option<String> {
match value {
serde_json::Value::Null => None,
serde_json::Value::String(value) => Some(value),
value => serde_json::to_string(&value).ok(),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn the_rollout_id_headers_are_the_envelope_pair_rule_headers() {
use tapes_capture::envelope::{HARNESS_THREAD_ID_RULES, HarnessThreadRule};
let pair = HARNESS_THREAD_ID_RULES
.iter()
.find_map(|rule| match rule {
HarnessThreadRule::DivergentPair { thread, session } => Some((*thread, *session)),
_ => None,
})
.expect("codex is declared as a divergent pair");
assert_eq!(
[pair.0, pair.1],
[CODEX_ROLLOUT_ID_HEADERS[0], CODEX_ROLLOUT_ID_HEADERS[1]],
);
}
#[test]
fn read_parses_session_meta_first_row() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("rollout-test.jsonl");
std::fs::write(
&path,
r#"{"timestamp":"2026-06-15T23:11:58.261Z","type":"session_meta","payload":{"id":"019ecd8e-4281-7353-8a00-09df678443b1","timestamp":"2026-06-15T23:11:52.984Z","cwd":"/tmp/work","originator":"codex-tui","cli_version":"0.139.0","source":"cli","thread_source":"user","model_provider":"paper-openai"}}"#,
)
.unwrap();
let got = read(&path).unwrap();
assert_eq!(got.session_id, "019ecd8e-4281-7353-8a00-09df678443b1");
assert_eq!(got.cwd.as_deref(), Some("/tmp/work"));
assert_eq!(got.cli_version.as_deref(), Some("0.139.0"));
assert!(got.has_model_provider("paper-openai"));
}
#[test]
fn read_accepts_structured_source_metadata() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("rollout-test.jsonl");
std::fs::write(
&path,
r#"{"timestamp":"2026-06-15T23:11:58.261Z","type":"session_meta","payload":{"id":"019ecd8e-4281-7353-8a00-09df678443b1","timestamp":"2026-06-15T23:11:52.984Z","cwd":"/tmp/work","source":{"subagent":{"agent_nickname":"Kant"}},"thread_source":"subagent","model_provider":"paper-openai"}}"#,
)
.unwrap();
let got = read(&path).unwrap();
assert_eq!(
got.source.as_deref(),
Some(r#"{"subagent":{"agent_nickname":"Kant"}}"#)
);
}
#[test]
fn read_recovers_subagent_lineage() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("rollout-test.jsonl");
std::fs::write(
&path,
r#"{"timestamp":"2026-06-15T23:11:58.261Z","type":"session_meta","payload":{"id":"019ecd8e-4281-7353-8a00-09df678443b1","session_id":"root-session","parent_thread_id":"parent-thread","timestamp":"2026-06-15T23:11:52.984Z","cwd":"/tmp/work","source":{"subagent":{"other":"guardian","agent_nickname":"Kant"}},"thread_source":"subagent","model_provider":"paper-openai"}}"#,
)
.unwrap();
let got = read(&path).unwrap();
assert_eq!(got.session_id, "019ecd8e-4281-7353-8a00-09df678443b1");
assert_eq!(got.cwd.as_deref(), Some("/tmp/work"));
assert_eq!(got.root_session_id.as_deref(), Some("root-session"));
assert_eq!(got.parent_thread_id.as_deref(), Some("parent-thread"));
assert_eq!(got.subagent_kind.as_deref(), Some("guardian"));
assert_eq!(
got.source.as_deref(),
Some(r#"{"subagent":{"agent_nickname":"Kant","other":"guardian"}}"#)
);
}
#[test]
fn read_accepts_thread_spawn_source_metadata() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("rollout-test.jsonl");
std::fs::write(
&path,
r#"{"timestamp":"2026-07-22T23:11:58.261Z","type":"session_meta","payload":{"id":"child-thread","session_id":"root-session","parent_thread_id":"parent-thread","timestamp":"2026-07-22T23:11:52.984Z","cwd":"/tmp/work","source":{"subagent":{"thread_spawn":{"parent_thread_id":"parent-thread","depth":2,"agent_path":"/root/child/grandchild","agent_nickname":"Euler","agent_role":null}}},"thread_source":"subagent","model_provider":"paper-openai"}}"#,
)
.unwrap();
let got = read(&path).unwrap();
assert_eq!(got.subagent_kind.as_deref(), Some("thread_spawn"));
assert_eq!(got.root_session_id.as_deref(), Some("root-session"));
assert_eq!(got.parent_thread_id.as_deref(), Some("parent-thread"));
}
#[test]
fn read_leaves_lineage_empty_for_root_rollouts() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("rollout-root.jsonl");
std::fs::write(
&path,
r#"{"timestamp":"2026-06-15T23:11:58.261Z","type":"session_meta","payload":{"id":"root-thread","timestamp":"2026-06-15T23:11:52.984Z","cwd":"/tmp/work","thread_source":"user","model_provider":"paper-openai"}}"#,
)
.unwrap();
let got = read(&path).unwrap();
assert_eq!(got.session_id, "root-thread");
assert!(got.root_session_id.is_none());
assert!(got.parent_thread_id.is_none());
assert!(got.subagent_kind.is_none());
}
#[test]
fn subagent_kind_is_absent_when_the_source_names_none() {
assert_eq!(
subagent_kind_from_source(&serde_json::json!("cli")),
None,
"a bare-string source has no subagent object"
);
assert_eq!(
subagent_kind_from_source(&serde_json::json!({"subagent": {"agent_nickname": "Kant"}})),
None,
"no recognised kind key means no kind, not a guess",
);
assert_eq!(
subagent_kind_from_source(&serde_json::json!({"subagent": "guardian"})),
Some("guardian".to_owned()),
"a bare-string subagent is the kind itself",
);
}
#[test]
fn read_skips_malformed_rows_before_session_meta() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("rollout-test.jsonl");
std::fs::write(
&path,
r#"not json
{"timestamp":"2026-06-15T23:11:58.261Z","type":"session_meta","payload":{"id":"019ecd8e-4281-7353-8a00-09df678443b1","timestamp":"2026-06-15T23:11:52.984Z","cwd":"/tmp/work","model_provider":"paper-openai"}}"#,
)
.unwrap();
let got = read(&path).unwrap();
assert_eq!(got.session_id, "019ecd8e-4281-7353-8a00-09df678443b1");
}
}