diff --git a/core/src/lib.rs b/core/src/lib.rs index 0f0c163..9f5a505 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -13,6 +13,7 @@ pub mod resume; pub mod retry; pub mod scoring; pub mod secrets; +pub mod sessions; pub mod tui; pub mod validate; pub mod watch; diff --git a/core/src/main.rs b/core/src/main.rs index dd44c92..53eb271 100644 --- a/core/src/main.rs +++ b/core/src/main.rs @@ -65,6 +65,21 @@ enum Commands { /// Handoff template: full (default), minimal, raw #[arg(long, default_value = "full")] template: String, + + /// Target a specific session by ID (or prefix). Use 'relay sessions' to list. + #[arg(long)] + session: Option, + }, + + /// List available Claude Code sessions + Sessions { + /// Maximum number of sessions to show + #[arg(long, default_value = "20")] + limit: usize, + + /// Filter by project path substring + #[arg(long)] + filter: Option, }, /// Show current session snapshot @@ -197,11 +212,31 @@ fn main() -> Result<()> { // ═══════════════════════════════════════════════════════════════ // HANDOFF // ═══════════════════════════════════════════════════════════════ - Commands::Handoff { to, deadline, dry_run, force, turns, include, clipboard, template } => { + Commands::Handoff { to, deadline, dry_run, force, turns, include, clipboard, template, session } => { if !cli.json { tui::print_banner(); } + // Resolve project directory from --session if provided + let project_dir = if let Some(ref sid) = session { + match relay::sessions::find_session(sid)? { + Some(entry) => { + if !cli.json { + eprintln!(" {} Targeting session {} ({})", + "📂".to_string(), &entry.session_id[..8], entry.project_path.dimmed()); + eprintln!(); + } + PathBuf::from(&entry.project_path) + } + None => { + eprintln!(" No session matching '{}'. Run 'relay sessions' to list.", sid); + return Ok(()); + } + } + } else { + project_dir + }; + let handoff_start = Instant::now(); // Step 1: Capture @@ -433,6 +468,28 @@ fn main() -> Result<()> { } } + // ═══════════════════════════════════════════════════════════════ + // SESSIONS + // ═══════════════════════════════════════════════════════════════ + Commands::Sessions { limit, filter: filter_project } => { + let sp = if !cli.json { Some(tui::spinner("Scanning sessions...")) } else { None }; + let mut sessions = relay::sessions::list_sessions()?; + if let Some(sp) = sp { sp.finish_and_clear(); } + + // Apply project filter + if let Some(ref filter) = filter_project { + sessions.retain(|s| s.project_path.contains(filter)); + } + + sessions.truncate(limit); + + if cli.json { + println!("{}", serde_json::to_string_pretty(&sessions)?); + } else { + tui::print_sessions(&sessions); + } + } + // ═══════════════════════════════════════════════════════════════ // STATUS // ═══════════════════════════════════════════════════════════════ diff --git a/core/src/sessions.rs b/core/src/sessions.rs new file mode 100644 index 0000000..f228ecc --- /dev/null +++ b/core/src/sessions.rs @@ -0,0 +1,241 @@ +//! Discover and list Claude Code sessions across all projects. +//! Scans ~/.claude/projects/ for .jsonl transcript files and extracts +//! session metadata (ID, project, timestamps, turn count, branch). + +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// Metadata for a single Claude Code session. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionEntry { + /// UUID session identifier (filename stem) + pub session_id: String, + /// Decoded project path (from the directory name) + pub project_path: String, + /// First timestamp in the transcript + pub started_at: String, + /// Last timestamp in the transcript + pub last_activity: String, + /// Number of user/assistant turns (excluding progress/system) + pub turns: usize, + /// Git branch at session start (if available) + pub branch: Option, + /// First user message (truncated) as task summary + pub task_summary: String, + /// Full path to the .jsonl file + pub transcript_path: String, +} + +/// Scan all Claude Code sessions and return metadata sorted by last_activity (newest first). +pub fn list_sessions() -> Result> { + let claude_projects = claude_projects_dir()?; + let mut entries = Vec::new(); + + let projects = std::fs::read_dir(&claude_projects)?; + for project_entry in projects.flatten() { + if !project_entry.path().is_dir() { + continue; + } + + let project_name = project_entry.file_name().to_string_lossy().to_string(); + let project_path = decode_project_path(&project_name); + + let Ok(files) = std::fs::read_dir(project_entry.path()) else { + continue; + }; + + for file_entry in files.flatten() { + let path = file_entry.path(); + if path.extension().map(|e| e == "jsonl").unwrap_or(false) && path.is_file() { + if let Some(entry) = parse_session_metadata(&path, &project_path) { + entries.push(entry); + } + } + } + } + + // Sort by last_activity descending (newest first) + entries.sort_by(|a, b| b.last_activity.cmp(&a.last_activity)); + + Ok(entries) +} + +/// Find a session by ID prefix (supports short IDs like first 8 chars). +pub fn find_session(session_id: &str) -> Result> { + let sessions = list_sessions()?; + let matched: Vec<_> = sessions + .into_iter() + .filter(|s| s.session_id.starts_with(session_id)) + .collect(); + + match matched.len() { + 0 => Ok(None), + 1 => Ok(Some(matched.into_iter().next().unwrap())), + _ => anyhow::bail!( + "Ambiguous session ID '{}' matches {} sessions. Use more characters.", + session_id, + matched.len() + ), + } +} + +/// Decode a project directory path from the encoded directory name. +/// e.g. "-Users-manavaryasingh-myproject" -> "/Users/manavaryasingh/myproject" +fn decode_project_path(encoded: &str) -> String { + if encoded.starts_with('-') { + format!("/{}", encoded[1..].replace('-', "/")) + } else { + encoded.replace('-', "/") + } +} + +/// Parse minimal metadata from a .jsonl transcript without reading the whole file. +fn parse_session_metadata(path: &Path, fallback_project_path: &str) -> Option { + let session_id = path.file_stem()?.to_string_lossy().to_string(); + + // Skip non-UUID-looking filenames (e.g. "skill-injections") + if !session_id.contains('-') || session_id.len() < 32 { + return None; + } + + let content = std::fs::read_to_string(path).ok()?; + if content.is_empty() { + return None; + } + + let lines: Vec<&str> = content.lines().collect(); + + let mut first_timestamp = String::new(); + let mut last_timestamp = String::new(); + let mut branch: Option = None; + let mut project_path: Option = None; + let mut turns = 0usize; + let mut task_summary = String::new(); + + for line in &lines { + let Ok(val) = serde_json::from_str::(line) else { + continue; + }; + + // Grab timestamp + if let Some(ts) = val.get("timestamp").and_then(|v| v.as_str()) { + if first_timestamp.is_empty() { + first_timestamp = ts.to_string(); + } + last_timestamp = ts.to_string(); + } + + // Grab cwd (actual project path) from the first entry that has one + if project_path.is_none() { + if let Some(cwd) = val.get("cwd").and_then(|v| v.as_str()) { + if !cwd.is_empty() { + project_path = Some(cwd.to_string()); + } + } + } + + // Grab branch from first entry that has one + if branch.is_none() { + if let Some(b) = val.get("gitBranch").and_then(|v| v.as_str()) { + if !b.is_empty() { + branch = Some(b.to_string()); + } + } + } + + // Count turns and grab first user message as task summary + let msg_type = val.get("type").and_then(|v| v.as_str()).unwrap_or(""); + match msg_type { + "user" => { + // Only count non-tool-result user messages + if val.get("toolUseResult").is_none() { + turns += 1; + if task_summary.is_empty() { + let message = val.get("message").cloned().unwrap_or_default(); + let text = extract_user_text(message.get("content")); + if text.len() > 5 && !text.starts_with('/') { + task_summary = truncate(&text, 80); + } + } + } + } + "assistant" => { + turns += 1; + } + _ => {} + } + } + + if first_timestamp.is_empty() { + return None; + } + + // Format timestamps for display (ISO -> local-friendly) + let started_at = format_timestamp(&first_timestamp); + let last_activity = format_timestamp(&last_timestamp); + + if task_summary.is_empty() { + task_summary = "(no user message)".into(); + } + + Some(SessionEntry { + session_id, + project_path: project_path.unwrap_or_else(|| fallback_project_path.to_string()), + started_at, + last_activity, + turns, + branch, + task_summary, + transcript_path: path.to_string_lossy().to_string(), + }) +} + +fn extract_user_text(content: Option<&serde_json::Value>) -> String { + let Some(c) = content else { return String::new() }; + if let Some(s) = c.as_str() { + return s.to_string(); + } + if let Some(arr) = c.as_array() { + for item in arr { + if item.get("type").and_then(|t| t.as_str()) == Some("text") { + if let Some(t) = item.get("text").and_then(|t| t.as_str()) { + return t.to_string(); + } + } + } + } + String::new() +} + +fn format_timestamp(ts: &str) -> String { + // Try to parse ISO 8601 and format as local time + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts) { + let local: chrono::DateTime = dt.into(); + return local.format("%Y-%m-%d %H:%M").to_string(); + } + // Fallback: return as-is but truncated + ts.chars().take(16).collect() +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + return s.to_string(); + } + let mut end = max; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + format!("{}...", &s[..end]) +} + +fn claude_projects_dir() -> Result { + let home = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?; + let dir = PathBuf::from(home).join(".claude/projects"); + if !dir.exists() { + anyhow::bail!("Claude projects directory not found: {}", dir.display()); + } + Ok(dir) +} diff --git a/core/src/tui.rs b/core/src/tui.rs index bb2e7d6..78d767b 100644 --- a/core/src/tui.rs +++ b/core/src/tui.rs @@ -297,6 +297,69 @@ pub fn print_agents( eprintln!(); } +// ─── Sessions Display ────────────────────────────────────────────────────── + +pub fn print_sessions(sessions: &[crate::sessions::SessionEntry]) { + eprintln!(); + let term_width = Term::stdout().size().1 as usize; + let width = term_width.min(72).max(40); + eprintln!(" {}", "═".repeat(width).cyan()); + eprintln!(" {} {}", "📂", "Claude Code Sessions".bold().cyan()); + eprintln!(" {}", "═".repeat(width).cyan()); + eprintln!(); + + if sessions.is_empty() { + eprintln!(" {}", "No sessions found.".dimmed()); + eprintln!(); + return; + } + + for (i, s) in sessions.iter().enumerate() { + let id_short = if s.session_id.len() >= 8 { + &s.session_id[..8] + } else { + &s.session_id + }; + + let branch_str = s.branch.as_deref().unwrap_or("-"); + + eprintln!( + " {} {} {} {} turns", + id_short.cyan().bold(), + s.last_activity.dimmed(), + branch_str.green(), + s.turns, + ); + eprintln!( + " {}", + s.project_path.dimmed(), + ); + + let task = if s.task_summary.len() > 60 { + let mut end = 57; + while end > 0 && !s.task_summary.is_char_boundary(end) { end -= 1; } + format!("{}...", &s.task_summary[..end]) + } else { + s.task_summary.clone() + }; + eprintln!(" {}", task); + + if i < sessions.len() - 1 { + eprintln!(" {}", "─".repeat(width.min(60)).dimmed()); + } + } + + eprintln!(); + eprintln!( + " {} {} session{}", + "📊".to_string(), + sessions.len().to_string().bold(), + if sessions.len() == 1 { "" } else { "s" }, + ); + eprintln!(" {}", "Use: relay handoff --session to target a specific session".dimmed()); + eprintln!(); +} + // ─── Handoff Result ───────────────────────────────────────────────────────── pub fn print_handoff_success(agent: &str, file: &str) {