@team-agent/installer 0.5.86 → 0.5.91

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 (39) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +104 -164
  4. package/crates/team-agent/src/cli/emit.rs +68 -87
  5. package/crates/team-agent/src/cli/mod.rs +1 -1
  6. package/crates/team-agent/src/cli/spec.rs +1 -4
  7. package/crates/team-agent/src/cli/status_port/brief.rs +187 -7
  8. package/crates/team-agent/src/cli/status_port/inbox.rs +42 -17
  9. package/crates/team-agent/src/cli/tests/lane_c.rs +34 -26
  10. package/crates/team-agent/src/cli/tests/main_preserved.rs +4 -70
  11. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +0 -68
  12. package/crates/team-agent/src/cli/types.rs +3 -16
  13. package/crates/team-agent/src/compiler.rs +4 -7
  14. package/crates/team-agent/src/coordinator/tests/tick_core.rs +5 -6
  15. package/crates/team-agent/src/coordinator/tick.rs +1 -16
  16. package/crates/team-agent/src/coordinator/types.rs +1 -1
  17. package/crates/team-agent/src/db/message_store.rs +10 -5
  18. package/crates/team-agent/src/fake_worker.rs +4 -4
  19. package/crates/team-agent/src/lifecycle/launch/identity.rs +39 -21
  20. package/crates/team-agent/src/lifecycle/launch/spawn.rs +1 -1
  21. package/crates/team-agent/src/lifecycle/restart/common.rs +1 -1
  22. package/crates/team-agent/src/lifecycle/tests/b0_reader_hideone_audit_contract.rs +3 -3
  23. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +2 -2
  24. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +0 -10
  25. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +2 -9
  26. package/crates/team-agent/src/lifecycle/tests/upgrade_compat_0211_red.rs +1 -26
  27. package/crates/team-agent/src/main.rs +7 -2
  28. package/crates/team-agent/src/mcp_server/mod.rs +1 -1
  29. package/crates/team-agent/src/messaging/mod.rs +2 -2
  30. package/crates/team-agent/src/messaging/results.rs +234 -386
  31. package/crates/team-agent/src/messaging/tests/runtime.rs +91 -135
  32. package/crates/team-agent/src/messaging/watchers.rs +1 -1
  33. package/crates/team-agent/src/model/enums.rs +17 -3
  34. package/crates/team-agent/src/model/spec.rs +8 -7
  35. package/crates/team-agent/src/provider/wire.rs +1 -1
  36. package/package.json +4 -4
  37. package/skills/team-agent/SKILL.md +3 -3
  38. package/skills/team-agent/command-coverage.json +2 -9
  39. package/skills/team-agent/references/team-agent-operator.md +661 -0
@@ -76,7 +76,6 @@ pub(crate) enum DispatchKind {
76
76
  Validate,
77
77
  InstallSkill,
78
78
  Profile,
79
- Collect,
80
79
  Results,
81
80
  Wait,
82
81
  Diagnose,
@@ -123,7 +122,6 @@ pub(crate) const ALL_DISPATCH_KINDS: &[DispatchKind] = &[
123
122
  DispatchKind::Validate,
124
123
  DispatchKind::InstallSkill,
125
124
  DispatchKind::Profile,
126
- DispatchKind::Collect,
127
125
  DispatchKind::Results,
128
126
  DispatchKind::Wait,
129
127
  DispatchKind::Diagnose,
@@ -170,7 +168,6 @@ pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
170
168
  CommandSpec { name: "send", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Send), summary: "persist a message for an in-team short name or fully-qualified logical recipient", usage: "usage: team-agent send TO MESSAGE... [--workspace WORKSPACE] [--team TEAM] [--mailbox] [--json]\nTO forms are co-equal: an in-team short name (for example `team-agent send reviewer \"Review\"`) or `<workspace>::<team>/<agent>`.", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: Some("next compatibility release"), action: Some("use positional logical TO and the returned message id"), governance: None },
171
169
  CommandSpec { name: "status", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Status), summary: "show current team status", usage: "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
172
170
  CommandSpec { name: "models", tier: CommandTier::Core, category: CommandCategory::Observe, kind: CommandKind::Dispatch(DispatchKind::Models), summary: "list exact provider model ids", usage: "usage: team-agent models [--provider pi|cursor_agent] [QUERY|--search TEXT] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
173
- CommandSpec { name: "collect", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Collect), summary: "collect reported results", usage: "usage: team-agent collect [--workspace WORKSPACE] [--team TEAM] [--result-file FILE] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
174
171
  CommandSpec { name: "results", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Results), summary: "read reported results for a case", usage: "usage: team-agent results --case CASE_ID [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
175
172
  CommandSpec { name: "wait", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Wait), summary: "block until a task result is stored", usage: "usage: team-agent wait --task TASK [--workspace WORKSPACE] [--json]", default_help: false, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
176
173
  CommandSpec { name: "restart", tier: CommandTier::Core, category: CommandCategory::TeamLifecycle, kind: CommandKind::Dispatch(DispatchKind::Restart), summary: "restart the selected team", usage: "usage: team-agent restart [WORKSPACE] [--team TEAM] [--allow-fresh] [--session-converge-deadline SECONDS] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
@@ -208,7 +205,7 @@ pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
208
205
  CommandSpec { name: "approvals", tier: CommandTier::Secondary, category: CommandCategory::Observe, kind: CommandKind::Dispatch(DispatchKind::Approvals), summary: "inspect provider approval state", usage: "usage: team-agent approvals [AGENT] [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: Some(GovernanceNote { decision: "secondary", reason: "approval diagnostics" }) },
209
206
  CommandSpec { name: "profile", tier: CommandTier::Secondary, category: CommandCategory::Setup, kind: CommandKind::Dispatch(DispatchKind::Profile), summary: "manage provider profiles", usage: "usage: team-agent profile COMMAND NAME [--workspace WORKSPACE] [--team TEAM] [--auth-mode MODE] [--proxy-mode direct|inherit] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: Some(GovernanceNote { decision: "secondary", reason: "provider/profile setup" }) },
210
207
  CommandSpec { name: "install-skill", tier: CommandTier::Secondary, category: CommandCategory::Setup, kind: CommandKind::Dispatch(DispatchKind::InstallSkill), summary: "install or uninstall Team Agent skills", usage: "usage: team-agent install-skill (--source DIR | --uninstall) [--target codex|claude|copilot|all] [--dest DIR] [--dry-run] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: Some(GovernanceNote { decision: "secondary", reason: "capability setup" }) },
211
- CommandSpec { name: "inbox", tier: CommandTier::Secondary, category: CommandCategory::Observe, kind: CommandKind::Dispatch(DispatchKind::Inbox), summary: "inspect queued messages", usage: "usage: team-agent inbox AGENT [--workspace WORKSPACE] [--team TEAM] [--limit N] [--since CURSOR] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
208
+ CommandSpec { name: "inbox", tier: CommandTier::Secondary, category: CommandCategory::Observe, kind: CommandKind::Dispatch(DispatchKind::Inbox), summary: "inspect recent message summaries", usage: "usage: team-agent inbox AGENT [-n N|--limit N] [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
212
209
 
213
210
  CommandSpec { name: "identity", tier: CommandTier::DevInternal, category: CommandCategory::Dev, kind: CommandKind::Dispatch(DispatchKind::Identity), summary: "inspect identity metadata", usage: "usage: team-agent identity [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
214
211
  CommandSpec { name: "watch", tier: CommandTier::DevInternal, category: CommandCategory::Dev, kind: CommandKind::Dispatch(DispatchKind::Watch), summary: "watch event logs", usage: "usage: team-agent watch [--workspace WORKSPACE] [--team TEAM]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: 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)
@@ -1,37 +1,62 @@
1
1
  //!
2
2
  use super::*;
3
3
 
4
+ const SUMMARY_LIMIT: usize = 120;
5
+
6
+ fn clean_text(text: &str) -> String {
7
+ text.chars().filter(|ch| !ch.is_control()).collect()
8
+ }
9
+
10
+ fn compact_field(value: Option<&Value>) -> Value {
11
+ value
12
+ .and_then(Value::as_str)
13
+ .map(|text| Value::String(clean_text(text)))
14
+ .unwrap_or(Value::Null)
15
+ }
16
+
4
17
  pub fn inbox(
5
18
  workspace: &Path,
6
19
  agent: &str,
7
20
  limit: usize,
8
- since: Option<&str>,
9
- as_json: bool,
10
21
  owner_team_id: Option<&str>,
11
22
  ) -> Result<Value, CliError> {
12
- let _ = as_json;
13
23
  let store = crate::message_store::MessageStore::open(workspace)
14
24
  .map_err(|e| CliError::Runtime(e.to_string()))?;
15
- let mut messages = store
25
+ let messages = store
16
26
  .inbox(agent, limit, owner_team_id)
17
- .map_err(|e| CliError::Runtime(e.to_string()))?;
18
- if let Some(cutoff) = since.and_then(parse_rfc3339) {
19
- messages.retain(|message| {
20
- message
21
- .get("created_at")
22
- .and_then(Value::as_str)
23
- .and_then(parse_rfc3339)
24
- .is_some_and(|created| created >= cutoff)
25
- });
26
- }
27
+ .map_err(|e| CliError::Runtime(e.to_string()))?
28
+ .into_iter()
29
+ .map(compact_message)
30
+ .collect::<Vec<_>>();
27
31
  Ok(json!({
28
32
  "ok": true,
29
33
  "agent_id": agent,
30
34
  "messages": messages,
31
- "since": since,
32
35
  }))
33
36
  }
34
37
 
35
- pub(super) fn parse_rfc3339(value: &str) -> Option<chrono::DateTime<chrono::FixedOffset>> {
36
- chrono::DateTime::parse_from_rfc3339(value).ok()
38
+ fn compact_message(message: Value) -> Value {
39
+ json!({
40
+ "message_id": compact_field(message.get("message_id")),
41
+ "sender": compact_field(message.get("sender")),
42
+ "recipient": compact_field(message.get("recipient")),
43
+ "status": compact_field(message.get("status")),
44
+ "created_at": compact_field(message.get("created_at")),
45
+ "summary": compact_summary(message.get("content").and_then(Value::as_str).unwrap_or("")),
46
+ })
47
+ }
48
+
49
+ fn compact_summary(content: &str) -> String {
50
+ let summary = content
51
+ .lines()
52
+ .map(|line| clean_text(line).trim().to_string())
53
+ .find(|line| !line.is_empty())
54
+ .unwrap_or_default();
55
+ let mut chars = summary.chars();
56
+ let truncated = chars.by_ref().take(SUMMARY_LIMIT).collect::<String>();
57
+ if chars.next().is_some() {
58
+ format!("{truncated}…")
59
+ } else {
60
+ truncated
61
+ }
37
62
  }
@@ -211,36 +211,44 @@ fn approvals_golden_shape_has_waiting_count_and_scan_not_agent() {
211
211
  let _ = std::fs::remove_dir_all(&ws);
212
212
  }
213
213
 
214
- // ── inbox: golden {ok,agent_id,messages,since} (status/inbox.py:35-38) — key is `agent_id`, carries
215
- // `since` (not `agent`/`limit`). RUST mod.rs:136-144 stub {ok,agent,limit,messages}. RED. ──────────
214
+ // ── inbox: compact fallback projection ───────────────────────────────────────────────
216
215
  #[test]
217
- fn inbox_golden_shape_is_agent_id_and_since_not_agent_limit() {
216
+ fn inbox_golden_shape_is_compact_and_scoped_to_agent() {
218
217
  let ws = tmp_workspace();
219
- let v = status_port::inbox(&ws, "alpha", 20, None, true, None).expect("inbox");
218
+ let v = status_port::inbox(&ws, "alpha", 3, None).expect("inbox");
220
219
  let obj = v.as_object().expect("inbox dict");
220
+ assert_eq!(v["agent_id"], json!("alpha"));
221
221
  let order: Vec<&str> = obj.keys().map(String::as_str).collect();
222
- assert_eq!(
223
- order,
224
- vec!["ok", "agent_id", "messages", "since"],
225
- "golden inbox 4-key order {{ok,agent_id,messages,since}} (inbox.py:38); Rust stub uses agent/limit. got {order:?}"
226
- );
227
- assert_eq!(
228
- v["agent_id"],
229
- json!("alpha"),
230
- "golden key is `agent_id` (not `agent`)"
231
- );
232
- assert!(
233
- obj.contains_key("since"),
234
- "golden inbox carries `since` (echoed, null here)"
235
- );
236
- assert!(
237
- !obj.contains_key("agent"),
238
- "golden inbox has NO bare `agent` key"
239
- );
240
- assert!(
241
- !obj.contains_key("limit"),
242
- "golden inbox has NO `limit` key in the result dict"
243
- );
222
+ assert_eq!(order, vec!["ok", "agent_id", "messages"]);
223
+ let _ = std::fs::remove_dir_all(&ws);
224
+ }
225
+
226
+ #[test]
227
+ fn inbox_excludes_leader_stage_result_notifications() {
228
+ let ws = tmp_workspace();
229
+ let store = crate::message_store::MessageStore::open(&ws).unwrap();
230
+ let message_id = store
231
+ .create_message(
232
+ None,
233
+ "leader",
234
+ "worker",
235
+ "Task t reported success; Result id: res-1",
236
+ None,
237
+ false,
238
+ None,
239
+ )
240
+ .unwrap();
241
+ let conn = crate::db::schema::open_db(store.db_path()).unwrap();
242
+ conn.execute(
243
+ "update messages set presentation = ?1 where message_id = ?2",
244
+ rusqlite::params![
245
+ json!({"sink": "leader", "class": "stage_result"}).to_string(),
246
+ message_id,
247
+ ],
248
+ )
249
+ .unwrap();
250
+ let value = status_port::inbox(&ws, "leader", 3, None).expect("inbox");
251
+ assert!(value["messages"].as_array().unwrap().is_empty());
244
252
  let _ = std::fs::remove_dir_all(&ws);
245
253
  }
246
254