diff --git a/crates/g3-cli/src/agent_mode.rs b/crates/g3-cli/src/agent_mode.rs index 8160ede..0032950 100644 --- a/crates/g3-cli/src/agent_mode.rs +++ b/crates/g3-cli/src/agent_mode.rs @@ -44,6 +44,25 @@ pub async fn run_agent_mode( // Skip session resume entirely when in chat mode (--agent --chat) let resuming_session = if chat { None // Chat mode always starts fresh + } else if let Some(ref session_id) = flags.resume { + // Explicit --resume flag takes precedence + match g3_core::load_continuation_by_id(session_id) { + Ok(continuation) => { + // Verify the session matches this agent (or allow any if agent name matches) + if continuation.agent_name.as_deref() != Some(agent_name) { + eprintln!("Error: Session '{}' belongs to agent '{}', not '{}'", + session_id, + continuation.agent_name.as_deref().unwrap_or("(none)"), + agent_name); + std::process::exit(1); + } + Some(continuation) + } + Err(e) => { + eprintln!("Error: {}", e); + std::process::exit(1); + } + } } else if flags.new_session { if !chat { output.print("\nšŸ†• Starting new session (--new-session flag set)"); diff --git a/crates/g3-cli/src/cli_args.rs b/crates/g3-cli/src/cli_args.rs index ce1df6b..6b82f55 100644 --- a/crates/g3-cli/src/cli_args.rs +++ b/crates/g3-cli/src/cli_args.rs @@ -30,6 +30,8 @@ pub struct CommonFlags { pub acd: bool, /// Load a project from the given path at startup pub project: Option, + /// Resume a specific session by ID + pub resume: Option, } #[derive(Parser, Clone)] @@ -136,6 +138,10 @@ pub struct Cli { #[arg(long)] pub new_session: bool, + /// Resume a specific session by ID (full or partial prefix) + #[arg(long, value_name = "SESSION_ID", conflicts_with = "new_session")] + pub resume: Option, + /// Automatically remind LLM to call remember tool after turns with tool calls #[arg(long)] pub auto_memory: bool, @@ -172,6 +178,7 @@ impl Cli { no_auto_memory: self.no_auto_memory, acd: self.acd, project: self.project.clone(), + resume: self.resume.clone(), } } } diff --git a/crates/g3-cli/src/lib.rs b/crates/g3-cli/src/lib.rs index 846cfcb..7ffee9a 100644 --- a/crates/g3-cli/src/lib.rs +++ b/crates/g3-cli/src/lib.rs @@ -246,13 +246,38 @@ async fn run_console_mode( agent.save_session_continuation(Some(result.response.clone())); Ok(()) } else { + // Handle --resume flag: load and restore specific session + if let Some(ref session_id) = cli.resume { + use crate::g3_status::{G3Status, Status}; + + match g3_core::load_continuation_by_id(session_id) { + Ok(continuation) => { + match agent.restore_from_continuation(&continuation) { + Ok(true) => { + G3Status::resuming(&continuation.session_id, Status::Done); + } + Ok(false) => { + G3Status::resuming_summary(&continuation.session_id); + } + Err(e) => { + G3Status::resuming(&continuation.session_id, Status::Error(e.to_string())); + } + } + } + Err(e) => { + eprintln!("Error: {}", e); + std::process::exit(1); + } + } + } + run_interactive( agent, cli.show_prompt, cli.show_code, combined_content, project.workspace(), - cli.new_session, + cli.new_session || cli.resume.is_some(), // Skip auto-resume prompt if --resume was used None, // agent_name (not in agent mode) initial_project, ) diff --git a/crates/g3-core/src/lib.rs b/crates/g3-core/src/lib.rs index bec5498..4d4e706 100644 --- a/crates/g3-core/src/lib.rs +++ b/crates/g3-core/src/lib.rs @@ -31,7 +31,7 @@ pub use retry::{execute_with_retry, retry_operation, RetryConfig, RetryResult}; pub use session_continuation::{ clear_continuation, find_incomplete_agent_session, format_session_time, get_session_dir, has_valid_continuation, list_sessions_for_directory, load_context_from_session_log, - load_continuation, save_continuation, SessionContinuation, + load_continuation, load_continuation_by_id, save_continuation, SessionContinuation, }; pub use task_result::TaskResult; diff --git a/crates/g3-core/src/session_continuation.rs b/crates/g3-core/src/session_continuation.rs index 200c169..2ff1e6c 100644 --- a/crates/g3-core/src/session_continuation.rs +++ b/crates/g3-core/src/session_continuation.rs @@ -221,6 +221,113 @@ pub fn load_continuation() -> Result> { Ok(Some(continuation)) } +/// Load a session continuation by session ID (full or partial prefix match). +/// +/// This function searches for sessions matching the given ID: +/// - First looks for `latest.json` (saved continuation artifact) +/// - Falls back to constructing a continuation from `session.json` if available +/// +/// This function searches for sessions matching the given ID: +/// - If an exact match is found, it returns that session +/// - If a unique prefix match is found, it returns that session +/// - If multiple sessions match the prefix, it returns an error listing them +/// - If no sessions match, it returns an error +/// +/// The session must be in the current working directory. +pub fn load_continuation_by_id(session_id: &str) -> Result { + let sessions_dir = get_sessions_dir(); + + if !sessions_dir.exists() { + anyhow::bail!("No sessions directory found. No sessions have been created yet."); + } + + let current_dir = std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + + let mut matches: Vec = Vec::new(); + + // Scan all session directories for matches + for entry in std::fs::read_dir(&sessions_dir)? { + let entry = entry?; + let path = entry.path(); + + if !path.is_dir() { + continue; + } + + let dir_name = path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + // Check if this session ID matches (exact or prefix) + if !dir_name.starts_with(session_id) { + continue; + } + + // Check for latest.json in this session directory + let latest_path = path.join(CONTINUATION_FILENAME); + let session_json_path = path.join("session.json"); + + // Try to load from latest.json first, then fall back to session.json + let continuation: SessionContinuation = if latest_path.exists() { + let json = std::fs::read_to_string(&latest_path)?; + serde_json::from_str(&json)? + } else if session_json_path.exists() { + // Construct a continuation from session.json + let json = std::fs::read_to_string(&session_json_path)?; + let session_data: serde_json::Value = serde_json::from_str(&json)?; + + // Extract working directory from session data + let working_dir = session_data + .get("working_directory") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + + // Extract context percentage + let context_pct = session_data + .get("context_window") + .and_then(|cw| cw.get("percentage_used")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.0) as f32; + + SessionContinuation { + version: CONTINUATION_VERSION.to_string(), + is_agent_mode: session_data.get("is_agent_mode").and_then(|v| v.as_bool()).unwrap_or(false), + agent_name: session_data.get("agent_name").and_then(|v| v.as_str()).map(|s| s.to_string()), + created_at: session_data.get("timestamp").and_then(|v| v.as_str()).unwrap_or_default().to_string(), + session_id: dir_name.to_string(), + description: None, + summary: None, + session_log_path: session_json_path.to_string_lossy().to_string(), + context_percentage: context_pct, + todo_snapshot: None, + working_directory: working_dir, + } + } else { + continue; + }; + + // Only include sessions from the current working directory + // If working_directory is empty (constructed from session.json without this field), + // we allow it since the user is explicitly requesting by ID + if continuation.working_directory.is_empty() + || continuation.working_directory == current_dir { + matches.push(continuation); + } + } + + match matches.len() { + 0 => anyhow::bail!("No session found matching '{}' in current directory", session_id), + 1 => Ok(matches.remove(0)), + _ => { + let ids: Vec<_> = matches.iter().map(|s| s.session_id.as_str()).collect(); + anyhow::bail!("Multiple sessions match '{}': {}", session_id, ids.join(", ")); + } + } +} + /// Clear the session continuation symlink (for /clear command) /// This only removes the symlink, not the actual session data pub fn clear_continuation() -> Result<()> {