@team-agent/installer 0.3.3 → 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 (63) 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 +52 -11
  5. package/crates/team-agent/src/cli/emit.rs +3 -2
  6. package/crates/team-agent/src/cli/mod.rs +225 -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/compiler/tests.rs +2 -2
  14. package/crates/team-agent/src/compiler.rs +1 -1
  15. package/crates/team-agent/src/coordinator/backoff.rs +57 -9
  16. package/crates/team-agent/src/coordinator/health.rs +65 -2
  17. package/crates/team-agent/src/coordinator/runtime_detectors.rs +28 -16
  18. package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +87 -0
  19. package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
  20. package/crates/team-agent/src/coordinator/tests/watch.rs +4 -2
  21. package/crates/team-agent/src/coordinator/tick.rs +195 -43
  22. package/crates/team-agent/src/leader/helpers.rs +2 -0
  23. package/crates/team-agent/src/leader/rediscover.rs +1 -0
  24. package/crates/team-agent/src/leader/start.rs +9 -1
  25. package/crates/team-agent/src/leader/takeover.rs +18 -1
  26. package/crates/team-agent/src/lifecycle/display.rs +3 -3
  27. package/crates/team-agent/src/lifecycle/launch.rs +772 -285
  28. package/crates/team-agent/src/lifecycle/mod.rs +1 -0
  29. package/crates/team-agent/src/lifecycle/profile_launch.rs +110 -4
  30. package/crates/team-agent/src/lifecycle/profile_smoke.rs +4 -1
  31. package/crates/team-agent/src/lifecycle/restart/agent.rs +16 -5
  32. package/crates/team-agent/src/lifecycle/restart/common.rs +35 -25
  33. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +31 -25
  34. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +2 -2
  35. package/crates/team-agent/src/lifecycle/tests/core.rs +5 -5
  36. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +4 -4
  37. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +5 -3
  38. package/crates/team-agent/src/lifecycle/types.rs +4 -0
  39. package/crates/team-agent/src/lifecycle/worker_command_context.rs +361 -0
  40. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +2 -1
  41. package/crates/team-agent/src/mcp_server/tests/scoped.rs +14 -1
  42. package/crates/team-agent/src/mcp_server/tests/send.rs +15 -1
  43. package/crates/team-agent/src/mcp_server/tools.rs +65 -9
  44. package/crates/team-agent/src/mcp_server/wire.rs +2 -1
  45. package/crates/team-agent/src/message_store.rs +80 -0
  46. package/crates/team-agent/src/messaging/results.rs +76 -5
  47. package/crates/team-agent/src/messaging/send.rs +3 -1
  48. package/crates/team-agent/src/messaging/types.rs +15 -1
  49. package/crates/team-agent/src/messaging/watchers.rs +68 -30
  50. package/crates/team-agent/src/model/enums.rs +7 -1
  51. package/crates/team-agent/src/model/permissions.rs +7 -0
  52. package/crates/team-agent/src/model/spec.rs +3 -1
  53. package/crates/team-agent/src/provider/adapter.rs +472 -7
  54. package/crates/team-agent/src/provider/classify.rs +6 -2
  55. package/crates/team-agent/src/provider/faults.rs +3 -2
  56. package/crates/team-agent/src/provider/startup_prompt.rs +25 -7
  57. package/crates/team-agent/src/provider/types.rs +11 -0
  58. package/crates/team-agent/src/session_capture.rs +1 -0
  59. package/crates/team-agent/src/state/persist.rs +95 -19
  60. package/crates/team-agent/src/tmux_backend/tests.rs +8 -7
  61. package/crates/team-agent/src/tmux_backend.rs +134 -6
  62. package/crates/team-agent/src/transport.rs +32 -0
  63. package/package.json +4 -4
@@ -72,9 +72,8 @@ pub fn launch_with_transport_in_workspace(
72
72
  spec_path.display()
73
73
  )));
74
74
  }
75
- let text = std::fs::read_to_string(spec_path).map_err(|e| {
76
- LifecycleError::Compile(format!("{}: {e}", spec_path.display()))
77
- })?;
75
+ let text = std::fs::read_to_string(spec_path)
76
+ .map_err(|e| LifecycleError::Compile(format!("{}: {e}", spec_path.display())))?;
78
77
  let spec = yaml::loads(&text).map_err(|e| LifecycleError::Compile(e.to_string()))?;
79
78
  let session_name = spec_session_name(&spec);
80
79
  let safety = effective_runtime_config(&spec)?;
@@ -101,8 +100,23 @@ pub fn launch_with_transport_in_workspace(
101
100
  let started = if dry_run {
102
101
  Vec::new()
103
102
  } else {
104
- let started = spawn_agents(workspace, spec_path, &spec, &session_name, &safety, transport)?;
105
- persist_spawn_agent_state(workspace, spec_path, &spec, &session_name, transport, &started, &safety)?;
103
+ let started = spawn_agents(
104
+ workspace,
105
+ spec_path,
106
+ &spec,
107
+ &session_name,
108
+ &safety,
109
+ transport,
110
+ )?;
111
+ persist_spawn_agent_state(
112
+ workspace,
113
+ spec_path,
114
+ &spec,
115
+ &session_name,
116
+ transport,
117
+ &started,
118
+ &safety,
119
+ )?;
106
120
  started
107
121
  };
108
122
  Ok(LaunchReport {
@@ -126,6 +140,10 @@ fn spawn_agents(
126
140
  transport: &dyn Transport,
127
141
  ) -> Result<Vec<StartedAgent>, LifecycleError> {
128
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
+ );
129
147
  let mut started = Vec::new();
130
148
  for agent in spec_agent_values(spec) {
131
149
  let Some(agent_id_raw) = agent.get("id").and_then(Value::as_str) else {
@@ -152,59 +170,167 @@ fn spawn_agents(
152
170
  // has both the role instruction AND the callable Team Agent MCP capability.
153
171
  // probe5 RED proved that `build_command(.., None, None, ..)` left the worker
154
172
  // without `report_result`; placeholders are substituted at spawn time.
155
- let role = agent.get("role").and_then(Value::as_str);
156
- let tools = worker_tool_refs(agent_tool_strings(agent), safety);
157
- let tool_refs: Vec<&str> = tools.iter().map(String::as_str).collect();
173
+ let command_agent = crate::lifecycle::worker_command_context::WorkerCommandAgent::from_yaml(
174
+ agent,
175
+ Some(agent_id_raw),
176
+ provider,
177
+ );
178
+ let system_prompt =
179
+ crate::lifecycle::worker_command_context::compile_worker_system_prompt(&command_agent)?;
180
+ let tools = crate::lifecycle::worker_command_context::resolved_tool_strings_for_command(
181
+ &command_agent,
182
+ provider,
183
+ safety,
184
+ )?;
185
+ let resolved_tool_refs: Vec<&str> = tools.iter().map(String::as_str).collect();
158
186
  let mcp_team_id =
159
187
  runtime_active_team_key_for_spawn(workspace, spec_path, spec, session_name);
160
188
  let mcp_config = adapter
161
189
  .mcp_config(auth_mode)
162
190
  .map_err(|e| LifecycleError::Provider(e.to_string()))?;
163
191
  let mcp_config = resolve_mcp_config(mcp_config, workspace, agent_id_raw, &mcp_team_id);
164
- let mcp_config_path = write_worker_mcp_config(workspace, agent_id_raw, &mcp_config)?;
165
- let profile_dir = team_dir.join("profiles");
166
- let profile_launch = crate::lifecycle::profile_launch::prepare_provider_profile_launch_with_profile_dir(
192
+ let mcp_config_path = write_worker_mcp_config_for_provider(
167
193
  workspace,
168
194
  agent_id_raw,
169
- agent,
170
- Some(&profile_dir),
171
- Some(&mcp_config),
195
+ &mcp_config,
196
+ Some(provider),
172
197
  )?;
173
- let command_model = profile_launch
174
- .command_overrides
175
- .model
176
- .as_deref()
177
- .or(model);
198
+ let profile_dir = team_dir.join("profiles");
199
+ let profile_launch =
200
+ crate::lifecycle::profile_launch::prepare_provider_profile_launch_with_profile_dir(
201
+ workspace,
202
+ agent_id_raw,
203
+ agent,
204
+ Some(&profile_dir),
205
+ Some(&mcp_config),
206
+ )?;
207
+ let command_model = profile_launch.command_overrides.model.as_deref().or(model);
178
208
  let mut plan = adapter
179
209
  .build_command_plan(crate::provider::ProviderCommandContext {
180
210
  auth_mode,
181
211
  mcp_config: Some(&mcp_config),
182
- system_prompt: role,
212
+ system_prompt: Some(system_prompt.as_str()),
183
213
  model: command_model,
184
- tools: &tool_refs,
214
+ tools: &resolved_tool_refs,
185
215
  profile_launch: Some(&profile_launch),
186
216
  })
187
217
  .map_err(|e| LifecycleError::Provider(e.to_string()))?;
188
218
  if !plan.managed_mcp_config && !profile_launch.managed_mcp_config {
189
219
  point_native_mcp_config_at_file(&mut plan.argv, provider, &mcp_config_path);
190
220
  }
191
- fill_spawn_placeholders_full(
192
- &mut plan.argv,
193
- workspace,
194
- agent_id_raw,
195
- Some(&mcp_team_id),
196
- );
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
+ }
273
+ fill_spawn_placeholders_full(&mut plan.argv, workspace, agent_id_raw, Some(&mcp_team_id));
197
274
  let window = WindowName::new(agent_id_raw);
198
- let mut env = inherited_env_with_team_overrides(
199
- workspace,
200
- agent_id_raw,
201
- Some(&mcp_team_id),
202
- );
275
+ let mut env =
276
+ inherited_env_with_team_overrides(workspace, agent_id_raw, Some(&mcp_team_id));
203
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
+ }
204
316
  let spawn = if started.is_empty() {
205
- 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
+ )
206
325
  } else {
207
- 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
+ )
208
334
  }
209
335
  .map_err(|e| LifecycleError::Transport(e.to_string()))?;
210
336
  let _ = adapter.handle_startup_prompts(
@@ -213,6 +339,11 @@ fn spawn_agents(
213
339
  30,
214
340
  0.5,
215
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
+ }
216
347
  if matches!(transport.liveness(&spawn.pane_id), Ok(PaneLiveness::Dead)) {
217
348
  continue;
218
349
  }
@@ -258,12 +389,7 @@ fn persist_spawn_agent_state(
258
389
  let team_id = explicit_active_team_key(&state)
259
390
  .unwrap_or_else(|| runtime_team_key_for_spec(spec_path, spec, session_name));
260
391
  let worker_tmux_socket = launched_worker_tmux_socket(transport, workspace);
261
- drop_worker_pane_seeded_owner(
262
- &mut state,
263
- &team_id,
264
- started,
265
- worker_tmux_socket.as_deref(),
266
- );
392
+ drop_worker_pane_seeded_owner(&mut state, &team_id, started, worker_tmux_socket.as_deref());
267
393
  // Only persist running state for agents whose spawn still has a live target.
268
394
  let live_windows: BTreeSet<String> = transport
269
395
  .list_windows(session_name)
@@ -276,10 +402,7 @@ fn persist_spawn_agent_state(
276
402
  .map(|agent| agent.agent_id.as_str().to_string())
277
403
  .collect();
278
404
  let pane_pids_by_agent = pane_pids_by_started_agent(transport, started);
279
- let profile_dir = spec_path
280
- .parent()
281
- .unwrap_or(workspace)
282
- .join("profiles");
405
+ let profile_dir = spec_path.parent().unwrap_or(workspace).join("profiles");
283
406
  let mut agents = serde_json::Map::new();
284
407
  let mut spawn_index = 0_u32;
285
408
  for agent in spec_agent_values(spec) {
@@ -317,9 +440,7 @@ fn persist_spawn_agent_state(
317
440
  let pane_pid = pane_pids_by_agent.get(id).copied();
318
441
  let spawned_at = spawn_timestamp_for_agent(spawn_index);
319
442
  spawn_index = spawn_index.saturating_add(1);
320
- let started_agent = started
321
- .iter()
322
- .find(|agent| agent.agent_id.as_str() == id);
443
+ let started_agent = started.iter().find(|agent| agent.agent_id.as_str() == id);
323
444
  agents.insert(
324
445
  id.to_string(),
325
446
  running_agent_state(
@@ -327,7 +448,7 @@ fn persist_spawn_agent_state(
327
448
  id,
328
449
  provider,
329
450
  workspace,
330
- spec_path.parent().unwrap_or(workspace),
451
+ workspace,
331
452
  &spawned_at,
332
453
  &team_id,
333
454
  Some(agent_id_to_pane_id(started, id)),
@@ -373,7 +494,10 @@ fn agent_id_to_pane_id<'a>(started: &'a [StartedAgent], agent_id: &str) -> &'a s
373
494
  .unwrap_or("")
374
495
  }
375
496
 
376
- fn save_launched_team_state(workspace: &Path, launched: &serde_json::Value) -> Result<(), LifecycleError> {
497
+ fn save_launched_team_state(
498
+ workspace: &Path,
499
+ launched: &serde_json::Value,
500
+ ) -> Result<(), LifecycleError> {
377
501
  save_launched_team_state_for_key(workspace, launched, None)
378
502
  }
379
503
 
@@ -404,7 +528,8 @@ fn save_launched_team_state_for_key(
404
528
  };
405
529
  let mut projected = crate::state::projection::project_top_level_view(&merged, &launched_key);
406
530
  drop_unbound_top_level_owner(&mut projected);
407
- save_runtime_state(workspace, &projected).map_err(|e| LifecycleError::StatePersist(e.to_string()))
531
+ save_runtime_state(workspace, &projected)
532
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))
408
533
  }
409
534
 
410
535
  fn drop_bare_worker_seeded_owner(launched: &mut serde_json::Value, launched_key: &str) {
@@ -506,7 +631,11 @@ fn drop_unbound_top_level_owner(state: &mut serde_json::Value) {
506
631
  }
507
632
  }
508
633
 
509
- fn drop_foreign_seeded_owner(existing: &serde_json::Value, launched_key: &str, launched: &mut serde_json::Value) {
634
+ fn drop_foreign_seeded_owner(
635
+ existing: &serde_json::Value,
636
+ launched_key: &str,
637
+ launched: &mut serde_json::Value,
638
+ ) {
510
639
  let Some(pane) = launched
511
640
  .get("team_owner")
512
641
  .and_then(|owner| owner.get("pane_id"))
@@ -549,8 +678,7 @@ fn drop_worker_pane_seeded_owner(
549
678
  .ok()
550
679
  .filter(|value| !value.is_empty());
551
680
  let has_leader_identity_env = has_positive_caller_leader_env();
552
- let seeded_from_bare_tmux =
553
- !has_leader_identity_env && tmux_pane.as_deref() == Some(pane);
681
+ let seeded_from_bare_tmux = !has_leader_identity_env && tmux_pane.as_deref() == Some(pane);
554
682
  let caller_tmux_socket = crate::tmux_backend::socket_name_from_tmux_env();
555
683
  if seeded_from_bare_tmux
556
684
  && (tmux_sockets_match_or_unknown(caller_tmux_socket.as_deref(), worker_tmux_socket)
@@ -563,19 +691,14 @@ fn drop_worker_pane_seeded_owner(
563
691
 
564
692
  fn seeded_pane_looks_like_worker(pane: &str, started: &[StartedAgent]) -> bool {
565
693
  pane.ends_with("-first")
566
- || started
567
- .iter()
568
- .any(|agent| {
569
- pane == agent.target
570
- || pane.starts_with(agent.target.as_str())
571
- || agent.target.starts_with(pane)
572
- })
694
+ || started.iter().any(|agent| {
695
+ pane == agent.target
696
+ || pane.starts_with(agent.target.as_str())
697
+ || agent.target.starts_with(pane)
698
+ })
573
699
  }
574
700
 
575
- fn launched_worker_tmux_socket(
576
- transport: &dyn Transport,
577
- workspace: &Path,
578
- ) -> Option<String> {
701
+ fn launched_worker_tmux_socket(transport: &dyn Transport, workspace: &Path) -> Option<String> {
579
702
  if matches!(transport.kind(), crate::transport::BackendKind::Tmux) {
580
703
  Some(crate::tmux_backend::socket_name_for_workspace(workspace))
581
704
  } else {
@@ -583,10 +706,7 @@ fn launched_worker_tmux_socket(
583
706
  }
584
707
  }
585
708
 
586
- fn tmux_sockets_match_or_unknown(
587
- caller_socket: Option<&str>,
588
- worker_socket: Option<&str>,
589
- ) -> bool {
709
+ fn tmux_sockets_match_or_unknown(caller_socket: Option<&str>, worker_socket: Option<&str>) -> bool {
590
710
  match (caller_socket, worker_socket) {
591
711
  (Some(caller), Some(worker)) => caller == worker,
592
712
  (Some(_), None) => false,
@@ -595,7 +715,9 @@ fn tmux_sockets_match_or_unknown(
595
715
  }
596
716
 
597
717
  fn env_nonempty(key: &str) -> bool {
598
- std::env::var(key).ok().is_some_and(|value| !value.is_empty())
718
+ std::env::var(key)
719
+ .ok()
720
+ .is_some_and(|value| !value.is_empty())
599
721
  }
600
722
 
601
723
  fn seed_unbound_launched_owner(launched: &mut serde_json::Value, launched_key: &str) {
@@ -664,7 +786,11 @@ fn unbound_launched_owner(
664
786
  }))
665
787
  }
666
788
 
667
- fn owner_pane_belongs_to_other_team(existing: &serde_json::Value, launched_key: &str, pane: &str) -> bool {
789
+ fn owner_pane_belongs_to_other_team(
790
+ existing: &serde_json::Value,
791
+ launched_key: &str,
792
+ pane: &str,
793
+ ) -> bool {
668
794
  existing
669
795
  .get("teams")
670
796
  .and_then(serde_json::Value::as_object)
@@ -700,18 +826,25 @@ fn running_agent_state(
700
826
  .and_then(Value::as_str)
701
827
  .and_then(parse_auth_mode)
702
828
  .unwrap_or(AuthMode::Subscription);
703
- let profile = agent.get("profile").map(yaml_value_to_json).unwrap_or(serde_json::Value::Null);
829
+ let profile = agent
830
+ .get("profile")
831
+ .map(yaml_value_to_json)
832
+ .unwrap_or(serde_json::Value::Null);
704
833
  let window = agent.get("window").and_then(Value::as_str).unwrap_or(id);
705
834
  let mcp_config = crate::provider::get_adapter(provider)
706
835
  .mcp_config(auth_mode)
707
836
  .map_err(|e| LifecycleError::Provider(e.to_string()))?;
708
837
  let mcp_config = resolve_mcp_config(mcp_config, workspace, id, team_id);
709
- 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))?;
710
840
  let mut state = serde_json::Map::new();
711
841
  state.insert("status".to_string(), serde_json::json!("running"));
712
842
  state.insert("provider".to_string(), serde_json::json!(provider));
713
843
  state.insert("agent_id".to_string(), serde_json::json!(id));
714
- state.insert("model".to_string(), model.map_or(serde_json::Value::Null, |m| serde_json::json!(m)));
844
+ state.insert(
845
+ "model".to_string(),
846
+ model.map_or(serde_json::Value::Null, |m| serde_json::json!(m)),
847
+ );
715
848
  state.insert("auth_mode".to_string(), serde_json::json!(auth_mode));
716
849
  state.insert("profile".to_string(), profile);
717
850
  if agent.get("profile").is_some() {
@@ -737,7 +870,10 @@ fn running_agent_state(
737
870
  state.insert("rollout_path".to_string(), serde_json::Value::Null);
738
871
  state.insert("captured_at".to_string(), serde_json::Value::Null);
739
872
  state.insert("captured_via".to_string(), serde_json::Value::Null);
740
- state.insert("attribution_confidence".to_string(), serde_json::Value::Null);
873
+ state.insert(
874
+ "attribution_confidence".to_string(),
875
+ serde_json::Value::Null,
876
+ );
741
877
  if let Some(started_agent) = started_agent {
742
878
  persist_started_agent_plan_state(&mut state, started_agent);
743
879
  }
@@ -832,6 +968,20 @@ pub(crate) fn write_worker_mcp_config(
832
968
  workspace: &Path,
833
969
  agent_id: &str,
834
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>,
835
985
  ) -> Result<PathBuf, LifecycleError> {
836
986
  let path = workspace
837
987
  .join(".team/runtime/mcp")
@@ -840,22 +990,70 @@ pub(crate) fn write_worker_mcp_config(
840
990
  std::fs::create_dir_all(parent)
841
991
  .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", parent.display())))?;
842
992
  }
843
- 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}))
844
999
  .map_err(|e| LifecycleError::StatePersist(format!("serialize mcp config: {e}")))?;
845
1000
  std::fs::write(&path, body)
846
1001
  .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", path.display())))?;
847
1002
  Ok(path)
848
1003
  }
849
1004
 
850
- pub(crate) fn point_native_mcp_config_at_file(argv: &mut [String], provider: Provider, path: &Path) {
851
- if !matches!(provider, Provider::Claude | Provider::ClaudeCode) {
852
- return;
853
- }
854
- let Some(index) = argv.iter().position(|arg| arg == "--mcp-config") else {
855
- return;
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();
856
1011
  };
857
- if let Some(value) = argv.get_mut(index.saturating_add(1)) {
858
- *value = path.to_string_lossy().to_string();
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
+
1031
+ pub(crate) fn point_native_mcp_config_at_file(
1032
+ argv: &mut [String],
1033
+ provider: Provider,
1034
+ path: &Path,
1035
+ ) {
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
+ _ => {}
859
1057
  }
860
1058
  }
861
1059
 
@@ -874,13 +1072,19 @@ fn permissions_json(
874
1072
  let resolved = permissions::resolve_permissions(&AgentPermissionInput {
875
1073
  id: Some(AgentId::new(id)),
876
1074
  provider,
877
- role: agent.get("role").and_then(Value::as_str).map(str::to_string),
1075
+ role: agent
1076
+ .get("role")
1077
+ .and_then(Value::as_str)
1078
+ .map(str::to_string),
878
1079
  tools,
879
1080
  })?;
880
1081
  let mut out = serde_json::Map::new();
881
1082
  out.insert("agent_id".to_string(), serde_json::json!(id));
882
1083
  out.insert("provider".to_string(), serde_json::json!(provider));
883
- out.insert("tools".to_string(), serde_json::json!(resolved.sorted_tool_strings()));
1084
+ out.insert(
1085
+ "tools".to_string(),
1086
+ serde_json::json!(resolved.sorted_tool_strings()),
1087
+ );
884
1088
  out.insert(
885
1089
  "resolved_tools".to_string(),
886
1090
  serde_json::Value::Array(
@@ -896,7 +1100,10 @@ fn permissions_json(
896
1100
  .collect(),
897
1101
  ),
898
1102
  );
899
- out.insert("has_prompt_only".to_string(), serde_json::json!(resolved.has_prompt_only));
1103
+ out.insert(
1104
+ "has_prompt_only".to_string(),
1105
+ serde_json::json!(resolved.has_prompt_only),
1106
+ );
900
1107
  Ok(serde_json::Value::Object(out))
901
1108
  }
902
1109
 
@@ -920,9 +1127,10 @@ fn spawn_timestamp_for_agent(offset_micros: u32) -> String {
920
1127
  match std::env::var("TEAM_AGENT_TEST_FIXED_SPAWNED_AT") {
921
1128
  Ok(value) => chrono::DateTime::parse_from_rfc3339(&value)
922
1129
  .map(|dt| {
923
- (dt.with_timezone(&chrono::Utc) + chrono::Duration::microseconds(i64::from(offset_micros)))
924
- .format("%Y-%m-%dT%H:%M:%S%.6f+00:00")
925
- .to_string()
1130
+ (dt.with_timezone(&chrono::Utc)
1131
+ + chrono::Duration::microseconds(i64::from(offset_micros)))
1132
+ .format("%Y-%m-%dT%H:%M:%S%.6f+00:00")
1133
+ .to_string()
926
1134
  })
927
1135
  .unwrap_or(value),
928
1136
  Err(_) => spawn_timestamp(),
@@ -964,6 +1172,10 @@ pub(crate) fn inherited_env_with_team_overrides(
964
1172
  "TEAM_AGENT_WORKSPACE".to_string(),
965
1173
  workspace.to_string_lossy().to_string(),
966
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());
967
1179
  env.insert("TEAM_AGENT_AGENT_ID".to_string(), agent_id.to_string());
968
1180
  if let Some(tid) = team_id.filter(|s| !s.is_empty()) {
969
1181
  env.insert("TEAM_AGENT_OWNER_TEAM_ID".to_string(), tid.to_string());
@@ -971,6 +1183,190 @@ pub(crate) fn inherited_env_with_team_overrides(
971
1183
  env
972
1184
  }
973
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
+
974
1370
  pub(crate) fn apply_profile_launch_env(
975
1371
  env: &mut BTreeMap<String, String>,
976
1372
  profile_launch: &crate::provider::ProviderProfileLaunch,
@@ -1078,7 +1474,10 @@ pub(crate) fn fill_spawn_placeholders_full(
1078
1474
  *arg = workspace_text.clone();
1079
1475
  } else if arg == "{agent_id}" {
1080
1476
  *arg = agent_id.to_string();
1081
- } else if arg.contains("{workspace}") || arg.contains("{agent_id}") || arg.contains("{team_id}") {
1477
+ } else if arg.contains("{workspace}")
1478
+ || arg.contains("{agent_id}")
1479
+ || arg.contains("{team_id}")
1480
+ {
1082
1481
  *arg = arg
1083
1482
  .replace("{workspace}", &workspace_text)
1084
1483
  .replace("{agent_id}", agent_id)
@@ -1087,30 +1486,12 @@ pub(crate) fn fill_spawn_placeholders_full(
1087
1486
  }
1088
1487
  }
1089
1488
 
1090
- fn agent_tool_strings(agent: &Value) -> Vec<String> {
1091
- agent
1092
- .get("tools")
1093
- .and_then(Value::as_list)
1094
- .map(|items| {
1095
- items
1096
- .iter()
1097
- .filter_map(Value::as_str)
1098
- .map(str::to_string)
1099
- .collect()
1100
- })
1101
- .unwrap_or_default()
1102
- }
1103
-
1104
1489
  fn spec_team_id(spec: &Value) -> Option<String> {
1105
1490
  spec.get("team")
1106
1491
  .and_then(|v| v.get("id").or_else(|| v.get("name")))
1107
1492
  .and_then(Value::as_str)
1108
1493
  .map(str::to_string)
1109
- .or_else(|| {
1110
- spec.get("name")
1111
- .and_then(Value::as_str)
1112
- .map(str::to_string)
1113
- })
1494
+ .or_else(|| spec.get("name").and_then(Value::as_str).map(str::to_string))
1114
1495
  }
1115
1496
 
1116
1497
  fn runtime_active_team_key_for_spawn(
@@ -1158,6 +1539,7 @@ fn parse_provider(raw: &str) -> Option<Provider> {
1158
1539
  "claude" => Some(Provider::Claude),
1159
1540
  "claude_code" => Some(Provider::ClaudeCode),
1160
1541
  "codex" => Some(Provider::Codex),
1542
+ "copilot" => Some(Provider::Copilot),
1161
1543
  "gemini_cli" => Some(Provider::GeminiCli),
1162
1544
  "fake" => Some(Provider::Fake),
1163
1545
  _ => None,
@@ -1173,7 +1555,10 @@ fn parse_auth_mode(raw: &str) -> Option<AuthMode> {
1173
1555
  }
1174
1556
  }
1175
1557
 
1176
- fn quick_start_requested_team_key<'a>(team_id: Option<&'a str>, name: Option<&'a str>) -> Option<&'a str> {
1558
+ fn quick_start_requested_team_key<'a>(
1559
+ team_id: Option<&'a str>,
1560
+ name: Option<&'a str>,
1561
+ ) -> Option<&'a str> {
1177
1562
  team_id.or(name).filter(|team| !team.is_empty())
1178
1563
  }
1179
1564
 
@@ -1239,7 +1624,7 @@ fn quick_start_depth_guard(
1239
1624
  Ok(QuickStartDepth {
1240
1625
  parent_team_key: Some(parent_key),
1241
1626
  team_depth,
1242
- })
1627
+ })
1243
1628
  }
1244
1629
 
1245
1630
  fn infer_parent_team_from_active_state(state: &serde_json::Value) -> Option<String> {
@@ -1255,9 +1640,7 @@ fn has_live_runtime_teams(state: &serde_json::Value) -> bool {
1255
1640
  state
1256
1641
  .get("teams")
1257
1642
  .and_then(serde_json::Value::as_object)
1258
- .is_some_and(|teams| {
1259
- teams.values().any(team_has_running_agent)
1260
- })
1643
+ .is_some_and(|teams| teams.values().any(team_has_running_agent))
1261
1644
  }
1262
1645
 
1263
1646
  fn team_has_running_agent(team: &serde_json::Value) -> bool {
@@ -1265,20 +1648,18 @@ fn team_has_running_agent(team: &serde_json::Value) -> bool {
1265
1648
  .and_then(serde_json::Value::as_object)
1266
1649
  .is_some_and(|agents| {
1267
1650
  agents.values().any(|agent| {
1268
- agent
1269
- .get("status")
1270
- .and_then(serde_json::Value::as_str)
1271
- == Some("running")
1651
+ agent.get("status").and_then(serde_json::Value::as_str) == Some("running")
1272
1652
  })
1273
1653
  })
1274
1654
  }
1275
1655
 
1276
1656
  fn looks_ambiguous_child_team_key(team: &str) -> bool {
1277
1657
  let team = team.trim().to_ascii_lowercase();
1278
- team != "child" && (team.starts_with("child-")
1279
- || team.starts_with("child_")
1280
- || team.starts_with("child.")
1281
- || team.starts_with("child"))
1658
+ team != "child"
1659
+ && (team.starts_with("child-")
1660
+ || team.starts_with("child_")
1661
+ || team.starts_with("child.")
1662
+ || team.starts_with("child"))
1282
1663
  }
1283
1664
 
1284
1665
  fn looks_grandchild_team_key(team: &str) -> bool {
@@ -1290,7 +1671,11 @@ fn looks_grandchild_team_key(team: &str) -> bool {
1290
1671
  || team.starts_with("grandchild")
1291
1672
  }
1292
1673
 
1293
- fn annotate_team_depth(state: &mut serde_json::Value, parent_team_key: Option<&str>, team_depth: u64) {
1674
+ fn annotate_team_depth(
1675
+ state: &mut serde_json::Value,
1676
+ parent_team_key: Option<&str>,
1677
+ team_depth: u64,
1678
+ ) {
1294
1679
  let Some(obj) = state.as_object_mut() else {
1295
1680
  return;
1296
1681
  };
@@ -1337,9 +1722,7 @@ fn runtime_state_has_quick_start_team(state: &serde_json::Value, team: &str) ->
1337
1722
  || state
1338
1723
  .get("session_name")
1339
1724
  .and_then(serde_json::Value::as_str)
1340
- .is_some_and(|session| {
1341
- session == team || session.strip_prefix("team-") == Some(team)
1342
- })
1725
+ .is_some_and(|session| session == team || session.strip_prefix("team-") == Some(team))
1343
1726
  }
1344
1727
 
1345
1728
  fn json_team_identity_matches(state: &serde_json::Value, team: &str) -> bool {
@@ -1412,7 +1795,9 @@ pub fn quick_start_with_transport(
1412
1795
  transport: &dyn Transport,
1413
1796
  ) -> Result<QuickStartReport, LifecycleError> {
1414
1797
  let workspace = team_workspace(agents_dir);
1415
- quick_start_with_transport_in_workspace(&workspace, agents_dir, name, yes, fresh, team_id, transport)
1798
+ quick_start_with_transport_in_workspace(
1799
+ &workspace, agents_dir, name, yes, fresh, team_id, transport,
1800
+ )
1416
1801
  }
1417
1802
 
1418
1803
  pub fn quick_start_with_transport_in_workspace(
@@ -1468,11 +1853,12 @@ pub fn quick_start_with_transport_in_workspace(
1468
1853
  .map(SessionName::new),
1469
1854
  state_path: Some(state_path),
1470
1855
  next_actions: vec![
1471
- "run restart to resume the existing team or pass --fresh to replace it".to_string(),
1856
+ "run restart to resume the existing team or pass --fresh to replace it"
1857
+ .to_string(),
1472
1858
  ],
1473
- });
1474
- }
1475
- }
1859
+ });
1860
+ }
1861
+ }
1476
1862
  }
1477
1863
  // CR-040/042: repeated quick-start from one template with distinct --team-id/--name
1478
1864
  // must NOT collide on the template-derived tmux session. Override the compiled
@@ -1488,12 +1874,12 @@ pub fn quick_start_with_transport_in_workspace(
1488
1874
  runtime_team_key_for_spec(&spec_path, &spec, &session_name)
1489
1875
  });
1490
1876
  let spec_path = agents_dir.join("team.spec.yaml");
1491
- std::fs::write(&spec_path, yaml::dumps(&spec)).map_err(|e| {
1492
- LifecycleError::StatePersist(format!("{}: {e}", spec_path.display()))
1493
- })?;
1877
+ std::fs::write(&spec_path, yaml::dumps(&spec))
1878
+ .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", spec_path.display())))?;
1494
1879
  let _store = crate::message_store::MessageStore::open(&workspace)
1495
1880
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
1496
- let resolved_spec_path = std::fs::canonicalize(&spec_path).unwrap_or_else(|_| spec_path.clone());
1881
+ let resolved_spec_path =
1882
+ std::fs::canonicalize(&spec_path).unwrap_or_else(|_| spec_path.clone());
1497
1883
  let state = initial_runtime_state(&spec, &resolved_spec_path, &workspace, agents_dir);
1498
1884
  save_launched_team_state_for_key(&workspace, &state, Some(&state_team_key))?;
1499
1885
  annotate_persisted_team_depth(
@@ -1505,14 +1891,16 @@ pub fn quick_start_with_transport_in_workspace(
1505
1891
  // FIX (rt-host-a real-machine finding): dry_run=false so launch_with_transport calls spawn_agents
1506
1892
  // and really creates the tmux session + worker windows (was hardcoded true → never spawned, which
1507
1893
  // also starved the coordinator: no session → first tick TmuxSessionMissing → run_daemon loop exits).
1508
- let mut launch = launch_with_transport_in_workspace(&workspace, &spec_path, false, yes, true, transport)?;
1894
+ let mut launch =
1895
+ launch_with_transport_in_workspace(&workspace, &spec_path, false, yes, true, transport)?;
1509
1896
  annotate_persisted_team_depth(
1510
1897
  &workspace,
1511
1898
  &state_team_key,
1512
1899
  team_depth.parent_team_key.as_deref(),
1513
1900
  team_depth.team_depth,
1514
1901
  )?;
1515
- launch.leader_receiver_attached = launched_team_receiver_is_attached(&workspace, &state_team_key);
1902
+ launch.leader_receiver_attached =
1903
+ launched_team_receiver_is_attached(&workspace, &state_team_key);
1516
1904
  launch.session_capture_incomplete_agents =
1517
1905
  quick_start_session_capture_incomplete_agents(&workspace, &state_team_key);
1518
1906
  let coordinator_workspace = crate::coordinator::WorkspacePath::new(workspace.clone());
@@ -1532,12 +1920,29 @@ pub fn quick_start_with_transport_in_workspace(
1532
1920
  // asynchronously after spawn), so the verdict is PendingToolLoad — never
1533
1921
  // bare Ready.
1534
1922
  let worker_readiness = quick_start_worker_readiness(&workspace, &state_team_key);
1923
+ let attach_commands = crate::tmux_backend::attach_commands_for_windows(
1924
+ &workspace,
1925
+ &session_name,
1926
+ launch
1927
+ .started
1928
+ .iter()
1929
+ .map(|started| started.agent_id.as_str()),
1930
+ );
1931
+ let mut next_actions = vec![format!(
1932
+ "team compiled; real spawn is behind the transport/provider boundary; {coordinator_action}"
1933
+ )];
1934
+ next_actions.extend(attach_commands.iter().cloned());
1935
+ let display_backend = state
1936
+ .get("display_backend")
1937
+ .and_then(serde_json::Value::as_str)
1938
+ .unwrap_or("none")
1939
+ .to_string();
1535
1940
  Ok(QuickStartReport::Ready {
1536
1941
  session_name,
1537
1942
  launch: Box::new(launch),
1538
- next_actions: vec![format!(
1539
- "team compiled; real spawn is behind the transport/provider boundary; {coordinator_action}"
1540
- )],
1943
+ next_actions,
1944
+ attach_commands,
1945
+ display_backend,
1541
1946
  worker_readiness,
1542
1947
  })
1543
1948
  }
@@ -1556,7 +1961,10 @@ fn quick_start_worker_readiness(workspace: &Path, team_key: &str) -> QuickStartR
1556
1961
  .and_then(serde_json::Value::as_object)
1557
1962
  .and_then(|teams| teams.get(team_key))
1558
1963
  .unwrap_or(&state);
1559
- let Some(agents) = team_state.get("agents").and_then(serde_json::Value::as_object) else {
1964
+ let Some(agents) = team_state
1965
+ .get("agents")
1966
+ .and_then(serde_json::Value::as_object)
1967
+ else {
1560
1968
  return QuickStartReadiness::PendingToolLoad;
1561
1969
  };
1562
1970
  let all_spawned = !agents.is_empty();
@@ -1575,9 +1983,12 @@ fn quick_start_worker_readiness(workspace: &Path, team_key: &str) -> QuickStartR
1575
1983
  if !unhealthy.is_empty() {
1576
1984
  unhealthy.sort();
1577
1985
  unhealthy.dedup();
1578
- QuickStartReadiness::Degraded { unhealthy_agents: unhealthy }
1986
+ QuickStartReadiness::Degraded {
1987
+ unhealthy_agents: unhealthy,
1988
+ }
1579
1989
  } else {
1580
- let incomplete_agents = crate::session_capture::incomplete_interacted_resumable_agent_ids(team_state);
1990
+ let incomplete_agents =
1991
+ crate::session_capture::incomplete_interacted_resumable_agent_ids(team_state);
1581
1992
  let all_resumable_have_session = incomplete_agents.is_empty();
1582
1993
  let _readiness_ready = all_spawned && all_attached_receiver && all_resumable_have_session;
1583
1994
  QuickStartReadiness::PendingToolLoad
@@ -1621,10 +2032,7 @@ fn team_uses_fake_model_harness(team_state: &serde_json::Value) -> bool {
1621
2032
  .is_some_and(|agents| {
1622
2033
  !agents.is_empty()
1623
2034
  && agents.values().all(|agent| {
1624
- agent
1625
- .get("model")
1626
- .and_then(serde_json::Value::as_str)
1627
- == Some("fake")
2035
+ agent.get("model").and_then(serde_json::Value::as_str) == Some("fake")
1628
2036
  })
1629
2037
  })
1630
2038
  }
@@ -1649,9 +2057,11 @@ fn leader_receiver_is_attached(team_state: &serde_json::Value) -> bool {
1649
2057
  /// `--dangerously-*` flag,产出危险审批继承态。launch 在 inherited=false 且无 --yes 时拒。
1650
2058
  pub fn detect_dangerous_approval() -> Result<DangerousApproval, LifecycleError> {
1651
2059
  if let Ok(raw) = std::env::var("TEAM_AGENT_TEST_PROCESS_ANCESTRY_ARGV_JSON") {
1652
- let argv_tokens = serde_json::from_str::<Vec<String>>(&raw)
1653
- .map_err(|e| LifecycleError::StatePersist(format!("invalid test ancestry argv: {e}")))?;
1654
- return Ok(detect_dangerous_approval_in_argv(&argv_tokens).unwrap_or_else(disabled_dangerous_approval));
2060
+ let argv_tokens = serde_json::from_str::<Vec<String>>(&raw).map_err(|e| {
2061
+ LifecycleError::StatePersist(format!("invalid test ancestry argv: {e}"))
2062
+ })?;
2063
+ return Ok(detect_dangerous_approval_in_argv(&argv_tokens)
2064
+ .unwrap_or_else(disabled_dangerous_approval));
1655
2065
  }
1656
2066
  for argv_tokens in process_ancestry_argv(std::process::id()) {
1657
2067
  if let Some(detected) = detect_dangerous_approval_in_argv(&argv_tokens) {
@@ -1667,7 +2077,8 @@ fn detect_dangerous_approval_in_argv(argv_tokens: &[String]) -> Option<Dangerous
1667
2077
  for token in argv_tokens {
1668
2078
  for (provider, flag) in dangerous_leader_flags() {
1669
2079
  if token == flag {
1670
- let unexpected_binary = !binary_matches_provider(provider, ancestry_binary_name.as_deref());
2080
+ let unexpected_binary =
2081
+ !binary_matches_provider(provider, ancestry_binary_name.as_deref());
1671
2082
  return Some(DangerousApproval {
1672
2083
  enabled: true,
1673
2084
  source: DangerousApprovalSource::LeaderProcess,
@@ -1857,9 +2268,9 @@ pub fn add_agent(
1857
2268
  }
1858
2269
  Err(error) => return Err(LifecycleError::TeamSelect(error.to_string())),
1859
2270
  };
1860
- let team_dir = selected
1861
- .spec_workspace
1862
- .ok_or_else(|| LifecycleError::TeamSelect("active team spec workspace not found".to_string()))?;
2271
+ let team_dir = selected.spec_workspace.ok_or_else(|| {
2272
+ LifecycleError::TeamSelect("active team spec workspace not found".to_string())
2273
+ })?;
1863
2274
  add_agent_with_transport_at_paths(
1864
2275
  &selected.run_workspace,
1865
2276
  &team_dir,
@@ -1910,8 +2321,9 @@ fn add_agent_with_transport_at_paths(
1910
2321
  .map(str::to_string)
1911
2322
  .or_else(|| explicit_active_team_key(&runtime_state))
1912
2323
  .unwrap_or_else(|| crate::state::projection::team_state_key(&runtime_state));
1913
- let owner_state = crate::state::projection::select_runtime_state(run_workspace, Some(&canonical_team_key))
1914
- .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
2324
+ let owner_state =
2325
+ crate::state::projection::select_runtime_state(run_workspace, Some(&canonical_team_key))
2326
+ .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
1915
2327
  ensure_owner_allowed_for_state(&owner_state, Some(agent_id))?;
1916
2328
  if !role_file_path.exists() {
1917
2329
  return Err(LifecycleError::Compile(format!(
@@ -1929,9 +2341,8 @@ fn add_agent_with_transport_at_paths(
1929
2341
  .map_err(|e| LifecycleError::Compile(e.to_string()))?;
1930
2342
  let safety = effective_runtime_config(&spec)?;
1931
2343
  let spec_path = team_dir.join("team.spec.yaml");
1932
- std::fs::write(&spec_path, yaml::dumps(&spec)).map_err(|e| {
1933
- LifecycleError::StatePersist(format!("{}: {e}", spec_path.display()))
1934
- })?;
2344
+ std::fs::write(&spec_path, yaml::dumps(&spec))
2345
+ .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", spec_path.display())))?;
1935
2346
  let (meta, _) = crate::compiler::read_front_matter(&dynamic_role_file)
1936
2347
  .map_err(|e| LifecycleError::Compile(e.to_string()))?;
1937
2348
  upsert_agent_state_from_role(
@@ -1978,8 +2389,9 @@ fn upsert_agent_state_from_role(
1978
2389
  dynamic_role_file: &Path,
1979
2390
  safety: &DangerousApproval,
1980
2391
  ) -> Result<(), LifecycleError> {
1981
- let mut state = crate::state::projection::select_runtime_state(workspace, Some(canonical_team_key))
1982
- .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
2392
+ let mut state =
2393
+ crate::state::projection::select_runtime_state(workspace, Some(canonical_team_key))
2394
+ .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
1983
2395
  if !state.is_object() {
1984
2396
  state = serde_json::json!({});
1985
2397
  }
@@ -2027,10 +2439,7 @@ fn upsert_agent_state_from_role(
2027
2439
  if let Some(profile) = meta.get("profile").and_then(Value::as_str) {
2028
2440
  if let Some(obj) = entry.as_object_mut() {
2029
2441
  obj.insert("profile".to_string(), serde_json::json!(profile));
2030
- if let Some(team_dir) = dynamic_role_file
2031
- .parent()
2032
- .and_then(Path::parent)
2033
- {
2442
+ if let Some(team_dir) = dynamic_role_file.parent().and_then(Path::parent) {
2034
2443
  obj.insert(
2035
2444
  "_profile_dir".to_string(),
2036
2445
  serde_json::json!(team_dir.join("profiles").to_string_lossy().to_string()),
@@ -2077,6 +2486,7 @@ pub fn fork_agent(
2077
2486
  workspace: &Path,
2078
2487
  source_agent_id: &AgentId,
2079
2488
  as_agent_id: &AgentId,
2489
+ label: Option<&str>,
2080
2490
  open_display: bool,
2081
2491
  team: Option<&str>,
2082
2492
  ) -> Result<ForkAgentReport, LifecycleError> {
@@ -2090,6 +2500,7 @@ pub fn fork_agent(
2090
2500
  workspace,
2091
2501
  source_agent_id,
2092
2502
  as_agent_id,
2503
+ label,
2093
2504
  open_display,
2094
2505
  team,
2095
2506
  &crate::tmux_backend::TmuxBackend::for_workspace(&selected.run_workspace),
@@ -2100,6 +2511,7 @@ pub fn fork_agent_with_transport(
2100
2511
  workspace: &Path,
2101
2512
  source_agent_id: &AgentId,
2102
2513
  as_agent_id: &AgentId,
2514
+ label: Option<&str>,
2103
2515
  open_display: bool,
2104
2516
  team: Option<&str>,
2105
2517
  transport: &dyn Transport,
@@ -2111,9 +2523,9 @@ pub fn fork_agent_with_transport(
2111
2523
  crate::state::selector::SelectorMode::RequireSpec,
2112
2524
  )
2113
2525
  .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
2114
- let spec_workspace = selected
2115
- .spec_workspace
2116
- .ok_or_else(|| LifecycleError::TeamSelect("active team spec workspace not found".to_string()))?;
2526
+ let spec_workspace = selected.spec_workspace.ok_or_else(|| {
2527
+ LifecycleError::TeamSelect("active team spec workspace not found".to_string())
2528
+ })?;
2117
2529
  let workspace = selected.run_workspace;
2118
2530
  let state = selected.state;
2119
2531
  ensure_owner_allowed_for_state(&state, Some(source_agent_id))?;
@@ -2126,8 +2538,9 @@ pub fn fork_agent_with_transport(
2126
2538
  "agent id already exists: {as_agent_id}"
2127
2539
  )));
2128
2540
  }
2129
- let source_agent = find_spec_agent(&spec, source_agent_id)
2130
- .ok_or_else(|| LifecycleError::RequirementUnmet(format!("unknown worker agent id: {source_agent_id}")))?;
2541
+ let source_agent = find_spec_agent(&spec, source_agent_id).ok_or_else(|| {
2542
+ LifecycleError::RequirementUnmet(format!("unknown worker agent id: {source_agent_id}"))
2543
+ })?;
2131
2544
  let session_id = state
2132
2545
  .get("agents")
2133
2546
  .and_then(|v| v.get(source_agent_id.as_str()))
@@ -2157,13 +2570,14 @@ pub fn fork_agent_with_transport(
2157
2570
  as_agent_id.as_str()
2158
2571
  )));
2159
2572
  }
2160
- 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)?;
2161
2574
  crate::model::spec::validate_spec(&new_spec, &spec_workspace)
2162
2575
  .map_err(|e| LifecycleError::Compile(e.to_string()))?;
2163
2576
  std::fs::write(&spec_path, yaml::dumps(&new_spec))
2164
2577
  .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", spec_path.display())))?;
2165
- let new_agent = find_spec_agent(&new_spec, as_agent_id)
2166
- .ok_or_else(|| LifecycleError::RequirementUnmet(format!("unknown worker agent id: {as_agent_id}")))?;
2578
+ let new_agent = find_spec_agent(&new_spec, as_agent_id).ok_or_else(|| {
2579
+ LifecycleError::RequirementUnmet(format!("unknown worker agent id: {as_agent_id}"))
2580
+ })?;
2167
2581
  let provider = new_agent
2168
2582
  .get("provider")
2169
2583
  .and_then(Value::as_str)
@@ -2185,46 +2599,56 @@ pub fn fork_agent_with_transport(
2185
2599
  "{provider_str} does not support native session fork"
2186
2600
  )));
2187
2601
  }
2188
- let role = new_agent.get("role").and_then(Value::as_str);
2189
2602
  let model = new_agent.get("model").and_then(Value::as_str);
2190
2603
  let safety = effective_runtime_config(&new_spec)?;
2191
- let tools = worker_tool_refs(agent_tool_strings(new_agent), &safety);
2192
- let tool_refs: Vec<&str> = tools.iter().map(String::as_str).collect();
2604
+ let command_agent = crate::lifecycle::worker_command_context::WorkerCommandAgent::from_yaml(
2605
+ new_agent,
2606
+ Some(as_agent_id.as_str()),
2607
+ provider,
2608
+ );
2609
+ let system_prompt =
2610
+ crate::lifecycle::worker_command_context::compile_worker_system_prompt(&command_agent)?;
2611
+ let tools = crate::lifecycle::worker_command_context::resolved_tool_strings_for_command(
2612
+ &command_agent,
2613
+ provider,
2614
+ &safety,
2615
+ )?;
2616
+ let resolved_tool_refs: Vec<&str> = tools.iter().map(String::as_str).collect();
2193
2617
  let fork_team = crate::messaging::leader_receiver::active_team_key(&workspace, &state);
2194
- let mcp_config = adapter
2195
- .mcp_config(auth_mode)
2196
- .map_err(|e| {
2197
- let _ = std::fs::write(&spec_path, text.as_bytes());
2198
- LifecycleError::Provider(e.to_string())
2199
- })?;
2618
+ let mcp_config = adapter.mcp_config(auth_mode).map_err(|e| {
2619
+ let _ = std::fs::write(&spec_path, text.as_bytes());
2620
+ LifecycleError::Provider(e.to_string())
2621
+ })?;
2200
2622
  let mcp_config = resolve_mcp_config(mcp_config, &workspace, as_agent_id.as_str(), &fork_team);
2201
- let mcp_config_path = write_worker_mcp_config(&workspace, as_agent_id.as_str(), &mcp_config)
2202
- .map_err(|e| {
2203
- let _ = std::fs::write(&spec_path, text.as_bytes());
2204
- e
2205
- })?;
2206
- let profile_dir = spec_workspace.join("profiles");
2207
- let profile_launch = crate::lifecycle::profile_launch::prepare_provider_profile_launch_with_profile_dir(
2623
+ let mcp_config_path = write_worker_mcp_config_for_provider(
2208
2624
  &workspace,
2209
2625
  as_agent_id.as_str(),
2210
- new_agent,
2211
- Some(&profile_dir),
2212
- Some(&mcp_config),
2213
- )?;
2214
- let command_model = profile_launch
2215
- .command_overrides
2216
- .model
2217
- .as_deref()
2218
- .or(model);
2626
+ &mcp_config,
2627
+ Some(provider),
2628
+ )
2629
+ .map_err(|e| {
2630
+ let _ = std::fs::write(&spec_path, text.as_bytes());
2631
+ e
2632
+ })?;
2633
+ let profile_dir = spec_workspace.join("profiles");
2634
+ let profile_launch =
2635
+ crate::lifecycle::profile_launch::prepare_provider_profile_launch_with_profile_dir(
2636
+ &workspace,
2637
+ as_agent_id.as_str(),
2638
+ new_agent,
2639
+ Some(&profile_dir),
2640
+ Some(&mcp_config),
2641
+ )?;
2642
+ let command_model = profile_launch.command_overrides.model.as_deref().or(model);
2219
2643
  let mut plan = adapter
2220
2644
  .fork_plan(
2221
2645
  Some(&session_id),
2222
2646
  crate::provider::ProviderCommandContext {
2223
2647
  auth_mode,
2224
2648
  mcp_config: Some(&mcp_config),
2225
- system_prompt: role,
2649
+ system_prompt: Some(system_prompt.as_str()),
2226
2650
  model: command_model,
2227
- tools: &tool_refs,
2651
+ tools: &resolved_tool_refs,
2228
2652
  profile_launch: Some(&profile_launch),
2229
2653
  },
2230
2654
  )
@@ -2235,23 +2659,40 @@ pub fn fork_agent_with_transport(
2235
2659
  if !plan.managed_mcp_config && !profile_launch.managed_mcp_config {
2236
2660
  point_native_mcp_config_at_file(&mut plan.argv, provider, &mcp_config_path);
2237
2661
  }
2238
- fill_spawn_placeholders_full(&mut plan.argv, &workspace, as_agent_id.as_str(), Some(&fork_team));
2239
- let window = WindowName::new(as_agent_id.as_str());
2240
- // fork inherits the parent agent's owner team via runtime state (`active_team_key`).
2241
- let mut env = inherited_env_with_team_overrides(
2662
+ fill_spawn_placeholders_full(
2663
+ &mut plan.argv,
2242
2664
  &workspace,
2243
2665
  as_agent_id.as_str(),
2244
2666
  Some(&fork_team),
2245
2667
  );
2668
+ let window = WindowName::new(as_agent_id.as_str());
2669
+ // fork inherits the parent agent's owner team via runtime state (`active_team_key`).
2670
+ let mut env =
2671
+ inherited_env_with_team_overrides(&workspace, as_agent_id.as_str(), Some(&fork_team));
2246
2672
  apply_profile_launch_env(&mut env, &profile_launch);
2247
2673
  // golden operations.py:336 -> _tmux_start_command_for_agent_window (runtime.py:1017-1020): branch on
2248
2674
  // _tmux_session_exists — an ABSENT session => new-session (spawn_first), present => new-window
2249
2675
  // (spawn_into). The Rust restart seam (restart.rs spawn_agent_window) uses the same branch.
2250
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();
2251
2678
  let spawn_result = if session_live {
2252
- 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
+ )
2253
2687
  } else {
2254
- 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
+ )
2255
2696
  };
2256
2697
  let spawn = spawn_result.map_err(|e| {
2257
2698
  let _ = std::fs::write(&spec_path, text.as_bytes());
@@ -2324,26 +2765,25 @@ pub fn fork_agent_with_transport(
2324
2765
  );
2325
2766
  return Err(e);
2326
2767
  }
2327
- let coordinator_started =
2328
- crate::coordinator::start_coordinator(&crate::coordinator::WorkspacePath::new(
2329
- workspace.to_path_buf(),
2330
- ))
2331
- .map(|report| report.ok)
2332
- .map_err(|e| {
2333
- rollback_fork_after_spawn(
2334
- &workspace,
2335
- &spec_path,
2336
- &text,
2337
- &old_state,
2338
- transport,
2339
- &session_name,
2340
- &window,
2341
- &mcp_config_path,
2342
- as_agent_id,
2343
- &profile_launch,
2344
- );
2345
- LifecycleError::StatePersist(e.to_string())
2346
- })?;
2768
+ let coordinator_started = crate::coordinator::start_coordinator(
2769
+ &crate::coordinator::WorkspacePath::new(workspace.to_path_buf()),
2770
+ )
2771
+ .map(|report| report.ok)
2772
+ .map_err(|e| {
2773
+ rollback_fork_after_spawn(
2774
+ &workspace,
2775
+ &spec_path,
2776
+ &text,
2777
+ &old_state,
2778
+ transport,
2779
+ &session_name,
2780
+ &window,
2781
+ &mcp_config_path,
2782
+ as_agent_id,
2783
+ &profile_launch,
2784
+ );
2785
+ LifecycleError::StatePersist(e.to_string())
2786
+ })?;
2347
2787
  Ok(ForkAgentReport {
2348
2788
  source_agent_id: source_agent_id.clone(),
2349
2789
  new_agent_id: as_agent_id.clone(),
@@ -2384,8 +2824,7 @@ fn maybe_fail_fork_after_spawn(step: &str) -> Result<(), LifecycleError> {
2384
2824
  if reason.is_empty() {
2385
2825
  return Ok(());
2386
2826
  }
2387
- let should_fail = reason == step
2388
- || (step == "start_coordinator" && reason == "coordinator");
2827
+ let should_fail = reason == step || (step == "start_coordinator" && reason == "coordinator");
2389
2828
  if !should_fail {
2390
2829
  return Ok(());
2391
2830
  }
@@ -2429,16 +2868,13 @@ fn find_spec_agent<'a>(spec: &'a Value, agent_id: &AgentId) -> Option<&'a Value>
2429
2868
  if leader_is_agent {
2430
2869
  return None;
2431
2870
  }
2432
- spec.get("agents")?
2433
- .as_list()?
2434
- .iter()
2435
- .find(|agent| {
2436
- agent
2437
- .get("id")
2438
- .and_then(Value::as_str)
2439
- .map(|id| id == agent_id.as_str())
2440
- .unwrap_or(false)
2441
- })
2871
+ spec.get("agents")?.as_list()?.iter().find(|agent| {
2872
+ agent
2873
+ .get("id")
2874
+ .and_then(Value::as_str)
2875
+ .map(|id| id == agent_id.as_str())
2876
+ .unwrap_or(false)
2877
+ })
2442
2878
  }
2443
2879
 
2444
2880
  fn append_forked_agent(
@@ -2446,6 +2882,7 @@ fn append_forked_agent(
2446
2882
  source_agent: &Value,
2447
2883
  source_agent_id: &AgentId,
2448
2884
  as_agent_id: &AgentId,
2885
+ label: Option<&str>,
2449
2886
  ) -> Result<Value, LifecycleError> {
2450
2887
  let mut new_agent = source_agent.clone();
2451
2888
  set_yaml_map_value(
@@ -2454,11 +2891,16 @@ fn append_forked_agent(
2454
2891
  Value::Str(as_agent_id.as_str().to_string()),
2455
2892
  )?;
2456
2893
  // golden operations.py:315 `str(label or new_agent.get("role") or as_agent_id)` — Python `or`
2457
- // falsiness: an EMPTY-string role is falsy and falls through to as_agent_id.
2458
- let role = new_agent
2459
- .get("role")
2460
- .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
2461
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
+ })
2462
2904
  .unwrap_or_else(|| as_agent_id.as_str())
2463
2905
  .to_string();
2464
2906
  set_yaml_map_value(&mut new_agent, "role", Value::Str(role.clone()))?;
@@ -2477,12 +2919,17 @@ fn append_forked_agent(
2477
2919
  )?;
2478
2920
 
2479
2921
  let Value::Map(pairs) = spec else {
2480
- return Err(LifecycleError::Compile("spec root is not a map".to_string()));
2922
+ return Err(LifecycleError::Compile(
2923
+ "spec root is not a map".to_string(),
2924
+ ));
2481
2925
  };
2482
2926
  let mut out = Vec::new();
2483
2927
  for (key, value) in pairs {
2484
2928
  if key == "agents" {
2485
- let mut agents = value.as_list().map(|items| items.to_vec()).unwrap_or_default();
2929
+ let mut agents = value
2930
+ .as_list()
2931
+ .map(|items| items.to_vec())
2932
+ .unwrap_or_default();
2486
2933
  agents.push(new_agent.clone());
2487
2934
  out.push((key.clone(), Value::List(agents)));
2488
2935
  } else if key == "runtime" {
@@ -2496,7 +2943,9 @@ fn append_forked_agent(
2496
2943
 
2497
2944
  fn set_yaml_map_value(value: &mut Value, key: &str, next: Value) -> Result<(), LifecycleError> {
2498
2945
  let Value::Map(pairs) = value else {
2499
- return Err(LifecycleError::Compile("agent entry is not a map".to_string()));
2946
+ return Err(LifecycleError::Compile(
2947
+ "agent entry is not a map".to_string(),
2948
+ ));
2500
2949
  };
2501
2950
  if let Some((_, existing)) = pairs.iter_mut().find(|(k, _)| k == key) {
2502
2951
  *existing = next;
@@ -2515,10 +2964,15 @@ fn runtime_with_startup_agent(runtime: &Value, agent_id: &AgentId) -> Value {
2515
2964
  for (key, value) in pairs {
2516
2965
  if key == "startup_order" {
2517
2966
  saw_startup = true;
2518
- let mut order = value.as_list().map(|items| items.to_vec()).unwrap_or_default();
2519
- let already_present = order
2520
- .iter()
2521
- .any(|item| item.as_str().map(|id| id == agent_id.as_str()).unwrap_or(false));
2967
+ let mut order = value
2968
+ .as_list()
2969
+ .map(|items| items.to_vec())
2970
+ .unwrap_or_default();
2971
+ let already_present = order.iter().any(|item| {
2972
+ item.as_str()
2973
+ .map(|id| id == agent_id.as_str())
2974
+ .unwrap_or(false)
2975
+ });
2522
2976
  if !already_present {
2523
2977
  order.push(Value::Str(agent_id.as_str().to_string()));
2524
2978
  }
@@ -2574,18 +3028,37 @@ fn upsert_forked_agent_state(
2574
3028
  let mut entry = serde_json::Map::new();
2575
3029
  entry.insert("status".to_string(), serde_json::json!("running"));
2576
3030
  entry.insert("provider".to_string(), serde_json::json!(provider));
2577
- entry.insert("agent_id".to_string(), serde_json::json!(as_agent_id.as_str()));
2578
- entry.insert("window".to_string(), serde_json::json!(as_agent_id.as_str()));
2579
- entry.insert("forked_from".to_string(), serde_json::json!(source_agent_id.as_str()));
3031
+ entry.insert(
3032
+ "agent_id".to_string(),
3033
+ serde_json::json!(as_agent_id.as_str()),
3034
+ );
3035
+ entry.insert(
3036
+ "window".to_string(),
3037
+ serde_json::json!(as_agent_id.as_str()),
3038
+ );
3039
+ entry.insert(
3040
+ "forked_from".to_string(),
3041
+ serde_json::json!(source_agent_id.as_str()),
3042
+ );
2580
3043
  entry.insert(
2581
3044
  "spawn_cwd".to_string(),
2582
3045
  serde_json::json!(spawn_cwd.to_string_lossy().to_string()),
2583
3046
  );
2584
- entry.insert("pane_id".to_string(), serde_json::json!(spawn.pane_id.as_str()));
3047
+ entry.insert(
3048
+ "pane_id".to_string(),
3049
+ serde_json::json!(spawn.pane_id.as_str()),
3050
+ );
2585
3051
  if let Some(pid) = spawn.child_pid {
2586
3052
  entry.insert("pane_pid".to_string(), serde_json::json!(pid));
2587
3053
  }
2588
- for key in ["auth_mode", "model", "model_source", "profile", "_profile_dir", "role"] {
3054
+ for key in [
3055
+ "auth_mode",
3056
+ "model",
3057
+ "model_source",
3058
+ "profile",
3059
+ "_profile_dir",
3060
+ "role",
3061
+ ] {
2589
3062
  if let Some(value) = spec_agent.get(key) {
2590
3063
  entry.insert(key.to_string(), yaml_value_to_json(value));
2591
3064
  }
@@ -2602,9 +3075,15 @@ fn upsert_forked_agent_state(
2602
3075
  entry.insert("rollout_path".to_string(), serde_json::Value::Null);
2603
3076
  entry.insert("captured_at".to_string(), serde_json::Value::Null);
2604
3077
  entry.insert("captured_via".to_string(), serde_json::Value::Null);
2605
- entry.insert("attribution_confidence".to_string(), serde_json::Value::Null);
3078
+ entry.insert(
3079
+ "attribution_confidence".to_string(),
3080
+ serde_json::Value::Null,
3081
+ );
2606
3082
  persist_command_plan_state(&mut entry, plan, profile_launch);
2607
- agent_map.insert(as_agent_id.as_str().to_string(), serde_json::Value::Object(entry));
3083
+ agent_map.insert(
3084
+ as_agent_id.as_str().to_string(),
3085
+ serde_json::Value::Object(entry),
3086
+ );
2608
3087
  if let Some(entry) = agent_map
2609
3088
  .get_mut(as_agent_id.as_str())
2610
3089
  .and_then(serde_json::Value::as_object_mut)
@@ -2642,12 +3121,9 @@ pub(crate) fn ensure_owner_allowed_for_state(
2642
3121
  None,
2643
3122
  )
2644
3123
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
2645
- if let Some(refusal) = crate::state::owner_gate::check_team_owner(
2646
- state,
2647
- &caller,
2648
- false,
2649
- &NoopLiveness,
2650
- ) {
3124
+ if let Some(refusal) =
3125
+ crate::state::owner_gate::check_team_owner(state, &caller, false, &NoopLiveness)
3126
+ {
2651
3127
  return Err(LifecycleError::OwnerRefused(refusal.to_string()));
2652
3128
  }
2653
3129
  Ok(())
@@ -2676,7 +3152,10 @@ fn initial_runtime_state(
2676
3152
  let Some(id) = agent.get("id").and_then(Value::as_str) else {
2677
3153
  continue;
2678
3154
  };
2679
- let provider = agent.get("provider").and_then(Value::as_str).unwrap_or("codex");
3155
+ let provider = agent
3156
+ .get("provider")
3157
+ .and_then(Value::as_str)
3158
+ .unwrap_or("codex");
2680
3159
  let role = agent.get("role").and_then(Value::as_str).unwrap_or(id);
2681
3160
  let model = agent.get("model").and_then(Value::as_str);
2682
3161
  let auth_mode = agent.get("auth_mode").and_then(Value::as_str);
@@ -2698,7 +3177,9 @@ fn initial_runtime_state(
2698
3177
  .get("runtime")
2699
3178
  .and_then(|runtime| runtime.get("display_backend"))
2700
3179
  .and_then(Value::as_str)
2701
- .and_then(|backend| serde_json::from_value::<DisplayBackend>(serde_json::json!(backend)).ok());
3180
+ .and_then(|backend| {
3181
+ serde_json::from_value::<DisplayBackend>(serde_json::json!(backend)).ok()
3182
+ });
2702
3183
  let display_backend =
2703
3184
  crate::lifecycle::display::resolve_display_backend(requested_display, None).backend;
2704
3185
  let mut state = serde_json::Map::new();
@@ -2720,11 +3201,16 @@ fn initial_runtime_state(
2720
3201
  );
2721
3202
  state.insert(
2722
3203
  "leader".to_string(),
2723
- spec.get("leader").map(yaml_value_to_json).unwrap_or(serde_json::Value::Null),
3204
+ spec.get("leader")
3205
+ .map(yaml_value_to_json)
3206
+ .unwrap_or(serde_json::Value::Null),
2724
3207
  );
2725
3208
  state.insert("agents".to_string(), serde_json::Value::Object(agents));
2726
3209
  state.insert("tasks".to_string(), spec_tasks_json(spec));
2727
- state.insert("display_backend".to_string(), serde_json::json!(display_backend));
3210
+ state.insert(
3211
+ "display_backend".to_string(),
3212
+ serde_json::json!(display_backend),
3213
+ );
2728
3214
  let mut state = serde_json::Value::Object(state);
2729
3215
  if !seed_launched_owner_from_env(&mut state) {
2730
3216
  let team_id = crate::state::projection::team_state_key(&state);
@@ -2800,9 +3286,7 @@ fn has_positive_caller_leader_env() -> bool {
2800
3286
  fn spec_tasks_json(spec: &Value) -> serde_json::Value {
2801
3287
  spec.get("tasks")
2802
3288
  .and_then(Value::as_list)
2803
- .map(|tasks| {
2804
- serde_json::Value::Array(tasks.iter().map(yaml_value_to_json).collect())
2805
- })
3289
+ .map(|tasks| serde_json::Value::Array(tasks.iter().map(yaml_value_to_json).collect()))
2806
3290
  .unwrap_or_else(|| serde_json::json!([]))
2807
3291
  }
2808
3292
 
@@ -2841,7 +3325,10 @@ fn override_spec_session_name(spec: &mut Value, session_name: &str) {
2841
3325
  if let Some((_, existing)) = runtime.iter_mut().find(|(k, _)| k == "session_name") {
2842
3326
  *existing = Value::Str(session_name.to_string());
2843
3327
  } else {
2844
- runtime.push(("session_name".to_string(), Value::Str(session_name.to_string())));
3328
+ runtime.push((
3329
+ "session_name".to_string(),
3330
+ Value::Str(session_name.to_string()),
3331
+ ));
2845
3332
  }
2846
3333
  }
2847
3334
  Some(other) => {
@@ -2863,12 +3350,22 @@ fn override_spec_session_name(spec: &mut Value, session_name: &str) {
2863
3350
  }
2864
3351
 
2865
3352
  fn spec_session_name(spec: &Value) -> SessionName {
2866
- let name = spec
3353
+ if let Some(name) = spec
2867
3354
  .get("runtime")
2868
3355
  .and_then(|v| v.get("session_name"))
2869
3356
  .and_then(Value::as_str)
2870
- .unwrap_or("team-agent");
2871
- 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}"))
2872
3369
  }
2873
3370
 
2874
3371
  fn spec_agents(spec: &Value) -> Vec<AgentId> {
@@ -2948,20 +3445,11 @@ fn disabled_dangerous_approval() -> DangerousApproval {
2948
3445
  }
2949
3446
  }
2950
3447
 
2951
- pub(crate) fn effective_runtime_config_for_worker_spawn() -> Result<DangerousApproval, LifecycleError> {
3448
+ pub(crate) fn effective_runtime_config_for_worker_spawn(
3449
+ ) -> Result<DangerousApproval, LifecycleError> {
2952
3450
  detect_dangerous_approval()
2953
3451
  }
2954
3452
 
2955
- pub(crate) fn worker_tool_refs(
2956
- mut tools: Vec<String>,
2957
- safety: &DangerousApproval,
2958
- ) -> Vec<String> {
2959
- if safety.enabled && !tools.iter().any(|tool| tool == "dangerous_auto_approve") {
2960
- tools.push("dangerous_auto_approve".to_string());
2961
- }
2962
- tools
2963
- }
2964
-
2965
3453
  fn write_launch_permission_audit(
2966
3454
  workspace: &Path,
2967
3455
  safety: &DangerousApproval,
@@ -3019,6 +3507,5 @@ fn agent_id_exists_in_team_dir(team_dir: &Path, agent_id: &AgentId) -> bool {
3019
3507
  .exists()
3020
3508
  }
3021
3509
 
3022
-
3023
3510
  mod plan;
3024
3511
  pub use plan::{handle_report_result, start_plan};