use std::path::PathBuf;
use atm_core::SessionId;
use thiserror::Error;
use tracing::{debug, info, trace, warn};
use crate::registry::RegistryHandle;
use crate::tmux::find_pane_for_pid;
pub const DEFAULT_TRANSCRIPT_MAX_AGE_SECS: u64 = 60;
#[derive(Debug, Error)]
pub enum DiscoveryError {
#[error("failed to read /proc: {0}")]
ProcReadError(String),
#[error("failed to read process {pid}: {message}")]
ProcessReadError { pid: u32, message: String },
#[error("no active transcript found for PID {0}")]
NoActiveTranscript(u32),
#[error("registry error: {0}")]
RegistryError(String),
}
#[derive(Debug, Clone, Default)]
pub struct DiscoveryResult {
pub discovered: u32,
pub failed: u32,
}
#[derive(Debug, Clone)]
struct ClaudeProcess {
pid: u32,
cwd: PathBuf,
tmux_pane: Option<String>,
}
pub struct DiscoveryService {
registry: RegistryHandle,
transcript_max_age_secs: u64,
}
impl DiscoveryService {
#[must_use]
pub fn new(registry: RegistryHandle) -> Self {
Self {
registry,
transcript_max_age_secs: DEFAULT_TRANSCRIPT_MAX_AGE_SECS,
}
}
#[must_use]
pub fn with_max_age(registry: RegistryHandle, transcript_max_age_secs: u64) -> Self {
Self {
registry,
transcript_max_age_secs,
}
}
pub async fn discover(&self) -> DiscoveryResult {
let mut result = DiscoveryResult::default();
let processes = match tokio::task::spawn_blocking(scan_claude_processes).await {
Ok(Ok(p)) => p,
Ok(Err(e)) => {
warn!(error = %e, "Failed to scan for Claude processes");
return result;
}
Err(e) => {
warn!(error = %e, "Discovery task panicked");
return result;
}
};
if processes.is_empty() {
debug!("No Claude processes found");
return result;
}
debug!(count = processes.len(), "Found Claude processes");
let max_age_secs = self.transcript_max_age_secs;
for process in processes {
match self.discover_session(&process, max_age_secs).await {
Ok(Some(session_id)) => {
debug!(
session_id = %session_id,
pid = process.pid,
"Discovered session"
);
result.discovered += 1;
}
Ok(None) => {
debug!(
pid = process.pid,
"Skipped process (already registered or no transcript)"
);
}
Err(e) => {
debug!(
pid = process.pid,
error = %e,
"Failed to discover session"
);
result.failed += 1;
}
}
}
if result.discovered > 0 || result.failed > 0 {
info!(
discovered = result.discovered,
failed = result.failed,
"Discovery complete"
);
}
result
}
async fn discover_session(
&self,
process: &ClaudeProcess,
#[allow(unused_variables)] max_age_secs: u64,
) -> Result<Option<SessionId>, DiscoveryError> {
let pid = process.pid;
let cwd = process.cwd.clone();
let tmux_pane = process.tmux_pane.clone();
let session_id = SessionId::pending_from_pid(pid);
debug!(
pid,
session_id = %session_id,
tmux_pane = ?tmux_pane,
"Creating pending session for discovered Claude process"
);
match self
.registry
.register_discovered(session_id.clone(), pid, cwd, tmux_pane)
.await
{
Ok(()) => Ok(Some(session_id)),
Err(e) => Err(DiscoveryError::RegistryError(e.to_string())),
}
}
}
fn scan_claude_processes() -> Result<Vec<ClaudeProcess>, DiscoveryError> {
let mut processes = Vec::new();
let proc_dir =
std::fs::read_dir("/proc").map_err(|e| DiscoveryError::ProcReadError(e.to_string()))?;
for entry in proc_dir.flatten() {
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
let pid: u32 = match name.parse() {
Ok(p) => p,
Err(_) => continue,
};
if let Some(process) = check_claude_process(pid) {
processes.push(process);
}
}
Ok(processes)
}
fn is_claude_path(path: &str) -> bool {
path.ends_with("/claude") || path == "claude" || path.contains("claude/versions/")
}
fn check_claude_process(pid: u32) -> Option<ClaudeProcess> {
if let Some(process) = check_claude_via_exe(pid) {
return Some(process);
}
let result = check_claude_via_cmdline(pid);
if result.is_some() {
trace!(
pid,
"Detected Claude via cmdline fallback (exe check failed)"
);
}
result
}
fn check_claude_via_exe(pid: u32) -> Option<ClaudeProcess> {
let exe_path = format!("/proc/{pid}/exe");
let exe = std::fs::read_link(&exe_path).ok()?;
let exe_str = exe.to_string_lossy();
if !is_claude_path(&exe_str) {
return None;
}
get_process_info(pid)
}
fn check_claude_via_cmdline(pid: u32) -> Option<ClaudeProcess> {
let cmdline_path = format!("/proc/{pid}/cmdline");
let cmdline_bytes = std::fs::read(&cmdline_path).ok()?;
let is_claude = cmdline_bytes
.split(|&b| b == 0)
.filter_map(|bytes| std::str::from_utf8(bytes).ok())
.filter(|s| !s.is_empty())
.any(|arg| {
if arg.starts_with('-') {
return false;
}
is_claude_path(arg)
});
if !is_claude {
return None;
}
get_process_info(pid)
}
fn get_process_info(pid: u32) -> Option<ClaudeProcess> {
let cwd_path = format!("/proc/{pid}/cwd");
let cwd = std::fs::read_link(&cwd_path).ok()?;
let tmux_pane = find_pane_for_pid(pid);
Some(ClaudeProcess {
pid,
cwd,
tmux_pane,
})
}
#[cfg(test)]
use std::path::Path;
#[cfg(test)]
use std::time::{Duration, SystemTime};
#[cfg(test)]
fn cwd_to_project_dir(cwd: &Path) -> PathBuf {
let escaped = cwd.to_string_lossy().replace('/', "-");
let home = std::env::var("HOME")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/tmp"));
home.join(".claude/projects").join(escaped)
}
#[cfg(test)]
fn find_active_transcript(project_dir: &Path, max_age_secs: u64) -> Option<PathBuf> {
let now = SystemTime::now();
let max_age = Duration::from_secs(max_age_secs);
let entries = std::fs::read_dir(project_dir).ok()?;
let mut candidates: Vec<(PathBuf, SystemTime)> = entries
.flatten()
.filter_map(|entry| {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
return None;
}
let stem = path.file_stem()?.to_string_lossy();
if stem.starts_with("agent-") {
return None;
}
let metadata = entry.metadata().ok()?;
let mtime = metadata.modified().ok()?;
let age = now.duration_since(mtime).ok()?;
if age > max_age {
return None;
}
Some((path, mtime))
})
.collect();
candidates.sort_by(|a, b| b.1.cmp(&a.1));
candidates.into_iter().next().map(|(path, _)| path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
use std::time::Duration;
use tempfile::TempDir;
#[test]
fn test_cwd_to_project_dir_simple() {
let cwd = Path::new("/home/user/code/project");
let project_dir = cwd_to_project_dir(cwd);
let expected_suffix = ".claude/projects/-home-user-code-project";
assert!(
project_dir.to_string_lossy().ends_with(expected_suffix),
"Expected path to end with '{}', got '{}'",
expected_suffix,
project_dir.display()
);
}
#[test]
fn test_cwd_to_project_dir_root() {
let cwd = Path::new("/");
let project_dir = cwd_to_project_dir(cwd);
assert!(project_dir.to_string_lossy().contains(".claude/projects"));
}
#[test]
fn test_cwd_to_project_dir_nested() {
let cwd = Path::new("/home/user/very/deeply/nested/project");
let project_dir = cwd_to_project_dir(cwd);
let expected_suffix = "-home-user-very-deeply-nested-project";
assert!(
project_dir.to_string_lossy().ends_with(expected_suffix),
"Got: {}",
project_dir.display()
);
}
#[test]
fn test_find_active_transcript_empty_dir() {
let temp_dir = TempDir::new().unwrap();
let result = find_active_transcript(temp_dir.path(), DEFAULT_TRANSCRIPT_MAX_AGE_SECS);
assert!(result.is_none());
}
#[test]
fn test_find_active_transcript_no_jsonl() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("test.txt"), "not jsonl").unwrap();
let result = find_active_transcript(temp_dir.path(), DEFAULT_TRANSCRIPT_MAX_AGE_SECS);
assert!(result.is_none());
}
#[test]
fn test_find_active_transcript_ignores_agent_files() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("agent-abc123.jsonl"), "{}").unwrap();
let result = find_active_transcript(temp_dir.path(), DEFAULT_TRANSCRIPT_MAX_AGE_SECS);
assert!(result.is_none());
}
#[test]
fn test_find_active_transcript_finds_recent() {
let temp_dir = TempDir::new().unwrap();
let session_file = temp_dir
.path()
.join("226f3c14-cc34-4118-804b-b7d442aa2363.jsonl");
fs::write(&session_file, "{}").unwrap();
let result = find_active_transcript(temp_dir.path(), DEFAULT_TRANSCRIPT_MAX_AGE_SECS);
assert!(result.is_some());
assert_eq!(result.unwrap(), session_file);
}
#[test]
fn test_find_active_transcript_picks_most_recent() {
let temp_dir = TempDir::new().unwrap();
let older = temp_dir
.path()
.join("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl");
let newer = temp_dir
.path()
.join("ffffffff-0000-1111-2222-333333333333.jsonl");
fs::write(&older, "old").unwrap();
std::thread::sleep(Duration::from_millis(10));
fs::write(&newer, "new").unwrap();
let result = find_active_transcript(temp_dir.path(), DEFAULT_TRANSCRIPT_MAX_AGE_SECS);
assert!(result.is_some());
assert_eq!(result.unwrap(), newer);
}
#[test]
fn test_find_active_transcript_respects_custom_max_age() {
let temp_dir = TempDir::new().unwrap();
let session_file = temp_dir
.path()
.join("226f3c14-cc34-4118-804b-b7d442aa2363.jsonl");
fs::write(&session_file, "{}").unwrap();
std::thread::sleep(Duration::from_millis(1));
let result = find_active_transcript(temp_dir.path(), 0);
assert!(result.is_none());
let result = find_active_transcript(temp_dir.path(), DEFAULT_TRANSCRIPT_MAX_AGE_SECS);
assert!(result.is_some());
}
#[test]
fn test_discovery_result_default() {
let result = DiscoveryResult::default();
assert_eq!(result.discovered, 0);
assert_eq!(result.failed, 0);
}
#[test]
fn test_is_claude_path_absolute_path() {
assert!(is_claude_path("/usr/local/bin/claude"));
assert!(is_claude_path("/home/user/.local/bin/claude"));
}
#[test]
fn test_is_claude_path_bare_command() {
assert!(is_claude_path("claude"));
}
#[test]
fn test_is_claude_path_versioned_install() {
assert!(is_claude_path(
"/home/user/.local/share/claude/versions/1.2.3/claude"
));
assert!(is_claude_path("~/.local/share/claude/versions/0.5.0/node"));
}
#[test]
fn test_is_claude_path_rejects_non_claude() {
assert!(!is_claude_path("/usr/bin/bash"));
assert!(!is_claude_path("vim"));
assert!(!is_claude_path("/home/user/claudette")); assert!(!is_claude_path("claude-dev")); }
#[test]
fn test_is_claude_path_edge_cases() {
assert!(!is_claude_path("/home/claudeuser/bin/tool"));
assert!(!is_claude_path(""));
}
}