3 Commits

Author SHA1 Message Date
Yeachan-Heo
ccebabe605 Preserve verified session persistence while syncing remote runtime branch history
Origin/rcc/runtime advanced independently while this branch implemented
conversation history persistence. This merge keeps the tested local tree
as the source of truth for the user-requested feature while recording the
remote branch tip so future work can proceed from a shared history.

Constraint: Push required incorporating origin/rcc/runtime history without breaking the verified session-persistence implementation
Rejected: Force-push over origin/rcc/runtime | would discard remote branch history
Confidence: medium
Scope-risk: narrow
Reversibility: clean
Directive: Before the next broad CLI/runtime refactor, compare this branch against origin/rcc/runtime for any remote-only startup behavior worth porting deliberately
Tested: cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace
Not-tested: Remote-only runtime startup semantics not exercised by the session persistence change
2026-04-01 01:02:05 +00:00
Yeachan-Heo
146260083c 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
2026-04-01 00:58:14 +00:00
Yeachan-Heo
cd01d0e387 Honor Claude config defaults across runtime sessions
The runtime now discovers both legacy and current Claude config files at
user and project scope, merges them in precedence order, and carries the
resolved model, permission mode, instruction files, and MCP server
configuration into session startup.

This keeps CLI defaults aligned with project policy and exposes configured
MCP tools without requiring manual flags.

Constraint: Must support both legacy .claude.json and current .claude/settings.json layouts
Constraint: Session startup must preserve CLI flag precedence over config defaults
Rejected: Read only project settings files | would ignore user-scoped defaults and MCP servers
Rejected: Delay MCP tool discovery until first tool call | model would not see configured MCP tools during planning
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep config precedence synchronized between prompt loading, session startup, and status reporting
Tested: cargo fmt --all --check; cargo clippy --workspace --all-targets --all-features -- -D warnings; cargo test --workspace --all-features
Not-tested: Live remote MCP servers and interactive REPL session startup against external services
2026-04-01 00:36:32 +00:00
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);
} }