Skip to main content

forge_agent/env/
processes.rs

1//! OS-level process introspection for the Inspector pane's
2//! PROCESSES section.
3//!
4//! Walks the descendant tree of the spawned `claude` binary at the
5//! operating-system level via the `sysinfo` crate, returning a
6//! sorted snapshot of "interesting" descendants: shells, compilers,
7//! task runners, monitors - anything claude's tool flow spawned
8//! that's still alive at scan time.
9//!
10//! Why not key off the wire alone? The wire's `task_started` /
11//! `task_updated` pair covers `Bash` with `run_in_background:true`, a
12//! foreground call that outlives a beat, and the `Monitor` tool's
13//! streaming watcher, but it doesn't surface:
14//! - A foreground Bash that ends within a beat, which leaves no frame.
15//! - Grandchild processes the spawned shell forks off (e.g. a
16//!   `cargo build` that fans out into `rustc` workers).
17//! - Anything that detaches from claude's tool registry but stays
18//!   in the process tree.
19//!
20//! macOS and Linux are first-class, via `sysinfo`. Windows falls back
21//! to an empty snapshot: shell-process names (`cmd.exe` /
22//! `powershell.exe`) need a recogniser nobody has asked for yet.
23//!
24//! The snapshot is consumed by `forge-workspace`'s seat walk (thin
25//! mediator), which passes the session's live background commands
26//! through so the scan can adopt matching OS processes by cmdline
27//! lookup (a backgrounded `local_bash` sits outside claude's tree).
28
29use std::time::{Duration, SystemTime};
30
31use forge_primitives::McpServerStatus;
32use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};
33
34/// How old a snapshot may be before another walk replaces it.
35///
36/// One constant for every walker: the workspace's loop walks every seat a
37/// view is holding, so a cadence held separately by each would let the
38/// walkers add up to a busier machine than either intended.
39pub const SCAN_STALENESS: Duration = Duration::from_secs(1);
40
41/// Snapshot of `claude`'s descendant processes at one point in time.
42/// Always succeeds - failures (sysinfo errors, process gone) collapse
43/// to an empty `processes` vec.
44#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
45pub struct ProcessSnapshot {
46    /// Sorted descendants of the supplied `claude_pid`. Sort order:
47    /// memory descending - most resource-hungry processes first so
48    /// a top-N display surfaces the interesting ones.
49    pub processes: Vec<ProcessEntry>,
50    /// When the scan ran. The walk compares against this to decide
51    /// whether to take a fresh scan.
52    pub scanned_at: SystemTime,
53}
54
55/// One descendant process the scanner found alive. Field shape is
56/// driven by what a view's process row builder needs (PID + cmdline +
57/// memory for the metadata line) + the cmdline-matching needed for
58/// wire-tracked overlay (`process_cmdline_matches_tool_input`).
59#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
60pub struct ProcessEntry {
61    /// OS process identifier.
62    pub pid: u32,
63    /// Parent process identifier - `claude_pid` for direct children,
64    /// some other descendant for grandchildren.
65    pub parent_pid: u32,
66    /// Process name as the OS reports it (e.g. `"zsh"`, `"cargo"`,
67    /// `"rustc"`). Used as the row headline when no wire-tracked
68    /// task overlay matches.
69    pub name: String,
70    /// Full cmdline joined with spaces (e.g.
71    /// `"cargo nextest run --no-fail-fast"`). Used for the row's
72    /// detail line and for fuzzy match against `ToolCall.raw_input.command`.
73    pub command: String,
74    /// Resident memory in bytes. Rendered abbreviated (`234M`,
75    /// `1.2G`) on the row's metadata line.
76    pub memory_bytes: u64,
77}
78
79/// Walk `claude_pid`'s descendant tree plus any command-matched
80/// backgrounded `local_bash` and build a sorted snapshot. Always
81/// returns successfully - sysinfo failures, missing processes, or empty
82/// trees all collapse to an empty snapshot with a fresh `scanned_at`.
83///
84/// `extra_commands` are the session's live backgrounded `local_bash`
85/// commands: such a bash is `setsid`-detached (or orphaned to init when
86/// its session's claude exits) so it sits outside claude's tree, and
87/// adopting the command-matched wrapper plus its subtree gives it a real
88/// RAM + process-tree row instead of a memory-less synthetic one.
89///
90/// Filters applied to every candidate: skip the scanner's own pid,
91/// zombies/dead (in the table but no live work), and the one-shot `ps` /
92/// `sysinfo` self-probes that would themselves match the walk window.
93pub fn scan(claude_pid: u32, extra_commands: &[String]) -> ProcessSnapshot {
94    // `System::new()` is cheap; refreshing processes is the expensive
95    // bit. Limit the refresh to memory + cmdline only (skip CPU sampling
96    // which needs two spaced refreshes, disk I/O we don't render, etc.).
97    let mut system = System::new();
98    system.refresh_processes_specifics(
99        ProcessesToUpdate::All,
100        true,
101        ProcessRefreshKind::nothing().with_memory().with_cmd(sysinfo::UpdateKind::Always),
102    );
103
104    let self_pid = sysinfo::get_current_pid().ok();
105    let candidates = live_candidates(&system, self_pid);
106    let mut processes = select_snapshot(candidates, claude_pid, extra_commands);
107
108    // Sort by memory descending so the rendered top-N surfaces the
109    // heaviest workers first. Stable secondary key by PID keeps
110    // ordering deterministic for tests + same-memory ties.
111    processes.sort_by(|a, b| b.memory_bytes.cmp(&a.memory_bytes).then_with(|| a.pid.cmp(&b.pid)));
112
113    ProcessSnapshot { processes, scanned_at: SystemTime::now() }
114}
115
116/// Build a `ProcessEntry` for every live, interesting process in the
117/// table, dropping the scanner's own pid, zombies/dead, and the one-shot
118/// `ps` / `sysinfo` self-probes.
119fn live_candidates(system: &System, self_pid: Option<Pid>) -> Vec<ProcessEntry> {
120    let mut out = Vec::new();
121    for (pid, proc) in system.processes() {
122        if self_pid == Some(*pid) {
123            continue;
124        }
125        if matches!(proc.status(), sysinfo::ProcessStatus::Zombie | sysinfo::ProcessStatus::Dead) {
126            continue;
127        }
128        let name = proc.name().to_string_lossy().to_string();
129        if name == "ps" || name == "sysinfo" {
130            continue;
131        }
132        let command = proc
133            .cmd()
134            .iter()
135            .map(|arg| arg.to_string_lossy().into_owned())
136            .collect::<Vec<_>>()
137            .join(" ");
138        out.push(ProcessEntry {
139            pid: pid.as_u32(),
140            parent_pid: proc.parent().map_or(0, Pid::as_u32),
141            name,
142            command,
143            memory_bytes: proc.memory(),
144        });
145    }
146    out
147}
148
149/// Select the processes that belong in the snapshot: the descendants of
150/// `claude_pid`, plus any candidate whose cmdline matches one of
151/// `extra_commands` together with ITS descendants (a backgrounded
152/// `local_bash` living outside claude's tree). Pure over `candidates` so
153/// the adoption logic is unit-testable without a live process table.
154fn select_snapshot(
155    candidates: Vec<ProcessEntry>,
156    claude_pid: u32,
157    extra_commands: &[String],
158) -> Vec<ProcessEntry> {
159    // sysinfo has no "children of X" query - index parent -> children
160    // once, then walk.
161    let mut children_of: std::collections::HashMap<u32, Vec<u32>> =
162        std::collections::HashMap::new();
163    for entry in &candidates {
164        children_of.entry(entry.parent_pid).or_default().push(entry.pid);
165    }
166
167    let mut included: std::collections::HashSet<u32> = std::collections::HashSet::new();
168    // claude's descendants (claude itself is never a row).
169    include_subtree(&children_of, claude_pid, &mut included);
170
171    // Adopt command-matched processes outside claude's tree + their
172    // subtrees. Collect the roots under the immutable borrow first, then
173    // walk each - the root itself joins the snapshot, unlike claude.
174    if !extra_commands.is_empty() {
175        let matched_roots: Vec<u32> = candidates
176            .iter()
177            .filter(|entry| !included.contains(&entry.pid))
178            // Only a genuine claude bash wrapper is an adoptable backgrounded
179            // local_bash; gating on a successful unwrap keeps a foreign process
180            // that merely substring-matches the wire command out of the snapshot.
181            .filter(|entry| extract_inner_command(&entry.command).is_some())
182            .filter(|entry| {
183                extra_commands
184                    .iter()
185                    .any(|cmd| process_cmdline_matches_tool_input(&entry.command, cmd))
186            })
187            .map(|entry| entry.pid)
188            .collect();
189        for root in matched_roots {
190            if included.insert(root) {
191                include_subtree(&children_of, root, &mut included);
192            }
193        }
194    }
195
196    candidates.into_iter().filter(|entry| included.contains(&entry.pid)).collect()
197}
198
199/// Insert every descendant of `root` (not `root` itself) into
200/// `included`, walking the `parent -> children` index.
201fn include_subtree(
202    children_of: &std::collections::HashMap<u32, Vec<u32>>,
203    root: u32,
204    included: &mut std::collections::HashSet<u32>,
205) {
206    let mut stack = vec![root];
207    while let Some(pid) = stack.pop() {
208        let Some(children) = children_of.get(&pid) else { continue };
209        for &child in children {
210            if included.insert(child) {
211                stack.push(child);
212            }
213        }
214    }
215}
216
217/// Unwrap a shell-wrapper cmdline to the user command inside it.
218/// Recognizes the shape claude wraps Bash tool calls in (captured via
219/// `ps -axww`): `/bin/zsh -c source <snapshot> ... && eval '<CMD>' <
220/// /dev/null && pwd -P >| /tmp/claude-<hash>-cwd`. The inner command is
221/// single-quoted between `eval '` and `' < /dev/null`. Returns `None`
222/// when `cmdline` is not a recognized wrapper (non-shell processes pass
223/// through unchanged at the call site).
224pub fn extract_inner_command(cmdline: &str) -> Option<String> {
225    let after_eval = cmdline.split_once("eval '")?.1;
226    // Terminate at the OUTERMOST `' < /dev/null` (the eval close-quote) so a
227    // command that itself contains that redirect doesn't cut off early.
228    let inner = after_eval.rsplit_once("' < /dev/null")?.0;
229    // The wrapper re-escapes each `'` as the POSIX sequence `'"'"'`; reverse it
230    // so a single-quote command still matches its wire `raw_input.command`.
231    Some(inner.trim().replace(r#"'"'"'"#, "'"))
232}
233
234/// Strip the executable's directory from a cmdline headline so the row
235/// reads `cargo nextest run`, not `/opt/homebrew/bin/cargo nextest run`.
236/// Only the FIRST token (the executable) is basenamed; args are kept
237/// verbatim (they carry the distinguishing detail). A first token with
238/// no `/` (e.g. a `postgres: walwriter` process title) is unchanged.
239pub fn basename_exe(cmdline: &str) -> String {
240    let cmdline = cmdline.trim();
241    match cmdline.split_once(char::is_whitespace) {
242        Some((exe, rest)) => {
243            let base = exe.rsplit('/').next().unwrap_or(exe);
244            format!("{base} {rest}")
245        }
246        None => cmdline.rsplit('/').next().unwrap_or(cmdline).to_owned(),
247    }
248}
249
250/// Collapse every run of ASCII whitespace (spaces, tabs, newlines) to a
251/// single space and trim the ends. `sysinfo`'s `proc.cmd()` joins argv
252/// with single spaces, so a multi-line / multi-space wire command never
253/// matches the captured cmdline byte-for-byte; normalizing both sides
254/// first makes the substring check robust.
255fn normalize_ws(s: &str) -> String {
256    s.split_whitespace().collect::<Vec<_>>().join(" ")
257}
258
259/// True when `process_cmd` plausibly matches the shell command
260/// captured in a wire-tracked tool's `raw_input.command`. Used by
261/// a view's process row builder (path A+) to overlay wire-tracked
262/// task descriptions onto OS-detected processes when both refer to
263/// the same work.
264///
265/// Unwraps the shell wrapper first (so the comparison is against the
266/// user command, not the `/bin/zsh -c ... eval '...'` chrome), then matches
267/// on a whitespace-normalized substring basis.
268///
269/// Empty `tool_command` returns `false` (no useful match possible).
270pub fn process_cmdline_matches_tool_input(process_cmd: &str, tool_command: &str) -> bool {
271    let needle = cmdline_needle(tool_command);
272    if needle.is_empty() {
273        return false;
274    }
275    cmdline_haystack(process_cmd).contains(&needle)
276}
277
278/// The normalized command text a wire-tracked tool call is matched on.
279/// `normalize_ws` already trims, so a whitespace-only command yields the
280/// empty string and the caller can treat that as "no needle".
281pub fn cmdline_needle(tool_command: &str) -> String {
282    normalize_ws(tool_command)
283}
284
285/// The normalized cmdline text a process is matched on: the unwrapped
286/// inner command for a shell wrapper, else the cmdline itself.
287pub fn cmdline_haystack(process_cmd: &str) -> String {
288    let inner = extract_inner_command(process_cmd).unwrap_or_else(|| process_cmd.to_owned());
289    normalize_ws(&inner)
290}
291
292/// Friendly display name + kind for a recognized known-infra process.
293#[derive(Debug, Clone, PartialEq, Eq)]
294pub struct InfraLabel {
295    pub name: String,
296}
297
298/// Friendly name for a known-infra process (MCP servers today), derived
299/// from its cmdline. `None` when unrecognized.
300///
301/// Recognizes the `npm exec <pkg>` and `node .../<bin>` shapes captured
302/// via `ps -axww`, with the `@version` suffix stripped: `<base>-mcp[-server]`
303/// → `<base>` (`@upstash/context7-mcp` → `context7`), a package literally
304/// named `mcp` → its scope (`@playwright/mcp@latest` → `playwright`), and
305/// the `@modelcontextprotocol/server-<base>` convention → `<base>`.
306pub fn classify_known_infra(cmdline: &str) -> Option<InfraLabel> {
307    let name = mcp_name_from_npm(cmdline).or_else(|| mcp_name_from_node(cmdline))?;
308    Some(InfraLabel { name })
309}
310
311/// Parse an npm package spec into `(scope, name)` with the `@version`
312/// stripped. `@playwright/mcp@latest` -> `(Some("playwright"), "mcp")`;
313/// `@upstash/context7-mcp` -> `(Some("upstash"), "context7-mcp")`;
314/// `context7-mcp@1.0` -> `(None, "context7-mcp")`.
315fn parse_npm_pkg(pkg_spec: &str) -> (Option<&str>, &str) {
316    let (scope, rest) = match pkg_spec.strip_prefix('@') {
317        Some(after_at) => match after_at.split_once('/') {
318            Some((scope, rest)) => (Some(scope), rest),
319            None => (None, pkg_spec),
320        },
321        None => (None, pkg_spec),
322    };
323    let name = rest.split('@').next().unwrap_or(rest);
324    (scope, name)
325}
326
327/// Friendly MCP name from a parsed `(scope, name)`, or `None` when the
328/// package isn't a recognized MCP server:
329/// - `<base>-mcp` / `<base>-mcp-server` -> `<base>`
330/// - package literally named `mcp` -> the scope (`@playwright/mcp` -> `playwright`)
331/// - `server-<base>` (the `@modelcontextprotocol` convention) -> `<base>`
332fn mcp_friendly_name(scope: Option<&str>, name: &str) -> Option<String> {
333    if let Some(base) = name.strip_suffix("-mcp-server").or_else(|| name.strip_suffix("-mcp"))
334        && !base.is_empty()
335    {
336        return Some(base.to_owned());
337    }
338    if name == "mcp" {
339        return scope.filter(|s| !s.is_empty()).map(ToOwned::to_owned);
340    }
341    if let Some(base) = name.strip_prefix("server-")
342        && !base.is_empty()
343    {
344        return Some(base.to_owned());
345    }
346    None
347}
348
349/// `npm exec @playwright/mcp@latest` → `playwright`. Requires the literal
350/// `npm` + `exec` argv prefix; the package spec must resolve to an MCP
351/// name via [`mcp_friendly_name`] or this returns `None`.
352fn mcp_name_from_npm(cmdline: &str) -> Option<String> {
353    let mut toks = cmdline.split_whitespace();
354    if toks.next()? != "npm" || toks.next()? != "exec" {
355        return None;
356    }
357    let (scope, name) = parse_npm_pkg(toks.next()?);
358    mcp_friendly_name(scope, name)
359}
360
361/// `node /opt/foo-mcp-server.js` → `foo`. Requires a `node` `argv[0]` and
362/// a later token whose basename (sans `.js` / `@version`) resolves via
363/// [`mcp_friendly_name`] (scope-less, so a bare `mcp` doesn't match).
364fn mcp_name_from_node(cmdline: &str) -> Option<String> {
365    let mut toks = cmdline.split_whitespace();
366    if !toks.next()?.ends_with("node") {
367        return None;
368    }
369    toks.find_map(|tok| {
370        let base = tok.rsplit('/').next().unwrap_or(tok);
371        let base = base.strip_suffix(".js").unwrap_or(base);
372        let name = base.split('@').next().unwrap_or(base);
373        mcp_friendly_name(None, name)
374    })
375}
376
377/// A configured MCP server distilled to what the process matcher needs:
378/// the configured `name` plus the stdio launch `command` + `args` used to
379/// match it against a live process cmdline.
380#[derive(Debug, Clone, PartialEq, Eq)]
381pub struct ConfiguredMcpServer {
382    pub name: String,
383    pub command: String,
384    pub args: Vec<String>,
385}
386
387/// Extract the stdio-launched configured MCP servers from an mcp-status
388/// snapshot. A server contributes `(name, command, args)` only when its
389/// `config` blob carries a string `command` (the stdio shape); url-only
390/// (http/sse) servers have no local process, so they're skipped.
391pub fn configured_mcp_servers(servers: &[McpServerStatus]) -> Vec<ConfiguredMcpServer> {
392    servers
393        .iter()
394        .filter_map(|server| {
395            let config = server.config.as_ref()?;
396            let command = config.get("command")?.as_str()?.to_owned();
397            let args = config
398                .get("args")
399                .and_then(serde_json::Value::as_array)
400                .map(|arr| arr.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect())
401                .unwrap_or_default();
402            Some(ConfiguredMcpServer { name: server.name.clone(), command, args })
403        })
404        .collect()
405}
406
407/// The configured server whose command + args uniquely text-match
408/// `cmdline`. `None` when none matches or several do - the ambiguous case
409/// declines rather than guesses.
410///
411/// Deliberately no package-convention fallback: this is the claim
412/// evidence for the MCP SERVERS join, which is snapshot-sourced - a
413/// package-derived name for a server the snapshot does not list is
414/// guessing by construction.
415pub fn configured_text_match<'a>(
416    cmdline: &str,
417    configured: &'a [ConfiguredMcpServer],
418) -> Option<&'a ConfiguredMcpServer> {
419    let inner = extract_inner_command(cmdline).unwrap_or_else(|| cmdline.to_owned());
420    let haystack = normalize_ws(&inner);
421    let matched: Vec<&ConfiguredMcpServer> =
422        configured.iter().filter(|server| configured_server_matches(&haystack, server)).collect();
423    match matched.as_slice() {
424        [only] => Some(only),
425        _ => None,
426    }
427}
428
429/// Friendly label for a known-infra process: the CONFIGURED MCP server name
430/// when the process's command + args uniquely match one of `configured`,
431/// else the package-derived name from [`classify_known_infra`]. `None` when
432/// neither produces a name - the process isn't a recognized MCP server.
433///
434/// The configured match is tried FIRST because [`classify_known_infra`] only
435/// knows package-naming conventions: a locally-launched script fits none of
436/// them, so gating on it would drop a configured server to a generic row.
437pub fn resolve_infra_label(
438    cmdline: &str,
439    configured: &[ConfiguredMcpServer],
440) -> Option<InfraLabel> {
441    let inner = extract_inner_command(cmdline).unwrap_or_else(|| cmdline.to_owned());
442    let haystack = normalize_ws(&inner);
443    let matched: Vec<&ConfiguredMcpServer> =
444        configured.iter().filter(|server| configured_server_matches(&haystack, server)).collect();
445    match matched.as_slice() {
446        [only] => Some(InfraLabel { name: strip_plugin_namespace(&only.name).to_owned() }),
447        [] => classify_known_infra(&inner),
448        many => {
449            tracing::debug!(
450                candidates = ?many.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
451                cmdline = %cmdline,
452                "MCP process matched multiple configured servers; using package name"
453            );
454            classify_known_infra(&inner)
455        }
456    }
457}
458
459/// The bare server name inside a plugin-namespaced configured name. The CLI
460/// exposes a plugin-provided MCP server as `plugin:<plugin>:<server>`, which
461/// reads worse than the package-derived name; anything else (including a
462/// plain name that merely contains a colon) is returned verbatim.
463pub fn strip_plugin_namespace(name: &str) -> &str {
464    let mut parts = name.splitn(3, ':');
465    match (parts.next(), parts.next(), parts.next()) {
466        (Some("plugin"), Some(plugin), Some(server))
467            if !plugin.is_empty() && !server.is_empty() =>
468        {
469            server
470        }
471        _ => name,
472    }
473}
474
475/// One depth-0 process the elimination join may consider, with whether an
476/// alive wire tool call already claims it.
477#[derive(Debug, Clone, Copy)]
478pub struct RootProcess<'a> {
479    pub pid: u32,
480    pub cmdline: &'a str,
481    pub wire_matched: bool,
482}
483
484/// Executables an stdio MCP server's live process image runs under, matched on
485/// the `argv[0]` basename so a server that `exec`ed out of its configured
486/// `sh -c` wrapper still qualifies.
487///
488/// **No shell belongs here.** A shell is how claude spawns both its Bash-tool
489/// wrapper and its `type: "command"` hooks, and both land as direct children of
490/// the same pid - so whitelisting one puts every tool call and hook in the
491/// candidate pool, which declines the pairing and drops the row out of the MCP
492/// tier while it runs. Nothing is lost: a server that has `exec`ed is no longer
493/// shell-shaped, and one that hasn't still text-matches its own `-c` script.
494const INTERPRETERS: [&str; 7] = ["node", "python", "python3", "deno", "bun", "npx", "uvx"];
495
496/// True when a root process may stand in for an unmatched server: its `argv[0]`
497/// is one of [`INTERPRETERS`]. Keeps transient tool processes (an `rg` from a
498/// Grep) out of the pool, which would otherwise decline the pairing on any scan
499/// where one was alive and flicker the row.
500fn is_elimination_candidate(cmdline: &str) -> bool {
501    let Some(exe) = cmdline.split_whitespace().next() else {
502        return false;
503    };
504    let base = exe.rsplit('/').next().unwrap_or(exe);
505    INTERPRETERS.contains(&base)
506}
507
508/// Pair the sole unmatched `Connected` stdio server with the sole unmatched
509/// candidate process, returning that process's pid and the name its row takes.
510///
511/// A join by ELIMINATION, not identity - nothing observable ties a server to
512/// its process - so two leftovers on either side decline rather than guess.
513///
514/// What the `Connected` filter buys is narrow, and it is not liveness: it only
515/// guarantees the name attached comes from a server the CLI last reported as
516/// connected, so a `Pending` or `Failed` server can never lend its name. It
517/// does NOT establish that the elected process belongs to a live server. A
518/// server that connected and then died still reads `Connected` until the CLI
519/// notices, and there is no liveness signal on the wire to check - so in that
520/// window the sole remaining candidate can be paired with a dead server's name.
521/// Declining on snapshot age would not help: in that case the snapshot is fresh
522/// and still says `Connected`.
523pub fn elect_unmatched_server(
524    servers: &[McpServerStatus],
525    configured: &[ConfiguredMcpServer],
526    roots: &[RootProcess<'_>],
527) -> Option<(u32, String)> {
528    let mut claimed: std::collections::HashSet<String> = std::collections::HashSet::new();
529    let mut candidate = None;
530    let mut candidates = 0usize;
531    for root in roots.iter().filter(|root| !root.wire_matched) {
532        if let Some(label) = resolve_infra_label(root.cmdline, configured) {
533            claimed.insert(label.name);
534        } else if is_elimination_candidate(root.cmdline) {
535            candidates += 1;
536            candidate = Some(root.pid);
537        }
538    }
539    if candidates != 1 {
540        return None;
541    }
542    let mut unmatched = configured
543        .iter()
544        .map(|server| strip_plugin_namespace(&server.name))
545        .filter(|name| !claimed.contains(*name))
546        .filter(|name| {
547            servers.iter().any(|server| {
548                strip_plugin_namespace(&server.name) == *name
549                    && server.status == forge_primitives::McpServerConnectionStatus::Connected
550            })
551        });
552    let elected = unmatched.next()?;
553    if unmatched.next().is_some() {
554        return None;
555    }
556    Some((candidate?, elected.to_owned()))
557}
558
559/// True when `haystack` (a whitespace-normalized process cmdline) contains
560/// every match token of `server`. Leading `npx`/`npm` passthrough flags
561/// (e.g. `-y`) are dropped first: `npx -y <pkg>` re-execs into `npm exec
562/// <pkg>` with the flag stripped, so the tokens are the package spec (the
563/// first non-flag arg) plus everything after it - where the real
564/// distinguisher `--cdp-endpoint <url>` lives and survives into the process
565/// cmdline. Empty / all-flag args fall back to the bare `command`. Substring
566/// containment tolerates the process-side `@version` suffix.
567fn configured_server_matches(haystack: &str, server: &ConfiguredMcpServer) -> bool {
568    let mut tokens = server.args.iter().skip_while(|arg| arg.starts_with('-')).peekable();
569    if tokens.peek().is_none() {
570        let command = server.command.as_str();
571        return !command.is_empty() && haystack.contains(command);
572    }
573    tokens.all(|token| !token.is_empty() && haystack.contains(token.as_str()))
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579
580    #[test]
581    fn empty_tool_command_never_matches() {
582        assert!(!process_cmdline_matches_tool_input("any process cmd", ""));
583        assert!(!process_cmdline_matches_tool_input("any process cmd", "   "));
584    }
585
586    /// Ground-truth wrapper captured via `ps -axww` on this machine
587    /// (a backgrounded Bash tool call). Differs from the idealized
588    /// `-c -l source ~/.zshrc` shape: no `-l`, a `source <snapshot>
589    /// 2>/dev/null || true` clause, a `setopt ... || true` clause, and a
590    /// trailing `&& pwd -P >| /tmp/claude-<hash>-cwd` after the
591    /// `< /dev/null` redirect. The inner command is single-quoted
592    /// between `eval '` and `' < /dev/null`.
593    const REAL_WRAPPER: &str = "/bin/zsh -c source /Users/x/.claude/shell-snapshots/snapshot-zsh-1.sh 2>/dev/null || true && setopt NO_EXTENDED_GLOB NO_BARE_GLOB_QUAL 2>/dev/null || true && eval 'gh run watch 123 --exit-status' < /dev/null && pwd -P >| /tmp/claude-ab12-cwd";
594
595    /// Ground-truth wrapper captured via `ps -axww` for a backgrounded Bash
596    /// whose command contains single-quotes: the wrapper re-escapes each `'`
597    /// inside the eval'd command as the POSIX sequence `'"'"'` (close-quote,
598    /// a double-quoted quote, reopen-quote), and the wire `raw_input.command`
599    /// was `echo 'sq-marker'; sleep 40`.
600    const SINGLE_QUOTE_WRAPPER: &str = r#"/bin/zsh -c source /Users/x/.claude/shell-snapshots/snapshot-zsh-1.sh 2>/dev/null || true && setopt NO_EXTENDED_GLOB NO_BARE_GLOB_QUAL 2>/dev/null || true && eval 'echo '"'"'sq-marker'"'"'; sleep 40' < /dev/null && pwd -P >| /tmp/claude-ab12-cwd"#;
601
602    fn entry(pid: u32, parent_pid: u32, command: &str) -> ProcessEntry {
603        ProcessEntry {
604            pid,
605            parent_pid,
606            name: "bash".to_owned(),
607            command: command.to_owned(),
608            memory_bytes: 1024,
609        }
610    }
611
612    fn selected_pids(entries: &[ProcessEntry]) -> std::collections::BTreeSet<u32> {
613        entries.iter().map(|entry| entry.pid).collect()
614    }
615
616    #[test]
617    fn select_snapshot_keeps_claude_descendants_drops_siblings() {
618        // claude=100; child 200 -> grandchild 300; unrelated sibling 400 under init.
619        let candidates =
620            vec![entry(200, 100, "cargo build"), entry(300, 200, "rustc"), entry(400, 1, "sshd")];
621        assert_eq!(
622            selected_pids(&select_snapshot(candidates, 100, &[])),
623            [200, 300].into_iter().collect()
624        );
625    }
626
627    #[test]
628    fn select_snapshot_without_commands_drops_detached_bash() {
629        // Reproduce-first: a backgrounded bash setsid-detached under init
630        // (parent 1, not a claude descendant). With no extra_commands the
631        // scan misses it entirely - the memory-less synthetic-row bug.
632        let candidates = vec![entry(200, 100, "cargo build"), entry(500, 1, REAL_WRAPPER)];
633        assert_eq!(
634            selected_pids(&select_snapshot(candidates, 100, &[])),
635            [200].into_iter().collect()
636        );
637    }
638
639    #[test]
640    fn select_snapshot_adopts_command_matched_detached_bash_and_subtree() {
641        // Same detached bash, now with its wire command in extra_commands:
642        // it AND the child it spawned join the snapshot as adopted roots;
643        // the unrelated init-child stays out.
644        let candidates = vec![
645            entry(200, 100, "cargo build"),
646            entry(500, 1, REAL_WRAPPER),
647            entry(600, 500, "gh api repos/foo"),
648            entry(700, 1, "sshd"),
649        ];
650        let extra = vec!["gh run watch 123 --exit-status".to_owned()];
651        assert_eq!(
652            selected_pids(&select_snapshot(candidates, 100, &extra)),
653            [200, 500, 600].into_iter().collect()
654        );
655    }
656
657    #[test]
658    fn select_snapshot_command_match_inside_claude_tree_not_doubled() {
659        // A matched bash that IS a claude descendant appears exactly once.
660        let candidates = vec![entry(500, 100, REAL_WRAPPER)];
661        let extra = vec!["gh run watch 123 --exit-status".to_owned()];
662        let selected = select_snapshot(candidates, 100, &extra);
663        assert_eq!(selected.len(), 1);
664        assert_eq!(selected[0].pid, 500);
665    }
666
667    #[test]
668    fn select_snapshot_does_not_adopt_foreign_non_wrapper_match() {
669        // A foreign process whose raw cmdline substring-matches the wire
670        // command but is NOT a claude bash wrapper (nothing to unwrap) must
671        // not be adopted, even though the substring matches.
672        let candidates = vec![
673            entry(200, 100, "cargo build"),
674            entry(500, 1, "gh run watch 123 --exit-status --repo busytools/forge"),
675        ];
676        let extra = vec!["gh run watch 123 --exit-status".to_owned()];
677        assert_eq!(
678            selected_pids(&select_snapshot(candidates, 100, &extra)),
679            [200].into_iter().collect()
680        );
681    }
682
683    #[test]
684    fn extract_inner_command_unwraps_real_wrapper() {
685        assert_eq!(
686            extract_inner_command(REAL_WRAPPER).as_deref(),
687            Some("gh run watch 123 --exit-status")
688        );
689        // A plain non-wrapper cmdline passes through as None so the
690        // caller keeps the original.
691        assert_eq!(extract_inner_command("rustc --crate-name forge_tui"), None);
692        assert_eq!(extract_inner_command("node balance-transfer.mjs"), None);
693    }
694
695    #[test]
696    fn substring_match_holds_for_wrapped_shell_invocation() {
697        // The user-typed command resolves out of the real wrapper.
698        let tool_command = "gh run watch 123 --exit-status";
699        assert!(process_cmdline_matches_tool_input(REAL_WRAPPER, tool_command));
700    }
701
702    #[test]
703    fn wrapped_multiline_command_matches_after_normalize() {
704        // The failure the idealized fixture hid: `proc.cmd()` joins argv
705        // with single spaces, so a multi-line / multi-space wire command
706        // is not a raw substring of the captured cmdline. Normalizing
707        // whitespace on both sides (after unwrapping) makes it match.
708        let tool_command = "gh run watch 123\n  --exit-status";
709        assert!(process_cmdline_matches_tool_input(REAL_WRAPPER, tool_command));
710    }
711
712    #[test]
713    fn the_split_predicate_matches_the_recorded_pre_change_behaviour() {
714        // Expected column captured from process_cmdline_matches_tool_input
715        // before the split. Literal on purpose: asserting the helpers compose
716        // to the predicate they are defined from would be one expression
717        // against itself.
718        let cases: [(&str, &str, bool); 10] = [
719            // The real wrapper unwraps to the eval'd command.
720            (REAL_WRAPPER, "gh run watch 123 --exit-status", true),
721            // Same wrapper, a command it does not carry.
722            (REAL_WRAPPER, "gh run watch 999", false),
723            // A bare non-wrapper cmdline matches itself.
724            ("rustc --crate-name forge_tui", "rustc --crate-name forge_tui", true),
725            // Whitespace-collapsed on both sides.
726            ("cargo  nextest\n run", "cargo nextest run", true),
727            ("cargo nextest run", "cargo\nnextest\nrun", true),
728            // A needle longer than the haystack never matches.
729            ("cargo nextest", "cargo nextest run", false),
730            // A wrapper the unwrap recognises for a different shape.
731            ("/bin/zsh -c -l source x && eval 'echo hi'", "echo hi", true),
732            // The `'"'"'` single-quote escape the wrapper applies.
733            (SINGLE_QUOTE_WRAPPER, "echo 'sq-marker'; sleep 40", true),
734            // Empty and whitespace-only commands match nothing.
735            ("anything", "", false),
736            ("anything", "   ", false),
737        ];
738        for (process_cmd, tool_command, expected) in cases {
739            let haystack = cmdline_haystack(process_cmd);
740            let needle = cmdline_needle(tool_command);
741            assert_eq!(
742                !needle.is_empty() && haystack.contains(&needle),
743                expected,
744                "haystack {haystack:?} / needle {needle:?} for {process_cmd:?} \
745                 vs {tool_command:?}",
746            );
747            assert_eq!(
748                process_cmdline_matches_tool_input(process_cmd, tool_command),
749                expected,
750                "the one-shot predicate drifted from the recorded behaviour for \
751                 {process_cmd:?} vs {tool_command:?}",
752            );
753        }
754    }
755
756    #[test]
757    fn a_whitespace_only_command_has_no_needle() {
758        // The empty guard now lives in the needle, so a needle of "" must never
759        // be treated as a match. Both values are literals, not a round trip
760        // through the implementation.
761        assert_eq!(cmdline_needle("   \n\t "), "");
762        assert_eq!(cmdline_needle(""), "");
763        assert_eq!(cmdline_needle("cargo  nextest\n run"), "cargo nextest run");
764        assert_eq!(cmdline_haystack("cargo  nextest\n run"), "cargo nextest run");
765    }
766
767    #[test]
768    fn extract_inner_command_unescapes_shell_single_quotes() {
769        // The `'"'"'` escape the wrapper applies to each single-quote must be
770        // reversed so the recovered command reads verbatim.
771        assert_eq!(
772            extract_inner_command(SINGLE_QUOTE_WRAPPER).as_deref(),
773            Some("echo 'sq-marker'; sleep 40")
774        );
775    }
776
777    #[test]
778    fn extract_inner_command_terminates_at_outermost_redirect() {
779        // A command that itself contains `' < /dev/null` must not terminate the
780        // unwrap early: the real terminator is the OUTERMOST occurrence (the
781        // eval close-quote), so unwrapping splits on the last one.
782        let wrapper = format!(
783            "/bin/zsh -c source /x/snap.sh 2>/dev/null || true && eval '{}' < /dev/null && pwd -P >| /tmp/claude-x-cwd",
784            r#"echo '"'"'hi'"'"' < /dev/null"#
785        );
786        assert_eq!(extract_inner_command(&wrapper).as_deref(), Some("echo 'hi' < /dev/null"));
787    }
788
789    #[test]
790    fn single_quote_command_matches_its_wrapper() {
791        // Without the un-escape the `'"'"'`-vs-`'` mismatch breaks the
792        // substring match, so the OS process never correlates to its wire
793        // tool call (renders as a raw unmatched row + a synthetic duplicate).
794        assert!(process_cmdline_matches_tool_input(
795            SINGLE_QUOTE_WRAPPER,
796            "echo 'sq-marker'; sleep 40"
797        ));
798    }
799
800    #[test]
801    fn exact_match_holds() {
802        assert!(process_cmdline_matches_tool_input(
803            "cargo build --release",
804            "cargo build --release"
805        ));
806    }
807
808    #[test]
809    fn unrelated_command_does_not_match() {
810        assert!(!process_cmdline_matches_tool_input(
811            "rustc --crate-name forge_tui",
812            "cargo nextest run"
813        ));
814    }
815
816    #[test]
817    fn partial_word_does_not_match_false_positive() {
818        // Defensive: "cargo" alone shouldn't match a row that just
819        // happens to contain "cargo" - but our substring rule says
820        // it does. Documented here as a known limitation; the
821        // alternative, a token-level match, is a lot more code for a
822        // narrow gain.
823        assert!(process_cmdline_matches_tool_input("/usr/bin/cargo build", "cargo"));
824    }
825
826    #[test]
827    fn basename_exe_strips_exe_dir_keeps_args() {
828        assert_eq!(basename_exe("/opt/homebrew/bin/cargo nextest run"), "cargo nextest run");
829        assert_eq!(
830            basename_exe("/Users/x/.rustup/toolchains/nightly/bin/rustc --crate-name forge_tui"),
831            "rustc --crate-name forge_tui"
832        );
833        // A bare test binary with no args is just its basename.
834        assert_eq!(
835            basename_exe("/Users/x/proj/target/debug/deps/some_test-9ab1"),
836            "some_test-9ab1"
837        );
838        // First token with no `/` (a process title) is returned unchanged.
839        assert_eq!(basename_exe("postgres: walwriter"), "postgres: walwriter");
840        // Already-bare command is unchanged.
841        assert_eq!(basename_exe("cargo build"), "cargo build");
842        // Edges don't panic.
843        assert_eq!(basename_exe(""), "");
844        let _ = basename_exe("/");
845    }
846
847    #[test]
848    fn classify_known_infra_recognizes_npm_exec_mcp() {
849        // Real shapes captured via ps -axww.
850        let c = classify_known_infra("npm exec @upstash/context7-mcp").expect("context7");
851        assert_eq!(c.name, "context7");
852
853        let n = classify_known_infra("npm exec @notionhq/notion-mcp-server").expect("notion");
854        assert_eq!(n.name, "notion");
855
856        // Non-MCP npm exec + unrelated processes don't classify.
857        assert!(classify_known_infra("npm exec @scope/some-tool").is_none());
858        assert!(classify_known_infra("rustc --crate-name forge_tui").is_none());
859        assert!(classify_known_infra("node balance-transfer.mjs").is_none());
860    }
861
862    #[test]
863    fn classify_known_infra_recognizes_node_launched_mcp() {
864        let l = classify_known_infra("node /opt/foo-mcp-server.js").expect("foo");
865        assert_eq!(l.name, "foo");
866    }
867
868    #[test]
869    fn classify_known_infra_recognizes_broadened_shapes() {
870        // @scope/mcp (package literally named `mcp`) -> the scope is the
871        // name. Real shape captured via ps: `npm exec @playwright/mcp@latest
872        // --cdp-endpoint ...`.
873        assert_eq!(
874            classify_known_infra(
875                "npm exec @playwright/mcp@latest --cdp-endpoint http://localhost:9222"
876            )
877            .expect("playwright")
878            .name,
879            "playwright"
880        );
881        assert_eq!(
882            classify_known_infra("npm exec @playwright/mcp").expect("playwright").name,
883            "playwright"
884        );
885        // @modelcontextprotocol/server-<name> convention.
886        assert_eq!(
887            classify_known_infra("npm exec @modelcontextprotocol/server-filesystem")
888                .expect("filesystem")
889                .name,
890            "filesystem"
891        );
892        // @version suffix stripped on the existing -mcp shape.
893        assert_eq!(
894            classify_known_infra("npm exec @upstash/context7-mcp@2.0").expect("context7").name,
895            "context7"
896        );
897        // The real playwright node child folds to the same name, so the
898        // same-name MCP-child dedup collapses it under the parent.
899        assert_eq!(
900            classify_known_infra(
901                "node /Users/x/.npm/_npx/abc/node_modules/.bin/playwright-mcp --cdp-endpoint http://localhost:9222"
902            )
903            .expect("playwright child")
904            .name,
905            "playwright"
906        );
907    }
908
909    #[test]
910    fn scan_returns_empty_snapshot_for_missing_pid() {
911        // PID 0 is the kernel scheduler on Linux / never has children
912        // on macOS - walk should yield zero descendants. Confirms
913        // `scan` is total even for nonsense input.
914        let snapshot = scan(0, &[]);
915        // Don't assert empty - PID 0 has children on Linux (kthreadd).
916        // What we DO assert: scanned_at is recent + no panic.
917        let age = SystemTime::now().duration_since(snapshot.scanned_at).map_or(0, |d| d.as_secs());
918        assert!(age <= 5, "scanned_at must be recent");
919    }
920
921    #[test]
922    fn scan_for_self_pid_runs_without_panic() {
923        // Walking the test runner's own PID should yield a snapshot
924        // (possibly empty if no children exist). Mainly testing
925        // that the scan path doesn't trip over its own foot when
926        // self_pid filter applies.
927        let me = std::process::id();
928        let _ = scan(me, &[]);
929    }
930
931    fn mcp_status(name: &str, config: Option<serde_json::Value>) -> McpServerStatus {
932        McpServerStatus {
933            name: name.to_owned(),
934            status: forge_primitives::McpServerConnectionStatus::Connected,
935            server_info: None,
936            error: None,
937            config,
938            scope: None,
939            tools: None,
940            sampling_configured: None,
941            sampling_required: None,
942        }
943    }
944
945    fn configured(name: &str, command: &str, args: &[&str]) -> ConfiguredMcpServer {
946        ConfiguredMcpServer {
947            name: name.to_owned(),
948            command: command.to_owned(),
949            args: args.iter().map(|a| (*a).to_owned()).collect(),
950        }
951    }
952
953    fn playwright_pair() -> Vec<ConfiguredMcpServer> {
954        // Real config shape: `npx -y @playwright/mcp@latest --cdp-endpoint <url>`.
955        // npx re-execs into `npm exec ...` and STRIPS the leading `-y`, so the
956        // live process (the match haystack) never carries it.
957        vec![
958            configured(
959                "playwright",
960                "npx",
961                &["-y", "@playwright/mcp@latest", "--cdp-endpoint", "http://192.0.2.10:9222"],
962            ),
963            configured(
964                "playwright-local",
965                "npx",
966                &["-y", "@playwright/mcp@latest", "--cdp-endpoint", "http://127.0.0.1:9222"],
967            ),
968        ]
969    }
970
971    #[test]
972    fn configured_mcp_servers_extracts_stdio_command_and_args() {
973        // Extraction is verbatim - the leading `-y` is preserved here; it's
974        // only dropped at match time (npx strips it re-execing to npm exec).
975        let servers = vec![mcp_status(
976            "playwright",
977            Some(serde_json::json!({
978                "type": "stdio",
979                "command": "npx",
980                "args": ["-y", "@playwright/mcp@latest", "--cdp-endpoint", "http://192.0.2.10:9222"],
981                "env": {},
982            })),
983        )];
984        assert_eq!(
985            configured_mcp_servers(&servers),
986            vec![configured(
987                "playwright",
988                "npx",
989                &["-y", "@playwright/mcp@latest", "--cdp-endpoint", "http://192.0.2.10:9222"],
990            )]
991        );
992    }
993
994    #[test]
995    fn configured_mcp_servers_skips_url_only_and_configless() {
996        let servers = vec![
997            // http (url-only) server has no local process to name.
998            mcp_status("remote", Some(serde_json::json!({"type": "http", "url": "https://x/mcp"}))),
999            // no config blob at all.
1000            mcp_status("bare", None),
1001        ];
1002        assert!(configured_mcp_servers(&servers).is_empty());
1003    }
1004
1005    #[test]
1006    fn resolve_infra_label_uses_configured_name_for_same_package_distinct_args() {
1007        // Two servers on the same @playwright/mcp package, distinguished
1008        // only by --cdp-endpoint. Package-only naming collapses both to
1009        // "playwright"; the configured match keeps them distinct.
1010        let servers = playwright_pair();
1011        let remote = "npm exec @playwright/mcp@latest --cdp-endpoint http://192.0.2.10:9222";
1012        let local = "npm exec @playwright/mcp@latest --cdp-endpoint http://127.0.0.1:9222";
1013        assert_eq!(resolve_infra_label(remote, &servers).expect("remote").name, "playwright");
1014        assert_eq!(resolve_infra_label(local, &servers).expect("local").name, "playwright-local");
1015    }
1016
1017    #[test]
1018    fn resolve_infra_label_falls_back_to_package_name_when_unmatched() {
1019        // A running MCP process with no configured match keeps the
1020        // package-derived name.
1021        let ctx = resolve_infra_label("npm exec @upstash/context7-mcp", &playwright_pair())
1022            .expect("context7");
1023        assert_eq!(ctx.name, "context7");
1024    }
1025
1026    #[test]
1027    fn resolve_infra_label_falls_back_on_ambiguous_match() {
1028        // Two configured servers whose args both match one process: an
1029        // ambiguous tie falls back to the package name rather than guessing.
1030        let servers = vec![
1031            configured("pw-a", "npx", &["@playwright/mcp@latest"]),
1032            configured("pw-b", "npx", &["@playwright/mcp@latest"]),
1033        ];
1034        let label = resolve_infra_label(
1035            "npm exec @playwright/mcp@latest --cdp-endpoint http://192.0.2.10:9222",
1036            &servers,
1037        )
1038        .expect("playwright");
1039        assert_eq!(label.name, "playwright");
1040    }
1041
1042    #[test]
1043    fn resolve_infra_label_names_local_node_script_by_configured_name() {
1044        // A locally-launched server fits no package-naming convention, so
1045        // `classify_known_infra` can't name it. The configured match must be
1046        // tried FIRST or the row degrades to a raw-invocation generic process.
1047        let servers = vec![configured("airmail", "node", &["/Users/x/airmail/dist/server.js"])];
1048        let label =
1049            resolve_infra_label("node /Users/x/airmail/dist/server.js", &servers).expect("airmail");
1050        assert_eq!(label.name, "airmail");
1051    }
1052
1053    #[test]
1054    fn resolve_infra_label_is_none_for_non_mcp_process() {
1055        // A non-MCP process stays unclassified even when servers are configured.
1056        assert!(resolve_infra_label("rustc --crate-name forge_tui", &playwright_pair()).is_none());
1057    }
1058
1059    #[test]
1060    fn resolve_infra_label_without_configured_matches_package_name() {
1061        // No configured servers -> identical to classify_known_infra.
1062        assert_eq!(
1063            resolve_infra_label(
1064                "npm exec @playwright/mcp@latest --cdp-endpoint http://192.0.2.10:9222",
1065                &[],
1066            )
1067            .expect("playwright")
1068            .name,
1069            "playwright"
1070        );
1071    }
1072
1073    /// The live airmail shape. Its config `exec`s a downloaded shim, so the
1074    /// process image shares no text with the command it launched from - the
1075    /// case the elimination join exists for. Captured via `ps -axww`.
1076    const SHIM: &str = "node /var/folders/0q/q18_z8w555v6z6q3q_3fc_t00000gn/T/tmp.9Vg/shim.mjs";
1077
1078    fn root(pid: u32, cmdline: &str) -> RootProcess<'_> {
1079        RootProcess { pid, cmdline, wire_matched: false }
1080    }
1081
1082    /// airmail's real config, plus the three servers that DO text-match.
1083    fn airmail_config() -> Vec<ConfiguredMcpServer> {
1084        vec![
1085            configured(
1086                "airmail",
1087                "sh",
1088                &["-c", "shim=$(mktemp -d)/shim.mjs && exec node \"$shim\""],
1089            ),
1090            configured("context7", "npx", &["-y", "@upstash/context7-mcp"]),
1091        ]
1092    }
1093
1094    fn connected(names: &[&str]) -> Vec<McpServerStatus> {
1095        names.iter().map(|name| mcp_status(name, None)).collect()
1096    }
1097
1098    #[test]
1099    fn elects_the_sole_unmatched_connected_server_for_the_sole_candidate() {
1100        // context7's process text-matches, so it claims its server and drops
1101        // out of both sides; airmail and the shim are the only leftovers.
1102        let elected = elect_unmatched_server(
1103            &connected(&["airmail", "context7"]),
1104            &airmail_config(),
1105            &[root(100, "npm exec @upstash/context7-mcp"), root(200, SHIM)],
1106        );
1107        assert_eq!(elected, Some((200, "airmail".to_owned())));
1108    }
1109
1110    #[test]
1111    fn declines_when_a_second_server_is_unmatched() {
1112        // Two unmatched servers and one leftover process: which one the
1113        // process belongs to is unknowable, so no row gets named.
1114        let elected = elect_unmatched_server(
1115            &connected(&["airmail", "context7"]),
1116            &airmail_config(),
1117            &[root(200, SHIM)],
1118        );
1119        assert_eq!(elected, None);
1120    }
1121
1122    #[test]
1123    fn declines_when_a_second_candidate_process_is_unmatched() {
1124        let elected = elect_unmatched_server(
1125            &connected(&["airmail", "context7"]),
1126            &airmail_config(),
1127            &[
1128                root(100, "npm exec @upstash/context7-mcp"),
1129                root(200, SHIM),
1130                root(300, "node /Users/x/other-service.mjs"),
1131            ],
1132        );
1133        assert_eq!(elected, None);
1134    }
1135
1136    #[test]
1137    fn declines_a_server_that_has_not_handshaken() {
1138        // Without the Connected gate a crashed server would pair with any
1139        // leftover process - labelling an unrelated build as airmail.
1140        let mut servers = connected(&["airmail", "context7"]);
1141        servers[0].status = forge_primitives::McpServerConnectionStatus::Pending;
1142        let elected = elect_unmatched_server(
1143            &servers,
1144            &airmail_config(),
1145            &[root(100, "npm exec @upstash/context7-mcp"), root(200, SHIM)],
1146        );
1147        assert_eq!(elected, None);
1148    }
1149
1150    #[test]
1151    fn a_transient_tool_process_does_not_spoil_the_election() {
1152        // The stability property. An `rg` from a Grep call is alive at scan
1153        // time and is not interpreter-shaped; counting it would decline the
1154        // pairing and flicker airmail's row back to a raw path every time
1155        // the user searched.
1156        let elected = elect_unmatched_server(
1157            &connected(&["airmail", "context7"]),
1158            &airmail_config(),
1159            &[
1160                root(100, "npm exec @upstash/context7-mcp"),
1161                root(200, SHIM),
1162                root(300, "rg --json -e pattern /Users/x/Projects"),
1163            ],
1164        );
1165        assert_eq!(elected, Some((200, "airmail".to_owned())));
1166    }
1167
1168    #[test]
1169    fn a_shell_spawned_child_of_claude_does_not_spoil_the_election() {
1170        // Claude spawns its Bash-tool wrapper AND its `type: "command"` hooks
1171        // through a shell, both as direct children of the same pid - so any
1172        // shell in INTERPRETERS would put every tool call and hook in the pool,
1173        // declining the pairing and dropping the row out of the MCP tier while
1174        // it runs. Re-adding one fails this test.
1175        let wrapper = "/bin/bash -c source /Users/x/.claude/snap.sh 2>/dev/null || true && eval 'cargo build' < /dev/null && pwd -P >| /tmp/claude-ab12-cwd";
1176        let elected = elect_unmatched_server(
1177            &connected(&["airmail", "context7"]),
1178            &airmail_config(),
1179            &[root(100, "npm exec @upstash/context7-mcp"), root(200, SHIM), root(400, wrapper)],
1180        );
1181        assert_eq!(elected, Some((200, "airmail".to_owned())));
1182    }
1183
1184    #[test]
1185    fn a_wire_matched_process_is_never_a_candidate() {
1186        // A foreground Bash already owns its row; it must not stand in for a
1187        // server even when its image is interpreter-shaped.
1188        let elected = elect_unmatched_server(
1189            &connected(&["airmail"]),
1190            &airmail_config()[..1],
1191            &[RootProcess { pid: 200, cmdline: "node build.mjs", wire_matched: true }],
1192        );
1193        assert_eq!(elected, None);
1194    }
1195
1196    #[test]
1197    fn configured_mcp_servers_extracts_command_only_server() {
1198        // A stdio server with a command and no args extracts with empty args.
1199        let servers =
1200            vec![mcp_status("fs", Some(serde_json::json!({"type": "stdio", "command": "mcp-fs"})))];
1201        assert_eq!(configured_mcp_servers(&servers), vec![configured("fs", "mcp-fs", &[])]);
1202    }
1203
1204    #[test]
1205    fn resolve_infra_label_matches_bare_command_server() {
1206        // A server configured as a bare command (no args) matches a process
1207        // whose cmdline contains that command, taking the configured name.
1208        let servers = vec![configured("ctx-custom", "context7-mcp", &[])];
1209        let label = resolve_infra_label("npm exec @upstash/context7-mcp", &servers).expect("ctx");
1210        assert_eq!(label.name, "ctx-custom");
1211    }
1212
1213    #[test]
1214    fn resolve_infra_label_near_miss_falls_back_to_package() {
1215        // A single configured server on the right package but a DIFFERENT
1216        // --cdp-endpoint than the process: the endpoint token is absent, so
1217        // the every-token match fails and the package name wins. An
1218        // any-token match would wrongly pick "pw-remote".
1219        let servers = vec![configured(
1220            "pw-remote",
1221            "npx",
1222            &["-y", "@playwright/mcp@latest", "--cdp-endpoint", "http://192.0.2.10:9222"],
1223        )];
1224        let label = resolve_infra_label(
1225            "npm exec @playwright/mcp@latest --cdp-endpoint http://127.0.0.1:9222",
1226            &servers,
1227        )
1228        .expect("pw");
1229        assert_eq!(label.name, "playwright");
1230    }
1231
1232    #[test]
1233    fn resolve_infra_label_strips_plugin_namespace_from_configured_name() {
1234        // A plugin-provided server is namespaced `plugin:<plugin>:<server>`
1235        // by the CLI (the `context7` plugin's `.mcp.json` names its server
1236        // `context7`, launched as `npx -y @upstash/context7-mcp`), so the
1237        // configured name alone reads worse than the package-derived one.
1238        let servers =
1239            vec![configured("plugin:context7:context7", "npx", &["-y", "@upstash/context7-mcp"])];
1240        assert_eq!(
1241            resolve_infra_label("npm exec @upstash/context7-mcp", &servers).expect("ctx").name,
1242            "context7",
1243        );
1244    }
1245
1246    #[test]
1247    fn resolve_infra_label_keeps_plain_configured_name_with_a_colon() {
1248        // Only the `plugin:<plugin>:<server>` shape is stripped; a name that
1249        // merely contains a colon survives verbatim.
1250        let servers = vec![configured("ctx:7", "npx", &["@upstash/context7-mcp"])];
1251        assert_eq!(
1252            resolve_infra_label("npm exec @upstash/context7-mcp", &servers).expect("ctx").name,
1253            "ctx:7",
1254        );
1255    }
1256
1257    #[test]
1258    fn resolve_infra_label_empty_config_does_not_match_everything() {
1259        // A degenerate config (empty command, no args) must not match every
1260        // process; it falls back to the package name.
1261        let servers = vec![configured("blank", "", &[])];
1262        let label = resolve_infra_label("npm exec @upstash/context7-mcp", &servers).expect("ctx");
1263        assert_eq!(label.name, "context7");
1264    }
1265}