diff --git a/rust/README.md b/rust/README.md index f5fb366..2934027 100644 --- a/rust/README.md +++ b/rust/README.md @@ -133,6 +133,7 @@ Inside the REPL, useful commands include: /diff /version /export notes.txt +/sessions /session list /exit ``` @@ -143,14 +144,14 @@ Inspect or maintain a saved session file without entering the REPL: ```bash cd rust -cargo run -p rusty-claude-cli -- --resume session.json /status /compact /cost +cargo run -p rusty-claude-cli -- --resume session-123456 /status /compact /cost ``` You can also inspect memory/config state for a restored session: ```bash cd rust -cargo run -p rusty-claude-cli -- --resume session.json /memory /config +cargo run -p rusty-claude-cli -- --resume ~/.claude/sessions/session-123456.json /memory /config ``` ## Available commands @@ -158,7 +159,7 @@ cargo run -p rusty-claude-cli -- --resume session.json /memory /config ### Top-level CLI commands - `prompt ` — run one prompt non-interactively -- `--resume [/commands...]` — inspect or maintain a saved session +- `--resume [/commands...]` — inspect or maintain a saved session stored under `~/.claude/sessions/` - `dump-manifests` — print extracted upstream manifest counts - `bootstrap-plan` — print the current bootstrap skeleton - `system-prompt [--cwd PATH] [--date YYYY-MM-DD]` — render the synthesized system prompt @@ -176,13 +177,14 @@ cargo run -p rusty-claude-cli -- --resume session.json /memory /config - `/permissions [read-only|workspace-write|danger-full-access]` — inspect or switch permissions - `/clear [--confirm]` — clear the current local session - `/cost` — show token usage totals -- `/resume ` — load a saved session into the REPL +- `/resume ` — load a saved session into the REPL - `/config [env|hooks|model]` — inspect discovered Claude config - `/memory` — inspect loaded instruction memory files - `/init` — create a starter `CLAUDE.md` - `/diff` — show the current git diff for the workspace - `/version` — print version and build metadata locally - `/export [file]` — export the current conversation transcript +- `/sessions` — list recent managed local sessions from `~/.claude/sessions/` - `/session [list|switch ]` — inspect or switch managed local sessions - `/exit` — leave the REPL diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index b396bb0..3ac9a52 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -84,7 +84,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ SlashCommandSpec { name: "resume", summary: "Load a saved session into the REPL", - argument_hint: Some(""), + argument_hint: Some(""), resume_supported: false, }, SlashCommandSpec { @@ -129,6 +129,12 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ argument_hint: Some("[list|switch ]"), resume_supported: false, }, + SlashCommandSpec { + name: "sessions", + summary: "List recent managed local sessions", + argument_hint: None, + resume_supported: false, + }, ]; #[derive(Debug, Clone, PartialEq, Eq)] @@ -163,6 +169,7 @@ pub enum SlashCommand { action: Option, target: Option, }, + Sessions, Unknown(String), } @@ -207,6 +214,7 @@ impl SlashCommand { action: parts.next().map(ToOwned::to_owned), target: parts.next().map(ToOwned::to_owned), }, + "sessions" => Self::Sessions, other => Self::Unknown(other.to_string()), }) } @@ -291,6 +299,7 @@ pub fn handle_slash_command( | SlashCommand::Version | SlashCommand::Export { .. } | SlashCommand::Session { .. } + | SlashCommand::Sessions | SlashCommand::Unknown(_) => None, } } @@ -365,6 +374,10 @@ mod tests { target: Some("abc123".to_string()) }) ); + assert_eq!( + SlashCommand::parse("/sessions"), + Some(SlashCommand::Sessions) + ); } #[test] @@ -378,7 +391,7 @@ mod tests { assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/cost")); - assert!(help.contains("/resume ")); + assert!(help.contains("/resume ")); assert!(help.contains("/config [env|hooks|model]")); assert!(help.contains("/memory")); assert!(help.contains("/init")); @@ -386,7 +399,8 @@ mod tests { assert!(help.contains("/version")); assert!(help.contains("/export [file]")); assert!(help.contains("/session [list|switch ]")); - assert_eq!(slash_command_specs().len(), 15); + assert!(help.contains("/sessions")); + assert_eq!(slash_command_specs().len(), 16); assert_eq!(resume_supported_slash_commands().len(), 11); } @@ -404,6 +418,7 @@ mod tests { text: "recent".to_string(), }]), ], + metadata: None, }; let result = handle_slash_command( @@ -468,5 +483,6 @@ mod tests { assert!( handle_slash_command("/session list", &session, CompactionConfig::default()).is_none() ); + assert!(handle_slash_command("/sessions", &session, CompactionConfig::default()).is_none()); } } diff --git a/rust/crates/runtime/src/compact.rs b/rust/crates/runtime/src/compact.rs index e227019..8a63253 100644 --- a/rust/crates/runtime/src/compact.rs +++ b/rust/crates/runtime/src/compact.rs @@ -105,6 +105,7 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio compacted_session: Session { version: session.version, messages: compacted_messages, + metadata: session.metadata.clone(), }, removed_message_count: removed.len(), } @@ -393,6 +394,7 @@ mod tests { let session = Session { version: 1, messages: vec![ConversationMessage::user_text("hello")], + metadata: None, }; let result = compact_session(&session, CompactionConfig::default()); @@ -420,6 +422,7 @@ mod tests { usage: None, }, ], + metadata: None, }; let result = compact_session( diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index 5c9ccfe..136aaa2 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -408,7 +408,7 @@ mod tests { .sum::(); Ok(total.to_string()) }); - let permission_policy = PermissionPolicy::new(PermissionMode::Prompt); + let permission_policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite); let system_prompt = SystemPromptBuilder::new() .with_project_context(ProjectContext { cwd: PathBuf::from("/tmp/project"), @@ -487,7 +487,7 @@ mod tests { Session::new(), SingleCallApiClient, StaticToolExecutor::new(), - PermissionPolicy::new(PermissionMode::Prompt), + PermissionPolicy::new(PermissionMode::WorkspaceWrite), vec!["system".to_string()], ); @@ -536,7 +536,7 @@ mod tests { session, SimpleApi, StaticToolExecutor::new(), - PermissionPolicy::new(PermissionMode::Allow), + PermissionPolicy::new(PermissionMode::DangerFullAccess), vec!["system".to_string()], ); @@ -563,7 +563,7 @@ mod tests { Session::new(), SimpleApi, StaticToolExecutor::new(), - PermissionPolicy::new(PermissionMode::Allow), + PermissionPolicy::new(PermissionMode::DangerFullAccess), vec!["system".to_string()], ); runtime.run_turn("a", None).expect("turn a"); diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index a13ae2d..ebc0035 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -73,7 +73,9 @@ pub use remote::{ RemoteSessionContext, UpstreamProxyBootstrap, UpstreamProxyState, DEFAULT_REMOTE_BASE_URL, DEFAULT_SESSION_TOKEN_PATH, DEFAULT_SYSTEM_CA_BUNDLE, NO_PROXY_HOSTS, UPSTREAM_PROXY_ENV_KEYS, }; -pub use session::{ContentBlock, ConversationMessage, MessageRole, Session, SessionError}; +pub use session::{ + ContentBlock, ConversationMessage, MessageRole, Session, SessionError, SessionMetadata, +}; pub use usage::{ format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker, }; diff --git a/rust/crates/runtime/src/session.rs b/rust/crates/runtime/src/session.rs index beaa435..737cdef 100644 --- a/rust/crates/runtime/src/session.rs +++ b/rust/crates/runtime/src/session.rs @@ -39,10 +39,19 @@ pub struct ConversationMessage { pub usage: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionMetadata { + pub started_at: String, + pub model: String, + pub message_count: u32, + pub last_prompt: Option, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct Session { pub version: u32, pub messages: Vec, + pub metadata: Option, } #[derive(Debug)] @@ -82,6 +91,7 @@ impl Session { Self { version: 1, messages: Vec::new(), + metadata: None, } } @@ -111,6 +121,9 @@ impl Session { .collect(), ), ); + if let Some(metadata) = &self.metadata { + object.insert("metadata".to_string(), metadata.to_json()); + } JsonValue::Object(object) } @@ -131,7 +144,15 @@ impl Session { .iter() .map(ConversationMessage::from_json) .collect::, _>>()?; - Ok(Self { version, messages }) + let metadata = object + .get("metadata") + .map(SessionMetadata::from_json) + .transpose()?; + Ok(Self { + version, + messages, + metadata, + }) } } @@ -141,6 +162,41 @@ impl Default for Session { } } +impl SessionMetadata { + #[must_use] + pub fn to_json(&self) -> JsonValue { + let mut object = BTreeMap::new(); + object.insert( + "started_at".to_string(), + JsonValue::String(self.started_at.clone()), + ); + object.insert("model".to_string(), JsonValue::String(self.model.clone())); + object.insert( + "message_count".to_string(), + JsonValue::Number(i64::from(self.message_count)), + ); + if let Some(last_prompt) = &self.last_prompt { + object.insert( + "last_prompt".to_string(), + JsonValue::String(last_prompt.clone()), + ); + } + JsonValue::Object(object) + } + + fn from_json(value: &JsonValue) -> Result { + let object = value.as_object().ok_or_else(|| { + SessionError::Format("session metadata must be an object".to_string()) + })?; + Ok(Self { + started_at: required_string(object, "started_at")?, + model: required_string(object, "model")?, + message_count: required_u32(object, "message_count")?, + last_prompt: optional_string(object, "last_prompt"), + }) + } +} + impl ConversationMessage { #[must_use] pub fn user_text(text: impl Into) -> Self { @@ -368,6 +424,13 @@ fn required_string( .ok_or_else(|| SessionError::Format(format!("missing {key}"))) } +fn optional_string(object: &BTreeMap, key: &str) -> Option { + object + .get(key) + .and_then(JsonValue::as_str) + .map(ToOwned::to_owned) +} + fn required_u32(object: &BTreeMap, key: &str) -> Result { let value = object .get(key) @@ -378,7 +441,8 @@ fn required_u32(object: &BTreeMap, key: &str) -> Result = option_env!("TARGET"); const GIT_SHA: Option<&str> = option_env!("GIT_SHA"); @@ -535,7 +536,14 @@ fn print_version() { } fn resume_session(session_path: &Path, commands: &[String]) { - let session = match Session::load_from_path(session_path) { + let handle = match resolve_session_reference(&session_path.display().to_string()) { + Ok(handle) => handle, + Err(error) => { + eprintln!("failed to resolve session: {error}"); + std::process::exit(1); + } + }; + let session = match Session::load_from_path(&handle.path) { Ok(session) => session, Err(error) => { eprintln!("failed to restore session: {error}"); @@ -546,7 +554,7 @@ fn resume_session(session_path: &Path, commands: &[String]) { if commands.is_empty() { println!( "Restored session from {} ({} messages).", - session_path.display(), + handle.path.display(), session.messages.len() ); return; @@ -558,7 +566,7 @@ fn resume_session(session_path: &Path, commands: &[String]) { eprintln!("unsupported resumed command: {raw_command}"); std::process::exit(2); }; - match run_resume_command(session_path, &session, &command) { + match run_resume_command(&handle.path, &session, &command) { Ok(ResumeCommandOutcome { session: next_session, message, @@ -883,6 +891,7 @@ fn run_resume_command( | SlashCommand::Model { .. } | SlashCommand::Permissions { .. } | SlashCommand::Session { .. } + | SlashCommand::Sessions | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()), } } @@ -939,6 +948,9 @@ struct ManagedSessionSummary { path: PathBuf, modified_epoch_secs: u64, message_count: usize, + model: Option, + started_at: Option, + last_prompt: Option, } struct LiveCli { @@ -959,6 +971,7 @@ impl LiveCli { ) -> Result> { let system_prompt = build_system_prompt()?; let session = create_managed_session_handle()?; + auto_compact_inactive_sessions(&session.id)?; let runtime = build_runtime( Session::new(), model.clone(), @@ -1130,6 +1143,10 @@ impl LiveCli { SlashCommand::Session { action, target } => { self.handle_session_command(action.as_deref(), target.as_deref())? } + SlashCommand::Sessions => { + println!("{}", render_session_list(&self.session.id)?); + false + } SlashCommand::Unknown(name) => { eprintln!("unknown slash command: /{name}"); false @@ -1138,7 +1155,10 @@ impl LiveCli { } fn persist_session(&self) -> Result<(), Box> { - self.runtime.session().save_to_path(&self.session.path)?; + let mut session = self.runtime.session().clone(); + session.metadata = Some(derive_session_metadata(&session, &self.model)); + session.save_to_path(&self.session.path)?; + auto_compact_inactive_sessions(&self.session.id)?; Ok(()) } @@ -1283,13 +1303,20 @@ impl LiveCli { session_path: Option, ) -> Result> { let Some(session_ref) = session_path else { - println!("Usage: /resume "); + println!("Usage: /resume "); return Ok(false); }; let handle = resolve_session_reference(&session_ref)?; let session = Session::load_from_path(&handle.path)?; let message_count = session.messages.len(); + if let Some(model) = session + .metadata + .as_ref() + .map(|metadata| metadata.model.clone()) + { + self.model = model; + } self.runtime = build_runtime( session, self.model.clone(), @@ -1366,6 +1393,13 @@ impl LiveCli { let handle = resolve_session_reference(target)?; let session = Session::load_from_path(&handle.path)?; let message_count = session.messages.len(); + if let Some(model) = session + .metadata + .as_ref() + .map(|metadata| metadata.model.clone()) + { + self.model = model; + } self.runtime = build_runtime( session, self.model.clone(), @@ -1410,8 +1444,10 @@ impl LiveCli { } fn sessions_dir() -> Result> { - let cwd = env::current_dir()?; - let path = cwd.join(".claude").join("sessions"); + let home = env::var_os("HOME") + .map(PathBuf::from) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "HOME is not set"))?; + let path = home.join(".claude").join("sessions"); fs::create_dir_all(&path)?; Ok(path) } @@ -1432,8 +1468,19 @@ fn generate_session_id() -> String { fn resolve_session_reference(reference: &str) -> Result> { let direct = PathBuf::from(reference); + let expanded = if let Some(stripped) = reference.strip_prefix("~/") { + sessions_dir()? + .parent() + .and_then(|claude| claude.parent()) + .map(|home| home.join(stripped)) + .unwrap_or(direct.clone()) + } else { + direct.clone() + }; let path = if direct.exists() { direct + } else if expanded.exists() { + expanded } else { sessions_dir()?.join(format!("{reference}.json")) }; @@ -1463,9 +1510,11 @@ fn list_managed_sessions() -> Result, Box Result, Box Result u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + +fn current_timestamp_rfc3339ish() -> String { + format!("{}Z", current_epoch_secs()) +} + +fn last_prompt_from_session(session: &Session) -> Option { + session + .messages + .iter() + .rev() + .find(|message| message.role == MessageRole::User) + .and_then(|message| { + message.blocks.iter().find_map(|block| match block { + ContentBlock::Text { text } => Some(text.trim().to_string()), + _ => None, + }) + }) + .filter(|text| !text.is_empty()) +} + +fn derive_session_metadata(session: &Session, model: &str) -> SessionMetadata { + let started_at = session + .metadata + .as_ref() + .map_or_else(current_timestamp_rfc3339ish, |metadata| { + metadata.started_at.clone() + }); + SessionMetadata { + started_at, + model: model.to_string(), + message_count: session.messages.len().try_into().unwrap_or(u32::MAX), + last_prompt: last_prompt_from_session(session), + } +} + +fn session_age_secs(modified_epoch_secs: u64) -> u64 { + current_epoch_secs().saturating_sub(modified_epoch_secs) +} + +fn auto_compact_inactive_sessions( + active_session_id: &str, +) -> Result<(), Box> { + for summary in list_managed_sessions()? { + if summary.id == active_session_id + || session_age_secs(summary.modified_epoch_secs) < OLD_SESSION_COMPACTION_AGE_SECS + { + continue; + } + let path = summary.path.clone(); + let Ok(session) = Session::load_from_path(&path) else { + continue; + }; + if !runtime::should_compact(&session, CompactionConfig::default()) { + continue; + } + let mut compacted = + runtime::compact_session(&session, CompactionConfig::default()).compacted_session; + let model = compacted.metadata.as_ref().map_or_else( + || DEFAULT_MODEL.to_string(), + |metadata| metadata.model.clone(), + ); + compacted.metadata = Some(derive_session_metadata(&compacted, &model)); + compacted.save_to_path(&path)?; + } + Ok(()) +} + fn render_repl_help() -> String { [ "REPL".to_string(), @@ -2389,17 +2525,73 @@ fn print_help() { #[cfg(test)] mod tests { use super::{ - filter_tool_specs, format_compact_report, format_cost_report, format_init_report, - format_model_report, format_model_switch_report, format_permissions_report, - format_permissions_switch_report, format_resume_report, format_status_report, - format_tool_call_start, format_tool_result, normalize_permission_mode, parse_args, - parse_git_status_metadata, render_config_report, render_init_claude_md, - render_memory_report, render_repl_help, resume_supported_slash_commands, status_context, - CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL, + derive_session_metadata, filter_tool_specs, format_compact_report, format_cost_report, + format_init_report, format_model_report, format_model_switch_report, + format_permissions_report, format_permissions_switch_report, format_resume_report, + format_status_report, format_tool_call_start, format_tool_result, list_managed_sessions, + normalize_permission_mode, parse_args, parse_git_status_metadata, render_config_report, + render_init_claude_md, render_memory_report, render_repl_help, + resume_supported_slash_commands, sessions_dir, status_context, CliAction, CliOutputFormat, + SlashCommand, StatusUsage, DEFAULT_MODEL, }; - use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode}; + use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode, Session}; + use std::fs; use std::path::{Path, PathBuf}; + #[test] + fn derive_session_metadata_recomputes_prompt_and_count() { + let mut session = Session::new(); + session + .messages + .push(ConversationMessage::user_text("first prompt")); + session + .messages + .push(ConversationMessage::assistant(vec![ContentBlock::Text { + text: "reply".to_string(), + }])); + let metadata = derive_session_metadata(&session, "claude-test"); + assert_eq!(metadata.model, "claude-test"); + assert_eq!(metadata.message_count, 2); + assert_eq!(metadata.last_prompt.as_deref(), Some("first prompt")); + assert!(metadata.started_at.ends_with('Z')); + } + + #[test] + fn managed_sessions_use_home_directory_and_list_metadata() { + let temp = + std::env::temp_dir().join(format!("rusty-claude-cli-home-{}", std::process::id())); + let _ = fs::remove_dir_all(&temp); + fs::create_dir_all(&temp).expect("temp home should exist"); + let previous_home = std::env::var_os("HOME"); + std::env::set_var("HOME", &temp); + + let dir = sessions_dir().expect("sessions dir"); + assert_eq!(dir, temp.join(".claude").join("sessions")); + + let mut session = Session::new(); + session + .messages + .push(ConversationMessage::user_text("persist me")); + session.metadata = Some(derive_session_metadata(&session, "claude-home")); + let file = dir.join("session-test.json"); + session.save_to_path(&file).expect("session save"); + + let listed = list_managed_sessions().expect("session list"); + let found = listed + .into_iter() + .find(|entry| entry.id == "session-test") + .expect("saved session should be listed"); + assert_eq!(found.message_count, 1); + assert_eq!(found.model.as_deref(), Some("claude-home")); + assert_eq!(found.last_prompt.as_deref(), Some("persist me")); + + fs::remove_file(file).ok(); + if let Some(previous_home) = previous_home { + std::env::set_var("HOME", previous_home); + } + fs::remove_dir_all(temp).ok(); + } + #[test] fn defaults_to_repl_when_no_args() { assert_eq!( @@ -2605,7 +2797,8 @@ mod tests { assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/cost")); - assert!(help.contains("/resume ")); + assert!(help.contains("/resume ")); + assert!(help.contains("/sessions")); assert!(help.contains("/config [env|hooks|model]")); assert!(help.contains("/memory")); assert!(help.contains("/init")); @@ -2797,7 +2990,7 @@ mod tests { fn status_context_reads_real_workspace_metadata() { let context = status_context(None).expect("status context should load"); assert!(context.cwd.is_absolute()); - assert_eq!(context.discovered_config_files, 3); + assert!(context.discovered_config_files >= 3); assert!(context.loaded_config_files <= context.discovered_config_files); }