use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use chrono::{DateTime, Utc};
use serde::Serialize;
fn older_than(path: &Path, after: Option<SystemTime>) -> bool {
let Some(after) = after else {
return false;
};
fs::metadata(path)
.and_then(|m| m.modified())
.map(|mtime| mtime < after)
.unwrap_or(false)
}
fn nests_under(child: &str, parent: &str, sep: char) -> bool {
child
.strip_prefix(parent)
.is_some_and(|rest| rest.is_empty() || rest.starts_with(sep))
}
fn cwd_nests(here: &str, repo: &str) -> bool {
let (here, repo) = (here.to_lowercase(), repo.to_lowercase());
let (here, repo) = (here.trim_end_matches('/'), repo.trim_end_matches('/'));
nests_under(here, repo, '/') || nests_under(repo, here, '/')
}
fn cwd_nests_encoded(here: &str, repo: &str) -> bool {
let here = here.trim_end_matches('/').replace('/', "-").to_lowercase();
let repo = repo.trim_end_matches('/').replace('/', "-").to_lowercase();
nests_under(&here, &repo, '-') || nests_under(&repo, &here, '-')
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Association {
Exact,
Inferred,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TemporalConfidence {
Full,
Partial,
None,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SessionInfo {
pub session_id: String,
pub agent: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub started_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<DateTime<Utc>>,
pub message_count: usize,
pub user_message_count: usize,
pub agent_message_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub source_path: PathBuf,
pub association: Association,
pub temporal_confidence: TemporalConfidence,
}
fn decode_claude_project_dir(dir_name: &str) -> Option<String> {
if !dir_name.starts_with('-') {
return None;
}
Some(dir_name.replace('-', "/"))
}
fn project_label_from_cwd(cwd: &str) -> Option<String> {
cwd.trim_end_matches(['/', '\\'])
.rsplit(['/', '\\'])
.find(|seg| !seg.is_empty())
.map(str::to_string)
}
pub fn discover_claude_sessions(
projects_root: &Path,
modified_after: Option<SystemTime>,
cwd_filter: Option<&str>,
) -> Vec<SessionInfo> {
let mut out = Vec::new();
let mut skipped = 0usize;
let Ok(dirs) = fs::read_dir(projects_root) else {
return out;
};
for dir_entry in dirs.flatten() {
let dir_path = dir_entry.path();
if !dir_path.is_dir() {
continue;
}
let dir_name = dir_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string();
let decoded_cwd = decode_claude_project_dir(&dir_name);
if let Some(want) = cwd_filter {
let want_enc = want.replace('/', "-").to_lowercase();
let dir_lc = dir_name.to_lowercase();
if !nests_under(&dir_lc, &want_enc, '-') && !nests_under(&want_enc, &dir_lc, '-') {
continue;
}
}
let Ok(files) = fs::read_dir(&dir_path) else {
continue;
};
for file_entry in files.flatten() {
let path = file_entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
if older_than(&path, modified_after) {
continue;
}
match scan_claude_session_file(&path, decoded_cwd.as_deref()) {
Some(info) => out.push(info),
None => skipped += 1,
}
}
}
if skipped > 0 {
eprintln!("aicx: sessions: skipped {skipped} unreadable file(s) (claude)");
}
out
}
fn scan_claude_session_file(path: &Path, decoded_cwd: Option<&str>) -> Option<SessionInfo> {
let file = fs::File::open(path).ok()?;
let reader = BufReader::new(file);
let session_id = path.file_stem().and_then(|s| s.to_str())?.to_string();
let mut started_at: Option<DateTime<Utc>> = None;
let mut updated_at: Option<DateTime<Utc>> = None;
let mut message_count = 0usize;
let mut user_message_count = 0usize;
let mut agent_message_count = 0usize;
let mut recorded_cwd: Option<String> = None;
let mut title: Option<String> = None;
let mut saw_parsable_line = false;
for line in reader.lines().map_while(Result::ok) {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
saw_parsable_line = true;
if recorded_cwd.is_none()
&& let Some(cwd) = value.get("cwd").and_then(|v| v.as_str())
&& !cwd.trim().is_empty()
{
recorded_cwd = Some(cwd.to_string());
}
if let Some(ts) = value
.get("timestamp")
.and_then(|v| v.as_str())
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
{
started_at = Some(started_at.map_or(ts, |cur| cur.min(ts)));
updated_at = Some(updated_at.map_or(ts, |cur| cur.max(ts)));
}
let role = value.get("type").and_then(|v| v.as_str()).or_else(|| {
value
.get("message")
.and_then(|m| m.get("role"))
.and_then(|v| v.as_str())
});
match role {
Some("user") => {
message_count += 1;
user_message_count += 1;
if title.is_none()
&& let Some(text) = value
.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_str())
&& !text.trim().is_empty()
{
title = Some(short_title(text));
}
}
Some("assistant") => {
message_count += 1;
agent_message_count += 1;
}
_ => {}
}
}
let (repo_path, association) = match (recorded_cwd, decoded_cwd) {
(Some(cwd), _) => (Some(cwd), Association::Exact),
(None, Some(decoded)) => (Some(decoded.to_string()), Association::Inferred),
(None, None) => (None, Association::Unknown),
};
let project = repo_path.as_deref().and_then(project_label_from_cwd);
let temporal_confidence = if started_at.is_some() {
TemporalConfidence::Full
} else if saw_parsable_line {
TemporalConfidence::Partial
} else {
TemporalConfidence::None
};
Some(SessionInfo {
session_id,
agent: "claude".to_string(),
project,
repo_path,
started_at,
updated_at,
message_count,
user_message_count,
agent_message_count,
title,
source_path: path.to_path_buf(),
association,
temporal_confidence,
})
}
const MAX_CODEX_SCAN_DEPTH: usize = 16;
pub fn discover_codex_sessions(
sessions_root: &Path,
modified_after: Option<SystemTime>,
) -> Vec<SessionInfo> {
let mut out = Vec::new();
let mut skipped = 0usize;
let mut stack = vec![(sessions_root.to_path_buf(), 0usize)];
while let Some((dir, depth)) = stack.pop() {
let Ok(read) = fs::read_dir(&dir) else {
continue;
};
for entry in read.flatten() {
let Ok(file_type) = entry.file_type() else {
skipped += 1;
continue;
};
let path = entry.path();
if file_type.is_dir() {
if depth < MAX_CODEX_SCAN_DEPTH {
stack.push((path, depth + 1));
}
} else if file_type.is_file()
&& path.extension().and_then(|e| e.to_str()) == Some("jsonl")
&& !older_than(&path, modified_after)
{
match scan_codex_session_file(&path) {
Some(info) => out.push(info),
None => skipped += 1,
}
}
}
}
if skipped > 0 {
eprintln!("aicx: sessions: skipped {skipped} unreadable file(s) (codex)");
}
out
}
fn scan_codex_session_file(path: &Path) -> Option<SessionInfo> {
let file = fs::File::open(path).ok()?;
let reader = BufReader::new(file);
let mut session_id: Option<String> = None;
let mut cwd: Option<String> = None;
let mut started_at: Option<DateTime<Utc>> = None;
let mut updated_at: Option<DateTime<Utc>> = None;
let mut user_message_count = 0usize;
let mut agent_message_count = 0usize;
let mut title: Option<String> = None;
let mut saw_parsable_line = false;
for line in reader.lines().map_while(Result::ok) {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
saw_parsable_line = true;
if let Some(ts) = v
.get("timestamp")
.and_then(|t| t.as_str())
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|d| d.with_timezone(&Utc))
{
started_at = Some(started_at.map_or(ts, |c| c.min(ts)));
updated_at = Some(updated_at.map_or(ts, |c| c.max(ts)));
}
let typ = v.get("type").and_then(|t| t.as_str());
let payload = v.get("payload");
if typ == Some("session_meta") {
if session_id.is_none() {
session_id = payload
.and_then(|p| p.get("id"))
.and_then(|i| i.as_str())
.map(String::from);
}
if cwd.is_none() {
cwd = payload
.and_then(|p| p.get("cwd"))
.and_then(|c| c.as_str())
.map(String::from);
}
continue;
}
if typ == Some("response_item")
&& payload.and_then(|p| p.get("type")).and_then(|t| t.as_str()) == Some("message")
{
match payload.and_then(|p| p.get("role")).and_then(|r| r.as_str()) {
Some("user") => {
user_message_count += 1;
if title.is_none() {
title = codex_message_text(payload).map(|t| short_title(&t));
}
}
Some("assistant") => agent_message_count += 1,
_ => {}
}
}
}
let session_id = session_id.unwrap_or_else(|| {
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string()
});
let project = cwd.as_deref().and_then(project_label_from_cwd);
let (repo_path, association) = match cwd {
Some(c) => (Some(c), Association::Exact),
None => (None, Association::Unknown),
};
let temporal_confidence = if started_at.is_some() {
TemporalConfidence::Full
} else if saw_parsable_line {
TemporalConfidence::Partial
} else {
TemporalConfidence::None
};
Some(SessionInfo {
session_id,
agent: "codex".to_string(),
project,
repo_path,
started_at,
updated_at,
message_count: user_message_count + agent_message_count,
user_message_count,
agent_message_count,
title,
source_path: path.to_path_buf(),
association,
temporal_confidence,
})
}
pub fn discover_grok_sessions(
sessions_root: &Path,
modified_after: Option<SystemTime>,
) -> Vec<SessionInfo> {
let mut sessions = discover_codex_sessions(sessions_root, modified_after);
for s in &mut sessions {
s.agent = "grok".to_string();
}
sessions
}
fn codex_message_text(payload: Option<&serde_json::Value>) -> Option<String> {
let content = payload?.get("content")?;
if let Some(s) = content.as_str() {
return Some(s.to_string());
}
content.as_array()?.iter().find_map(|part| {
part.get("text")
.and_then(|t| t.as_str())
.filter(|t| !t.trim().is_empty())
.map(String::from)
})
}
pub fn discover_gemini_sessions(
tmp_root: &Path,
modified_after: Option<SystemTime>,
cwd_filter: Option<&str>,
) -> Vec<SessionInfo> {
let mut out = Vec::new();
let mut skipped = 0usize;
let Ok(dirs) = fs::read_dir(tmp_root) else {
return out;
};
let want_base = cwd_filter
.and_then(project_label_from_cwd)
.map(|s| s.to_lowercase());
for dir_entry in dirs.flatten() {
let proj_dir = dir_entry.path();
if !proj_dir.is_dir() {
continue;
}
let dir_name = proj_dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string();
if let Some(want) = &want_base
&& dir_name.to_lowercase() != *want
{
continue;
}
let chats = proj_dir.join("chats");
let Ok(files) = fs::read_dir(&chats) else {
continue;
};
for file_entry in files.flatten() {
let path = file_entry.path();
let ext = path.extension().and_then(|e| e.to_str());
if (ext != Some("json") && ext != Some("jsonl")) || older_than(&path, modified_after) {
continue;
}
match scan_gemini_session_file(&path, &dir_name) {
Some(info) => out.push(info),
None => skipped += 1,
}
}
}
if skipped > 0 {
eprintln!("aicx: sessions: skipped {skipped} unreadable file(s) (gemini)");
}
out
}
fn scan_gemini_session_file(path: &Path, dir_name: &str) -> Option<SessionInfo> {
let file = fs::File::open(path).ok()?;
let reader = BufReader::new(file);
let v: serde_json::Value = serde_json::from_reader(reader).ok()?;
let session_id = v
.get("sessionId")
.and_then(|s| s.as_str())
.map(String::from)
.unwrap_or_else(|| {
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string()
});
let parse_ts = |key: &str| {
v.get(key)
.and_then(|s| s.as_str())
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|d| d.with_timezone(&Utc))
};
let started_at = parse_ts("startTime");
let updated_at = parse_ts("lastUpdated").or(started_at);
let mut user_message_count = 0usize;
let mut agent_message_count = 0usize;
let mut title = None;
let mut messages_have_time = false;
if let Some(msgs) = v.get("messages").and_then(|m| m.as_array()) {
for m in msgs {
if !messages_have_time
&& m.get("timestamp")
.and_then(|t| t.as_str())
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.is_some()
{
messages_have_time = true;
}
match m.get("type").and_then(|t| t.as_str()) {
Some("user") => {
user_message_count += 1;
if title.is_none()
&& let Some(t) = m
.get("content")
.and_then(|c| c.as_str())
.filter(|t| !t.trim().is_empty())
{
title = Some(short_title(t));
}
}
Some("gemini") | Some("model") | Some("assistant") => agent_message_count += 1,
_ => {}
}
}
}
let is_hash = dir_name.len() == 64 && dir_name.chars().all(|c| c.is_ascii_hexdigit());
let project = if is_hash || dir_name.is_empty() {
None
} else {
Some(dir_name.to_string())
};
let association = if project.is_some() {
Association::Inferred
} else {
Association::Unknown
};
let temporal_confidence = if started_at.is_some() {
TemporalConfidence::Full
} else if updated_at.is_some() || messages_have_time {
TemporalConfidence::Partial
} else {
TemporalConfidence::None
};
Some(SessionInfo {
session_id,
agent: "gemini".to_string(),
project,
repo_path: None,
started_at,
updated_at,
message_count: user_message_count + agent_message_count,
user_message_count,
agent_message_count,
title,
source_path: path.to_path_buf(),
association,
temporal_confidence,
})
}
const JUNIE_SESSION_DIR_PREFIX: &str = "session-";
const JUNIE_EVENTS_FILENAME: &str = "events.jsonl";
fn parse_compact_junie_timestamp(compact_date: &str, compact_time: &str) -> Option<DateTime<Utc>> {
if compact_date.len() != 6 || compact_time.len() != 6 {
return None;
}
if !compact_date.is_ascii() || !compact_time.is_ascii() {
return None;
}
let year = 2000 + compact_date[0..2].parse::<i32>().ok()?;
let month = compact_date[2..4].parse::<u32>().ok()?;
let day = compact_date[4..6].parse::<u32>().ok()?;
let hour = compact_time[0..2].parse::<u32>().ok()?;
let minute = compact_time[2..4].parse::<u32>().ok()?;
let second = compact_time[4..6].parse::<u32>().ok()?;
let naive =
chrono::NaiveDate::from_ymd_opt(year, month, day)?.and_hms_opt(hour, minute, second)?;
Some(DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc))
}
fn parse_junie_compact_pair(s: &str) -> Option<DateTime<Utc>> {
let mut parts = s.split('-');
let compact_date = parts.next()?;
let compact_time = parts.next()?;
parse_compact_junie_timestamp(compact_date, compact_time)
}
pub fn discover_junie_sessions(
sessions_root: &Path,
modified_after: Option<SystemTime>,
) -> Vec<SessionInfo> {
let mut out = Vec::new();
let mut skipped = 0usize;
let Ok(dirs) = fs::read_dir(sessions_root) else {
return out;
};
for dir_entry in dirs.flatten() {
let dir_path = dir_entry.path();
if !dir_path.is_dir() {
continue;
}
let Some(session_id) = dir_path
.file_name()
.and_then(|n| n.to_str())
.and_then(|n| n.strip_prefix(JUNIE_SESSION_DIR_PREFIX))
.filter(|id| !id.is_empty())
else {
continue;
};
let events = dir_path.join(JUNIE_EVENTS_FILENAME);
if !events.is_file() || older_than(&events, modified_after) {
continue;
}
match scan_junie_session_file(&events, session_id) {
Some(info) => out.push(info),
None => skipped += 1,
}
}
if skipped > 0 {
eprintln!("aicx: sessions: skipped {skipped} unreadable file(s) (junie)");
}
out
}
fn scan_junie_session_file(path: &Path, session_id: &str) -> Option<SessionInfo> {
let file = fs::File::open(path).ok()?;
let reader = BufReader::new(file);
let anchor = parse_junie_compact_pair(session_id);
let mut started_at: Option<DateTime<Utc>> = anchor;
let mut updated_at: Option<DateTime<Utc>> = anchor;
let mut user_message_count = 0usize;
let mut agent_message_count = 0usize;
let mut title: Option<String> = None;
let mut recorded_cwd: Option<String> = None;
let mut saw_parsable_line = false;
let mut last_result_render: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for line in reader.lines().map_while(Result::ok) {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
saw_parsable_line = true;
match v.get("kind").and_then(|k| k.as_str()) {
Some("UserPromptEvent") => {
let prompt = v
.get("prompt")
.and_then(|p| p.as_str())
.map(str::trim)
.filter(|t| !t.is_empty());
if let Some(ts) = v
.get("requestId")
.and_then(|r| r.as_str())
.and_then(|r| r.strip_prefix("prompt-"))
.and_then(parse_junie_compact_pair)
{
started_at = Some(started_at.map_or(ts, |c| c.min(ts)));
updated_at = Some(updated_at.map_or(ts, |c| c.max(ts)));
}
let is_meta = v
.get("customAttachments")
.and_then(|a| a.as_array())
.is_some_and(|attachments| {
attachments.iter().any(|attachment| {
matches!(
attachment.get("kind").and_then(|k| k.as_str()),
Some("PlanAttachment" | "ContinueTaskAttachment")
)
})
});
if !is_meta && let Some(text) = prompt {
user_message_count += 1;
if title.is_none() {
title = Some(short_title(text));
}
}
}
Some("UserResponseEvent") => {
if v.get("prompt")
.and_then(|p| p.as_str())
.is_some_and(|t| !t.trim().is_empty())
{
user_message_count += 1;
}
}
_ => {
let Some(agent_event) = v.get("event").and_then(|e| e.get("agentEvent")) else {
continue;
};
match agent_event.get("kind").and_then(|k| k.as_str()) {
Some("CurrentDirectoryUpdatedEvent") => {
if recorded_cwd.is_none()
&& let Some(cwd) = agent_event
.get("currentDirectory")
.and_then(|c| c.as_str())
.map(str::trim)
.filter(|c| !c.is_empty())
{
recorded_cwd = Some(cwd.to_string());
}
}
Some("ResultBlockUpdatedEvent") => {
let Some(result) = agent_event
.get("result")
.and_then(|r| r.as_str())
.map(str::trim)
.filter(|r| !r.is_empty())
else {
continue;
};
let step_id = agent_event
.get("stepId")
.and_then(|s| s.as_str())
.unwrap_or("(no-step)")
.to_string();
if last_result_render
.get(&step_id)
.is_some_and(|prev| prev == result)
{
continue;
}
last_result_render.insert(step_id, result.to_string());
agent_message_count += 1;
}
_ => {}
}
}
}
}
let project = recorded_cwd.as_deref().and_then(project_label_from_cwd);
let (repo_path, association) = match recorded_cwd {
Some(c) => (Some(c), Association::Exact),
None => (None, Association::Unknown),
};
let temporal_confidence = if started_at.is_some() {
TemporalConfidence::Full
} else if saw_parsable_line {
TemporalConfidence::Partial
} else {
TemporalConfidence::None
};
Some(SessionInfo {
session_id: session_id.to_string(),
agent: "junie".to_string(),
project,
repo_path,
started_at,
updated_at,
message_count: user_message_count + agent_message_count,
user_message_count,
agent_message_count,
title,
source_path: path.to_path_buf(),
association,
temporal_confidence,
})
}
pub fn discover_sessions_at(
home: &Path,
modified_after: Option<SystemTime>,
cwd_filter: Option<&str>,
agent: Option<&str>,
) -> Vec<SessionInfo> {
let mut discovered = Vec::new();
if agent.is_none_or(|a| a == "claude") {
discovered.extend(discover_claude_sessions(
&home.join(".claude").join("projects"),
modified_after,
cwd_filter,
));
}
if agent.is_none_or(|a| a == "codex") {
discovered.extend(discover_codex_sessions(
&home.join(".codex").join("sessions"),
modified_after,
));
}
if agent.is_none_or(|a| a == "grok") {
discovered.extend(discover_grok_sessions(
&home.join(".grok").join("sessions"),
modified_after,
));
}
if agent.is_none_or(|a| a == "gemini") {
discovered.extend(discover_gemini_sessions(
&home.join(".gemini").join("tmp"),
modified_after,
cwd_filter,
));
}
if agent.is_none_or(|a| a == "junie") {
discovered.extend(discover_junie_sessions(
&home.join(".junie").join("sessions"),
modified_after,
));
}
discovered
}
fn codex_rollout_id_segment(stem: &str) -> Option<&str> {
let rest = stem.strip_prefix("rollout-")?;
let ts = rest.get(..19)?;
if rest.as_bytes().get(19) != Some(&b'-')
|| !ts
.chars()
.all(|c| c.is_ascii_digit() || c == '-' || c == 'T')
{
return None;
}
rest.get(20..).filter(|id| !id.is_empty())
}
pub fn find_session_by_id(home: &Path, id: &str) -> Option<SessionInfo> {
let claude_root = home.join(".claude").join("projects");
if let Ok(dirs) = fs::read_dir(&claude_root) {
let mut candidates: Vec<(PathBuf, Option<String>)> = Vec::new();
for d in dirs.flatten() {
let dp = d.path();
if !dp.is_dir() {
continue;
}
let decoded = decode_claude_project_dir(
dp.file_name().and_then(|n| n.to_str()).unwrap_or_default(),
);
let Ok(files) = fs::read_dir(&dp) else {
continue;
};
for f in files.flatten() {
let p = f.path();
if p.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or_default();
if stem.starts_with(id) {
candidates.push((p, decoded.clone()));
}
}
}
candidates.sort();
if candidates.len() > 1 {
eprintln!(
"aicx: session: id '{id}' matches {} claude sessions; using the first (sorted)",
candidates.len()
);
}
for (p, decoded) in candidates {
if let Some(info) = scan_claude_session_file(&p, decoded.as_deref()) {
return Some(info);
}
}
}
let mut candidates: Vec<PathBuf> = Vec::new();
let mut stack = vec![(home.join(".codex").join("sessions"), 0usize)];
while let Some((dir, depth)) = stack.pop() {
let Ok(read) = fs::read_dir(&dir) else {
continue;
};
for e in read.flatten() {
let Ok(file_type) = e.file_type() else {
continue;
};
let p = e.path();
if file_type.is_dir() {
if depth < MAX_CODEX_SCAN_DEPTH {
stack.push((p, depth + 1));
}
continue;
}
if !file_type.is_file() || p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
continue;
}
let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or_default();
let matched = codex_rollout_id_segment(stem)
.map_or_else(|| stem.starts_with(id), |seg| seg.starts_with(id));
if matched {
candidates.push(p);
}
}
}
candidates.sort();
if candidates.len() > 1 {
eprintln!(
"aicx: session: id '{id}' matches {} codex rollouts; using the first (sorted)",
candidates.len()
);
}
for p in candidates {
if let Some(info) = scan_codex_session_file(&p) {
return Some(info);
}
}
let gemini_root = home.join(".gemini").join("tmp");
if let Ok(dirs) = fs::read_dir(&gemini_root) {
let mut candidates: Vec<SessionInfo> = Vec::new();
for d in dirs.flatten() {
let dir_name = d
.path()
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string();
let Ok(files) = fs::read_dir(d.path().join("chats")) else {
continue;
};
for f in files.flatten() {
let p = f.path();
let ext = p.extension().and_then(|x| x.to_str());
if ext != Some("json") && ext != Some("jsonl") {
continue;
}
if let Some(info) = scan_gemini_session_file(&p, &dir_name)
&& info.session_id.starts_with(id)
{
candidates.push(info);
}
}
}
candidates.sort_by(|a, b| {
a.session_id
.cmp(&b.session_id)
.then_with(|| a.source_path.cmp(&b.source_path))
});
if candidates.len() > 1 {
eprintln!(
"aicx: session: id '{id}' matches {} gemini sessions; using the first (sorted)",
candidates.len()
);
}
if let Some(info) = candidates.into_iter().next() {
return Some(info);
}
}
let junie_root = home.join(".junie").join("sessions");
if let Ok(dirs) = fs::read_dir(&junie_root) {
let mut candidates: Vec<(String, PathBuf)> = Vec::new();
for d in dirs.flatten() {
let dp = d.path();
if !dp.is_dir() {
continue;
}
let Some(sid) = dp
.file_name()
.and_then(|n| n.to_str())
.and_then(|n| n.strip_prefix(JUNIE_SESSION_DIR_PREFIX))
.filter(|s| !s.is_empty())
else {
continue;
};
if !sid.starts_with(id) {
continue;
}
let events = dp.join(JUNIE_EVENTS_FILENAME);
if events.is_file() {
candidates.push((sid.to_string(), events));
}
}
candidates.sort();
if candidates.len() > 1 {
eprintln!(
"aicx: session: id '{id}' matches {} junie sessions; using the first (sorted)",
candidates.len()
);
}
for (sid, events) in candidates {
if let Some(info) = scan_junie_session_file(&events, &sid) {
return Some(info);
}
}
}
None
}
fn short_title(text: &str) -> String {
let first_line = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
let trimmed = first_line.trim();
if trimmed.chars().count() <= 80 {
trimmed.to_string()
} else {
let clipped: String = trimmed.chars().take(77).collect();
format!("{clipped}...")
}
}
pub fn select_sessions(
mut sessions: Vec<SessionInfo>,
here: Option<&str>,
agent: Option<&str>,
since: Option<DateTime<Utc>>,
limit: usize,
) -> Vec<SessionInfo> {
if let Some(agent) = agent {
sessions.retain(|s| s.agent == agent);
}
if let Some(since) = since {
sessions.retain(|s| s.updated_at.or(s.started_at).is_none_or(|t| t >= since));
}
if let Some(here) = here {
sessions.retain(|s| {
s.repo_path.as_deref().is_some_and(|p| {
if s.association == Association::Inferred {
cwd_nests_encoded(here, p)
} else {
cwd_nests(here, p)
}
})
});
}
sessions.sort_by(|a, b| {
let ta = a.updated_at.or(a.started_at);
let tb = b.updated_at.or(b.started_at);
tb.cmp(&ta).then_with(|| a.session_id.cmp(&b.session_id))
});
if limit > 0 {
sessions.truncate(limit);
}
sessions
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn write_session(dir: &Path, name: &str, lines: &[&str]) {
let path = dir.join(name);
let mut f = fs::File::create(&path).unwrap();
for line in lines {
writeln!(f, "{line}").unwrap();
}
}
fn temp_root(tag: &str) -> PathBuf {
let base =
std::env::temp_dir().join(format!("aicx_sessions_test_{tag}_{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
fs::create_dir_all(&base).unwrap();
base
}
#[test]
fn discovers_claude_session_with_absolute_time_and_counts() {
let root = temp_root("basic");
let proj = root.join("-Users-user-Git-transcript-builder");
fs::create_dir_all(&proj).unwrap();
write_session(
&proj,
"0eb1a73c-1234.jsonl",
&[
r#"{"type":"user","cwd":"/Users/user/Git/transcript-builder","sessionId":"0eb1a73c","message":{"role":"user","content":"hej claude, pomóż mi"},"timestamp":"2026-06-08T01:42:13.000Z"}"#,
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"jasne"}]},"timestamp":"2026-06-08T01:42:23.000Z"}"#,
r#"{"type":"user","message":{"role":"user","content":"dzięki"},"timestamp":"2026-06-08T01:45:00.000Z"}"#,
],
);
let sessions = discover_claude_sessions(&root, None, None);
let _ = fs::remove_dir_all(&root);
assert_eq!(sessions.len(), 1);
let s = &sessions[0];
assert_eq!(s.session_id, "0eb1a73c-1234");
assert_eq!(s.agent, "claude");
assert_eq!(s.temporal_confidence, TemporalConfidence::Full);
assert_eq!(
s.started_at.unwrap().to_rfc3339(),
"2026-06-08T01:42:13+00:00"
);
assert_eq!(
s.updated_at.unwrap().to_rfc3339(),
"2026-06-08T01:45:00+00:00"
);
assert_eq!(s.association, Association::Exact);
assert_eq!(
s.repo_path.as_deref(),
Some("/Users/user/Git/transcript-builder")
);
assert_eq!(s.project.as_deref(), Some("transcript-builder"));
assert_eq!(s.message_count, 3);
assert_eq!(s.user_message_count, 2);
assert_eq!(s.agent_message_count, 1);
assert_eq!(s.title.as_deref(), Some("hej claude, pomóż mi"));
}
#[test]
fn serialized_session_exposes_rfc3339_timestamps() {
let root = temp_root("serialize");
let proj = root.join("-tmp-proj");
fs::create_dir_all(&proj).unwrap();
write_session(
&proj,
"s1.jsonl",
&[
r#"{"type":"user","message":{"role":"user","content":"x"},"timestamp":"2026-01-23T09:00:00.000Z"}"#,
],
);
let sessions = discover_claude_sessions(&root, None, None);
let _ = fs::remove_dir_all(&root);
let json = serde_json::to_string(&sessions[0]).unwrap();
assert!(json.contains("2026-01-23T09:00:00Z"), "json: {json}");
}
#[test]
fn session_without_timestamps_is_marked_not_silently_full() {
let root = temp_root("notime");
let proj = root.join("-tmp-proj2");
fs::create_dir_all(&proj).unwrap();
write_session(
&proj,
"s2.jsonl",
&[r#"{"type":"user","message":{"role":"user","content":"no time here"}}"#],
);
let sessions = discover_claude_sessions(&root, None, None);
let _ = fs::remove_dir_all(&root);
assert_eq!(sessions[0].temporal_confidence, TemporalConfidence::Partial);
assert!(sessions[0].started_at.is_none());
assert_eq!(sessions[0].association, Association::Inferred);
}
#[test]
fn cwd_prune_keeps_hyphenated_path_dirs() {
let root = temp_root("cwdprune");
let proj = root.join("-Users-me-vc-workspace-aicx");
fs::create_dir_all(&proj).unwrap();
write_session(
&proj,
"h1.jsonl",
&[
r#"{"type":"user","cwd":"/Users/me/vc-workspace/aicx","message":{"role":"user","content":"x"},"timestamp":"2026-06-08T10:00:00.000Z"}"#,
],
);
let kept = discover_claude_sessions(&root, None, Some("/Users/me/vc-workspace/aicx"));
assert_eq!(kept.len(), 1, "hyphenated cwd must not be mis-pruned");
let pruned = discover_claude_sessions(&root, None, Some("/Users/me/other-repo"));
let _ = fs::remove_dir_all(&root);
assert_eq!(pruned.len(), 0);
}
#[test]
fn find_session_by_id_locates_claude_by_filename() {
let home = temp_root("findsession");
let proj = home.join(".claude").join("projects").join("-tmp-proj");
fs::create_dir_all(&proj).unwrap();
write_session(
&proj,
"abc12345-dead-beef.jsonl",
&[
r#"{"type":"user","cwd":"/tmp/proj","message":{"role":"user","content":"hi"},"timestamp":"2026-06-08T10:00:00.000Z"}"#,
],
);
let found = find_session_by_id(&home, "abc12345");
let _ = fs::remove_dir_all(&home);
let s = found.expect("session found by id prefix");
assert_eq!(s.session_id, "abc12345-dead-beef");
assert_eq!(s.agent, "claude");
let home2 = temp_root("findsession2");
fs::create_dir_all(&home2).unwrap();
let none = find_session_by_id(&home2, "nope");
let _ = fs::remove_dir_all(&home2);
assert!(none.is_none());
}
#[test]
fn discovers_gemini_session_from_whole_file_json() {
let root = temp_root("gemini");
let chats = root.join("myproj").join("chats");
fs::create_dir_all(&chats).unwrap();
let doc = r#"{
"sessionId":"116b0791-gemini",
"projectHash":"abc",
"startTime":"2026-03-22T20:43:32.318Z",
"lastUpdated":"2026-03-22T20:43:53.023Z",
"messages":[
{"id":"1","type":"user","content":"zrób raport"},
{"id":"2","type":"gemini","content":"ok"},
{"id":"3","type":"gemini","content":"gotowe"}
]
}"#;
fs::write(chats.join("session-2026-03-22T20-43.json"), doc).unwrap();
let sessions = discover_gemini_sessions(&root, None, None);
let _ = fs::remove_dir_all(&root);
assert_eq!(sessions.len(), 1);
let s = &sessions[0];
assert_eq!(s.agent, "gemini");
assert_eq!(s.session_id, "116b0791-gemini");
assert_eq!(s.project.as_deref(), Some("myproj"));
assert_eq!(s.user_message_count, 1);
assert_eq!(s.agent_message_count, 2);
assert_eq!(s.message_count, 3);
assert_eq!(s.title.as_deref(), Some("zrób raport"));
assert_eq!(s.temporal_confidence, TemporalConfidence::Full);
assert_eq!(
s.started_at.unwrap().to_rfc3339(),
"2026-03-22T20:43:32.318+00:00"
);
assert!(s.repo_path.is_none());
}
#[test]
fn discovers_codex_session_from_meta_and_message_stream() {
let root = temp_root("codex");
let day = root.join("2026").join("01").join("29");
fs::create_dir_all(&day).unwrap();
write_session(
&day,
"rollout-2026-01-29T13-58-09-019c09d5.jsonl",
&[
r#"{"timestamp":"2026-01-29T12:58:09.421Z","type":"session_meta","payload":{"id":"019c09d5-codex","cwd":"/Users/me/hosted/Vetcoders"}}"#,
r#"{"timestamp":"2026-01-29T12:58:10.000Z","type":"response_item","payload":{"type":"message","role":"developer","content":[{"text":"bootstrap"}]}}"#,
r#"{"timestamp":"2026-01-29T12:59:00.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"text":"zrób to"}]}}"#,
r#"{"timestamp":"2026-01-29T13:00:00.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"text":"robię"}]}}"#,
],
);
let sessions = discover_codex_sessions(&root, None);
let _ = fs::remove_dir_all(&root);
assert_eq!(sessions.len(), 1);
let s = &sessions[0];
assert_eq!(s.agent, "codex");
assert_eq!(s.session_id, "019c09d5-codex");
assert_eq!(s.project.as_deref(), Some("Vetcoders"));
assert_eq!(s.association, Association::Exact);
assert_eq!(s.user_message_count, 1);
assert_eq!(s.agent_message_count, 1);
assert_eq!(s.message_count, 2);
assert_eq!(s.title.as_deref(), Some("zrób to"));
assert_eq!(s.temporal_confidence, TemporalConfidence::Full);
assert_eq!(
s.started_at.unwrap().to_rfc3339(),
"2026-01-29T12:58:09.421+00:00"
);
}
fn mk_info(id: &str, repo: Option<&str>, updated: &str) -> SessionInfo {
SessionInfo {
session_id: id.to_string(),
agent: "claude".to_string(),
project: repo.and_then(project_label_from_cwd),
repo_path: repo.map(str::to_string),
started_at: None,
updated_at: Some(
DateTime::parse_from_rfc3339(updated)
.unwrap()
.with_timezone(&Utc),
),
message_count: 1,
user_message_count: 1,
agent_message_count: 0,
title: None,
source_path: PathBuf::from(format!("/x/{id}.jsonl")),
association: Association::Exact,
temporal_confidence: TemporalConfidence::Full,
}
}
#[test]
fn select_sessions_filters_by_cwd_and_sorts_newest_first() {
let here = "/Users/me/repo-a";
let sessions = vec![
mk_info("aaa", Some("/Users/me/repo-a"), "2026-06-01T10:00:00Z"),
mk_info("bbb", Some("/Users/me/repo-a"), "2026-06-08T10:00:00Z"),
mk_info("ccc", Some("/Users/me/repo-b"), "2026-06-09T10:00:00Z"),
];
let got = select_sessions(sessions.clone(), Some(here), None, None, 0);
assert_eq!(got.len(), 2);
assert_eq!(got[0].session_id, "bbb", "newest first");
assert_eq!(got[1].session_id, "aaa");
let limited = select_sessions(sessions.clone(), Some(here), None, None, 1);
assert_eq!(limited.len(), 1);
assert_eq!(limited[0].session_id, "bbb");
let all = select_sessions(sessions.clone(), None, None, None, 0);
assert_eq!(all.len(), 3);
assert_eq!(all[0].session_id, "ccc");
let recent = select_sessions(
sessions,
None,
None,
Some(
DateTime::parse_from_rfc3339("2026-06-08T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
0,
);
assert_eq!(recent.len(), 2, "only bbb (06-08) and ccc (06-09) survive");
assert!(recent.iter().all(|s| s.session_id != "aaa"));
}
#[test]
fn cwd_nests_requires_segment_boundary() {
assert!(!cwd_nests("/a/repo", "/a/repo-backup"));
assert!(!cwd_nests("/a/repo-backup", "/a/repo"));
assert!(!cwd_nests("/a/repository", "/a/repo"));
assert!(cwd_nests("/a/repo/sub", "/a/repo"));
assert!(cwd_nests("/a/repo", "/a/repo/sub"));
assert!(cwd_nests("/a/Repo", "/a/repo"));
assert!(cwd_nests("/a/repo/", "/a/repo"));
}
#[test]
fn cwd_prune_requires_encoded_segment_boundary() {
let root = temp_root("cwdprune_boundary");
let sibling = root.join("-Users-me-repository");
fs::create_dir_all(&sibling).unwrap();
write_session(
&sibling,
"x1.jsonl",
&[
r#"{"type":"user","cwd":"/Users/me/repository","message":{"role":"user","content":"x"},"timestamp":"2026-06-08T10:00:00.000Z"}"#,
],
);
let nested = root.join("-Users-me-repo-sub");
fs::create_dir_all(&nested).unwrap();
write_session(
&nested,
"x2.jsonl",
&[
r#"{"type":"user","cwd":"/Users/me/repo/sub","message":{"role":"user","content":"y"},"timestamp":"2026-06-08T11:00:00.000Z"}"#,
],
);
let got = discover_claude_sessions(&root, None, Some("/Users/me/repo"));
let _ = fs::remove_dir_all(&root);
assert_eq!(got.len(), 1, "repository pruned, repo/sub kept");
assert_eq!(got[0].session_id, "x2");
}
#[test]
fn select_sessions_keeps_sessions_without_timestamp_in_since_window() {
let mut no_time = mk_info("notime", Some("/Users/me/repo-a"), "2026-06-01T10:00:00Z");
no_time.updated_at = None;
no_time.started_at = None;
no_time.temporal_confidence = TemporalConfidence::None;
let sessions = vec![
no_time,
mk_info("old", Some("/Users/me/repo-a"), "2026-06-01T10:00:00Z"),
mk_info("new", Some("/Users/me/repo-a"), "2026-06-08T10:00:00Z"),
];
let got = select_sessions(
sessions,
None,
None,
Some(
DateTime::parse_from_rfc3339("2026-06-07T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
0,
);
let ids: Vec<&str> = got.iter().map(|s| s.session_id.as_str()).collect();
assert!(ids.contains(&"notime"), "no-timestamp session survives");
assert!(ids.contains(&"new"));
assert!(!ids.contains(&"old"), "dated-out session is dropped");
assert_eq!(ids.last(), Some(&"notime"));
}
#[test]
fn select_sessions_sort_is_deterministic_on_equal_timestamps() {
let sessions = vec![
mk_info("zzz", None, "2026-06-08T10:00:00Z"),
mk_info("aaa", None, "2026-06-08T10:00:00Z"),
mk_info("mmm", None, "2026-06-08T10:00:00Z"),
];
let got = select_sessions(sessions, None, None, None, 0);
let ids: Vec<&str> = got.iter().map(|s| s.session_id.as_str()).collect();
assert_eq!(ids, vec!["aaa", "mmm", "zzz"], "session_id tie-break");
let sessions = vec![
mk_info("zzz", None, "2026-06-08T10:00:00Z"),
mk_info("aaa", None, "2026-06-08T10:00:00Z"),
];
let one = select_sessions(sessions, None, None, None, 1);
assert_eq!(one[0].session_id, "aaa");
}
#[test]
fn select_sessions_matches_inferred_repo_in_encoded_space() {
let mut inferred = mk_info(
"inf",
Some("/Users/me/vc/workspace/aicx"),
"2026-06-08T10:00:00Z",
);
inferred.association = Association::Inferred;
let got = select_sessions(
vec![inferred.clone()],
Some("/Users/me/vc-workspace/aicx"),
None,
None,
0,
);
assert_eq!(got.len(), 1, "inferred match happens in encoded space");
let none = select_sessions(vec![inferred], Some("/Users/me/other"), None, None, 0);
assert!(none.is_empty());
}
#[test]
fn garbage_only_file_reports_temporal_confidence_none() {
let root = temp_root("garbage_claude");
let proj = root.join("-tmp-garbage");
fs::create_dir_all(&proj).unwrap();
write_session(&proj, "g1.jsonl", &["this is not json", "neither is this"]);
let sessions = discover_claude_sessions(&root, None, None);
let _ = fs::remove_dir_all(&root);
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].temporal_confidence, TemporalConfidence::None);
let root = temp_root("garbage_codex");
let day = root.join("2026").join("01").join("29");
fs::create_dir_all(&day).unwrap();
write_session(&day, "rollout-garbage.jsonl", &["{{{ not json"]);
let sessions = discover_codex_sessions(&root, None);
let _ = fs::remove_dir_all(&root);
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].temporal_confidence, TemporalConfidence::None);
}
#[test]
fn gemini_session_without_any_time_signal_reports_none() {
let root = temp_root("gemini_notime");
let chats = root.join("proj").join("chats");
fs::create_dir_all(&chats).unwrap();
let no_time =
r#"{"sessionId":"g-1","messages":[{"id":"1","type":"user","content":"hej"}]}"#;
fs::write(chats.join("session-a.json"), no_time).unwrap();
let msg_time = r#"{"sessionId":"g-2","messages":[{"id":"1","type":"user","content":"hej","timestamp":"2026-06-08T10:00:00.000Z"}]}"#;
fs::write(chats.join("session-b.json"), msg_time).unwrap();
let mut sessions = discover_gemini_sessions(&root, None, None);
let _ = fs::remove_dir_all(&root);
sessions.sort_by(|a, b| a.session_id.cmp(&b.session_id));
assert_eq!(sessions.len(), 2);
assert_eq!(sessions[0].session_id, "g-1");
assert_eq!(sessions[0].temporal_confidence, TemporalConfidence::None);
assert_eq!(sessions[1].session_id, "g-2");
assert_eq!(sessions[1].temporal_confidence, TemporalConfidence::Partial);
}
#[test]
fn find_session_by_id_codex_matches_id_segment_not_timestamp() {
let home = temp_root("codexfind");
let day = home.join(".codex").join("sessions").join("2026").join("01");
fs::create_dir_all(&day).unwrap();
write_session(
&day,
"rollout-2026-01-29T13-58-09-019c1111-aaaa.jsonl",
&[
r#"{"timestamp":"2026-01-29T12:58:09.421Z","type":"session_meta","payload":{"id":"019c1111-aaaa","cwd":"/tmp/a"}}"#,
],
);
write_session(
&day,
"rollout-2026-01-29T14-00-00-019c2222-bbbb.jsonl",
&[
r#"{"timestamp":"2026-01-29T13:00:00.000Z","type":"session_meta","payload":{"id":"019c2222-bbbb","cwd":"/tmp/b"}}"#,
],
);
let found = find_session_by_id(&home, "019c2222");
assert_eq!(
found.expect("found by id segment").session_id,
"019c2222-bbbb"
);
assert!(find_session_by_id(&home, "2026").is_none());
let ambiguous = find_session_by_id(&home, "019c");
assert_eq!(
ambiguous
.expect("ambiguous prefix still resolves")
.session_id,
"019c1111-aaaa"
);
let _ = fs::remove_dir_all(&home);
}
#[test]
fn discovers_junie_session_with_compact_timestamps_and_counts() {
let root = temp_root("junie");
let session_dir = root.join("session-260408-214715-abcd");
fs::create_dir_all(&session_dir).unwrap();
write_session(
&session_dir,
"events.jsonl",
&[
r#"{"kind":"SessionA2uxEvent","event":{"state":"IN_PROGRESS","agentEvent":{"kind":"CurrentDirectoryUpdatedEvent","currentDirectory":"/tmp/repo"}}}"#,
r#"{"kind":"UserPromptEvent","requestId":"prompt-260408-214823-br8l","prompt":"vc-init","presentablePrompt":"vc-init"}"#,
r#"{"kind":"SessionA2uxEvent","event":{"state":"IN_PROGRESS","agentEvent":{"kind":"ResultBlockUpdatedEvent","stepId":"step-1","result":"Initial plan"}}}"#,
r#"{"kind":"SessionA2uxEvent","event":{"state":"IN_PROGRESS","agentEvent":{"kind":"ResultBlockUpdatedEvent","stepId":"step-1","result":"Initial plan"}}}"#,
r#"{"kind":"UserResponseEvent","prompt":"jedziemy","isChoice":true}"#,
r#"{"kind":"SessionA2uxEvent","event":{"state":"IN_PROGRESS","agentEvent":{"kind":"ResultBlockUpdatedEvent","stepId":"step-1","result":"Refined plan"}}}"#,
],
);
let sessions = discover_junie_sessions(&root, None);
let _ = fs::remove_dir_all(&root);
assert_eq!(sessions.len(), 1);
let s = &sessions[0];
assert_eq!(s.agent, "junie");
assert_eq!(s.session_id, "260408-214715-abcd");
assert_eq!(s.temporal_confidence, TemporalConfidence::Full);
assert_eq!(
s.started_at.unwrap().to_rfc3339(),
"2026-04-08T21:47:15+00:00"
);
assert_eq!(
s.updated_at.unwrap().to_rfc3339(),
"2026-04-08T21:48:23+00:00"
);
assert_eq!(s.repo_path.as_deref(), Some("/tmp/repo"));
assert_eq!(s.project.as_deref(), Some("repo"));
assert_eq!(s.association, Association::Exact);
assert_eq!(s.user_message_count, 2);
assert_eq!(s.agent_message_count, 2);
assert_eq!(s.message_count, 4);
assert_eq!(s.title.as_deref(), Some("vc-init"));
}
#[test]
fn junie_meta_prompt_is_not_an_operator_message_or_title() {
let root = temp_root("junie_meta");
let session_dir = root.join("session-260605-183024-junie");
fs::create_dir_all(&session_dir).unwrap();
write_session(
&session_dir,
"events.jsonl",
&[
r#"{"kind":"UserPromptEvent","requestId":"prompt-260605-183101-meta1","prompt":"Implement the suggested plan","customAttachments":[{"kind":"PlanAttachment"}]}"#,
r#"{"kind":"UserPromptEvent","requestId":"prompt-260605-183102-real1","prompt":"real operator question"}"#,
],
);
let sessions = discover_junie_sessions(&root, None);
let _ = fs::remove_dir_all(&root);
assert_eq!(sessions.len(), 1);
let s = &sessions[0];
assert_eq!(s.user_message_count, 1, "meta prompt not counted");
assert_eq!(s.title.as_deref(), Some("real operator question"));
}
#[test]
fn junie_garbage_only_file_reports_temporal_none_without_anchor() {
let root = temp_root("junie_garbage");
let session_dir = root.join("session-opaque");
fs::create_dir_all(&session_dir).unwrap();
write_session(&session_dir, "events.jsonl", &["{{{ not json"]);
let sessions = discover_junie_sessions(&root, None);
let _ = fs::remove_dir_all(&root);
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].temporal_confidence, TemporalConfidence::None);
assert!(sessions[0].started_at.is_none());
}
#[test]
fn find_session_by_id_locates_junie_by_dir_name() {
let home = temp_root("junie_find");
let session_dir = home
.join(".junie")
.join("sessions")
.join("session-260408-214715-abcd");
fs::create_dir_all(&session_dir).unwrap();
write_session(
&session_dir,
"events.jsonl",
&[
r#"{"kind":"UserPromptEvent","requestId":"prompt-260408-214823-br8l","prompt":"hej"}"#,
],
);
let found = find_session_by_id(&home, "260408-214715");
let _ = fs::remove_dir_all(&home);
let s = found.expect("junie session found by id prefix");
assert_eq!(s.agent, "junie");
assert_eq!(s.session_id, "260408-214715-abcd");
}
#[test]
fn find_session_by_id_claude_ambiguous_prefix_resolves_deterministically() {
let home = temp_root("claude_ambiguous");
let proj_a = home.join(".claude").join("projects").join("-proj-a");
let proj_b = home.join(".claude").join("projects").join("-proj-b");
fs::create_dir_all(&proj_a).unwrap();
fs::create_dir_all(&proj_b).unwrap();
write_session(
&proj_a,
"abc1zzzz-in-a.jsonl",
&[
r#"{"type":"user","cwd":"/proj/a","message":{"role":"user","content":"a"},"timestamp":"2026-06-08T10:00:00.000Z"}"#,
],
);
write_session(
&proj_b,
"abc1aaaa-in-b.jsonl",
&[
r#"{"type":"user","cwd":"/proj/b","message":{"role":"user","content":"b"},"timestamp":"2026-06-08T11:00:00.000Z"}"#,
],
);
let found = find_session_by_id(&home, "abc1");
let _ = fs::remove_dir_all(&home);
let s = found.expect("ambiguous prefix still resolves");
assert_eq!(s.session_id, "abc1zzzz-in-a");
assert_eq!(s.repo_path.as_deref(), Some("/proj/a"));
}
#[test]
fn find_session_by_id_gemini_ambiguous_prefix_resolves_deterministically() {
let home = temp_root("gemini_ambiguous");
let chats = home.join(".gemini").join("tmp").join("proj").join("chats");
fs::create_dir_all(&chats).unwrap();
fs::write(
chats.join("session-1.json"),
r#"{"sessionId":"g-abc1-zzz","messages":[{"id":"1","type":"user","content":"z"}]}"#,
)
.unwrap();
fs::write(
chats.join("session-2.json"),
r#"{"sessionId":"g-abc1-aaa","messages":[{"id":"1","type":"user","content":"a"}]}"#,
)
.unwrap();
let found = find_session_by_id(&home, "g-abc1");
let _ = fs::remove_dir_all(&home);
let s = found.expect("ambiguous prefix still resolves");
assert_eq!(s.session_id, "g-abc1-aaa", "smallest session id wins");
}
#[cfg(unix)]
fn make_unreadable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o000)).unwrap();
fs::read_to_string(path).is_err()
}
#[cfg(unix)]
#[test]
fn claude_unreadable_file_is_skipped_not_fatal() {
let root = temp_root("unreadable_claude");
let proj = root.join("-tmp-proj");
fs::create_dir_all(&proj).unwrap();
write_session(
&proj,
"good.jsonl",
&[
r#"{"type":"user","message":{"role":"user","content":"ok"},"timestamp":"2026-06-08T10:00:00.000Z"}"#,
],
);
write_session(&proj, "locked.jsonl", &["{}"]);
if !make_unreadable(&proj.join("locked.jsonl")) {
let _ = fs::remove_dir_all(&root);
return; }
let sessions = discover_claude_sessions(&root, None, None);
let _ = fs::remove_dir_all(&root);
assert_eq!(sessions.len(), 1, "unreadable file skipped, scan survives");
assert_eq!(sessions[0].session_id, "good");
}
#[cfg(unix)]
#[test]
fn codex_unreadable_file_is_skipped_not_fatal() {
let root = temp_root("unreadable_codex");
let day = root.join("2026").join("01").join("29");
fs::create_dir_all(&day).unwrap();
write_session(
&day,
"rollout-good.jsonl",
&[
r#"{"timestamp":"2026-01-29T12:58:09.421Z","type":"session_meta","payload":{"id":"good-codex","cwd":"/tmp/a"}}"#,
],
);
write_session(&day, "rollout-locked.jsonl", &["{}"]);
if !make_unreadable(&day.join("rollout-locked.jsonl")) {
let _ = fs::remove_dir_all(&root);
return;
}
let sessions = discover_codex_sessions(&root, None);
let _ = fs::remove_dir_all(&root);
assert_eq!(sessions.len(), 1, "unreadable file skipped, scan survives");
assert_eq!(sessions[0].session_id, "good-codex");
}
#[cfg(unix)]
#[test]
fn gemini_unreadable_file_is_skipped_not_fatal() {
let root = temp_root("unreadable_gemini");
let chats = root.join("proj").join("chats");
fs::create_dir_all(&chats).unwrap();
fs::write(
chats.join("session-good.json"),
r#"{"sessionId":"g-good","messages":[{"id":"1","type":"user","content":"hej"}]}"#,
)
.unwrap();
fs::write(chats.join("session-locked.json"), "{}").unwrap();
if !make_unreadable(&chats.join("session-locked.json")) {
let _ = fs::remove_dir_all(&root);
return;
}
let sessions = discover_gemini_sessions(&root, None, None);
let _ = fs::remove_dir_all(&root);
assert_eq!(sessions.len(), 1, "unreadable file skipped, scan survives");
assert_eq!(sessions[0].session_id, "g-good");
}
#[cfg(unix)]
#[test]
fn junie_unreadable_events_file_is_skipped_not_fatal() {
let root = temp_root("unreadable_junie");
let good = root.join("session-260408-214715-good");
fs::create_dir_all(&good).unwrap();
write_session(
&good,
"events.jsonl",
&[
r#"{"kind":"UserPromptEvent","requestId":"prompt-260408-214823-br8l","prompt":"hej"}"#,
],
);
let locked = root.join("session-260408-220000-lock");
fs::create_dir_all(&locked).unwrap();
write_session(&locked, "events.jsonl", &["{}"]);
if !make_unreadable(&locked.join("events.jsonl")) {
let _ = fs::remove_dir_all(&root);
return;
}
let sessions = discover_junie_sessions(&root, None);
let _ = fs::remove_dir_all(&root);
assert_eq!(sessions.len(), 1, "unreadable file skipped, scan survives");
assert_eq!(sessions[0].session_id, "260408-214715-good");
}
#[cfg(unix)]
#[test]
fn codex_walk_does_not_follow_symlinks() {
use std::os::unix::fs::symlink;
let home = temp_root("codex_symlink_home");
let root = home.join(".codex").join("sessions");
fs::create_dir_all(&root).unwrap();
let outside = temp_root("codex_symlink_outside");
write_session(
&outside,
"rollout-2026-01-29T13-58-09-019c09d5.jsonl",
&[
r#"{"timestamp":"2026-01-29T12:58:09.421Z","type":"session_meta","payload":{"id":"019c09d5-ext","cwd":"/tmp/x"}}"#,
],
);
symlink(&root, root.join("loop")).unwrap();
symlink(&outside, root.join("ext")).unwrap();
symlink(
outside.join("rollout-2026-01-29T13-58-09-019c09d5.jsonl"),
root.join("rollout-2026-01-29T13-58-09-019c09d5.jsonl"),
)
.unwrap();
let sessions = discover_codex_sessions(&root, None);
assert!(
sessions.is_empty(),
"no session may arrive through a symlink: {sessions:?}"
);
assert!(find_session_by_id(&home, "019c09d5").is_none());
let _ = fs::remove_dir_all(&home);
let _ = fs::remove_dir_all(&outside);
}
#[test]
fn codex_walk_caps_depth() {
let home = temp_root("codex_depth_home");
let root = home.join(".codex").join("sessions");
let mut in_cap = root.clone();
for i in 0..MAX_CODEX_SCAN_DEPTH {
in_cap = in_cap.join(format!("d{i}"));
}
fs::create_dir_all(&in_cap).unwrap();
write_session(
&in_cap,
"rollout-2026-01-29T13-58-09-incap1111.jsonl",
&[
r#"{"timestamp":"2026-01-29T12:58:09.421Z","type":"session_meta","payload":{"id":"incap1111","cwd":"/tmp/a"}}"#,
],
);
let mut beyond = root.clone();
for i in 0..(MAX_CODEX_SCAN_DEPTH + 1) {
beyond = beyond.join(format!("e{i}"));
}
fs::create_dir_all(&beyond).unwrap();
write_session(
&beyond,
"rollout-2026-01-29T14-00-00-beyond2222.jsonl",
&[
r#"{"timestamp":"2026-01-29T13:00:00.000Z","type":"session_meta","payload":{"id":"beyond2222","cwd":"/tmp/b"}}"#,
],
);
let sessions = discover_codex_sessions(&root, None);
assert_eq!(sessions.len(), 1, "only the in-cap rollout is discovered");
assert_eq!(sessions[0].session_id, "incap1111");
assert!(find_session_by_id(&home, "incap1111").is_some());
assert!(find_session_by_id(&home, "beyond2222").is_none());
let _ = fs::remove_dir_all(&home);
}
#[test]
fn codex_rollout_id_segment_parses_rollout_shape() {
assert_eq!(
codex_rollout_id_segment("rollout-2026-01-29T13-58-09-019c09d5-aaaa"),
Some("019c09d5-aaaa")
);
assert_eq!(codex_rollout_id_segment("rollout-garbage"), None);
assert_eq!(codex_rollout_id_segment("some-other-file"), None);
assert_eq!(
codex_rollout_id_segment("rollout-2026-01-29T13-58-09-"),
None
);
}
}