use std::collections::HashSet;
use std::process::Command;
use trusty_mpm_core::session::{ControlModel, Session, SessionHost, SessionId, SessionStatus};
use crate::state::DaemonState;
use crate::tmux::TmuxDriver;
const CLAUDE_COMMANDS: &[&str] = &["claude", "claude-code", "claude-mpm", "tm"];
pub fn is_claude_command(command: &str) -> bool {
let lower = command.trim().to_lowercase();
if lower.is_empty() {
return false;
}
lower == "tm"
|| CLAUDE_COMMANDS
.iter()
.any(|c| *c != "tm" && lower.contains(c))
}
fn parse_pane_line(line: &str) -> Option<(String, String)> {
let trimmed = line.trim();
let (session, command) = trimmed.split_once(char::is_whitespace)?;
let command = command.trim();
if session.is_empty() || command.is_empty() {
return None;
}
Some((session.to_string(), command.to_string()))
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct DiscoveryResult {
pub adopted: usize,
pub sessions: Vec<String>,
}
pub fn discover_claude_sessions(state: &DaemonState) -> DiscoveryResult {
let driver = match TmuxDriver::discover() {
Ok(driver) => driver,
Err(_) => {
tracing::info!("tmux unavailable; session auto-discovery skipped");
return DiscoveryResult::default();
}
};
let raw = match driver.list_claude_panes() {
Ok(raw) => raw,
Err(e) => {
tracing::warn!("tmux pane listing failed during discovery: {e}");
return DiscoveryResult::default();
}
};
let registered: HashSet<String> = state
.list_sessions()
.into_iter()
.map(|s| s.tmux_name)
.collect();
let mut result = DiscoveryResult::default();
let mut seen: HashSet<String> = HashSet::new();
for line in raw.lines() {
let Some((session_name, command)) = parse_pane_line(line) else {
continue;
};
if !is_claude_command(&command) {
continue;
}
if registered.contains(&session_name) || !seen.insert(session_name.clone()) {
continue;
}
let mut session = Session::new(SessionId::new(), String::new(), ControlModel::Tmux, None);
session.tmux_name = session_name.clone();
session.status = SessionStatus::Active;
session.origin = SessionHost::Tmux;
state.register_session(session);
tracing::info!("auto-discovered Claude Code tmux session: {session_name}");
result.adopted += 1;
result.sessions.push(session_name);
}
result
}
pub fn is_claude_process(cmdline: &str) -> bool {
let lower = cmdline.trim().to_lowercase();
if lower.is_empty() {
return false;
}
let exe = lower.split_whitespace().next().unwrap_or_default();
let basename = exe.rsplit('/').next().unwrap_or(exe);
basename == "claude" || basename == "claude-code"
}
fn parse_ps_line(line: &str) -> Option<(u32, String)> {
let mut fields = line.split_whitespace();
let _user = fields.next()?;
let pid: u32 = fields.next()?.parse().ok()?;
for _ in 0..8 {
fields.next()?;
}
let command: String = fields.collect::<Vec<_>>().join(" ");
if command.is_empty() {
return None;
}
Some((pid, command))
}
fn parse_lsof_cwds(text: &str) -> std::collections::HashMap<u32, String> {
let mut map = std::collections::HashMap::new();
let mut current: Option<u32> = None;
let mut in_cwd_fd = false;
for line in text.lines() {
if let Some(pid) = line.strip_prefix('p').and_then(|p| p.parse::<u32>().ok()) {
current = Some(pid);
in_cwd_fd = false;
} else if let Some(fd) = line.strip_prefix('f') {
in_cwd_fd = fd == "cwd";
} else if let (Some(path), Some(pid), true) = (
line.strip_prefix('n').filter(|p| !p.is_empty()),
current,
in_cwd_fd,
) {
map.entry(pid).or_insert_with(|| path.to_string());
}
}
map
}
const LSOF_PID_CHUNK: usize = 32;
fn process_cwds_chunk(pids: &[u32]) -> std::collections::HashMap<u32, String> {
let pid_list = pids
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(",");
match Command::new("lsof")
.args(["-a", "-p", &pid_list, "-d", "cwd", "-Ffpn"])
.output()
{
Ok(out) => parse_lsof_cwds(&String::from_utf8_lossy(&out.stdout)),
Err(_) => {
tracing::info!("`lsof` unavailable; native session workdirs unknown");
std::collections::HashMap::new()
}
}
}
fn process_cwds(pids: &[u32]) -> std::collections::HashMap<u32, String> {
let mut map = std::collections::HashMap::new();
for chunk in pids.chunks(LSOF_PID_CHUNK) {
map.extend(process_cwds_chunk(chunk));
}
map
}
fn cwd_basename(cwd: &str) -> String {
cwd.trim_end_matches('/')
.rsplit('/')
.find(|s| !s.is_empty())
.unwrap_or("session")
.to_string()
}
pub fn discover_native_processes(state: &DaemonState) -> DiscoveryResult {
let output = match Command::new("ps").arg("aux").output() {
Ok(out) if out.status.success() => out,
Ok(_) | Err(_) => {
tracing::info!("`ps` unavailable; native process discovery skipped");
return DiscoveryResult::default();
}
};
let raw = String::from_utf8_lossy(&output.stdout);
let existing = state.list_sessions();
let registered_pids: HashSet<u32> = existing.iter().filter_map(|s| s.pid).collect();
let registered_names: HashSet<String> = existing.iter().map(|s| s.tmux_name.clone()).collect();
let registered_workdirs: HashSet<String> = existing
.iter()
.filter(|s| s.origin == SessionHost::Native && !s.workdir.is_empty())
.map(|s| s.workdir.clone())
.collect();
let mut candidate_pids: Vec<u32> = raw
.lines()
.filter_map(parse_ps_line)
.filter(|(pid, cmdline)| is_claude_process(cmdline) && !registered_pids.contains(pid))
.map(|(pid, _)| pid)
.collect();
candidate_pids.sort_unstable();
candidate_pids.dedup();
let cwds = process_cwds(&candidate_pids);
let mut result = DiscoveryResult::default();
let mut seen_workdirs: HashSet<String> = HashSet::new();
for pid in candidate_pids {
let cwd = cwds.get(&pid).cloned().unwrap_or_default();
if !cwd.is_empty()
&& (registered_workdirs.contains(&cwd) || !seen_workdirs.insert(cwd.clone()))
{
continue;
}
let name = format!("{}-{}", cwd_basename(&cwd), pid);
if registered_names.contains(&name) {
continue;
}
let mut session = Session::new(SessionId::new(), cwd.clone(), ControlModel::Pty, None);
session.tmux_name = name.clone();
session.status = SessionStatus::Active;
session.origin = SessionHost::Native;
session.pid = Some(pid);
state.register_session(session);
tracing::info!("auto-discovered native Claude Code process: {name} (pid {pid})");
result.adopted += 1;
result.sessions.push(name);
}
result
}
pub fn discover_all(state: &DaemonState) -> DiscoveryResult {
let mut result = discover_claude_sessions(state);
let native = discover_native_processes(state);
result.adopted += native.adopted;
result.sessions.extend(native.sessions);
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_claude_command_matches_known() {
for cmd in [
"claude",
"claude-code",
"claude-mpm",
"tm",
"Claude",
"CLAUDE-CODE",
] {
assert!(is_claude_command(cmd), "expected `{cmd}` to match");
}
}
#[test]
fn is_claude_command_rejects_others() {
for cmd in ["bash", "zsh", "vim", "tmux", "node", "", " "] {
assert!(!is_claude_command(cmd), "expected `{cmd}` not to match");
}
}
#[test]
fn parse_pane_line_splits_fields() {
assert_eq!(
parse_pane_line("my-project claude"),
Some(("my-project".to_string(), "claude".to_string())),
);
assert_eq!(
parse_pane_line(" proj claude-code "),
Some(("proj".to_string(), "claude-code".to_string())),
);
assert_eq!(parse_pane_line("lonely"), None);
assert_eq!(parse_pane_line(""), None);
}
#[test]
fn discover_with_no_tmux_is_empty() {
let state = DaemonState::new();
let result = discover_claude_sessions(&state);
assert_eq!(result.adopted, result.sessions.len());
}
#[test]
fn is_claude_process_matches() {
for cmd in [
"claude",
"claude-code",
"/usr/local/bin/claude --resume",
"/opt/homebrew/bin/claude-code",
"CLAUDE --dangerously-skip-permissions",
"claude --dangerously-skip-permissions \
--system-prompt-file /Users/bob/proj/.claude-mpm/PM_INSTRUCTIONS.md",
] {
assert!(is_claude_process(cmd), "expected `{cmd}` to match");
}
}
#[test]
fn is_claude_process_rejects_noise() {
for cmd in [
"grep claude",
"tm daemon",
"/usr/bin/trusty-mpm daemon",
"python3 -m claude_mpm.mcp.messaging_server",
"uv tool uvx --from claude-mpm claude-mpm",
"/Applications/Claude.app/Contents/Helpers/chrome-native-host",
"node /opt/claude-code/cli.js",
"vim notes.txt",
"",
" ",
] {
assert!(!is_claude_process(cmd), "expected `{cmd}` not to match");
}
}
#[test]
fn parse_ps_line_extracts_pid_and_command() {
let line = "bob 12345 0.1 0.5 4096 2048 s001 S 10:00AM 0:01.23 /usr/local/bin/claude --resume";
assert_eq!(
parse_ps_line(line),
Some((12345, "/usr/local/bin/claude --resume".to_string())),
);
assert_eq!(
parse_ps_line("USER PID %CPU %MEM VSZ RSS TT STAT STARTED TIME COMMAND"),
None,
);
assert_eq!(parse_ps_line(""), None);
}
#[test]
fn parse_lsof_cwds_maps_pid_to_path() {
let out = "p123\nfcwd\nn/Users/bob/Projects/alpha\nftxt\nn/usr/local/bin/claude\n\
p456\nfcwd\nn/Users/bob/Projects/beta\n";
let map = parse_lsof_cwds(out);
assert_eq!(map.len(), 2);
assert_eq!(
map.get(&123).map(String::as_str),
Some("/Users/bob/Projects/alpha"),
);
assert_eq!(
map.get(&456).map(String::as_str),
Some("/Users/bob/Projects/beta"),
);
assert!(parse_lsof_cwds("p789\nftxt\nn/usr/local/bin/claude\n").is_empty());
assert!(parse_lsof_cwds("p789\n").is_empty());
assert!(parse_lsof_cwds("").is_empty());
}
#[test]
fn cwd_basename_extracts_last_component() {
assert_eq!(cwd_basename("/Users/bob/Projects/trusty-mpm"), "trusty-mpm");
assert_eq!(
cwd_basename("/Users/bob/Projects/trusty-mpm/"),
"trusty-mpm"
);
assert_eq!(cwd_basename(""), "session");
assert_eq!(cwd_basename("/"), "session");
}
#[test]
fn discover_native_with_no_processes_is_well_formed() {
let state = DaemonState::new();
let result = discover_native_processes(&state);
assert_eq!(result.adopted, result.sessions.len());
}
#[test]
fn discover_all_merges_results() {
let state = DaemonState::new();
let result = discover_all(&state);
assert_eq!(result.adopted, result.sessions.len());
}
}