trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
//! Auto-discovery of Claude Code sessions, in tmux and in native processes.
//!
//! Why: `GET /sessions` only reports daemon-managed sessions, but operators run
//! `claude`, `claude-code`, `claude-mpm`, or `tm` in tmux panes the daemon never
//! created — and, more commonly, in native Terminal.app windows that have no
//! tmux at all. Those sessions were invisible until manually `/adopt`-ed.
//! Scanning tmux *and* the process table at startup (and on demand) brings them
//! under oversight automatically.
//! What: [`discover_claude_sessions`] runs `tmux list-panes -a`;
//! [`discover_native_processes`] runs `ps aux` and matches `claude`/`claude-code`
//! processes, resolving each one's working directory via `lsof`.
//! [`discover_all`] runs both. [`is_claude_command`] / [`is_claude_process`] are
//! the pure predicates the scans key on.
//! Test: `cargo test -p trusty-mpm-daemon discovery` covers the predicates and
//! the line parsers without spawning tmux, ps, or lsof.

use std::collections::HashSet;
use std::path::PathBuf;
use std::process::Command;

use crate::core::own_binary_names::OWN_BINARY_NAMES;
use crate::core::session::{ControlModel, Session, SessionHost, SessionId, SessionStatus};

use super::state::DaemonState;
use super::tmux::TmuxDriver;

/// Process names, other than this crate's own [`OWN_BINARY_NAMES`], that mark
/// a tmux pane as running Claude Code.
///
/// Why: auto-discovery must recognise the handful of *other* binaries an
/// operator runs a Claude Code session under; keeping the list in one place
/// makes the predicate auditable. This crate's own binary names come from
/// [`OWN_BINARY_NAMES`] instead of being hand-copied here — a second
/// hand-copy of that list is exactly what caused #4058 (a `trusty-mpm`-
/// launched session was invisible to discovery because this list only had
/// `"tm"`).
/// What: substrings matched case-insensitively against `pane_current_command`.
const OTHER_CLAUDE_LAUNCHER_COMMANDS: &[&str] = &["claude", "claude-code", "claude-mpm"];

/// True when `command` names a Claude Code process worth adopting.
///
/// Why: the discovery scan must decide, per pane, whether it hosts Claude Code;
/// a pure predicate keeps that decision unit-testable.
/// What: case-insensitively matches `command` against
/// [`OTHER_CLAUDE_LAUNCHER_COMMANDS`] (substring — `claude`/`claude-code`/
/// `claude-mpm` may appear with extra invocation args) and [`OWN_BINARY_NAMES`]
/// (EXACT equality, #4058 review round 1 MEDIUM finding 1) — `pane_current_command`
/// is always a bare process name here, never a full command line, so a
/// substring check on this crate's own names would also match a sibling
/// binary whose name extends ours (the retired `trusty-mpm-gui` was one),
/// spuriously auto-adopting its pane. Exact match closes that window without
/// reopening the original #4058 defect.
/// Test: `is_claude_command_matches_known`, `is_claude_command_rejects_others`
/// (covers `trusty-mpm-sibling`), `is_claude_command_matches_trusty_mpm` (#4058).
pub fn is_claude_command(command: &str) -> bool {
    let lower = command.trim().to_lowercase();
    if lower.is_empty() {
        return false;
    }
    OWN_BINARY_NAMES.iter().any(|c| lower == *c)
        || OTHER_CLAUDE_LAUNCHER_COMMANDS
            .iter()
            .any(|c| lower.contains(c))
}

/// Parse one `tmux list-panes -a` line into `(session_name, pane_command)`.
///
/// Why: the scan formats panes as `#{session_name} #{pane_current_command}`;
/// isolating the split keeps [`discover_claude_sessions`] readable and lets the
/// parser be tested without tmux.
/// What: splits on the first whitespace run; returns `None` for an empty or
/// single-field line.
/// Test: `parse_pane_line_splits_fields`.
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()))
}

/// Outcome of one auto-discovery scan.
///
/// Why: the `POST /sessions/discover` handler and the Telegram/TUI `/discover`
/// commands report how many sessions the scan adopted; bundling the count with
/// the names lets callers log the specifics.
/// What: the number of newly-registered sessions and their tmux names.
/// Test: covered indirectly by `discover_claude_sessions` against a daemon with
/// no tmux (yields an empty result).
#[derive(Debug, Default, PartialEq, Eq)]
pub struct DiscoveryResult {
    /// Number of tmux sessions newly registered by the scan.
    pub adopted: usize,
    /// Friendly tmux names of the newly-registered sessions.
    pub sessions: Vec<String>,
    /// Why the scan did not run at all, when it did not (#5784).
    ///
    /// Why: a REFUSED scan and a scan that ran and found nothing were
    /// byte-identical — both `{adopted: 0, sessions: []}` — so
    /// `POST /sessions/discover` answered "nothing to adopt" to a caller whose
    /// real answer is "this daemon is not allowed to look".
    /// What: `None` means the scan ran. `Some` carries the gate's own reason,
    /// naming the mismatch and the variable that lifts it.
    /// Test: `scratch_home_daemon_does_not_spawn_tmux`.
    pub skipped: Option<String>,
}

/// Scan existing tmux sessions and register any running Claude Code.
///
/// Why: sessions the daemon did not create are invisible to `GET /sessions`
/// until adopted; running this at startup (and on demand via the API) keeps the
/// registry honest without operator intervention.
/// What: runs `tmux list-panes -a -F "#{session_name} #{pane_current_command}"`,
/// and for every pane whose command satisfies [`is_claude_command`], registers
/// a [`Session`] for the owning tmux session — unless one is already registered
/// under that `tmux_name`. tmux being absent yields an empty [`DiscoveryResult`]
/// rather than an error.
/// Test: `is_claude_command_*` cover the predicate; the tmux-absent path is
/// exercised by `discover_with_no_tmux_is_empty`.
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();
        }
    };

    // Already-registered tmux names — never register the same session twice.
    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;
        }
        // Register a tmux-hosted session under the discovered tmux name. The
        // workdir is unknown from the pane listing, so it is left empty — a
        // later snapshot or hook event can enrich it.
        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
}

/// True when a `ps aux` command line is a Claude Code CLI session worth adopting.
///
/// Why: native discovery must distinguish the actual `claude` CLI process from
/// the dozens of unrelated processes whose command line merely mentions
/// "claude" — MCP servers (`python3 -m claude_mpm...`), the desktop app
/// (`/Applications/Claude.app/...`), trusty-mpm itself, or a piped `grep claude`.
/// A pure predicate keeps that decision unit-testable without spawning `ps`.
/// What: matches solely on the *executable basename* — the last path component
/// of the first whitespace-separated token — which must be exactly `claude` or
/// `claude-code`. This is both precise and robust: `/usr/local/bin/claude
/// --system-prompt-file …/.claude-mpm/PM.md` matches (the `.claude-mpm` *path*
/// no longer falsely disqualifies a real session), while `node /opt/claude/cli.js`,
/// `python3 -m claude_mpm…`, `grep claude`, and `uv tool … claude-mpm` do not —
/// their executables are `node`/`python3`/`grep`/`uv`, not `claude`.
/// Test: `is_claude_process_matches`, `is_claude_process_rejects_noise`.
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"
}

/// Parse one `ps aux` line into `(pid, command_line)`.
///
/// Why: `ps aux` columns are `USER PID %CPU %MEM VSZ RSS TT STAT STARTED TIME
/// COMMAND`; isolating the split keeps [`discover_native_processes`] readable
/// and lets the parser be tested without `ps`.
/// What: takes field 2 as the pid and everything from field 11 onward as the
/// command line; returns `None` for the header row or a malformed line.
/// Test: `parse_ps_line_extracts_pid_and_command`.
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()?;
    // Skip %CPU %MEM VSZ RSS TT STAT STARTED TIME (8 columns) to reach COMMAND.
    for _ in 0..8 {
        fields.next()?;
    }
    let command: String = fields.collect::<Vec<_>>().join(" ");
    if command.is_empty() {
        return None;
    }
    Some((pid, command))
}

/// Parse `lsof -Ffpn` field output into a `pid -> cwd` map.
///
/// Why: `lsof -F` emits one field per line — `p<pid>` starts a process block,
/// `f<fd>` names a file descriptor, and `n<path>` carries that descriptor's
/// path. Even with `-d cwd`, `lsof` still reports the `txt` (executable)
/// descriptor, so the parser must record only the `n` line that follows an
/// `fcwd` marker — otherwise it would mistake the executable path for the cwd.
/// A pure parser keeps this unit-testable without spawning `lsof`.
/// What: walks the lines, tracking the current `p<pid>` and whether the most
/// recent `f` field was `cwd`; records `n<path>` only while inside an `fcwd`
/// block.
/// Test: `parse_lsof_cwds_maps_pid_to_path`.
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
}

/// How many pids to pass to one `lsof -p` invocation.
///
/// Why: macOS `lsof` mis-handles a very long `-p` comma list — past a few dozen
/// pids it stops honouring later entries and reports a wrong (often the first
/// process's) working directory for the rest. Chunking keeps every `-p` list
/// short enough for `lsof` to parse correctly, while still being far cheaper
/// than one `lsof` per pid.
const LSOF_PID_CHUNK: usize = 32;

/// Resolve a single chunk of pids' working directories via one `lsof` call.
///
/// Why: factored out of [`process_cwds`] so the chunking loop stays readable.
/// What: runs `lsof -a -p <pid1>,...,<pidN> -d cwd -Ffpn` for the chunk and
/// returns a `pid -> cwd` map. The `-a` flag ANDs the `-p` and `-d` selections
/// (`lsof` ORs them by default); the `f` field lets [`parse_lsof_cwds`] tell the
/// `cwd` descriptor from the `txt` one. `lsof` being unavailable yields an empty
/// map.
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()
    {
        // `lsof` exits non-zero when some pids have vanished, but still prints
        // the rows it could read — so parse stdout regardless of exit status.
        Ok(out) => parse_lsof_cwds(&String::from_utf8_lossy(&out.stdout)),
        Err(_) => {
            tracing::info!("`lsof` unavailable; native session workdirs unknown");
            std::collections::HashMap::new()
        }
    }
}

/// Resolve many processes' working directories with chunked `lsof` calls.
///
/// Why: a native Claude Code process's workdir is the project it operates on,
/// but calling `lsof` once per pid is pathologically slow (Claude Code spawns
/// hundreds of `claude` subprocesses) and one `lsof` over a huge `-p` list is
/// unreliable on macOS. Chunking strikes the balance: a handful of `lsof` calls,
/// each with a short, correctly-parsed pid list.
/// What: splits `pids` into [`LSOF_PID_CHUNK`]-sized chunks, runs
/// [`process_cwds_chunk`] on each, and merges the results into one `pid -> cwd`
/// map. An empty `pids` slice yields an empty map.
/// Test: `parse_lsof_cwds_maps_pid_to_path` covers the per-chunk parsing.
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
}

/// Last path component of a working directory, defaulting to `"session"`.
///
/// Why: the native session's friendly name is `<cwd-basename>-<pid>`; a missing
/// or root cwd must still yield a usable label.
/// What: returns the final non-empty path segment, or `"session"` as fallback.
/// Test: `cwd_basename_extracts_last_component`.
fn cwd_basename(cwd: &str) -> String {
    cwd.trim_end_matches('/')
        .rsplit('/')
        .find(|s| !s.is_empty())
        .unwrap_or("session")
        .to_string()
}

/// Decide whether (and how) to register one native-discovery candidate.
///
/// Why (issue #3596): this is the crux of the phantom-identity defect,
/// factored out of [`discover_native_processes`] so the identity-linking and
/// de-dup rules are unit-testable without spawning real `ps`/`lsof` processes
/// — the same pattern [`DaemonState::reap_against`](super::state::DaemonState::reap_against)
/// uses to keep its pure decision logic hermetically testable. A process
/// whose resolved `cwd` matches a cwd already tracked by the `SessionManager`
/// store (`managed_cwds`) already has a durable, correlated identity there
/// (e.g. via `TM_MANAGED_SESSION_ID`) — registering it again here under a
/// freshly-minted [`SessionId::new()`] would give the SAME process a second,
/// uncorrelated identity, which is exactly the defect this closes.
/// What: returns `None` when the candidate must NOT get a new legacy
/// [`Session`] — either because `cwd` is already SM-managed, or because it
/// collides with an existing legacy registration by workdir or friendly name
/// (the pre-existing de-dup rules, unchanged); otherwise returns
/// `Some(session)`, a freshly-built [`Session`] tagged [`SessionHost::Native`]
/// with `project_path` populated from `cwd` (issue #3596's second symptom:
/// before this fix `project_dir` was always `None`, so
/// [`DaemonState::list_sessions_for_project`](super::state::DaemonState::list_sessions_for_project)'s
/// exact-equality filter could never match ANY natively-discovered session).
/// Test: `decide_native_registration_skips_already_managed_cwd`,
/// `decide_native_registration_sets_project_path`,
/// `decide_native_registration_dedupes_by_workdir_and_name`.
fn decide_native_registration(
    pid: u32,
    cwd: &str,
    managed_cwds: &HashSet<String>,
    registered_workdirs: &HashSet<String>,
    seen_workdirs: &mut HashSet<String>,
    registered_names: &HashSet<String>,
) -> Option<Session> {
    // Already has a durable managed identity — never mint a second one.
    if !cwd.is_empty() && managed_cwds.contains(cwd) {
        return None;
    }
    // Collapse the many `claude` subprocesses of one session: a directory
    // already claimed (this scan or a prior one) yields no new session.
    if !cwd.is_empty()
        && (registered_workdirs.contains(cwd) || !seen_workdirs.insert(cwd.to_string()))
    {
        return None;
    }
    let name = format!("{}-{}", cwd_basename(cwd), pid);
    if registered_names.contains(&name) {
        return None;
    }
    // Native processes are not tmux-hosted; tag the control model `Pty` (an
    // OS-owned terminal) and the origin `Native`.
    let mut session = Session::new(SessionId::new(), cwd.to_string(), ControlModel::Pty, None);
    session.tmux_name = name;
    session.status = SessionStatus::Active;
    session.origin = SessionHost::Native;
    session.pid = Some(pid);
    // Populate `project_path` so cwd-scoped lookup (`list_sessions_for_project`,
    // `GET /sessions?project=`, `tm sessions list`) can actually find this
    // session — see this function's doc for why it was structurally `None`
    // before.
    if !cwd.is_empty() {
        session.project_path = Some(PathBuf::from(cwd));
    }
    Some(session)
}

/// Scan the OS process table and register native Claude Code processes.
///
/// Why: most Claude Code sessions run in native Terminal.app windows, not tmux;
/// `discover_claude_sessions` alone leaves them invisible. Scanning `ps aux`
/// brings them into `GET /sessions` like any other session.
/// What: runs `ps aux`, collects every pid satisfying [`is_claude_process`],
/// resolves all their working directories in one batched `lsof` call, and —
/// via [`decide_native_registration`] — registers one [`Session`] per
/// distinct working directory NOT already tracked by the `SessionManager`
/// store, tagged [`SessionHost::Native`] with `name = "<cwd-basename>-<pid>"`
/// and `project_path` set from its cwd. Claude Code spawns several `claude`
/// subprocesses per session, so processes are de-duplicated by working
/// directory (the lowest pid wins). Directories already registered as a
/// session — by pid, by name, by an existing native session's workdir, or by
/// an already-managed `SessionManager` record's cwd (issue #3596) — are
/// skipped. `ps` being unavailable yields an empty [`DiscoveryResult`].
/// Test: `is_claude_process_*`, `parse_ps_line_extracts_pid_and_command`,
/// `parse_lsof_cwds_maps_pid_to_path`, `cwd_basename_extracts_last_component`,
/// and the `decide_native_registration_*` tests cover the pure pieces.
pub async 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);

    // Already-registered pids, friendly names, and native workdirs — never
    // register the same session twice across repeated scans.
    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();

    // Cwds already tracked by the SessionManager — a process rooted at one of
    // these already has a durable managed identity (issue #3596); native
    // discovery must not mint a second, uncorrelated one for it. Only
    // non-terminal records count: a Decommissioned/Deleted record's workspace
    // is gone, so its stale cwd must never block a genuinely new session.
    let managed_cwds: HashSet<String> = state
        .session_manager()
        .await
        .list()
        .await
        .into_iter()
        .filter(|r| !r.state.is_terminal())
        .map(|r| r.cwd.to_string_lossy().into_owned())
        .collect();

    // Pass 1: collect candidate pids (sorted, so the lowest pid wins per cwd).
    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();

    // Pass 2: resolve every candidate's working directory in one `lsof` call.
    let cwds = process_cwds(&candidate_pids);

    // Pass 3: register one native session per distinct, not-already-managed
    // working directory.
    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();
        let Some(session) = decide_native_registration(
            pid,
            &cwd,
            &managed_cwds,
            &registered_workdirs,
            &mut seen_workdirs,
            &registered_names,
        ) else {
            continue;
        };
        let name = session.tmux_name.clone();
        state.register_session(session);
        tracing::info!("auto-discovered native Claude Code process: {name} (pid {pid})");
        result.adopted += 1;
        result.sessions.push(name);
    }
    result
}

/// Run every discovery scan (tmux panes and native processes).
///
/// Why: startup and `POST /sessions/discover` must surface Claude Code wherever
/// it runs; a single entry point keeps both scans in lockstep.
/// What: runs [`discover_claude_sessions`] then [`discover_native_processes`]
/// and merges their [`DiscoveryResult`]s.
///
/// Scratch-environment gate (#5784): both scans read state that `$HOME` does
/// not isolate — the tmux server and the host process table — and adopting
/// what they find makes the operator's real sessions this daemon's to resume,
/// reap, and kill on shutdown, with the adopted record carrying the real
/// project's working directory. `discover_claude_sessions` is already covered
/// by [`super::tmux::TmuxDriver::discover`]'s own gate; the `ps`/`lsof` scan
/// touches no tmux, so the check is repeated here to cover it.
///
/// The check here is the ROOT-AWARE one (#6348): this function holds the
/// daemon's framework root, and a daemon isolated by that root alone — real
/// `$HOME`, scratch data dir — passed the `$HOME`-only gate and adopted the
/// operator's live panes and processes.
/// Test: `scratch_home_daemon_does_not_spawn_tmux`; the merge is exercised by
/// `discover_all_merges_results`.
pub async fn discover_all(state: &DaemonState) -> DiscoveryResult {
    let access = crate::core::host_state_gate::host_state_access_for_root(state.framework_root());
    if let Some(reason) = access.skip_reason() {
        tracing::warn!("#5784: session auto-discovery skipped — {reason}");
        return DiscoveryResult {
            skipped: Some(reason),
            ..Default::default()
        };
    }
    let mut result = discover_claude_sessions(state);
    let native = discover_native_processes(state).await;
    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() {
        // #4058 review round 1 MEDIUM finding 1: a sibling binary whose name
        // extends ours (`trusty-mpm-gui` was one) — a substring match on
        // `OWN_BINARY_NAMES` would wrongly adopt it.
        for cmd in [
            "bash",
            "zsh",
            "vim",
            "tmux",
            "node",
            "trusty-mpm-sibling",
            "",
            "  ",
        ] {
            assert!(!is_claude_command(cmd), "expected `{cmd}` not to match");
        }
    }

    /// #4058: a session launched via the `trusty-mpm` `[[bin]]` target (the
    /// second binary `cargo install trusty-mpm` ships alongside `tm`, built
    /// from the identical source) must be recognised by auto-discovery just
    /// like a `tm`-launched session — before this fix, `CLAUDE_COMMANDS` only
    /// listed `"tm"` and `trusty-mpm`-launched sessions were silently
    /// invisible to `GET /sessions`.
    #[test]
    fn is_claude_command_matches_trusty_mpm() {
        for cmd in ["trusty-mpm", "TRUSTY-MPM", "  trusty-mpm  "] {
            assert!(is_claude_command(cmd), "expected `{cmd}` 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())),
        );
        // Extra whitespace between fields is tolerated.
        assert_eq!(
            parse_pane_line("  proj   claude-code  "),
            Some(("proj".to_string(), "claude-code".to_string())),
        );
        // A line with no command field is rejected.
        assert_eq!(parse_pane_line("lonely"), None);
        assert_eq!(parse_pane_line(""), None);
    }

    #[test]
    fn discover_with_no_tmux_is_empty() {
        // In CI tmux is typically absent (or hosts no Claude panes); discovery
        // must return a well-formed empty result, never panic.
        let state = DaemonState::new();
        let result = discover_claude_sessions(&state);
        assert_eq!(result.adopted, result.sessions.len());
    }

    #[test]
    fn is_claude_process_matches() {
        // The executable basename must be exactly `claude` / `claude-code`.
        for cmd in [
            "claude",
            "claude-code",
            "/usr/local/bin/claude --resume",
            "/opt/homebrew/bin/claude-code",
            "CLAUDE --dangerously-skip-permissions",
            // Regression: a real PM session whose `--system-prompt-file` path
            // contains `.claude-mpm` must still match — the path must not
            // disqualify the session.
            "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 [
            // The executable is not `claude` — basename is grep/tm/node/etc.
            "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())),
        );
        // The `ps aux` header row has no numeric pid and must be rejected.
        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() {
        // `lsof -Ffpn` emits `p<pid>`, then `f<fd>` / `n<path>` pairs. Only the
        // path following an `fcwd` marker is the working directory; the `ftxt`
        // path (the executable) must be ignored.
        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"),
        );
        // A process with only a `txt` descriptor contributes no cwd entry.
        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");
    }

    #[tokio::test]
    async fn discover_native_with_no_processes_is_well_formed() {
        // `ps` is present in CI but is unlikely to host a Claude Code process;
        // the scan must return a well-formed result and never panic.
        let state = DaemonState::new();
        let result = discover_native_processes(&state).await;
        assert_eq!(result.adopted, result.sessions.len());
    }

    #[tokio::test]
    async fn discover_all_merges_results() {
        let state = DaemonState::new();
        let result = discover_all(&state).await;
        assert_eq!(result.adopted, result.sessions.len());
    }

    #[test]
    fn decide_native_registration_skips_already_managed_cwd() {
        // Issue #3596 symptom 1: a process whose cwd is already tracked by the
        // SessionManager store already has a durable, correlated identity
        // there — it must NOT get a second, uncorrelated Session minted for
        // it here. This is the phantom-identity defect's core fix; against
        // the pre-fix code (no `managed_cwds` parameter existed at all) this
        // candidate was ALWAYS registered under a fresh `SessionId::new()`.
        let managed_cwds = HashSet::from(["/work/managed-worktree".to_string()]);
        let registered_workdirs = HashSet::new();
        let mut seen_workdirs = HashSet::new();
        let registered_names = HashSet::new();

        let result = decide_native_registration(
            4242,
            "/work/managed-worktree",
            &managed_cwds,
            &registered_workdirs,
            &mut seen_workdirs,
            &registered_names,
        );

        assert!(
            result.is_none(),
            "an already-managed cwd must not mint a phantom Session"
        );
    }

    #[test]
    fn decide_native_registration_sets_project_path() {
        // Issue #3596 symptom 2: `project_path` must be populated from the
        // resolved cwd so `list_sessions_for_project` can find this session.
        // Against the pre-fix code this was always `None` (`Session::new(...,
        // None)`), so this assertion fails against pre-fix behaviour.
        let managed_cwds = HashSet::new();
        let registered_workdirs = HashSet::new();
        let mut seen_workdirs = HashSet::new();
        let registered_names = HashSet::new();

        let session = decide_native_registration(
            4242,
            "/work/ad-hoc-project",
            &managed_cwds,
            &registered_workdirs,
            &mut seen_workdirs,
            &registered_names,
        )
        .expect("a not-yet-registered, not-managed cwd must be registered");

        assert_eq!(
            session.project_path,
            Some(PathBuf::from("/work/ad-hoc-project"))
        );
        assert_eq!(session.origin, SessionHost::Native);
        assert_eq!(session.pid, Some(4242));
    }

    #[test]
    fn decide_native_registration_dedupes_by_workdir_and_name() {
        // The pre-existing de-dup rules (by registered workdir, and by
        // collapsing repeat candidates at the same cwd within one scan) must
        // still hold unchanged.
        let managed_cwds = HashSet::new();
        let registered_workdirs = HashSet::from(["/work/already-native".to_string()]);
        let mut seen_workdirs = HashSet::new();
        let registered_names = HashSet::new();
        assert!(
            decide_native_registration(
                1,
                "/work/already-native",
                &managed_cwds,
                &registered_workdirs,
                &mut seen_workdirs,
                &registered_names,
            )
            .is_none(),
            "a workdir already registered as a native session must be skipped"
        );

        // A second candidate at a fresh cwd claims it; a third candidate at
        // the SAME cwd (a sibling `claude` subprocess) is collapsed away.
        let managed_cwds = HashSet::new();
        let registered_workdirs = HashSet::new();
        let mut seen_workdirs = HashSet::new();
        let registered_names = HashSet::new();
        assert!(
            decide_native_registration(
                2,
                "/work/fresh",
                &managed_cwds,
                &registered_workdirs,
                &mut seen_workdirs,
                &registered_names,
            )
            .is_some()
        );
        assert!(
            decide_native_registration(
                3,
                "/work/fresh",
                &managed_cwds,
                &registered_workdirs,
                &mut seen_workdirs,
                &registered_names,
            )
            .is_none(),
            "a second candidate at an already-seen cwd must be collapsed"
        );
    }

    #[test]
    fn native_discovery_populates_project_path_for_cwd_scoped_lookup() {
        // Issue #3596 symptom 2, end-to-end: register a native-origin session
        // exactly as `discover_native_processes` now would, then confirm
        // `list_sessions_for_project` — the same lookup `GET
        // /sessions?project=` and `tm sessions list` use (see
        // `bin/tm/commands/session.rs`'s `SessionAction::List`) — actually
        // finds it. Before the fix `project_path` was always `None`, making
        // this lookup structurally incapable of matching ANY
        // natively-discovered session; this test fails against that pre-fix
        // behaviour.
        let state = DaemonState::new();
        let cwd = "/work/native-project";
        let session = decide_native_registration(
            9999,
            cwd,
            &HashSet::new(),
            &HashSet::new(),
            &mut HashSet::new(),
            &HashSet::new(),
        )
        .expect("fresh cwd must register");
        let id = session.id;
        state.register_session(session);

        let found = state.list_sessions_for_project(std::path::Path::new(cwd));
        assert_eq!(found.len(), 1);
        assert_eq!(found[0].id, id);
    }
}