@team-agent/installer 0.3.8 → 0.3.10

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 (30) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/emit.rs +20 -4
  4. package/crates/team-agent/src/cli/leader.rs +12 -8
  5. package/crates/team-agent/src/cli/tests/base.rs +14 -0
  6. package/crates/team-agent/src/cli/tests/run_delegation.rs +6 -2
  7. package/crates/team-agent/src/coordinator/tests/spine.rs +6 -0
  8. package/crates/team-agent/src/coordinator/tick.rs +83 -1
  9. package/crates/team-agent/src/leader/lease.rs +19 -0
  10. package/crates/team-agent/src/leader/rediscover/tests.rs +12 -0
  11. package/crates/team-agent/src/leader/rediscover.rs +2 -0
  12. package/crates/team-agent/src/leader/start.rs +34 -23
  13. package/crates/team-agent/src/leader/tests/identity.rs +22 -0
  14. package/crates/team-agent/src/leader/tests/wake_start_owner.rs +13 -0
  15. package/crates/team-agent/src/lifecycle/launch.rs +35 -0
  16. package/crates/team-agent/src/lifecycle/restart/agent.rs +17 -3
  17. package/crates/team-agent/src/lifecycle/restart/common.rs +75 -0
  18. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +211 -6
  19. package/crates/team-agent/src/lifecycle/restart/selection.rs +51 -14
  20. package/crates/team-agent/src/lifecycle/restart.rs +8 -4
  21. package/crates/team-agent/src/lifecycle/tests/core.rs +89 -15
  22. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +144 -3
  23. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +3 -1
  24. package/crates/team-agent/src/messaging/delivery.rs +83 -2
  25. package/crates/team-agent/src/messaging/results.rs +27 -22
  26. package/crates/team-agent/src/messaging/tests/runtime.rs +108 -0
  27. package/crates/team-agent/src/provider/approvals/parsing.rs +43 -14
  28. package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +12 -9
  29. package/crates/team-agent/src/transport/test_support.rs +12 -1
  30. package/package.json +4 -4
package/Cargo.lock CHANGED
@@ -566,7 +566,7 @@ dependencies = [
566
566
 
567
567
  [[package]]
568
568
  name = "team-agent"
569
- version = "0.3.8"
569
+ version = "0.3.10"
570
570
  dependencies = [
571
571
  "anyhow",
572
572
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent"]
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.3.8"
12
+ version = "0.3.10"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -23,7 +23,7 @@ pub fn emit(output: &CmdOutput, as_json: bool) -> Option<String> {
23
23
  }
24
24
  }
25
25
 
26
- /// `main(argv)`(`parser.py:84`):**CLI 唯一进程入口**。codex/claude passthrough 早返回 →
26
+ /// `main(argv)`(`parser.py:84`):**CLI 唯一进程入口**。codex/claude/copilot passthrough 早返回 →
27
27
  /// 解析 argv 到 subcommand → 调对应 handler → 异常落盘 + 信封 + `ExitCode::Error` →
28
28
  /// `consume_leader_inbox_summary` → `emit` → `result.ok is False ? Error : Ok`。
29
29
  /// **行为入口**:契约可端到端跑 argv→(stdout, exit code)。
@@ -31,7 +31,7 @@ pub fn run(argv: &[String], cwd: &Path) -> ExitCode {
31
31
  let Some(command) = argv.first().map(String::as_str) else {
32
32
  return emit_missing_subcommand_usage();
33
33
  };
34
- if command == "codex" || command == "claude" {
34
+ if command == "codex" || command == "claude" || command == "copilot" {
35
35
  return cmd_leader_passthrough(command, &argv[1..], cwd)
36
36
  .map(emit_result)
37
37
  .unwrap_or(ExitCode::Error);
@@ -193,7 +193,7 @@ fn is_known_subcommand(command: &str) -> bool {
193
193
  fn command_help(command: Option<&str>) -> String {
194
194
  match command {
195
195
  None => {
196
- let mut commands = vec!["codex", "claude"];
196
+ let mut commands = vec!["codex", "claude", "copilot"];
197
197
  commands.extend_from_slice(DISPATCH_COMMANDS);
198
198
  commands.extend_from_slice(SPEC_ONLY_HELP_COMMANDS);
199
199
  format!(
@@ -261,7 +261,7 @@ fn emit_unknown_subcommand_usage(command: &str) -> ExitCode {
261
261
 
262
262
  /// 在已知子命令里找与 `input` 最接近的一个(Levenshtein ≤ 阈值)。无足够接近者 → None。
263
263
  fn nearest_subcommand(input: &str) -> Option<&'static str> {
264
- let mut candidates: Vec<&'static str> = vec!["codex", "claude"];
264
+ let mut candidates: Vec<&'static str> = vec!["codex", "claude", "copilot"];
265
265
  candidates.extend_from_slice(DISPATCH_COMMANDS);
266
266
  candidates.extend_from_slice(SPEC_ONLY_HELP_COMMANDS);
267
267
  // 阈值随长度放宽,但短词收紧,避免 'x' 误配任何东西。
@@ -1418,6 +1418,22 @@ mod tests {
1418
1418
  "top-level --help is missing spec-only help command `{command}`"
1419
1419
  );
1420
1420
  }
1421
+ assert!(
1422
+ top_help.contains("copilot"),
1423
+ "top-level leader passthrough help must list copilot"
1424
+ );
1425
+ }
1426
+
1427
+ #[test]
1428
+ fn copilot_is_listed_as_leader_passthrough_candidate() {
1429
+ assert!(command_help(None).contains("copilot"));
1430
+ assert_eq!(nearest_subcommand("copliot"), Some("copilot"));
1431
+ }
1432
+
1433
+ #[test]
1434
+ fn copilot_help_dispatches_as_leader_passthrough() {
1435
+ let cwd = tmp_workspace();
1436
+ assert_eq!(run(&cli_argv(&["copilot", "--help"]), &cwd), ExitCode::Ok);
1421
1437
  }
1422
1438
 
1423
1439
  #[test]
@@ -1,4 +1,4 @@
1
- //! cli · leader — `codex`/`claude` passthrough(`cmd_leader_passthrough` + `_provider_args` /
1
+ //! cli · leader — `codex`/`claude`/`copilot` passthrough(`cmd_leader_passthrough` + `_provider_args` /
2
2
  //! `_leader_launcher_args`)+ leader fallback inbox 摘要(`consume_leader_inbox_summary` 及
3
3
  //! `_leader_inbox_entries` / `_leader_inbox_summary` / `_leader_inbox_entry_title`)。
4
4
 
@@ -68,9 +68,9 @@ fn without_leader_json(values: &[String]) -> Vec<String> {
68
68
  out
69
69
  }
70
70
 
71
- /// `codex`/`claude` passthrough(`parser.py:86`/`_run_leader_passthrough`):leader 早返回,
71
+ /// `codex`/`claude`/`copilot` passthrough(`parser.py:86`/`_run_leader_passthrough`):leader 早返回,
72
72
  /// **不**进 subparser。`-h`/`--help` 打 usage 直接返回 [`CmdResult::none`]。否则解析 attach
73
- /// 旗标 + `lifecycle_port::start_leader`。`command` ∈ {codex, claude}。
73
+ /// 旗标 + `lifecycle_port::start_leader`。`command` ∈ {codex, claude, copilot}。
74
74
  pub fn cmd_leader_passthrough(
75
75
  command: &str,
76
76
  provider_args: &[String],
@@ -82,15 +82,19 @@ pub fn cmd_leader_passthrough(
82
82
  let as_json = leader_launcher_json(provider_args);
83
83
  let launcher_args = without_leader_json(provider_args);
84
84
  let attach = leader_launcher_args(&launcher_args)?;
85
- let provider = if command == "codex" {
86
- crate::model::enums::Provider::Codex
87
- } else {
88
- crate::model::enums::Provider::ClaudeCode
89
- };
85
+ let provider = leader_passthrough_provider(command);
90
86
  let value = lifecycle_port::start_leader(provider, &attach.provider_args, cwd, &attach)?;
91
87
  Ok(CmdResult::from_json(value, as_json))
92
88
  }
93
89
 
90
+ pub(crate) fn leader_passthrough_provider(command: &str) -> crate::model::enums::Provider {
91
+ match command {
92
+ "codex" => crate::model::enums::Provider::Codex,
93
+ "copilot" => crate::model::enums::Provider::Copilot,
94
+ _ => crate::model::enums::Provider::ClaudeCode,
95
+ }
96
+ }
97
+
94
98
  // =============================================================================
95
99
  // leader fallback inbox 摘要(helpers.py `consume_leader_inbox_summary`)
96
100
  // =============================================================================
@@ -617,5 +617,19 @@ Truncated: more fallback entries available; run team-agent inbox leader";
617
617
  assert_eq!(r.exit, ExitCode::Ok);
618
618
  let r2 = cmd_leader_passthrough("claude", &["--help".into()], Path::new(".")).unwrap();
619
619
  assert_eq!(r2.output, CmdOutput::None);
620
+ let r3 = cmd_leader_passthrough("copilot", &["--help".into()], Path::new(".")).unwrap();
621
+ assert_eq!(r3.output, CmdOutput::None);
620
622
  }
621
623
 
624
+ #[test]
625
+ fn cmd_leader_passthrough_maps_copilot_provider() {
626
+ assert_eq!(leader_passthrough_provider("codex"), crate::model::enums::Provider::Codex);
627
+ assert_eq!(
628
+ leader_passthrough_provider("claude"),
629
+ crate::model::enums::Provider::ClaudeCode
630
+ );
631
+ assert_eq!(
632
+ leader_passthrough_provider("copilot"),
633
+ crate::model::enums::Provider::Copilot
634
+ );
635
+ }
@@ -264,9 +264,13 @@ fn current_uid() -> Option<String> {
264
264
  json: true,
265
265
  };
266
266
  let text = outcome_text(cmd_restart(&args));
267
+ // RED-2-STILL: entry gate now resolves via canonical_run_workspace; for a no-spec workspace
268
+ // the "no spec" surfacing may come from either the entry gate ("missing spec for restart")
269
+ // or, when a stray ancestor .team lets it past, the resolve gate ("active team spec not
270
+ // found"). Both are the REAL crate::lifecycle error (not a placeholder ok:true).
267
271
  assert!(
268
- text.contains("missing spec for restart"),
269
- "cmd_restart must surface the REAL crate::lifecycle::restart 'missing spec for restart' error \
272
+ text.contains("missing spec for restart") || text.contains("active team spec not found"),
273
+ "cmd_restart must surface the REAL crate::lifecycle::restart missing-spec error \
270
274
  (not the placeholder's canned ok:true); got {text}"
271
275
  );
272
276
  }
@@ -258,4 +258,10 @@ fn spine_tick_session_missing_emits_event() {
258
258
  .any(|e| e.get("event").and_then(|v| v.as_str()) == Some("coordinator.session_missing")),
259
259
  "the tmux-missing gate must emit a coordinator.session_missing event before the stop report; got {events:?}"
260
260
  );
261
+ assert!(
262
+ events
263
+ .iter()
264
+ .any(|e| e.get("event").and_then(|v| v.as_str()) == Some("coordinator.session_missing_alert")),
265
+ "the tmux-missing gate must emit an explicit leader-visible alert before stopping; got {events:?}"
266
+ );
261
267
  }
@@ -198,6 +198,7 @@ impl Coordinator {
198
198
  "coordinator.session_missing",
199
199
  serde_json::json!({"session": session_name}),
200
200
  )?;
201
+ notify_session_missing(self.workspace.as_path(), &state, &event_log, session_name)?;
201
202
  return Ok(empty_tick_report(
202
203
  false,
203
204
  true,
@@ -971,7 +972,8 @@ impl Coordinator {
971
972
  "runtime_approval.auto_approved",
972
973
  serde_json::json!({
973
974
  "agent_id": agent_id,
974
- "tool": prompt.tool,
975
+ "server": prompt.server.as_deref(),
976
+ "tool": prompt.tool.as_deref(),
975
977
  "choice": choice,
976
978
  "cleared": cleared,
977
979
  "policy_source": approval_policy.source,
@@ -980,6 +982,23 @@ impl Coordinator {
980
982
  "worker_capability_above_leader": approval_policy.worker_capability_above_leader,
981
983
  }),
982
984
  )?;
985
+ event_log.write(
986
+ "mcp.tool.auto_approved",
987
+ serde_json::json!({
988
+ "agent_id": agent_id,
989
+ "server": prompt.server.as_deref(),
990
+ "tool": prompt.tool.as_deref(),
991
+ "choice": choice,
992
+ "cleared": cleared,
993
+ "inherit_reason": approval_policy.inherit_reason(),
994
+ "bypass_source": approval_policy.source,
995
+ "provider": approval_policy.provider,
996
+ "flag": approval_policy.flag,
997
+ "inherited": approval_policy.inherited,
998
+ "explicit_yes_confirmed": approval_policy.explicit_yes_confirmed,
999
+ "worker_capability_above_leader": approval_policy.worker_capability_above_leader,
1000
+ }),
1001
+ )?;
983
1002
  }
984
1003
  RuntimeApprovalDecision::AwaitingHumanConfirm => {
985
1004
  let Some(reason) = awaiting_human_confirm_reason(&prompt, auto_answer_allowed) else {
@@ -2110,6 +2129,8 @@ struct RuntimeApprovalPolicy {
2110
2129
  source: String,
2111
2130
  inherited: bool,
2112
2131
  explicit_yes_confirmed: bool,
2132
+ provider: Option<String>,
2133
+ flag: Option<String>,
2113
2134
  worker_capability_above_leader: bool,
2114
2135
  }
2115
2136
 
@@ -2127,6 +2148,14 @@ impl RuntimeApprovalPolicy {
2127
2148
  && (!self.worker_capability_above_leader
2128
2149
  || (self.source == "runtime_config" && self.explicit_yes_confirmed))
2129
2150
  }
2151
+
2152
+ fn inherit_reason(&self) -> &'static str {
2153
+ match self.source.as_str() {
2154
+ "leader_process" if self.inherited => "leader_bypass",
2155
+ "runtime_config" if self.explicit_yes_confirmed => "runtime_config_explicit_yes",
2156
+ _ => "none",
2157
+ }
2158
+ }
2130
2159
  }
2131
2160
 
2132
2161
  fn runtime_approval_policy_from_agent(agent: &Value) -> RuntimeApprovalPolicy {
@@ -2151,6 +2180,14 @@ fn runtime_approval_policy_from_agent(agent: &Value) -> RuntimeApprovalPolicy {
2151
2180
  .and_then(|p| p.get("explicit_yes_confirmed"))
2152
2181
  .and_then(Value::as_bool)
2153
2182
  .unwrap_or(false),
2183
+ provider: policy
2184
+ .and_then(|p| p.get("provider"))
2185
+ .and_then(Value::as_str)
2186
+ .map(str::to_string),
2187
+ flag: policy
2188
+ .and_then(|p| p.get("flag"))
2189
+ .and_then(Value::as_str)
2190
+ .map(str::to_string),
2154
2191
  worker_capability_above_leader: policy
2155
2192
  .and_then(|p| p.get("worker_capability_above_leader"))
2156
2193
  .and_then(Value::as_bool)
@@ -2423,3 +2460,48 @@ fn remove_file_if_exists(path: &Path) -> Result<(), std::io::Error> {
2423
2460
  Err(e) => Err(e),
2424
2461
  }
2425
2462
  }
2463
+
2464
+ fn notify_session_missing(
2465
+ workspace: &Path,
2466
+ state: &Value,
2467
+ event_log: &EventLog,
2468
+ session_name: &str,
2469
+ ) -> Result<(), TickError> {
2470
+ let content = format!(
2471
+ "coordinator.session_missing\nerror: tmux session {session_name} is missing; coordinator is stopping\naction: restart the team or recover the missing tmux session\nlog: .team/logs/events.jsonl"
2472
+ );
2473
+ let dedupe_key = format!("coordinator.session_missing:{session_name}");
2474
+ match crate::messaging::send_to_leader_receiver(
2475
+ workspace,
2476
+ state,
2477
+ "leader",
2478
+ &content,
2479
+ None,
2480
+ "coordinator",
2481
+ false,
2482
+ Some(&dedupe_key),
2483
+ event_log,
2484
+ ) {
2485
+ Ok(outcome) => {
2486
+ event_log.write(
2487
+ "coordinator.session_missing_alert",
2488
+ serde_json::json!({
2489
+ "session": session_name,
2490
+ "leader_notification_status": crate::messaging::helpers::status_wire(outcome.status),
2491
+ "message_id": outcome.message_id,
2492
+ }),
2493
+ )?;
2494
+ }
2495
+ Err(error) => {
2496
+ event_log.write(
2497
+ "coordinator.session_missing_alert_failed",
2498
+ serde_json::json!({
2499
+ "session": session_name,
2500
+ "error": error.to_string(),
2501
+ "action": "inspect .team/logs/events.jsonl and restart the team",
2502
+ }),
2503
+ )?;
2504
+ }
2505
+ }
2506
+ Ok(())
2507
+ }
@@ -654,6 +654,8 @@ fn leader_command_provider(command: &str) -> Option<Provider> {
654
654
  Some(Provider::ClaudeCode)
655
655
  } else if lower.contains("codex") {
656
656
  Some(Provider::Codex)
657
+ } else if lower.contains("copilot") {
658
+ Some(Provider::Copilot)
657
659
  } else if lower.contains("fake") {
658
660
  Some(Provider::Fake)
659
661
  } else {
@@ -1082,3 +1084,20 @@ pub fn detect_dual_state_divergence(
1082
1084
  "team_owner_epoch": team_epoch,
1083
1085
  })))
1084
1086
  }
1087
+
1088
+ #[cfg(test)]
1089
+ mod tests {
1090
+ use super::*;
1091
+
1092
+ #[test]
1093
+ fn leader_command_provider_recognizes_copilot() {
1094
+ assert_eq!(
1095
+ leader_command_provider("copilot --allow-all-tools"),
1096
+ Some(Provider::Copilot)
1097
+ );
1098
+ assert_eq!(
1099
+ leader_command_provider("/usr/local/bin/copilot"),
1100
+ Some(Provider::Copilot)
1101
+ );
1102
+ }
1103
+ }
@@ -80,6 +80,18 @@ fn event_named(events: &[Value], name: &str) -> Value {
80
80
  .unwrap_or_else(|| panic!("missing event {name}; got {events:?}"))
81
81
  }
82
82
 
83
+ #[test]
84
+ fn leader_command_provider_recognizes_copilot() {
85
+ assert_eq!(
86
+ leader_command_provider("copilot --allow-all-tools"),
87
+ Some(Provider::Copilot)
88
+ );
89
+ assert_eq!(
90
+ leader_command_provider("/usr/local/bin/copilot"),
91
+ Some(Provider::Copilot)
92
+ );
93
+ }
94
+
83
95
  fn last_event_named(events: &[Value], name: &str) -> Value {
84
96
  events
85
97
  .iter()
@@ -590,6 +590,8 @@ fn leader_command_provider(command: &str) -> Option<Provider> {
590
590
  Some(Provider::ClaudeCode)
591
591
  } else if lower.contains("codex") {
592
592
  Some(Provider::Codex)
593
+ } else if lower.contains("copilot") {
594
+ Some(Provider::Copilot)
593
595
  } else if lower.contains("fake") {
594
596
  Some(Provider::Fake)
595
597
  } else {
@@ -14,8 +14,8 @@ use super::helpers::{
14
14
  };
15
15
  use super::owner_bind::leader_identity_context;
16
16
  use super::{
17
- LeaderError, LeaderLaunchOutcome, LeaderLaunchSocket, LeaderLaunchStatus, LeaderStartMode,
18
- LeaderStartPlan,
17
+ LeaderError, LeaderIdentity, LeaderLaunchOutcome, LeaderLaunchSocket, LeaderLaunchStatus,
18
+ LeaderStartMode, LeaderStartPlan,
19
19
  };
20
20
 
21
21
  // ── leader::start — leader_start_plan / start_leader / session 名 ──
@@ -72,27 +72,7 @@ pub fn leader_start_plan(
72
72
  } else {
73
73
  LeaderStartMode::NewTmuxSession
74
74
  };
75
- let mut leader_env = BTreeMap::new();
76
- leader_env.insert(
77
- "TEAM_AGENT_LEADER_PROVIDER".to_string(),
78
- provider_wire(provider).to_string(),
79
- );
80
- leader_env.insert(
81
- "TEAM_AGENT_LEADER_SESSION_UUID".to_string(),
82
- identity.leader_session_uuid.as_str().to_string(),
83
- );
84
- leader_env.insert(
85
- "TEAM_AGENT_MACHINE_FINGERPRINT".to_string(),
86
- identity.machine_fingerprint.clone(),
87
- );
88
- leader_env.insert(
89
- "TEAM_AGENT_WORKSPACE".to_string(),
90
- identity.workspace_abspath.to_string_lossy().into_owned(),
91
- );
92
- leader_env.insert(
93
- "TEAM_AGENT_TEAM_ID".to_string(),
94
- identity.team_id.as_str().to_string(),
95
- );
75
+ let leader_env = leader_env_for_identity(provider, &identity);
96
76
  let argv = start_argv(
97
77
  mode,
98
78
  provider,
@@ -119,6 +99,37 @@ pub fn leader_start_plan(
119
99
  })
120
100
  }
121
101
 
102
+ pub(crate) fn leader_env_for_identity(
103
+ provider: Provider,
104
+ identity: &LeaderIdentity,
105
+ ) -> BTreeMap<String, String> {
106
+ let mut leader_env = BTreeMap::new();
107
+ leader_env.insert(
108
+ "TEAM_AGENT_LEADER_PROVIDER".to_string(),
109
+ provider_wire(provider).to_string(),
110
+ );
111
+ leader_env.insert(
112
+ "TEAM_AGENT_LEADER_SESSION_UUID".to_string(),
113
+ identity.leader_session_uuid.as_str().to_string(),
114
+ );
115
+ leader_env.insert(
116
+ "TEAM_AGENT_MACHINE_FINGERPRINT".to_string(),
117
+ identity.machine_fingerprint.clone(),
118
+ );
119
+ leader_env.insert(
120
+ "TEAM_AGENT_WORKSPACE".to_string(),
121
+ identity.workspace_abspath.to_string_lossy().into_owned(),
122
+ );
123
+ leader_env.insert(
124
+ "TEAM_AGENT_TEAM_ID".to_string(),
125
+ identity.team_id.as_str().to_string(),
126
+ );
127
+ if provider == Provider::Copilot {
128
+ leader_env.insert("COPILOT_DISABLE_TERMINAL_TITLE".to_string(), "1".to_string());
129
+ }
130
+ leader_env
131
+ }
132
+
122
133
  /// `start_leader`(card §46;`__init__.py:60`)。计算并执行 leader 启动计划(spawn + 信号处理)。
123
134
  /// 进程退出后 `Err`/退出码经 caller 处理(此处返 `Result` 替代 Python 的 `SystemExit`)。
124
135
  pub fn start_leader(
@@ -90,6 +90,28 @@ use super::*;
90
90
  }
91
91
  }
92
92
 
93
+ #[test]
94
+ #[serial_test::serial(env)]
95
+ fn copilot_leader_env_disables_terminal_title_only_for_copilot() {
96
+ let ws = std::env::temp_dir().join(format!("ta_rs_lsp_copilot_{}", std::process::id()));
97
+ std::fs::create_dir_all(&ws).unwrap();
98
+ let identity = leader_identity_context(&ws, None, Some(&serde_json::json!({}))).unwrap();
99
+
100
+ let copilot = leader_env_for_identity(Provider::Copilot, &identity);
101
+ assert_eq!(
102
+ copilot.get("COPILOT_DISABLE_TERMINAL_TITLE").map(String::as_str),
103
+ Some("1")
104
+ );
105
+
106
+ for provider in [Provider::Codex, Provider::ClaudeCode] {
107
+ let leader_env = leader_env_for_identity(provider, &identity);
108
+ assert!(
109
+ !leader_env.contains_key("COPILOT_DISABLE_TERMINAL_TITLE"),
110
+ "{provider:?} leader env must not include copilot title override"
111
+ );
112
+ }
113
+ }
114
+
93
115
  // ═══════════════ P2 FIX-LOOP RED (复绿即对抗 cross-model findings) ═══════════════
94
116
  // Lock CORRECT Python v0.2.11 leader-identity behavior the contracts missed.
95
117
  // Golden re-probed via /tmp/probe_p2_leader.py vs team-agent-public @ 439bef8
@@ -134,6 +134,19 @@ use super::*;
134
134
  assert!(got.as_str().starts_with("team-agent-leader-claude_code-proj-"), "got {}", got.as_str());
135
135
  }
136
136
 
137
+ #[test]
138
+ fn leader_session_name_uses_copilot_provider_string() {
139
+ let base = std::env::temp_dir().join(format!("ta_rs_lsn_copilot_{}", std::process::id()));
140
+ let dir = base.join("proj");
141
+ std::fs::create_dir_all(&dir).unwrap();
142
+ let got = leader_session_name(Provider::Copilot, &dir);
143
+ assert!(
144
+ got.as_str().starts_with("team-agent-leader-copilot-proj-"),
145
+ "got {}",
146
+ got.as_str()
147
+ );
148
+ }
149
+
137
150
  // =====================================================================
138
151
  // 6. Family A 正源 owner 绑定 — bind_owner_from_caller_pane(unimplemented → RED)
139
152
  // =====================================================================
@@ -288,6 +288,7 @@ fn spawn_agents(
288
288
  let mut env =
289
289
  inherited_env_with_team_overrides(workspace, agent_id_raw, Some(&mcp_team_id));
290
290
  apply_profile_launch_env(&mut env, &profile_launch);
291
+ apply_mcp_auto_approval_env(&mut env, &safety);
291
292
  // Python providers.py:145 + launch/core.py:253 — fresh launch runs the worker
292
293
  // with cwd=workspace, same as the RS fork/add and restart paths.
293
294
  let env_unset: Vec<String> = profile_launch.env_unset.iter().cloned().collect();
@@ -1390,6 +1391,39 @@ pub(crate) fn inherited_env_with_team_overrides(
1390
1391
  env
1391
1392
  }
1392
1393
 
1394
+ pub(crate) fn apply_mcp_auto_approval_env(
1395
+ env: &mut BTreeMap<String, String>,
1396
+ safety: &DangerousApproval,
1397
+ ) {
1398
+ for key in [
1399
+ "TEAM_AGENT_LEADER_BYPASS",
1400
+ "TEAM_AGENT_LEADER_BYPASS_SOURCE",
1401
+ "TEAM_AGENT_LEADER_BYPASS_PROVIDER",
1402
+ "TEAM_AGENT_LEADER_BYPASS_FLAG",
1403
+ "TEAM_AGENT_MCP_AUTO_APPROVE",
1404
+ "TEAM_AGENT_MCP_AUTO_APPROVE_SOURCE",
1405
+ ] {
1406
+ env.remove(key);
1407
+ }
1408
+ if safety.enabled
1409
+ && matches!(safety.source, DangerousApprovalSource::LeaderProcess)
1410
+ && safety.inherited
1411
+ {
1412
+ env.insert("TEAM_AGENT_LEADER_BYPASS".to_string(), "1".to_string());
1413
+ env.insert("TEAM_AGENT_LEADER_BYPASS_SOURCE".to_string(), "leader_process".to_string());
1414
+ if let Some(provider) = safety.provider.as_deref() {
1415
+ env.insert("TEAM_AGENT_LEADER_BYPASS_PROVIDER".to_string(), provider.to_string());
1416
+ }
1417
+ if let Some(flag) = safety.flag.as_deref() {
1418
+ env.insert("TEAM_AGENT_LEADER_BYPASS_FLAG".to_string(), flag.to_string());
1419
+ }
1420
+ env.insert("TEAM_AGENT_MCP_AUTO_APPROVE".to_string(), "team_orchestrator".to_string());
1421
+ env.insert("TEAM_AGENT_MCP_AUTO_APPROVE_SOURCE".to_string(), "leader_bypass".to_string());
1422
+ } else {
1423
+ env.insert("TEAM_AGENT_LEADER_BYPASS".to_string(), "0".to_string());
1424
+ }
1425
+ }
1426
+
1393
1427
  /// BUG / B2 灵魂件 + C-1-2 + C-6-1 cr verdict — Copilot per-worker AGENTS.md
1394
1428
  /// 写入 + `COPILOT_CUSTOM_INSTRUCTIONS_DIRS` 注入。
1395
1429
  ///
@@ -2971,6 +3005,7 @@ pub fn fork_agent_with_transport(
2971
3005
  let mut env =
2972
3006
  inherited_env_with_team_overrides(&workspace, as_agent_id.as_str(), Some(&fork_team));
2973
3007
  apply_profile_launch_env(&mut env, &profile_launch);
3008
+ apply_mcp_auto_approval_env(&mut env, &safety);
2974
3009
  // golden operations.py:336 -> _tmux_start_command_for_agent_window (runtime.py:1017-1020): branch on
2975
3010
  // _tmux_session_exists — an ABSENT session => new-session (spawn_first), present => new-window
2976
3011
  // (spawn_into). The Rust restart seam (restart.rs spawn_agent_window) uses the same branch.
@@ -108,17 +108,31 @@ pub(crate) fn start_agent_at_paths(
108
108
  let provider = agent_provider(&agent);
109
109
  let session_id = agent_session_id(&agent);
110
110
  let rollout_path = agent_rollout_path(&agent);
111
- let rollout_exists = rollout_path
111
+ let resume_backing_exists = session_id
112
112
  .as_ref()
113
- .map(|p| p.as_path().exists())
113
+ .map(|session| {
114
+ resume_backing_exists_for_agent(
115
+ workspace,
116
+ agent_id,
117
+ &agent,
118
+ provider,
119
+ session,
120
+ rollout_path.as_ref(),
121
+ )
122
+ })
114
123
  .unwrap_or(false);
115
124
  let start_mode = decide_start_mode(
116
125
  provider_wire(provider),
117
126
  session_id.as_ref(),
118
127
  rollout_path.as_ref(),
119
- rollout_exists,
128
+ resume_backing_exists,
120
129
  allow_fresh,
121
130
  );
131
+ if matches!(start_mode, StartMode::Noop) {
132
+ return Err(LifecycleError::RequirementUnmet(format!(
133
+ "resume_not_ready: session backing store missing for agent {agent_id}; rerun with --allow-fresh to start fresh"
134
+ )));
135
+ }
122
136
  let spawn_session_id = if matches!(start_mode, StartMode::Resumed) {
123
137
  session_id.as_ref()
124
138
  } else {
@@ -122,6 +122,7 @@ pub(super) fn spawn_agent_window(
122
122
  team_id.as_deref(),
123
123
  );
124
124
  crate::lifecycle::launch::apply_profile_launch_env(&mut env, &profile_launch);
125
+ crate::lifecycle::launch::apply_mcp_auto_approval_env(&mut env, safety);
125
126
  let spawn_cwd = spawn_cwd_override
126
127
  .or_else(|| {
127
128
  agent
@@ -242,6 +243,80 @@ pub(super) fn agent_rollout_path(agent: &serde_json::Value) -> Option<RolloutPat
242
243
  .map(RolloutPath::new)
243
244
  }
244
245
 
246
+ pub(super) fn resume_backing_exists_for_agent(
247
+ workspace: &Path,
248
+ agent_id: &AgentId,
249
+ agent: &serde_json::Value,
250
+ provider: Provider,
251
+ session_id: &SessionId,
252
+ rollout_path: Option<&RolloutPath>,
253
+ ) -> bool {
254
+ match provider {
255
+ Provider::Codex => rollout_path_exists(rollout_path),
256
+ Provider::Claude | Provider::ClaudeCode => {
257
+ rollout_path_exists(rollout_path)
258
+ || event_log_transcript_exists(workspace, agent_id.as_str(), session_id.as_str())
259
+ }
260
+ Provider::Copilot => copilot_session_store_has_session(session_id.as_str()),
261
+ Provider::GeminiCli | Provider::Fake => {
262
+ let _ = agent;
263
+ true
264
+ }
265
+ }
266
+ }
267
+
268
+ fn rollout_path_exists(rollout_path: Option<&RolloutPath>) -> bool {
269
+ rollout_path
270
+ .as_ref()
271
+ .is_some_and(|path| path.as_path().exists())
272
+ }
273
+
274
+ fn event_log_transcript_exists(workspace: &Path, agent_id: &str, session_id: &str) -> bool {
275
+ let Ok(events) = crate::event_log::EventLog::new(workspace).tail(0) else {
276
+ return false;
277
+ };
278
+ events.iter().rev().any(|event| {
279
+ event.get("event").and_then(serde_json::Value::as_str) == Some("session.captured")
280
+ && ["agent_id", "worker_id"]
281
+ .iter()
282
+ .any(|key| event.get(*key).and_then(serde_json::Value::as_str) == Some(agent_id))
283
+ && event.get("session_id").and_then(serde_json::Value::as_str) == Some(session_id)
284
+ && event_transcript_path(event).is_some_and(|path| path.exists())
285
+ })
286
+ }
287
+
288
+ fn event_transcript_path(event: &serde_json::Value) -> Option<PathBuf> {
289
+ event
290
+ .get("rollout_path")
291
+ .or_else(|| event.get("transcript_path"))
292
+ .and_then(serde_json::Value::as_str)
293
+ .filter(|path| !path.is_empty())
294
+ .map(PathBuf::from)
295
+ }
296
+
297
+ fn copilot_session_store_has_session(session_id: &str) -> bool {
298
+ let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
299
+ return false;
300
+ };
301
+ let db_path = home.join(".copilot").join("session-store.db");
302
+ if !db_path.exists() {
303
+ return false;
304
+ }
305
+ let Ok(conn) = rusqlite::Connection::open_with_flags(
306
+ db_path,
307
+ rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
308
+ ) else {
309
+ return false;
310
+ };
311
+ conn
312
+ .query_row(
313
+ "select 1 from sessions where id = ?1 limit 1",
314
+ [session_id],
315
+ |_| Ok(()),
316
+ )
317
+ .is_ok()
318
+ }
319
+
245
320
  pub(crate) fn refresh_missing_provider_sessions(
246
321
  state: &mut serde_json::Value,
247
322
  ) -> Result<bool, LifecycleError> {