From ded19815f850dd5742f13c957a77f6c617c91678 Mon Sep 17 00:00:00 2001 From: ndom91 Date: Sun, 14 Jun 2026 12:43:48 +0200 Subject: [PATCH] Support CLAUDE_CONFIG_DIR and checking multiple claude config dirs --- apps/server-rs/src/lib.rs | 62 ++++++----- packages/runtime-rs/src/watch_plan.rs | 153 +++++++++++++++++++++++++- 2 files changed, 181 insertions(+), 34 deletions(-) diff --git a/apps/server-rs/src/lib.rs b/apps/server-rs/src/lib.rs index 061b33b..3fa4d76 100644 --- a/apps/server-rs/src/lib.rs +++ b/apps/server-rs/src/lib.rs @@ -38,6 +38,7 @@ use opensessions_runtime::sidebar_coordinator::SidebarCoordinator; use opensessions_runtime::sidebar_width_sync::clamp_sidebar_width; use opensessions_runtime::tmux_provider::{StdCommandRunner, TmuxProvider}; use opensessions_runtime::tracker::{AgentTracker, PanePresenceInput}; +use opensessions_runtime::watch_plan::claude_code_projects_dirs; use opensessions_sidebar_core::app::App as SidebarApp; use opensessions_sidebar_core::generated::protocol::ServerMessage as SidebarServerMessage; use serde_json::Value; @@ -1905,42 +1906,43 @@ fn scan_amp_threads(home: &Path, now_ms: u64, snapshots: &mut Vec) { - let projects_dir = home.join(".claude/projects"); - let Ok(projects) = fs::read_dir(projects_dir) else { - return; - }; - - for project in projects.flatten() { - let project_path = project.path(); - if !project_path.is_dir() { - continue; - } - let encoded = project.file_name().to_string_lossy().to_string(); - let project_dir = decode_claude_project_dir(&encoded, |path| Path::new(path).is_dir()); - let Ok(files) = fs::read_dir(project_path) else { + for projects_dir in claude_code_projects_dirs(home) { + let Ok(projects) = fs::read_dir(projects_dir) else { continue; }; - for file in files.flatten() { - let path = file.path(); - if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") { - continue; - } - let Some(mtime_ms) = file_mtime_ms(&path) else { - continue; - }; - if now_ms.saturating_sub(mtime_ms) > AGENT_WATCHER_RECENT_MS { + + for project in projects.flatten() { + let project_path = project.path(); + if !project_path.is_dir() { continue; } - let Some(thread_id) = path.file_stem().and_then(|stem| stem.to_str()) else { + let encoded = project.file_name().to_string_lossy().to_string(); + let project_dir = decode_claude_project_dir(&encoded, |path| Path::new(path).is_dir()); + let Ok(files) = fs::read_dir(project_path) else { continue; }; - let Ok(raw) = fs::read_to_string(&path) else { - continue; - }; - if let Some(snapshot) = - claude_code_snapshot_from_jsonl(thread_id, &project_dir, &raw, mtime_ms, now_ms) - { - snapshots.push(snapshot); + for file in files.flatten() { + let path = file.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") { + continue; + } + let Some(mtime_ms) = file_mtime_ms(&path) else { + continue; + }; + if now_ms.saturating_sub(mtime_ms) > AGENT_WATCHER_RECENT_MS { + continue; + } + let Some(thread_id) = path.file_stem().and_then(|stem| stem.to_str()) else { + continue; + }; + let Ok(raw) = fs::read_to_string(&path) else { + continue; + }; + if let Some(snapshot) = + claude_code_snapshot_from_jsonl(thread_id, &project_dir, &raw, mtime_ms, now_ms) + { + snapshots.push(snapshot); + } } } } diff --git a/packages/runtime-rs/src/watch_plan.rs b/packages/runtime-rs/src/watch_plan.rs index 3f0e86e..a88b7a9 100644 --- a/packages/runtime-rs/src/watch_plan.rs +++ b/packages/runtime-rs/src/watch_plan.rs @@ -1,4 +1,6 @@ use std::collections::BTreeSet; +use std::ffi::OsString; +use std::fs; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -48,10 +50,13 @@ pub fn builtin_provider_specs(home: &Path) -> Vec { }, ProviderWatchSpec { provider: "claude-code".to_string(), - roots: vec![WatchRoot { - path: home.join(".claude/projects"), - kind: WatchKind::RecursiveDirectory, - }], + roots: claude_code_projects_dirs(home) + .into_iter() + .map(|path| WatchRoot { + path, + kind: WatchKind::RecursiveDirectory, + }) + .collect(), debounce: Duration::from_millis(150), fallback_poll: Some(Duration::from_secs(2)), }, @@ -91,6 +96,65 @@ pub fn builtin_provider_specs(home: &Path) -> Vec { ] } +pub fn claude_code_projects_dirs(home: &Path) -> Vec { + claude_code_projects_dirs_with_config_dir(home, std::env::var_os("CLAUDE_CONFIG_DIR")) +} + +fn claude_code_projects_dirs_with_config_dir( + home: &Path, + config_dir: Option, +) -> Vec { + let mut dirs = Vec::new(); + push_unique(&mut dirs, home.join(".claude/projects")); + + if let Some(config_dir) = config_dir.filter(|value| !value.is_empty()) { + push_unique( + &mut dirs, + expand_home_path(home, PathBuf::from(config_dir)).join("projects"), + ); + } + + if let Ok(entries) = fs::read_dir(home) { + let mut sibling_projects_dirs = entries + .flatten() + .filter_map(|entry| { + let name = entry.file_name(); + let name = name.to_str()?; + if !name.starts_with(".claude") { + return None; + } + let projects_dir = entry.path().join("projects"); + projects_dir.is_dir().then_some(projects_dir) + }) + .collect::>(); + sibling_projects_dirs.sort(); + for projects_dir in sibling_projects_dirs { + push_unique(&mut dirs, projects_dir); + } + } + + dirs +} + +fn expand_home_path(home: &Path, path: PathBuf) -> PathBuf { + let Some(path_text) = path.to_str() else { + return path; + }; + if path_text == "~" { + return home.to_path_buf(); + } + if let Some(rest) = path_text.strip_prefix("~/") { + return home.join(rest); + } + path +} + +fn push_unique(paths: &mut Vec, path: PathBuf) { + if !paths.iter().any(|existing| existing == &path) { + paths.push(path); + } +} + pub fn coalesce_watch_roots(input: Vec<(&str, PathBuf, WatchKind)>) -> Vec { let mut roots: Vec = Vec::new(); @@ -137,3 +201,84 @@ fn push_provider(providers: &mut Vec, provider: &str) { fn path_len(path: &Path) -> usize { path.components().count() } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn claude_code_projects_dirs_include_env_config_dir() { + let home = unique_test_home("claude-env"); + + let dirs = claude_code_projects_dirs_with_config_dir( + &home, + Some(OsString::from("~/.claude-personal")), + ); + + assert_eq!( + dirs, + vec![ + home.join(".claude/projects"), + home.join(".claude-personal/projects"), + ] + ); + + fs::remove_dir_all(&home).expect("clean test home"); + } + + #[test] + fn claude_code_projects_dirs_scan_existing_sibling_config_dirs() { + let home = unique_test_home("claude-siblings"); + fs::create_dir_all(home.join(".claude/projects")).expect("create default projects dir"); + fs::create_dir_all(home.join(".claude-personal/projects")) + .expect("create personal projects dir"); + fs::create_dir_all(home.join(".claude-work/no-projects")) + .expect("create non-project claude dir"); + fs::create_dir_all(home.join(".not-claude/projects")).expect("create unrelated dir"); + + let dirs = claude_code_projects_dirs_with_config_dir(&home, None); + + assert_eq!( + dirs, + vec![ + home.join(".claude/projects"), + home.join(".claude-personal/projects"), + ] + ); + + fs::remove_dir_all(&home).expect("clean test home"); + } + + #[test] + fn claude_code_projects_dirs_deduplicate_env_and_scan_matches() { + let home = unique_test_home("claude-dedupe"); + fs::create_dir_all(home.join(".claude-personal/projects")) + .expect("create personal projects dir"); + + let dirs = claude_code_projects_dirs_with_config_dir( + &home, + Some(OsString::from(home.join(".claude-personal"))), + ); + + assert_eq!( + dirs, + vec![ + home.join(".claude/projects"), + home.join(".claude-personal/projects"), + ] + ); + + fs::remove_dir_all(&home).expect("clean test home"); + } + + fn unique_test_home(name: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time is after epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!("opensessions-{name}-{suffix}")); + fs::create_dir_all(&path).expect("create test home"); + path + } +}