use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
pub const ACTIVE_WINDOW: Duration = Duration::from_secs(300);
pub const SCAN_WINDOW: Duration = Duration::from_secs(30 * 24 * 60 * 60);
const RESUME_SLACK: Duration = Duration::from_secs(30 * 24 * 60 * 60);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum AgentKind {
ClaudeCode,
Codex,
Opencode,
Vibe,
}
impl AgentKind {
pub fn display(&self) -> &'static str {
match self {
AgentKind::ClaudeCode => "claude",
AgentKind::Codex => "codex",
AgentKind::Opencode => "opencode",
AgentKind::Vibe => "vibe",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentSession {
pub kind: AgentKind,
pub cwd: PathBuf,
pub last_activity: SystemTime,
pub ended: bool,
pub id: String,
pub name: Option<String>,
}
const NAME_MAX_CHARS: usize = 60;
const NAME_SCAN_BYTES: u64 = 64 * 1024;
fn clean_session_name(raw: &str) -> Option<String> {
if let Some(rest) = raw.split("<command-name>").nth(1) {
if let Some(cmd) = rest.split("</command-name>").next() {
if let Some(clean) = collapse_and_cap(cmd) {
return Some(clean);
}
}
}
collapse_and_cap(raw)
}
fn clean_id(raw: &str) -> Option<String> {
let cleaned: String = raw.chars().filter(|c| !c.is_control()).collect();
if cleaned.is_empty() {
None
} else {
Some(cleaned)
}
}
fn read_capped(path: &Path, cap: u64) -> Option<String> {
use std::io::Read;
let file = std::fs::File::open(path).ok()?;
let mut buf = String::new();
file.take(cap.saturating_add(1)).read_to_string(&mut buf).ok()?;
if buf.len() as u64 > cap {
return None;
}
Some(buf)
}
fn collapse_and_cap(raw: &str) -> Option<String> {
let stripped: String = raw
.chars()
.map(|c| if c.is_control() && !c.is_whitespace() { ' ' } else { c })
.collect();
let collapsed = stripped.split_whitespace().collect::<Vec<_>>().join(" ");
if collapsed.is_empty() {
return None;
}
Some(collapsed.chars().take(NAME_MAX_CHARS).collect())
}
fn first_user_text(path: &Path) -> Option<String> {
use std::io::{BufRead, Read};
let file = std::fs::File::open(path).ok()?;
let mut reader = std::io::BufReader::new(file.take(NAME_SCAN_BYTES));
let mut line = String::new();
loop {
line.clear();
let n = reader.read_line(&mut line).ok()?;
if n == 0 {
return None;
}
let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
continue;
};
if v.get("type").and_then(|t| t.as_str()) == Some("user") {
let content = v.get("message").and_then(|m| m.get("content"));
let text = match content {
Some(serde_json::Value::String(s)) => Some(s.clone()),
Some(serde_json::Value::Array(parts)) => parts.iter().find_map(|p| {
(p.get("type").and_then(|t| t.as_str()) == Some("text"))
.then(|| p.get("text").and_then(|t| t.as_str()).map(str::to_string))
.flatten()
}),
_ => None,
};
if let Some(t) = text.as_deref().and_then(clean_session_name) {
return Some(t);
}
}
if let Some(p) = v.get("payload") {
if p.get("type").and_then(|t| t.as_str()) == Some("user_message") {
if let Some(t) = p.get("message").and_then(|m| m.as_str()).and_then(clean_session_name) {
return Some(t);
}
}
}
}
}
pub struct ClaudeCodeSource;
struct LiveEntry {
name: Option<String>,
dead: bool,
}
fn pid_is_alive(pid: u64) -> bool {
#[cfg(unix)]
{
let Ok(pid) = libc::pid_t::try_from(pid) else {
return true;
};
if pid <= 0 {
return true;
}
(unsafe { libc::kill(pid, 0) } == 0) || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
#[cfg(not(unix))]
{
let _ = pid;
true
}
}
fn claude_live_registry(projects_base: &Path) -> std::collections::HashMap<String, LiveEntry> {
let mut map = std::collections::HashMap::new();
let Some(sessions_dir) = projects_base.parent().map(|p| p.join("sessions")) else {
return map;
};
let Ok(entries) = std::fs::read_dir(sessions_dir) else {
return map;
};
for entry in entries.flatten() {
let Some(raw) = read_capped(&entry.path(), NAME_SCAN_BYTES) else {
continue;
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) else {
continue;
};
let Some(sid) = v.get("sessionId").and_then(|x| x.as_str()) else {
continue;
};
let incoming = LiveEntry {
name: v.get("name").and_then(|x| x.as_str()).and_then(clean_session_name),
dead: v.get("pid").and_then(|x| x.as_u64()).is_some_and(|p| !pid_is_alive(p)),
};
match map.entry(sid.to_string()) {
std::collections::hash_map::Entry::Vacant(slot) => {
slot.insert(incoming);
}
std::collections::hash_map::Entry::Occupied(mut slot) => {
let current = slot.get_mut();
if current.dead && !incoming.dead {
let kept_name = incoming.name.or_else(|| current.name.take());
*current = LiveEntry {
name: kept_name,
dead: false,
};
} else if current.dead == incoming.dead && current.name.is_none() {
current.name = incoming.name;
}
}
}
}
map
}
impl ClaudeCodeSource {
pub fn scan(&self, base: &Path, worktrees: &[PathBuf], now: SystemTime) -> Vec<AgentSession> {
self.scan_impl(base, worktrees, now, true)
}
pub fn scan_matched(&self, base: &Path, worktrees: &[PathBuf], now: SystemTime) -> Vec<AgentSession> {
self.scan_impl(base, worktrees, now, false)
}
fn scan_impl(&self, base: &Path, worktrees: &[PathBuf], now: SystemTime, sweep: bool) -> Vec<AgentSession> {
let live = claude_live_registry(base);
let slugs: Vec<String> = worktrees
.iter()
.map(|wt| claude_slug(&wt.components().collect::<PathBuf>()))
.collect();
let mut out = Vec::new();
let mut claimed: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
for (wt, slug) in worktrees.iter().zip(&slugs) {
if slugs.iter().filter(|s| *s == slug).count() > 1 {
continue;
}
claimed.insert(slug.as_str());
scan_claude_dir(&base.join(slug), wt, &live, now, &mut out);
}
if !sweep {
return out;
}
if let Ok(entries) = std::fs::read_dir(base) {
for entry in entries.flatten() {
let dir = entry.path();
if !dir.is_dir() {
continue; }
let name = entry.file_name();
if claimed.contains(name.to_string_lossy().as_ref()) {
continue;
}
scan_claude_dir(&dir, &dir, &live, now, &mut out);
}
}
out
}
}
fn scan_claude_dir(
dir: &Path,
cwd: &Path,
live: &std::collections::HashMap<String, LiveEntry>,
now: SystemTime,
out: &mut Vec<AgentSession>,
) {
let Ok(entries) = std::fs::read_dir(dir) else {
return; };
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue; }
let Some(mtime) = file_mtime(&path) else {
continue;
};
if !within_scan_window(mtime, now) {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let Some(id) = clean_id(stem) else {
continue;
};
let entry = live.get(stem);
out.push(AgentSession {
kind: AgentKind::ClaudeCode,
cwd: cwd.to_path_buf(),
last_activity: mtime,
ended: entry.is_some_and(|e| e.dead),
id,
name: entry.and_then(|e| e.name.clone()).or_else(|| first_user_text(&path)),
});
}
}
pub struct CodexSource;
fn codex_thread_names(sessions_base: &Path) -> std::collections::HashMap<String, String> {
let mut map = std::collections::HashMap::new();
let Some(index) = sessions_base.parent().map(|p| p.join("session_index.jsonl")) else {
return map;
};
let Some(contents) = read_capped(&index, 8 * 1024 * 1024) else {
return map;
};
for line in contents.lines() {
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
if let (Some(id), Some(name)) = (
v.get("id").and_then(|x| x.as_str()),
v.get("thread_name").and_then(|x| x.as_str()),
) {
if let Some(clean) = clean_session_name(name) {
map.insert(id.to_string(), clean);
}
}
}
map
}
impl CodexSource {
pub fn scan(&self, base: &Path, now: SystemTime) -> Vec<AgentSession> {
self.scan_naming(base, now, &|_, _| true)
}
fn scan_naming(&self, base: &Path, now: SystemTime, want_name: &dyn Fn(&Path, &str) -> bool) -> Vec<AgentSession> {
let thread_names = codex_thread_names(base);
let days = codex_day_dirs(base, now);
let mut out = Vec::new();
for day_dir in days {
let Ok(entries) = std::fs::read_dir(&day_dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if !name.starts_with("rollout-") || path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue; }
let Some(mtime) = file_mtime(&path) else {
continue;
};
if !within_scan_window(mtime, now) {
continue;
}
let Some((cwd, id)) = codex_first_line_meta(&path) else {
continue; };
let name = thread_names.get(&id).cloned().or_else(|| {
if want_name(&cwd, &id) {
first_user_text(&path)
} else {
None
}
});
out.push(AgentSession {
kind: AgentKind::Codex,
cwd,
last_activity: mtime,
ended: false,
id,
name,
});
}
}
out
}
}
pub struct OpencodeSource;
impl OpencodeSource {
pub fn scan(&self, base: &Path, now: SystemTime) -> Vec<AgentSession> {
if let Some(db) = base
.parent()
.and_then(|p| p.parent())
.map(|p| p.join("opencode.db"))
.filter(|p| p.exists())
{
if let Some(sessions) = opencode_scan_db(&db, now) {
return sessions;
}
}
let Ok(entries) = std::fs::read_dir(base) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let Some(raw) = read_capped(&path, NAME_SCAN_BYTES) else {
continue;
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) else {
continue;
};
let id = v.get("id").and_then(|s| s.as_str()).unwrap_or_default();
let worktree = v.get("worktree").and_then(|s| s.as_str()).unwrap_or_default();
if id.is_empty() || id == "global" || worktree.is_empty() {
continue; }
let recorded_ms = v
.get("time")
.and_then(|t| t.get("updated").or_else(|| t.get("created")))
.and_then(|n| n.as_u64());
let last_activity = match recorded_ms {
Some(ms) => match SystemTime::UNIX_EPOCH.checked_add(Duration::from_millis(ms)) {
Some(t) => t,
None => continue,
},
None => match file_mtime(&path) {
Some(t) => t,
None => continue,
},
};
if !within_scan_window(last_activity, now) {
continue;
}
let Some(id) = clean_id(id) else {
continue;
};
out.push(AgentSession {
kind: AgentKind::Opencode,
cwd: PathBuf::from(worktree),
last_activity,
ended: false,
id,
name: None,
});
}
out
}
}
fn opencode_scan_db(db: &Path, now: SystemTime) -> Option<Vec<AgentSession>> {
let cutoff_ms = now
.duration_since(SystemTime::UNIX_EPOCH)
.ok()?
.saturating_sub(SCAN_WINDOW)
.as_millis() as i64;
let conn = rusqlite::Connection::open_with_flags(
db,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.ok()?;
let mut stmt = conn
.prepare(
"SELECT id, directory, title, time_updated, time_archived FROM session \
WHERE parent_id IS NULL AND time_updated >= ?1",
)
.ok()?;
let rows = stmt
.query_map([cutoff_ms], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, Option<String>>(2)?,
r.get::<_, i64>(3)?,
!matches!(r.get_ref(4)?, rusqlite::types::ValueRef::Null),
))
})
.ok()?;
Some(
rows
.filter_map(|row| {
let (id, dir, title, ms, ended) = row.ok()?;
let id = clean_id(&id)?;
let last_activity = SystemTime::UNIX_EPOCH.checked_add(Duration::from_millis(u64::try_from(ms).ok()?))?;
Some(AgentSession {
kind: AgentKind::Opencode,
cwd: PathBuf::from(dir),
last_activity,
ended,
id,
name: title.as_deref().and_then(clean_session_name),
})
})
.collect(),
)
}
pub struct VibeSource;
impl VibeSource {
pub fn scan(&self, base: &Path, now: SystemTime) -> Vec<AgentSession> {
let Ok(entries) = std::fs::read_dir(base) else {
return Vec::new();
};
let cutoff = civil_date(
now
.checked_sub(SCAN_WINDOW + RESUME_SLACK)
.unwrap_or(SystemTime::UNIX_EPOCH),
);
let mut out = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if !name.starts_with("session_") {
continue;
}
if let (Some(cut), Some(start)) = (cutoff, vibe_dir_date(&name)) {
if start < cut {
continue;
}
}
let dir = entry.path();
if !dir.is_dir() {
continue;
}
let meta_path = dir.join("meta.json");
let Some(last_activity) = file_mtime(&dir.join("messages.jsonl")).or_else(|| file_mtime(&meta_path)) else {
continue;
};
if !within_scan_window(last_activity, now) {
continue;
}
let Some(raw) = read_capped(&meta_path, NAME_SCAN_BYTES) else {
continue;
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) else {
continue; };
let cwd = v
.get("environment")
.and_then(|e| e.get("working_directory"))
.and_then(|s| s.as_str())
.unwrap_or_default();
if cwd.is_empty() {
continue;
}
let ended = v.get("end_time").is_some_and(|t| !t.is_null());
let Some(id) = v
.get("session_id")
.and_then(|s| s.as_str())
.and_then(clean_id)
.or_else(|| clean_id(&entry.file_name().to_string_lossy()))
else {
continue;
};
out.push(AgentSession {
kind: AgentKind::Vibe,
cwd: PathBuf::from(cwd),
last_activity,
ended,
id,
name: v.get("title").and_then(|t| t.as_str()).and_then(clean_session_name),
});
}
out
}
}
pub trait SessionSource {
fn kind(&self) -> AgentKind;
fn sessions(&self, base: &Path, worktrees: &[PathBuf], now: SystemTime) -> Vec<AgentSession>;
}
impl SessionSource for ClaudeCodeSource {
fn kind(&self) -> AgentKind {
AgentKind::ClaudeCode
}
fn sessions(&self, base: &Path, worktrees: &[PathBuf], now: SystemTime) -> Vec<AgentSession> {
self.scan(base, worktrees, now)
}
}
impl SessionSource for CodexSource {
fn kind(&self) -> AgentKind {
AgentKind::Codex
}
fn sessions(&self, base: &Path, _worktrees: &[PathBuf], now: SystemTime) -> Vec<AgentSession> {
self.scan(base, now)
}
}
impl SessionSource for OpencodeSource {
fn kind(&self) -> AgentKind {
AgentKind::Opencode
}
fn sessions(&self, base: &Path, _worktrees: &[PathBuf], now: SystemTime) -> Vec<AgentSession> {
self.scan(base, now)
}
}
impl SessionSource for VibeSource {
fn kind(&self) -> AgentKind {
AgentKind::Vibe
}
fn sessions(&self, base: &Path, _worktrees: &[PathBuf], now: SystemTime) -> Vec<AgentSession> {
self.scan(base, now)
}
}
fn codex_first_line_meta(path: &Path) -> Option<(PathBuf, String)> {
use std::io::{BufRead, Read};
let file = std::fs::File::open(path).ok()?;
let mut line = String::new();
std::io::BufReader::new(file.take(NAME_SCAN_BYTES))
.read_line(&mut line)
.ok()?;
if !line.ends_with('\n') && line.len() as u64 >= NAME_SCAN_BYTES {
return None;
}
let v: serde_json::Value = serde_json::from_str(line.trim()).ok()?;
let payload = v.get("payload")?;
let cwd = payload.get("cwd")?.as_str()?;
if cwd.is_empty() {
return None;
}
let id = payload
.get("session_id")
.or_else(|| payload.get("id"))
.and_then(|s| s.as_str())
.and_then(clean_id)
.or_else(|| path.file_stem().and_then(|s| clean_id(&s.to_string_lossy())))?;
Some((PathBuf::from(cwd), id))
}
fn subdirs_flat(dirs: &[PathBuf]) -> Vec<PathBuf> {
let mut out = Vec::new();
for dir in dirs {
let Ok(entries) = std::fs::read_dir(dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
out.push(path);
}
}
}
out
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; let y = yoe + era * 400 + i64::from(m <= 2);
(y, m, d)
}
fn civil_date(t: SystemTime) -> Option<(i64, u32, u32)> {
let days = t.duration_since(SystemTime::UNIX_EPOCH).ok()?.as_secs() / 86_400;
Some(civil_from_days(days as i64))
}
pub fn codex_day_dir(t: SystemTime) -> PathBuf {
let (y, m, d) = civil_date(t).unwrap_or((1970, 1, 1));
PathBuf::from(format!("{y:04}/{m:02}/{d:02}"))
}
fn dir_num(p: &Path) -> Option<i64> {
p.file_name()?.to_str()?.parse().ok()
}
fn vibe_dir_date(name: &str) -> Option<(i64, u32, u32)> {
let date = name.strip_prefix("session_")?.get(0..8)?;
if !date.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
Some((
date[0..4].parse().ok()?,
date[4..6].parse().ok()?,
date[6..8].parse().ok()?,
))
}
fn codex_day_dirs(base: &Path, now: SystemTime) -> Vec<PathBuf> {
let cutoff = civil_date(
now
.checked_sub(SCAN_WINDOW + RESUME_SLACK)
.unwrap_or(SystemTime::UNIX_EPOCH),
);
let Some((cy, cm, cd)) = cutoff else {
return subdirs_flat(&subdirs_flat(&subdirs_flat(&[base.to_path_buf()])));
};
let mut days = Vec::new();
for ydir in subdirs_flat(&[base.to_path_buf()]) {
let y = dir_num(&ydir);
if y.is_some_and(|y| y < cy) {
continue;
}
for mdir in subdirs_flat(&[ydir]) {
let m = dir_num(&mdir);
if y.zip(m).is_some_and(|(y, m)| (y, m) < (cy, i64::from(cm))) {
continue;
}
for ddir in subdirs_flat(&[mdir]) {
let d = dir_num(&ddir);
if y
.zip(m)
.zip(d)
.is_some_and(|((y, m), d)| (y, m, d) < (cy, i64::from(cm), i64::from(cd)))
{
continue;
}
days.push(ddir);
}
}
}
days
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct WorktreeAgents {
pub sessions: Vec<AgentSession>,
}
impl WorktreeAgents {
pub fn top(&self) -> Option<&AgentSession> {
self.sessions.first()
}
}
pub fn summarize_with<F>(
sessions: &[AgentSession],
worktrees: &[(String, std::path::PathBuf)],
canonicalize: F,
) -> std::collections::BTreeMap<String, WorktreeAgents>
where
F: Fn(&Path) -> PathBuf,
{
let keyed: Vec<(&String, PathBuf)> = worktrees
.iter()
.map(|(id, path)| (id, comparison_key(&canonicalize(path))))
.collect();
let mut map = std::collections::BTreeMap::<String, WorktreeAgents>::new();
for s in sessions {
let skey = comparison_key(&canonicalize(&s.cwd));
for (id, wkey) in &keyed {
if &skey == wkey {
map.entry((*id).clone()).or_default().sessions.push(s.clone());
}
}
}
for agents in map.values_mut() {
agents.sessions.sort_by_key(session_sort_key);
}
map
}
pub fn path_display_key(path: &Path) -> String {
match path.to_str() {
Some(s) => s.to_owned(),
None => {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
path.hash(&mut h);
format!("{}#{:016x}", path.to_string_lossy(), h.finish())
}
}
}
fn session_sort_key(s: &AgentSession) -> (bool, std::cmp::Reverse<SystemTime>, AgentKind, String) {
(s.ended, std::cmp::Reverse(s.last_activity), s.kind, s.id.clone())
}
fn comparison_key(path: &Path) -> PathBuf {
path.components().collect()
}
pub fn summarize(
sessions: &[AgentSession],
worktrees: &[(String, std::path::PathBuf)],
) -> std::collections::BTreeMap<String, WorktreeAgents> {
summarize_with(sessions, worktrees, |p| {
p.canonicalize().unwrap_or_else(|_| p.to_path_buf())
})
}
pub fn agents_home() -> Option<PathBuf> {
std::env::var_os("GWM_AGENTS_HOME")
.map(PathBuf::from)
.or_else(dirs::home_dir)
}
pub fn detect_all(
home: &Path,
worktrees: &[(String, PathBuf)],
pins: &[(String, String)],
now: SystemTime,
) -> std::collections::BTreeMap<String, WorktreeAgents> {
let paths: Vec<PathBuf> = worktrees.iter().map(|(_, p)| p.clone()).collect();
let pinned_ids: std::collections::BTreeSet<&str> = pins.iter().map(|(_, sid)| sid.as_str()).collect();
let mut sessions = collect_with(home, &paths, now, false, &pinned_ids);
sessions.sort_by_key(session_sort_key);
let mut map = summarize(&sessions, worktrees);
overlay_pins(&mut map, &sessions, pins, home, now);
map
}
pub fn collect_sessions(home: &Path, worktree_paths: &[PathBuf], now: SystemTime) -> Vec<AgentSession> {
collect_with(home, worktree_paths, now, true, &std::collections::BTreeSet::new())
}
fn collect_with(
home: &Path,
worktree_paths: &[PathBuf],
now: SystemTime,
sweep: bool,
pinned_ids: &std::collections::BTreeSet<&str>,
) -> Vec<AgentSession> {
let claude = ClaudeCodeSource;
let base = home.join(".claude/projects");
let mut sessions = if sweep {
claude.scan(&base, worktree_paths, now)
} else {
claude.scan_matched(&base, worktree_paths, now)
};
let codex_base = home.join(".codex/sessions");
if sweep {
sessions.extend(CodexSource.scan(&codex_base, now));
} else {
let keys: std::collections::BTreeSet<PathBuf> = worktree_paths
.iter()
.map(|p| comparison_key(&p.canonicalize().unwrap_or_else(|_| p.to_path_buf())))
.collect();
let want = |cwd: &Path, id: &str| {
pinned_ids.contains(id)
|| keys.contains(&comparison_key(
&cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf()),
))
};
sessions.extend(CodexSource.scan_naming(&codex_base, now, &want));
}
let opencode_base = std::env::var_os("XDG_DATA_HOME")
.filter(|_| std::env::var_os("GWM_AGENTS_HOME").is_none())
.map(PathBuf::from)
.filter(|p| p.is_absolute())
.unwrap_or_else(|| home.join(".local/share"))
.join("opencode/storage/project");
sessions.extend(OpencodeSource.scan(&opencode_base, now));
sessions.extend(VibeSource.scan(&home.join(".vibe/logs/session"), now));
sessions
}
pub fn detect_with_sessions(
home: &Path,
worktrees: &[(String, PathBuf)],
pins: &[(String, String)],
now: SystemTime,
) -> (std::collections::BTreeMap<String, WorktreeAgents>, Vec<AgentSession>) {
let paths: Vec<PathBuf> = worktrees.iter().map(|(_, p)| p.clone()).collect();
let mut sessions = collect_sessions(home, &paths, now);
sessions.sort_by_key(session_sort_key);
let mut map = summarize(&sessions, worktrees);
overlay_pins(&mut map, &sessions, pins, home, now);
(map, sessions)
}
fn overlay_pins(
map: &mut std::collections::BTreeMap<String, WorktreeAgents>,
sessions: &[AgentSession],
pins: &[(String, String)],
home: &Path,
now: SystemTime,
) {
for (wt_id, sid) in pins {
let found = sessions
.iter()
.find(|s| &s.id == sid)
.cloned()
.or_else(|| claude_session_by_id(&home.join(".claude/projects"), sid, now));
let Some(session) = found else {
continue; };
let agents = map.entry(wt_id.clone()).or_default();
if !agents.sessions.iter().any(|s| &s.id == sid) {
agents.sessions.push(session);
agents.sessions.sort_by_key(session_sort_key);
}
}
}
fn claude_session_by_id(base: &Path, sid: &str, now: SystemTime) -> Option<AgentSession> {
if sid.contains(['/', '\\']) {
return None;
}
let live = claude_live_registry(base);
let entries = std::fs::read_dir(base).ok()?;
for dir in entries.flatten() {
let path = dir.path().join(format!("{sid}.jsonl"));
let Some(mtime) = file_mtime(&path) else {
continue;
};
if !within_scan_window(mtime, now) {
continue;
}
let entry = live.get(sid);
return Some(AgentSession {
kind: AgentKind::ClaudeCode,
cwd: dir.path(),
last_activity: mtime,
ended: entry.is_some_and(|e| e.dead),
id: clean_id(sid)?,
name: entry.and_then(|e| e.name.clone()).or_else(|| first_user_text(&path)),
});
}
None
}
fn file_mtime(path: &Path) -> Option<SystemTime> {
std::fs::metadata(path).and_then(|m| m.modified()).ok()
}
fn within_scan_window(t: SystemTime, now: SystemTime) -> bool {
now.duration_since(t).unwrap_or(Duration::ZERO) <= SCAN_WINDOW
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Freshness {
Active,
Idle,
}
impl Freshness {
pub fn classify(last_activity: SystemTime, ended: bool, now: SystemTime) -> Self {
if ended {
return Freshness::Idle;
}
let elapsed = now.duration_since(last_activity).unwrap_or(Duration::ZERO);
if elapsed <= ACTIVE_WINDOW {
Freshness::Active
} else {
Freshness::Idle
}
}
}
pub fn claude_slug(path: &Path) -> String {
path
.to_string_lossy()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect()
}