@team-agent/installer 0.3.2 → 0.3.3

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 (78) hide show
  1. package/Cargo.lock +34 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/Cargo.toml +1 -1
  4. package/crates/team-agent/src/cli/adapters.rs +196 -19
  5. package/crates/team-agent/src/cli/diagnose.rs +144 -10
  6. package/crates/team-agent/src/cli/emit.rs +286 -52
  7. package/crates/team-agent/src/cli/leader.rs +37 -8
  8. package/crates/team-agent/src/cli/mod.rs +799 -316
  9. package/crates/team-agent/src/cli/status_port.rs +25 -2
  10. package/crates/team-agent/src/cli/tests/divergence.rs +1 -2
  11. package/crates/team-agent/src/cli/tests/lane_c.rs +23 -13
  12. package/crates/team-agent/src/cli/tests/main_preserved.rs +2 -0
  13. package/crates/team-agent/src/cli/tests/run_delegation.rs +57 -3
  14. package/crates/team-agent/src/cli/types.rs +17 -0
  15. package/crates/team-agent/src/compiler.rs +15 -5
  16. package/crates/team-agent/src/coordinator/health.rs +89 -20
  17. package/crates/team-agent/src/coordinator/mod.rs +4 -0
  18. package/crates/team-agent/src/coordinator/runtime_detectors.rs +500 -0
  19. package/crates/team-agent/src/coordinator/runtime_observation.rs +58 -0
  20. package/crates/team-agent/src/coordinator/tick.rs +222 -69
  21. package/crates/team-agent/src/coordinator/types.rs +15 -3
  22. package/crates/team-agent/src/db/schema.rs +37 -2
  23. package/crates/team-agent/src/diagnose/comms.rs +226 -0
  24. package/crates/team-agent/src/diagnose/mod.rs +45 -0
  25. package/crates/team-agent/src/diagnose/orphans.rs +658 -0
  26. package/crates/team-agent/src/fake_worker.rs +146 -3
  27. package/crates/team-agent/src/leader/start.rs +121 -23
  28. package/crates/team-agent/src/leader/types.rs +44 -1
  29. package/crates/team-agent/src/lib.rs +3 -0
  30. package/crates/team-agent/src/lifecycle/display.rs +645 -47
  31. package/crates/team-agent/src/lifecycle/launch.rs +818 -116
  32. package/crates/team-agent/src/lifecycle/mod.rs +2 -0
  33. package/crates/team-agent/src/lifecycle/profile_launch.rs +810 -0
  34. package/crates/team-agent/src/lifecycle/profile_smoke.rs +522 -0
  35. package/crates/team-agent/src/lifecycle/restart/agent.rs +99 -23
  36. package/crates/team-agent/src/lifecycle/restart/common.rs +177 -83
  37. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +443 -9
  38. package/crates/team-agent/src/lifecycle/restart/remove.rs +22 -6
  39. package/crates/team-agent/src/lifecycle/restart/team_state.rs +19 -0
  40. package/crates/team-agent/src/lifecycle/restart.rs +4 -1
  41. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +5 -5
  42. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +37 -7
  43. package/crates/team-agent/src/lifecycle/types.rs +19 -0
  44. package/crates/team-agent/src/mcp_server/helpers.rs +1 -0
  45. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +341 -0
  46. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +10 -0
  47. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +158 -0
  48. package/crates/team-agent/src/mcp_server/mod.rs +3 -74
  49. package/crates/team-agent/src/mcp_server/tests/scoped.rs +1 -1
  50. package/crates/team-agent/src/mcp_server/tests/send.rs +6 -5
  51. package/crates/team-agent/src/mcp_server/tools.rs +312 -111
  52. package/crates/team-agent/src/mcp_server/types.rs +6 -4
  53. package/crates/team-agent/src/mcp_server/wire.rs +19 -7
  54. package/crates/team-agent/src/message_store.rs +21 -4
  55. package/crates/team-agent/src/messaging/delivery.rs +87 -37
  56. package/crates/team-agent/src/messaging/mod.rs +9 -6
  57. package/crates/team-agent/src/messaging/results.rs +153 -16
  58. package/crates/team-agent/src/messaging/selftest.rs +199 -12
  59. package/crates/team-agent/src/messaging/send.rs +35 -3
  60. package/crates/team-agent/src/messaging/tests/runtime.rs +19 -4
  61. package/crates/team-agent/src/messaging/types.rs +11 -3
  62. package/crates/team-agent/src/os_probe.rs +119 -0
  63. package/crates/team-agent/src/packaging/migrate.rs +10 -2
  64. package/crates/team-agent/src/packaging/tests.rs +23 -0
  65. package/crates/team-agent/src/provider/adapter.rs +483 -67
  66. package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +1 -7
  67. package/crates/team-agent/src/provider/classify.rs +51 -4
  68. package/crates/team-agent/src/provider/startup_prompt.rs +94 -0
  69. package/crates/team-agent/src/provider/types.rs +47 -0
  70. package/crates/team-agent/src/session_capture.rs +616 -0
  71. package/crates/team-agent/src/state/persist.rs +57 -0
  72. package/crates/team-agent/src/state/projection.rs +32 -23
  73. package/crates/team-agent/src/state/selector.rs +5 -2
  74. package/crates/team-agent/src/tmux_backend.rs +97 -60
  75. package/crates/team-agent/src/transport/test_support.rs +9 -0
  76. package/crates/team-agent/src/transport/tests/wire.rs +4 -0
  77. package/crates/team-agent/src/transport.rs +13 -2
  78. package/package.json +4 -4
@@ -102,7 +102,7 @@ pub fn launch_with_transport_in_workspace(
102
102
  Vec::new()
103
103
  } else {
104
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)?;
105
+ persist_spawn_agent_state(workspace, spec_path, &spec, &session_name, transport, &started, &safety)?;
106
106
  started
107
107
  };
108
108
  Ok(LaunchReport {
@@ -113,6 +113,7 @@ pub fn launch_with_transport_in_workspace(
113
113
  permissions,
114
114
  safety,
115
115
  leader_receiver_attached: false,
116
+ session_capture_incomplete_agents: Vec::new(),
116
117
  })
117
118
  }
118
119
 
@@ -161,32 +162,49 @@ fn spawn_agents(
161
162
  .map_err(|e| LifecycleError::Provider(e.to_string()))?;
162
163
  let mcp_config = resolve_mcp_config(mcp_config, workspace, agent_id_raw, &mcp_team_id);
163
164
  let mcp_config_path = write_worker_mcp_config(workspace, agent_id_raw, &mcp_config)?;
164
- let mut argv = adapter
165
- .build_command_with_tools(
165
+ let profile_dir = team_dir.join("profiles");
166
+ let profile_launch = crate::lifecycle::profile_launch::prepare_provider_profile_launch_with_profile_dir(
167
+ workspace,
168
+ agent_id_raw,
169
+ agent,
170
+ Some(&profile_dir),
171
+ Some(&mcp_config),
172
+ )?;
173
+ let command_model = profile_launch
174
+ .command_overrides
175
+ .model
176
+ .as_deref()
177
+ .or(model);
178
+ let mut plan = adapter
179
+ .build_command_plan(crate::provider::ProviderCommandContext {
166
180
  auth_mode,
167
- Some(&mcp_config),
168
- role,
169
- model,
170
- &tool_refs,
171
- )
181
+ mcp_config: Some(&mcp_config),
182
+ system_prompt: role,
183
+ model: command_model,
184
+ tools: &tool_refs,
185
+ profile_launch: Some(&profile_launch),
186
+ })
172
187
  .map_err(|e| LifecycleError::Provider(e.to_string()))?;
173
- point_native_mcp_config_at_file(&mut argv, provider, &mcp_config_path);
188
+ if !plan.managed_mcp_config && !profile_launch.managed_mcp_config {
189
+ point_native_mcp_config_at_file(&mut plan.argv, provider, &mcp_config_path);
190
+ }
174
191
  fill_spawn_placeholders_full(
175
- &mut argv,
192
+ &mut plan.argv,
176
193
  workspace,
177
194
  agent_id_raw,
178
195
  Some(&mcp_team_id),
179
196
  );
180
197
  let window = WindowName::new(agent_id_raw);
181
- let env = inherited_env_with_team_overrides(
198
+ let mut env = inherited_env_with_team_overrides(
182
199
  workspace,
183
200
  agent_id_raw,
184
201
  Some(&mcp_team_id),
185
202
  );
203
+ apply_profile_launch_env(&mut env, &profile_launch);
186
204
  let spawn = if started.is_empty() {
187
- transport.spawn_first(session_name, &window, &argv, team_dir, &env)
205
+ transport.spawn_first(session_name, &window, &plan.argv, team_dir, &env)
188
206
  } else {
189
- transport.spawn_into(session_name, &window, &argv, team_dir, &env)
207
+ transport.spawn_into(session_name, &window, &plan.argv, team_dir, &env)
190
208
  }
191
209
  .map_err(|e| LifecycleError::Transport(e.to_string()))?;
192
210
  let _ = adapter.handle_startup_prompts(
@@ -204,6 +222,13 @@ fn spawn_agents(
204
222
  target: spawn.pane_id.as_str().to_string(),
205
223
  session_id: None,
206
224
  rollout_path: None,
225
+ pending_session_id: plan.expected_session_id.clone(),
226
+ claude_config_dir: profile_launch.claude_config_dir.clone(),
227
+ provider_projects_root: plan
228
+ .provider_projects_root
229
+ .clone()
230
+ .or_else(|| profile_launch.claude_projects_root.clone()),
231
+ managed_mcp_config: plan.managed_mcp_config || profile_launch.managed_mcp_config,
207
232
  display: WorkerDisplay::Blocked {
208
233
  reason: AdaptiveBlockReason::NotImplementedThisPlatform,
209
234
  },
@@ -219,6 +244,7 @@ fn persist_spawn_agent_state(
219
244
  session_name: &SessionName,
220
245
  transport: &dyn Transport,
221
246
  started: &[StartedAgent],
247
+ safety: &DangerousApproval,
222
248
  ) -> Result<(), LifecycleError> {
223
249
  let state_path = crate::state::persist::runtime_state_path(workspace);
224
250
  let mut state = if state_path.exists() {
@@ -250,8 +276,12 @@ fn persist_spawn_agent_state(
250
276
  .map(|agent| agent.agent_id.as_str().to_string())
251
277
  .collect();
252
278
  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");
253
283
  let mut agents = serde_json::Map::new();
254
- let spawned_at = spawn_timestamp();
284
+ let mut spawn_index = 0_u32;
255
285
  for agent in spec_agent_values(spec) {
256
286
  let Some(id) = agent.get("id").and_then(Value::as_str) else {
257
287
  continue;
@@ -285,9 +315,27 @@ fn persist_spawn_agent_state(
285
315
  continue;
286
316
  }
287
317
  let pane_pid = pane_pids_by_agent.get(id).copied();
318
+ let spawned_at = spawn_timestamp_for_agent(spawn_index);
319
+ spawn_index = spawn_index.saturating_add(1);
320
+ let started_agent = started
321
+ .iter()
322
+ .find(|agent| agent.agent_id.as_str() == id);
288
323
  agents.insert(
289
324
  id.to_string(),
290
- running_agent_state(agent, id, provider, workspace, &spawned_at, &team_id, pane_pid)?,
325
+ running_agent_state(
326
+ agent,
327
+ id,
328
+ provider,
329
+ workspace,
330
+ spec_path.parent().unwrap_or(workspace),
331
+ &spawned_at,
332
+ &team_id,
333
+ Some(agent_id_to_pane_id(started, id)),
334
+ pane_pid,
335
+ safety,
336
+ started_agent,
337
+ Some(&profile_dir),
338
+ )?,
291
339
  );
292
340
  }
293
341
  if let Some(obj) = state.as_object_mut() {
@@ -317,6 +365,14 @@ fn pane_pids_by_started_agent(
317
365
  .collect()
318
366
  }
319
367
 
368
+ fn agent_id_to_pane_id<'a>(started: &'a [StartedAgent], agent_id: &str) -> &'a str {
369
+ started
370
+ .iter()
371
+ .find(|agent| agent.agent_id.as_str() == agent_id)
372
+ .map(|agent| agent.target.as_str())
373
+ .unwrap_or("")
374
+ }
375
+
320
376
  fn save_launched_team_state(workspace: &Path, launched: &serde_json::Value) -> Result<(), LifecycleError> {
321
377
  save_launched_team_state_for_key(workspace, launched, None)
322
378
  }
@@ -340,6 +396,7 @@ fn save_launched_team_state_for_key(
340
396
  }
341
397
  promote_launched_binding_from_team_entry(&mut launched, &launched_key);
342
398
  drop_foreign_seeded_owner(&existing, &launched_key, &mut launched);
399
+ drop_bare_worker_seeded_owner(&mut launched, &launched_key);
343
400
  let merged = if team_key.is_some() {
344
401
  merge_workspace_team_state_with_key(&existing, &launched, &launched_key)
345
402
  } else {
@@ -350,6 +407,20 @@ fn save_launched_team_state_for_key(
350
407
  save_runtime_state(workspace, &projected).map_err(|e| LifecycleError::StatePersist(e.to_string()))
351
408
  }
352
409
 
410
+ fn drop_bare_worker_seeded_owner(launched: &mut serde_json::Value, launched_key: &str) {
411
+ if has_positive_caller_leader_env() {
412
+ return;
413
+ }
414
+ let pane = launched
415
+ .get("team_owner")
416
+ .and_then(|owner| owner.get("pane_id"))
417
+ .and_then(serde_json::Value::as_str)
418
+ .unwrap_or("");
419
+ if pane.ends_with("-first") {
420
+ seed_unbound_launched_owner(launched, launched_key);
421
+ }
422
+ }
423
+
353
424
  fn merge_workspace_team_state_with_key(
354
425
  existing: &serde_json::Value,
355
426
  launched: &serde_json::Value,
@@ -445,7 +516,15 @@ fn drop_foreign_seeded_owner(existing: &serde_json::Value, launched_key: &str, l
445
516
  return;
446
517
  };
447
518
  if owner_pane_belongs_to_other_team(existing, launched_key, pane) {
448
- seed_unbound_launched_owner(launched, launched_key);
519
+ let replacement = unbound_launched_owner(launched, launched_key);
520
+ if let Some(obj) = launched.as_object_mut() {
521
+ if let Some(owner) = replacement {
522
+ obj.insert("team_owner".to_string(), owner);
523
+ } else {
524
+ obj.remove("team_owner");
525
+ }
526
+ obj.remove("owner_epoch");
527
+ }
449
528
  }
450
529
  }
451
530
 
@@ -469,23 +548,30 @@ fn drop_worker_pane_seeded_owner(
469
548
  let tmux_pane = std::env::var("TMUX_PANE")
470
549
  .ok()
471
550
  .filter(|value| !value.is_empty());
472
- let has_leader_identity_env = leader_pane.is_some()
473
- || env_nonempty("TEAM_AGENT_LEADER_SESSION_UUID")
474
- || env_nonempty("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE")
475
- || env_nonempty("TEAM_AGENT_LEADER_PROVIDER")
476
- || env_nonempty("TEAM_AGENT_ID")
477
- || env_nonempty("TEAM_AGENT_TEAM_ID");
551
+ let has_leader_identity_env = has_positive_caller_leader_env();
478
552
  let seeded_from_bare_tmux =
479
553
  !has_leader_identity_env && tmux_pane.as_deref() == Some(pane);
480
554
  let caller_tmux_socket = crate::tmux_backend::socket_name_from_tmux_env();
481
555
  if seeded_from_bare_tmux
482
- && tmux_sockets_match_or_unknown(caller_tmux_socket.as_deref(), worker_tmux_socket)
483
- && started.iter().any(|agent| agent.target == pane)
556
+ && (tmux_sockets_match_or_unknown(caller_tmux_socket.as_deref(), worker_tmux_socket)
557
+ || pane.ends_with("-first"))
558
+ && seeded_pane_looks_like_worker(pane, started)
484
559
  {
485
560
  seed_unbound_launched_owner(launched, launched_key);
486
561
  }
487
562
  }
488
563
 
564
+ fn seeded_pane_looks_like_worker(pane: &str, started: &[StartedAgent]) -> bool {
565
+ 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
+ })
573
+ }
574
+
489
575
  fn launched_worker_tmux_socket(
490
576
  transport: &dyn Transport,
491
577
  workspace: &Path,
@@ -513,6 +599,35 @@ fn env_nonempty(key: &str) -> bool {
513
599
  }
514
600
 
515
601
  fn seed_unbound_launched_owner(launched: &mut serde_json::Value, launched_key: &str) {
602
+ let Some(owner) = unbound_launched_owner(launched, launched_key) else {
603
+ return;
604
+ };
605
+ let provider = launched
606
+ .get("team_owner")
607
+ .and_then(|owner| owner.get("provider"))
608
+ .and_then(serde_json::Value::as_str)
609
+ .filter(|provider| !provider.is_empty())
610
+ .unwrap_or("codex");
611
+ let owner_epoch = 1u64;
612
+ let receiver = serde_json::json!({
613
+ "mode": "direct_tmux",
614
+ "status": "unbound",
615
+ "provider": provider,
616
+ "leader_session_uuid": owner.get("leader_session_uuid").cloned().unwrap_or(serde_json::Value::Null),
617
+ "owner_epoch": owner_epoch,
618
+ "discovery": "quick_start",
619
+ });
620
+ if let Some(obj) = launched.as_object_mut() {
621
+ obj.insert("leader_receiver".to_string(), receiver);
622
+ obj.insert("team_owner".to_string(), owner);
623
+ obj.insert("owner_epoch".to_string(), serde_json::json!(owner_epoch));
624
+ }
625
+ }
626
+
627
+ fn unbound_launched_owner(
628
+ launched: &serde_json::Value,
629
+ launched_key: &str,
630
+ ) -> Option<serde_json::Value> {
516
631
  let provider = launched
517
632
  .get("team_owner")
518
633
  .and_then(|owner| owner.get("provider"))
@@ -531,40 +646,22 @@ fn seed_unbound_launched_owner(launched: &mut serde_json::Value, launched_key: &
531
646
  let os_user = std::env::var("USER")
532
647
  .or_else(|_| std::env::var("USERNAME"))
533
648
  .unwrap_or_default();
534
- let Ok(uuid) = crate::model::ids::LeaderSessionUuid::derive(
649
+ let uuid = crate::model::ids::LeaderSessionUuid::derive(
535
650
  machine_fingerprint,
536
651
  workspace,
537
652
  &os_user,
538
653
  launched_key,
539
- ) else {
540
- return;
541
- };
542
- let owner_epoch = 1u64;
543
- let owner = serde_json::json!({
544
- "pane_id": "__team_agent_unbound__",
654
+ )
655
+ .ok()?;
656
+ Some(serde_json::json!({
545
657
  "provider": provider,
546
658
  "machine_fingerprint": machine_fingerprint,
547
659
  "leader_session_uuid": uuid.as_str(),
548
- "owner_epoch": owner_epoch,
660
+ "owner_epoch": 1u64,
549
661
  "claimed_at": spawn_timestamp(),
550
662
  "claimed_via": "quick-start",
551
663
  "os_user": os_user,
552
- });
553
- let receiver = serde_json::json!({
554
- "mode": "direct_tmux",
555
- "status": "attached",
556
- "provider": provider,
557
- "pane_id": "__team_agent_unbound__",
558
- "pane": "__team_agent_unbound__",
559
- "leader_session_uuid": uuid.as_str(),
560
- "owner_epoch": owner_epoch,
561
- "discovery": "quick_start",
562
- });
563
- if let Some(obj) = launched.as_object_mut() {
564
- obj.insert("leader_receiver".to_string(), receiver);
565
- obj.insert("team_owner".to_string(), owner);
566
- obj.insert("owner_epoch".to_string(), serde_json::json!(owner_epoch));
567
- }
664
+ }))
568
665
  }
569
666
 
570
667
  fn owner_pane_belongs_to_other_team(existing: &serde_json::Value, launched_key: &str, pane: &str) -> bool {
@@ -588,9 +685,14 @@ fn running_agent_state(
588
685
  id: &str,
589
686
  provider: Provider,
590
687
  workspace: &Path,
688
+ spawn_cwd: &Path,
591
689
  spawned_at: &str,
592
690
  team_id: &str,
691
+ pane_id: Option<&str>,
593
692
  pane_pid: Option<u32>,
693
+ safety: &DangerousApproval,
694
+ started_agent: Option<&StartedAgent>,
695
+ profile_dir: Option<&Path>,
594
696
  ) -> Result<serde_json::Value, LifecycleError> {
595
697
  let model = agent.get("model").and_then(Value::as_str);
596
698
  let auth_mode = agent
@@ -612,6 +714,14 @@ fn running_agent_state(
612
714
  state.insert("model".to_string(), model.map_or(serde_json::Value::Null, |m| serde_json::json!(m)));
613
715
  state.insert("auth_mode".to_string(), serde_json::json!(auth_mode));
614
716
  state.insert("profile".to_string(), profile);
717
+ if agent.get("profile").is_some() {
718
+ if let Some(profile_dir) = profile_dir {
719
+ state.insert(
720
+ "_profile_dir".to_string(),
721
+ serde_json::json!(profile_dir.to_string_lossy().to_string()),
722
+ );
723
+ }
724
+ }
615
725
  state.insert("window".to_string(), serde_json::json!(window));
616
726
  state.insert(
617
727
  "mcp_config".to_string(),
@@ -622,23 +732,60 @@ fn running_agent_state(
622
732
  permissions_json(agent, id, provider)
623
733
  .map_err(|e| LifecycleError::Compile(e.to_string()))?,
624
734
  );
735
+ persist_effective_approval_policy(&mut state, safety);
625
736
  state.insert("session_id".to_string(), serde_json::Value::Null);
626
737
  state.insert("rollout_path".to_string(), serde_json::Value::Null);
627
738
  state.insert("captured_at".to_string(), serde_json::Value::Null);
628
739
  state.insert("captured_via".to_string(), serde_json::Value::Null);
629
740
  state.insert("attribution_confidence".to_string(), serde_json::Value::Null);
741
+ if let Some(started_agent) = started_agent {
742
+ persist_started_agent_plan_state(&mut state, started_agent);
743
+ }
630
744
  state.insert(
631
745
  "spawn_cwd".to_string(),
632
- serde_json::json!(workspace.to_string_lossy().to_string()),
746
+ serde_json::json!(spawn_cwd.to_string_lossy().to_string()),
633
747
  );
634
748
  state.insert("spawned_at".to_string(), serde_json::json!(spawned_at));
749
+ if let Some(pane_id) = pane_id.filter(|pane| !pane.is_empty()) {
750
+ state.insert("pane_id".to_string(), serde_json::json!(pane_id));
751
+ }
635
752
  if let Some(pane_pid) = pane_pid {
636
753
  state.insert("pane_pid".to_string(), serde_json::json!(pane_pid));
637
754
  }
638
755
  Ok(serde_json::Value::Object(state))
639
756
  }
640
757
 
641
- fn resolve_mcp_config(
758
+ pub(crate) fn effective_approval_policy(safety: &DangerousApproval) -> serde_json::Value {
759
+ serde_json::json!({
760
+ "enabled": safety.enabled,
761
+ "source": dangerous_approval_source_str(safety.source),
762
+ "inherited": safety.inherited,
763
+ "explicit_yes_confirmed": safety.enabled && matches!(safety.source, DangerousApprovalSource::RuntimeConfig),
764
+ "provider": safety.provider,
765
+ "flag": safety.flag,
766
+ "worker_capability_above_leader": safety.worker_capability_above_leader,
767
+ })
768
+ }
769
+
770
+ pub(crate) fn persist_effective_approval_policy(
771
+ agent_state: &mut serde_json::Map<String, serde_json::Value>,
772
+ safety: &DangerousApproval,
773
+ ) {
774
+ agent_state.insert(
775
+ "effective_approval_policy".to_string(),
776
+ effective_approval_policy(safety),
777
+ );
778
+ }
779
+
780
+ fn dangerous_approval_source_str(source: DangerousApprovalSource) -> &'static str {
781
+ match source {
782
+ DangerousApprovalSource::RuntimeConfig => "runtime_config",
783
+ DangerousApprovalSource::LeaderProcess => "leader_process",
784
+ DangerousApprovalSource::Disabled => "disabled",
785
+ }
786
+ }
787
+
788
+ pub(crate) fn resolve_mcp_config(
642
789
  config: crate::provider::McpConfig,
643
790
  workspace: &Path,
644
791
  agent_id: &str,
@@ -681,7 +828,7 @@ fn resolve_mcp_placeholders(
681
828
  }
682
829
  }
683
830
 
684
- fn write_worker_mcp_config(
831
+ pub(crate) fn write_worker_mcp_config(
685
832
  workspace: &Path,
686
833
  agent_id: &str,
687
834
  config: &crate::provider::McpConfig,
@@ -700,7 +847,7 @@ fn write_worker_mcp_config(
700
847
  Ok(path)
701
848
  }
702
849
 
703
- fn point_native_mcp_config_at_file(argv: &mut [String], provider: Provider, path: &Path) {
850
+ pub(crate) fn point_native_mcp_config_at_file(argv: &mut [String], provider: Provider, path: &Path) {
704
851
  if !matches!(provider, Provider::Claude | Provider::ClaudeCode) {
705
852
  return;
706
853
  }
@@ -766,6 +913,22 @@ fn spawn_timestamp() -> String {
766
913
  }
767
914
  }
768
915
 
916
+ fn spawn_timestamp_for_agent(offset_micros: u32) -> String {
917
+ if offset_micros == 0 {
918
+ return spawn_timestamp();
919
+ }
920
+ match std::env::var("TEAM_AGENT_TEST_FIXED_SPAWNED_AT") {
921
+ Ok(value) => chrono::DateTime::parse_from_rfc3339(&value)
922
+ .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()
926
+ })
927
+ .unwrap_or(value),
928
+ Err(_) => spawn_timestamp(),
929
+ }
930
+ }
931
+
769
932
  pub(crate) fn fill_spawn_placeholders(argv: &mut [String], workspace: &Path, agent_id: &str) {
770
933
  fill_spawn_placeholders_full(argv, workspace, agent_id, None);
771
934
  }
@@ -808,6 +971,87 @@ pub(crate) fn inherited_env_with_team_overrides(
808
971
  env
809
972
  }
810
973
 
974
+ pub(crate) fn apply_profile_launch_env(
975
+ env: &mut BTreeMap<String, String>,
976
+ profile_launch: &crate::provider::ProviderProfileLaunch,
977
+ ) {
978
+ for key in &profile_launch.env_unset {
979
+ env.remove(key);
980
+ }
981
+ env.extend(profile_launch.env_overlay.clone());
982
+ }
983
+
984
+ fn persist_started_agent_plan_state(
985
+ state: &mut serde_json::Map<String, serde_json::Value>,
986
+ started_agent: &StartedAgent,
987
+ ) {
988
+ if let Some(session_id) = started_agent.pending_session_id.as_ref() {
989
+ state.insert(
990
+ "_pending_session_id".to_string(),
991
+ serde_json::json!(session_id.as_str()),
992
+ );
993
+ }
994
+ if let Some(root) = started_agent.provider_projects_root.as_ref() {
995
+ state.insert(
996
+ "claude_projects_root".to_string(),
997
+ serde_json::json!(root.to_string_lossy().to_string()),
998
+ );
999
+ }
1000
+ if started_agent.managed_mcp_config {
1001
+ state.insert("managed_mcp_config".to_string(), serde_json::json!(true));
1002
+ }
1003
+ if started_agent.managed_mcp_config
1004
+ || started_agent.claude_config_dir.is_some()
1005
+ || started_agent.provider_projects_root.is_some()
1006
+ {
1007
+ state.insert(
1008
+ "profile_launch".to_string(),
1009
+ serde_json::json!({
1010
+ "managed_mcp_config": started_agent.managed_mcp_config,
1011
+ "claude_config_dir": started_agent.claude_config_dir.as_ref().map(|path| path.to_string_lossy().to_string()),
1012
+ "claude_projects_root": started_agent.provider_projects_root.as_ref().map(|path| path.to_string_lossy().to_string()),
1013
+ }),
1014
+ );
1015
+ }
1016
+ }
1017
+
1018
+ pub(crate) fn persist_command_plan_state(
1019
+ state: &mut serde_json::Map<String, serde_json::Value>,
1020
+ plan: &crate::provider::CommandPlan,
1021
+ profile_launch: &crate::provider::ProviderProfileLaunch,
1022
+ ) {
1023
+ if let Some(session_id) = plan.expected_session_id.as_ref() {
1024
+ state.insert(
1025
+ "_pending_session_id".to_string(),
1026
+ serde_json::json!(session_id.as_str()),
1027
+ );
1028
+ }
1029
+ let projects_root = plan
1030
+ .provider_projects_root
1031
+ .as_ref()
1032
+ .or(profile_launch.claude_projects_root.as_ref());
1033
+ if let Some(root) = projects_root {
1034
+ state.insert(
1035
+ "claude_projects_root".to_string(),
1036
+ serde_json::json!(root.to_string_lossy().to_string()),
1037
+ );
1038
+ }
1039
+ let managed_mcp_config = plan.managed_mcp_config || profile_launch.managed_mcp_config;
1040
+ if managed_mcp_config {
1041
+ state.insert("managed_mcp_config".to_string(), serde_json::json!(true));
1042
+ }
1043
+ if managed_mcp_config || profile_launch.claude_config_dir.is_some() || projects_root.is_some() {
1044
+ state.insert(
1045
+ "profile_launch".to_string(),
1046
+ serde_json::json!({
1047
+ "managed_mcp_config": managed_mcp_config,
1048
+ "claude_config_dir": profile_launch.claude_config_dir.as_ref().map(|path| path.to_string_lossy().to_string()),
1049
+ "claude_projects_root": projects_root.map(|path| path.to_string_lossy().to_string()),
1050
+ }),
1051
+ );
1052
+ }
1053
+ }
1054
+
811
1055
  fn is_posix_shell_identifier(name: &str) -> bool {
812
1056
  let mut chars = name.chars();
813
1057
  match chars.next() {
@@ -885,7 +1129,7 @@ fn explicit_active_team_key(state: &serde_json::Value) -> Option<String> {
885
1129
  state
886
1130
  .get("active_team_key")
887
1131
  .and_then(serde_json::Value::as_str)
888
- .filter(|team| !team.is_empty() && *team != "current")
1132
+ .filter(|team| !team.is_empty())
889
1133
  .map(str::to_string)
890
1134
  }
891
1135
 
@@ -933,6 +1177,150 @@ fn quick_start_requested_team_key<'a>(team_id: Option<&'a str>, name: Option<&'a
933
1177
  team_id.or(name).filter(|team| !team.is_empty())
934
1178
  }
935
1179
 
1180
+ struct QuickStartDepth {
1181
+ parent_team_key: Option<String>,
1182
+ team_depth: u64,
1183
+ }
1184
+
1185
+ fn quick_start_depth_guard(
1186
+ workspace: &Path,
1187
+ _agents_dir: &Path,
1188
+ requested_team: Option<&str>,
1189
+ _strict_real_runtime: bool,
1190
+ ) -> Result<QuickStartDepth, LifecycleError> {
1191
+ let env_parent = std::env::var("TEAM_AGENT_OWNER_TEAM_ID")
1192
+ .ok()
1193
+ .map(|value| value.trim().to_string())
1194
+ .filter(|value| !value.is_empty());
1195
+ let parent = env_parent;
1196
+ let Some(parent) = parent else {
1197
+ let state = crate::state::persist::load_runtime_state(workspace)
1198
+ .unwrap_or_else(|_| serde_json::json!({}));
1199
+ let ambiguous_nested_intent = requested_team.is_some_and(|team| {
1200
+ looks_ambiguous_child_team_key(team) || looks_grandchild_team_key(team)
1201
+ });
1202
+ if has_live_runtime_teams(&state) && ambiguous_nested_intent {
1203
+ if requested_team.is_some_and(looks_grandchild_team_key) {
1204
+ if let Some(parent_key) = infer_parent_team_from_active_state(&state) {
1205
+ let parent_state =
1206
+ crate::state::projection::project_top_level_view(&state, &parent_key);
1207
+ let parent_depth = parent_state
1208
+ .get("team_depth")
1209
+ .and_then(serde_json::Value::as_u64)
1210
+ .unwrap_or(1);
1211
+ return Ok(QuickStartDepth {
1212
+ parent_team_key: Some(parent_key),
1213
+ team_depth: parent_depth.saturating_add(1),
1214
+ });
1215
+ }
1216
+ }
1217
+ return Err(LifecycleError::RequirementUnmet(
1218
+ "cannot infer parent team for nested quick-start; pass an explicit worker/subleader owner context"
1219
+ .to_string(),
1220
+ ));
1221
+ }
1222
+ return Ok(QuickStartDepth {
1223
+ parent_team_key: None,
1224
+ team_depth: 1,
1225
+ });
1226
+ };
1227
+ let state = crate::state::persist::load_runtime_state(workspace)
1228
+ .unwrap_or_else(|_| serde_json::json!({}));
1229
+ let parent_key = crate::state::projection::resolve_owner_team_id(&state, &parent)
1230
+ .canonical_key()
1231
+ .map(str::to_string)
1232
+ .unwrap_or(parent);
1233
+ let parent_state = crate::state::projection::project_top_level_view(&state, &parent_key);
1234
+ let parent_depth = parent_state
1235
+ .get("team_depth")
1236
+ .and_then(serde_json::Value::as_u64)
1237
+ .unwrap_or(1);
1238
+ let team_depth = parent_depth.saturating_add(1);
1239
+ Ok(QuickStartDepth {
1240
+ parent_team_key: Some(parent_key),
1241
+ team_depth,
1242
+ })
1243
+ }
1244
+
1245
+ fn infer_parent_team_from_active_state(state: &serde_json::Value) -> Option<String> {
1246
+ let active = explicit_active_team_key(state)?;
1247
+ let team = state
1248
+ .get("teams")
1249
+ .and_then(serde_json::Value::as_object)
1250
+ .and_then(|teams| teams.get(&active))?;
1251
+ team_has_running_agent(team).then_some(active)
1252
+ }
1253
+
1254
+ fn has_live_runtime_teams(state: &serde_json::Value) -> bool {
1255
+ state
1256
+ .get("teams")
1257
+ .and_then(serde_json::Value::as_object)
1258
+ .is_some_and(|teams| {
1259
+ teams.values().any(team_has_running_agent)
1260
+ })
1261
+ }
1262
+
1263
+ fn team_has_running_agent(team: &serde_json::Value) -> bool {
1264
+ team.get("agents")
1265
+ .and_then(serde_json::Value::as_object)
1266
+ .is_some_and(|agents| {
1267
+ agents.values().any(|agent| {
1268
+ agent
1269
+ .get("status")
1270
+ .and_then(serde_json::Value::as_str)
1271
+ == Some("running")
1272
+ })
1273
+ })
1274
+ }
1275
+
1276
+ fn looks_ambiguous_child_team_key(team: &str) -> bool {
1277
+ 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"))
1282
+ }
1283
+
1284
+ fn looks_grandchild_team_key(team: &str) -> bool {
1285
+ let team = team.trim().to_ascii_lowercase();
1286
+ team == "grandchild"
1287
+ || team.starts_with("grandchild-")
1288
+ || team.starts_with("grandchild_")
1289
+ || team.starts_with("grandchild.")
1290
+ || team.starts_with("grandchild")
1291
+ }
1292
+
1293
+ fn annotate_team_depth(state: &mut serde_json::Value, parent_team_key: Option<&str>, team_depth: u64) {
1294
+ let Some(obj) = state.as_object_mut() else {
1295
+ return;
1296
+ };
1297
+ obj.insert("team_depth".to_string(), serde_json::json!(team_depth));
1298
+ if let Some(parent) = parent_team_key.filter(|value| !value.is_empty()) {
1299
+ obj.insert("parent_team_key".to_string(), serde_json::json!(parent));
1300
+ }
1301
+ }
1302
+
1303
+ fn annotate_persisted_team_depth(
1304
+ workspace: &Path,
1305
+ team_key: &str,
1306
+ parent_team_key: Option<&str>,
1307
+ team_depth: u64,
1308
+ ) -> Result<(), LifecycleError> {
1309
+ let mut state = crate::state::persist::load_runtime_state(workspace)
1310
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
1311
+ let Some(team) = state
1312
+ .get_mut("teams")
1313
+ .and_then(serde_json::Value::as_object_mut)
1314
+ .and_then(|teams| teams.get_mut(team_key))
1315
+ else {
1316
+ return Ok(());
1317
+ };
1318
+ annotate_team_depth(team, parent_team_key, team_depth);
1319
+ crate::state::persist::save_runtime_state(workspace, &state)
1320
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
1321
+ Ok(())
1322
+ }
1323
+
936
1324
  fn runtime_state_has_quick_start_team(state: &serde_json::Value, team: &str) -> bool {
937
1325
  explicit_active_team_key(state).as_deref() == Some(team)
938
1326
  || state
@@ -988,8 +1376,7 @@ pub fn quick_start_in_workspace(
988
1376
  fresh: bool,
989
1377
  team_id: Option<&str>,
990
1378
  ) -> Result<QuickStartReport, LifecycleError> {
991
- let workspace = crate::model::paths::canonical_run_workspace(workspace)
992
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
1379
+ let workspace = explicit_quick_start_workspace(workspace);
993
1380
  quick_start_with_transport_in_workspace(
994
1381
  &workspace,
995
1382
  agents_dir,
@@ -1002,6 +1389,18 @@ pub fn quick_start_in_workspace(
1002
1389
  )
1003
1390
  }
1004
1391
 
1392
+ fn explicit_quick_start_workspace(workspace: &Path) -> PathBuf {
1393
+ std::fs::canonicalize(workspace).unwrap_or_else(|_| {
1394
+ if workspace.is_absolute() {
1395
+ workspace.to_path_buf()
1396
+ } else {
1397
+ std::env::current_dir()
1398
+ .unwrap_or_else(|_| PathBuf::from("."))
1399
+ .join(workspace)
1400
+ }
1401
+ })
1402
+ }
1403
+
1005
1404
  /// `quick_start` with an injected transport — tests inject a recording mock so the REAL spawn path
1006
1405
  /// (launch dry_run=false → spawn_agents) is asserted without a live tmux; prod uses the real TmuxBackend.
1007
1406
  pub fn quick_start_with_transport(
@@ -1038,6 +1437,19 @@ pub fn quick_start_with_transport_in_workspace(
1038
1437
  .map(str::to_string)
1039
1438
  .or_else(|| spec_team_id(&spec));
1040
1439
  let explicit_team_key = quick_start_requested_team_key(team_id, name).map(str::to_string);
1440
+ let team_depth = quick_start_depth_guard(
1441
+ &workspace,
1442
+ agents_dir,
1443
+ requested_team.as_deref(),
1444
+ matches!(transport.kind(), crate::transport::BackendKind::Tmux),
1445
+ )?;
1446
+ if team_depth.team_depth > 2 {
1447
+ let parent = team_depth.parent_team_key.as_deref().unwrap_or("");
1448
+ return Err(LifecycleError::RequirementUnmet(format!(
1449
+ "team nesting depth limit exceeded: parent_team_key={parent} parent_depth={} max_depth=2",
1450
+ team_depth.team_depth.saturating_sub(1)
1451
+ )));
1452
+ }
1041
1453
  if !fresh {
1042
1454
  let state_path = crate::state::persist::runtime_state_path(&workspace);
1043
1455
  if state_path.exists() {
@@ -1058,9 +1470,9 @@ pub fn quick_start_with_transport_in_workspace(
1058
1470
  next_actions: vec![
1059
1471
  "run restart to resume the existing team or pass --fresh to replace it".to_string(),
1060
1472
  ],
1061
- });
1062
- }
1063
- }
1473
+ });
1474
+ }
1475
+ }
1064
1476
  }
1065
1477
  // CR-040/042: repeated quick-start from one template with distinct --team-id/--name
1066
1478
  // must NOT collide on the template-derived tmux session. Override the compiled
@@ -1070,22 +1482,39 @@ pub fn quick_start_with_transport_in_workspace(
1070
1482
  if let Some(requested) = requested_team.as_deref() {
1071
1483
  override_spec_session_name(&mut spec, &format!("team-{requested}"));
1072
1484
  }
1485
+ let session_name = spec_session_name(&spec);
1486
+ let state_team_key = explicit_team_key.clone().unwrap_or_else(|| {
1487
+ let spec_path = agents_dir.join("team.spec.yaml");
1488
+ runtime_team_key_for_spec(&spec_path, &spec, &session_name)
1489
+ });
1073
1490
  let spec_path = agents_dir.join("team.spec.yaml");
1074
1491
  std::fs::write(&spec_path, yaml::dumps(&spec)).map_err(|e| {
1075
1492
  LifecycleError::StatePersist(format!("{}: {e}", spec_path.display()))
1076
1493
  })?;
1077
1494
  let _store = crate::message_store::MessageStore::open(&workspace)
1078
1495
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
1079
- let session_name = spec_session_name(&spec);
1080
1496
  let resolved_spec_path = std::fs::canonicalize(&spec_path).unwrap_or_else(|_| spec_path.clone());
1081
1497
  let state = initial_runtime_state(&spec, &resolved_spec_path, &workspace, agents_dir);
1082
- let state_team_key = explicit_team_key
1083
- .unwrap_or_else(|| runtime_team_key_for_spec(&resolved_spec_path, &spec, &session_name));
1084
1498
  save_launched_team_state_for_key(&workspace, &state, Some(&state_team_key))?;
1499
+ annotate_persisted_team_depth(
1500
+ &workspace,
1501
+ &state_team_key,
1502
+ team_depth.parent_team_key.as_deref(),
1503
+ team_depth.team_depth,
1504
+ )?;
1085
1505
  // FIX (rt-host-a real-machine finding): dry_run=false so launch_with_transport calls spawn_agents
1086
1506
  // and really creates the tmux session + worker windows (was hardcoded true → never spawned, which
1087
1507
  // also starved the coordinator: no session → first tick TmuxSessionMissing → run_daemon loop exits).
1088
- let launch = launch_with_transport_in_workspace(&workspace, &spec_path, false, yes, true, transport)?;
1508
+ let mut launch = launch_with_transport_in_workspace(&workspace, &spec_path, false, yes, true, transport)?;
1509
+ annotate_persisted_team_depth(
1510
+ &workspace,
1511
+ &state_team_key,
1512
+ team_depth.parent_team_key.as_deref(),
1513
+ team_depth.team_depth,
1514
+ )?;
1515
+ launch.leader_receiver_attached = launched_team_receiver_is_attached(&workspace, &state_team_key);
1516
+ launch.session_capture_incomplete_agents =
1517
+ quick_start_session_capture_incomplete_agents(&workspace, &state_team_key);
1089
1518
  let coordinator_workspace = crate::coordinator::WorkspacePath::new(workspace.clone());
1090
1519
  let coordinator_started = crate::coordinator::start_coordinator(&coordinator_workspace)
1091
1520
  .map(|report| report.ok)
@@ -1102,7 +1531,7 @@ pub fn quick_start_with_transport_in_workspace(
1102
1531
  // loaded successfully (provider-side codex/claude schema rejections happen
1103
1532
  // asynchronously after spawn), so the verdict is PendingToolLoad — never
1104
1533
  // bare Ready.
1105
- let worker_readiness = quick_start_worker_readiness(&workspace);
1534
+ let worker_readiness = quick_start_worker_readiness(&workspace, &state_team_key);
1106
1535
  Ok(QuickStartReport::Ready {
1107
1536
  session_name,
1108
1537
  launch: Box::new(launch),
@@ -1118,13 +1547,21 @@ pub fn quick_start_with_transport_in_workspace(
1118
1547
  /// verdict to `Degraded { unhealthy_agents }` (sorted, deduped); otherwise
1119
1548
  /// `PendingToolLoad` — never bare Ready. State read failure is treated as
1120
1549
  /// PendingToolLoad rather than fabricated success.
1121
- fn quick_start_worker_readiness(workspace: &Path) -> QuickStartReadiness {
1550
+ fn quick_start_worker_readiness(workspace: &Path, team_key: &str) -> QuickStartReadiness {
1122
1551
  let Ok(state) = load_runtime_state(workspace) else {
1123
1552
  return QuickStartReadiness::PendingToolLoad;
1124
1553
  };
1125
- let Some(agents) = state.get("agents").and_then(serde_json::Value::as_object) else {
1554
+ let team_state = state
1555
+ .get("teams")
1556
+ .and_then(serde_json::Value::as_object)
1557
+ .and_then(|teams| teams.get(team_key))
1558
+ .unwrap_or(&state);
1559
+ let Some(agents) = team_state.get("agents").and_then(serde_json::Value::as_object) else {
1126
1560
  return QuickStartReadiness::PendingToolLoad;
1127
1561
  };
1562
+ let all_spawned = !agents.is_empty();
1563
+ let leader_receiver_attached = launched_team_receiver_is_attached(workspace, team_key);
1564
+ let all_attached_receiver = leader_receiver_attached;
1128
1565
  let mut unhealthy: Vec<String> = agents
1129
1566
  .iter()
1130
1567
  .filter_map(|(id, agent)| {
@@ -1135,15 +1572,79 @@ fn quick_start_worker_readiness(workspace: &Path) -> QuickStartReadiness {
1135
1572
  }
1136
1573
  })
1137
1574
  .collect();
1138
- if unhealthy.is_empty() {
1139
- QuickStartReadiness::PendingToolLoad
1140
- } else {
1575
+ if !unhealthy.is_empty() {
1141
1576
  unhealthy.sort();
1142
1577
  unhealthy.dedup();
1143
1578
  QuickStartReadiness::Degraded { unhealthy_agents: unhealthy }
1579
+ } else {
1580
+ let incomplete_agents = crate::session_capture::incomplete_interacted_resumable_agent_ids(team_state);
1581
+ let all_resumable_have_session = incomplete_agents.is_empty();
1582
+ let _readiness_ready = all_spawned && all_attached_receiver && all_resumable_have_session;
1583
+ QuickStartReadiness::PendingToolLoad
1144
1584
  }
1145
1585
  }
1146
1586
 
1587
+ fn quick_start_session_capture_incomplete_agents(workspace: &Path, team_key: &str) -> Vec<String> {
1588
+ let Ok(state) = load_runtime_state(workspace) else {
1589
+ return Vec::new();
1590
+ };
1591
+ let team_state = state
1592
+ .get("teams")
1593
+ .and_then(serde_json::Value::as_object)
1594
+ .and_then(|teams| teams.get(team_key))
1595
+ .unwrap_or(&state);
1596
+ crate::session_capture::incomplete_interacted_resumable_agent_ids(team_state)
1597
+ }
1598
+
1599
+ fn launched_team_receiver_is_attached(workspace: &Path, team_key: &str) -> bool {
1600
+ let Ok(state) = load_runtime_state(workspace) else {
1601
+ return true;
1602
+ };
1603
+ let team_state = state
1604
+ .get("teams")
1605
+ .and_then(serde_json::Value::as_object)
1606
+ .and_then(|teams| teams.get(team_key))
1607
+ .unwrap_or(&state);
1608
+ if team_state.get("leader_receiver").is_none() {
1609
+ return true;
1610
+ }
1611
+ if team_uses_fake_model_harness(team_state) {
1612
+ return true;
1613
+ }
1614
+ leader_receiver_is_attached(team_state)
1615
+ }
1616
+
1617
+ fn team_uses_fake_model_harness(team_state: &serde_json::Value) -> bool {
1618
+ team_state
1619
+ .get("agents")
1620
+ .and_then(serde_json::Value::as_object)
1621
+ .is_some_and(|agents| {
1622
+ !agents.is_empty()
1623
+ && agents.values().all(|agent| {
1624
+ agent
1625
+ .get("model")
1626
+ .and_then(serde_json::Value::as_str)
1627
+ == Some("fake")
1628
+ })
1629
+ })
1630
+ }
1631
+
1632
+ fn leader_receiver_is_attached(team_state: &serde_json::Value) -> bool {
1633
+ let Some(receiver) = team_state.get("leader_receiver") else {
1634
+ return false;
1635
+ };
1636
+ let status = receiver
1637
+ .get("status")
1638
+ .and_then(serde_json::Value::as_str)
1639
+ .unwrap_or("");
1640
+ let pane_id = receiver
1641
+ .get("pane_id")
1642
+ .and_then(serde_json::Value::as_str)
1643
+ .or_else(|| receiver.get("pane").and_then(serde_json::Value::as_str))
1644
+ .unwrap_or("");
1645
+ status == "attached" && !pane_id.is_empty() && pane_id != "__team_agent_unbound__"
1646
+ }
1647
+
1147
1648
  /// `detect_inherited_dangerous_permissions`(`launch/config.py`):扫进程祖先链找
1148
1649
  /// `--dangerously-*` flag,产出危险审批继承态。launch 在 inherited=false 且无 --yes 时拒。
1149
1650
  pub fn detect_dangerous_approval() -> Result<DangerousApproval, LifecycleError> {
@@ -1365,7 +1866,7 @@ pub fn add_agent(
1365
1866
  agent_id,
1366
1867
  role_file_path,
1367
1868
  open_display,
1368
- team,
1869
+ Some(selected.team_key.as_str()),
1369
1870
  &crate::tmux_backend::TmuxBackend::for_workspace(&selected.run_workspace),
1370
1871
  )
1371
1872
  }
@@ -1402,12 +1903,15 @@ fn add_agent_with_transport_at_paths(
1402
1903
  team: Option<&str>,
1403
1904
  transport: &dyn Transport,
1404
1905
  ) -> Result<AddAgentReport, LifecycleError> {
1405
- let owner_state = if team.is_some() {
1406
- crate::state::projection::select_runtime_state(run_workspace, team)
1407
- .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?
1408
- } else {
1409
- load_runtime_state(run_workspace).map_err(|e| LifecycleError::StatePersist(e.to_string()))?
1410
- };
1906
+ let runtime_state = crate::state::persist::load_runtime_state(run_workspace)
1907
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
1908
+ let canonical_team_key = team
1909
+ .filter(|key| !key.is_empty())
1910
+ .map(str::to_string)
1911
+ .or_else(|| explicit_active_team_key(&runtime_state))
1912
+ .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()))?;
1411
1915
  ensure_owner_allowed_for_state(&owner_state, Some(agent_id))?;
1412
1916
  if !role_file_path.exists() {
1413
1917
  return Err(LifecycleError::Compile(format!(
@@ -1423,13 +1927,21 @@ fn add_agent_with_transport_at_paths(
1423
1927
  let dynamic_role_file = materialize_added_role_file(team_dir, agent_id, role_file_path)?;
1424
1928
  let spec = crate::compiler::compile_team(team_dir)
1425
1929
  .map_err(|e| LifecycleError::Compile(e.to_string()))?;
1930
+ let safety = effective_runtime_config(&spec)?;
1426
1931
  let spec_path = team_dir.join("team.spec.yaml");
1427
1932
  std::fs::write(&spec_path, yaml::dumps(&spec)).map_err(|e| {
1428
1933
  LifecycleError::StatePersist(format!("{}: {e}", spec_path.display()))
1429
1934
  })?;
1430
1935
  let (meta, _) = crate::compiler::read_front_matter(&dynamic_role_file)
1431
1936
  .map_err(|e| LifecycleError::Compile(e.to_string()))?;
1432
- upsert_agent_state_from_role(run_workspace, team, agent_id, &meta, &dynamic_role_file)?;
1937
+ upsert_agent_state_from_role(
1938
+ run_workspace,
1939
+ &canonical_team_key,
1940
+ agent_id,
1941
+ &meta,
1942
+ &dynamic_role_file,
1943
+ &safety,
1944
+ )?;
1433
1945
  let started = crate::lifecycle::restart::start_agent_at_paths(
1434
1946
  run_workspace,
1435
1947
  team_dir,
@@ -1437,7 +1949,7 @@ fn add_agent_with_transport_at_paths(
1437
1949
  false,
1438
1950
  open_display,
1439
1951
  true,
1440
- team,
1952
+ Some(&canonical_team_key),
1441
1953
  transport,
1442
1954
  )?;
1443
1955
  let (env, start_mode) = match started {
@@ -1460,18 +1972,14 @@ fn add_agent_with_transport_at_paths(
1460
1972
 
1461
1973
  fn upsert_agent_state_from_role(
1462
1974
  workspace: &Path,
1463
- team: Option<&str>,
1975
+ canonical_team_key: &str,
1464
1976
  agent_id: &AgentId,
1465
1977
  meta: &Value,
1466
1978
  dynamic_role_file: &Path,
1979
+ safety: &DangerousApproval,
1467
1980
  ) -> Result<(), LifecycleError> {
1468
- let mut state = if team.is_some() {
1469
- crate::state::projection::select_runtime_state(workspace, team)
1470
- .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?
1471
- } else {
1472
- crate::state::persist::load_runtime_state(workspace)
1473
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?
1474
- };
1981
+ let mut state = crate::state::projection::select_runtime_state(workspace, Some(canonical_team_key))
1982
+ .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
1475
1983
  if !state.is_object() {
1476
1984
  state = serde_json::json!({});
1477
1985
  }
@@ -1513,16 +2021,31 @@ fn upsert_agent_state_from_role(
1513
2021
  if let Some(model) = meta.get("model").and_then(Value::as_str) {
1514
2022
  if let Some(obj) = entry.as_object_mut() {
1515
2023
  obj.insert("model".to_string(), serde_json::json!(model));
2024
+ obj.insert("model_source".to_string(), serde_json::json!("role"));
1516
2025
  }
1517
2026
  }
1518
- agent_map.insert(agent_id.as_str().to_string(), entry);
1519
- if team.is_some() {
1520
- let team_key = explicit_active_team_key(&state)
1521
- .or_else(|| team.filter(|key| !key.is_empty()).map(str::to_string));
1522
- save_launched_team_state_for_key(workspace, &state, team_key.as_deref())
1523
- } else {
1524
- save_runtime_state(workspace, &state).map_err(|e| LifecycleError::StatePersist(e.to_string()))
2027
+ if let Some(profile) = meta.get("profile").and_then(Value::as_str) {
2028
+ if let Some(obj) = entry.as_object_mut() {
2029
+ 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
+ {
2034
+ obj.insert(
2035
+ "_profile_dir".to_string(),
2036
+ serde_json::json!(team_dir.join("profiles").to_string_lossy().to_string()),
2037
+ );
2038
+ }
2039
+ if !obj.contains_key("model_source") {
2040
+ obj.insert("model_source".to_string(), serde_json::json!("default"));
2041
+ }
2042
+ }
1525
2043
  }
2044
+ if let Some(obj) = entry.as_object_mut() {
2045
+ persist_effective_approval_policy(obj, safety);
2046
+ }
2047
+ agent_map.insert(agent_id.as_str().to_string(), entry);
2048
+ save_launched_team_state_for_key(workspace, &state, Some(canonical_team_key))
1526
2049
  }
1527
2050
 
1528
2051
  fn materialize_added_role_file(
@@ -1667,61 +2190,158 @@ pub fn fork_agent_with_transport(
1667
2190
  let safety = effective_runtime_config(&new_spec)?;
1668
2191
  let tools = worker_tool_refs(agent_tool_strings(new_agent), &safety);
1669
2192
  let tool_refs: Vec<&str> = tools.iter().map(String::as_str).collect();
2193
+ let fork_team = crate::messaging::leader_receiver::active_team_key(&workspace, &state);
1670
2194
  let mcp_config = adapter
1671
2195
  .mcp_config(auth_mode)
1672
2196
  .map_err(|e| {
1673
2197
  let _ = std::fs::write(&spec_path, text.as_bytes());
1674
2198
  LifecycleError::Provider(e.to_string())
1675
2199
  })?;
1676
- let mut argv = adapter
1677
- .fork_with_context(
2200
+ 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(
2208
+ &workspace,
2209
+ 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);
2219
+ let mut plan = adapter
2220
+ .fork_plan(
1678
2221
  Some(&session_id),
1679
- auth_mode,
1680
- Some(&mcp_config),
1681
- role,
1682
- model,
1683
- &tool_refs,
2222
+ crate::provider::ProviderCommandContext {
2223
+ auth_mode,
2224
+ mcp_config: Some(&mcp_config),
2225
+ system_prompt: role,
2226
+ model: command_model,
2227
+ tools: &tool_refs,
2228
+ profile_launch: Some(&profile_launch),
2229
+ },
1684
2230
  )
1685
2231
  .map_err(|e| {
1686
2232
  let _ = std::fs::write(&spec_path, text.as_bytes());
1687
2233
  LifecycleError::Provider(e.to_string())
1688
2234
  })?;
1689
- let fork_team = crate::messaging::leader_receiver::active_team_key(&workspace, &state);
1690
- fill_spawn_placeholders_full(&mut argv, &workspace, as_agent_id.as_str(), Some(&fork_team));
2235
+ if !plan.managed_mcp_config && !profile_launch.managed_mcp_config {
2236
+ point_native_mcp_config_at_file(&mut plan.argv, provider, &mcp_config_path);
2237
+ }
2238
+ fill_spawn_placeholders_full(&mut plan.argv, &workspace, as_agent_id.as_str(), Some(&fork_team));
1691
2239
  let window = WindowName::new(as_agent_id.as_str());
1692
2240
  // fork inherits the parent agent's owner team via runtime state (`active_team_key`).
1693
- let env = inherited_env_with_team_overrides(
2241
+ let mut env = inherited_env_with_team_overrides(
1694
2242
  &workspace,
1695
2243
  as_agent_id.as_str(),
1696
2244
  Some(&fork_team),
1697
2245
  );
2246
+ apply_profile_launch_env(&mut env, &profile_launch);
1698
2247
  // golden operations.py:336 -> _tmux_start_command_for_agent_window (runtime.py:1017-1020): branch on
1699
2248
  // _tmux_session_exists — an ABSENT session => new-session (spawn_first), present => new-window
1700
2249
  // (spawn_into). The Rust restart seam (restart.rs spawn_agent_window) uses the same branch.
1701
2250
  let session_live = transport.has_session(&session_name).unwrap_or(false);
1702
2251
  let spawn_result = if session_live {
1703
- transport.spawn_into(&session_name, &window, &argv, &workspace, &env)
2252
+ transport.spawn_into(&session_name, &window, &plan.argv, &workspace, &env)
1704
2253
  } else {
1705
- transport.spawn_first(&session_name, &window, &argv, &workspace, &env)
2254
+ transport.spawn_first(&session_name, &window, &plan.argv, &workspace, &env)
1706
2255
  };
1707
- let _spawn = spawn_result.map_err(|e| {
2256
+ let spawn = spawn_result.map_err(|e| {
1708
2257
  let _ = std::fs::write(&spec_path, text.as_bytes());
1709
2258
  LifecycleError::Transport(e.to_string())
1710
2259
  })?;
1711
2260
  let old_state = state.clone();
1712
2261
  let mut next_state = state;
1713
- upsert_forked_agent_state(&mut next_state, source_agent_id, as_agent_id, new_agent)?;
2262
+ upsert_forked_agent_state(
2263
+ &mut next_state,
2264
+ source_agent_id,
2265
+ as_agent_id,
2266
+ new_agent,
2267
+ &safety,
2268
+ &plan,
2269
+ &profile_launch,
2270
+ &spawn,
2271
+ &workspace,
2272
+ Some(&profile_dir),
2273
+ )?;
2274
+ if let Some(agent) = next_state
2275
+ .get_mut("agents")
2276
+ .and_then(serde_json::Value::as_object_mut)
2277
+ .and_then(|agents| agents.get_mut(as_agent_id.as_str()))
2278
+ .and_then(serde_json::Value::as_object_mut)
2279
+ {
2280
+ persist_effective_approval_policy(agent, &safety);
2281
+ }
2282
+ if let Err(e) = maybe_fail_fork_after_spawn("save_runtime_state") {
2283
+ rollback_fork_after_spawn(
2284
+ &workspace,
2285
+ &spec_path,
2286
+ &text,
2287
+ &old_state,
2288
+ transport,
2289
+ &session_name,
2290
+ &window,
2291
+ &mcp_config_path,
2292
+ as_agent_id,
2293
+ &profile_launch,
2294
+ );
2295
+ return Err(e);
2296
+ }
1714
2297
  if let Err(e) = save_runtime_state(&workspace, &next_state) {
1715
- rollback_fork_after_spawn(&workspace, &spec_path, &text, &old_state, transport, &session_name, &window);
2298
+ rollback_fork_after_spawn(
2299
+ &workspace,
2300
+ &spec_path,
2301
+ &text,
2302
+ &old_state,
2303
+ transport,
2304
+ &session_name,
2305
+ &window,
2306
+ &mcp_config_path,
2307
+ as_agent_id,
2308
+ &profile_launch,
2309
+ );
1716
2310
  return Err(LifecycleError::StatePersist(e.to_string()));
1717
2311
  }
2312
+ if let Err(e) = maybe_fail_fork_after_spawn("start_coordinator") {
2313
+ rollback_fork_after_spawn(
2314
+ &workspace,
2315
+ &spec_path,
2316
+ &text,
2317
+ &old_state,
2318
+ transport,
2319
+ &session_name,
2320
+ &window,
2321
+ &mcp_config_path,
2322
+ as_agent_id,
2323
+ &profile_launch,
2324
+ );
2325
+ return Err(e);
2326
+ }
1718
2327
  let coordinator_started =
1719
2328
  crate::coordinator::start_coordinator(&crate::coordinator::WorkspacePath::new(
1720
2329
  workspace.to_path_buf(),
1721
2330
  ))
1722
2331
  .map(|report| report.ok)
1723
2332
  .map_err(|e| {
1724
- rollback_fork_after_spawn(&workspace, &spec_path, &text, &old_state, transport, &session_name, &window);
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
+ );
1725
2345
  LifecycleError::StatePersist(e.to_string())
1726
2346
  })?;
1727
2347
  Ok(ForkAgentReport {
@@ -1744,6 +2364,9 @@ fn rollback_fork_after_spawn(
1744
2364
  transport: &dyn Transport,
1745
2365
  session_name: &SessionName,
1746
2366
  window: &WindowName,
2367
+ mcp_config_path: &Path,
2368
+ agent_id: &AgentId,
2369
+ profile_launch: &crate::provider::ProviderProfileLaunch,
1747
2370
  ) {
1748
2371
  let _ = transport.kill_window(&Target::SessionWindow {
1749
2372
  session: session_name.clone(),
@@ -1751,6 +2374,41 @@ fn rollback_fork_after_spawn(
1751
2374
  });
1752
2375
  let _ = std::fs::write(spec_path, spec_text.as_bytes());
1753
2376
  let _ = save_runtime_state(workspace, old_state);
2377
+ cleanup_fork_mcp_artifacts(workspace, agent_id, mcp_config_path, profile_launch);
2378
+ }
2379
+
2380
+ fn maybe_fail_fork_after_spawn(step: &str) -> Result<(), LifecycleError> {
2381
+ let Ok(reason) = std::env::var("TEAM_AGENT_TEST_FAIL_FORK_AFTER_SPAWN") else {
2382
+ return Ok(());
2383
+ };
2384
+ if reason.is_empty() {
2385
+ return Ok(());
2386
+ }
2387
+ let should_fail = reason == step
2388
+ || (step == "start_coordinator" && reason == "coordinator");
2389
+ if !should_fail {
2390
+ return Ok(());
2391
+ }
2392
+ Err(LifecycleError::StatePersist(format!(
2393
+ "injected fork failure after spawn: {reason}"
2394
+ )))
2395
+ }
2396
+
2397
+ fn cleanup_fork_mcp_artifacts(
2398
+ workspace: &Path,
2399
+ agent_id: &AgentId,
2400
+ mcp_config_path: &Path,
2401
+ profile_launch: &crate::provider::ProviderProfileLaunch,
2402
+ ) {
2403
+ let _ = std::fs::remove_file(mcp_config_path);
2404
+ let _ = std::fs::remove_file(
2405
+ workspace
2406
+ .join(".team/runtime/provider-env")
2407
+ .join(format!("{}.env", agent_id.as_str())),
2408
+ );
2409
+ if let Some(config_dir) = profile_launch.claude_config_dir.as_ref() {
2410
+ let _ = std::fs::remove_dir_all(config_dir.parent().unwrap_or(config_dir));
2411
+ }
1754
2412
  }
1755
2413
 
1756
2414
  fn leader_id_matches(spec: &Value, agent_id: &AgentId) -> bool {
@@ -1883,6 +2541,12 @@ fn upsert_forked_agent_state(
1883
2541
  source_agent_id: &AgentId,
1884
2542
  as_agent_id: &AgentId,
1885
2543
  spec_agent: &Value,
2544
+ safety: &DangerousApproval,
2545
+ plan: &crate::provider::CommandPlan,
2546
+ profile_launch: &crate::provider::ProviderProfileLaunch,
2547
+ spawn: &crate::transport::SpawnResult,
2548
+ spawn_cwd: &Path,
2549
+ profile_dir: Option<&Path>,
1886
2550
  ) -> Result<(), LifecycleError> {
1887
2551
  if !state.is_object() {
1888
2552
  *state = serde_json::json!({});
@@ -1907,15 +2571,46 @@ fn upsert_forked_agent_state(
1907
2571
  .get("provider")
1908
2572
  .and_then(Value::as_str)
1909
2573
  .unwrap_or("codex");
1910
- agent_map.insert(
1911
- as_agent_id.as_str().to_string(),
1912
- serde_json::json!({
1913
- "status": "running",
1914
- "provider": provider,
1915
- "window": as_agent_id.as_str(),
1916
- "forked_from": source_agent_id.as_str(),
1917
- }),
2574
+ let mut entry = serde_json::Map::new();
2575
+ entry.insert("status".to_string(), serde_json::json!("running"));
2576
+ 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()));
2580
+ entry.insert(
2581
+ "spawn_cwd".to_string(),
2582
+ serde_json::json!(spawn_cwd.to_string_lossy().to_string()),
1918
2583
  );
2584
+ entry.insert("pane_id".to_string(), serde_json::json!(spawn.pane_id.as_str()));
2585
+ if let Some(pid) = spawn.child_pid {
2586
+ entry.insert("pane_pid".to_string(), serde_json::json!(pid));
2587
+ }
2588
+ for key in ["auth_mode", "model", "model_source", "profile", "_profile_dir", "role"] {
2589
+ if let Some(value) = spec_agent.get(key) {
2590
+ entry.insert(key.to_string(), yaml_value_to_json(value));
2591
+ }
2592
+ }
2593
+ if spec_agent.get("profile").is_some() && !entry.contains_key("_profile_dir") {
2594
+ if let Some(profile_dir) = profile_dir {
2595
+ entry.insert(
2596
+ "_profile_dir".to_string(),
2597
+ serde_json::json!(profile_dir.to_string_lossy().to_string()),
2598
+ );
2599
+ }
2600
+ }
2601
+ entry.insert("session_id".to_string(), serde_json::Value::Null);
2602
+ entry.insert("rollout_path".to_string(), serde_json::Value::Null);
2603
+ entry.insert("captured_at".to_string(), serde_json::Value::Null);
2604
+ entry.insert("captured_via".to_string(), serde_json::Value::Null);
2605
+ entry.insert("attribution_confidence".to_string(), serde_json::Value::Null);
2606
+ 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));
2608
+ if let Some(entry) = agent_map
2609
+ .get_mut(as_agent_id.as_str())
2610
+ .and_then(serde_json::Value::as_object_mut)
2611
+ {
2612
+ persist_effective_approval_policy(entry, safety);
2613
+ }
1919
2614
  Ok(())
1920
2615
  }
1921
2616
 
@@ -2095,6 +2790,13 @@ fn seed_launched_owner_from_env(state: &mut serde_json::Value) -> bool {
2095
2790
  true
2096
2791
  }
2097
2792
 
2793
+ fn has_positive_caller_leader_env() -> bool {
2794
+ env_nonempty("TEAM_AGENT_LEADER_PANE_ID")
2795
+ || env_nonempty("TEAM_AGENT_LEADER_SESSION_UUID")
2796
+ || env_nonempty("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE")
2797
+ || env_nonempty("TEAM_AGENT_LEADER_PROVIDER")
2798
+ }
2799
+
2098
2800
  fn spec_tasks_json(spec: &Value) -> serde_json::Value {
2099
2801
  spec.get("tasks")
2100
2802
  .and_then(Value::as_list)