use std::collections::{HashMap, HashSet};
use std::io::{BufRead, BufReader, Read};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use serde::Deserialize;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use super::{Agent, ObserveRequest, SessionEvent, SessionsRegistry};
const CODEX_HOME_ENV: &str = "CODEX_HOME";
const WATCH_INTERVAL: Duration = Duration::from_secs(5);
const RECENT_ACTIVITY_WINDOW: Duration = Duration::from_secs(300);
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(60);
const MAX_HEAD_BYTES: u64 = 1024 * 1024;
fn codex_home() -> Option<PathBuf> {
if let Some(dir) = std::env::var_os(CODEX_HOME_ENV).filter(|d| !d.is_empty()) {
return Some(PathBuf::from(dir));
}
dirs::home_dir().map(|home| home.join(".codex"))
}
#[derive(Debug, Deserialize)]
struct HeadLine {
#[serde(rename = "type")]
kind: String,
#[serde(default)]
payload: Option<SessionMeta>,
}
#[derive(Debug, Deserialize)]
struct SessionMeta {
#[serde(default)]
id: Option<String>,
#[serde(default)]
session_id: Option<String>,
#[serde(default)]
cwd: Option<PathBuf>,
#[serde(default)]
source: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Head {
Session { id: String, cwd: Option<PathBuf> },
Skip,
Incomplete,
}
fn parse_head(line: &str) -> Head {
let Ok(head) = serde_json::from_str::<HeadLine>(line) else {
return Head::Skip;
};
let Some(meta) = head.payload.filter(|_| head.kind == "session_meta") else {
return Head::Skip;
};
if meta
.source
.as_ref()
.is_some_and(serde_json::Value::is_object)
{
return Head::Skip;
}
match meta
.id
.or(meta.session_id)
.filter(|id| !id.trim().is_empty())
{
Some(id) => Head::Session { id, cwd: meta.cwd },
None => Head::Skip,
}
}
fn read_head(path: &Path) -> Head {
let Ok(file) = std::fs::File::open(path) else {
return Head::Skip;
};
let mut line = String::new();
let mut reader = BufReader::new(file.take(MAX_HEAD_BYTES));
match reader.read_line(&mut line) {
Ok(_) if line.ends_with('\n') => parse_head(&line),
Ok(n) if n as u64 >= MAX_HEAD_BYTES => Head::Skip,
Ok(_) => Head::Incomplete,
Err(_) => Head::Skip,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LockState {
Held,
Free,
Absent,
Unknown,
}
#[cfg(unix)]
fn probe_lock(locks: &Path, id: &str) -> LockState {
use nix::errno::Errno;
use nix::fcntl::{Flock, FlockArg};
let file = match std::fs::File::open(locks.join(format!("{id}.lock"))) {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound && locks.is_dir() => {
return LockState::Absent
}
Err(_) => return LockState::Unknown,
};
match Flock::lock(file, FlockArg::LockSharedNonblock) {
Ok(_guard) => LockState::Free,
Err((_, Errno::EWOULDBLOCK)) => LockState::Held,
Err(_) => LockState::Unknown,
}
}
#[cfg(not(unix))]
fn probe_lock(_locks: &Path, _id: &str) -> LockState {
LockState::Unknown
}
#[derive(Debug, Clone)]
enum Tracked {
Unread { size: u64 },
Skipped,
Session(SessionTrack),
}
#[derive(Debug, Clone)]
struct SessionTrack {
id: String,
cwd: Option<PathBuf>,
size: u64,
lock_seen: bool,
ended: bool,
last_report: SystemTime,
}
type ScanState = HashMap<PathBuf, Tracked>;
#[derive(Debug, Clone, PartialEq, Eq)]
enum Action {
Seen {
id: String,
cwd: Option<PathBuf>,
transcript_path: PathBuf,
},
End { id: String },
}
impl Action {
fn apply(self, registry: &SessionsRegistry) {
match self {
Self::Seen {
id,
cwd,
transcript_path,
} => registry.observe(ObserveRequest {
pid: None,
agent: Agent::Codex,
session_id: id,
cwd,
transcript_path: Some(transcript_path),
event: SessionEvent::TranscriptDiscovered,
repo: None,
model: None,
}),
Self::End { id } => {
registry.end(&id, Some("codex rollout watcher"), None);
}
}
}
}
fn is_recent(modified: SystemTime, now: SystemTime) -> bool {
now.duration_since(modified)
.map_or(true, |elapsed| elapsed <= RECENT_ACTIVITY_WINDOW)
}
fn list_rollouts(root: &Path) -> Vec<(PathBuf, u64, SystemTime)> {
let mut out = Vec::new();
let subdirs = |dir: &Path| -> Vec<PathBuf> {
std::fs::read_dir(dir)
.map(|entries| {
entries
.flatten()
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
.map(|e| e.path())
.collect()
})
.unwrap_or_default()
};
for year in subdirs(root) {
for month in subdirs(&year) {
for day in subdirs(&month) {
let Ok(files) = std::fs::read_dir(&day) else {
continue;
};
for file in files.flatten() {
let path = file.path();
let is_rollout = path.extension().is_some_and(|e| e == "jsonl")
&& path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("rollout-"));
if !is_rollout {
continue;
}
let Ok(meta) = file.metadata() else {
continue;
};
let modified = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
out.push((path, meta.len(), modified));
}
}
}
}
out
}
fn scan(
sessions: &Path,
locks: &Path,
state: &mut ScanState,
now: SystemTime,
probe: &dyn Fn(&Path, &str) -> LockState,
) -> Vec<Action> {
let mut actions = Vec::new();
let mut present = HashSet::new();
for (path, size, modified) in list_rollouts(sessions) {
present.insert(path.clone());
let recent = is_recent(modified, now);
let tracked = match state.remove(&path) {
None if !recent => Tracked::Unread { size },
None => first_read(&path, size, locks, now, probe, &mut actions),
Some(Tracked::Unread { size: old }) if size != old && recent => {
first_read(&path, size, locks, now, probe, &mut actions)
}
Some(Tracked::Unread { .. }) => Tracked::Unread { size },
Some(Tracked::Skipped) => Tracked::Skipped,
Some(Tracked::Session(track)) => {
Tracked::Session(rescan(track, &path, size, locks, now, probe, &mut actions))
}
};
state.insert(path, tracked);
}
state.retain(|path, tracked| {
if present.contains(path) {
return true;
}
if let Tracked::Session(track) = tracked {
if !track.ended {
actions.push(Action::End {
id: track.id.clone(),
});
}
}
false
});
actions
}
fn first_read(
path: &Path,
size: u64,
locks: &Path,
now: SystemTime,
probe: &dyn Fn(&Path, &str) -> LockState,
actions: &mut Vec<Action>,
) -> Tracked {
let (id, cwd) = match read_head(path) {
Head::Skip => return Tracked::Skipped,
Head::Incomplete => return Tracked::Unread { size },
Head::Session { id, cwd } => (id, cwd),
};
let lock = probe(locks, &id);
let gone = matches!(lock, LockState::Free | LockState::Absent);
if !gone {
actions.push(Action::Seen {
id: id.clone(),
cwd: cwd.clone(),
transcript_path: path.to_path_buf(),
});
}
Tracked::Session(SessionTrack {
id,
cwd,
size,
lock_seen: lock == LockState::Held,
ended: gone,
last_report: now,
})
}
fn rescan(
mut track: SessionTrack,
path: &Path,
size: u64,
locks: &Path,
now: SystemTime,
probe: &dyn Fn(&Path, &str) -> LockState,
actions: &mut Vec<Action>,
) -> SessionTrack {
let grew = size > track.size;
track.size = size;
if track.ended {
if !grew {
return track;
}
track.ended = false;
track.lock_seen = false;
track.last_report = SystemTime::UNIX_EPOCH;
}
let lock = probe(locks, &track.id);
if lock == LockState::Held {
track.lock_seen = true;
}
if track.lock_seen && matches!(lock, LockState::Free | LockState::Absent) {
track.ended = true;
actions.push(Action::End {
id: track.id.clone(),
});
return track;
}
let alive = grew || lock == LockState::Held;
let due = now
.duration_since(track.last_report)
.is_ok_and(|since| since >= HEARTBEAT_INTERVAL);
if alive && due {
track.last_report = now;
actions.push(Action::Seen {
id: track.id.clone(),
cwd: track.cwd.clone(),
transcript_path: path.to_path_buf(),
});
}
track
}
pub fn spawn(registry: Arc<SessionsRegistry>, token: CancellationToken) -> JoinHandle<()> {
tokio::spawn(async move {
let Some(home) = codex_home() else {
tracing::debug!("no Codex home; Codex rollout watcher idle");
token.cancelled().await;
return;
};
let sessions = home.join("sessions");
let locks = home.join("thread-writer-locks");
tracing::debug!("Codex rollout watcher scanning {}", sessions.display());
let mut state = ScanState::new();
loop {
let (scan_sessions, scan_locks) = (sessions.clone(), locks.clone());
let mut owned = std::mem::take(&mut state);
let (returned, actions) = tokio::task::spawn_blocking(move || {
let actions = scan(
&scan_sessions,
&scan_locks,
&mut owned,
SystemTime::now(),
&probe_lock,
);
(owned, actions)
})
.await
.unwrap_or_else(|_| (ScanState::new(), Vec::new()));
state = returned;
for action in actions {
action.apply(®istry);
}
tokio::select! {
() = token.cancelled() => break,
() = tokio::time::sleep(WATCH_INTERVAL) => {}
}
}
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::io::Write;
use crate::sessions::SessionState;
const ID: &str = "019a0000-0000-7000-8000-00000000c0de";
fn head(id: &str, source: serde_json::Value) -> String {
serde_json::json!({
"timestamp": "2026-09-25T00:00:00Z",
"type": "session_meta",
"payload": {
"id": id,
"session_id": id,
"cwd": "/work/repo",
"originator": "codex-tui",
"source": source,
"base_instructions": { "text": "x".repeat(2048) },
},
})
.to_string()
}
fn write_rollout(root: &Path, id: &str, lines: &[String]) -> PathBuf {
let dir = root.join("2026").join("09").join("25");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(format!("rollout-2026-09-25T00-00-00-{id}.jsonl"));
let mut f = std::fs::File::create(&path).unwrap();
for line in lines {
writeln!(f, "{line}").unwrap();
}
path
}
fn append(path: &Path, line: &str) {
let mut f = std::fs::OpenOptions::new().append(true).open(path).unwrap();
writeln!(f, "{line}").unwrap();
}
fn scripted(cell: &RefCell<LockState>) -> impl Fn(&Path, &str) -> LockState + '_ {
move |_, _| *cell.borrow()
}
fn ids(actions: &[Action]) -> Vec<String> {
actions
.iter()
.map(|a| match a {
Action::Seen { id, .. } => format!("seen:{id}"),
Action::End { id } => format!("end:{id}"),
})
.collect()
}
#[test]
fn parse_head_reads_a_session_and_skips_subagents_and_junk() {
assert_eq!(
parse_head(&head(ID, serde_json::json!("cli"))),
Head::Session {
id: ID.to_string(),
cwd: Some(PathBuf::from("/work/repo")),
}
);
let subagent = serde_json::json!({ "subagent": { "thread_spawn": {} } });
assert_eq!(parse_head(&head(ID, subagent)), Head::Skip);
assert_eq!(parse_head("not json"), Head::Skip);
assert_eq!(
parse_head(r#"{"type":"response_item","payload":{"id":"x"}}"#),
Head::Skip
);
assert_eq!(
parse_head(r#"{"type":"session_meta","payload":{"cwd":"/w"}}"#),
Head::Skip
);
assert_eq!(
parse_head(r#"{"type":"session_meta","payload":{"session_id":"s"}}"#),
Head::Session {
id: "s".to_string(),
cwd: None
}
);
}
#[test]
fn read_head_needs_a_complete_first_line() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("r.jsonl");
std::fs::write(&path, head(ID, serde_json::json!("vscode"))).unwrap();
assert_eq!(read_head(&path), Head::Incomplete);
std::fs::write(&path, "").unwrap();
assert_eq!(read_head(&path), Head::Incomplete);
std::fs::write(
&path,
format!("{}\n{{}}\n", head(ID, serde_json::json!("vscode"))),
)
.unwrap();
assert!(matches!(read_head(&path), Head::Session { .. }));
assert_eq!(read_head(&tmp.path().join("missing")), Head::Skip);
}
#[test]
fn a_recent_rollout_is_discovered_once_with_its_cwd() {
let tmp = tempfile::tempdir().unwrap();
let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
let lock = RefCell::new(LockState::Unknown);
let mut state = ScanState::new();
let now = SystemTime::now();
let first = scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock));
assert_eq!(
first,
vec![Action::Seen {
id: ID.to_string(),
cwd: Some(PathBuf::from("/work/repo")),
transcript_path: path,
}]
);
assert!(scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty());
}
#[test]
fn subagent_rollouts_and_non_rollout_files_are_never_reported() {
let tmp = tempfile::tempdir().unwrap();
let sub = serde_json::json!({ "subagent": "review" });
write_rollout(tmp.path(), "child", &[head("child", sub)]);
let dir = tmp.path().join("2026/09/25");
std::fs::write(dir.join("notes.jsonl"), "{}\n").unwrap();
std::fs::write(tmp.path().join("rollout-loose.jsonl"), "{}\n").unwrap();
let lock = RefCell::new(LockState::Held);
let mut state = ScanState::new();
let now = SystemTime::now();
assert!(scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty());
let later = now + HEARTBEAT_INTERVAL * 2;
assert!(scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock)).is_empty());
}
#[test]
fn an_old_rollout_is_recorded_silently_and_read_when_it_grows() {
let tmp = tempfile::tempdir().unwrap();
let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
let lock = RefCell::new(LockState::Unknown);
let mut state = ScanState::new();
let far = SystemTime::now() + RECENT_ACTIVITY_WINDOW * 10;
assert!(scan(tmp.path(), tmp.path(), &mut state, far, &scripted(&lock)).is_empty());
assert!(matches!(state[&path], Tracked::Unread { .. }));
append(&path, "{}");
let now = SystemTime::now();
assert_eq!(
ids(&scan(
tmp.path(),
tmp.path(),
&mut state,
now,
&scripted(&lock)
)),
vec![format!("seen:{ID}")]
);
}
#[test]
fn a_held_lock_heartbeats_and_its_release_ends_the_session() {
let tmp = tempfile::tempdir().unwrap();
write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
let lock = RefCell::new(LockState::Held);
let mut state = ScanState::new();
let t0 = SystemTime::now();
assert_eq!(
scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock)).len(),
1
);
let t1 = t0 + WATCH_INTERVAL;
assert!(scan(tmp.path(), tmp.path(), &mut state, t1, &scripted(&lock)).is_empty());
let t2 = t0 + HEARTBEAT_INTERVAL;
assert_eq!(
ids(&scan(
tmp.path(),
tmp.path(),
&mut state,
t2,
&scripted(&lock)
)),
vec![format!("seen:{ID}")]
);
*lock.borrow_mut() = LockState::Free;
assert_eq!(
ids(&scan(
tmp.path(),
tmp.path(),
&mut state,
t2,
&scripted(&lock)
)),
vec![format!("end:{ID}")]
);
let t3 = t2 + HEARTBEAT_INTERVAL;
assert!(scan(tmp.path(), tmp.path(), &mut state, t3, &scripted(&lock)).is_empty());
}
#[test]
fn a_lock_never_seen_held_does_not_end_the_session() {
let tmp = tempfile::tempdir().unwrap();
write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
let lock = RefCell::new(LockState::Unknown);
let mut state = ScanState::new();
let t0 = SystemTime::now();
scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
let later = t0 + HEARTBEAT_INTERVAL * 3;
assert!(scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock)).is_empty());
*lock.borrow_mut() = LockState::Held;
scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock));
*lock.borrow_mut() = LockState::Unknown;
let actions = scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock));
assert!(!ids(&actions).iter().any(|a| a.starts_with("end:")));
}
#[test]
fn growth_without_a_lock_is_a_heartbeat_not_a_state() {
let tmp = tempfile::tempdir().unwrap();
let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("exec"))]);
let lock = RefCell::new(LockState::Unknown);
let mut state = ScanState::new();
let t0 = SystemTime::now();
scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
append(&path, "{}");
let due = t0 + HEARTBEAT_INTERVAL;
assert_eq!(
ids(&scan(
tmp.path(),
tmp.path(),
&mut state,
due,
&scripted(&lock)
)),
vec![format!("seen:{ID}")]
);
}
#[test]
fn an_archived_rollout_ends_its_session_once() {
let tmp = tempfile::tempdir().unwrap();
let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("vscode"))]);
let lock = RefCell::new(LockState::Held);
let mut state = ScanState::new();
let now = SystemTime::now();
scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock));
let archived = tmp.path().join("archived.jsonl");
std::fs::rename(&path, archived).unwrap();
assert_eq!(
ids(&scan(
tmp.path(),
tmp.path(),
&mut state,
now,
&scripted(&lock)
)),
vec![format!("end:{ID}")]
);
assert!(state.is_empty());
assert!(scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty());
}
#[test]
fn a_resumed_session_is_reported_again_after_an_end() {
let tmp = tempfile::tempdir().unwrap();
let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
let lock = RefCell::new(LockState::Held);
let mut state = ScanState::new();
let t0 = SystemTime::now();
scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
*lock.borrow_mut() = LockState::Absent;
scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
*lock.borrow_mut() = LockState::Held;
append(&path, "{}");
let due = t0 + HEARTBEAT_INTERVAL;
assert_eq!(
ids(&scan(
tmp.path(),
tmp.path(),
&mut state,
due,
&scripted(&lock)
)),
vec![format!("seen:{ID}")]
);
}
#[test]
fn a_head_still_being_written_is_read_once_it_is_complete() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("2026/09/25");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(format!("rollout-2026-09-25T00-00-00-{ID}.jsonl"));
let full = head(ID, serde_json::json!("cli"));
std::fs::write(&path, &full[..full.len() / 2]).unwrap();
let lock = RefCell::new(LockState::Held);
let mut state = ScanState::new();
let now = SystemTime::now();
assert!(scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty());
assert!(matches!(state[&path], Tracked::Unread { .. }));
std::fs::write(&path, format!("{full}\n")).unwrap();
assert_eq!(
ids(&scan(
tmp.path(),
tmp.path(),
&mut state,
now,
&scripted(&lock)
)),
vec![format!("seen:{ID}")]
);
}
#[test]
fn a_restart_does_not_list_a_session_whose_lock_is_already_released() {
for gone in [LockState::Free, LockState::Absent] {
let tmp = tempfile::tempdir().unwrap();
let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
let lock = RefCell::new(gone);
let mut state = ScanState::new();
let now = SystemTime::now();
assert!(
scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty(),
"{gone:?}"
);
let later = now + HEARTBEAT_INTERVAL * 2;
assert!(scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock)).is_empty());
*lock.borrow_mut() = LockState::Held;
append(&path, "{}");
assert_eq!(
ids(&scan(
tmp.path(),
tmp.path(),
&mut state,
later,
&scripted(&lock)
)),
vec![format!("seen:{ID}")]
);
}
}
#[test]
fn scanning_a_missing_root_is_empty() {
let lock = RefCell::new(LockState::Unknown);
let mut state = ScanState::new();
let missing = Path::new("/nonexistent/codex/sessions");
assert!(scan(
missing,
missing,
&mut state,
SystemTime::now(),
&scripted(&lock)
)
.is_empty());
}
#[cfg(unix)]
#[test]
fn probe_lock_tells_held_from_free_and_absent() {
use nix::fcntl::{Flock, FlockArg};
let tmp = tempfile::tempdir().unwrap();
assert_eq!(
probe_lock(&tmp.path().join("missing"), ID),
LockState::Unknown
);
assert_eq!(probe_lock(tmp.path(), ID), LockState::Absent);
let path = tmp.path().join(format!("{ID}.lock"));
std::fs::write(&path, "").unwrap();
assert_eq!(probe_lock(tmp.path(), ID), LockState::Free);
let holder = Flock::lock(
std::fs::File::open(&path).unwrap(),
FlockArg::LockExclusiveNonblock,
)
.unwrap();
assert_eq!(probe_lock(tmp.path(), ID), LockState::Held);
assert_eq!(probe_lock(tmp.path(), ID), LockState::Held);
drop(holder);
assert_eq!(probe_lock(tmp.path(), ID), LockState::Free);
}
#[test]
fn actions_apply_to_the_registry_as_codex_sessions() {
let registry = SessionsRegistry::new();
Action::Seen {
id: ID.to_string(),
cwd: Some(PathBuf::from("/work/repo")),
transcript_path: PathBuf::from("/r.jsonl"),
}
.apply(®istry);
let listed = registry.list();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].agent, Agent::Codex);
assert_eq!(listed[0].state, SessionState::Idle);
assert_eq!(listed[0].cwd.as_deref(), Some(Path::new("/work/repo")));
Action::End { id: ID.to_string() }.apply(®istry);
assert_eq!(registry.list()[0].state, SessionState::Ended);
}
}