pub mod claude;
pub mod codex;
pub mod cursor;
pub mod extract;
pub mod gemini;
pub mod opencode;
pub mod pi;
pub mod search;
pub mod windsurf;
use crate::pricing::Provider;
use crate::util;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Surface {
Cli,
Editor,
DesktopCode,
DesktopCowork,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ActivityState {
#[default]
Working,
WaitingForInput,
ApiError,
}
impl Surface {
pub fn is_desktop(&self) -> bool {
matches!(self, Surface::DesktopCode | Surface::DesktopCowork)
}
pub fn label(&self, provider: Provider) -> &'static str {
match (self, provider) {
(Surface::Editor, Provider::Cursor) => "Cursor",
(Surface::DesktopCowork, _) => "Claude Cowork",
(Surface::DesktopCode, _) => "Claude Code",
(_, Provider::Claude) => "Claude",
(_, Provider::Codex) => "Codex",
(_, Provider::Cursor) => "Cursor",
(_, Provider::Gemini) => "Gemini",
(_, Provider::OpenCode) => "OpenCode",
(_, Provider::Pi) => "Pi",
(_, Provider::Windsurf) => "Windsurf",
}
}
}
#[derive(Debug, Clone)]
pub struct MacMeta {
pub meta_path: PathBuf,
pub session_dir: PathBuf,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct ContextUsage {
pub used: u64,
pub max: u64,
#[serde(default, alias = "compacting")]
pub compacted: bool,
}
impl ContextUsage {
pub fn percent_to_compact(&self) -> f64 {
let compact_at = self.max as f64 * *crate::config::COMPACT_THRESHOLD;
if compact_at <= 0.0 {
return 0.0;
}
self.used as f64 / compact_at * 100.0
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub struct ContextBreakdown {
pub total: u64,
pub startup: u64,
pub tool_output: u64,
pub tool_input: u64,
pub attachments: u64,
pub user_text: u64,
pub assistant_text: u64,
pub after_compaction: bool,
pub superseded: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CtxPoint {
pub ts: String,
pub window: u64,
#[serde(default)]
pub after_compaction: bool,
}
pub const MAX_CTX_POINTS: usize = 2000;
pub fn decimate(series: &mut Vec<CtxPoint>) {
if series.len() <= MAX_CTX_POINTS {
return;
}
let last = series.pop();
let mut kept: Vec<CtxPoint> = series.iter().step_by(2).cloned().collect();
if let Some(last) = last {
kept.push(last);
}
*series = kept;
}
impl ContextBreakdown {
pub fn estimated(&self) -> u64 {
self.tool_output + self.tool_input + self.attachments + self.user_text + self.assistant_text
}
pub fn unaccounted(&self) -> i64 {
self.total as i64 - self.startup as i64 - self.estimated() as i64
}
}
#[derive(Debug, Clone)]
pub struct Session {
pub provider: Provider,
pub surface: Surface,
pub session_id: String,
pub started_at: String,
pub last_active: String,
pub model: String,
pub harness: String,
pub label_source: String,
pub data_file: Option<PathBuf>,
pub title: Option<String>,
pub mac_meta: Option<MacMeta>,
pub abbrev_label: String,
pub input_tokens: u64,
pub output_tokens: u64,
pub tool_count: u64,
pub total_cost: Option<f64>,
pub cost_available: bool,
pub cost_is_free: bool,
pub cost_hour: f64,
pub cost_today: f64,
pub costs_by_day: HashMap<String, HashMap<String, f64>>,
pub costs_by_hour: HashMap<String, HashMap<String, f64>>,
pub subagents: Vec<Subagent>,
pub subagents_cost: f64,
pub context: Option<ContextUsage>,
pub last_tool: String,
pub process: Option<crate::proc::ProcInfo>,
pub inferred_running: bool,
pub activity_state: ActivityState,
pub tokens_per_min: f64,
pub cost_per_min: f64,
}
impl Session {
pub fn new(provider: Provider, session_id: String) -> Self {
Session {
provider,
surface: Surface::Cli,
session_id,
started_at: String::new(),
last_active: String::new(),
model: String::new(),
harness: String::new(),
label_source: String::new(),
data_file: None,
title: None,
mac_meta: None,
abbrev_label: String::new(),
input_tokens: 0,
output_tokens: 0,
tool_count: 0,
total_cost: Some(0.0),
cost_available: true,
cost_is_free: false,
cost_hour: 0.0,
cost_today: 0.0,
costs_by_day: HashMap::new(),
costs_by_hour: HashMap::new(),
subagents: Vec::new(),
subagents_cost: 0.0,
context: None,
last_tool: String::new(),
process: None,
inferred_running: false,
activity_state: ActivityState::Working,
tokens_per_min: 0.0,
cost_per_min: 0.0,
}
}
pub fn key(&self) -> String {
format!("{}:{}", self.provider.as_str(), self.session_id)
}
pub fn is_running(&self) -> bool {
self.process.is_some() || self.inferred_running
}
pub fn resume_argv(&self) -> Option<Vec<String>> {
let argv = match self.provider {
Provider::Claude => vec!["claude", "--resume", &self.session_id],
Provider::Codex => vec!["codex", "resume", &self.session_id],
Provider::OpenCode => vec!["opencode", "--session", &self.session_id],
Provider::Pi => vec!["pi", "--session", &self.session_id],
Provider::Cursor | Provider::Gemini | Provider::Windsurf => return None,
};
Some(argv.into_iter().map(str::to_string).collect())
}
pub fn work_dir(&self) -> Option<PathBuf> {
Some(PathBuf::from(&self.label_source)).filter(|dir| dir.is_dir())
}
pub fn is_compacting(&self) -> bool {
self.context.is_some_and(|c| c.compacted) && self.is_running()
}
pub fn display_label(&self) -> &str {
self.title
.as_deref()
.filter(|t| !t.is_empty())
.unwrap_or_else(|| {
if self.abbrev_label.is_empty() {
if self.label_source.is_empty() {
"unknown"
} else {
&self.label_source
}
} else {
&self.abbrev_label
}
})
}
}
pub fn extract_activity_state(session: &Session) -> ActivityState {
let Some(file) = session.data_file.as_ref() else {
return ActivityState::Working;
};
if session.provider == crate::pricing::Provider::OpenCode {
return opencode::extract_activity_state(file, &session.session_id);
}
let Some(text) = crate::util::read_tail(file, 65_536) else {
return ActivityState::Working;
};
for line in text.lines().rev() {
let Ok(item) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
continue;
};
if is_api_error_event(&item) {
return ActivityState::ApiError;
}
if is_waiting_for_input_event(session.provider, &item) {
return ActivityState::WaitingForInput;
}
if is_passive_event(&item) {
continue;
}
return ActivityState::Working;
}
ActivityState::Working
}
fn is_passive_event(item: &serde_json::Value) -> bool {
let kind = item
.get("type")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
if matches!(kind, "session_meta" | "turn_context") {
return true;
}
kind == "event_msg"
&& item
.get("payload")
.and_then(|p| p.get("type"))
.and_then(serde_json::Value::as_str)
== Some("token_count")
}
fn is_api_error_event(item: &serde_json::Value) -> bool {
let kind = item
.get("type")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let subtype = item
.get("subtype")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
if matches!(kind, "error" | "api_error") || matches!(subtype, "api_error" | "error") {
return true;
}
let payload = item.get("payload").unwrap_or(item);
matches!(
payload.get("type").and_then(serde_json::Value::as_str),
Some("error" | "api_error" | "stream_error" | "turn_aborted")
)
}
fn is_waiting_for_input_event(
provider: crate::pricing::Provider,
item: &serde_json::Value,
) -> bool {
match provider {
crate::pricing::Provider::Claude | crate::pricing::Provider::Cursor => {
if item.get("type").and_then(serde_json::Value::as_str) != Some("assistant") {
return false;
}
let Some(blocks) = item
.get("message")
.and_then(|m| m.get("content"))
.and_then(serde_json::Value::as_array)
else {
return false;
};
blocks.iter().any(|b| {
matches!(
b.get("type").and_then(serde_json::Value::as_str),
Some("tool_use" | "toolCall")
) && b
.get("name")
.and_then(serde_json::Value::as_str)
.is_some_and(is_input_request_tool)
})
}
crate::pricing::Provider::Codex => {
let payload = item.get("payload").unwrap_or(item);
matches!(
item.get("type").and_then(serde_json::Value::as_str),
Some("function_call" | "custom_tool_call" | "response_item")
) && payload
.get("name")
.and_then(serde_json::Value::as_str)
.is_some_and(is_input_request_tool)
}
crate::pricing::Provider::Pi => {
item.get("type").and_then(serde_json::Value::as_str) == Some("message")
&& item
.get("message")
.and_then(|m| m.get("content"))
.and_then(serde_json::Value::as_array)
.is_some_and(|blocks| {
blocks.iter().any(|b| {
b.get("type").and_then(serde_json::Value::as_str) == Some("toolCall")
&& b.get("name")
.and_then(serde_json::Value::as_str)
.is_some_and(is_input_request_tool)
})
})
}
crate::pricing::Provider::Gemini => {
item.get("type").and_then(serde_json::Value::as_str) == Some("gemini")
&& item
.get("toolCalls")
.and_then(serde_json::Value::as_array)
.is_some_and(|calls| {
calls.iter().any(|call| {
call.get("name")
.and_then(serde_json::Value::as_str)
.is_some_and(is_input_request_tool)
})
})
}
crate::pricing::Provider::OpenCode | crate::pricing::Provider::Windsurf => false,
}
}
fn is_input_request_tool(name: &str) -> bool {
matches!(
name.to_ascii_lowercase().as_str(),
"askuserquestion"
| "ask_user_question"
| "ask_user"
| "askuser"
| "question"
| "request_user_input"
)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn each_provider_resumes_the_way_its_harness_does() {
let argv = |provider| Session::new(provider, "sid".into()).resume_argv();
assert_eq!(
argv(Provider::Claude),
Some(vec!["claude".into(), "--resume".into(), "sid".into()])
);
assert_eq!(
argv(Provider::Codex),
Some(vec!["codex".into(), "resume".into(), "sid".into()])
);
assert_eq!(
argv(Provider::OpenCode),
Some(vec!["opencode".into(), "--session".into(), "sid".into()])
);
assert_eq!(
argv(Provider::Pi),
Some(vec!["pi".into(), "--session".into(), "sid".into()])
);
for provider in [Provider::Cursor, Provider::Gemini, Provider::Windsurf] {
assert_eq!(argv(provider), None, "{provider:?} has no resume command");
}
}
#[test]
fn a_resumed_session_only_claims_a_directory_that_exists() {
let mut s = Session::new(Provider::Claude, "sid".into());
s.label_source = "/nonexistent/gone".into();
assert_eq!(s.work_dir(), None);
s.label_source = std::env::temp_dir().to_string_lossy().into_owned();
assert_eq!(s.work_dir(), Some(std::env::temp_dir()));
}
#[test]
fn classifies_completed_assistant_responses_as_waiting() {
let claude = json!({
"type": "assistant",
"message": {"content": [{"type": "tool_use", "name": "AskUserQuestion"}]}
});
assert!(is_waiting_for_input_event(Provider::Claude, &claude));
let codex = json!({
"type": "function_call",
"payload": {"name": "request_user_input"}
});
assert!(is_waiting_for_input_event(Provider::Codex, &codex));
}
#[test]
fn keeps_tool_turns_and_api_errors_distinct() {
let tool_turn = json!({
"type": "assistant",
"message": {"content": [{"type": "tool_use", "name": "Read"}]}
});
assert!(!is_waiting_for_input_event(Provider::Claude, &tool_turn));
let error = json!({"type": "system", "subtype": "api_error"});
assert!(is_api_error_event(&error));
}
#[test]
fn ignores_codex_token_bookkeeping_when_finding_last_state() {
let item = json!({
"type": "event_msg",
"payload": {"type": "token_count"}
});
assert!(is_passive_event(&item));
}
#[test]
fn tail_state_uses_the_last_meaningful_codex_event() {
let path = std::env::temp_dir().join(format!(
"cctop-activity-state-{}-{}.jsonl",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock after epoch")
.as_nanos()
));
std::fs::write(
&path,
concat!(
"{\"type\":\"function_call\",\"payload\":{\"name\":\"request_user_input\"}}\n",
"{\"type\":\"event_msg\",\"payload\":{\"type\":\"token_count\"}}\n"
),
)
.expect("write transcript");
let mut session = Session::new(Provider::Codex, "test".into());
session.data_file = Some(path.clone());
assert_eq!(
extract_activity_state(&session),
ActivityState::WaitingForInput
);
let _ = std::fs::remove_file(path);
}
fn detail(ts: &str, full: &str) -> ToolDetail {
ToolDetail {
d: "x".into(),
ts: ts.into(),
full: Some(full.into()),
..Default::default()
}
}
#[test]
fn finalize_caps_details_per_session_keeping_the_newest() {
let mut data = SessionData::default();
for tool in ["Read", "Bash"] {
let list = data.metrics.tool_details.entry(tool.into()).or_default();
for i in 0..crate::config::MAX_TOOL_DETAILS {
list.push(detail(&format!("2026-01-01T00:{i:04}"), "arg"));
}
}
data.metrics
.tool_details
.entry("Edit".into())
.or_default()
.push(detail("2027-01-01T00:00", "arg"));
data.finalize();
let total: usize = data.metrics.tool_details.values().map(Vec::len).sum();
assert_eq!(total, crate::config::MAX_SESSION_TOOL_DETAILS);
assert_eq!(data.metrics.tool_details["Edit"].len(), 1);
let read = &data.metrics.tool_details["Read"];
assert_eq!(
read.last().expect("kept details").ts,
format!("2026-01-01T00:{:04}", crate::config::MAX_TOOL_DETAILS - 1)
);
}
#[test]
fn finalize_bounds_the_large_string_fields() {
let mut data = SessionData::default();
let mut d = detail("2026-01-01T00:00", &"é".repeat(5_000));
d.delta = Some(Delta {
added: 1,
removed: 0,
hunks: vec!["+".repeat(5_000)],
});
data.metrics.tool_details.insert("Bash".into(), vec![d]);
data.finalize();
let d = &data.metrics.tool_details["Bash"][0];
assert_eq!(
d.full.as_ref().expect("full kept").chars().count(),
crate::config::MAX_TOOL_DETAIL_CHARS + 1 );
assert_eq!(
d.delta.as_ref().expect("delta kept").hunks[0]
.chars()
.count(),
crate::config::MAX_DIFF_LINE_CHARS + 1
);
}
#[test]
fn finalize_leaves_a_small_session_untouched() {
let mut data = SessionData::default();
data.metrics
.tool_details
.insert("Bash".into(), vec![detail("2026-01-01T00:00", "ls")]);
data.finalize();
assert_eq!(
data.metrics.tool_details["Bash"][0].full.as_deref(),
Some("ls")
);
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Tokens {
#[serde(default)]
pub input: u64,
#[serde(default)]
pub output: u64,
#[serde(default)]
pub cache_read: u64,
#[serde(default)]
pub cache_write_5m: u64,
#[serde(default)]
pub cache_write_1h: u64,
#[serde(default)]
pub input_total: u64,
#[serde(default)]
pub cached_input: u64,
#[serde(default)]
pub reasoning_output: u64,
#[serde(default)]
pub total: u64,
}
impl Tokens {
pub fn all_input(&self) -> u64 {
self.input + self.cached_input + self.cache_read + self.cache_write_5m + self.cache_write_1h
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Costs {
#[serde(default)]
pub input: f64,
#[serde(default)]
pub output: f64,
#[serde(default)]
pub cache_read: f64,
#[serde(default)]
pub cache_write_5m: f64,
#[serde(default)]
pub cache_write_1h: f64,
#[serde(default)]
pub cached_input: f64,
#[serde(default)]
pub total: f64,
}
#[derive(Default)]
pub struct FallbackRates(HashMap<String, Option<crate::pricing::GenericPricing>>);
impl FallbackRates {
pub fn costs(&mut self, model: &str, tokens: &Tokens) -> Option<Costs> {
let p = (*self
.0
.entry(model.to_string())
.or_insert_with(|| crate::pricing::resolve_generic(model)))?;
let per_m = |count: u64, rate: f64| count as f64 * rate / 1e6;
let costs = Costs {
input: per_m(tokens.input, p.input),
output: per_m(tokens.output, p.output),
cache_read: per_m(tokens.cache_read, p.cache_read),
cache_write_5m: per_m(tokens.cache_write_5m, p.cache_write),
cache_write_1h: per_m(tokens.cache_write_1h, p.cache_write),
cached_input: per_m(tokens.cached_input, p.cache_read),
total: 0.0,
};
Some(Costs {
total: costs.input
+ costs.output
+ costs.cache_read
+ costs.cache_write_5m
+ costs.cache_write_1h
+ costs.cached_input,
..costs
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelBreakdown {
pub model: String,
pub tokens: Tokens,
pub costs: Costs,
pub total: f64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Delta {
pub added: u32,
pub removed: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hunks: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ToolDetail {
pub d: String,
pub ts: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub full: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dur_ms: Option<i64>,
#[serde(default)]
pub tokens_in: u64,
#[serde(default)]
pub tokens_out: u64,
#[serde(default)]
pub shared: u8,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delta: Option<Delta>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub failed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Metrics {
pub tool_count: u64,
pub tools: HashMap<String, u64>,
pub tool_details: HashMap<String, Vec<ToolDetail>>,
pub mcp_tool_count: u64,
pub mcp_tools: Vec<String>,
pub skill_count: u64,
pub skills: HashMap<String, u64>,
pub web_fetch_count: u64,
pub web_fetches: Vec<String>,
pub web_search_count: u64,
pub web_searches: Vec<String>,
pub lines_added: u64,
pub lines_removed: u64,
pub api_duration_ms: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SubagentStatus {
Running,
Done,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Subagent {
pub agent_id: String,
#[serde(rename = "type")]
pub agent_type: String,
pub description: String,
pub model: String,
pub started_at: Option<String>,
pub last_active: Option<String>,
pub duration_ms: i64,
pub status: SubagentStatus,
pub cost: f64,
pub tool_count: u64,
pub tool_use_id: Option<String>,
pub context: Option<ContextUsage>,
pub ghost: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SessionData {
pub title: Option<String>,
pub custom_title: Option<String>,
pub ai_title: Option<String>,
pub last_model: String,
#[serde(default)]
pub reasoning_effort: Option<String>,
pub models: Vec<String>,
pub model_breakdown: Vec<ModelBreakdown>,
pub tokens: Tokens,
pub costs: Costs,
pub costs_by_day: HashMap<String, HashMap<String, f64>>,
pub costs_by_hour: HashMap<String, HashMap<String, f64>>,
pub metrics: Metrics,
#[serde(default)]
pub context_breakdown: Option<ContextBreakdown>,
#[serde(default)]
pub context_series: Vec<CtxPoint>,
pub subagents: Vec<Subagent>,
pub rates: Option<CodexRates>,
pub error: Option<String>,
}
fn trim_tool_details(details: &mut HashMap<String, Vec<ToolDetail>>) {
for list in details.values_mut() {
for d in list.iter_mut() {
truncate_chars(&mut d.d, crate::config::MAX_TOOL_DETAIL_CHARS);
if let Some(full) = d.full.as_mut() {
truncate_chars(full, crate::config::MAX_TOOL_DETAIL_CHARS);
}
if let Some(delta) = d.delta.as_mut() {
for line in delta.hunks.iter_mut() {
truncate_chars(line, crate::config::MAX_DIFF_LINE_CHARS);
}
}
}
}
let total: usize = details.values().map(Vec::len).sum();
if total <= crate::config::MAX_SESSION_TOOL_DETAILS {
return;
}
let mut ranked: Vec<(&str, usize)> = details
.iter()
.flat_map(|(name, list)| (0..list.len()).map(move |i| (name.as_str(), i)))
.collect();
ranked.sort_by(|a, b| {
details[b.0][b.1]
.ts
.cmp(&details[a.0][a.1].ts)
.then(b.1.cmp(&a.1))
});
ranked.truncate(crate::config::MAX_SESSION_TOOL_DETAILS);
let mut keep: HashMap<&str, Vec<bool>> = details
.iter()
.map(|(name, list)| (name.as_str(), vec![false; list.len()]))
.collect();
for (name, i) in ranked {
keep.get_mut(name).expect("name came from details")[i] = true;
}
let keep: HashMap<String, Vec<bool>> =
keep.into_iter().map(|(k, v)| (k.to_string(), v)).collect();
for (name, list) in details.iter_mut() {
let flags = &keep[name];
let mut i = 0;
list.retain(|_| {
i += 1;
flags[i - 1]
});
}
details.retain(|_, list| !list.is_empty());
}
fn truncate_chars(s: &mut String, max: usize) {
if s.chars().count() <= max {
return;
}
let end = s.char_indices().nth(max).map(|(i, _)| i).unwrap_or(s.len());
s.truncate(end);
s.push('…');
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct CodexRates {
pub input: f64,
pub cached_input: f64,
pub output: f64,
}
impl SessionData {
fn bucket_total(map: &HashMap<String, HashMap<String, f64>>, key: &str) -> f64 {
map.get(key).map(|m| m.values().sum()).unwrap_or(0.0)
}
pub fn cost_this_hour(&self) -> f64 {
let key = crate::util::local_hour_key(&chrono::Utc::now());
Self::bucket_total(&self.costs_by_hour, &key)
}
pub fn finalize(&mut self) {
trim_tool_details(&mut self.metrics.tool_details);
}
pub fn cost_today(&self) -> f64 {
let today = crate::util::local_date_key(&chrono::Utc::now());
self.costs_by_day
.iter()
.filter(|(day, _)| day.as_str() >= today.as_str())
.map(|(_, m)| m.values().sum::<f64>())
.sum()
}
}
pub fn list_all() -> Vec<Session> {
let ((mut codex, claude), ((opencode, pi), (cursor, (gemini, windsurf)))) = rayon::join(
|| rayon::join(codex::list_sessions, claude::list_sessions),
|| {
rayon::join(
|| rayon::join(opencode::list_sessions, pi::list_sessions),
|| {
rayon::join(cursor::list_sessions, || {
rayon::join(gemini::list_sessions, windsurf::list_sessions)
})
},
)
},
);
codex.extend(claude);
codex.extend(opencode);
codex.extend(pi);
codex.extend(cursor);
codex.extend(gemini);
codex.extend(windsurf);
let mut sessions = codex;
sessions.sort_by(|a, b| b.started_at.cmp(&a.started_at));
sessions
}
pub fn transcript_files(main: &Path) -> Vec<PathBuf> {
let mut files = vec![main.to_path_buf()];
let stem = main.with_extension("");
let subagents_dir = stem.join("subagents");
if subagents_dir.is_dir() {
for entry in crate::config::list_dir(&subagents_dir) {
if entry.ends_with(".jsonl") {
files.push(subagents_dir.join(entry));
}
}
}
files
}
pub fn effective_mtime_ms(session: &Session) -> u64 {
let Some(f) = &session.data_file else {
return 0;
};
match session.provider {
Provider::Claude => transcript_files(f)
.iter()
.map(|p| crate::config::file_mtime_ms(p))
.max()
.unwrap_or(0),
Provider::Codex | Provider::Cursor | Provider::Gemini | Provider::Pi => {
crate::config::file_mtime_ms(f)
}
Provider::OpenCode | Provider::Windsurf => util::parse_ts(&session.last_active)
.map(|d| d.timestamp_millis().max(0) as u64)
.unwrap_or_else(|| crate::config::file_mtime_ms(f)),
}
}