Persist CLI conversation history across sessions

The Rust CLI now stores managed sessions under ~/.claude/sessions,
records additive session metadata in the canonical JSON transcript,
and exposes a /sessions listing alias alongside ID-or-path resume.
Inactive oversized sessions are compacted automatically so old
transcripts remain resumable without growing unchecked.

Constraint: Session JSON must stay backward-compatible with legacy files that lack metadata
Constraint: Managed sessions must use a single canonical JSON file per session without new dependencies
Rejected: Sidecar metadata/index files | duplicated state and diverged from the requested single-file persistence model
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep CLI policy in the CLI; only add transcript-adjacent metadata to runtime::Session unless another consumer truly needs more
Tested: cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace
Not-tested: Manual interactive REPL smoke test against the live Anthropic API
This commit is contained in:
Yeachan-Heo
2026-04-01 00:58:14 +00:00
parent d6341d54c1
commit 146260083c
8 changed files with 342 additions and 37 deletions

View File

@@ -133,6 +133,7 @@ Inside the REPL, useful commands include:
/diff /diff
/version /version
/export notes.txt /export notes.txt
/sessions
/session list /session list
/exit /exit
``` ```
@@ -143,14 +144,14 @@ Inspect or maintain a saved session file without entering the REPL:
```bash ```bash
cd rust 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: You can also inspect memory/config state for a restored session:
```bash ```bash
cd rust 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 ## Available commands
@@ -158,7 +159,7 @@ cargo run -p rusty-claude-cli -- --resume session.json /memory /config
### Top-level CLI commands ### Top-level CLI commands
- `prompt <text...>` — run one prompt non-interactively - `prompt <text...>` — run one prompt non-interactively
- `--resume <session.json> [/commands...]` — inspect or maintain a saved session - `--resume <session-id-or-path> [/commands...]` — inspect or maintain a saved session stored under `~/.claude/sessions/`
- `dump-manifests` — print extracted upstream manifest counts - `dump-manifests` — print extracted upstream manifest counts
- `bootstrap-plan` — print the current bootstrap skeleton - `bootstrap-plan` — print the current bootstrap skeleton
- `system-prompt [--cwd PATH] [--date YYYY-MM-DD]` — render the synthesized system prompt - `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 - `/permissions [read-only|workspace-write|danger-full-access]` — inspect or switch permissions
- `/clear [--confirm]` — clear the current local session - `/clear [--confirm]` — clear the current local session
- `/cost` — show token usage totals - `/cost` — show token usage totals
- `/resume <session-path>` — load a saved session into the REPL - `/resume <session-id-or-path>` — load a saved session into the REPL
- `/config [env|hooks|model]` — inspect discovered Claude config - `/config [env|hooks|model]` — inspect discovered Claude config
- `/memory` — inspect loaded instruction memory files - `/memory` — inspect loaded instruction memory files
- `/init` — create a starter `CLAUDE.md` - `/init` — create a starter `CLAUDE.md`
- `/diff` — show the current git diff for the workspace - `/diff` — show the current git diff for the workspace
- `/version` — print version and build metadata locally - `/version` — print version and build metadata locally
- `/export [file]` — export the current conversation transcript - `/export [file]` — export the current conversation transcript
- `/sessions` — list recent managed local sessions from `~/.claude/sessions/`
- `/session [list|switch <session-id>]` — inspect or switch managed local sessions - `/session [list|switch <session-id>]` — inspect or switch managed local sessions
- `/exit` — leave the REPL - `/exit` — leave the REPL

View File

@@ -84,7 +84,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
SlashCommandSpec { SlashCommandSpec {
name: "resume", name: "resume",
summary: "Load a saved session into the REPL", summary: "Load a saved session into the REPL",
argument_hint: Some("<session-path>"), argument_hint: Some("<session-id-or-path>"),
resume_supported: false, resume_supported: false,
}, },
SlashCommandSpec { SlashCommandSpec {
@@ -129,6 +129,12 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
argument_hint: Some("[list|switch <session-id>]"), argument_hint: Some("[list|switch <session-id>]"),
resume_supported: false, resume_supported: false,
}, },
SlashCommandSpec {
name: "sessions",
summary: "List recent managed local sessions",
argument_hint: None,
resume_supported: false,
},
]; ];
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -163,6 +169,7 @@ pub enum SlashCommand {
action: Option<String>, action: Option<String>,
target: Option<String>, target: Option<String>,
}, },
Sessions,
Unknown(String), Unknown(String),
} }
@@ -207,6 +214,7 @@ impl SlashCommand {
action: parts.next().map(ToOwned::to_owned), action: parts.next().map(ToOwned::to_owned),
target: parts.next().map(ToOwned::to_owned), target: parts.next().map(ToOwned::to_owned),
}, },
"sessions" => Self::Sessions,
other => Self::Unknown(other.to_string()), other => Self::Unknown(other.to_string()),
}) })
} }
@@ -291,6 +299,7 @@ pub fn handle_slash_command(
| SlashCommand::Version | SlashCommand::Version
| SlashCommand::Export { .. } | SlashCommand::Export { .. }
| SlashCommand::Session { .. } | SlashCommand::Session { .. }
| SlashCommand::Sessions
| SlashCommand::Unknown(_) => None, | SlashCommand::Unknown(_) => None,
} }
} }
@@ -365,6 +374,10 @@ mod tests {
target: Some("abc123".to_string()) target: Some("abc123".to_string())
}) })
); );
assert_eq!(
SlashCommand::parse("/sessions"),
Some(SlashCommand::Sessions)
);
} }
#[test] #[test]
@@ -378,7 +391,7 @@ mod tests {
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/clear [--confirm]"));
assert!(help.contains("/cost")); assert!(help.contains("/cost"));
assert!(help.contains("/resume <session-path>")); assert!(help.contains("/resume <session-id-or-path>"));
assert!(help.contains("/config [env|hooks|model]")); assert!(help.contains("/config [env|hooks|model]"));
assert!(help.contains("/memory")); assert!(help.contains("/memory"));
assert!(help.contains("/init")); assert!(help.contains("/init"));
@@ -386,7 +399,8 @@ mod tests {
assert!(help.contains("/version")); assert!(help.contains("/version"));
assert!(help.contains("/export [file]")); assert!(help.contains("/export [file]"));
assert!(help.contains("/session [list|switch <session-id>]")); assert!(help.contains("/session [list|switch <session-id>]"));
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); assert_eq!(resume_supported_slash_commands().len(), 11);
} }
@@ -404,6 +418,7 @@ mod tests {
text: "recent".to_string(), text: "recent".to_string(),
}]), }]),
], ],
metadata: None,
}; };
let result = handle_slash_command( let result = handle_slash_command(
@@ -468,5 +483,6 @@ mod tests {
assert!( assert!(
handle_slash_command("/session list", &session, CompactionConfig::default()).is_none() handle_slash_command("/session list", &session, CompactionConfig::default()).is_none()
); );
assert!(handle_slash_command("/sessions", &session, CompactionConfig::default()).is_none());
} }
} }

View File

@@ -105,6 +105,7 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
compacted_session: Session { compacted_session: Session {
version: session.version, version: session.version,
messages: compacted_messages, messages: compacted_messages,
metadata: session.metadata.clone(),
}, },
removed_message_count: removed.len(), removed_message_count: removed.len(),
} }
@@ -393,6 +394,7 @@ mod tests {
let session = Session { let session = Session {
version: 1, version: 1,
messages: vec![ConversationMessage::user_text("hello")], messages: vec![ConversationMessage::user_text("hello")],
metadata: None,
}; };
let result = compact_session(&session, CompactionConfig::default()); let result = compact_session(&session, CompactionConfig::default());
@@ -420,6 +422,7 @@ mod tests {
usage: None, usage: None,
}, },
], ],
metadata: None,
}; };
let result = compact_session( let result = compact_session(

View File

@@ -408,7 +408,7 @@ mod tests {
.sum::<i32>(); .sum::<i32>();
Ok(total.to_string()) Ok(total.to_string())
}); });
let permission_policy = PermissionPolicy::new(PermissionMode::Prompt); let permission_policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite);
let system_prompt = SystemPromptBuilder::new() let system_prompt = SystemPromptBuilder::new()
.with_project_context(ProjectContext { .with_project_context(ProjectContext {
cwd: PathBuf::from("/tmp/project"), cwd: PathBuf::from("/tmp/project"),
@@ -487,7 +487,7 @@ mod tests {
Session::new(), Session::new(),
SingleCallApiClient, SingleCallApiClient,
StaticToolExecutor::new(), StaticToolExecutor::new(),
PermissionPolicy::new(PermissionMode::Prompt), PermissionPolicy::new(PermissionMode::WorkspaceWrite),
vec!["system".to_string()], vec!["system".to_string()],
); );
@@ -536,7 +536,7 @@ mod tests {
session, session,
SimpleApi, SimpleApi,
StaticToolExecutor::new(), StaticToolExecutor::new(),
PermissionPolicy::new(PermissionMode::Allow), PermissionPolicy::new(PermissionMode::DangerFullAccess),
vec!["system".to_string()], vec!["system".to_string()],
); );
@@ -563,7 +563,7 @@ mod tests {
Session::new(), Session::new(),
SimpleApi, SimpleApi,
StaticToolExecutor::new(), StaticToolExecutor::new(),
PermissionPolicy::new(PermissionMode::Allow), PermissionPolicy::new(PermissionMode::DangerFullAccess),
vec!["system".to_string()], vec!["system".to_string()],
); );
runtime.run_turn("a", None).expect("turn a"); runtime.run_turn("a", None).expect("turn a");

View File

@@ -73,7 +73,9 @@ pub use remote::{
RemoteSessionContext, UpstreamProxyBootstrap, UpstreamProxyState, DEFAULT_REMOTE_BASE_URL, RemoteSessionContext, UpstreamProxyBootstrap, UpstreamProxyState, DEFAULT_REMOTE_BASE_URL,
DEFAULT_SESSION_TOKEN_PATH, DEFAULT_SYSTEM_CA_BUNDLE, NO_PROXY_HOSTS, UPSTREAM_PROXY_ENV_KEYS, 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::{ pub use usage::{
format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker, format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker,
}; };

View File

@@ -39,10 +39,19 @@ pub struct ConversationMessage {
pub usage: Option<TokenUsage>, pub usage: Option<TokenUsage>,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionMetadata {
pub started_at: String,
pub model: String,
pub message_count: u32,
pub last_prompt: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Session { pub struct Session {
pub version: u32, pub version: u32,
pub messages: Vec<ConversationMessage>, pub messages: Vec<ConversationMessage>,
pub metadata: Option<SessionMetadata>,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -82,6 +91,7 @@ impl Session {
Self { Self {
version: 1, version: 1,
messages: Vec::new(), messages: Vec::new(),
metadata: None,
} }
} }
@@ -111,6 +121,9 @@ impl Session {
.collect(), .collect(),
), ),
); );
if let Some(metadata) = &self.metadata {
object.insert("metadata".to_string(), metadata.to_json());
}
JsonValue::Object(object) JsonValue::Object(object)
} }
@@ -131,7 +144,15 @@ impl Session {
.iter() .iter()
.map(ConversationMessage::from_json) .map(ConversationMessage::from_json)
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
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<Self, SessionError> {
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 { impl ConversationMessage {
#[must_use] #[must_use]
pub fn user_text(text: impl Into<String>) -> Self { pub fn user_text(text: impl Into<String>) -> Self {
@@ -368,6 +424,13 @@ fn required_string(
.ok_or_else(|| SessionError::Format(format!("missing {key}"))) .ok_or_else(|| SessionError::Format(format!("missing {key}")))
} }
fn optional_string(object: &BTreeMap<String, JsonValue>, key: &str) -> Option<String> {
object
.get(key)
.and_then(JsonValue::as_str)
.map(ToOwned::to_owned)
}
fn required_u32(object: &BTreeMap<String, JsonValue>, key: &str) -> Result<u32, SessionError> { fn required_u32(object: &BTreeMap<String, JsonValue>, key: &str) -> Result<u32, SessionError> {
let value = object let value = object
.get(key) .get(key)
@@ -378,7 +441,8 @@ fn required_u32(object: &BTreeMap<String, JsonValue>, key: &str) -> Result<u32,
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ContentBlock, ConversationMessage, MessageRole, Session}; use super::{ContentBlock, ConversationMessage, MessageRole, Session, SessionMetadata};
use crate::json::JsonValue;
use crate::usage::TokenUsage; use crate::usage::TokenUsage;
use std::fs; use std::fs;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
@@ -386,6 +450,12 @@ mod tests {
#[test] #[test]
fn persists_and_restores_session_json() { fn persists_and_restores_session_json() {
let mut session = Session::new(); let mut session = Session::new();
session.metadata = Some(SessionMetadata {
started_at: "2026-04-01T00:00:00Z".to_string(),
model: "claude-sonnet".to_string(),
message_count: 3,
last_prompt: Some("hello".to_string()),
});
session session
.messages .messages
.push(ConversationMessage::user_text("hello")); .push(ConversationMessage::user_text("hello"));
@@ -428,5 +498,23 @@ mod tests {
restored.messages[1].usage.expect("usage").total_tokens(), restored.messages[1].usage.expect("usage").total_tokens(),
17 17
); );
assert_eq!(restored.metadata, session.metadata);
}
#[test]
fn loads_legacy_session_without_metadata() {
let legacy = r#"{
"version": 1,
"messages": [
{
"role": "user",
"blocks": [{"type": "text", "text": "hello"}]
}
]
}"#;
let restored = Session::from_json(&JsonValue::parse(legacy).expect("legacy json"))
.expect("legacy session should parse");
assert_eq!(restored.messages.len(), 1);
assert!(restored.metadata.is_none());
} }
} }

View File

@@ -300,6 +300,7 @@ mod tests {
cache_read_input_tokens: 0, cache_read_input_tokens: 0,
}), }),
}], }],
metadata: None,
}; };
let tracker = UsageTracker::from_session(&session); let tracker = UsageTracker::from_session(&session);

View File

@@ -27,7 +27,7 @@ use runtime::{
AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest,
OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, Session, SessionMetadata, TokenUsage, ToolError, ToolExecutor, UsageTracker,
}; };
use serde_json::json; use serde_json::json;
use tools::{execute_tool, mvp_tool_specs, ToolSpec}; use tools::{execute_tool, mvp_tool_specs, ToolSpec};
@@ -37,6 +37,7 @@ const DEFAULT_MAX_TOKENS: u32 = 32;
const DEFAULT_DATE: &str = "2026-03-31"; const DEFAULT_DATE: &str = "2026-03-31";
const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545; const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545;
const VERSION: &str = env!("CARGO_PKG_VERSION"); const VERSION: &str = env!("CARGO_PKG_VERSION");
const OLD_SESSION_COMPACTION_AGE_SECS: u64 = 60 * 60 * 24;
const BUILD_TARGET: Option<&str> = option_env!("TARGET"); const BUILD_TARGET: Option<&str> = option_env!("TARGET");
const GIT_SHA: Option<&str> = option_env!("GIT_SHA"); const GIT_SHA: Option<&str> = option_env!("GIT_SHA");
@@ -535,7 +536,14 @@ fn print_version() {
} }
fn resume_session(session_path: &Path, commands: &[String]) { 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, Ok(session) => session,
Err(error) => { Err(error) => {
eprintln!("failed to restore session: {error}"); eprintln!("failed to restore session: {error}");
@@ -546,7 +554,7 @@ fn resume_session(session_path: &Path, commands: &[String]) {
if commands.is_empty() { if commands.is_empty() {
println!( println!(
"Restored session from {} ({} messages).", "Restored session from {} ({} messages).",
session_path.display(), handle.path.display(),
session.messages.len() session.messages.len()
); );
return; return;
@@ -558,7 +566,7 @@ fn resume_session(session_path: &Path, commands: &[String]) {
eprintln!("unsupported resumed command: {raw_command}"); eprintln!("unsupported resumed command: {raw_command}");
std::process::exit(2); std::process::exit(2);
}; };
match run_resume_command(session_path, &session, &command) { match run_resume_command(&handle.path, &session, &command) {
Ok(ResumeCommandOutcome { Ok(ResumeCommandOutcome {
session: next_session, session: next_session,
message, message,
@@ -883,6 +891,7 @@ fn run_resume_command(
| SlashCommand::Model { .. } | SlashCommand::Model { .. }
| SlashCommand::Permissions { .. } | SlashCommand::Permissions { .. }
| SlashCommand::Session { .. } | SlashCommand::Session { .. }
| SlashCommand::Sessions
| SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()), | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
} }
} }
@@ -939,6 +948,9 @@ struct ManagedSessionSummary {
path: PathBuf, path: PathBuf,
modified_epoch_secs: u64, modified_epoch_secs: u64,
message_count: usize, message_count: usize,
model: Option<String>,
started_at: Option<String>,
last_prompt: Option<String>,
} }
struct LiveCli { struct LiveCli {
@@ -959,6 +971,7 @@ impl LiveCli {
) -> Result<Self, Box<dyn std::error::Error>> { ) -> Result<Self, Box<dyn std::error::Error>> {
let system_prompt = build_system_prompt()?; let system_prompt = build_system_prompt()?;
let session = create_managed_session_handle()?; let session = create_managed_session_handle()?;
auto_compact_inactive_sessions(&session.id)?;
let runtime = build_runtime( let runtime = build_runtime(
Session::new(), Session::new(),
model.clone(), model.clone(),
@@ -1130,6 +1143,10 @@ impl LiveCli {
SlashCommand::Session { action, target } => { SlashCommand::Session { action, target } => {
self.handle_session_command(action.as_deref(), target.as_deref())? self.handle_session_command(action.as_deref(), target.as_deref())?
} }
SlashCommand::Sessions => {
println!("{}", render_session_list(&self.session.id)?);
false
}
SlashCommand::Unknown(name) => { SlashCommand::Unknown(name) => {
eprintln!("unknown slash command: /{name}"); eprintln!("unknown slash command: /{name}");
false false
@@ -1138,7 +1155,10 @@ impl LiveCli {
} }
fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> { fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> {
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(()) Ok(())
} }
@@ -1283,13 +1303,20 @@ impl LiveCli {
session_path: Option<String>, session_path: Option<String>,
) -> Result<bool, Box<dyn std::error::Error>> { ) -> Result<bool, Box<dyn std::error::Error>> {
let Some(session_ref) = session_path else { let Some(session_ref) = session_path else {
println!("Usage: /resume <session-path>"); println!("Usage: /resume <session-id-or-path>");
return Ok(false); return Ok(false);
}; };
let handle = resolve_session_reference(&session_ref)?; let handle = resolve_session_reference(&session_ref)?;
let session = Session::load_from_path(&handle.path)?; let session = Session::load_from_path(&handle.path)?;
let message_count = session.messages.len(); 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( self.runtime = build_runtime(
session, session,
self.model.clone(), self.model.clone(),
@@ -1366,6 +1393,13 @@ impl LiveCli {
let handle = resolve_session_reference(target)?; let handle = resolve_session_reference(target)?;
let session = Session::load_from_path(&handle.path)?; let session = Session::load_from_path(&handle.path)?;
let message_count = session.messages.len(); 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( self.runtime = build_runtime(
session, session,
self.model.clone(), self.model.clone(),
@@ -1410,8 +1444,10 @@ impl LiveCli {
} }
fn sessions_dir() -> Result<PathBuf, Box<dyn std::error::Error>> { fn sessions_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?; let home = env::var_os("HOME")
let path = cwd.join(".claude").join("sessions"); .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)?; fs::create_dir_all(&path)?;
Ok(path) Ok(path)
} }
@@ -1432,8 +1468,19 @@ fn generate_session_id() -> String {
fn resolve_session_reference(reference: &str) -> Result<SessionHandle, Box<dyn std::error::Error>> { fn resolve_session_reference(reference: &str) -> Result<SessionHandle, Box<dyn std::error::Error>> {
let direct = PathBuf::from(reference); 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() { let path = if direct.exists() {
direct direct
} else if expanded.exists() {
expanded
} else { } else {
sessions_dir()?.join(format!("{reference}.json")) sessions_dir()?.join(format!("{reference}.json"))
}; };
@@ -1463,9 +1510,11 @@ fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::er
.and_then(|time| time.duration_since(UNIX_EPOCH).ok()) .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_secs()) .map(|duration| duration.as_secs())
.unwrap_or_default(); .unwrap_or_default();
let message_count = Session::load_from_path(&path) let session = Session::load_from_path(&path).ok();
.map(|session| session.messages.len()) let derived_message_count = session.as_ref().map_or(0, |session| session.messages.len());
.unwrap_or_default(); let stored = session
.as_ref()
.and_then(|session| session.metadata.as_ref());
let id = path let id = path
.file_stem() .file_stem()
.and_then(|value| value.to_str()) .and_then(|value| value.to_str())
@@ -1475,7 +1524,12 @@ fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::er
id, id,
path, path,
modified_epoch_secs, modified_epoch_secs,
message_count, message_count: stored.map_or(derived_message_count, |metadata| {
metadata.message_count as usize
}),
model: stored.map(|metadata| metadata.model.clone()),
started_at: stored.map(|metadata| metadata.started_at.clone()),
last_prompt: stored.and_then(|metadata| metadata.last_prompt.clone()),
}); });
} }
sessions.sort_by(|left, right| right.modified_epoch_secs.cmp(&left.modified_epoch_secs)); sessions.sort_by(|left, right| right.modified_epoch_secs.cmp(&left.modified_epoch_secs));
@@ -1498,17 +1552,99 @@ fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::e
} else { } else {
"○ saved" "○ saved"
}; };
let model = session.model.as_deref().unwrap_or("unknown");
let started = session.started_at.as_deref().unwrap_or("unknown");
let last_prompt = session.last_prompt.as_deref().map_or_else(
|| "-".to_string(),
|prompt| truncate_for_summary(prompt, 36),
);
lines.push(format!( lines.push(format!(
" {id:<20} {marker:<10} msgs={msgs:<4} modified={modified} path={path}", " {id:<20} {marker:<10} msgs={msgs:<4} model={model:<24} started={started} modified={modified} last={last_prompt} path={path}",
id = session.id, id = session.id,
msgs = session.message_count, msgs = session.message_count,
model = model,
started = started,
modified = session.modified_epoch_secs, modified = session.modified_epoch_secs,
last_prompt = last_prompt,
path = session.path.display(), path = session.path.display(),
)); ));
} }
Ok(lines.join("\n")) Ok(lines.join("\n"))
} }
fn current_epoch_secs() -> 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<String> {
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<dyn std::error::Error>> {
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 { fn render_repl_help() -> String {
[ [
"REPL".to_string(), "REPL".to_string(),
@@ -2389,17 +2525,73 @@ fn print_help() {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
filter_tool_specs, format_compact_report, format_cost_report, format_init_report, derive_session_metadata, filter_tool_specs, format_compact_report, format_cost_report,
format_model_report, format_model_switch_report, format_permissions_report, format_init_report, format_model_report, format_model_switch_report,
format_permissions_switch_report, format_resume_report, format_status_report, format_permissions_report, format_permissions_switch_report, format_resume_report,
format_tool_call_start, format_tool_result, normalize_permission_mode, parse_args, format_status_report, format_tool_call_start, format_tool_result, list_managed_sessions,
parse_git_status_metadata, render_config_report, render_init_claude_md, normalize_permission_mode, parse_args, parse_git_status_metadata, render_config_report,
render_memory_report, render_repl_help, resume_supported_slash_commands, status_context, render_init_claude_md, render_memory_report, render_repl_help,
CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL, 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}; 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] #[test]
fn defaults_to_repl_when_no_args() { fn defaults_to_repl_when_no_args() {
assert_eq!( assert_eq!(
@@ -2605,7 +2797,8 @@ mod tests {
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/clear [--confirm]"));
assert!(help.contains("/cost")); assert!(help.contains("/cost"));
assert!(help.contains("/resume <session-path>")); assert!(help.contains("/resume <session-id-or-path>"));
assert!(help.contains("/sessions"));
assert!(help.contains("/config [env|hooks|model]")); assert!(help.contains("/config [env|hooks|model]"));
assert!(help.contains("/memory")); assert!(help.contains("/memory"));
assert!(help.contains("/init")); assert!(help.contains("/init"));
@@ -2797,7 +2990,7 @@ mod tests {
fn status_context_reads_real_workspace_metadata() { fn status_context_reads_real_workspace_metadata() {
let context = status_context(None).expect("status context should load"); let context = status_context(None).expect("status context should load");
assert!(context.cwd.is_absolute()); 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); assert!(context.loaded_config_files <= context.discovered_config_files);
} }