@team-agent/installer 0.5.112 → 0.5.113

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 (44) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +100 -44
  4. package/crates/team-agent/src/cli/emit.rs +127 -98
  5. package/crates/team-agent/src/cli/leaders.rs +32 -32
  6. package/crates/team-agent/src/cli/mod.rs +6 -6
  7. package/crates/team-agent/src/cli/named_address.rs +17 -16
  8. package/crates/team-agent/src/cli/route.rs +4 -4
  9. package/crates/team-agent/src/cli/send/presentation.rs +19 -18
  10. package/crates/team-agent/src/cli/send/resolve.rs +11 -11
  11. package/crates/team-agent/src/cli/send.rs +4 -4
  12. package/crates/team-agent/src/cli/spec.rs +29 -29
  13. package/crates/team-agent/src/cli/status.rs +7 -7
  14. package/crates/team-agent/src/cli/tests/base.rs +6 -6
  15. package/crates/team-agent/src/cli/tests/divergence.rs +3 -2
  16. package/crates/team-agent/src/cli/tests/lane_c.rs +1 -1
  17. package/crates/team-agent/src/cli/tests/named_address.rs +3 -3
  18. package/crates/team-agent/src/cli/tests/status_send.rs +7 -7
  19. package/crates/team-agent/src/cli/triage.rs +53 -2
  20. package/crates/team-agent/src/cli/types.rs +8 -8
  21. package/crates/team-agent/src/compiler/tests.rs +12 -0
  22. package/crates/team-agent/src/compiler.rs +0 -9
  23. package/crates/team-agent/src/leader/start.rs +5 -0
  24. package/crates/team-agent/src/leader/tests/identity.rs +21 -0
  25. package/crates/team-agent/src/lifecycle/launch/identity.rs +172 -13
  26. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +325 -14
  27. package/crates/team-agent/src/lifecycle/launch.rs +2 -2
  28. package/crates/team-agent/src/lifecycle/profile_launch.rs +58 -46
  29. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +97 -52
  30. package/crates/team-agent/src/lifecycle/restart.rs +1 -1
  31. package/crates/team-agent/src/lifecycle/tests/stale_team_saveconflict_contract.rs +3 -2
  32. package/crates/team-agent/src/provider/adapter.rs +8 -59
  33. package/crates/team-agent/src/provider/adapters/claude.rs +6 -14
  34. package/crates/team-agent/src/provider/adapters/codex.rs +8 -18
  35. package/crates/team-agent/src/provider/adapters/copilot.rs +5 -22
  36. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +8 -45
  37. package/crates/team-agent/src/provider/adapters/grok.rs +7 -31
  38. package/crates/team-agent/src/provider/adapters/pi.rs +7 -18
  39. package/crates/team-agent/src/provider/command_helpers.rs +45 -0
  40. package/crates/team-agent/src/provider/mod.rs +1 -0
  41. package/crates/team-agent/src/provider/model_catalog.rs +21 -28
  42. package/crates/team-agent/src/provider/tests/adapter.rs +70 -0
  43. package/crates/team-agent/src/provider/tests/command_helpers.rs +55 -0
  44. package/package.json +4 -4
@@ -209,7 +209,7 @@ fn csv_agent_status(
209
209
  health: Option<&serde_json::Map<String, Value>>,
210
210
  ) -> &'static str {
211
211
  if stale_agent_bucket(agent).is_some() {
212
- return "错误";
212
+ return "error";
213
213
  }
214
214
  let raw = agent
215
215
  .get("status")
@@ -226,7 +226,7 @@ fn csv_agent_status(
226
226
  || matches!(hstatus.as_str(), "failed" | "error" | "spawn_failed")
227
227
  || matches!(raw.as_str(), "stopped" | "done")
228
228
  {
229
- return "错误";
229
+ return "error";
230
230
  }
231
231
  let pane_present = agent
232
232
  .get("pane_id")
@@ -239,21 +239,21 @@ fn csv_agent_status(
239
239
  .filter(|pane| !pane.is_empty())
240
240
  .is_some();
241
241
  if matches!(raw.as_str(), "running" | "busy" | "working") && !pane_present {
242
- return "错误";
242
+ return "error";
243
243
  }
244
244
  // 0.5.35 R4: canonical UNKNOWN beats legacy WORKING in human CSV too.
245
245
  if canonical_worker_state_is_unknown(agent) {
246
- return "未知";
246
+ return "unknown";
247
247
  }
248
248
  if raw == "idle" || hstatus == "idle" {
249
- return "空闲";
249
+ return "idle";
250
250
  }
251
251
  if matches!(raw.as_str(), "running" | "busy" | "working")
252
252
  || matches!(hstatus.as_str(), "running" | "working" | "busy")
253
253
  {
254
- return "工作";
254
+ return "working";
255
255
  }
256
- "错误"
256
+ "error"
257
257
  }
258
258
 
259
259
  /// `_latest_result_line`(`commands.py:333-337`):agent_id/summary/created_at 渲染单行。
@@ -420,7 +420,7 @@ fn format_status_csv_preserves_agent_order_and_collapses_errors() {
420
420
  });
421
421
  assert_eq!(
422
422
  format_status_csv(&data),
423
- "zeta,空闲\nalpha,工作\nerr_failed,错误\nerr_missing_pane,错误\nerr_unknown,错误\nerr_stopped,错误"
423
+ "zeta,idle\nalpha,working\nerr_failed,error\nerr_missing_pane,error\nerr_unknown,error\nerr_stopped,error"
424
424
  );
425
425
  }
426
426
 
@@ -489,7 +489,7 @@ fn cli_error_payload_plain_runtime() {
489
489
  assert_eq!(payload.error, "some other error");
490
490
  assert_eq!(
491
491
  payload.action,
492
- "先运行 team-agent doctor --workspace . 检查所选队伍,或查看此处列出的错误日志。"
492
+ "Run team-agent doctor --workspace . for the selected team, or inspect the error log shown here."
493
493
  );
494
494
  assert_eq!(payload.log, "/tmp/y.log");
495
495
  assert_eq!(payload.reason, None);
@@ -511,7 +511,7 @@ fn cli_error_payload_tmux_conflict_quick_start_enrichment() {
511
511
  // context reset is only through restart --allow-fresh with explicit consent.
512
512
  assert_eq!(
513
513
  payload.action,
514
- "终端会话 `my-team` 已存在,可能是你的已有队伍。恢复用 team-agent restart;只有明确同意丢弃旧对话才用 --allow-fresh。另建队伍请修改 TEAM.md 的 name 后再 quick-start;不要关闭已有队伍凑成功。"
514
+ "Terminal session `my-team` already exists and may belong to your existing team. Resume with team-agent restart; use --allow-fresh only after explicitly accepting loss of saved context. To create another team, change the name in TEAM.md before quick-start. Do not shut down an existing team to bypass this refusal."
515
515
  );
516
516
  let next = payload.next_actions.as_ref().unwrap();
517
517
  assert_eq!(next.len(), 2);
@@ -529,11 +529,11 @@ fn cli_error_payload_tmux_conflict_non_quick_start_enrichment() {
529
529
  assert_eq!(payload.session_name.as_deref(), Some("my-team"));
530
530
  assert_eq!(
531
531
  payload.action,
532
- "终端会话 `my-team` 已存在,可能属于运行中的队伍。不要关闭它;请使用另一个队伍名称后再启动。"
532
+ "Terminal session `my-team` already exists and may belong to a running team. Do not shut it down; choose another team name before starting."
533
533
  );
534
534
  let next = payload.next_actions.as_ref().unwrap();
535
535
  assert_eq!(next.len(), 1);
536
- assert!(next[0].contains("队伍") && next[0].contains("名称"));
536
+ assert!(next[0].contains("team") && next[0].contains("name"));
537
537
  }
538
538
 
539
539
  #[test]
@@ -749,7 +749,7 @@ fn cmd_leader_passthrough_help_is_pure_human_guidance() {
749
749
  let result = cmd_leader_passthrough(provider, &[alias.into()], &cwd).unwrap();
750
750
  assert_eq!(result.exit, ExitCode::Ok);
751
751
  assert!(
752
- matches!(&result.output, CmdOutput::Human(text) if text.contains(provider) && text.contains("用法") && text.contains("Examples") && text.contains("下一步"))
752
+ matches!(&result.output, CmdOutput::Human(text) if text.contains(provider) && text.contains("Usage") && text.contains("Examples") && text.contains("Next Action"))
753
753
  );
754
754
  }
755
755
  }
@@ -457,7 +457,7 @@ fn red_send_target_none_is_not_broadcast() {
457
457
  assert_eq!(send_target(None, Some("*")), MessageTarget::Broadcast);
458
458
  }
459
459
 
460
- // Unified doctor human output is triage only, including the comms gate.
460
+ // Doctor retains bounded triage navigation plus complete diagnostic details.
461
461
  #[test]
462
462
  fn cmd_doctor_comms_human_is_compact_triage_without_boundary_prefix() {
463
463
  let mut args = DoctorArgs {
@@ -483,8 +483,9 @@ fn cmd_doctor_comms_human_is_compact_triage_without_boundary_prefix() {
483
483
  };
484
484
  assert_eq!(text, crate::cli::triage::render("doctor", &report));
485
485
  assert!(text.starts_with("doctor:"));
486
- assert_eq!(text.lines().count(), 1 + report["issues"].as_array().unwrap().len()
486
+ assert_eq!(text.lines().count(), 2 + report["issues"].as_array().unwrap().len()
487
487
  + report["suggested_repairs"].as_array().unwrap().len());
488
+ assert!(text.lines().last().unwrap().starts_with("details: "));
488
489
  }
489
490
 
490
491
  // ---- #13 / #27 (P2): run() must NOT treat 'claude_code' as a passthrough trigger ----
@@ -257,7 +257,7 @@ fn inbox_excludes_leader_stage_result_notifications() {
257
257
  // the deterministic check sub-shapes (run_id is a random uuid; not value-locked). ────────────────
258
258
  #[test]
259
259
  fn comms_selftest_golden_boundary_scope_and_check_shapes() {
260
- let boundary = "这里只检查终端连接是否一致,不实际发送消息,也不证明队友已回复。";
260
+ let boundary = "This checks terminal connection consistency only; it does not send a message or prove that a worker replied.";
261
261
  let ws = tmp_workspace();
262
262
  let v = diagnose_port::comms_selftest(&ws, None, None).expect("comms_selftest");
263
263
  let obj = v.as_object().expect("comms dict");
@@ -377,21 +377,21 @@ fn send_to_name_mutual_exclusion() {
377
377
  let args = named_send_args(&cwd, Some("team-a/qa"), Some("%1"), None, &["hello"]);
378
378
  let err = cmd_send(&args).unwrap_err();
379
379
  assert!(
380
- matches!(err, CliError::Usage(ref message) if message.contains("--to-name/--pane/--to") && message.contains("不能同时")),
380
+ matches!(err, CliError::Usage(ref message) if message.contains("--to-name/--pane/--to") && message.contains("cannot be combined")),
381
381
  "expected --to-name mutual exclusion, got {err:?}"
382
382
  );
383
383
 
384
384
  let args = named_send_args(&cwd, Some("team-a/qa"), None, Some("worker"), &["hello"]);
385
385
  let err = cmd_send(&args).unwrap_err();
386
386
  assert!(
387
- matches!(err, CliError::Usage(ref message) if message.contains("--to-name/--pane/--to") && message.contains("不能同时")),
387
+ matches!(err, CliError::Usage(ref message) if message.contains("--to-name/--pane/--to") && message.contains("cannot be combined")),
388
388
  "expected --to-name/--to mutual exclusion, got {err:?}"
389
389
  );
390
390
 
391
391
  let args = named_send_args(&cwd, Some("team-a/qa"), None, None, &[]);
392
392
  let err = cmd_send(&args).unwrap_err();
393
393
  assert!(
394
- matches!(err, CliError::Usage(ref message) if message == "请填写任务内容;--to-name 不能发送空消息"),
394
+ matches!(err, CliError::Usage(ref message) if message == "Provide a task message; --to-name cannot send an empty message"),
395
395
  "expected empty-message usage error, got {err:?}"
396
396
  );
397
397
  let _ = std::fs::remove_dir_all(&cwd);
@@ -641,11 +641,11 @@ fn cmd_send_joins_message_with_single_space() {
641
641
  // 应立即失败以强制重新审视,而不是悄悄跑一条从未验过的分支。
642
642
  assert!(
643
643
  !delivered,
644
- "hermetic seed 不应产生 delivered=true;tier 假设变了"
644
+ "hermetic seed must not produce delivered=true; the fixture assumption changed"
645
645
  );
646
646
  assert_eq!(
647
647
  v.get("reminder").and_then(|reminder| reminder.as_str()),
648
- Some("任务已收下,但还未确认送到队友对话。查看 team-agent status/doctor,等真实回复,不反复重发。")
648
+ Some("Task accepted, but delivery is unconfirmed. Check team-agent status/doctor; wait for an actual reply without repeated sends.")
649
649
  );
650
650
  }
651
651
  }
@@ -665,15 +665,15 @@ fn cmd_send_default_human_output_is_brief_without_false_delivered() {
665
665
  "send must separate outcome and next action: {text}"
666
666
  );
667
667
  assert!(
668
- lines[0].contains("任务已收下") && lines[0].contains("还未送到队友的对话"),
668
+ lines[0].contains("Task accepted") && lines[0].contains("delivery to the agent's conversation is still pending"),
669
669
  "queued send must report its true pending outcome: {text}"
670
670
  );
671
671
  assert!(
672
- lines[1].contains("下一步")
672
+ lines[1].contains("Next:")
673
673
  && lines[1].contains("team-agent status")
674
674
  && lines[1].contains("doctor")
675
- && lines[1].contains("等真实回复")
676
- && lines[1].contains("不反复重发"),
675
+ && lines[1].contains("wait for an actual reply")
676
+ && lines[1].contains("without repeated sends"),
677
677
  "queued send must give honest next steps: {text}"
678
678
  );
679
679
  assert!(
@@ -951,7 +951,7 @@ fn cmd_send_unknown_task_surfaces_golden_error_envelope_not_silent() {
951
951
  );
952
952
  assert_eq!(
953
953
  payload.action,
954
- "先运行 team-agent doctor --workspace . 检查所选队伍,或查看此处列出的错误日志。"
954
+ "Run team-agent doctor --workspace . for the selected team, or inspect the error log shown here."
955
955
  );
956
956
  let _ = std::fs::remove_dir_all(&ws);
957
957
  }
@@ -56,9 +56,34 @@ pub(crate) fn render(command: &str, report: &Value) -> String {
56
56
  for repair in repairs {
57
57
  lines.push(bounded(format!("repair: {}", summarize(repair, true))));
58
58
  }
59
+ // Short triage lines are navigation, not the authoritative refusal. Retain
60
+ // complete causes/actions as JSON, without dumping unrelated runtime or
61
+ // provider diagnostics. Values have already crossed the redaction boundary.
62
+ let details = diagnostic_details(&report);
63
+ if details.as_object().is_some_and(|details| !details.is_empty()) {
64
+ lines.push(format!("details: {details}"));
65
+ }
59
66
  lines.join("\n")
60
67
  }
61
68
 
69
+ // Shared by doctor/diagnose and send. Never include message bodies, profiles or
70
+ // unrelated runtime/provider dumps in a command's diagnostic output.
71
+ pub(crate) fn diagnostic_details(report: &Value) -> Value {
72
+ let report = crate::redaction::redact_external_value(report);
73
+ let mut details = serde_json::Map::new();
74
+ for key in [
75
+ "error", "status", "summary", "reason", "blockers", "action",
76
+ "next_action", "next_actions", "issues", "suggested_repairs",
77
+ "state_path", "log", "schema_error", "stage", "delivery_status",
78
+ "message_status", "verification", "channel", "turn_verification",
79
+ ] {
80
+ if let Some(value) = report.get(key) {
81
+ details.insert(key.to_string(), value.clone());
82
+ }
83
+ }
84
+ Value::Object(details)
85
+ }
86
+
62
87
  fn array_items(value: Option<&Value>) -> &[Value] {
63
88
  value.and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[])
64
89
  }
@@ -126,6 +151,7 @@ fn bounded(line: String) -> String {
126
151
 
127
152
  #[cfg(test)]
128
153
  mod tests {
154
+ #![allow(clippy::unwrap_used)]
129
155
  use super::*;
130
156
  use serde_json::json;
131
157
 
@@ -149,12 +175,37 @@ mod tests {
149
175
  #[test]
150
176
  fn renderer_sanitizes_and_bounds_utf8_lines() {
151
177
  let report = json!({"ok": false, "issues": ["问题\n".to_string() + &"x".repeat(200)]});
152
- for line in render("doctor", &report).lines() {
153
- assert!(line.len() <= LINE_LIMIT_BYTES);
178
+ let output = render("doctor", &report);
179
+ for line in output.lines() {
180
+ if let Some(details) = line.strip_prefix("details: ") {
181
+ let parsed: Value = serde_json::from_str(details).unwrap();
182
+ assert_eq!(parsed.get("issues"), report.get("issues"));
183
+ } else {
184
+ assert!(line.len() <= LINE_LIMIT_BYTES);
185
+ }
154
186
  assert!(!line.chars().any(char::is_control));
155
187
  }
156
188
  }
157
189
 
190
+ #[test]
191
+ fn renderer_preserves_typed_failure_details_and_long_actions() {
192
+ let action = "inspect the precise failure ".repeat(20);
193
+ let report = json!({
194
+ "ok": false,
195
+ "status": "preflight_blocked",
196
+ "reason": "exact refusal",
197
+ "blockers": [{"stage": "database", "reason": "permission denied"}],
198
+ "next_actions": [action],
199
+ "state_path": "/tmp/workspace/.team/runtime/state.json"
200
+ });
201
+ let output = render("doctor", &report);
202
+ let details = output.lines().find_map(|line| line.strip_prefix("details: ")).unwrap();
203
+ let parsed: Value = serde_json::from_str(details).unwrap();
204
+ for key in ["status", "reason", "blockers", "next_actions", "state_path"] {
205
+ assert_eq!(parsed.get(key), report.get(key), "lost {key}");
206
+ }
207
+ }
208
+
158
209
  #[test]
159
210
  fn renderer_projects_secret_findings_without_values() {
160
211
  let report = json!({
@@ -84,7 +84,7 @@ impl CliError {
84
84
  ok: false,
85
85
  error: error.clone(),
86
86
  action: if super::spec::command_spec(command).is_some() {
87
- "先运行 team-agent doctor --workspace . 检查所选队伍,或查看此处列出的错误日志。"
87
+ "Run team-agent doctor --workspace . for the selected team, or inspect the error log shown here."
88
88
  } else {
89
89
  "run `team-agent doctor` or inspect the log path shown here"
90
90
  }
@@ -99,27 +99,27 @@ impl CliError {
99
99
  payload.session_name = Some(session.clone());
100
100
  if command == "quick-start" {
101
101
  payload.action = format!(
102
- "终端会话 `{session}` 已存在,可能是你的已有队伍。恢复用 team-agent restart;只有明确同意丢弃旧对话才用 --allow-fresh。另建队伍请修改 TEAM.md 的 name 后再 quick-start;不要关闭已有队伍凑成功。"
102
+ "Terminal session `{session}` already exists and may belong to your existing team. Resume with team-agent restart; use --allow-fresh only after explicitly accepting loss of saved context. To create another team, change the name in TEAM.md before quick-start. Do not shut down an existing team to bypass this refusal."
103
103
  );
104
104
  payload.next_actions = Some(vec![
105
- "如果是自己的已有队伍,使用 team-agent restart 恢复。".to_string(),
106
- "如要另建队伍,修改 TEAM.md 的 name 后再运行 team-agent quick-start。"
105
+ "If this is your existing team, resume it with team-agent restart.".to_string(),
106
+ "To create another team, change the name in TEAM.md before running team-agent quick-start."
107
107
  .to_string(),
108
108
  ]);
109
109
  } else {
110
110
  payload.action = format!(
111
- "终端会话 `{session}` 已存在,可能属于运行中的队伍。不要关闭它;请使用另一个队伍名称后再启动。"
111
+ "Terminal session `{session}` already exists and may belong to a running team. Do not shut it down; choose another team name before starting."
112
112
  );
113
- payload.next_actions = Some(vec!["请使用另一个队伍名称后再启动。".to_string()]);
113
+ payload.next_actions = Some(vec!["Choose another team name before starting.".to_string()]);
114
114
  }
115
115
  } else if error.contains("Team Agent launcher flag")
116
116
  && error.contains("must appear before --")
117
117
  {
118
118
  payload.action =
119
- String::from("将主控启动选项放在 -- 前面;-- 后面只能是工具自己的参数。");
119
+ String::from("Place Team Agent launcher options before --; only native provider arguments belong after --.");
120
120
  } else if error.contains("managed launcher refuses a different ambient tmux server") {
121
121
  payload.action = String::from(
122
- "先退出当前 tmux 客户端再重试;只有明确需要嵌套连接时才在 -- 前加 --allow-nested-attach。",
122
+ "Leave the current tmux client before retrying. Add --allow-nested-attach before -- only when nested attachment is intentional.",
123
123
  );
124
124
  }
125
125
  payload
@@ -7,6 +7,18 @@ use std::sync::atomic::{AtomicU32, Ordering};
7
7
 
8
8
  static COUNTER: AtomicU32 = AtomicU32::new(0);
9
9
 
10
+ #[test]
11
+ fn cursor_effort_is_rejected_by_shared_admission_for_every_level() {
12
+ for effort in ["low", "medium", "high", "xhigh", "max", "ultra"] {
13
+ let role = write_tmp("cursor.md", &format!("---\nprovider: cursor_agent\neffort: {effort}\n---\nRole\n"));
14
+ let error = compile_role_agent(&role, &Value::Map(Vec::new()), "/workspace")
15
+ .err()
16
+ .expect("Cursor must reject effort before command construction");
17
+ assert!(error.to_string().contains("cursor_agent does not support effort; the Cursor CLI has no --effort flag"));
18
+ fs::remove_dir_all(role.parent().unwrap()).unwrap();
19
+ }
20
+ }
21
+
10
22
  fn unique_base() -> PathBuf {
11
23
  let n = COUNTER.fetch_add(1, Ordering::Relaxed);
12
24
  std::env::temp_dir().join(format!("ta_rs_compile_{}_{}", std::process::id(), n))
@@ -590,15 +590,6 @@ fn compile_role_agent_with_mode(
590
590
  effort.as_str()
591
591
  )));
592
592
  }
593
- if provider_enum == Provider::CursorAgent {
594
- return Err(ModelError::Validation(format!(
595
- "{}: cursor_agent does not support effort '{}'. \
596
- The Cursor CLI has no `--effort` flag; the framework must not drop the field and still launch. \
597
- Remove effort from the role file (do not map it into `--model[effort=]` until that form is measured).",
598
- role_path.display(),
599
- effort.as_str()
600
- )));
601
- }
602
593
  agent_items.push(("effort", Value::Str(effort.as_str().to_string())));
603
594
  }
604
595
  Ok(CompiledRole {
@@ -403,6 +403,11 @@ pub(crate) fn leader_env_for_identity(
403
403
  "1".to_string(),
404
404
  );
405
405
  }
406
+ // Managed providers run after `sh -lc`; restore the launcher's native
407
+ // executable lookup path at invocation, not just in the tmux client env.
408
+ if let Some(path) = std::env::var_os("PATH").and_then(|path| path.into_string().ok()) {
409
+ leader_env.insert("PATH".to_string(), path);
410
+ }
406
411
  leader_env
407
412
  }
408
413
 
@@ -291,6 +291,27 @@ fn copilot_leader_env_disables_terminal_title_only_for_copilot() {
291
291
  }
292
292
  }
293
293
 
294
+ #[test]
295
+ #[serial_test::serial(env)]
296
+ fn leader_env_preserves_native_path_for_managed_shell_wrapper() {
297
+ let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
298
+ let native_path = "/tmp/native leader/bin:/usr/bin:/bin";
299
+ let _env = EnvGuard::apply(&[("PATH", Some(native_path))]);
300
+ let ws = p2_temp_ws("native_path");
301
+ let identity = leader_identity_context(&ws, None, Some(&serde_json::json!({}))).unwrap();
302
+ let env = leader_env_for_identity(Provider::Pi, &identity);
303
+ assert_eq!(env.get("PATH").map(String::as_str), Some(native_path));
304
+ let command = crate::tmux_backend::leader_shell_wrapper_command(
305
+ &["pi".to_string(), "--name".to_string(), "leader".to_string()],
306
+ &ws, &env, &[], "pi",
307
+ );
308
+ assert!(command.contains("PATH='/tmp/native leader/bin:/usr/bin:/bin'"));
309
+ assert!(command.find("PATH=").unwrap() < command.find(" pi --name leader").unwrap());
310
+ assert_eq!(env.get("TEAM_AGENT_TEAM_ID").map(String::as_str), Some(identity.team_id.as_str()));
311
+ let _unset = EnvGuard::apply(&[("PATH", None)]);
312
+ assert!(!leader_env_for_identity(Provider::Pi, &identity).contains_key("PATH"));
313
+ }
314
+
294
315
  // ═══════════════ P2 FIX-LOOP RED (复绿即对抗 cross-model findings) ═══════════════
295
316
  // Lock CORRECT Python v0.2.11 leader-identity behavior the contracts missed.
296
317
  // Golden re-probed via /tmp/probe_p2_leader.py vs team-agent-public @ 439bef8
@@ -476,26 +476,62 @@ pub(super) fn annotate_persisted_team_depth(
476
476
  }
477
477
 
478
478
  /// ---
479
- /// purpose: 判断 runtime state 里是否已存在该 quick-start 团队
480
- /// returns: 活跃键、teams 表、投影出的团队键、身份字段或 session 名任一命中即 true
479
+ /// purpose: 判断目标团队是否已有 worker runtime,已证明的 Leader-only 登记不算初始化
480
+ /// returns: 未初始化的有效 Leader-only 登记为 false;已有或无法分类的目标 runtime 为 true
481
481
  /// ---
482
482
  pub(super) fn runtime_state_has_quick_start_team(state: &serde_json::Value, team: &str) -> bool {
483
- explicit_active_team_key(state).as_deref() == Some(team)
484
- || state
485
- .get("teams")
486
- .and_then(serde_json::Value::as_object)
487
- .is_some_and(|teams| {
488
- teams.contains_key(team)
489
- || teams
490
- .values()
491
- .any(|entry| json_team_identity_matches(entry, team))
492
- })
483
+ let entry = state
484
+ .get("teams")
485
+ .and_then(serde_json::Value::as_object)
486
+ .and_then(|teams| teams.get(team));
487
+ if let Some(entry) = entry {
488
+ return !is_quick_start_leader_bootstrap(entry);
489
+ }
490
+ // Legacy aliases still identify existing/ambiguous runtime, not a proven
491
+ // canonical bootstrap record. Do not grant fresh creation through an alias.
492
+ if state
493
+ .get("teams")
494
+ .and_then(serde_json::Value::as_object)
495
+ .is_some_and(|teams| teams.values().any(|entry| json_team_identity_matches(entry, team)))
496
+ {
497
+ return true;
498
+ }
499
+ let matches = explicit_active_team_key(state).as_deref() == Some(team)
493
500
  || crate::state::projection::team_state_key(state) == team
494
501
  || json_team_identity_matches(state, team)
495
502
  || state
496
503
  .get("session_name")
497
504
  .and_then(serde_json::Value::as_str)
498
- .is_some_and(|session| session == team || session.strip_prefix("team-") == Some(team))
505
+ .is_some_and(|session| session == team || session.strip_prefix("team-") == Some(team));
506
+ matches && !is_quick_start_leader_bootstrap(state)
507
+ }
508
+
509
+ // An empty agent table alone is not fresh: stopped/removed/partial teams keep
510
+ // their spec, session or task history. Only a coherent attached leader binding
511
+ // with no worker initialization evidence may proceed through first creation.
512
+ pub(super) fn is_quick_start_leader_bootstrap(state: &serde_json::Value) -> bool {
513
+ if !state.is_object()
514
+ || state.get("teams").is_some_and(|teams| !teams.is_object())
515
+ || !state.get("agents").and_then(serde_json::Value::as_object).is_some_and(|agents| agents.is_empty())
516
+ || state.get("spec_path").is_some()
517
+ || state.get("team_dir").is_some_and(|dir| !dir.is_null())
518
+ || state.get("session_name").is_some_and(|session| !session.is_null())
519
+ || state.get("tasks").is_some_and(|tasks| {
520
+ !tasks.as_object().is_some_and(|tasks| tasks.is_empty())
521
+ && !tasks.as_array().is_some_and(|tasks| tasks.is_empty())
522
+ })
523
+ || state.get("status").is_some_and(|status| status.as_str() != Some("alive"))
524
+ || state.get("archived_at").is_some_and(|value| !value.is_null())
525
+ {
526
+ return false;
527
+ }
528
+ let owner = state.get("team_owner");
529
+ let receiver = state.get("leader_receiver");
530
+ let owner_pane = owner.and_then(|owner| owner.get("pane_id")).and_then(serde_json::Value::as_str).filter(|pane| !pane.is_empty());
531
+ let receiver_pane = receiver.and_then(|receiver| receiver.get("pane_id")).and_then(serde_json::Value::as_str);
532
+ owner_pane.is_some()
533
+ && owner_pane == receiver_pane
534
+ && receiver.and_then(|receiver| receiver.get("status")).and_then(serde_json::Value::as_str) == Some("attached")
499
535
  }
500
536
 
501
537
  /// ---
@@ -513,3 +549,126 @@ pub(super) fn json_team_identity_matches(state: &serde_json::Value, team: &str)
513
549
  .and_then(serde_json::Value::as_str)
514
550
  .is_some_and(|value| value == team)
515
551
  }
552
+
553
+ #[cfg(test)]
554
+ mod quick_start_runtime_identity_tests {
555
+ use super::runtime_state_has_quick_start_team;
556
+ use serde_json::{json, Value};
557
+
558
+ fn leader_bootstrap() -> Value {
559
+ json!({
560
+ "agents": {},
561
+ "session_name": null,
562
+ "tasks": {},
563
+ "team_owner": {"pane_id": "%0", "provider": "pi", "owner_epoch": 1},
564
+ "leader_receiver": {"pane_id": "%0", "status": "attached", "session_name": "team-agent-leader-pi-fixture"},
565
+ "leader_client": {"diagnostic_only": true}
566
+ })
567
+ }
568
+
569
+ #[test]
570
+ fn current_launcher_seed_is_not_initialized_and_is_not_mutated() {
571
+ let seed = leader_bootstrap();
572
+ let state = json!({"active_team_key": "current", "team_key": "current", "teams": {"current": seed}});
573
+ let before = state.clone();
574
+ assert!(!runtime_state_has_quick_start_team(&state, "current"));
575
+ assert_eq!(state, before);
576
+ }
577
+
578
+ #[test]
579
+ fn real_native_managed_launcher_empty_array_shape_is_fresh_and_unchanged() {
580
+ let mut entry = leader_bootstrap();
581
+ entry["tasks"] = json!([]);
582
+ entry["is_external_leader"] = json!(false);
583
+ entry["team_key"] = json!("current");
584
+ entry["tmux_socket"] = json!("/private/tmp/fixture-native-leader");
585
+ entry["owner_epoch"] = json!(1);
586
+ entry["team_owner"]["machine_fingerprint"] = json!("");
587
+ entry["team_owner"]["leader_session_uuid"] = json!("fixture-native-uuid");
588
+ entry["leader_receiver"]["owner_epoch"] = json!(1);
589
+ entry["leader_receiver"]["leader_session_uuid"] = json!("fixture-native-uuid");
590
+ let state = json!({
591
+ "agents": {}, "tasks": [], "session_name": null,
592
+ "active_team_key": "current", "team_key": "current",
593
+ "teams": {"current": entry}
594
+ });
595
+ let before = state.clone();
596
+ assert!(!runtime_state_has_quick_start_team(&state, "current"));
597
+ assert_eq!(state, before);
598
+ }
599
+
600
+ #[test]
601
+ fn legacy_flat_leader_binding_can_initialize() {
602
+ let mut state = leader_bootstrap();
603
+ state["active_team_key"] = json!("current");
604
+ assert!(!runtime_state_has_quick_start_team(&state, "current"));
605
+ }
606
+
607
+ #[test]
608
+ fn stopped_removed_partial_and_unknown_teams_remain_existing() {
609
+ let cases = [
610
+ ("agents", json!({"worker": {"status": "stopped"}})),
611
+ ("spec_path", json!("/tmp/current/team.spec.yaml")),
612
+ ("spec_path", Value::Null),
613
+ ("team_dir", json!("/tmp/.team/current")),
614
+ ("session_name", json!("team-current")),
615
+ ("session_name", json!("team-agent-leader-stale-worker-field")),
616
+ ("tasks", json!({"finished_task": {"status": "done"}})),
617
+ ("tasks", json!([{"status": "done"}])),
618
+ ("tasks", Value::Null),
619
+ ("tasks", json!("unknown")),
620
+ ("tasks", json!(false)),
621
+ ("tasks", json!(0)),
622
+ ("status", json!("stopped")),
623
+ ("archived_at", json!("2026-10-08")),
624
+ ("agents", Value::Null),
625
+ ("agents", json!([])),
626
+ ("team_owner", Value::Null),
627
+ ("leader_receiver", json!({"pane_id": "%99", "status": "attached"})),
628
+ ("leader_receiver", json!({"pane_id": "%0", "status": "pending"})),
629
+ ];
630
+ for (key, value) in cases {
631
+ let mut entry = leader_bootstrap();
632
+ entry[key] = value;
633
+ let state = json!({"active_team_key": "current", "teams": {"current": entry}});
634
+ assert!(runtime_state_has_quick_start_team(&state, "current"), "must protect {key}: {state}");
635
+ }
636
+ for entry in [json!({}), Value::Null, json!({"agents": {}, "session_name": null})] {
637
+ let state = json!({"teams": {"current": entry}});
638
+ assert!(runtime_state_has_quick_start_team(&state, "current"));
639
+ }
640
+ }
641
+
642
+ #[test]
643
+ fn malformed_team_tables_do_not_allow_flat_bootstrap_to_bypass_the_guard() {
644
+ for teams in [Value::Null, json!([]), json!("damaged")] {
645
+ let mut state = leader_bootstrap();
646
+ state["active_team_key"] = json!("current");
647
+ state["teams"] = teams;
648
+ assert!(runtime_state_has_quick_start_team(&state, "current"));
649
+ }
650
+ }
651
+
652
+ #[test]
653
+ fn only_the_requested_team_supplies_initialization_evidence() {
654
+ let state = json!({
655
+ "active_team_key": "other", "spec_path": "/tmp/other/spec.yaml",
656
+ "session_name": "team-other", "agents": {"other_worker": {}},
657
+ "teams": {
658
+ "other": {"spec_path": "/tmp/other/spec.yaml", "agents": {"other_worker": {}}},
659
+ "current": leader_bootstrap()
660
+ }
661
+ });
662
+ assert!(!runtime_state_has_quick_start_team(&state, "current"));
663
+ assert!(runtime_state_has_quick_start_team(&state, "other"));
664
+ assert!(!runtime_state_has_quick_start_team(&state, "new-team"));
665
+ }
666
+
667
+ #[test]
668
+ fn ambiguous_legacy_identity_is_not_fresh() {
669
+ let mut first = leader_bootstrap();
670
+ first["name"] = json!("current");
671
+ let state = json!({"teams": {"first": first, "second": {"name": "current"}}});
672
+ assert!(runtime_state_has_quick_start_team(&state, "current"));
673
+ }
674
+ }