@team-agent/installer 0.3.4 → 0.3.5

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 (55) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +8 -0
  4. package/crates/team-agent/src/cli/diagnose.rs +51 -10
  5. package/crates/team-agent/src/cli/emit.rs +2 -1
  6. package/crates/team-agent/src/cli/mod.rs +217 -80
  7. package/crates/team-agent/src/cli/send.rs +1 -0
  8. package/crates/team-agent/src/cli/status_port.rs +135 -7
  9. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +8 -1
  10. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  11. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +39 -0
  12. package/crates/team-agent/src/cli/types.rs +5 -1
  13. package/crates/team-agent/src/coordinator/backoff.rs +57 -9
  14. package/crates/team-agent/src/coordinator/health.rs +65 -2
  15. package/crates/team-agent/src/coordinator/runtime_detectors.rs +28 -16
  16. package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +87 -0
  17. package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
  18. package/crates/team-agent/src/coordinator/tick.rs +195 -43
  19. package/crates/team-agent/src/leader/helpers.rs +2 -0
  20. package/crates/team-agent/src/leader/rediscover.rs +1 -0
  21. package/crates/team-agent/src/leader/start.rs +9 -1
  22. package/crates/team-agent/src/leader/takeover.rs +18 -1
  23. package/crates/team-agent/src/lifecycle/launch.rs +434 -29
  24. package/crates/team-agent/src/lifecycle/profile_launch.rs +110 -4
  25. package/crates/team-agent/src/lifecycle/profile_smoke.rs +4 -1
  26. package/crates/team-agent/src/lifecycle/restart/common.rs +19 -2
  27. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +2 -2
  28. package/crates/team-agent/src/lifecycle/tests/core.rs +1 -1
  29. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +4 -4
  30. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +3 -1
  31. package/crates/team-agent/src/lifecycle/worker_command_context.rs +44 -9
  32. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +2 -1
  33. package/crates/team-agent/src/mcp_server/tests/scoped.rs +14 -1
  34. package/crates/team-agent/src/mcp_server/tests/send.rs +15 -1
  35. package/crates/team-agent/src/mcp_server/tools.rs +65 -9
  36. package/crates/team-agent/src/mcp_server/wire.rs +2 -1
  37. package/crates/team-agent/src/message_store.rs +80 -0
  38. package/crates/team-agent/src/messaging/results.rs +76 -5
  39. package/crates/team-agent/src/messaging/send.rs +3 -1
  40. package/crates/team-agent/src/messaging/types.rs +15 -1
  41. package/crates/team-agent/src/messaging/watchers.rs +68 -30
  42. package/crates/team-agent/src/model/enums.rs +7 -1
  43. package/crates/team-agent/src/model/permissions.rs +7 -0
  44. package/crates/team-agent/src/model/spec.rs +3 -1
  45. package/crates/team-agent/src/provider/adapter.rs +472 -7
  46. package/crates/team-agent/src/provider/classify.rs +6 -2
  47. package/crates/team-agent/src/provider/faults.rs +3 -2
  48. package/crates/team-agent/src/provider/startup_prompt.rs +25 -7
  49. package/crates/team-agent/src/provider/types.rs +11 -0
  50. package/crates/team-agent/src/session_capture.rs +1 -0
  51. package/crates/team-agent/src/state/persist.rs +95 -19
  52. package/crates/team-agent/src/tmux_backend/tests.rs +8 -7
  53. package/crates/team-agent/src/tmux_backend.rs +80 -6
  54. package/crates/team-agent/src/transport.rs +32 -0
  55. package/package.json +4 -4
@@ -140,6 +140,10 @@ fn spawn_agents(
140
140
  transport: &dyn Transport,
141
141
  ) -> Result<Vec<StartedAgent>, LifecycleError> {
142
142
  let team_dir = spec_path.parent().unwrap_or_else(|| Path::new("."));
143
+ let runtime_fast = matches!(
144
+ spec.get("runtime").and_then(|v| v.get("fast")),
145
+ Some(Value::Bool(true))
146
+ );
143
147
  let mut started = Vec::new();
144
148
  for agent in spec_agent_values(spec) {
145
149
  let Some(agent_id_raw) = agent.get("id").and_then(Value::as_str) else {
@@ -185,7 +189,12 @@ fn spawn_agents(
185
189
  .mcp_config(auth_mode)
186
190
  .map_err(|e| LifecycleError::Provider(e.to_string()))?;
187
191
  let mcp_config = resolve_mcp_config(mcp_config, workspace, agent_id_raw, &mcp_team_id);
188
- let mcp_config_path = write_worker_mcp_config(workspace, agent_id_raw, &mcp_config)?;
192
+ let mcp_config_path = write_worker_mcp_config_for_provider(
193
+ workspace,
194
+ agent_id_raw,
195
+ &mcp_config,
196
+ Some(provider),
197
+ )?;
189
198
  let profile_dir = team_dir.join("profiles");
190
199
  let profile_launch =
191
200
  crate::lifecycle::profile_launch::prepare_provider_profile_launch_with_profile_dir(
@@ -209,15 +218,119 @@ fn spawn_agents(
209
218
  if !plan.managed_mcp_config && !profile_launch.managed_mcp_config {
210
219
  point_native_mcp_config_at_file(&mut plan.argv, provider, &mcp_config_path);
211
220
  }
221
+ // C-A-4 cr verdict v2 — Copilot BYOK(compatible_api)硬性校验:
222
+ // "A model is required for BYOK"(help-providers 原文)。检查 agent
223
+ // 的 model 来源:角色 spec.model > profile COPILOT_MODEL(经 env_overlay)
224
+ // > --model 旗(本 worker 路径不在 argv 后追加用户 --model)。三者全空 → 报错
225
+ // 含 "model" 字面,失败信息透传给 leader。
226
+ if matches!(provider, Provider::Copilot) && auth_mode == AuthMode::CompatibleApi {
227
+ let has_model = model.is_some_and(|s| !s.is_empty())
228
+ || profile_launch.command_overrides.model.as_deref().is_some_and(|s| !s.is_empty())
229
+ || profile_launch
230
+ .env_overlay
231
+ .get("COPILOT_MODEL")
232
+ .is_some_and(|v| !v.is_empty());
233
+ if !has_model {
234
+ return Err(LifecycleError::RequirementUnmet(
235
+ "copilot BYOK profile requires a model (set COPILOT_MODEL, agent.model, or --model)"
236
+ .to_string(),
237
+ ));
238
+ }
239
+ }
240
+ // §B1 + C-7-1 + C-6-2 + C-3-2 cr verdict v2 — Copilot launch-time argv 注入:
241
+ // -n <agent_id> 会话命名(main-help:104)→ resume-by-name + 人查 双键
242
+ // -C <workspace> 双保险 cwd(main-help:55-56),防 shell 包装意外
243
+ // --log-dir <path> per-worker 定向日志(help-logging)→ 故障期可读 + N18 隔离
244
+ // --log-level info 配套日志级别
245
+ // --disable-mcp-server <n>... C-3-2 残留 MCP server 按名禁(扫 mcp list)
246
+ if matches!(provider, Provider::Copilot) {
247
+ plan.argv.push("-n".to_string());
248
+ plan.argv.push(agent_id_raw.to_string());
249
+ plan.argv.push("-C".to_string());
250
+ plan.argv.push(workspace.to_string_lossy().to_string());
251
+ let log_dir = workspace
252
+ .join(".team")
253
+ .join("logs")
254
+ .join("copilot")
255
+ .join(agent_id_raw);
256
+ std::fs::create_dir_all(&log_dir).map_err(|e| {
257
+ LifecycleError::StatePersist(format!("{}: {e}", log_dir.display()))
258
+ })?;
259
+ plan.argv.push("--log-dir".to_string());
260
+ plan.argv.push(log_dir.to_string_lossy().to_string());
261
+ plan.argv.push("--log-level".to_string());
262
+ plan.argv.push("info".to_string());
263
+ // C-3-2/C-3-3 cr verdict v2 — spawn 前扫 `copilot mcp list` 找用户全局/
264
+ // workspace 的 MCP 残留,对每个非 team_orchestrator server 追加
265
+ // --disable-mcp-server <name>,并落 mcp-residual.txt + event。
266
+ apply_copilot_mcp_residual_disables(
267
+ &workspace,
268
+ agent_id_raw,
269
+ &mut plan.argv,
270
+ &log_dir,
271
+ )?;
272
+ }
212
273
  fill_spawn_placeholders_full(&mut plan.argv, workspace, agent_id_raw, Some(&mcp_team_id));
213
274
  let window = WindowName::new(agent_id_raw);
214
275
  let mut env =
215
276
  inherited_env_with_team_overrides(workspace, agent_id_raw, Some(&mcp_team_id));
216
277
  apply_profile_launch_env(&mut env, &profile_launch);
278
+ // Python providers.py:145 + launch/core.py:253 — fresh launch runs the worker
279
+ // with cwd=workspace, same as the RS fork/add and restart paths.
280
+ let env_unset: Vec<String> = profile_launch.env_unset.iter().cloned().collect();
281
+ // BUG / C-1-2 / C-6-1 cr verdict — Copilot system_prompt 走 spawn env overlay +
282
+ // per-worker AGENTS.md(B2 灵魂件降级):写
283
+ // <workspace>/.team/runtime/copilot-instructions/<agent_id>/AGENTS.md
284
+ // 全文 == compile_worker_system_prompt 输出,并通过 spawn env
285
+ // `COPILOT_CUSTOM_INSTRUCTIONS_DIRS=<该目录>` 让 copilot CLI 加载。
286
+ // **禁** silent 写 ~/.copilot/AGENTS.md(C-1-2)+ **禁** -i 作首条消息(C-1-5)。
287
+ if matches!(provider, Provider::Copilot) {
288
+ apply_copilot_instructions_overlay(
289
+ workspace,
290
+ agent_id_raw,
291
+ system_prompt.as_str(),
292
+ &mut env,
293
+ )?;
294
+ // C-A-6 cr verdict v2 — Copilot worker env 全量继承下,用户 shell 的
295
+ // COPILOT_GITHUB_TOKEN / GH_TOKEN / GITHUB_TOKEN 会穿透 + 按 cmd-login 实证
296
+ // **优先于凭据库**(可能静默改变 auth 通道)。一期只观测不剥除(剥除是
297
+ // 行为变更,cr 裁);命中任一就发 warn event 让 user 可见。
298
+ let mut passthrough: Vec<String> = Vec::new();
299
+ for key in ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"] {
300
+ if env.get(key).is_some_and(|v| !v.is_empty()) {
301
+ passthrough.push(key.to_string());
302
+ }
303
+ }
304
+ if !passthrough.is_empty() {
305
+ let event_log = crate::event_log::EventLog::new(workspace);
306
+ let _ = event_log.write(
307
+ "provider.copilot.token_passthrough_warning",
308
+ serde_json::json!({
309
+ "agent_id": agent_id_raw,
310
+ "tokens": passthrough,
311
+ "reason": "user shell GITHUB_TOKEN family takes precedence over copilot credential store (cmd-login)",
312
+ }),
313
+ );
314
+ }
315
+ }
217
316
  let spawn = if started.is_empty() {
218
- transport.spawn_first(session_name, &window, &plan.argv, team_dir, &env)
317
+ transport.spawn_first_with_env_unset(
318
+ session_name,
319
+ &window,
320
+ &plan.argv,
321
+ workspace,
322
+ &env,
323
+ &env_unset,
324
+ )
219
325
  } else {
220
- transport.spawn_into(session_name, &window, &plan.argv, team_dir, &env)
326
+ transport.spawn_into_with_env_unset(
327
+ session_name,
328
+ &window,
329
+ &plan.argv,
330
+ workspace,
331
+ &env,
332
+ &env_unset,
333
+ )
221
334
  }
222
335
  .map_err(|e| LifecycleError::Transport(e.to_string()))?;
223
336
  let _ = adapter.handle_startup_prompts(
@@ -226,6 +339,11 @@ fn spawn_agents(
226
339
  30,
227
340
  0.5,
228
341
  );
342
+ // Python launch/core.py:235-237 — runtime.fast toggles the provider's fast mode
343
+ // after spawn; provider specifics live behind the adapter (F032).
344
+ if runtime_fast {
345
+ let _ = adapter.enable_fast_mode(transport, &Target::Pane(spawn.pane_id.clone()));
346
+ }
229
347
  if matches!(transport.liveness(&spawn.pane_id), Ok(PaneLiveness::Dead)) {
230
348
  continue;
231
349
  }
@@ -330,7 +448,7 @@ fn persist_spawn_agent_state(
330
448
  id,
331
449
  provider,
332
450
  workspace,
333
- spec_path.parent().unwrap_or(workspace),
451
+ workspace,
334
452
  &spawned_at,
335
453
  &team_id,
336
454
  Some(agent_id_to_pane_id(started, id)),
@@ -717,7 +835,8 @@ fn running_agent_state(
717
835
  .mcp_config(auth_mode)
718
836
  .map_err(|e| LifecycleError::Provider(e.to_string()))?;
719
837
  let mcp_config = resolve_mcp_config(mcp_config, workspace, id, team_id);
720
- let mcp_config_path = write_worker_mcp_config(workspace, id, &mcp_config)?;
838
+ let mcp_config_path =
839
+ write_worker_mcp_config_for_provider(workspace, id, &mcp_config, Some(provider))?;
721
840
  let mut state = serde_json::Map::new();
722
841
  state.insert("status".to_string(), serde_json::json!("running"));
723
842
  state.insert("provider".to_string(), serde_json::json!(provider));
@@ -849,6 +968,20 @@ pub(crate) fn write_worker_mcp_config(
849
968
  workspace: &Path,
850
969
  agent_id: &str,
851
970
  config: &crate::provider::McpConfig,
971
+ ) -> Result<PathBuf, LifecycleError> {
972
+ write_worker_mcp_config_for_provider(workspace, agent_id, config, None)
973
+ }
974
+
975
+ /// C-3-4 cr verdict v2 — Copilot 的 mcp config schema 字段名是 `transport`
976
+ /// (实测 cmd-mcp-add 原文取值 stdio|http|sse),不是 canonical 的 `type`。当
977
+ /// provider==Copilot 时写出文件前先做 type→transport 翻译;其它 provider 不动。
978
+ /// 文件路径同 canonical `<ws>/.team/runtime/mcp/<agent_id>.json`,因为 launch
979
+ /// 路径会用 `--additional-mcp-config @<file>` 直指它。
980
+ pub(crate) fn write_worker_mcp_config_for_provider(
981
+ workspace: &Path,
982
+ agent_id: &str,
983
+ config: &crate::provider::McpConfig,
984
+ provider: Option<Provider>,
852
985
  ) -> Result<PathBuf, LifecycleError> {
853
986
  let path = workspace
854
987
  .join(".team/runtime/mcp")
@@ -857,26 +990,70 @@ pub(crate) fn write_worker_mcp_config(
857
990
  std::fs::create_dir_all(parent)
858
991
  .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", parent.display())))?;
859
992
  }
860
- let body = serde_json::to_string_pretty(&serde_json::json!({"mcpServers": config.raw}))
993
+ let raw = if matches!(provider, Some(Provider::Copilot)) {
994
+ copilot_translate_mcp_servers(&config.raw)
995
+ } else {
996
+ config.raw.clone()
997
+ };
998
+ let body = serde_json::to_string_pretty(&serde_json::json!({"mcpServers": raw}))
861
999
  .map_err(|e| LifecycleError::StatePersist(format!("serialize mcp config: {e}")))?;
862
1000
  std::fs::write(&path, body)
863
1001
  .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", path.display())))?;
864
1002
  Ok(path)
865
1003
  }
866
1004
 
1005
+ /// C-3-4 cr verdict v2 — McpConfig.raw 是 `{name: {type, command, args, env}}` 形;
1006
+ /// copilot mcp add schema 取 `transport` 替 `type`(stdio|http|sse 同值)。仅
1007
+ /// 字段名变换,其余字段全保留。
1008
+ fn copilot_translate_mcp_servers(raw: &serde_json::Value) -> serde_json::Value {
1009
+ let Some(servers) = raw.as_object() else {
1010
+ return raw.clone();
1011
+ };
1012
+ let mut translated = serde_json::Map::new();
1013
+ for (name, server) in servers {
1014
+ let Some(obj) = server.as_object() else {
1015
+ translated.insert(name.clone(), server.clone());
1016
+ continue;
1017
+ };
1018
+ let mut out = serde_json::Map::new();
1019
+ for (key, value) in obj {
1020
+ if key == "type" {
1021
+ out.insert("transport".to_string(), value.clone());
1022
+ } else {
1023
+ out.insert(key.clone(), value.clone());
1024
+ }
1025
+ }
1026
+ translated.insert(name.clone(), serde_json::Value::Object(out));
1027
+ }
1028
+ serde_json::Value::Object(translated)
1029
+ }
1030
+
867
1031
  pub(crate) fn point_native_mcp_config_at_file(
868
1032
  argv: &mut [String],
869
1033
  provider: Provider,
870
1034
  path: &Path,
871
1035
  ) {
872
- if !matches!(provider, Provider::Claude | Provider::ClaudeCode) {
873
- return;
874
- }
875
- let Some(index) = argv.iter().position(|arg| arg == "--mcp-config") else {
876
- return;
877
- };
878
- if let Some(value) = argv.get_mut(index.saturating_add(1)) {
879
- *value = path.to_string_lossy().to_string();
1036
+ match provider {
1037
+ Provider::Claude | Provider::ClaudeCode => {
1038
+ let Some(index) = argv.iter().position(|arg| arg == "--mcp-config") else {
1039
+ return;
1040
+ };
1041
+ if let Some(value) = argv.get_mut(index.saturating_add(1)) {
1042
+ *value = path.to_string_lossy().to_string();
1043
+ }
1044
+ }
1045
+ // §C1 note: copilot `--additional-mcp-config` 接受 `@file`,直接指向既有
1046
+ // `.team/runtime/mcp/<agent>.json`(launch 路径 write_worker_mcp_config 已写)。
1047
+ // 既避免 inline JSON 包 mcpServers wrapper 的语义错位,也更利于 ps 验法。
1048
+ Provider::Copilot => {
1049
+ let Some(index) = argv.iter().position(|arg| arg == "--additional-mcp-config") else {
1050
+ return;
1051
+ };
1052
+ if let Some(value) = argv.get_mut(index.saturating_add(1)) {
1053
+ *value = format!("@{}", path.to_string_lossy());
1054
+ }
1055
+ }
1056
+ _ => {}
880
1057
  }
881
1058
  }
882
1059
 
@@ -995,6 +1172,10 @@ pub(crate) fn inherited_env_with_team_overrides(
995
1172
  "TEAM_AGENT_WORKSPACE".to_string(),
996
1173
  workspace.to_string_lossy().to_string(),
997
1174
  );
1175
+ // Python providers.py:131 — TEAM_AGENT_ID must be the worker ITSELF, overriding any
1176
+ // value inherited from the launching process (an add-agent/fork issued from another
1177
+ // worker's MCP server carries the CALLER's TEAM_AGENT_ID in its environ).
1178
+ env.insert("TEAM_AGENT_ID".to_string(), agent_id.to_string());
998
1179
  env.insert("TEAM_AGENT_AGENT_ID".to_string(), agent_id.to_string());
999
1180
  if let Some(tid) = team_id.filter(|s| !s.is_empty()) {
1000
1181
  env.insert("TEAM_AGENT_OWNER_TEAM_ID".to_string(), tid.to_string());
@@ -1002,6 +1183,190 @@ pub(crate) fn inherited_env_with_team_overrides(
1002
1183
  env
1003
1184
  }
1004
1185
 
1186
+ /// BUG / B2 灵魂件 + C-1-2 + C-6-1 cr verdict — Copilot per-worker AGENTS.md
1187
+ /// 写入 + `COPILOT_CUSTOM_INSTRUCTIONS_DIRS` 注入。
1188
+ ///
1189
+ /// 目录布局:`<workspace>/.team/runtime/copilot-instructions/<agent_id>/AGENTS.md`
1190
+ /// * 含 `<agent_id>` segment(C-6-2 per-agent isolation,N18 精神)
1191
+ /// * 文件内容 ≡ `compile_worker_system_prompt` 输出(B2 ps/文件双验法)
1192
+ /// * **禁** silent 写全局 `~/.copilot/AGENTS.md`(C-1-2 grep guard)
1193
+ ///
1194
+ /// 失败回 `LifecycleError::StatePersist` 以与既有 state 持久化错误同源,
1195
+ /// 不 silent 吞(MUST-NOT-13 诚实)。
1196
+ pub(crate) fn apply_copilot_instructions_overlay(
1197
+ workspace: &Path,
1198
+ agent_id: &str,
1199
+ system_prompt: &str,
1200
+ env: &mut BTreeMap<String, String>,
1201
+ ) -> Result<(), LifecycleError> {
1202
+ let dir = workspace
1203
+ .join(".team")
1204
+ .join("runtime")
1205
+ .join("copilot-instructions")
1206
+ .join(agent_id);
1207
+ std::fs::create_dir_all(&dir)
1208
+ .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", dir.display())))?;
1209
+ let agents_md = dir.join("AGENTS.md");
1210
+ std::fs::write(&agents_md, system_prompt.as_bytes())
1211
+ .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", agents_md.display())))?;
1212
+ env.insert(
1213
+ "COPILOT_CUSTOM_INSTRUCTIONS_DIRS".to_string(),
1214
+ dir.to_string_lossy().to_string(),
1215
+ );
1216
+ // ★ C-4 P0(N39 红线 / MUST-12) — copilot config 默认 `updateTerminalTitle=true`
1217
+ // 会改 tmux window 名(help-config 原文)。tmux window 名是框架定位 agent 的
1218
+ // anchor(window==agent_id);copilot 静默改写 → 寻址 / kill / 保护集 三处同源
1219
+ // 派生漂移 → B5 protected_set 误判、MUST-12 pane 身份失锚、N39 同源派生破。
1220
+ // 漏关后果定级为【B5 leader 误杀同级 incident】,绝不允许 silent 跳过。
1221
+ // 主案:env `COPILOT_DISABLE_TERMINAL_TITLE=1`(help-config 原文 "Can also be
1222
+ // disabled via the COPILOT_DISABLE_TERMINAL_TITLE environment variable")。
1223
+ env.insert("COPILOT_DISABLE_TERMINAL_TITLE".to_string(), "1".to_string());
1224
+ Ok(())
1225
+ }
1226
+
1227
+ /// C-3-2/C-3-3 cr verdict v2 — Copilot spawn 前调 `copilot mcp list` 扫用户全局
1228
+ /// `~/.copilot/mcp-config.json` 与 workspace `.mcp.json` 的 MCP 残留;对每个非
1229
+ /// `team_orchestrator` server 追加 `--disable-mcp-server <name>`(main-help:72-73)
1230
+ /// 并落 `<log_dir>/mcp-residual.txt` + emit `provider.copilot.mcp_residual_detected`
1231
+ /// event(MUST-NOT-13 诚实记录,非 silent)。
1232
+ ///
1233
+ /// 失败回 `LifecycleError::StatePersist`,不 silent 吞;`copilot mcp list` 自身
1234
+ /// 无法运行(命令缺失 / 退出码非零)时,仅记 `mcp-residual.txt` 的 unavailable
1235
+ /// 行,不阻断 spawn(provider 一期 subscription-only,工具链可能未完全就绪)。
1236
+ fn apply_copilot_mcp_residual_disables(
1237
+ workspace: &Path,
1238
+ agent_id: &str,
1239
+ argv: &mut Vec<String>,
1240
+ log_dir: &Path,
1241
+ ) -> Result<(), LifecycleError> {
1242
+ let listing = std::process::Command::new("copilot")
1243
+ .arg("mcp")
1244
+ .arg("list")
1245
+ .output();
1246
+ let residual_path = log_dir.join("mcp-residual.txt");
1247
+ match listing {
1248
+ Ok(out) if out.status.success() => {
1249
+ let text = String::from_utf8_lossy(&out.stdout).to_string();
1250
+ std::fs::write(&residual_path, &text).map_err(|e| {
1251
+ LifecycleError::StatePersist(format!("{}: {e}", residual_path.display()))
1252
+ })?;
1253
+ let residual_servers = parse_copilot_mcp_list_server_names(&text);
1254
+ let non_orchestrator: Vec<String> = residual_servers
1255
+ .iter()
1256
+ .filter(|name| name.as_str() != "team_orchestrator")
1257
+ .cloned()
1258
+ .collect();
1259
+ for name in &non_orchestrator {
1260
+ argv.push("--disable-mcp-server".to_string());
1261
+ argv.push(name.clone());
1262
+ }
1263
+ if !non_orchestrator.is_empty() {
1264
+ let event_log = crate::event_log::EventLog::new(workspace);
1265
+ let _ = event_log.write(
1266
+ "provider.copilot.mcp_residual_detected",
1267
+ serde_json::json!({
1268
+ "agent_id": agent_id,
1269
+ "residual_servers": non_orchestrator,
1270
+ "log_path": residual_path.to_string_lossy(),
1271
+ }),
1272
+ );
1273
+ }
1274
+ }
1275
+ Ok(out) => {
1276
+ let stderr = String::from_utf8_lossy(&out.stderr).to_string();
1277
+ std::fs::write(
1278
+ &residual_path,
1279
+ format!("copilot mcp list exit={:?} stderr={stderr}\n", out.status.code()),
1280
+ )
1281
+ .map_err(|e| {
1282
+ LifecycleError::StatePersist(format!("{}: {e}", residual_path.display()))
1283
+ })?;
1284
+ }
1285
+ Err(e) => {
1286
+ std::fs::write(
1287
+ &residual_path,
1288
+ format!("copilot mcp list unavailable: {e}\n"),
1289
+ )
1290
+ .map_err(|e| {
1291
+ LifecycleError::StatePersist(format!("{}: {e}", residual_path.display()))
1292
+ })?;
1293
+ }
1294
+ }
1295
+ Ok(())
1296
+ }
1297
+
1298
+ /// 解析 `copilot mcp list` 输出取 server 名集合(te 真机实证 v2,1.0.59 形态):
1299
+ /// ```text
1300
+ /// User servers:
1301
+ /// foo (local)
1302
+ /// bar (http)
1303
+ /// Builtin servers:
1304
+ /// github-mcp-server (local)
1305
+ /// ```
1306
+ /// 或空集形态(te 真机实证 fake HOME 无 mcp-config.json):
1307
+ /// ```text
1308
+ /// No MCP servers configured.
1309
+ ///
1310
+ /// Add a server with:
1311
+ /// copilot mcp add <name> -- <command> [args...]
1312
+ /// copilot mcp add --transport http <name> <url>
1313
+ /// ```
1314
+ ///
1315
+ /// 规则:
1316
+ /// 1. 首行含 "No MCP servers configured" → 立即返空(避免把 "Add a server with"
1317
+ /// 段下的 help 行误识为 server)
1318
+ /// 2. 段标题行(非缩进、以 `:` 结尾):只有 *servers:* 后缀的段(User/Builtin/
1319
+ /// Workspace servers:)才进 server-listing 模式;其余段(如 "Add a server with:")
1320
+ /// 进 skip 模式直到下个 servers: 段或文档结束
1321
+ /// 3. servers: 段内的缩进行取首段 token,剥 ` (local)`/` (http)`/` (sse)` 后缀
1322
+ /// 4. 空行 / 不识别行容忍跳过(诚实降级:漏识 = silent 残留,在 mcp-residual.txt
1323
+ /// 全量落盘留证)
1324
+ fn parse_copilot_mcp_list_server_names(text: &str) -> Vec<String> {
1325
+ let mut out: Vec<String> = Vec::new();
1326
+ let mut in_servers_section = false;
1327
+ for line in text.lines() {
1328
+ let trimmed_end = line.trim_end();
1329
+ if trimmed_end.is_empty() {
1330
+ continue;
1331
+ }
1332
+ // C-3-2 fix(te 真机实证):空集 sentinel 立即返空。
1333
+ if trimmed_end
1334
+ .trim_start()
1335
+ .starts_with("No MCP servers configured")
1336
+ {
1337
+ return Vec::new();
1338
+ }
1339
+ // 段标题行(非缩进):决定后续缩进行是否取 server 名。"*servers:" 是
1340
+ // listing 段(User/Builtin/Workspace),其它段都 skip(如 "Add a server with:"
1341
+ // 下面的 help 命令缩进行)。
1342
+ if !(line.starts_with(' ') || line.starts_with('\t')) {
1343
+ let lower = trimmed_end.to_ascii_lowercase();
1344
+ in_servers_section = lower.trim_end_matches(':').ends_with("servers");
1345
+ continue;
1346
+ }
1347
+ if !in_servers_section {
1348
+ continue;
1349
+ }
1350
+ let trimmed = trimmed_end.trim_start();
1351
+ if trimmed.is_empty() {
1352
+ continue;
1353
+ }
1354
+ let mut token = trimmed.split_whitespace().next().unwrap_or("").to_string();
1355
+ // 剥常见装饰后缀(实测形如 "(local)"/"(http)"/"(sse)" 是独立 whitespace
1356
+ // 分隔的 token,首段 token 通常不带括号;若实际 copilot 把括号粘连首段
1357
+ // token,这里多做一次后缀剥离守护)。
1358
+ if let Some(idx) = token.find('(') {
1359
+ token.truncate(idx);
1360
+ }
1361
+ token = token.trim_end_matches(':').trim().to_string();
1362
+ if token.is_empty() {
1363
+ continue;
1364
+ }
1365
+ out.push(token);
1366
+ }
1367
+ out
1368
+ }
1369
+
1005
1370
  pub(crate) fn apply_profile_launch_env(
1006
1371
  env: &mut BTreeMap<String, String>,
1007
1372
  profile_launch: &crate::provider::ProviderProfileLaunch,
@@ -1174,6 +1539,7 @@ fn parse_provider(raw: &str) -> Option<Provider> {
1174
1539
  "claude" => Some(Provider::Claude),
1175
1540
  "claude_code" => Some(Provider::ClaudeCode),
1176
1541
  "codex" => Some(Provider::Codex),
1542
+ "copilot" => Some(Provider::Copilot),
1177
1543
  "gemini_cli" => Some(Provider::GeminiCli),
1178
1544
  "fake" => Some(Provider::Fake),
1179
1545
  _ => None,
@@ -2120,6 +2486,7 @@ pub fn fork_agent(
2120
2486
  workspace: &Path,
2121
2487
  source_agent_id: &AgentId,
2122
2488
  as_agent_id: &AgentId,
2489
+ label: Option<&str>,
2123
2490
  open_display: bool,
2124
2491
  team: Option<&str>,
2125
2492
  ) -> Result<ForkAgentReport, LifecycleError> {
@@ -2133,6 +2500,7 @@ pub fn fork_agent(
2133
2500
  workspace,
2134
2501
  source_agent_id,
2135
2502
  as_agent_id,
2503
+ label,
2136
2504
  open_display,
2137
2505
  team,
2138
2506
  &crate::tmux_backend::TmuxBackend::for_workspace(&selected.run_workspace),
@@ -2143,6 +2511,7 @@ pub fn fork_agent_with_transport(
2143
2511
  workspace: &Path,
2144
2512
  source_agent_id: &AgentId,
2145
2513
  as_agent_id: &AgentId,
2514
+ label: Option<&str>,
2146
2515
  open_display: bool,
2147
2516
  team: Option<&str>,
2148
2517
  transport: &dyn Transport,
@@ -2201,7 +2570,7 @@ pub fn fork_agent_with_transport(
2201
2570
  as_agent_id.as_str()
2202
2571
  )));
2203
2572
  }
2204
- let new_spec = append_forked_agent(&spec, source_agent, source_agent_id, as_agent_id)?;
2573
+ let new_spec = append_forked_agent(&spec, source_agent, source_agent_id, as_agent_id, label)?;
2205
2574
  crate::model::spec::validate_spec(&new_spec, &spec_workspace)
2206
2575
  .map_err(|e| LifecycleError::Compile(e.to_string()))?;
2207
2576
  std::fs::write(&spec_path, yaml::dumps(&new_spec))
@@ -2251,11 +2620,16 @@ pub fn fork_agent_with_transport(
2251
2620
  LifecycleError::Provider(e.to_string())
2252
2621
  })?;
2253
2622
  let mcp_config = resolve_mcp_config(mcp_config, &workspace, as_agent_id.as_str(), &fork_team);
2254
- let mcp_config_path = write_worker_mcp_config(&workspace, as_agent_id.as_str(), &mcp_config)
2255
- .map_err(|e| {
2256
- let _ = std::fs::write(&spec_path, text.as_bytes());
2257
- e
2258
- })?;
2623
+ let mcp_config_path = write_worker_mcp_config_for_provider(
2624
+ &workspace,
2625
+ as_agent_id.as_str(),
2626
+ &mcp_config,
2627
+ Some(provider),
2628
+ )
2629
+ .map_err(|e| {
2630
+ let _ = std::fs::write(&spec_path, text.as_bytes());
2631
+ e
2632
+ })?;
2259
2633
  let profile_dir = spec_workspace.join("profiles");
2260
2634
  let profile_launch =
2261
2635
  crate::lifecycle::profile_launch::prepare_provider_profile_launch_with_profile_dir(
@@ -2300,10 +2674,25 @@ pub fn fork_agent_with_transport(
2300
2674
  // _tmux_session_exists — an ABSENT session => new-session (spawn_first), present => new-window
2301
2675
  // (spawn_into). The Rust restart seam (restart.rs spawn_agent_window) uses the same branch.
2302
2676
  let session_live = transport.has_session(&session_name).unwrap_or(false);
2677
+ let env_unset: Vec<String> = profile_launch.env_unset.iter().cloned().collect();
2303
2678
  let spawn_result = if session_live {
2304
- transport.spawn_into(&session_name, &window, &plan.argv, &workspace, &env)
2679
+ transport.spawn_into_with_env_unset(
2680
+ &session_name,
2681
+ &window,
2682
+ &plan.argv,
2683
+ &workspace,
2684
+ &env,
2685
+ &env_unset,
2686
+ )
2305
2687
  } else {
2306
- transport.spawn_first(&session_name, &window, &plan.argv, &workspace, &env)
2688
+ transport.spawn_first_with_env_unset(
2689
+ &session_name,
2690
+ &window,
2691
+ &plan.argv,
2692
+ &workspace,
2693
+ &env,
2694
+ &env_unset,
2695
+ )
2307
2696
  };
2308
2697
  let spawn = spawn_result.map_err(|e| {
2309
2698
  let _ = std::fs::write(&spec_path, text.as_bytes());
@@ -2493,6 +2882,7 @@ fn append_forked_agent(
2493
2882
  source_agent: &Value,
2494
2883
  source_agent_id: &AgentId,
2495
2884
  as_agent_id: &AgentId,
2885
+ label: Option<&str>,
2496
2886
  ) -> Result<Value, LifecycleError> {
2497
2887
  let mut new_agent = source_agent.clone();
2498
2888
  set_yaml_map_value(
@@ -2501,11 +2891,16 @@ fn append_forked_agent(
2501
2891
  Value::Str(as_agent_id.as_str().to_string()),
2502
2892
  )?;
2503
2893
  // golden operations.py:315 `str(label or new_agent.get("role") or as_agent_id)` — Python `or`
2504
- // falsiness: an EMPTY-string role is falsy and falls through to as_agent_id.
2505
- let role = new_agent
2506
- .get("role")
2507
- .and_then(Value::as_str)
2894
+ // falsiness: an EMPTY-string label/role is falsy and falls through to the next tier.
2895
+ // The label IS the forked agent's new role (it feeds the identity prompt — B2 family).
2896
+ let role = label
2508
2897
  .filter(|s| !s.is_empty())
2898
+ .or_else(|| {
2899
+ new_agent
2900
+ .get("role")
2901
+ .and_then(Value::as_str)
2902
+ .filter(|s| !s.is_empty())
2903
+ })
2509
2904
  .unwrap_or_else(|| as_agent_id.as_str())
2510
2905
  .to_string();
2511
2906
  set_yaml_map_value(&mut new_agent, "role", Value::Str(role.clone()))?;
@@ -2955,12 +3350,22 @@ fn override_spec_session_name(spec: &mut Value, session_name: &str) {
2955
3350
  }
2956
3351
 
2957
3352
  fn spec_session_name(spec: &Value) -> SessionName {
2958
- let name = spec
3353
+ if let Some(name) = spec
2959
3354
  .get("runtime")
2960
3355
  .and_then(|v| v.get("session_name"))
2961
3356
  .and_then(Value::as_str)
2962
- .unwrap_or("team-agent");
2963
- SessionName::new(name)
3357
+ .filter(|name| !name.is_empty())
3358
+ {
3359
+ return SessionName::new(name);
3360
+ }
3361
+ // Python launch/core.py:56 — fallback derives from the team name, not a constant.
3362
+ let team_name = spec
3363
+ .get("team")
3364
+ .and_then(|team| team.get("name"))
3365
+ .and_then(Value::as_str)
3366
+ .filter(|name| !name.is_empty())
3367
+ .unwrap_or("agent");
3368
+ SessionName::new(format!("team-{team_name}"))
2964
3369
  }
2965
3370
 
2966
3371
  fn spec_agents(spec: &Value) -> Vec<AgentId> {