From 01218ddfefd6bcad35a245cb5e2fdec1cfa3e1df Mon Sep 17 00:00:00 2001 From: kimchh <109632934+kimchh@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:30:54 -0700 Subject: [PATCH] feat(detect): configurable wrapper process detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a [detection] config section with a wrappers map that lets users configure custom wrapper processes for agent identification. Strategies per wrapper: - "inspect" — scan argv for a known agent name - "inspect:" — same, with a fallback agent - fixed string — always resolve to that agent --- src/app/agents.rs | 3 +- src/config/io.rs | 1 + src/config/model.rs | 14 ++- src/detect/mod.rs | 261 ++++++++++++++++++++++++++++++++++++---- src/pane.rs | 28 +++-- src/platform/windows.rs | 7 +- 6 files changed, 278 insertions(+), 36 deletions(-) diff --git a/src/app/agents.rs b/src/app/agents.rs index ccdd4af662..8f01724b55 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -430,7 +430,8 @@ pub(super) fn runtime_hosts_agent( fn live_runtime_agent(runtime: &crate::terminal::TerminalRuntime) -> Option { let job = crate::detect::foreground_job(runtime.child_pid()?)?; - crate::detect::identify_agent_in_job(&job) + let wrappers = crate::detect::detection_wrappers(); + crate::detect::identify_agent_in_job(&job, &wrappers) .map(|(agent, _)| agent) .or_else(|| { job.processes diff --git a/src/config/io.rs b/src/config/io.rs index e19f6944ba..7cbbc0649c 100644 --- a/src/config/io.rs +++ b/src/config/io.rs @@ -6,6 +6,7 @@ use super::{model::LoadedConfig, Config, CONFIG_PATH_ENV_VAR}; const KNOWN_TOP_LEVEL_CONFIG_KEYS: &[&str] = &[ "advanced", + "detection", "experimental", "keys", "onboarding", diff --git a/src/config/model.rs b/src/config/model.rs index b1f0ebc374..40b7a0101e 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -1,4 +1,4 @@ -use std::{collections::BTreeSet, num::NonZeroUsize}; +use std::{collections::BTreeSet, collections::HashMap, num::NonZeroUsize}; use crossterm::event::KeyModifiers; use serde::{de, Deserialize, Deserializer, Serialize}; @@ -284,6 +284,17 @@ pub fn validated_sidebar_bounds(min: u16, max: u16) -> Option<(u16, u16)> { } } +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct DetectionConfig { + /// Map of wrapper process names to detection strategy. + /// Value `"inspect"` scans the wrapper's argv for a known agent name. + /// Value `"inspect:"` does the same but falls back to `` if nothing found. + /// Any other value is a fixed agent label (e.g. `"claude"`). + #[serde(default)] + pub wrappers: HashMap, +} + #[derive(Debug, Default, Deserialize)] #[serde(default)] pub struct Config { @@ -298,6 +309,7 @@ pub struct Config { pub advanced: AdvancedConfig, pub experimental: ExperimentalConfig, pub remote: RemoteConfig, + pub detection: DetectionConfig, } #[derive(Debug)] diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 33c4361c77..a6bbbf0ca8 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -201,19 +201,27 @@ fn lookup_agent(name: &str) -> Option { } } +/// Load configured wrapper processes from user config. +pub fn detection_wrappers() -> std::collections::HashMap { + crate::config::Config::load().config.detection.wrappers +} + /// Identify which agent is running from the process name. /// Returns `None` for plain shells or unrecognized programs. pub fn identify_agent(process_name: &str) -> Option { parse_agent_label(process_name) } -pub fn identify_agent_in_job(job: &crate::platform::ForegroundJob) -> Option<(Agent, String)> { +pub fn identify_agent_in_job( + job: &crate::platform::ForegroundJob, + wrappers: &std::collections::HashMap, +) -> Option<(Agent, String)> { if let Some(process) = job .processes .iter() .find(|process| process.pid == job.process_group_id) { - let candidate = normalized_process_name(process); + let candidate = normalized_process_name(process, wrappers); if let Some(agent) = identify_agent(&candidate) { return Some((agent, candidate)); } @@ -222,7 +230,7 @@ pub fn identify_agent_in_job(job: &crate::platform::ForegroundJob) -> Option<(Ag let mut best: Option<(u8, Agent, String)> = None; for process in &job.processes { - let candidate = normalized_process_name(process); + let candidate = normalized_process_name(process, wrappers); let Some(agent) = identify_agent(&candidate) else { continue; }; @@ -320,10 +328,23 @@ pub fn foreground_process_group_id(child_pid: u32) -> Option { crate::platform::foreground_process_group_id(child_pid) } -fn normalized_process_name(process: &crate::platform::ForegroundProcess) -> String { +fn normalized_process_name( + process: &crate::platform::ForegroundProcess, + wrappers: &std::collections::HashMap, +) -> String { let effective = process.argv0.as_deref().unwrap_or(&process.name); let lower_effective = effective.to_lowercase(); + // Check user-configured wrapper processes first. + if !wrappers.is_empty() { + let lookup_name = normalized_agent_lookup_name(path_basename(&lower_effective)); + if let Some(strategy) = wrappers.get(&lookup_name) { + if let Some(agent_name) = configured_wrapper_agent(process.argv.as_deref(), strategy) { + return agent_name; + } + } + } + if is_generic_runtime_or_shell(&lower_effective) { if let Some(wrapped_agent) = wrapped_agent_name_from_runtime_argv(&lower_effective, process.argv.as_deref()) @@ -360,6 +381,46 @@ fn wrapped_agent_name_from_runtime_argv(runtime: &str, argv: Option<&[String]>) } } +/// Resolve an agent from a user-configured wrapper process. +/// +/// The `strategy` value is one of: +/// - `"inspect"` — scan argv for a known agent name or `--` flag +/// - `"inspect:"` — same, but fall back to `` if nothing found +/// - any other string — fixed agent label (e.g. `"claude"`) +fn configured_wrapper_agent(argv: Option<&[String]>, strategy: &str) -> Option { + if let Some(rest) = strategy.strip_prefix("inspect") { + let default_agent = rest.strip_prefix(':').filter(|s| !s.is_empty()); + + if let Some(argv) = argv { + // Try positional args first (wrapper /path/to/claude ...) + if let Some(name) = script_arg_agent_name(argv, &[], &[]) { + return Some(name); + } + // Try -- flags (wrapper --opencode ...) + for arg in argv.iter().skip(1) { + if let Some(flag_name) = arg.strip_prefix("--") { + let normalized = normalized_agent_lookup_name(flag_name); + if lookup_agent(&normalized).is_some() { + return Some(normalized); + } + } + } + } + + // Fall back to configured default + default_agent.and_then(|name| { + let normalized = normalized_agent_lookup_name(name); + lookup_agent(&normalized)?; + Some(normalized) + }) + } else { + // Fixed mapping + let normalized = normalized_agent_lookup_name(strategy); + lookup_agent(&normalized)?; + Some(normalized) + } +} + fn windows_cmd_arg_agent_name(argv: &[String]) -> Option { let mut args = argv.iter().skip(1); while let Some(arg) = args.next() { @@ -804,7 +865,7 @@ mod tests { }; assert_eq!( - identify_agent_in_job(&job), + identify_agent_in_job(&job, &std::collections::HashMap::new()), Some((Agent::Codex, "codex".to_string())) ); } @@ -820,7 +881,7 @@ mod tests { }; assert_eq!( - identify_agent_in_job(&job), + identify_agent_in_job(&job, &std::collections::HashMap::new()), Some((Agent::Claude, "claude".to_string())) ); } @@ -836,7 +897,7 @@ mod tests { }; assert_eq!( - identify_agent_in_job(&job), + identify_agent_in_job(&job, &std::collections::HashMap::new()), Some((Agent::Codex, "codex".to_string())) ); } @@ -853,7 +914,7 @@ mod tests { }; assert_eq!( - identify_agent_in_job(&job), + identify_agent_in_job(&job, &std::collections::HashMap::new()), Some((Agent::Codex, "codex".to_string())) ); } @@ -870,7 +931,7 @@ mod tests { }; assert_eq!( - identify_agent_in_job(&job), + identify_agent_in_job(&job, &std::collections::HashMap::new()), Some((Agent::Claude, "claude".to_string())) ); } @@ -887,7 +948,7 @@ mod tests { }; assert_eq!( - identify_agent_in_job(&job), + identify_agent_in_job(&job, &std::collections::HashMap::new()), Some((Agent::Pi, "pi".to_string())) ); } @@ -904,7 +965,7 @@ mod tests { }; assert_eq!( - identify_agent_in_job(&job), + identify_agent_in_job(&job, &std::collections::HashMap::new()), Some((Agent::Omp, "omp".to_string())) ); } @@ -924,7 +985,7 @@ mod tests { }; assert_eq!( - identify_agent_in_job(&job), + identify_agent_in_job(&job, &std::collections::HashMap::new()), Some((Agent::Pi, "pi".to_string())) ); } @@ -943,7 +1004,10 @@ mod tests { )], }; - assert_eq!(identify_agent_in_job(&job), None); + assert_eq!( + identify_agent_in_job(&job, &std::collections::HashMap::new()), + None + ); } #[test] @@ -964,7 +1028,7 @@ mod tests { }; assert_eq!( - identify_agent_in_job(&job), + identify_agent_in_job(&job, &std::collections::HashMap::new()), Some((Agent::Codex, "codex".to_string())) ); } @@ -986,7 +1050,7 @@ mod tests { }; assert_eq!( - identify_agent_in_job(&job), + identify_agent_in_job(&job, &std::collections::HashMap::new()), Some((Agent::Claude, "claude".to_string())) ); } @@ -1010,7 +1074,10 @@ mod tests { )], }; - assert_eq!(identify_agent_in_job(&job), None); + assert_eq!( + identify_agent_in_job(&job, &std::collections::HashMap::new()), + None + ); } #[test] @@ -1025,7 +1092,7 @@ mod tests { }; assert_eq!( - identify_agent_in_job(&job), + identify_agent_in_job(&job, &std::collections::HashMap::new()), Some((Agent::OpenCode, "opencode.exe".to_string())) ); } @@ -1042,7 +1109,7 @@ mod tests { }; assert_eq!( - identify_agent_in_job(&job), + identify_agent_in_job(&job, &std::collections::HashMap::new()), Some((Agent::OpenCode, "opencode".to_string())) ); } @@ -1066,7 +1133,10 @@ mod tests { )], }; - assert_eq!(identify_agent_in_job(&job), None); + assert_eq!( + identify_agent_in_job(&job, &std::collections::HashMap::new()), + None + ); } #[test] @@ -1080,7 +1150,10 @@ mod tests { )], }; - assert_eq!(identify_agent_in_job(&job), None); + assert_eq!( + identify_agent_in_job(&job, &std::collections::HashMap::new()), + None + ); } #[test] @@ -1094,7 +1167,10 @@ mod tests { )], }; - assert_eq!(identify_agent_in_job(&job), None); + assert_eq!( + identify_agent_in_job(&job, &std::collections::HashMap::new()), + None + ); } #[test] @@ -1109,7 +1185,7 @@ mod tests { }; assert_eq!( - identify_agent_in_job(&job), + identify_agent_in_job(&job, &std::collections::HashMap::new()), Some((Agent::Codex, "codex".to_string())) ); } @@ -1148,7 +1224,7 @@ mod tests { }; assert_eq!( - identify_agent_in_job(&job), + identify_agent_in_job(&job, &std::collections::HashMap::new()), Some((Agent::Cursor, "cursor".to_string())) ); @@ -1198,7 +1274,7 @@ mod tests { "expected sleep in {job:?}" ); assert_eq!( - identify_agent_in_job(&job), + identify_agent_in_job(&job, &std::collections::HashMap::new()), None, "sleep should not map to an agent" ); @@ -1235,7 +1311,7 @@ mod tests { "expected sleep in {job:?}" ); assert_eq!( - identify_agent_in_job(&job), + identify_agent_in_job(&job, &std::collections::HashMap::new()), None, "sleep should not map to an agent" ); @@ -1279,7 +1355,140 @@ mod tests { "expected wrapper and agent child in {job:?}" ); assert_eq!( - identify_agent_in_job(&job), + identify_agent_in_job(&job, &std::collections::HashMap::new()), + Some((Agent::Codex, "codex".to_string())) + ); + } + + #[test] + fn configured_wrapper_inspect_finds_agent_from_flag() { + let mut wrappers = std::collections::HashMap::new(); + wrappers.insert("my-sandbox".to_string(), "inspect:claude".to_string()); + + let job = crate::platform::ForegroundJob { + process_group_id: 123, + processes: vec![foreground_process( + 123, + "my-sandbox", + &["my-sandbox", "--opencode", "/workspace"], + )], + }; + + assert_eq!( + identify_agent_in_job(&job, &wrappers), + Some((Agent::OpenCode, "opencode".to_string())) + ); + } + + #[test] + fn configured_wrapper_inspect_falls_back_to_default() { + let mut wrappers = std::collections::HashMap::new(); + wrappers.insert("my-sandbox".to_string(), "inspect:claude".to_string()); + + let job = crate::platform::ForegroundJob { + process_group_id: 123, + processes: vec![foreground_process( + 123, + "my-sandbox", + &["my-sandbox", "/workspace"], + )], + }; + + assert_eq!( + identify_agent_in_job(&job, &wrappers), + Some((Agent::Claude, "claude".to_string())) + ); + } + + #[test] + fn configured_wrapper_inspect_no_default_returns_none() { + let mut wrappers = std::collections::HashMap::new(); + wrappers.insert("my-sandbox".to_string(), "inspect".to_string()); + + let job = crate::platform::ForegroundJob { + process_group_id: 123, + processes: vec![foreground_process( + 123, + "my-sandbox", + &["my-sandbox", "/workspace"], + )], + }; + + assert_eq!(identify_agent_in_job(&job, &wrappers), None); + } + + #[test] + fn configured_wrapper_inspect_finds_agent_from_positional() { + let mut wrappers = std::collections::HashMap::new(); + wrappers.insert("wrapper-tool".to_string(), "inspect:claude".to_string()); + + let job = crate::platform::ForegroundJob { + process_group_id: 123, + processes: vec![foreground_process( + 123, + "wrapper-tool", + &["wrapper-tool", "codex", "--some-flag"], + )], + }; + + assert_eq!( + identify_agent_in_job(&job, &wrappers), + Some((Agent::Codex, "codex".to_string())) + ); + } + + #[test] + fn configured_wrapper_fixed_mapping() { + let mut wrappers = std::collections::HashMap::new(); + wrappers.insert("sealed-box".to_string(), "claude".to_string()); + + let job = crate::platform::ForegroundJob { + process_group_id: 123, + processes: vec![foreground_process( + 123, + "sealed-box", + &["sealed-box", "--some-flag"], + )], + }; + + assert_eq!( + identify_agent_in_job(&job, &wrappers), + Some((Agent::Claude, "claude".to_string())) + ); + } + + #[test] + fn configured_wrapper_invalid_fixed_agent_returns_none() { + let mut wrappers = std::collections::HashMap::new(); + wrappers.insert("bad-wrapper".to_string(), "nonexistent-agent".to_string()); + + let job = crate::platform::ForegroundJob { + process_group_id: 123, + processes: vec![foreground_process( + 123, + "bad-wrapper", + &["bad-wrapper", "claude"], + )], + }; + + assert_eq!(identify_agent_in_job(&job, &wrappers), None); + } + + #[test] + fn empty_wrappers_does_not_break_existing_detection() { + let wrappers = std::collections::HashMap::new(); + + let job = crate::platform::ForegroundJob { + process_group_id: 123, + processes: vec![foreground_process( + 123, + "node", + &["node", "/path/to/bin/codex"], + )], + }; + + assert_eq!( + identify_agent_in_job(&job, &wrappers), Some((Agent::Codex, "codex".to_string())) ); } diff --git a/src/pane.rs b/src/pane.rs index 4a10f8e4be..c953206562 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -508,6 +508,7 @@ fn agent_hint_for_non_leader_foreground_job_members( fn identify_process_group_leader_in_job( job: &crate::platform::ForegroundJob, + wrappers: &std::collections::HashMap, ) -> Option<(Agent, String)> { let leader = job .processes @@ -517,7 +518,7 @@ fn identify_process_group_leader_in_job( process_group_id: job.process_group_id, processes: vec![leader.clone()], }; - crate::detect::identify_agent_in_job(&leader_job) + crate::detect::identify_agent_in_job(&leader_job, wrappers) } fn process_probe_result( @@ -554,12 +555,13 @@ fn probe_foreground_process_from_jobs( leader_job: Option, foreground_job: impl FnOnce() -> Option, read_hint: impl Fn(u32) -> Option + Copy, + wrappers: &std::collections::HashMap, ) -> ProcessProbeResult { if let Some(job) = leader_job.as_ref() { if let Some(hinted) = hinted_process_probe_result(job, pid, read_hint) { return hinted; } - if let Some((agent, process_name)) = crate::detect::identify_agent_in_job(job) { + if let Some((agent, process_name)) = crate::detect::identify_agent_in_job(job, wrappers) { return process_probe_result(job, pid, agent, process_name); } } @@ -574,7 +576,7 @@ fn probe_foreground_process_from_jobs( crate::detect::agent_label(agent).to_string(), ); } - if let Some((agent, process_name)) = identify_process_group_leader_in_job(job) { + if let Some((agent, process_name)) = identify_process_group_leader_in_job(job, wrappers) { return process_probe_result(job, pid, agent, process_name); } if let Some(agent) = agent_hint_for_non_leader_foreground_job_members(job, read_hint) { @@ -586,7 +588,7 @@ fn probe_foreground_process_from_jobs( ); } - let identified = crate::detect::identify_agent_in_job(job); + let identified = crate::detect::identify_agent_in_job(job, wrappers); return ProcessProbeResult { process_group_id: Some(job.process_group_id), foreground_is_pane_shell: job.processes.iter().any(|process| process.pid == pid), @@ -603,13 +605,18 @@ fn probe_foreground_process_from_jobs( } } -fn probe_foreground_process(pid: u32, foreground_pgid: Option) -> ProcessProbeResult { +fn probe_foreground_process( + pid: u32, + foreground_pgid: Option, + wrappers: &std::collections::HashMap, +) -> ProcessProbeResult { probe_foreground_process_from_jobs( pid, foreground_pgid, foreground_pgid.and_then(crate::detect::foreground_group_leader_job), || crate::detect::foreground_job(pid), crate::platform::process_agent_hint, + wrappers, ) } @@ -630,6 +637,7 @@ fn spawn_basic_detection_task( let detect_reset = detect_reset_notify.clone(); let pending_release = Arc::new(Mutex::new(None)); let pending_release_for_task = pending_release.clone(); + let detection_wrappers = crate::detect::detection_wrappers(); let handle = tokio::spawn(async move { let mut agent_presence = AgentDetectionPresence::from_agent(None); @@ -722,7 +730,7 @@ fn spawn_basic_detection_task( last_process_check = now; let had_process_probe = has_process_probe; has_process_probe = true; - let probe = probe_foreground_process(pid, foreground_pgid); + let probe = probe_foreground_process(pid, foreground_pgid, &detection_wrappers); let process_group_id = probe.process_group_id; let tracked_process_group_id = process_group_for_change_tracking(foreground_pgid, process_group_id); @@ -2055,6 +2063,7 @@ impl PaneRuntime { let pending_release = Arc::new(Mutex::new(None)); let pending_release_for_task = pending_release.clone(); + let detection_wrappers = crate::detect::detection_wrappers(); let handle = tokio::spawn(async move { let mut agent_presence = AgentDetectionPresence::from_agent(initial_state.detected_agent); @@ -2159,7 +2168,8 @@ impl PaneRuntime { let had_process_probe = has_process_probe; has_process_probe = true; if pid > 0 { - let probe = probe_foreground_process(pid, foreground_pgid); + let probe = + probe_foreground_process(pid, foreground_pgid, &detection_wrappers); let process_name = probe.process_name; let process_group_id = probe.process_group_id; let tracked_process_group_id = process_group_for_change_tracking( @@ -3557,6 +3567,7 @@ mod tests { Some(job), || None, |pid| (pid == 99).then_some(Agent::Claude), + &std::collections::HashMap::new(), ); assert_eq!(result.agent, Some(Agent::Claude)); @@ -3576,6 +3587,7 @@ mod tests { None, || Some(job), |pid| (pid == 99).then_some(Agent::Claude), + &std::collections::HashMap::new(), ); assert_eq!(result.agent, Some(Agent::Claude)); @@ -3598,6 +3610,7 @@ mod tests { None, || Some(job), |pid| (pid == 100).then_some(Agent::Claude), + &std::collections::HashMap::new(), ); assert_eq!(result.agent, Some(Agent::Codex)); @@ -3620,6 +3633,7 @@ mod tests { None, || Some(job), |pid| (pid == 100).then_some(Agent::Claude), + &std::collections::HashMap::new(), ); assert_eq!(result.agent, Some(Agent::Claude)); diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 35ecfd827f..4adf1c43ac 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -491,7 +491,12 @@ fn select_pane_foreground_job( let descendants = descendant_entries(shell_pid, entries); let mut candidates = Vec::new(); for entry in std::iter::once(shell).chain(descendants) { - if crate::detect::identify_agent_in_job(&foreground_job_from_entry(entry)).is_some() { + if crate::detect::identify_agent_in_job( + &foreground_job_from_entry(entry), + &crate::detect::detection_wrappers(), + ) + .is_some() + { candidates.push(entry); } }