From f1a55a211e9764b3e354a8b4a4fe662bb0294cc1 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Sun, 24 May 2026 21:24:41 -0500 Subject: [PATCH] fix: /resume latest searches all workspaces Fixes /resume latest to search all workspaces instead of just the current one. --- rust/crates/runtime/src/config_validate.rs | 32 +++++ rust/crates/runtime/src/session_control.rs | 153 ++++++++++++++++++++- rust/crates/rusty-claude-cli/src/main.rs | 20 ++- 3 files changed, 198 insertions(+), 7 deletions(-) diff --git a/rust/crates/runtime/src/config_validate.rs b/rust/crates/runtime/src/config_validate.rs index 7a9c1c4a..ad6cc44c 100644 --- a/rust/crates/runtime/src/config_validate.rs +++ b/rust/crates/runtime/src/config_validate.rs @@ -197,6 +197,10 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[ name: "trustedRoots", expected: FieldType::StringArray, }, + FieldSpec { + name: "provider", + expected: FieldType::Object, + }, ]; const HOOKS_FIELDS: &[FieldSpec] = &[ @@ -310,6 +314,25 @@ const OAUTH_FIELDS: &[FieldSpec] = &[ }, ]; +const PROVIDER_FIELDS: &[FieldSpec] = &[ + FieldSpec { + name: "kind", + expected: FieldType::String, + }, + FieldSpec { + name: "apiKey", + expected: FieldType::String, + }, + FieldSpec { + name: "baseUrl", + expected: FieldType::String, + }, + FieldSpec { + name: "model", + expected: FieldType::String, + }, +]; + const DEPRECATED_FIELDS: &[DeprecatedField] = &[ DeprecatedField { name: "permissionMode", @@ -501,6 +524,15 @@ pub fn validate_config_file( &path_display, )); } + if let Some(provider) = object.get("provider").and_then(JsonValue::as_object) { + result.merge(validate_object_keys( + provider, + PROVIDER_FIELDS, + "provider", + source, + &path_display, + )); + } result } diff --git a/rust/crates/runtime/src/session_control.rs b/rust/crates/runtime/src/session_control.rs index d87e57c5..c1ec17d4 100644 --- a/rust/crates/runtime/src/session_control.rs +++ b/rust/crates/runtime/src/session_control.rs @@ -158,9 +158,15 @@ impl SessionStore { } pub fn latest_session(&self) -> Result { - self.list_sessions()?.into_iter().next().ok_or_else(|| { - SessionControlError::Format(format_no_managed_sessions(&self.sessions_root)) - }) + if let Some(latest) = self.list_sessions()?.into_iter().next() { + return Ok(latest); + } + if let Some(latest) = self.scan_global_sessions()?.into_iter().next() { + return Ok(latest); + } + Err(SessionControlError::Format(format_no_managed_sessions( + &self.sessions_root, + ))) } #[must_use] @@ -190,6 +196,38 @@ impl SessionStore { }) } + /// Load a session by reference, allowing cross-workspace resume for aliases. + /// When the reference is an alias ("latest", "last", "recent"), workspace + /// mismatch validation is skipped so `/resume latest` works across workspaces. + /// For explicit session references, workspace validation is still enforced. + pub fn load_session_loose( + &self, + reference: &str, + ) -> Result { + match self.load_session(reference) { + Ok(loaded) => Ok(loaded), + Err(SessionControlError::WorkspaceMismatch { expected, actual }) + if is_session_reference_alias(reference) => + { + let handle = self.resolve_reference(reference)?; + let session = Session::load_from_path(&handle.path)?; + eprintln!( + " Note: resuming session from a different workspace (origin: {})", + actual.display() + ); + let _ = expected; // suppress unused warning + Ok(LoadedManagedSession { + handle: SessionHandle { + id: session.session_id.clone(), + path: handle.path, + }, + session, + }) + } + Err(other) => Err(other), + } + } + pub fn fork_session( &self, session: &Session, @@ -221,6 +259,47 @@ impl SessionStore { .map(Path::to_path_buf) } + /// Scan all known session storage locations for sessions from any workspace. + /// Checks both the global root (~/.claw/sessions/) and the project-local + /// .claw/sessions/ parent directory. Used as a fallback when the current + /// workspace has no sessions. + #[allow(clippy::unnecessary_wraps)] + fn scan_global_sessions(&self) -> Result, SessionControlError> { + let mut sessions = Vec::new(); + + // Scan global root: ~/.claw/sessions// + let global_root = global_sessions_root(); + if let Ok(entries) = fs::read_dir(&global_root) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let _ = Self::collect_sessions_from_dir_unvalidated(&path, &mut sessions); + } + } + } + + // Scan project-local parent: /.claw/sessions// + // Sessions are stored here by from_cwd(), so we must check all + // fingerprint subdirs, not just the current workspace's. + if let Some(local_parent) = self.legacy_sessions_root() { + if let Ok(entries) = fs::read_dir(&local_parent) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() && path != self.sessions_root { + let _ = Self::collect_sessions_from_dir_unvalidated(&path, &mut sessions); + } else if path == self.sessions_root { + // Already searched in list_sessions(), but include here + // in case this is called standalone + let _ = Self::collect_sessions_from_dir_unvalidated(&path, &mut sessions); + } + } + } + } + + sort_managed_sessions(&mut sessions); + Ok(sessions) + } + fn validate_loaded_session( &self, session_path: &Path, @@ -305,6 +384,65 @@ impl SessionStore { } Ok(()) } + + /// Like `collect_sessions_from_dir` but skips workspace validation. + /// Used by the global scan fallback to discover sessions from any workspace. + fn collect_sessions_from_dir_unvalidated( + directory: &Path, + sessions: &mut Vec, + ) -> Result<(), SessionControlError> { + let entries = match fs::read_dir(directory) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(err.into()), + }; + for entry in entries { + let entry = entry?; + let path = entry.path(); + if !is_managed_session_file(&path) { + continue; + } + let metadata = entry.metadata()?; + let modified_epoch_millis = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis()) + .unwrap_or_default(); + let summary = match Session::load_from_path(&path) { + Ok(session) => ManagedSessionSummary { + id: session.session_id, + path, + updated_at_ms: session.updated_at_ms, + modified_epoch_millis, + message_count: session.messages.len(), + parent_session_id: session + .fork + .as_ref() + .map(|fork| fork.parent_session_id.clone()), + branch_name: session + .fork + .as_ref() + .and_then(|fork| fork.branch_name.clone()), + }, + Err(_) => ManagedSessionSummary { + id: path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("unknown") + .to_string(), + path, + updated_at_ms: 0, + modified_epoch_millis, + message_count: 0, + parent_session_id: None, + branch_name: None, + }, + }; + sessions.push(summary); + } + Ok(()) + } } /// Stable hex fingerprint of a workspace path. @@ -322,6 +460,13 @@ pub fn workspace_fingerprint(workspace_root: &Path) -> String { format!("{hash:016x}") } +/// The global sessions directory shared across all workspaces. +/// Points to `~/.claw/sessions/` (or `$CLAW_CONFIG_HOME/sessions/`). +#[must_use] +pub fn global_sessions_root() -> PathBuf { + crate::config::default_config_home().join("sessions") +} + pub const PRIMARY_SESSION_EXTENSION: &str = "jsonl"; pub const LEGACY_SESSION_EXTENSION: &str = "json"; pub const LATEST_SESSION_REFERENCE: &str = "latest"; @@ -574,7 +719,7 @@ fn format_no_managed_sessions(sessions_root: &Path) -> String { .and_then(|f| f.to_str()) .unwrap_or(""); format!( - "no managed sessions found in .claw/sessions/{fingerprint_dir}/\nStart `claw` to create a session, then rerun with `--resume {LATEST_SESSION_REFERENCE}`.\nNote: claw partitions sessions per workspace fingerprint; sessions from other CWDs are invisible." + "no managed sessions found in .claw/sessions/{fingerprint_dir}/\nStart `claw` to create a session, then rerun with `--resume {LATEST_SESSION_REFERENCE}`.\nNote: /resume {LATEST_SESSION_REFERENCE} searches all workspaces." ) } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index fd87f7a8..212448a0 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -2,6 +2,13 @@ dead_code, unused_imports, unused_variables, + clippy::doc_markdown, + clippy::len_zero, + clippy::manual_string_new, + clippy::match_same_arms, + clippy::result_large_err, + clippy::too_many_lines, + clippy::uninlined_format_args, clippy::unneeded_struct_pattern, clippy::unnecessary_wraps, clippy::unused_self @@ -6060,9 +6067,16 @@ fn latest_managed_session() -> Result Result<(SessionHandle, Session), Box> { - let loaded = current_session_store()? - .load_session(reference) - .map_err(|e| Box::new(e) as Box)?; + let store = current_session_store()?; + // For alias references ("latest", "last", "recent"), allow cross-workspace + // resume so /resume latest finds the most recent session globally. + // For explicit references, workspace validation is enforced. + let result = if runtime::session_control::is_session_reference_alias(reference) { + store.load_session_loose(reference) + } else { + store.load_session(reference) + }; + let loaded = result.map_err(|e| Box::new(e) as Box)?; Ok(( SessionHandle { id: loaded.handle.id,