use std::collections::{HashMap, HashSet};
use std::io::{BufRead as _, Read as _};
use std::path::{Component, Path};
use std::sync::{Mutex, OnceLock};
use serde_json::Value;
const MAX_SCAN_BYTES: u64 = 1024 * 1024;
const MAX_SCAN_LINES: usize = 64;
const MAX_REMEMBERED: usize = 4096;
#[derive(Debug, Clone, PartialEq, Eq)]
enum Answer {
Version(String),
NotSessionMeta,
}
#[derive(Default)]
struct Cache {
answers: HashMap<String, Answer>,
reading: HashSet<String>,
}
fn cache() -> &'static Mutex<Cache> {
static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
CACHE.get_or_init(Mutex::default)
}
pub fn version_for(data: &Value) -> Option<String> {
let path = data.get("transcript_path").and_then(Value::as_str)?;
if !plausible_session_file(Path::new(path)) {
return None;
}
let mut cache = cache().lock().unwrap_or_else(|e| e.into_inner());
match cache.answers.get(path) {
Some(Answer::Version(v)) => return Some(v.clone()),
Some(Answer::NotSessionMeta) => return None,
None => {}
}
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
return None;
};
if !cache.reading.insert(path.to_string()) {
return None;
}
drop(cache);
let path = path.to_string();
runtime.spawn_blocking(move || {
let answer = read_answer(Path::new(&path));
remember(path, answer);
});
None
}
fn remember(path: String, answer: Option<Answer>) {
let mut cache = cache().lock().unwrap_or_else(|e| e.into_inner());
cache.reading.remove(&path);
let Some(answer) = answer else {
return;
};
if cache.answers.len() >= MAX_REMEMBERED {
cache.answers.clear();
}
cache.answers.insert(path, answer);
}
fn plausible_session_file(path: &Path) -> bool {
path.is_absolute()
&& path.components().all(|c| {
matches!(
c,
Component::RootDir | Component::Prefix(_) | Component::Normal(_)
)
})
&& path.extension().is_some_and(|ext| ext == "jsonl")
}
fn read_answer(path: &Path) -> Option<Answer> {
if !std::fs::metadata(path).ok()?.is_file() {
return Some(Answer::NotSessionMeta);
}
let file = std::fs::File::open(path).ok()?;
let mut reader = std::io::BufReader::new(file.take(MAX_SCAN_BYTES));
let mut read = 0u64;
let mut line = Vec::new();
for _ in 0..MAX_SCAN_LINES {
line.clear();
let n = reader.read_until(b'\n', &mut line).ok()?;
read += n as u64;
if !line.ends_with(b"\n") {
return (read >= MAX_SCAN_BYTES).then_some(Answer::NotSessionMeta);
}
if let Some(version) = version_in(&line) {
return Some(Answer::Version(version));
}
}
Some(Answer::NotSessionMeta)
}
fn version_in(line: &[u8]) -> Option<String> {
let record: Value = serde_json::from_slice(line).ok()?;
let version = if record.get("type").and_then(Value::as_str) == Some("session_meta") {
record.get("payload")?.get("cli_version")?.as_str()?
} else {
record.get("sessionId")?.as_str()?;
record.get("version")?.as_str()?
};
let version = version.trim();
crate::hooks::agent_versions::looks_like_version(version).then(|| version.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn session_file(dir: &Path, first_line: &str) -> String {
let path = dir.join("rollout-2026-10-20T13-14-35-s.jsonl");
std::fs::write(&path, format!("{first_line}\n{{\"type\":\"event_msg\"}}\n"))
.expect("session file");
path.display().to_string()
}
const CODEX_META: &str = r#"{"timestamp":"2026-10-20T11:14:35Z","type":"session_meta","payload":{"id":"s","originator":"codex_exec","cli_version":"0.130.0","source":"exec","instructions":"…"}}"#;
#[test]
fn a_session_meta_first_line_names_the_version() {
let dir = tempfile::tempdir().expect("dir");
let path = session_file(dir.path(), CODEX_META);
assert_eq!(
read_answer(Path::new(&path)),
Some(Answer::Version("0.130.0".into()))
);
}
#[test]
fn a_claude_code_transcript_names_its_version_a_few_lines_in() {
let dir = tempfile::tempdir().expect("dir");
let path = dir.path().join("8f2c1e4a.jsonl");
std::fs::write(
&path,
concat!(
"{\"type\":\"last-prompt\",\"sessionId\":\"s\"}\n",
"{\"type\":\"permission-mode\",\"sessionId\":\"s\"}\n",
"{\"type\":\"attachment\",\"sessionId\":\"s\",\"version\":\"2.1.304\"}\n",
),
)
.expect("transcript");
assert_eq!(read_answer(&path), Some(Answer::Version("2.1.304".into())));
let other = dir.path().join("other.jsonl");
std::fs::write(&other, "{\"version\":\"1.0.0\"}\n").expect("other");
assert_eq!(read_answer(&other), None);
}
#[test]
fn another_shape_answers_nothing_and_is_remembered_once_the_scan_passes() {
let dir = tempfile::tempdir().expect("dir");
let path = session_file(dir.path(), r#"{"type":"user","message":{"role":"user"}}"#);
assert_eq!(read_answer(Path::new(&path)), None, "short: asked again");
let long = dir.path().join("long.jsonl");
std::fs::write(&long, "{\"type\":\"user\"}\n".repeat(MAX_SCAN_LINES + 1)).expect("long");
assert_eq!(read_answer(&long), Some(Answer::NotSessionMeta));
let junk = dir.path().join("junk.jsonl");
std::fs::write(&junk, "not json at all\n".repeat(MAX_SCAN_LINES)).expect("junk");
assert_eq!(read_answer(&junk), Some(Answer::NotSessionMeta));
}
#[test]
fn a_partial_line_is_not_read_as_a_record() {
let dir = tempfile::tempdir().expect("dir");
let path = dir.path().join("partial.jsonl");
std::fs::write(
&path,
r#"{"type":"session_meta","payload":{"cli_version":"0.130.0"}}"#,
)
.expect("partial");
assert_eq!(read_answer(&path), None);
}
#[test]
fn a_file_not_written_yet_is_asked_again() {
let dir = tempfile::tempdir().expect("dir");
assert_eq!(read_answer(&dir.path().join("absent.jsonl")), None);
let empty = dir.path().join("empty.jsonl");
std::fs::write(&empty, "").expect("empty");
assert_eq!(read_answer(&empty), None);
}
#[test]
fn a_value_that_is_not_a_version_is_refused() {
for bad in ["", " ", "0.130.0; rm -rf /", &"9".repeat(65)] {
let line = json!({"type": "session_meta", "payload": {"cli_version": bad}});
assert_eq!(version_in(line.to_string().as_bytes()), None, "{bad:?}");
}
let line = json!({"type": "session_meta", "payload": {"cli_version": "0.164.0-alpha.6"}});
assert_eq!(
version_in(line.to_string().as_bytes()).as_deref(),
Some("0.164.0-alpha.6")
);
}
#[test]
fn an_oversized_first_line_answers_nothing() {
let dir = tempfile::tempdir().expect("dir");
let huge = format!(
r#"{{"type":"session_meta","payload":{{"instructions":"{}","cli_version":"0.130.0"}}}}"#,
"x".repeat(MAX_SCAN_BYTES as usize)
);
let path = session_file(dir.path(), &huge);
assert_eq!(read_answer(Path::new(&path)), Some(Answer::NotSessionMeta));
}
#[test]
fn a_path_from_the_payload_is_checked_before_it_is_read() {
let dir = tempfile::tempdir().expect("dir");
assert!(!plausible_session_file(Path::new("relative/s.jsonl")));
assert!(!plausible_session_file(
&dir.path().join("..").join("s.jsonl")
));
assert!(!plausible_session_file(&dir.path().join("passwd")));
assert!(plausible_session_file(&dir.path().join("rollout-s.jsonl")));
}
#[tokio::test]
async fn the_first_ask_schedules_and_a_later_one_answers() {
let dir = tempfile::tempdir().expect("dir");
let path = session_file(dir.path(), CODEX_META);
let data = json!({"transcript_path": path, "hook_event_name": "PreToolUse"});
assert_eq!(version_for(&data), None, "never blocks on the file");
let mut answered = None;
for _ in 0..200 {
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
answered = version_for(&data);
if answered.is_some() {
break;
}
}
assert_eq!(answered.as_deref(), Some("0.130.0"));
assert_eq!(version_for(&json!({"tool_name": "Bash"})), None);
}
}