pub mod providers; pub mod terminal; use serde::Serialize; use std::path::{Path, PathBuf}; use providers::{claude, codex, gemini, openclaw, opencode}; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SessionMeta { pub provider_id: String, pub session_id: String, #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, #[serde(skip_serializing_if = "Option::is_none")] pub summary: Option, #[serde(skip_serializing_if = "Option::is_none")] pub project_dir: Option, #[serde(skip_serializing_if = "Option::is_none")] pub created_at: Option, #[serde(skip_serializing_if = "Option::is_none")] pub last_active_at: Option, #[serde(skip_serializing_if = "Option::is_none")] pub source_path: Option, #[serde(skip_serializing_if = "Option::is_none")] pub resume_command: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SessionMessage { pub role: String, pub content: String, #[serde(skip_serializing_if = "Option::is_none")] pub ts: Option, } pub fn scan_sessions() -> Vec { let (r1, r2, r3, r4, r5) = std::thread::scope(|s| { let h1 = s.spawn(codex::scan_sessions); let h2 = s.spawn(claude::scan_sessions); let h3 = s.spawn(opencode::scan_sessions); let h4 = s.spawn(openclaw::scan_sessions); let h5 = s.spawn(gemini::scan_sessions); ( h1.join().unwrap_or_default(), h2.join().unwrap_or_default(), h3.join().unwrap_or_default(), h4.join().unwrap_or_default(), h5.join().unwrap_or_default(), ) }); let mut sessions = Vec::new(); sessions.extend(r1); sessions.extend(r2); sessions.extend(r3); sessions.extend(r4); sessions.extend(r5); sessions.sort_by(|a, b| { let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0); let b_ts = b.last_active_at.or(b.created_at).unwrap_or(0); b_ts.cmp(&a_ts) }); sessions } pub fn load_messages(provider_id: &str, source_path: &str) -> Result, String> { // OpenCode SQLite sessions use a "sqlite:" prefixed source_path if provider_id == "opencode" && source_path.starts_with("sqlite:") { return opencode::load_messages_sqlite(source_path); } let path = Path::new(source_path); match provider_id { "codex" => codex::load_messages(path), "claude" => claude::load_messages(path), "opencode" => opencode::load_messages(path), "openclaw" => openclaw::load_messages(path), "gemini" => gemini::load_messages(path), _ => Err(format!("Unsupported provider: {provider_id}")), } } pub fn delete_session( provider_id: &str, session_id: &str, source_path: &str, ) -> Result { // OpenCode SQLite sessions bypass the file-based deletion path if provider_id == "opencode" && source_path.starts_with("sqlite:") { return opencode::delete_session_sqlite(session_id, source_path); } let root = provider_root(provider_id)?; delete_session_with_root(provider_id, session_id, Path::new(source_path), &root) } fn delete_session_with_root( provider_id: &str, session_id: &str, source_path: &Path, root: &Path, ) -> Result { let validated_root = canonicalize_existing_path(root, "session root")?; let validated_source = canonicalize_existing_path(source_path, "session source")?; if !validated_source.starts_with(&validated_root) { return Err(format!( "Session source path is outside provider root: {}", source_path.display() )); } match provider_id { "codex" => codex::delete_session(&validated_root, &validated_source, session_id), "claude" => claude::delete_session(&validated_root, &validated_source, session_id), "opencode" => opencode::delete_session(&validated_root, &validated_source, session_id), "openclaw" => openclaw::delete_session(&validated_root, &validated_source, session_id), "gemini" => gemini::delete_session(&validated_root, &validated_source, session_id), _ => Err(format!("Unsupported provider: {provider_id}")), } } fn provider_root(provider_id: &str) -> Result { let root = match provider_id { "codex" => crate::codex_config::get_codex_config_dir().join("sessions"), "claude" => crate::config::get_claude_config_dir().join("projects"), "opencode" => opencode::get_opencode_data_dir(), "openclaw" => crate::openclaw_config::get_openclaw_dir().join("agents"), "gemini" => crate::gemini_config::get_gemini_dir().join("tmp"), _ => return Err(format!("Unsupported provider: {provider_id}")), }; Ok(root) } fn canonicalize_existing_path(path: &Path, label: &str) -> Result { if !path.exists() { return Err(format!("{label} not found: {}", path.display())); } path.canonicalize() .map_err(|e| format!("Failed to resolve {label} {}: {e}", path.display())) } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; #[test] fn rejects_source_path_outside_provider_root() { let root = tempdir().expect("tempdir"); let outside = tempdir().expect("tempdir"); let source = outside.path().join("session.jsonl"); std::fs::write(&source, "{}").expect("write source"); let err = delete_session_with_root("codex", "session-1", &source, root.path()) .expect_err("expected outside-root path to be rejected"); assert!(err.contains("outside provider root")); } #[test] fn rejects_missing_source_path() { let root = tempdir().expect("tempdir"); let missing = root.path().join("missing.jsonl"); let err = delete_session_with_root("codex", "session-1", &missing, root.path()) .expect_err("expected missing source path to fail"); assert!(err.contains("session source not found")); } }