use serde_json::Value;
use std::collections::{BTreeMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::types::{AgentSession, SessionCandidate, SessionDirStat, TokenUsage};
use crate::{AGENT_CLAUDE, AGENT_CODEX, AGENT_GEMINI};
pub fn discover_session_files() -> Vec<SessionCandidate> {
user_home_dir()
.as_deref()
.map(discover_session_files_in_home)
.unwrap_or_default()
}
pub fn discover_session_files_in_home(home: &Path) -> Vec<SessionCandidate> {
let roots = [
(AGENT_CLAUDE, home.join(".claude/projects")),
(AGENT_CODEX, home.join(".codex/sessions")),
(AGENT_GEMINI, home.join(".gemini/tmp")),
];
let mut out = Vec::new();
for (agent, dir) in roots {
walk_agent_files(agent, &dir, &mut |path, meta| {
out.push(SessionCandidate {
agent,
path: path.to_path_buf(),
updated: meta.modified().unwrap_or(UNIX_EPOCH),
});
});
}
out
}
pub fn count_session_dirs() -> Vec<SessionDirStat> {
let Some(home) = user_home_dir() else {
return Vec::new();
};
[
(AGENT_CLAUDE, home.join(".claude/projects")),
(AGENT_CODEX, home.join(".codex/sessions")),
(AGENT_GEMINI, home.join(".gemini/tmp")),
]
.into_iter()
.filter_map(|(agent, dir)| {
let (mut sessions, mut bytes) = (0usize, 0u64);
walk_agent_files(agent, &dir, &mut |_, meta| {
sessions += 1;
bytes += meta.len();
});
(sessions > 0).then_some(SessionDirStat {
agent,
dir,
sessions,
bytes,
})
})
.collect()
}
pub fn parse_session_file(candidate: &SessionCandidate) -> Option<AgentSession> {
let content = fs::read_to_string(&candidate.path).ok()?;
parse_session_content(
candidate.agent,
&candidate.path,
candidate.updated,
&content,
)
}
pub fn parse_session_path(path: &Path) -> Option<AgentSession> {
let agent = agent_source_for_path(path)?;
let updated = fs::metadata(path)
.and_then(|metadata| metadata.modified())
.unwrap_or(UNIX_EPOCH);
parse_session_file(&SessionCandidate {
agent,
path: path.to_path_buf(),
updated,
})
}
pub fn parse_session_content(
agent: &str,
path: &Path,
updated: SystemTime,
content: &str,
) -> Option<AgentSession> {
if agent == AGENT_GEMINI {
parse_gemini_json(path, updated, content)
} else {
parse_jsonl(agent, path, updated, content)
}
}
pub fn session_log_path_from_str(raw: &str) -> Option<PathBuf> {
let trimmed = raw.trim().trim_end_matches(" (deleted)");
if trimmed.is_empty() {
return None;
}
let path = Path::new(trimmed);
if !path.is_absolute() || !is_agent_session_file(path) {
return None;
}
agent_source_for_path(path).map(|_| normalize_session_log_path(path))
}
pub fn normalize_session_log_path(path: &Path) -> PathBuf {
fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
pub fn agent_source_for_path(path: &Path) -> Option<&'static str> {
let value = path.to_string_lossy();
if value.contains("/.claude/") && path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")
{
Some(AGENT_CLAUDE)
} else if value.contains("/.codex/")
&& path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")
{
Some(AGENT_CODEX)
} else if value.contains("/.gemini/")
&& path.extension().and_then(|ext| ext.to_str()) == Some("json")
{
Some(AGENT_GEMINI)
} else {
None
}
}
pub fn fixture_session_path(agent: &str, home: &Path) -> Option<PathBuf> {
match agent {
AGENT_CLAUDE => Some(home.join(".claude/projects/test/session.jsonl")),
AGENT_CODEX => Some(home.join(".codex/sessions/2026/06/02/session.jsonl")),
AGENT_GEMINI => Some(home.join(".gemini/tmp/test/chats/session-test.json")),
_ => None,
}
}
pub fn is_codex_cli_entrypoint(target: Option<&str>) -> bool {
target.is_some_and(|target| {
Path::new(target).file_name().and_then(|name| name.to_str()) == Some("codex")
&& !target.contains("/node_modules/")
})
}
pub fn codex_exec_prompt(command: &str) -> Option<String> {
let mut args = command.split_once(" exec ")?.1.trim();
while let Some(rest) = strip_codex_exec_option(args) {
args = rest.trim_start();
}
(!args.starts_with('-'))
.then(|| args.trim_matches(['"', '\'']))
.and_then(clean_prompt_text)
}
fn parse_jsonl(
agent: &str,
path: &Path,
updated: SystemTime,
content: &str,
) -> Option<AgentSession> {
let mut acc = SessionAccumulator::new(agent, path, updated);
let mut codex_model = String::new();
let mut claude_message_models = BTreeMap::<String, TokenUsage>::new();
let mut claude_seen_usage = HashSet::new();
for line in content.lines() {
let Ok(obj) = serde_json::from_str::<Value>(line) else {
continue;
};
let (session_id, conversation_id) = local_session_ids(&obj);
if let Some(id) = session_id {
acc.session_id = id;
}
if let Some(id) = conversation_id {
acc.conversation_id = Some(id);
}
if acc.cwd.is_none() {
acc.cwd = obj
.get("cwd")
.and_then(Value::as_str)
.or_else(|| obj.pointer("/payload/cwd").and_then(Value::as_str))
.filter(|s| !s.is_empty())
.map(ToString::to_string);
}
if let Some(ts) = obj.get("timestamp").and_then(Value::as_str) {
acc.last_message_at = Some(ts.to_string());
acc.end_timestamp_ms = iso_ms(ts).or(acc.end_timestamp_ms);
}
let typ = obj.get("type").and_then(Value::as_str).unwrap_or("");
match (agent, typ) {
(AGENT_CLAUDE, "result") => {
acc.duration_ms = json_u64(&obj, "duration_ms");
if let Some(model_usage) = obj.get("modelUsage").and_then(Value::as_object) {
for (name, usage) in model_usage {
acc.model.get_or_insert_with(|| name.clone());
acc.add_usage(
name,
json_i64(usage, "inputTokens"),
json_i64(usage, "outputTokens"),
json_i64(usage, "cacheCreationInputTokens"),
json_i64(usage, "cacheReadInputTokens"),
0,
);
}
}
}
(AGENT_CLAUDE, "assistant") => {
if let Some(name) = obj.pointer("/message/model").and_then(Value::as_str) {
acc.model.get_or_insert_with(|| name.to_string());
}
if let Some(usage) = obj.pointer("/message/usage")
&& claude_seen_usage.insert(claude_usage_key(&obj))
{
let name = obj
.pointer("/message/model")
.and_then(Value::as_str)
.unwrap_or("unknown");
add_usage(
&mut claude_message_models,
name,
json_i64(usage, "input_tokens"),
json_i64(usage, "output_tokens"),
json_i64(usage, "cache_creation_input_tokens"),
json_i64(usage, "cache_read_input_tokens"),
0,
);
}
if let Some(items) = obj.pointer("/message/content").and_then(Value::as_array) {
for item in items
.iter()
.filter(|item| item.get("type").and_then(Value::as_str) == Some("tool_use"))
{
let name = item.get("name").and_then(Value::as_str).unwrap_or("?");
acc.add_tool(name);
if let Some(fp) = item
.pointer("/input/file_path")
.and_then(Value::as_str)
.filter(|s| !is_noise_path(s))
{
acc.add_file(fp);
}
}
}
}
(AGENT_CLAUDE, "queue-operation") if acc.prompt_preview.is_none() => {
if obj.get("operation").and_then(Value::as_str) == Some("enqueue")
&& let Some(text) = obj.get("content").and_then(Value::as_str)
&& let Some(text) = clean_prompt_text(text)
{
acc.prompt_preview = Some(text);
}
}
(AGENT_CLAUDE, "last-prompt") if acc.prompt_preview.is_none() => {
if let Some(text) = obj.get("lastPrompt").and_then(Value::as_str)
&& let Some(text) = clean_prompt_text(text)
{
acc.prompt_preview = Some(text);
}
}
(AGENT_CLAUDE, "user") => {
if acc.prompt_preview.is_none()
&& !is_claude_tool_result(&obj)
&& let Some(text) =
local_message_preview(obj.pointer("/message/content").unwrap_or(&obj))
{
acc.prompt_preview = Some(text);
}
}
(AGENT_CODEX, "turn_context") => {
if let Some(name) = obj.pointer("/payload/model").and_then(Value::as_str) {
codex_model = name.to_string();
acc.model = Some(name.to_string());
}
}
(AGENT_CODEX, "event_msg") => {
if obj.pointer("/payload/type").and_then(Value::as_str) == Some("token_count")
&& let Some(usage) = obj.pointer("/payload/info/total_token_usage")
{
let name = if codex_model.is_empty() {
"unknown"
} else {
&codex_model
};
acc.set_usage(
name,
json_i64(usage, "input_tokens"),
json_i64(usage, "output_tokens"),
0,
0,
json_i64(usage, "total_tokens"),
);
}
}
(AGENT_CODEX, "response_item")
if obj.pointer("/payload/type").and_then(Value::as_str)
== Some("function_call") =>
{
let name = obj
.pointer("/payload/name")
.and_then(Value::as_str)
.unwrap_or("?");
acc.add_tool(name);
}
(AGENT_CODEX, "message" | "input" | "user") => {
if let Some(text) = local_message_preview(&obj) {
acc.prompt_preview = Some(text);
}
}
_ if acc.prompt_preview.is_none() && typ.contains("user") => {
if let Some(text) = local_message_preview(&obj) {
acc.prompt_preview = Some(text);
}
}
_ => {}
}
}
if acc.model_usage.is_empty() {
acc.model_usage = claude_message_models;
}
acc.finish()
}
fn parse_gemini_json(path: &Path, updated: SystemTime, content: &str) -> Option<AgentSession> {
let root: Value = serde_json::from_str(content).ok()?;
let mut acc = SessionAccumulator::new(AGENT_GEMINI, path, updated);
if let Some(id) = root.get("sessionId").and_then(Value::as_str) {
acc.session_id = id.to_string();
acc.conversation_id = Some(id.to_string());
}
acc.start_timestamp_ms = root
.get("startTime")
.and_then(Value::as_str)
.and_then(iso_ms);
acc.end_timestamp_ms = root
.get("lastUpdated")
.and_then(Value::as_str)
.and_then(iso_ms)
.or(acc.start_timestamp_ms);
acc.duration_ms = acc
.start_timestamp_ms
.zip(acc.end_timestamp_ms)
.map(|(start, end)| end.saturating_sub(start))
.unwrap_or_default();
let Some(messages) = root.get("messages").and_then(Value::as_array) else {
return acc.finish();
};
for msg in messages {
if let Some(ts) = msg.get("timestamp").and_then(Value::as_str) {
acc.last_message_at = Some(ts.to_string());
}
match msg.get("type").and_then(Value::as_str) {
Some("user") if acc.prompt_preview.is_none() => {
if let Some(text) = local_message_preview(msg.get("content").unwrap_or(msg)) {
acc.prompt_preview = Some(text);
}
}
Some("gemini") | Some("assistant") | Some("model") => {
if let Some(model) = msg.get("model").and_then(Value::as_str) {
acc.model.get_or_insert_with(|| model.to_string());
if let Some(tokens) = msg.get("tokens") {
acc.add_usage(
model,
json_i64(tokens, "input"),
json_i64(tokens, "output"),
0,
json_i64(tokens, "cached"),
json_i64(tokens, "total"),
);
}
}
if let Some(tool_calls) = msg.get("toolCalls").and_then(Value::as_array) {
for call in tool_calls {
let name = call.get("name").and_then(Value::as_str).unwrap_or("?");
acc.add_tool(name);
if let Some(path) = find_file_arg(call).filter(|path| !is_noise_path(path))
{
acc.add_file(path);
}
}
}
}
_ => {}
}
}
acc.finish()
}
struct SessionAccumulator {
agent_type: String,
session_id: String,
conversation_id: Option<String>,
path: PathBuf,
updated: SystemTime,
start_timestamp_ms: Option<u64>,
end_timestamp_ms: Option<u64>,
model: Option<String>,
model_usage: BTreeMap<String, TokenUsage>,
tools: BTreeMap<String, usize>,
files: BTreeMap<String, usize>,
prompt_preview: Option<String>,
duration_ms: u64,
cwd: Option<String>,
last_message_at: Option<String>,
}
impl SessionAccumulator {
fn new(agent: &str, path: &Path, updated: SystemTime) -> Self {
let normalized = normalize_session_log_path(path);
let session_id = path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("session")
.to_string();
Self {
agent_type: agent.to_string(),
session_id,
conversation_id: None,
path: normalized.clone(),
updated,
start_timestamp_ms: None,
end_timestamp_ms: Some(system_time_ms(updated)),
model: None,
model_usage: BTreeMap::new(),
tools: BTreeMap::new(),
files: BTreeMap::new(),
prompt_preview: None,
duration_ms: 0,
cwd: None,
last_message_at: None,
}
}
fn add_usage(
&mut self,
model: &str,
input: i64,
output: i64,
cache_creation: i64,
cache_read: i64,
total: i64,
) {
add_usage(
&mut self.model_usage,
model,
input,
output,
cache_creation,
cache_read,
total,
);
}
fn set_usage(
&mut self,
model: &str,
input: i64,
output: i64,
cache_creation: i64,
cache_read: i64,
total: i64,
) {
let mut usage = TokenUsage::default();
usage.add(input, output, cache_creation, cache_read, total);
self.model_usage.insert(model.to_string(), usage);
}
fn add_tool(&mut self, name: &str) {
*self.tools.entry(name.to_string()).or_default() += 1;
}
fn add_file(&mut self, path: &str) {
*self.files.entry(path.to_string()).or_default() += 1;
}
fn finish(self) -> Option<AgentSession> {
let token_usage =
self.model_usage
.values()
.fold(TokenUsage::default(), |mut total, usage| {
total.input_tokens += usage.input_tokens;
total.output_tokens += usage.output_tokens;
total.cache_creation_tokens += usage.cache_creation_tokens;
total.cache_read_tokens += usage.cache_read_tokens;
total.total_tokens += usage.total_tokens;
total
});
if token_usage.total_tokens == 0
&& self.tools.is_empty()
&& self.prompt_preview.is_none()
&& self.model.is_none()
{
return None;
}
let display_id = format!("{}:{}", self.agent_type, short_session_id(&self.session_id));
Some(AgentSession {
agent_type: self.agent_type,
session_id: self.session_id,
conversation_id: self.conversation_id,
display_id,
path: self.path,
updated: self.updated,
start_timestamp_ms: self
.start_timestamp_ms
.or_else(|| Some(system_time_ms(self.updated).saturating_sub(self.duration_ms))),
end_timestamp_ms: self.end_timestamp_ms,
model: self.model,
usage: token_usage,
model_usage: self.model_usage,
tools: self.tools,
files: self.files,
prompt_preview: self.prompt_preview,
duration_ms: self.duration_ms,
cwd: self.cwd,
last_message_at: self.last_message_at,
})
}
}
fn walk_agent_files(agent: &'static str, dir: &Path, f: &mut dyn FnMut(&Path, &fs::Metadata)) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk_agent_files(agent, &path, f);
} else if is_agent_file_for(agent, &path)
&& let Ok(meta) = path.metadata()
{
f(&path, &meta);
}
}
}
fn is_agent_session_file(path: &Path) -> bool {
agent_source_for_path(path).is_some()
}
fn is_agent_file_for(agent: &str, path: &Path) -> bool {
match agent {
AGENT_CLAUDE | AGENT_CODEX => {
path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")
}
AGENT_GEMINI => {
path.extension().and_then(|ext| ext.to_str()) == Some("json")
&& path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("session-"))
&& path.to_string_lossy().contains("/chats/")
}
_ => false,
}
}
pub(crate) fn user_home_dir() -> Option<PathBuf> {
std::env::var("SUDO_USER")
.ok()
.and_then(|user| {
fs::read_to_string("/etc/passwd").ok().and_then(|passwd| {
passwd
.lines()
.find(|line| line.starts_with(&format!("{user}:")))
.and_then(|line| line.split(':').nth(5))
.map(PathBuf::from)
})
})
.or_else(dirs::home_dir)
}
fn add_usage(
models: &mut BTreeMap<String, TokenUsage>,
model: &str,
input: i64,
output: i64,
cache_creation: i64,
cache_read: i64,
total: i64,
) {
models.entry(model.to_string()).or_default().add(
input,
output,
cache_creation,
cache_read,
total,
);
}
fn local_session_ids(obj: &Value) -> (Option<String>, Option<String>) {
let session_id = first_json_string(
obj,
&["sessionId", "session_id"],
&["/payload/session_id", "/payload/sessionId"],
);
let conversation_id = first_json_string(
obj,
&["conversation_id", "conversationId", "thread_id", "threadId"],
&[
"/payload/conversation_id",
"/payload/conversationId",
"/payload/thread_id",
"/payload/threadId",
],
)
.or_else(|| session_id.clone());
(
session_id.or_else(|| conversation_id.clone()),
conversation_id,
)
}
fn first_json_string(obj: &Value, keys: &[&str], pointers: &[&str]) -> Option<String> {
keys.iter()
.filter_map(|key| obj.get(*key).and_then(Value::as_str))
.chain(
pointers
.iter()
.filter_map(|pointer| obj.pointer(pointer).and_then(Value::as_str)),
)
.find(|value| !value.is_empty())
.map(str::to_string)
}
fn strip_codex_exec_option(args: &str) -> Option<&str> {
let (head, rest) = args.split_once(char::is_whitespace).unwrap_or((args, ""));
match head {
"--json" | "--skip-git-repo-check" | "--ephemeral" => Some(rest),
"-C" | "-a" | "-s" | "-m" | "-c" | "-p" => rest
.trim_start()
.split_once(char::is_whitespace)
.map(|(_, rest)| rest),
_ => None,
}
}
fn claude_usage_key(obj: &Value) -> String {
obj.get("requestId")
.or_else(|| obj.pointer("/message/id"))
.or_else(|| obj.get("uuid"))
.and_then(Value::as_str)
.unwrap_or("usage")
.to_string()
}
fn local_message_preview(value: &Value) -> Option<String> {
let mut parts = Vec::new();
collect_local_text(value, &mut parts);
clean_prompt_text(&parts.join(" "))
}
fn collect_local_text(value: &Value, out: &mut Vec<String>) {
match value {
Value::String(text) => out.push(text.clone()),
Value::Array(items) => {
for item in items {
collect_local_text(item, out);
}
}
Value::Object(obj) => {
if obj.get("type").and_then(Value::as_str).is_some_and(|typ| {
typ == "tool_use" || typ == "function_call" || typ == "tool_result"
}) {
return;
}
for key in ["text", "content", "message", "input", "prompt"] {
if let Some(value) = obj.get(key) {
collect_local_text(value, out);
}
}
}
_ => {}
}
}
fn is_claude_tool_result(obj: &Value) -> bool {
obj.get("toolUseResult").is_some()
|| obj.get("tool_use_result").is_some()
|| obj
.pointer("/message/content")
.and_then(Value::as_array)
.is_some_and(|items| {
items
.iter()
.any(|item| item.get("type").and_then(Value::as_str) == Some("tool_result"))
})
}
fn find_file_arg(value: &Value) -> Option<&str> {
match value {
Value::Object(obj) => {
for key in ["file_path", "path", "filepath"] {
if let Some(path) = obj.get(key).and_then(Value::as_str) {
return Some(path);
}
}
obj.values().find_map(find_file_arg)
}
Value::Array(items) => items.iter().find_map(find_file_arg),
_ => None,
}
}
fn is_noise_path(path: &str) -> bool {
const NOISE: &[&str] = &[
"/.claude/",
"/.codex/",
"/.gemini/",
"/.git/",
"/node_modules/",
"/.npm/",
"/.cache/",
"CLAUDE.md",
"AGENTS.md",
];
NOISE.iter().any(|pat| path.contains(pat))
}
fn clean_prompt_text(text: &str) -> Option<String> {
let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
let text = text
.strip_prefix("<session>")
.and_then(|text| text.strip_suffix("</session>"))
.unwrap_or(&text)
.trim();
(!text.is_empty()).then(|| text.to_string())
}
fn short_session_id(id: &str) -> String {
let id = id.trim();
if id.is_empty() {
return "session".to_string();
}
let compact = id
.rsplit(['/', '\\'])
.next()
.unwrap_or(id)
.trim_end_matches(".jsonl");
const MAX_SESSION_ID_CHARS: usize = 12;
if compact.chars().count() <= MAX_SESSION_ID_CHARS {
return compact.to_string();
}
let head = compact.chars().take(6).collect::<String>();
let tail = compact
.chars()
.rev()
.take(5)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<String>();
format!("{head}.{tail}")
}
fn json_i64(value: &Value, key: &str) -> i64 {
value.get(key).and_then(Value::as_i64).unwrap_or(0)
}
fn json_u64(value: &Value, key: &str) -> u64 {
value.get(key).and_then(Value::as_u64).unwrap_or(0)
}
fn iso_ms(value: &str) -> Option<u64> {
chrono::DateTime::parse_from_rfc3339(value)
.ok()
.and_then(|ts| u64::try_from(ts.timestamp_millis()).ok())
}
fn system_time_ms(value: SystemTime) -> u64 {
value
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn local_session_ids_keep_distinct_conversation_id() {
assert_eq!(
local_session_ids(&json!({"sessionId": "run", "conversation_id": "conv"})),
(Some("run".to_string()), Some("conv".to_string()))
);
assert_eq!(
local_session_ids(&json!({"payload": {"thread_id": "thread"}})),
(Some("thread".to_string()), Some("thread".to_string()))
);
assert_eq!(
local_session_ids(&json!({"payload": {"model": "gpt"}})),
(None, None)
);
}
}