Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 32 additions & 30 deletions apps/server-rs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1905,42 +1906,43 @@ fn scan_amp_threads(home: &Path, now_ms: u64, snapshots: &mut Vec<AgentWatcherSn
}

fn scan_claude_code_projects(home: &Path, now_ms: u64, snapshots: &mut Vec<AgentWatcherSnapshot>) {
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);
}
}
}
}
Expand Down
153 changes: 149 additions & 4 deletions packages/runtime-rs/src/watch_plan.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use std::collections::BTreeSet;
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;

Expand Down Expand Up @@ -48,10 +50,13 @@ pub fn builtin_provider_specs(home: &Path) -> Vec<ProviderWatchSpec> {
},
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)),
},
Expand Down Expand Up @@ -91,6 +96,65 @@ pub fn builtin_provider_specs(home: &Path) -> Vec<ProviderWatchSpec> {
]
}

pub fn claude_code_projects_dirs(home: &Path) -> Vec<PathBuf> {
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<OsString>,
) -> Vec<PathBuf> {
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::<Vec<_>>();
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<PathBuf>, path: PathBuf) {
if !paths.iter().any(|existing| existing == &path) {
paths.push(path);
}
}

pub fn coalesce_watch_roots(input: Vec<(&str, PathBuf, WatchKind)>) -> Vec<CoalescedWatchRoot> {
let mut roots: Vec<CoalescedWatchRoot> = Vec::new();

Expand Down Expand Up @@ -137,3 +201,84 @@ fn push_provider(providers: &mut Vec<String>, 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
}
}