Add --resume <session-id> flag for explicit session resumption

- Add --resume CLI flag that conflicts with --new-session
- Add load_continuation_by_id() to load sessions by full or partial ID
- Support loading from latest.json or falling back to session.json
- Handle --resume in both normal and agent modes
- Agent mode validates session belongs to correct agent
This commit is contained in:
Dhanji R. Prasanna
2026-02-05 10:23:39 +11:00
parent 3046f0dd6e
commit fdb1255f02
5 changed files with 160 additions and 2 deletions

View File

@@ -44,6 +44,25 @@ pub async fn run_agent_mode(
// Skip session resume entirely when in chat mode (--agent --chat) // Skip session resume entirely when in chat mode (--agent --chat)
let resuming_session = if chat { let resuming_session = if chat {
None // Chat mode always starts fresh 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 { } else if flags.new_session {
if !chat { if !chat {
output.print("\n🆕 Starting new session (--new-session flag set)"); output.print("\n🆕 Starting new session (--new-session flag set)");

View File

@@ -30,6 +30,8 @@ pub struct CommonFlags {
pub acd: bool, pub acd: bool,
/// Load a project from the given path at startup /// Load a project from the given path at startup
pub project: Option<PathBuf>, pub project: Option<PathBuf>,
/// Resume a specific session by ID
pub resume: Option<String>,
} }
#[derive(Parser, Clone)] #[derive(Parser, Clone)]
@@ -136,6 +138,10 @@ pub struct Cli {
#[arg(long)] #[arg(long)]
pub new_session: bool, 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<String>,
/// Automatically remind LLM to call remember tool after turns with tool calls /// Automatically remind LLM to call remember tool after turns with tool calls
#[arg(long)] #[arg(long)]
pub auto_memory: bool, pub auto_memory: bool,
@@ -172,6 +178,7 @@ impl Cli {
no_auto_memory: self.no_auto_memory, no_auto_memory: self.no_auto_memory,
acd: self.acd, acd: self.acd,
project: self.project.clone(), project: self.project.clone(),
resume: self.resume.clone(),
} }
} }
} }

View File

@@ -246,13 +246,38 @@ async fn run_console_mode(
agent.save_session_continuation(Some(result.response.clone())); agent.save_session_continuation(Some(result.response.clone()));
Ok(()) Ok(())
} else { } 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( run_interactive(
agent, agent,
cli.show_prompt, cli.show_prompt,
cli.show_code, cli.show_code,
combined_content, combined_content,
project.workspace(), 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) None, // agent_name (not in agent mode)
initial_project, initial_project,
) )

View File

@@ -31,7 +31,7 @@ pub use retry::{execute_with_retry, retry_operation, RetryConfig, RetryResult};
pub use session_continuation::{ pub use session_continuation::{
clear_continuation, find_incomplete_agent_session, format_session_time, get_session_dir, clear_continuation, find_incomplete_agent_session, format_session_time, get_session_dir,
has_valid_continuation, list_sessions_for_directory, load_context_from_session_log, 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; pub use task_result::TaskResult;

View File

@@ -221,6 +221,113 @@ pub fn load_continuation() -> Result<Option<SessionContinuation>> {
Ok(Some(continuation)) 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<SessionContinuation> {
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<SessionContinuation> = 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) /// Clear the session continuation symlink (for /clear command)
/// This only removes the symlink, not the actual session data /// This only removes the symlink, not the actual session data
pub fn clear_continuation() -> Result<()> { pub fn clear_continuation() -> Result<()> {