@team-agent/installer 0.5.86 → 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.
package/Cargo.lock CHANGED
@@ -581,7 +581,7 @@ dependencies = [
581
581
 
582
582
  [[package]]
583
583
  name = "team-agent"
584
- version = "0.5.86"
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.86"
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() {
@@ -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)
@@ -32,8 +32,8 @@ use crate::model::enums::{Provider, ProviderEffort};
32
32
  use crate::model::yaml::Value;
33
33
  use crate::model::{paths, spec, yaml, ModelError};
34
34
  use crate::provider::wire::{
35
- builtin_provider_model as wire_builtin_provider_model, is_claude_family,
36
- parse_canonical_provider, provider_model_keys,
35
+ builtin_provider_model as wire_builtin_provider_model, parse_canonical_provider,
36
+ provider_model_keys,
37
37
  };
38
38
 
39
39
  pub const IGNORED_OWNER_TEAM_ID_FIELD: &str = "owner_team_id";
@@ -602,12 +602,9 @@ fn compile_role_agent_with_mode(
602
602
  })
603
603
  .unwrap_or("");
604
604
  let provider_enum = parse_canonical_provider(provider_str).unwrap_or(Provider::Codex);
605
- if effort.is_claude_only()
606
- && !is_claude_family(provider_enum)
607
- && provider_enum != Provider::Pi
608
- {
605
+ if let Err(reason) = effort.resolve_for_provider(provider_enum) {
609
606
  return Err(ModelError::Validation(format!(
610
- "{}: effort '{}' is only supported by claude/claude_code (provider: {provider_str})",
607
+ "{}: {reason} (effort: {}; provider: {provider_str})",
611
608
  role_path.display(),
612
609
  effort.as_str()
613
610
  )));
@@ -9,7 +9,7 @@
9
9
  //! - name: transport_has_session
10
10
  //! what: 探测 tmux session 是否存在,探测本身出错一律当不存在
11
11
  //! - name: provider_effort_from_raw
12
- //! what: 解析 effort 并要求该 provider 支持,否则 None
12
+ //! what: 解析 effort,保留兼容忽略项并拒绝非法 provider 组合
13
13
  //! - name: quick_start_depth_guard
14
14
  //! what: 定出本次 quick-start 的父团队与层级,推不出父团队时拒绝
15
15
  //! - name: annotate_persisted_team_depth
@@ -135,24 +135,24 @@ pub(super) fn parse_auth_mode(raw: &str) -> Option<AuthMode> {
135
135
  /// purpose: 从原始字符串解析 effort,并要求当前 provider 支持它
136
136
  /// params:
137
137
  /// raw: effort 原文,None 或空白返回 None
138
- /// returns: 解析成功且该 provider 支持时返回该档位,否则 None
138
+ /// returns: 支持的档位或兼容忽略的 None;已知非法 provider/effort 组合返回错误
139
139
  /// ---
140
140
  /// 0.4.x provider effort MVP step 4: low-level from a raw string. Returns
141
- /// `Some(effort)` when the level parses AND the provider supports it.
141
+ /// `Some(effort)` for supported levels, None for legacy ignored values,
142
+ /// and an error for provider/effort combinations rejected by configuration.
142
143
  pub(crate) fn provider_effort_from_raw(
143
144
  raw: Option<&str>,
144
145
  provider: Provider,
145
- ) -> Option<ProviderEffort> {
146
- let raw = raw?.trim();
147
- if raw.is_empty() {
148
- return None;
149
- }
150
- let effort = ProviderEffort::parse(raw)?;
151
- if effort.is_supported_by(provider) {
152
- Some(effort)
153
- } else {
154
- None
155
- }
146
+ ) -> Result<Option<ProviderEffort>, LifecycleError> {
147
+ let Some(effort) = raw.and_then(ProviderEffort::parse) else {
148
+ return Ok(None);
149
+ };
150
+ effort.resolve_for_provider(provider).map_err(|reason| {
151
+ LifecycleError::RequirementUnmet(format!(
152
+ "{reason} (effort: {}; provider: {provider:?})",
153
+ effort.as_str()
154
+ ))
155
+ })
156
156
  }
157
157
 
158
158
  /// ---
@@ -171,7 +171,7 @@ pub(crate) fn provider_effort_event_payload(
171
171
  return None;
172
172
  }
173
173
  let effort = ProviderEffort::parse(raw)?;
174
- if effort.is_supported_by(provider) {
174
+ if !matches!(effort.resolve_for_provider(provider), Ok(None)) {
175
175
  return None;
176
176
  }
177
177
  Some(serde_json::json!({
@@ -221,7 +221,7 @@ pub(crate) fn extend_worker_env_unset_for_effort(
221
221
  pub(crate) fn provider_effort_for_spawn(
222
222
  agent: &crate::model::yaml::Value,
223
223
  provider: Provider,
224
- ) -> Option<ProviderEffort> {
224
+ ) -> Result<Option<ProviderEffort>, LifecycleError> {
225
225
  provider_effort_from_raw(agent.get("effort").and_then(|v| v.as_str()), provider)
226
226
  }
227
227
 
@@ -252,7 +252,7 @@ pub(crate) fn provider_effort_event_if_dropped(
252
252
  pub(crate) fn provider_effort_for_spawn_json(
253
253
  agent: &serde_json::Value,
254
254
  provider: Provider,
255
- ) -> Option<ProviderEffort> {
255
+ ) -> Result<Option<ProviderEffort>, LifecycleError> {
256
256
  provider_effort_from_raw(
257
257
  agent.get("effort").and_then(serde_json::Value::as_str),
258
258
  provider,
@@ -307,10 +307,16 @@ pub(super) fn quick_start_depth_guard(
307
307
  requested_team: Option<&str>,
308
308
  _strict_real_runtime: bool,
309
309
  ) -> Result<QuickStartDepth, LifecycleError> {
310
- let env_parent = std::env::var("TEAM_AGENT_OWNER_TEAM_ID")
311
- .ok()
312
- .map(|value| value.trim().to_string())
313
- .filter(|value| !value.is_empty());
310
+ // OWNER_TEAM_ID is injected into every worker process. It is parent
311
+ // context only when the target quick-start workspace is the same workspace
312
+ // that produced the environment; otherwise a worker launching a fresh
313
+ // external workspace would inherit a false nesting parent.
314
+ let env_parent = owner_context_matches_workspace(workspace).then(|| {
315
+ std::env::var("TEAM_AGENT_OWNER_TEAM_ID")
316
+ .ok()
317
+ .map(|value| value.trim().to_string())
318
+ .filter(|value| !value.is_empty())
319
+ }).flatten();
314
320
  let parent = env_parent;
315
321
  let Some(parent) = parent else {
316
322
  let state = crate::state::persist::load_runtime_state(workspace)
@@ -365,6 +371,18 @@ pub(super) fn quick_start_depth_guard(
365
371
  /// purpose: 从 runtime state 推断父团队键
366
372
  /// returns: 活跃团队键,且该团队确有 running 席位时返回它,否则 None
367
373
  /// ---
374
+ fn owner_context_matches_workspace(workspace: &Path) -> bool {
375
+ let Some(env_workspace) = std::env::var_os("TEAM_AGENT_WORKSPACE") else {
376
+ return false;
377
+ };
378
+ let env_workspace = PathBuf::from(env_workspace);
379
+ env_workspace == workspace
380
+ || std::fs::canonicalize(&env_workspace)
381
+ .ok()
382
+ .zip(std::fs::canonicalize(workspace).ok())
383
+ .is_some_and(|(env_path, target_path)| env_path == target_path)
384
+ }
385
+
368
386
  pub(super) fn infer_parent_team_from_active_state(state: &serde_json::Value) -> Option<String> {
369
387
  let active = explicit_active_team_key(state)?;
370
388
  let team = state
@@ -152,7 +152,7 @@ pub(super) fn spawn_agents(
152
152
  // 0.4.x provider effort MVP step 4 + 7: resolve effort and emit
153
153
  // unsupported warning event when the spec asked for an effort the
154
154
  // provider can't satisfy.
155
- let agent_effort = provider_effort_for_spawn(agent, provider);
155
+ let agent_effort = provider_effort_for_spawn(agent, provider)?;
156
156
  if let Some(event_value) = provider_effort_event_if_dropped(agent, provider, agent_id_raw) {
157
157
  let _ = crate::event_log::EventLog::new(workspace)
158
158
  .write("provider.effort_unsupported", event_value);
@@ -355,7 +355,7 @@ pub(super) fn spawn_agent_window(
355
355
  let command_model = profile_launch.command_overrides.model.as_deref().or(model);
356
356
  // 0.4.x provider effort MVP: restart/resume preserves effort from the
357
357
  // persisted agent JSON (state's agent["effort"] field, set by launch).
358
- let restart_effort = crate::lifecycle::launch::provider_effort_for_spawn_json(agent, provider);
358
+ let restart_effort = crate::lifecycle::launch::provider_effort_for_spawn_json(agent, provider)?;
359
359
  if let Some(event_value) = crate::lifecycle::launch::provider_effort_event_if_dropped_json(
360
360
  agent,
361
361
  provider,
@@ -99,17 +99,17 @@ fn stale_snapshot_cannot_flip_status_or_diagnose_ok_readiness() {
99
99
  let case = B0HideOneCase::new("status-diagnose-hide-one");
100
100
  let root = case.current_state();
101
101
  save_runtime_state(&case.workspace, &root).expect("seed current root state");
102
- let probe = brief_probe::install_trusted_nodeprobe(
102
+ let tmux = brief_probe::install_native_tmux(
103
103
  &case.workspace.join("probe"),
104
104
  NEW_ENDPOINT,
105
105
  SESSION,
106
106
  WORKER,
107
107
  "%new",
108
- "fake",
108
+ "bash",
109
109
  );
110
110
  let _path = case.env.with_env(
111
111
  "PATH",
112
- &brief_probe::path_with_probe(&probe, std::env::var_os("PATH").as_deref()),
112
+ &brief_probe::path_with_probe(&tmux, std::env::var_os("PATH").as_deref()),
113
113
  );
114
114
 
115
115
  let status_before = case.run_json(&[
@@ -86,9 +86,8 @@ impl ProviderEffort {
86
86
  matches!(self, Self::Max)
87
87
  }
88
88
 
89
- /// True when the given provider supports this effort level. `max` is
90
- /// Claude-only; other levels are supported by Claude, Codex, and Grok.
91
- /// Copilot/Gemini/CursorAgent/Fake ignore the field (warning at runtime).
89
+ /// Native command support. Claude/Pi accept max; Codex/Grok accept
90
+ /// the other levels. Use resolve_for_provider for admission/ignore policy.
92
91
  pub fn is_supported_by(self, provider: Provider) -> bool {
93
92
  match provider {
94
93
  Provider::Claude | Provider::ClaudeCode | Provider::Pi => true,
@@ -98,6 +97,21 @@ impl ProviderEffort {
98
97
  }
99
98
  }
100
99
  }
100
+
101
+ /// One admission policy for compiler, direct specs and runtime commands.
102
+ /// Ok(None) preserves the legacy warning-and-ignore providers.
103
+ pub fn resolve_for_provider(self, provider: Provider) -> Result<Option<Self>, &'static str> {
104
+ if provider == Provider::CursorAgent {
105
+ return Err("cursor_agent does not support effort; the Cursor CLI has no --effort flag");
106
+ }
107
+ if self.is_supported_by(provider) {
108
+ Ok(Some(self))
109
+ } else if self.is_claude_only() {
110
+ Err("effort 'max' is only supported by claude/claude_code/pi")
111
+ } else {
112
+ Ok(None)
113
+ }
114
+ }
101
115
  }
102
116
 
103
117
  /// auth 模式(`AUTH_MODES` `profiles/constants.py:6`)。
@@ -18,7 +18,7 @@ use crate::model::ids::TaskId;
18
18
  use crate::model::task_graph::{find_dependency_cycle, TaskNode};
19
19
  use crate::model::yaml::Value as Yaml;
20
20
  use crate::model::yaml;
21
- use crate::provider::wire::{is_claude_family, parse_canonical_provider};
21
+ use crate::provider::wire::parse_canonical_provider;
22
22
 
23
23
  /// result_envelope_v1 顶层 required(= allowed)。
24
24
  const RESULT_REQUIRED: &[&str] = &[
@@ -463,15 +463,16 @@ fn check_agent(agent: &Yaml, path: &str, errors: &mut Vec<String>) {
463
463
  "{path}/effort: unknown effort '{raw}' (allowed: low|medium|high|xhigh|max)"
464
464
  ));
465
465
  }
466
- Some(effort) if effort.is_claude_only() => {
466
+ Some(effort) => {
467
467
  let provider = agent.get("provider").and_then(Yaml::as_str).unwrap_or("");
468
- if !parse_canonical_provider(provider).is_some_and(is_claude_family) {
469
- errors.push(format!(
470
- "{path}/effort: effort '{raw}' is only supported by claude/claude_code (provider: {provider})"
471
- ));
468
+ if let Some(provider_enum) = parse_canonical_provider(provider) {
469
+ if let Err(reason) = effort.resolve_for_provider(provider_enum) {
470
+ errors.push(format!(
471
+ "{path}/effort: {reason} (effort: {raw}; provider: {provider})"
472
+ ));
473
+ }
472
474
  }
473
475
  }
474
- Some(_) => {}
475
476
  }
476
477
  }
477
478
  check_keys_y(
@@ -231,7 +231,7 @@ mod tests {
231
231
  );
232
232
  assert!(
233
233
  model_spec_rs.contains("parse_canonical_provider")
234
- && model_spec_rs.contains("is_claude_family"),
234
+ && model_spec_rs.contains("resolve_for_provider"),
235
235
  "model spec must validate providers through typed helpers"
236
236
  );
237
237
  assert!(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.86",
3
+ "version": "0.5.89",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.5.86",
24
- "@team-agent/cli-darwin-x64": "0.5.86",
25
- "@team-agent/cli-linux-x64": "0.5.86"
23
+ "@team-agent/cli-darwin-arm64": "0.5.89",
24
+ "@team-agent/cli-darwin-x64": "0.5.89",
25
+ "@team-agent/cli-linux-x64": "0.5.89"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",