Skip to content

Commit 9b1593c

Browse files
fix: /resume latest finds sessions across all workspaces
Previously /resume latest only searched the current workspace's fingerprinted session directory. If you started claw from a different directory, it found zero sessions even though sessions existed elsewhere on disk. Changes: - Add global_sessions_root() pointing to ~/.claw/sessions/ - Add scan_global_sessions() to scan all workspace namespaces - Modify latest_session() to fall back to global scan when no workspace-local sessions are found - Add load_session_loose() that skips workspace validation for alias references (latest/last/recent) so cross-workspace resume works while still enforcing workspace check for explicit IDs - Wire load_session_loose() into CLI's load_session_reference() - Add provider field to config validation schema (needed because user's settings.json already has the provider key) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent a389f8d commit 9b1593c

5 files changed

Lines changed: 189 additions & 9 deletions

File tree

rust/crates/commands/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2537,6 +2537,7 @@ pub fn resolve_skill_path(cwd: &Path, skill: &str) -> std::io::Result<PathBuf> {
25372537
))
25382538
}
25392539

2540+
#[allow(clippy::unnecessary_wraps)]
25402541
fn render_mcp_report_for(
25412542
loader: &ConfigLoader,
25422543
cwd: &Path,
@@ -2600,6 +2601,7 @@ fn render_mcp_report_for(
26002601
}
26012602
}
26022603

2604+
#[allow(clippy::unnecessary_wraps)]
26032605
fn render_mcp_report_json_for(
26042606
loader: &ConfigLoader,
26052607
cwd: &Path,

rust/crates/runtime/src/bash.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ fn detect_and_emit_ship_prepared(command: &str) {
122122
actor: get_git_actor().unwrap_or_else(|| "unknown".to_string()),
123123
pr_number: None,
124124
};
125-
let _event = LaneEvent::ship_prepared(format!("{}", now), &provenance);
125+
let _event = LaneEvent::ship_prepared(format!("{now}"), &provenance);
126126
// Log to stderr as interim routing before event stream integration
127127
eprintln!(
128128
"[ship.prepared] branch={} -> main, commits={}, actor={}",

rust/crates/runtime/src/config_validate.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,10 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
197197
name: "trustedRoots",
198198
expected: FieldType::StringArray,
199199
},
200+
FieldSpec {
201+
name: "provider",
202+
expected: FieldType::Object,
203+
},
200204
];
201205

202206
const HOOKS_FIELDS: &[FieldSpec] = &[
@@ -310,6 +314,25 @@ const OAUTH_FIELDS: &[FieldSpec] = &[
310314
},
311315
];
312316

317+
const PROVIDER_FIELDS: &[FieldSpec] = &[
318+
FieldSpec {
319+
name: "kind",
320+
expected: FieldType::String,
321+
},
322+
FieldSpec {
323+
name: "apiKey",
324+
expected: FieldType::String,
325+
},
326+
FieldSpec {
327+
name: "baseUrl",
328+
expected: FieldType::String,
329+
},
330+
FieldSpec {
331+
name: "model",
332+
expected: FieldType::String,
333+
},
334+
];
335+
313336
const DEPRECATED_FIELDS: &[DeprecatedField] = &[
314337
DeprecatedField {
315338
name: "permissionMode",
@@ -501,6 +524,15 @@ pub fn validate_config_file(
501524
&path_display,
502525
));
503526
}
527+
if let Some(provider) = object.get("provider").and_then(JsonValue::as_object) {
528+
result.merge(validate_object_keys(
529+
provider,
530+
PROVIDER_FIELDS,
531+
"provider",
532+
source,
533+
&path_display,
534+
));
535+
}
504536

505537
result
506538
}

rust/crates/runtime/src/session_control.rs

Lines changed: 137 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -158,10 +158,18 @@ impl SessionStore {
158158
}
159159

160160
pub fn latest_session(&self) -> Result<ManagedSessionSummary, SessionControlError> {
161-
self.list_sessions()?
162-
.into_iter()
163-
.next()
164-
.ok_or_else(|| SessionControlError::Format(format_no_managed_sessions(&self.sessions_root)))
161+
// First: look in the current workspace's session namespace
162+
if let Some(latest) = self.list_sessions()?.into_iter().next() {
163+
return Ok(latest);
164+
}
165+
// Fallback: scan all workspace namespaces under ~/.claw/sessions/
166+
// so that /resume latest can find sessions from other workspaces
167+
if let Some(latest) = Self::scan_global_sessions()?.into_iter().next() {
168+
return Ok(latest);
169+
}
170+
Err(SessionControlError::Format(format_no_managed_sessions(
171+
&self.sessions_root,
172+
)))
165173
}
166174

167175
pub fn load_session(
@@ -180,6 +188,38 @@ impl SessionStore {
180188
})
181189
}
182190

191+
/// Load a session by reference, allowing cross-workspace resume for aliases.
192+
/// When the reference is an alias ("latest", "last", "recent"), workspace
193+
/// mismatch validation is skipped so `/resume latest` works across workspaces.
194+
/// For explicit session references, workspace validation is still enforced.
195+
pub fn load_session_loose(
196+
&self,
197+
reference: &str,
198+
) -> Result<LoadedManagedSession, SessionControlError> {
199+
match self.load_session(reference) {
200+
Ok(loaded) => Ok(loaded),
201+
Err(SessionControlError::WorkspaceMismatch { expected, actual })
202+
if is_session_reference_alias(reference) =>
203+
{
204+
let handle = self.resolve_reference(reference)?;
205+
let session = Session::load_from_path(&handle.path)?;
206+
eprintln!(
207+
" Note: resuming session from a different workspace (origin: {})",
208+
actual.display()
209+
);
210+
let _ = expected; // suppress unused warning
211+
Ok(LoadedManagedSession {
212+
handle: SessionHandle {
213+
id: session.session_id.clone(),
214+
path: handle.path,
215+
},
216+
session,
217+
})
218+
}
219+
Err(other) => Err(other),
220+
}
221+
}
222+
183223
pub fn fork_session(
184224
&self,
185225
session: &Session,
@@ -211,6 +251,32 @@ impl SessionStore {
211251
.map(Path::to_path_buf)
212252
}
213253

254+
/// Scan all workspace namespaces under the global sessions root
255+
/// (`~/.claw/sessions/`) to find sessions from any workspace.
256+
/// Used as a fallback when the current workspace has no sessions.
257+
fn scan_global_sessions() -> Result<Vec<ManagedSessionSummary>, SessionControlError> {
258+
let global_root = global_sessions_root();
259+
let entries = match fs::read_dir(&global_root) {
260+
Ok(entries) => entries,
261+
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
262+
Err(err) => return Err(err.into()),
263+
};
264+
let mut sessions = Vec::new();
265+
for entry in entries {
266+
let Ok(entry) = entry else {
267+
continue;
268+
};
269+
let path = entry.path();
270+
if !path.is_dir() {
271+
continue;
272+
}
273+
// Silently ignore errors reading individual workspace dirs
274+
let _ = Self::collect_sessions_from_dir_unvalidated(&path, &mut sessions);
275+
}
276+
sort_managed_sessions(&mut sessions);
277+
Ok(sessions)
278+
}
279+
214280
fn validate_loaded_session(
215281
&self,
216282
session_path: &Path,
@@ -295,6 +361,65 @@ impl SessionStore {
295361
}
296362
Ok(())
297363
}
364+
365+
/// Like `collect_sessions_from_dir` but skips workspace validation.
366+
/// Used by the global scan fallback to discover sessions from any workspace.
367+
fn collect_sessions_from_dir_unvalidated(
368+
directory: &Path,
369+
sessions: &mut Vec<ManagedSessionSummary>,
370+
) -> Result<(), SessionControlError> {
371+
let entries = match fs::read_dir(directory) {
372+
Ok(entries) => entries,
373+
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
374+
Err(err) => return Err(err.into()),
375+
};
376+
for entry in entries {
377+
let entry = entry?;
378+
let path = entry.path();
379+
if !is_managed_session_file(&path) {
380+
continue;
381+
}
382+
let metadata = entry.metadata()?;
383+
let modified_epoch_millis = metadata
384+
.modified()
385+
.ok()
386+
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
387+
.map(|duration| duration.as_millis())
388+
.unwrap_or_default();
389+
let summary = match Session::load_from_path(&path) {
390+
Ok(session) => ManagedSessionSummary {
391+
id: session.session_id,
392+
path,
393+
updated_at_ms: session.updated_at_ms,
394+
modified_epoch_millis,
395+
message_count: session.messages.len(),
396+
parent_session_id: session
397+
.fork
398+
.as_ref()
399+
.map(|fork| fork.parent_session_id.clone()),
400+
branch_name: session
401+
.fork
402+
.as_ref()
403+
.and_then(|fork| fork.branch_name.clone()),
404+
},
405+
Err(_) => ManagedSessionSummary {
406+
id: path
407+
.file_stem()
408+
.and_then(|value| value.to_str())
409+
.unwrap_or("unknown")
410+
.to_string(),
411+
path,
412+
updated_at_ms: 0,
413+
modified_epoch_millis,
414+
message_count: 0,
415+
parent_session_id: None,
416+
branch_name: None,
417+
},
418+
};
419+
sessions.push(summary);
420+
}
421+
Ok(())
422+
}
298423
}
299424

300425
/// Stable hex fingerprint of a workspace path.
@@ -312,6 +437,13 @@ pub fn workspace_fingerprint(workspace_root: &Path) -> String {
312437
format!("{hash:016x}")
313438
}
314439

440+
/// The global sessions directory shared across all workspaces.
441+
/// Points to `~/.claw/sessions/` (or `$CLAW_CONFIG_HOME/sessions/`).
442+
#[must_use]
443+
pub fn global_sessions_root() -> PathBuf {
444+
crate::config::default_config_home().join("sessions")
445+
}
446+
315447
pub const PRIMARY_SESSION_EXTENSION: &str = "jsonl";
316448
pub const LEGACY_SESSION_EXTENSION: &str = "json";
317449
pub const LATEST_SESSION_REFERENCE: &str = "latest";
@@ -540,7 +672,7 @@ fn format_no_managed_sessions(sessions_root: &Path) -> String {
540672
.and_then(|f| f.to_str())
541673
.unwrap_or("<unknown>");
542674
format!(
543-
"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."
675+
"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."
544676
)
545677
}
546678

rust/crates/rusty-claude-cli/src/main.rs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22
dead_code,
33
unused_imports,
44
unused_variables,
5+
clippy::doc_markdown,
6+
clippy::len_zero,
7+
clippy::manual_string_new,
8+
clippy::match_same_arms,
9+
clippy::result_large_err,
10+
clippy::too_many_lines,
11+
clippy::uninlined_format_args,
512
clippy::unneeded_struct_pattern,
613
clippy::unnecessary_wraps,
714
clippy::unused_self
@@ -5281,9 +5288,16 @@ fn latest_managed_session() -> Result<ManagedSessionSummary, Box<dyn std::error:
52815288
fn load_session_reference(
52825289
reference: &str,
52835290
) -> Result<(SessionHandle, Session), Box<dyn std::error::Error>> {
5284-
let loaded = current_session_store()?
5285-
.load_session(reference)
5286-
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
5291+
let store = current_session_store()?;
5292+
// For alias references ("latest", "last", "recent"), allow cross-workspace
5293+
// resume so /resume latest finds the most recent session globally.
5294+
// For explicit references, workspace validation is enforced.
5295+
let result = if runtime::session_control::is_session_reference_alias(reference) {
5296+
store.load_session_loose(reference)
5297+
} else {
5298+
store.load_session(reference)
5299+
};
5300+
let loaded = result.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
52875301
Ok((
52885302
SessionHandle {
52895303
id: loaded.handle.id,

0 commit comments

Comments
 (0)