use std::path::PathBuf;
use atm_core::{builtin_harnesses, Harness, HarnessDefinition, 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 DiscoveredProcess {
pid: u32,
cwd: PathBuf,
tmux_pane: Option<String>,
harness: Harness,
}
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_agent_processes).await {
Ok(Ok(p)) => p,
Ok(Err(e)) => {
warn!(error = %e, "Failed to scan for agent processes");
return result;
}
Err(e) => {
warn!(error = %e, "Discovery task panicked");
return result;
}
};
if processes.is_empty() {
debug!("No agent processes found");
return result;
}
debug!(count = processes.len(), "Found agent 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: &DiscoveredProcess,
#[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 harness = process.harness;
let session_id = SessionId::pending_from_pid(pid);
debug!(
pid,
session_id = %session_id,
tmux_pane = ?tmux_pane,
harness = %harness,
"Creating pending session for discovered agent process"
);
match self
.registry
.register_discovered(session_id.clone(), pid, cwd, tmux_pane, harness)
.await
{
Ok(()) => Ok(Some(session_id)),
Err(e) => Err(DiscoveryError::RegistryError(e.to_string())),
}
}
}
fn scan_agent_processes() -> Result<Vec<DiscoveredProcess>, 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) = detect_agent_process(pid) {
processes.push(process);
}
}
Ok(processes)
}
fn detect_agent_process(pid: u32) -> Option<DiscoveredProcess> {
builtin_harnesses()
.filter(|definition| definition.discovery_enabled)
.find_map(|definition| check_harness_process(pid, definition))
}
fn check_harness_process(
pid: u32,
definition: &'static HarnessDefinition,
) -> Option<DiscoveredProcess> {
if let Some(process) = check_via_exe(pid, definition) {
return Some(process);
}
let result = check_via_cmdline(pid, definition);
if result.is_some() {
trace!(
pid,
harness = definition.id,
"Detected harness via cmdline fallback (exe check failed)"
);
}
result
}
fn check_via_exe(pid: u32, definition: &'static HarnessDefinition) -> Option<DiscoveredProcess> {
let exe_path = format!("/proc/{pid}/exe");
let exe = std::fs::read_link(&exe_path).ok()?;
let exe_str = exe.to_string_lossy();
if !definition
.process_matchers
.iter()
.any(|matcher| matcher.matches(&exe_str))
{
return None;
}
get_process_info(pid, definition.harness)
}
fn check_via_cmdline(
pid: u32,
definition: &'static HarnessDefinition,
) -> Option<DiscoveredProcess> {
let cmdline_path = format!("/proc/{pid}/cmdline");
let cmdline_bytes = std::fs::read(&cmdline_path).ok()?;
let matched = cmdline_bytes
.split(|&b| b == 0)
.filter_map(|bytes| std::str::from_utf8(bytes).ok())
.filter(|s| !s.is_empty())
.enumerate()
.any(|(index, arg)| {
if arg.starts_with('-') {
return false;
}
cmdline_arg_matches_definition(index, arg, definition)
});
if !matched {
return None;
}
get_process_info(pid, definition.harness)
}
fn cmdline_arg_matches_definition(
index: usize,
arg: &str,
definition: &'static HarnessDefinition,
) -> bool {
let is_argv0 = index == 0;
let is_path_like = arg.contains('/');
if !is_path_like && (!is_argv0 || !definition.allow_bare_cmdline_match) {
return false;
}
definition
.process_matchers
.iter()
.any(|matcher| matcher.matches(arg))
}
fn get_process_info(pid: u32, harness: Harness) -> Option<DiscoveredProcess> {
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(DiscoveredProcess {
pid,
cwd,
tmux_pane,
harness,
})
}
#[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_key(|c| std::cmp::Reverse(c.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);
}
fn matches_harness_path(harness_id: &str, path: &str) -> bool {
atm_core::find_harness_definition(harness_id)
.map(|definition| {
definition
.process_matchers
.iter()
.any(|matcher| matcher.matches(path))
})
.unwrap_or(false)
}
#[test]
fn test_claude_registry_matcher_absolute_path() {
assert!(matches_harness_path("claude", "/usr/local/bin/claude"));
assert!(matches_harness_path(
"claude",
"/home/user/.local/bin/claude"
));
}
#[test]
fn test_claude_registry_matcher_bare_command() {
assert!(matches_harness_path("claude", "claude"));
}
#[test]
fn test_claude_registry_matcher_versioned_install() {
assert!(matches_harness_path(
"claude",
"/home/user/.local/share/claude/versions/1.2.3/claude"
));
assert!(matches_harness_path(
"claude",
"~/.local/share/claude/versions/0.5.0/node"
));
}
#[test]
fn test_claude_registry_matcher_rejects_non_claude() {
assert!(!matches_harness_path("claude", "/usr/bin/bash"));
assert!(!matches_harness_path("claude", "vim"));
assert!(!matches_harness_path("claude", "/home/user/claudette"));
assert!(!matches_harness_path("claude", "claude-dev"));
}
#[test]
fn test_pi_registry_matcher_rejects_bare_cmdline_match() {
let pi = atm_core::find_harness_definition("pi");
assert!(pi.is_some_and(|definition| !definition.allow_bare_cmdline_match));
assert!(matches_harness_path("pi", "/usr/bin/pi"));
assert!(!matches_harness_path("pi", "not-pi"));
}
#[test]
fn test_cmdline_matching_only_allows_bare_match_on_argv0() {
let claude = atm_core::find_harness_definition("claude")
.unwrap_or_else(atm_core::default_harness_definition);
assert!(cmdline_arg_matches_definition(0, "claude", claude));
assert!(!cmdline_arg_matches_definition(1, "claude", claude));
assert!(cmdline_arg_matches_definition(
1,
"/usr/local/bin/claude",
claude
));
}
#[test]
fn test_cmdline_matching_rejects_bare_pi_positional_arg() {
let pi = atm_core::find_harness_definition("pi")
.unwrap_or_else(atm_core::default_harness_definition);
assert!(!cmdline_arg_matches_definition(0, "pi", pi));
assert!(!cmdline_arg_matches_definition(2, "pi", pi));
assert!(cmdline_arg_matches_definition(
1,
"/home/user/.npm/pi-coding-agent/bin/pi.js",
pi
));
}
#[test]
fn test_only_adapter_backed_harnesses_are_discovery_enabled() {
let enabled: Vec<&str> = atm_core::builtin_harnesses()
.filter(|definition| definition.discovery_enabled)
.map(|definition| definition.id)
.collect();
assert_eq!(enabled, vec!["claude", "pi"]);
}
}