@team-agent/installer 0.5.85 → 0.5.89

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +2 -2
  4. package/crates/team-agent/src/cli/emit.rs +56 -12
  5. package/crates/team-agent/src/cli/mod.rs +0 -1
  6. package/crates/team-agent/src/cli/status_port/brief.rs +187 -7
  7. package/crates/team-agent/src/compiler/tests.rs +13 -24
  8. package/crates/team-agent/src/compiler.rs +5 -57
  9. package/crates/team-agent/src/leader/start.rs +0 -2
  10. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +0 -1
  11. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +0 -1
  12. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +0 -6
  13. package/crates/team-agent/src/lifecycle/launch/identity.rs +39 -22
  14. package/crates/team-agent/src/lifecycle/launch/layout.rs +0 -1
  15. package/crates/team-agent/src/lifecycle/launch/leader_context.rs +0 -1
  16. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +0 -58
  17. package/crates/team-agent/src/lifecycle/launch/ownership.rs +0 -1
  18. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +0 -3
  19. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +14 -24
  20. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +0 -1
  21. package/crates/team-agent/src/lifecycle/launch/readiness.rs +0 -1
  22. package/crates/team-agent/src/lifecycle/launch/spawn.rs +5 -9
  23. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +0 -1
  24. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +0 -1
  25. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +0 -1
  26. package/crates/team-agent/src/lifecycle/launch.rs +0 -9
  27. package/crates/team-agent/src/lifecycle/restart/common.rs +5 -9
  28. package/crates/team-agent/src/lifecycle/tests/b0_reader_hideone_audit_contract.rs +3 -3
  29. package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +1 -3
  30. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +42 -46
  31. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +30 -44
  32. package/crates/team-agent/src/lifecycle/tests/core_034_real_red.rs +32 -77
  33. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +2 -13
  34. package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +0 -1
  35. package/crates/team-agent/src/lifecycle/tests/pi_command_red.rs +0 -2
  36. package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +14 -85
  37. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +1 -1
  38. package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +0 -5
  39. package/crates/team-agent/src/lifecycle/tests/pi_session_delivery_red.rs +0 -1
  40. package/crates/team-agent/src/lifecycle/tests/restart_rebind_hotfix_252_red.rs +4 -4
  41. package/crates/team-agent/src/lifecycle/tests.rs +0 -1
  42. package/crates/team-agent/src/lifecycle/types.rs +0 -10
  43. package/crates/team-agent/src/lifecycle/worker_command_context.rs +4 -248
  44. package/crates/team-agent/src/model/enums.rs +17 -38
  45. package/crates/team-agent/src/model/mod.rs +0 -1
  46. package/crates/team-agent/src/model/spec.rs +31 -91
  47. package/crates/team-agent/src/provider/adapter.rs +48 -50
  48. package/crates/team-agent/src/provider/adapters/claude.rs +5 -30
  49. package/crates/team-agent/src/provider/adapters/codex.rs +5 -25
  50. package/crates/team-agent/src/provider/adapters/copilot.rs +8 -41
  51. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +4 -8
  52. package/crates/team-agent/src/provider/adapters/grok.rs +5 -58
  53. package/crates/team-agent/src/provider/adapters/mod.rs +5 -7
  54. package/crates/team-agent/src/provider/adapters/pi.rs +1 -56
  55. package/crates/team-agent/src/provider/bypass_flags.rs +1 -2
  56. package/crates/team-agent/src/provider/session/capture.rs +4 -4
  57. package/crates/team-agent/src/provider/tests/adapter.rs +7 -24
  58. package/crates/team-agent/src/provider/types.rs +1 -1
  59. package/crates/team-agent/src/provider/wire.rs +1 -1
  60. package/package.json +4 -4
  61. package/schemas/team.schema.json +4 -9
  62. package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +0 -784
  63. package/crates/team-agent/src/model/permissions.rs +0 -721
package/Cargo.lock CHANGED
@@ -581,7 +581,7 @@ dependencies = [
581
581
 
582
582
  [[package]]
583
583
  name = "team-agent"
584
- version = "0.5.85"
584
+ version = "0.5.89"
585
585
  dependencies = [
586
586
  "anyhow",
587
587
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.85"
12
+ version = "0.5.89"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -314,7 +314,7 @@ fn warn_ignored_owner_team_id(team_dir: &std::path::Path) {
314
314
  }
315
315
 
316
316
  /// `cmd_status`(`commands.py:90`)。CLI status 统一为只读七字段 brief;
317
- /// `--json` 与人读路径共享 nodeprobe-backed projection;`--summary`/`--detail` 保留参数兼容性但不暴露诊断。
317
+ /// `--json` 与人读路径共享 native tmux/process projection;`--summary`/`--detail` 保留参数兼容性但不暴露诊断。
318
318
  #[cfg(test)]
319
319
  pub(crate) fn cmd_status(args: &StatusArgs) -> Result<CmdResult, CliError> {
320
320
  cmd_status_for_team(args, args.team.as_deref())
@@ -357,7 +357,7 @@ pub fn cmd_status_for_team(args: &StatusArgs, team: Option<&str>) -> Result<CmdR
357
357
  }
358
358
  }
359
359
  // Status brief is deliberately independent of the legacy RuntimeSnapshot:
360
- // it performs one bounded nodeprobe sample and exposes exactly seven
360
+ // it performs one native tmux/process sample and exposes exactly seven
361
361
  // fields. `--summary` and `--detail` remain parser-compatible but do not
362
362
  // re-enable history, runtime diagnostics, or reminder text.
363
363
  if args.json {
@@ -417,7 +417,7 @@ fn command_help(command: Option<&str>) -> String {
417
417
  )
418
418
  .to_string(),
419
419
  Some("allow-peer-talk") => "usage: team-agent allow-peer-talk A B [--workspace WORKSPACE] [--json]".to_string(),
420
- Some("status") => "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]\n\n输出七字段:name/provider/runtime_status/activity/health/session_name/tmux_command;人读与 --json 使用同一投影。缺少可靠定位或 nodeprobe 证据时显示 unknown;tmux_command 可复制到对应目标。--summary/--detail 仅保留兼容性,不增加诊断字段。".to_string(),
420
+ Some("status") => "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]\n\n输出七字段:name/provider/runtime_status/activity/health/session_name/tmux_command;人读与 --json 使用同一投影。缺少可靠定位或原生 tmux/process 采样时显示 unknown;tmux_command 可复制到对应目标。--summary/--detail 仅保留兼容性,不增加诊断字段。".to_string(),
421
421
  Some("models") => "usage: team-agent models [--provider pi|cursor_agent] [QUERY|--search TEXT] [--json]\n\nLists exact provider model ids with case-insensitive multi-word search across provider, vendor, id, and display name. Cursor uses the local `agent --list-models` catalog.".to_string(),
422
422
  Some("leaders") => "usage: team-agent leaders [QUERY|--search TEXT] [--all|--stale] [--json] | --prune [--dry-run] [--json]\n\nLists LIVE leaders by default. Use --all to include retained STALE entries, --stale to inspect only STALE entries, QUERY or --search TEXT to match workspace/team/name fields, and --prune to remove only entries proven terminal by canonical state. --dry-run is valid only with --prune.".to_string(),
423
423
  Some("stop") => compat_hidden_help("stop", "usage: team-agent stop [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]"),
@@ -1069,17 +1069,37 @@ fn quick_start_args(args: &[String], cwd: &Path) -> Result<QuickStartArgs, CliEr
1069
1069
  ));
1070
1070
  }
1071
1071
  let parsed = parse_args(args);
1072
- let workspace = workspace(&parsed, cwd);
1073
- let agents_dir = parsed
1074
- .positionals
1075
- .first()
1076
- .map(PathBuf::from)
1077
- .unwrap_or_else(|| workspace.clone());
1078
- let agents_dir = if agents_dir.is_absolute() {
1079
- agents_dir
1080
- } else {
1081
- workspace.join(agents_dir)
1082
- };
1072
+ let explicit_workspace = parsed
1073
+ .workspace
1074
+ .as_deref()
1075
+ .map(|path| resolve_cli_path(cwd, path));
1076
+ let positional_agents_dir = parsed.positionals.first().map(PathBuf::from).map(|path| {
1077
+ if path.is_absolute() {
1078
+ path
1079
+ } else if let Some(workspace) = explicit_workspace.as_ref() {
1080
+ workspace.join(path)
1081
+ } else {
1082
+ cwd.join(path)
1083
+ }
1084
+ });
1085
+ let workspace = explicit_workspace
1086
+ .or_else(|| {
1087
+ let path = positional_agents_dir.as_ref()?;
1088
+ if let Some(run_workspace) = path
1089
+ .ancestors()
1090
+ .find(|ancestor| ancestor.file_name().and_then(|name| name.to_str()) == Some(".team"))
1091
+ .and_then(Path::parent)
1092
+ {
1093
+ return Some(run_workspace.to_path_buf());
1094
+ }
1095
+ path.join("TEAM.md").is_file().then(|| path.to_path_buf())
1096
+ })
1097
+ .unwrap_or_else(|| cwd.to_path_buf());
1098
+ // A positional team directory containing TEAM.md is a complete standalone
1099
+ // workspace. Use it as the runtime workspace unless --workspace explicitly
1100
+ // selects another root; this prevents host owner context/state leaking into
1101
+ // an independent quick-start target.
1102
+ let agents_dir = positional_agents_dir.unwrap_or_else(|| workspace.clone());
1083
1103
  // 0.5.x Phase 1d Batch 2: validate the `--backend` literal up-front
1084
1104
  // so users get a fast, actionable error instead of a downstream
1085
1105
  // factory refusal. Accept the same literals as the factory
@@ -2565,6 +2585,30 @@ mod tests {
2565
2585
  let _ = std::fs::remove_dir_all(&ws);
2566
2586
  }
2567
2587
 
2588
+ #[test]
2589
+ fn ux_quick_start_positional_team_dir_is_standalone_workspace() {
2590
+ let cwd = tmp_workspace();
2591
+ let ws = tmp_workspace();
2592
+ std::fs::write(ws.join("TEAM.md"), "# team\n").unwrap();
2593
+ let args = quick_start_args(&cli_argv(&[&ws.to_string_lossy()]), &cwd).unwrap();
2594
+ assert_eq!(args.workspace, ws);
2595
+ assert_eq!(args.agents_dir, ws);
2596
+ let _ = std::fs::remove_dir_all(&cwd);
2597
+ let _ = std::fs::remove_dir_all(&ws);
2598
+ }
2599
+
2600
+ #[test]
2601
+ fn ux_quick_start_team_dir_under_dot_team_uses_project_workspace() {
2602
+ let cwd = tmp_workspace();
2603
+ let team_dir = cwd.join(".team/current");
2604
+ std::fs::create_dir_all(&team_dir).unwrap();
2605
+ std::fs::write(team_dir.join("TEAM.md"), "# team\n").unwrap();
2606
+ let args = quick_start_args(&cli_argv(&[".team/current"]), &cwd).unwrap();
2607
+ assert_eq!(args.workspace, cwd);
2608
+ assert_eq!(args.agents_dir, team_dir);
2609
+ let _ = std::fs::remove_dir_all(&cwd);
2610
+ }
2611
+
2568
2612
  // ── E8 (N38): 未知子命令 → 最近似建议(additive,不破坏 golden invalid-choice 行) ──
2569
2613
  #[test]
2570
2614
  fn e8_unknown_subcommand_suggests_nearest_known_command() {
@@ -3646,7 +3646,6 @@ pub mod lifecycle_port {
3646
3646
  dry_run: false,
3647
3647
  tmux_endpoint: None,
3648
3648
  routes: Vec::new(),
3649
- permissions: Vec::new(),
3650
3649
  leader_receiver_attached: true,
3651
3650
  leader_bind_stage: None,
3652
3651
  leader_bind_reason: None,
@@ -1,38 +1,66 @@
1
- //! Seven-field, read-only status projection backed by one bounded nodeprobe sample.
1
+ //! Seven-field, read-only status projection backed by native tmux and process samples.
2
2
  //!
3
3
  //! This intentionally does not reuse RuntimeSnapshot: the legacy snapshot reads
4
4
  //! coordinator/db/history and carries diagnostic fields that are outside the
5
5
  //! status brief contract.
6
6
 
7
- use serde::Deserialize;
8
7
  use serde_json::{json, Map, Value};
9
- use sha2::{Digest, Sha256};
10
8
  use std::collections::BTreeMap;
9
+ use std::path::Path;
10
+ use std::process::Command;
11
+
12
+ #[cfg(test)]
13
+ use serde::Deserialize;
14
+ #[cfg(test)]
15
+ use sha2::{Digest, Sha256};
16
+ #[cfg(test)]
11
17
  use std::fs::{File, OpenOptions};
18
+ #[cfg(test)]
12
19
  use std::io::Read;
13
- use std::path::{Path, PathBuf};
14
- use std::process::{Child, Command, ExitStatus, Stdio};
20
+ #[cfg(test)]
21
+ use std::path::PathBuf;
22
+ #[cfg(test)]
23
+ use std::process::{Child, ExitStatus, Stdio};
24
+ #[cfg(test)]
15
25
  use std::sync::atomic::{AtomicBool, Ordering};
26
+ #[cfg(test)]
16
27
  use std::sync::mpsc;
17
28
  #[cfg(test)]
18
29
  use std::sync::Arc;
30
+ #[cfg(test)]
19
31
  use std::thread;
32
+ #[cfg(test)]
20
33
  use std::time::{Duration, Instant};
21
34
 
35
+ #[cfg(test)]
22
36
  const NODEPROBE_TIMEOUT: Duration = Duration::from_secs(2);
37
+ #[cfg(test)]
23
38
  const NODEPROBE_OUTPUT_LIMIT: u64 = 1024 * 1024;
39
+ #[cfg(test)]
24
40
  const NODEPROBE_KILL_GRACE: Duration = Duration::from_millis(100);
41
+ #[cfg(test)]
25
42
  const NODEPROBE_RECEIPT_LIMIT: u64 = 64 * 1024;
43
+ #[cfg(test)]
26
44
  const NODEPROBE_BINARY_LIMIT: u64 = 64 * 1024 * 1024;
45
+ #[cfg(test)]
27
46
  const NODEPROBE_READ_CHUNK: usize = 64 * 1024;
47
+ #[cfg(test)]
28
48
  const NODEPROBE_NAME: &str = "nodeprobe";
49
+ #[cfg(test)]
29
50
  const NODEPROBE_RECEIPT_SUFFIX: &str = ".capability.json";
51
+ #[cfg(test)]
30
52
  const NODEPROBE_RECEIPT_SCHEMA: &str = "nodeprobe-capability-v1";
53
+ #[cfg(test)]
31
54
  const NODEPROBE_SOURCE_REPO: &str = "Florious95/team-agent-scratch/nodeprobe";
55
+ #[cfg(test)]
32
56
  const NODEPROBE_SOURCE_COMMIT: &str = "ff316dc0afe8ab280e61d30934e7624579be6224";
57
+ #[cfg(test)]
33
58
  const NODEPROBE_SOURCE_TREE: &str = "5217a41aa914ddcb72c27f39f1b4af9ead68b1b6";
59
+ #[cfg(test)]
34
60
  const NODEPROBE_REPORT_SCHEMA: u64 = 1;
61
+ #[cfg(test)]
35
62
  const NODEPROBE_CAPABILITIES: &[&str] = &["tmux.list-panes", "ps.pid_ppid_stat_comm"];
63
+ #[cfg(test)]
36
64
  const NODEPROBE_FORBIDDEN: &[&str] = &[
37
65
  "tmux.capture-pane",
38
66
  "tmux.attach",
@@ -47,6 +75,7 @@ thread_local! {
47
75
  static TEST_RESOLVER_DELAY: std::cell::RefCell<Option<TestResolverDelay>> = const { std::cell::RefCell::new(None) };
48
76
  }
49
77
 
78
+ #[cfg(test)]
50
79
  static NODEPROBE_RESOLVER_ACTIVE: AtomicBool = AtomicBool::new(false);
51
80
 
52
81
  #[derive(Clone, Debug, PartialEq, Eq)]
@@ -90,6 +119,7 @@ struct ProbeResult {
90
119
  }
91
120
 
92
121
  #[derive(Debug, Deserialize)]
122
+ #[cfg(test)]
93
123
  struct NodeprobeCapabilityReceipt {
94
124
  schema: String,
95
125
  binary: String,
@@ -383,6 +413,126 @@ fn first_string(value: &Value, keys: &[&str]) -> Option<String> {
383
413
  }
384
414
 
385
415
  fn probe_registered_endpoints(nodes: &[RegisteredNode]) -> BTreeMap<String, Option<ProbeResult>> {
416
+ #[cfg(test)]
417
+ {
418
+ // Keep the legacy fixture seam for existing hermetic tests; production
419
+ // status never searches for or executes nodeprobe.
420
+ if TEST_NODEPROBE.with(|slot| slot.borrow().is_some()) {
421
+ return probe_registered_endpoints_with_test_nodeprobe(nodes);
422
+ }
423
+ }
424
+ let mut endpoints = BTreeMap::new();
425
+ for endpoint in nodes.iter().filter_map(|node| node.endpoint.as_deref()) {
426
+ if endpoints.contains_key(endpoint) {
427
+ continue;
428
+ }
429
+ endpoints.insert(
430
+ endpoint.to_string(),
431
+ sample_native_endpoint(endpoint, nodes),
432
+ );
433
+ }
434
+ endpoints
435
+ }
436
+
437
+ fn sample_native_endpoint(
438
+ endpoint: &str,
439
+ registered: &[RegisteredNode],
440
+ ) -> Option<ProbeResult> {
441
+ let output = run_native_tmux_list_panes(endpoint)?;
442
+ let nodes = output
443
+ .lines()
444
+ .filter_map(|line| parse_native_pane(line, endpoint, registered))
445
+ .collect::<Vec<_>>();
446
+ Some(ProbeResult { nodes })
447
+ }
448
+
449
+ fn run_native_tmux_list_panes(endpoint: &str) -> Option<String> {
450
+ const FORMAT: &str = "#{session_name}\t#{window_name}\t#{pane_id}\t#{pane_pid}\t#{pane_current_command}";
451
+ let flag = if Path::new(endpoint).is_absolute() { "-S" } else { "-L" };
452
+ let output = Command::new("tmux")
453
+ .arg(flag)
454
+ .arg(endpoint)
455
+ .args(["list-panes", "-a", "-F", FORMAT])
456
+ .output()
457
+ .ok()?;
458
+ output.status.success().then(|| String::from_utf8(output.stdout).ok())?
459
+ }
460
+
461
+ fn parse_native_pane(
462
+ line: &str,
463
+ endpoint: &str,
464
+ registered: &[RegisteredNode],
465
+ ) -> Option<ProbeNode> {
466
+ let mut fields = line.split('\t');
467
+ let session = fields.next()?.to_string();
468
+ let window = fields.next()?.to_string();
469
+ let pane = fields.next()?.to_string();
470
+ let pid = fields.next().filter(|value| !value.is_empty());
471
+ let command = fields.next().unwrap_or_default();
472
+ if session.is_empty() || window.is_empty() || pane.is_empty() {
473
+ return None;
474
+ }
475
+ let matched = registered.iter().find(|node| {
476
+ node.endpoint.as_deref() == Some(endpoint)
477
+ && node.session.as_deref() == Some(session.as_str())
478
+ && node.window.as_deref().is_none_or(|value| value == window)
479
+ && node.pane.as_deref() == Some(pane.as_str())
480
+ });
481
+ if let Some(pid) = pid {
482
+ let snapshot = process_snapshot(pid)?;
483
+ if snapshot.zombie {
484
+ return None;
485
+ }
486
+ }
487
+ let provider = matched
488
+ .map(|node| node.provider.clone())
489
+ .unwrap_or_else(|| "unknown".to_string());
490
+ let activity = if command.is_empty() {
491
+ "unknown"
492
+ } else if matches!(command, "bash" | "fish" | "sh" | "zsh" | "-bash" | "-zsh") {
493
+ "idle"
494
+ } else {
495
+ "working"
496
+ };
497
+ Some(ProbeNode {
498
+ socket: endpoint.to_string(),
499
+ session: session.clone(),
500
+ window: window.clone(),
501
+ pane,
502
+ provider,
503
+ activity: activity.to_string(),
504
+ health: "normal".to_string(),
505
+ session_name: Some(session),
506
+ evidence_method: Some("native_tmux".to_string()),
507
+ })
508
+ }
509
+
510
+ struct ProcessSnapshot {
511
+ zombie: bool,
512
+ }
513
+
514
+ fn process_snapshot(pid: &str) -> Option<ProcessSnapshot> {
515
+ let output = Command::new("ps")
516
+ .args(["-o", "pid=,ppid=,stat=,comm=", "-p", pid])
517
+ .output()
518
+ .ok()?;
519
+ if !output.status.success() || String::from_utf8_lossy(&output.stdout).trim().is_empty() {
520
+ return None;
521
+ }
522
+ let output_text = String::from_utf8_lossy(&output.stdout);
523
+ let stat = output_text
524
+ .split_whitespace()
525
+ .nth(2)
526
+ .unwrap_or_default();
527
+ Some(ProcessSnapshot {
528
+ zombie: stat.contains('Z'),
529
+ })
530
+ }
531
+
532
+ #[cfg(test)]
533
+ fn probe_registered_endpoints_with_test_nodeprobe(
534
+ nodes: &[RegisteredNode],
535
+ ) -> BTreeMap<String, Option<ProbeResult>> {
386
536
  let deadline = Instant::now() + NODEPROBE_TIMEOUT;
387
537
  let candidates = nodeprobe_candidates();
388
538
  let resolver_options = resolver_options();
@@ -410,6 +560,7 @@ fn probe_registered_endpoints(nodes: &[RegisteredNode]) -> BTreeMap<String, Opti
410
560
  endpoints
411
561
  }
412
562
 
563
+ #[cfg(test)]
413
564
  fn run_nodeprobe(binary: &Path, endpoint: &str, deadline: Instant) -> Option<Vec<u8>> {
414
565
  if Instant::now() >= deadline {
415
566
  return None;
@@ -476,6 +627,7 @@ fn run_nodeprobe(binary: &Path, endpoint: &str, deadline: Instant) -> Option<Vec
476
627
  }
477
628
  }
478
629
 
630
+ #[cfg(test)]
479
631
  fn wait_child_bounded(child: &mut Child, deadline: Instant) -> Option<ExitStatus> {
480
632
  loop {
481
633
  match child.try_wait() {
@@ -493,6 +645,7 @@ fn wait_child_bounded(child: &mut Child, deadline: Instant) -> Option<ExitStatus
493
645
  }
494
646
  }
495
647
 
648
+ #[cfg(test)]
496
649
  fn terminate_nodeprobe(child: &mut Child) {
497
650
  #[cfg(unix)]
498
651
  {
@@ -509,6 +662,7 @@ fn terminate_nodeprobe(child: &mut Child) {
509
662
  }
510
663
  }
511
664
 
665
+ #[cfg(test)]
512
666
  fn spawn_nodeprobe(binary: &Path, endpoint: &str) -> Option<Child> {
513
667
  let flag = if Path::new(endpoint).is_absolute() {
514
668
  "-S"
@@ -522,11 +676,13 @@ fn spawn_nodeprobe(binary: &Path, endpoint: &str) -> Option<Child> {
522
676
  }
523
677
 
524
678
  #[derive(Clone, Default)]
679
+ #[cfg(test)]
525
680
  struct ResolverOptions {
526
681
  #[cfg(test)]
527
682
  delay_after_selection: Option<TestResolverDelay>,
528
683
  }
529
684
 
685
+ #[cfg(test)]
530
686
  fn resolver_options() -> ResolverOptions {
531
687
  #[cfg(test)]
532
688
  {
@@ -543,11 +699,13 @@ fn resolver_options() -> ResolverOptions {
543
699
  // A resolver that is stuck in an uninterruptible filesystem syscall may outlive
544
700
  // the caller deadline. It owns only its candidate work; the active gate prevents
545
701
  // repeated status calls from accumulating more detached resolver threads.
702
+ #[cfg(test)]
546
703
  struct ResolverActiveGuard {
547
704
  #[cfg(test)]
548
705
  finished_signal: Option<ResolverDelaySignal>,
549
706
  }
550
707
 
708
+ #[cfg(test)]
551
709
  impl Drop for ResolverActiveGuard {
552
710
  fn drop(&mut self) {
553
711
  NODEPROBE_RESOLVER_ACTIVE.store(false, Ordering::Release);
@@ -558,6 +716,7 @@ impl Drop for ResolverActiveGuard {
558
716
  }
559
717
  }
560
718
 
719
+ #[cfg(test)]
561
720
  fn resolve_nodeprobe_binary(
562
721
  candidates: Vec<PathBuf>,
563
722
  deadline: Instant,
@@ -606,6 +765,7 @@ fn resolve_nodeprobe_binary(
606
765
  }
607
766
  }
608
767
 
768
+ #[cfg(test)]
609
769
  fn select_nodeprobe_binary(
610
770
  candidates: Vec<PathBuf>,
611
771
  deadline: Instant,
@@ -621,6 +781,7 @@ fn select_nodeprobe_binary(
621
781
  None
622
782
  }
623
783
 
784
+ #[cfg(test)]
624
785
  fn nodeprobe_candidates() -> Vec<PathBuf> {
625
786
  #[cfg(test)]
626
787
  {
@@ -642,6 +803,7 @@ fn nodeprobe_candidates() -> Vec<PathBuf> {
642
803
  candidates
643
804
  }
644
805
 
806
+ #[cfg(test)]
645
807
  fn validate_nodeprobe_candidate(path: &Path, deadline: Instant) -> Option<PathBuf> {
646
808
  if Instant::now() >= deadline {
647
809
  return None;
@@ -669,6 +831,7 @@ fn validate_nodeprobe_candidate(path: &Path, deadline: Instant) -> Option<PathBu
669
831
  (digest == receipt.binary_sha256).then_some(path.to_path_buf())
670
832
  }
671
833
 
834
+ #[cfg(test)]
672
835
  fn valid_nodeprobe_receipt(receipt: &NodeprobeCapabilityReceipt, binary: &Path) -> bool {
673
836
  receipt.schema == NODEPROBE_RECEIPT_SCHEMA
674
837
  && binary.file_name().and_then(|name| name.to_str()) == Some(NODEPROBE_NAME)
@@ -683,10 +846,12 @@ fn valid_nodeprobe_receipt(receipt: &NodeprobeCapabilityReceipt, binary: &Path)
683
846
  && same_string_set(&receipt.forbidden, NODEPROBE_FORBIDDEN)
684
847
  }
685
848
 
849
+ #[cfg(test)]
686
850
  fn valid_sha256(value: &str) -> bool {
687
851
  value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
688
852
  }
689
853
 
854
+ #[cfg(test)]
690
855
  fn same_string_set(actual: &[String], expected: &[&str]) -> bool {
691
856
  let mut actual = actual.to_vec();
692
857
  actual.sort_unstable();
@@ -698,6 +863,7 @@ fn same_string_set(actual: &[String], expected: &[&str]) -> bool {
698
863
  actual == expected
699
864
  }
700
865
 
866
+ #[cfg(test)]
701
867
  fn nodeprobe_receipt_path(binary: &Path) -> PathBuf {
702
868
  let name = binary
703
869
  .file_name()
@@ -706,6 +872,7 @@ fn nodeprobe_receipt_path(binary: &Path) -> PathBuf {
706
872
  binary.with_file_name(format!("{name}{NODEPROBE_RECEIPT_SUFFIX}"))
707
873
  }
708
874
 
875
+ #[cfg(test)]
709
876
  fn read_bounded_file(path: &Path, limit: u64, deadline: Instant) -> Option<Vec<u8>> {
710
877
  let metadata = std::fs::metadata(path).ok()?;
711
878
  if !metadata.is_file() || metadata.len() > limit {
@@ -729,6 +896,7 @@ fn read_bounded_file(path: &Path, limit: u64, deadline: Instant) -> Option<Vec<u
729
896
  }
730
897
  }
731
898
 
899
+ #[cfg(test)]
732
900
  fn sha256_file(path: &Path, deadline: Instant) -> Option<String> {
733
901
  let metadata = std::fs::metadata(path).ok()?;
734
902
  if !metadata.is_file() || metadata.len() > NODEPROBE_BINARY_LIMIT {
@@ -754,6 +922,7 @@ fn sha256_file(path: &Path, deadline: Instant) -> Option<String> {
754
922
  }
755
923
  }
756
924
 
925
+ #[cfg(test)]
757
926
  fn open_readonly_nonblocking(path: &Path) -> Option<File> {
758
927
  let mut options = OpenOptions::new();
759
928
  options.read(true);
@@ -765,6 +934,7 @@ fn open_readonly_nonblocking(path: &Path) -> Option<File> {
765
934
  options.open(path).ok()
766
935
  }
767
936
 
937
+ #[cfg(test)]
768
938
  fn is_executable(metadata: &std::fs::Metadata) -> bool {
769
939
  #[cfg(unix)]
770
940
  {
@@ -778,6 +948,7 @@ fn is_executable(metadata: &std::fs::Metadata) -> bool {
778
948
  }
779
949
  }
780
950
 
951
+ #[cfg(test)]
781
952
  fn current_nodeprobe_target() -> Option<&'static str> {
782
953
  match (std::env::consts::OS, std::env::consts::ARCH) {
783
954
  ("macos", "aarch64") => Some("aarch64-apple-darwin"),
@@ -789,6 +960,7 @@ fn current_nodeprobe_target() -> Option<&'static str> {
789
960
  }
790
961
  }
791
962
 
963
+ #[cfg(test)]
792
964
  fn configure_nodeprobe_command(command: &mut Command) {
793
965
  command
794
966
  .stdin(Stdio::null())
@@ -801,6 +973,7 @@ fn configure_nodeprobe_command(command: &mut Command) {
801
973
  }
802
974
  }
803
975
 
976
+ #[cfg(test)]
804
977
  fn parse_probe(bytes: &[u8], requested_endpoint: &str) -> Result<ProbeResult, ()> {
805
978
  let value: Value = serde_json::from_slice(bytes).map_err(|_| ())?;
806
979
  if value.get("schema_version").and_then(Value::as_u64) != Some(1) {
@@ -823,6 +996,7 @@ fn parse_probe(bytes: &[u8], requested_endpoint: &str) -> Result<ProbeResult, ()
823
996
  Ok(ProbeResult { nodes })
824
997
  }
825
998
 
999
+ #[cfg(test)]
826
1000
  fn probe_has_error(value: &Value) -> bool {
827
1001
  match value.get("error") {
828
1002
  None | Some(Value::Null) => false,
@@ -833,6 +1007,7 @@ fn probe_has_error(value: &Value) -> bool {
833
1007
  }
834
1008
  }
835
1009
 
1010
+ #[cfg(test)]
836
1011
  fn parse_probe_node(value: &Value, report_socket: &str) -> Option<ProbeNode> {
837
1012
  Some(ProbeNode {
838
1013
  // The accepted producer binds the socket once at the report envelope
@@ -857,6 +1032,7 @@ fn parse_probe_node(value: &Value, report_socket: &str) -> Option<ProbeNode> {
857
1032
  })
858
1033
  }
859
1034
 
1035
+ #[cfg(test)]
860
1036
  fn non_empty(value: &Value, key: &str) -> Option<String> {
861
1037
  value
862
1038
  .get(key)
@@ -895,12 +1071,16 @@ fn project_node(node: &RegisteredNode, probe: Option<&Option<ProbeResult>>) -> B
895
1071
  }
896
1072
  let pi_channel = observed.provider.eq_ignore_ascii_case("pi")
897
1073
  && observed.evidence_method.as_deref() == Some("pi_activity_channel");
898
- let activity = if observed.provider.eq_ignore_ascii_case("pi") && !pi_channel {
1074
+ let native_tmux = observed.evidence_method.as_deref() == Some("native_tmux");
1075
+ let activity = if observed.provider.eq_ignore_ascii_case("pi")
1076
+ && !pi_channel
1077
+ && !native_tmux
1078
+ {
899
1079
  "unknown"
900
1080
  } else {
901
1081
  valid_activity(&observed.activity)
902
1082
  };
903
- let health = if observed.provider.eq_ignore_ascii_case("pi") && !pi_channel {
1083
+ let health = if observed.provider.eq_ignore_ascii_case("pi") && !pi_channel && !native_tmux {
904
1084
  "unknown"
905
1085
  } else {
906
1086
  valid_health(&observed.health)