@team-agent/installer 0.3.14 → 0.3.16

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 (46) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +1 -0
  4. package/crates/team-agent/src/cli/emit.rs +92 -11
  5. package/crates/team-agent/src/cli/leader.rs +2 -0
  6. package/crates/team-agent/src/cli/mod.rs +255 -37
  7. package/crates/team-agent/src/cli/send.rs +252 -0
  8. package/crates/team-agent/src/cli/status.rs +5 -1
  9. package/crates/team-agent/src/cli/status_port.rs +24 -0
  10. package/crates/team-agent/src/cli/tests/base.rs +21 -3
  11. package/crates/team-agent/src/cli/tests/divergence.rs +6 -6
  12. package/crates/team-agent/src/cli/tests/run_delegation.rs +15 -3
  13. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +285 -2
  14. package/crates/team-agent/src/cli/tests/status_send.rs +25 -0
  15. package/crates/team-agent/src/cli/types.rs +32 -1
  16. package/crates/team-agent/src/compiler/tests.rs +2 -2
  17. package/crates/team-agent/src/compiler.rs +1 -1
  18. package/crates/team-agent/src/fake_worker.rs +32 -145
  19. package/crates/team-agent/src/leader/start.rs +272 -8
  20. package/crates/team-agent/src/leader/tests/basics.rs +1 -0
  21. package/crates/team-agent/src/leader/tests/identity.rs +121 -25
  22. package/crates/team-agent/src/leader/types.rs +9 -0
  23. package/crates/team-agent/src/lifecycle/display.rs +3 -3
  24. package/crates/team-agent/src/lifecycle/launch.rs +631 -70
  25. package/crates/team-agent/src/lifecycle/restart/agent.rs +137 -17
  26. package/crates/team-agent/src/lifecycle/restart/common.rs +38 -2
  27. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +79 -4
  28. package/crates/team-agent/src/lifecycle/tests/core.rs +14 -5
  29. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +731 -8
  30. package/crates/team-agent/src/lifecycle/types.rs +5 -0
  31. package/crates/team-agent/src/lifecycle/worker_command_context.rs +8 -0
  32. package/crates/team-agent/src/mcp_server/wire.rs +153 -3
  33. package/crates/team-agent/src/messaging/leader_receiver.rs +276 -43
  34. package/crates/team-agent/src/messaging/mod.rs +6 -3
  35. package/crates/team-agent/src/messaging/results.rs +240 -158
  36. package/crates/team-agent/src/messaging/send.rs +3 -2
  37. package/crates/team-agent/src/messaging/tests/e23.rs +35 -0
  38. package/crates/team-agent/src/messaging/tests/mod.rs +6 -2
  39. package/crates/team-agent/src/messaging/tests/runtime.rs +9 -9
  40. package/crates/team-agent/src/os_probe.rs +11 -0
  41. package/crates/team-agent/src/state/persist.rs +6 -0
  42. package/crates/team-agent/src/tmux_backend.rs +85 -0
  43. package/crates/team-agent/src/transport/test_support.rs +46 -1
  44. package/crates/team-agent/src/transport.rs +27 -0
  45. package/package.json +4 -4
  46. package/skills/team-agent/references/recovery-runbook.md +277 -0
@@ -123,6 +123,7 @@ pub fn launch_with_transport_in_workspace(
123
123
  session_name,
124
124
  started,
125
125
  dry_run,
126
+ tmux_endpoint: transport.tmux_endpoint(),
126
127
  routes,
127
128
  permissions,
128
129
  safety,
@@ -157,6 +158,22 @@ fn spawn_agents(
157
158
  spec.get("runtime").and_then(|v| v.get("fast")),
158
159
  Some(Value::Bool(true))
159
160
  );
161
+ let display_backend = spec_display_backend(spec);
162
+ let active_agent_ids = spec_agent_values(spec)
163
+ .into_iter()
164
+ .filter_map(|agent| {
165
+ if agent_is_paused(agent) {
166
+ None
167
+ } else {
168
+ agent.get("id").and_then(Value::as_str).map(AgentId::new)
169
+ }
170
+ })
171
+ .collect::<Vec<_>>();
172
+ let layout_plan = if display_backend == DisplayBackend::Adaptive {
173
+ adaptive_layout_plan(&active_agent_ids, ADAPTIVE_LAYOUT_MAX_PER_WINDOW)
174
+ } else {
175
+ Vec::new()
176
+ };
160
177
  let mut started = Vec::new();
161
178
  for agent in spec_agent_values(spec) {
162
179
  let Some(agent_id_raw) = agent.get("id").and_then(Value::as_str) else {
@@ -284,7 +301,6 @@ fn spawn_agents(
284
301
  )?;
285
302
  }
286
303
  fill_spawn_placeholders_full(&mut plan.argv, workspace, agent_id_raw, Some(&mcp_team_id));
287
- let window = WindowName::new(agent_id_raw);
288
304
  let mut env =
289
305
  inherited_env_with_team_overrides(workspace, agent_id_raw, Some(&mcp_team_id));
290
306
  apply_profile_launch_env(&mut env, &profile_launch);
@@ -349,7 +365,46 @@ fn spawn_agents(
349
365
  }),
350
366
  );
351
367
  }
352
- let spawn = if started.is_empty() {
368
+ let placement = layout_plan
369
+ .iter()
370
+ .find(|placement| placement.agent_id == agent_id)
371
+ .cloned();
372
+ let window = placement
373
+ .as_ref()
374
+ .map(|placement| placement.layout_window.clone())
375
+ .unwrap_or_else(|| WindowName::new(agent_id_raw));
376
+ let spawn = if let Some(placement) = placement.as_ref() {
377
+ if placement.starts_window {
378
+ if started.is_empty() {
379
+ transport.spawn_first_with_env_unset(
380
+ session_name,
381
+ &window,
382
+ &plan.argv,
383
+ workspace,
384
+ &env,
385
+ &env_unset,
386
+ )
387
+ } else {
388
+ transport.spawn_into_with_env_unset(
389
+ session_name,
390
+ &window,
391
+ &plan.argv,
392
+ workspace,
393
+ &env,
394
+ &env_unset,
395
+ )
396
+ }
397
+ } else {
398
+ transport.spawn_split_with_env_unset(
399
+ session_name,
400
+ &window,
401
+ &plan.argv,
402
+ workspace,
403
+ &env,
404
+ &env_unset,
405
+ )
406
+ }
407
+ } else if started.is_empty() {
353
408
  transport.spawn_first_with_env_unset(
354
409
  session_name,
355
410
  &window,
@@ -383,6 +438,25 @@ fn spawn_agents(
383
438
  if matches!(transport.liveness(&spawn.pane_id), Ok(PaneLiveness::Dead)) {
384
439
  continue;
385
440
  }
441
+ let display = if placement.is_some() {
442
+ WorkerDisplay::Adaptive {
443
+ status: DisplayStatus::Opened,
444
+ window: Some(spawn.window.clone()),
445
+ workspace_window: None,
446
+ pane_id: Some(spawn.pane_id.clone()),
447
+ pane_title: Some(agent_id_raw.to_string()),
448
+ target: Some(spawn.pane_id.as_str().to_string()),
449
+ target_worker_session: Some(session_name.as_str().to_string()),
450
+ linked_session: None,
451
+ leader_session: Some(session_name.clone()),
452
+ display_session: None,
453
+ fallback: None,
454
+ }
455
+ } else {
456
+ WorkerDisplay::Blocked {
457
+ reason: AdaptiveBlockReason::NotImplementedThisPlatform,
458
+ }
459
+ };
386
460
  started.push(StartedAgent {
387
461
  agent_id,
388
462
  start_mode: StartMode::Fresh,
@@ -396,14 +470,232 @@ fn spawn_agents(
396
470
  .clone()
397
471
  .or_else(|| profile_launch.claude_projects_root.clone()),
398
472
  managed_mcp_config: plan.managed_mcp_config || profile_launch.managed_mcp_config,
399
- display: WorkerDisplay::Blocked {
400
- reason: AdaptiveBlockReason::NotImplementedThisPlatform,
401
- },
473
+ layout_window: placement
474
+ .as_ref()
475
+ .map(|placement| placement.layout_window.clone()),
476
+ layout_index: placement.as_ref().map(|placement| placement.layout_index),
477
+ pane_index: placement.as_ref().map(|placement| placement.pane_index),
478
+ display,
402
479
  });
403
480
  }
404
481
  Ok(started)
405
482
  }
406
483
 
484
+ #[derive(Debug, Clone, PartialEq, Eq)]
485
+ pub(crate) struct LayoutPlacement {
486
+ pub agent_id: AgentId,
487
+ pub layout_window: WindowName,
488
+ pub layout_index: usize,
489
+ pub pane_index: usize,
490
+ pub starts_window: bool,
491
+ }
492
+
493
+ pub(crate) fn adaptive_layout_plan(
494
+ agent_ids: &[AgentId],
495
+ max_per_window: usize,
496
+ ) -> Vec<LayoutPlacement> {
497
+ let max_per_window = max_per_window.max(1);
498
+ agent_ids
499
+ .iter()
500
+ .enumerate()
501
+ .map(|(idx, agent_id)| {
502
+ let layout_index = idx / max_per_window;
503
+ let pane_index = idx % max_per_window;
504
+ LayoutPlacement {
505
+ agent_id: agent_id.clone(),
506
+ layout_window: WindowName::new(format!("team-w{}", layout_index + 1)),
507
+ layout_index,
508
+ pane_index,
509
+ starts_window: pane_index == 0,
510
+ }
511
+ })
512
+ .collect()
513
+ }
514
+
515
+ pub(crate) const ADAPTIVE_LAYOUT_MAX_PER_WINDOW: usize = 3;
516
+
517
+ pub(crate) fn state_uses_adaptive_layout(state: &serde_json::Value) -> bool {
518
+ state
519
+ .get("display_backend")
520
+ .and_then(serde_json::Value::as_str)
521
+ .is_some_and(|backend| backend == "adaptive")
522
+ || state
523
+ .get("runtime")
524
+ .and_then(|runtime| runtime.get("display_backend"))
525
+ .and_then(serde_json::Value::as_str)
526
+ .is_some_and(|backend| backend == "adaptive")
527
+ || state
528
+ .get("agents")
529
+ .and_then(serde_json::Value::as_object)
530
+ .is_some_and(|agents| {
531
+ agents.values().any(|agent| {
532
+ agent
533
+ .get("layout_window")
534
+ .and_then(serde_json::Value::as_str)
535
+ .is_some_and(|window| !window.is_empty())
536
+ })
537
+ })
538
+ }
539
+
540
+ pub(crate) fn adaptive_placement_for_agent(
541
+ state: &serde_json::Value,
542
+ transport: &dyn Transport,
543
+ session_name: &SessionName,
544
+ agent_id: &AgentId,
545
+ ) -> Option<LayoutPlacement> {
546
+ if !state_uses_adaptive_layout(state) {
547
+ return None;
548
+ }
549
+ let live_panes: BTreeSet<String> = transport
550
+ .list_targets()
551
+ .unwrap_or_default()
552
+ .into_iter()
553
+ .map(|pane| pane.pane_id.as_str().to_string())
554
+ .collect();
555
+ let live_windows: BTreeSet<String> = transport
556
+ .list_windows(session_name)
557
+ .unwrap_or_default()
558
+ .into_iter()
559
+ .map(|window| window.as_str().to_string())
560
+ .collect();
561
+ let mut windows: BTreeMap<usize, (String, usize)> = BTreeMap::new();
562
+ if let Some(agents) = state.get("agents").and_then(serde_json::Value::as_object) {
563
+ for (id, agent) in agents {
564
+ if id == agent_id.as_str() {
565
+ continue;
566
+ }
567
+ let Some(window) = agent
568
+ .get("layout_window")
569
+ .or_else(|| agent.get("window"))
570
+ .and_then(serde_json::Value::as_str)
571
+ .filter(|window| !window.is_empty())
572
+ else {
573
+ continue;
574
+ };
575
+ let pane_live = agent
576
+ .get("pane_id")
577
+ .and_then(serde_json::Value::as_str)
578
+ .is_some_and(|pane| live_panes.contains(pane));
579
+ if !pane_live && (!live_panes.is_empty() || !live_windows.contains(window)) {
580
+ continue;
581
+ }
582
+ let layout_index = agent
583
+ .get("layout_index")
584
+ .and_then(serde_json::Value::as_u64)
585
+ .map(|idx| idx as usize)
586
+ .or_else(|| parse_team_layout_index(window))
587
+ .unwrap_or(windows.len());
588
+ let entry = windows
589
+ .entry(layout_index)
590
+ .or_insert_with(|| (window.to_string(), 0));
591
+ entry.1 = entry.1.saturating_add(1);
592
+ }
593
+ }
594
+ if let Some((&layout_index, (window, count))) = windows.iter().next_back() {
595
+ if *count < ADAPTIVE_LAYOUT_MAX_PER_WINDOW {
596
+ return Some(LayoutPlacement {
597
+ agent_id: agent_id.clone(),
598
+ layout_window: WindowName::new(window.clone()),
599
+ layout_index,
600
+ pane_index: *count,
601
+ starts_window: false,
602
+ });
603
+ }
604
+ }
605
+ let next_index = windows.keys().next_back().map(|idx| idx + 1).unwrap_or(0);
606
+ let base = format!("team-w{}", next_index + 1);
607
+ Some(LayoutPlacement {
608
+ agent_id: agent_id.clone(),
609
+ layout_window: unique_layout_window_name(&base, &live_windows),
610
+ layout_index: next_index,
611
+ pane_index: 0,
612
+ starts_window: true,
613
+ })
614
+ }
615
+
616
+ pub(crate) fn adaptive_existing_placement_for_agent(
617
+ state: &serde_json::Value,
618
+ transport: &dyn Transport,
619
+ session_name: &SessionName,
620
+ agent_id: &AgentId,
621
+ ) -> Option<LayoutPlacement> {
622
+ if !state_uses_adaptive_layout(state) {
623
+ return None;
624
+ }
625
+ let agent = state.get("agents")?.get(agent_id.as_str())?;
626
+ let window = agent
627
+ .get("layout_window")
628
+ .or_else(|| agent.get("window"))
629
+ .and_then(serde_json::Value::as_str)
630
+ .filter(|window| !window.is_empty())?;
631
+ let layout_index = agent
632
+ .get("layout_index")
633
+ .and_then(serde_json::Value::as_u64)
634
+ .map(|idx| idx as usize)
635
+ .or_else(|| parse_team_layout_index(window))
636
+ .unwrap_or(0);
637
+ let desired_pane_index = agent
638
+ .get("pane_index")
639
+ .and_then(serde_json::Value::as_u64)
640
+ .map(|idx| idx as usize)
641
+ .unwrap_or(0);
642
+ let live_windows: BTreeSet<String> = transport
643
+ .list_windows(session_name)
644
+ .unwrap_or_default()
645
+ .into_iter()
646
+ .map(|window| window.as_str().to_string())
647
+ .collect();
648
+ if !live_windows.contains(window) {
649
+ return Some(LayoutPlacement {
650
+ agent_id: agent_id.clone(),
651
+ layout_window: WindowName::new(window),
652
+ layout_index,
653
+ pane_index: desired_pane_index,
654
+ starts_window: desired_pane_index == 0,
655
+ });
656
+ }
657
+ let existing_panes = transport
658
+ .list_targets()
659
+ .unwrap_or_default()
660
+ .into_iter()
661
+ .filter(|pane| {
662
+ pane.window_name.as_ref().is_some_and(|name| name.as_str() == window)
663
+ && agent
664
+ .get("pane_id")
665
+ .and_then(serde_json::Value::as_str)
666
+ .is_none_or(|agent_pane| agent_pane != pane.pane_id.as_str())
667
+ })
668
+ .count();
669
+ Some(LayoutPlacement {
670
+ agent_id: agent_id.clone(),
671
+ layout_window: WindowName::new(window),
672
+ layout_index,
673
+ pane_index: existing_panes,
674
+ starts_window: false,
675
+ })
676
+ }
677
+
678
+ fn parse_team_layout_index(window: &str) -> Option<usize> {
679
+ window
680
+ .strip_prefix("team-w")
681
+ .and_then(|rest| rest.split('-').next())
682
+ .and_then(|raw| raw.parse::<usize>().ok())
683
+ .and_then(|idx| idx.checked_sub(1))
684
+ }
685
+
686
+ fn unique_layout_window_name(base: &str, live_windows: &BTreeSet<String>) -> WindowName {
687
+ if !live_windows.contains(base) {
688
+ return WindowName::new(base);
689
+ }
690
+ for suffix in 2.. {
691
+ let candidate = format!("{base}-{suffix}");
692
+ if !live_windows.contains(&candidate) {
693
+ return WindowName::new(candidate);
694
+ }
695
+ }
696
+ unreachable!("unbounded suffix search always returns")
697
+ }
698
+
407
699
  fn persist_spawn_agent_state(
408
700
  workspace: &Path,
409
701
  spec_path: &Path,
@@ -464,7 +756,12 @@ fn persist_spawn_agent_state(
464
756
  agents.insert(id.to_string(), serde_json::Value::Object(paused));
465
757
  continue;
466
758
  }
467
- let window = agent.get("window").and_then(Value::as_str).unwrap_or(id);
759
+ let started_agent = started.iter().find(|agent| agent.agent_id.as_str() == id);
760
+ let window = started_agent
761
+ .and_then(|started| started.layout_window.as_ref())
762
+ .map(WindowName::as_str)
763
+ .or_else(|| agent.get("window").and_then(Value::as_str))
764
+ .unwrap_or(id);
468
765
  if !live_started_agents.contains(id)
469
766
  || (!live_windows.is_empty() && !live_windows.contains(window))
470
767
  {
@@ -483,7 +780,6 @@ fn persist_spawn_agent_state(
483
780
  let pane_pid = pane_pids_by_agent.get(id).copied();
484
781
  let spawned_at = spawn_timestamp_for_agent(spawn_index);
485
782
  spawn_index = spawn_index.saturating_add(1);
486
- let started_agent = started.iter().find(|agent| agent.agent_id.as_str() == id);
487
783
  agents.insert(
488
784
  id.to_string(),
489
785
  running_agent_state(
@@ -562,6 +858,7 @@ fn save_launched_team_state_for_key(
562
858
  );
563
859
  }
564
860
  promote_launched_binding_from_team_entry(&mut launched, &launched_key);
861
+ preserve_existing_leader_topology(&existing, &launched_key, &mut launched);
565
862
  drop_foreign_seeded_owner(&existing, &launched_key, &mut launched);
566
863
  drop_bare_worker_seeded_owner(&mut launched, &launched_key);
567
864
  let merged = if team_key.is_some() {
@@ -575,6 +872,28 @@ fn save_launched_team_state_for_key(
575
872
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))
576
873
  }
577
874
 
875
+ fn preserve_existing_leader_topology(
876
+ existing: &serde_json::Value,
877
+ launched_key: &str,
878
+ launched: &mut serde_json::Value,
879
+ ) {
880
+ let Some(obj) = launched.as_object_mut() else {
881
+ return;
882
+ };
883
+ let existing_team = existing
884
+ .get("teams")
885
+ .and_then(serde_json::Value::as_object)
886
+ .and_then(|teams| teams.get(launched_key))
887
+ .unwrap_or(existing);
888
+ for key in ["is_external_leader", "leader_client"] {
889
+ if !obj.contains_key(key) {
890
+ if let Some(value) = existing_team.get(key).or_else(|| existing.get(key)) {
891
+ obj.insert(key.to_string(), value.clone());
892
+ }
893
+ }
894
+ }
895
+ }
896
+
578
897
  fn drop_bare_worker_seeded_owner(launched: &mut serde_json::Value, launched_key: &str) {
579
898
  if has_positive_caller_leader_env() {
580
899
  return;
@@ -1279,7 +1598,11 @@ fn running_agent_state(
1279
1598
  .get("profile")
1280
1599
  .map(yaml_value_to_json)
1281
1600
  .unwrap_or(serde_json::Value::Null);
1282
- let window = agent.get("window").and_then(Value::as_str).unwrap_or(id);
1601
+ let window = started_agent
1602
+ .and_then(|started| started.layout_window.as_ref())
1603
+ .map(WindowName::as_str)
1604
+ .or_else(|| agent.get("window").and_then(Value::as_str))
1605
+ .unwrap_or(id);
1283
1606
  let mcp_config = crate::provider::get_adapter(provider)
1284
1607
  .mcp_config(auth_mode)
1285
1608
  .map_err(|e| LifecycleError::Provider(e.to_string()))?;
@@ -1325,6 +1648,25 @@ fn running_agent_state(
1325
1648
  );
1326
1649
  if let Some(started_agent) = started_agent {
1327
1650
  persist_started_agent_plan_state(&mut state, started_agent);
1651
+ if let Some(layout_window) = started_agent.layout_window.as_ref() {
1652
+ state.insert(
1653
+ "layout_window".to_string(),
1654
+ serde_json::json!(layout_window.as_str()),
1655
+ );
1656
+ }
1657
+ if let Some(layout_index) = started_agent.layout_index {
1658
+ state.insert("layout_index".to_string(), serde_json::json!(layout_index));
1659
+ }
1660
+ if let Some(pane_index) = started_agent.pane_index {
1661
+ state.insert("pane_index".to_string(), serde_json::json!(pane_index));
1662
+ }
1663
+ if !matches!(started_agent.display, WorkerDisplay::Blocked { .. }) {
1664
+ state.insert(
1665
+ "display".to_string(),
1666
+ serde_json::to_value(&started_agent.display)
1667
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?,
1668
+ );
1669
+ }
1328
1670
  }
1329
1671
  state.insert(
1330
1672
  "spawn_cwd".to_string(),
@@ -2016,6 +2358,17 @@ fn transport_has_session(transport: &dyn Transport, session_name: &SessionName)
2016
2358
  }
2017
2359
  }
2018
2360
 
2361
+ fn spec_display_backend(spec: &Value) -> DisplayBackend {
2362
+ let requested = spec
2363
+ .get("runtime")
2364
+ .and_then(|runtime| runtime.get("display_backend"))
2365
+ .and_then(Value::as_str)
2366
+ .and_then(|backend| {
2367
+ serde_json::from_value::<DisplayBackend>(serde_json::json!(backend)).ok()
2368
+ });
2369
+ crate::lifecycle::display::resolve_display_backend(requested, None).backend
2370
+ }
2371
+
2019
2372
  fn parse_provider(raw: &str) -> Option<Provider> {
2020
2373
  match raw {
2021
2374
  "claude" => Some(Provider::Claude),
@@ -2242,6 +2595,7 @@ pub fn quick_start_in_workspace(
2242
2595
  team_id: Option<&str>,
2243
2596
  ) -> Result<QuickStartReport, LifecycleError> {
2244
2597
  let workspace = explicit_quick_start_workspace(workspace);
2598
+ let transport = quick_start_tmux_backend(&workspace);
2245
2599
  quick_start_with_transport_in_workspace(
2246
2600
  &workspace,
2247
2601
  agents_dir,
@@ -2249,11 +2603,55 @@ pub fn quick_start_in_workspace(
2249
2603
  yes,
2250
2604
  fresh,
2251
2605
  team_id,
2252
- // CP-1: per-team socket bound to the selected run workspace.
2253
- &crate::tmux_backend::TmuxBackend::for_workspace(&workspace),
2606
+ &transport,
2607
+ )
2608
+ }
2609
+
2610
+ pub fn quick_start_in_workspace_with_display(
2611
+ workspace: &Path,
2612
+ agents_dir: &Path,
2613
+ name: Option<&str>,
2614
+ yes: bool,
2615
+ fresh: bool,
2616
+ team_id: Option<&str>,
2617
+ open_display: bool,
2618
+ ) -> Result<QuickStartReport, LifecycleError> {
2619
+ let workspace = explicit_quick_start_workspace(workspace);
2620
+ let transport = quick_start_tmux_backend(&workspace);
2621
+ quick_start_with_transport_in_workspace_with_display(
2622
+ &workspace,
2623
+ agents_dir,
2624
+ name,
2625
+ yes,
2626
+ fresh,
2627
+ team_id,
2628
+ &transport,
2629
+ open_display,
2254
2630
  )
2255
2631
  }
2256
2632
 
2633
+ pub(crate) fn quick_start_tmux_backend(workspace: &Path) -> crate::tmux_backend::TmuxBackend {
2634
+ if let Some(endpoint) = crate::tmux_backend::socket_name_from_tmux_env() {
2635
+ crate::tmux_backend::TmuxBackend::for_tmux_endpoint(&endpoint)
2636
+ } else {
2637
+ crate::tmux_backend::TmuxBackend::for_workspace(workspace)
2638
+ }
2639
+ }
2640
+
2641
+ pub(crate) fn selected_tmux_socket_source(
2642
+ transport: &dyn Transport,
2643
+ workspace: &Path,
2644
+ ) -> Option<&'static str> {
2645
+ let endpoint = transport.tmux_endpoint()?;
2646
+ if crate::tmux_backend::socket_name_from_tmux_env().as_deref() == Some(endpoint.as_str()) {
2647
+ Some("leader_env")
2648
+ } else if endpoint == crate::tmux_backend::socket_name_for_workspace(workspace) {
2649
+ Some("workspace")
2650
+ } else {
2651
+ None
2652
+ }
2653
+ }
2654
+
2257
2655
  fn explicit_quick_start_workspace(workspace: &Path) -> PathBuf {
2258
2656
  std::fs::canonicalize(workspace).unwrap_or_else(|_| {
2259
2657
  if workspace.is_absolute() {
@@ -2290,6 +2688,21 @@ pub fn quick_start_with_transport_in_workspace(
2290
2688
  fresh: bool,
2291
2689
  team_id: Option<&str>,
2292
2690
  transport: &dyn Transport,
2691
+ ) -> Result<QuickStartReport, LifecycleError> {
2692
+ quick_start_with_transport_in_workspace_with_display(
2693
+ workspace, agents_dir, name, yes, fresh, team_id, transport, true,
2694
+ )
2695
+ }
2696
+
2697
+ pub fn quick_start_with_transport_in_workspace_with_display(
2698
+ workspace: &Path,
2699
+ agents_dir: &Path,
2700
+ name: Option<&str>,
2701
+ yes: bool,
2702
+ fresh: bool,
2703
+ team_id: Option<&str>,
2704
+ transport: &dyn Transport,
2705
+ open_display: bool,
2293
2706
  ) -> Result<QuickStartReport, LifecycleError> {
2294
2707
  // B-7 / 036b N38 三行 fail-fast — TEAM_AGENT_LEADER_PANE_ID 主动路径在 quick-start
2295
2708
  // 入口验活;死/缺(Dead)的 pane 必须明确报错,不可 silent bind 到 spawner /
@@ -2308,6 +2721,9 @@ pub fn quick_start_with_transport_in_workspace(
2308
2721
  let workspace = workspace.to_path_buf();
2309
2722
  let mut spec = crate::compiler::compile_team(agents_dir)
2310
2723
  .map_err(|e| LifecycleError::Compile(e.to_string()))?;
2724
+ if !open_display {
2725
+ override_spec_display_backend(&mut spec, "none");
2726
+ }
2311
2727
  let requested_team = quick_start_requested_team_key(team_id, name)
2312
2728
  .map(str::to_string)
2313
2729
  .or_else(|| spec_team_id(&spec));
@@ -2343,7 +2759,13 @@ pub fn quick_start_with_transport_in_workspace(
2343
2759
  .as_ref()
2344
2760
  .map(|session| {
2345
2761
  let windows = quick_start_attach_window_names(&state);
2346
- crate::tmux_backend::attach_commands_for_windows(
2762
+ attach_commands_for_runtime_windows(
2763
+ state
2764
+ .get("tmux_endpoint")
2765
+ .and_then(serde_json::Value::as_str)
2766
+ .or_else(|| {
2767
+ state.get("tmux_socket").and_then(serde_json::Value::as_str)
2768
+ }),
2347
2769
  &workspace,
2348
2770
  session,
2349
2771
  windows.iter().map(String::as_str),
@@ -2394,7 +2816,8 @@ pub fn quick_start_with_transport_in_workspace(
2394
2816
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
2395
2817
  let resolved_spec_path =
2396
2818
  std::fs::canonicalize(&spec_path).unwrap_or_else(|_| spec_path.clone());
2397
- let state = initial_runtime_state(&spec, &resolved_spec_path, &workspace, agents_dir);
2819
+ let mut state = initial_runtime_state(&spec, &resolved_spec_path, &workspace, agents_dir);
2820
+ annotate_runtime_tmux_endpoint(&mut state, transport, &workspace);
2398
2821
  save_launched_team_state_for_key(&workspace, &state, Some(&state_team_key))?;
2399
2822
  annotate_persisted_team_depth(
2400
2823
  &workspace,
@@ -2434,13 +2857,17 @@ pub fn quick_start_with_transport_in_workspace(
2434
2857
  // asynchronously after spawn), so the verdict is PendingToolLoad — never
2435
2858
  // bare Ready.
2436
2859
  let worker_readiness = quick_start_worker_readiness(&workspace, &state_team_key);
2437
- let attach_commands = crate::tmux_backend::attach_commands_for_windows(
2860
+ let attach_windows = load_runtime_state(&workspace)
2861
+ .ok()
2862
+ .map(|state| {
2863
+ attach_window_names_with_managed_leader(&state, started_attach_window_names(&launch.started))
2864
+ })
2865
+ .unwrap_or_else(|| started_attach_window_names(&launch.started));
2866
+ let attach_commands = attach_commands_for_runtime_windows(
2867
+ launch.tmux_endpoint.as_deref(),
2438
2868
  &workspace,
2439
2869
  &session_name,
2440
- launch
2441
- .started
2442
- .iter()
2443
- .map(|started| started.agent_id.as_str()),
2870
+ attach_windows.iter().map(String::as_str),
2444
2871
  );
2445
2872
  let mut next_actions = vec![format!(
2446
2873
  "team compiled; real spawn is behind the transport/provider boundary; {coordinator_action}"
@@ -2464,8 +2891,104 @@ pub fn quick_start_with_transport_in_workspace(
2464
2891
  })
2465
2892
  }
2466
2893
 
2894
+ fn annotate_runtime_tmux_endpoint(state: &mut serde_json::Value, transport: &dyn Transport, workspace: &Path) {
2895
+ let Some(endpoint) = transport.tmux_endpoint() else {
2896
+ return;
2897
+ };
2898
+ if let Some(obj) = state.as_object_mut() {
2899
+ obj.insert("tmux_endpoint".to_string(), serde_json::json!(endpoint));
2900
+ obj.insert(
2901
+ "tmux_socket".to_string(),
2902
+ obj.get("tmux_endpoint")
2903
+ .cloned()
2904
+ .unwrap_or(serde_json::Value::Null),
2905
+ );
2906
+ if let Some(source) = selected_tmux_socket_source(transport, workspace) {
2907
+ obj.insert("tmux_socket_source".to_string(), serde_json::json!(source));
2908
+ }
2909
+ }
2910
+ }
2911
+
2912
+ fn attach_commands_for_runtime_windows<'a>(
2913
+ endpoint: Option<&str>,
2914
+ workspace: &Path,
2915
+ session_name: &SessionName,
2916
+ window_names: impl IntoIterator<Item = &'a str>,
2917
+ ) -> Vec<String> {
2918
+ let windows = window_names.into_iter().collect::<Vec<_>>();
2919
+ let attach = if let Some(endpoint) = endpoint.filter(|endpoint| !endpoint.is_empty()) {
2920
+ if Path::new(endpoint).is_absolute() {
2921
+ windows
2922
+ .iter()
2923
+ .map(|window_name| {
2924
+ format!(
2925
+ "tmux -S {} attach -t {}:{}",
2926
+ endpoint,
2927
+ session_name.as_str(),
2928
+ window_name
2929
+ )
2930
+ })
2931
+ .collect::<Vec<_>>()
2932
+ } else {
2933
+ crate::tmux_backend::attach_commands_for_windows(
2934
+ workspace,
2935
+ session_name,
2936
+ windows.iter().copied(),
2937
+ )
2938
+ }
2939
+ } else {
2940
+ crate::tmux_backend::attach_commands_for_windows(
2941
+ workspace,
2942
+ session_name,
2943
+ windows.iter().copied(),
2944
+ )
2945
+ };
2946
+ attach
2947
+ }
2948
+
2949
+ fn started_attach_window_names(started: &[StartedAgent]) -> Vec<String> {
2950
+ let mut windows = started
2951
+ .iter()
2952
+ .map(|started| {
2953
+ started
2954
+ .layout_window
2955
+ .as_ref()
2956
+ .map(|window| window.as_str().to_string())
2957
+ .unwrap_or_else(|| started.agent_id.as_str().to_string())
2958
+ })
2959
+ .collect::<Vec<_>>();
2960
+ windows.sort();
2961
+ windows.dedup();
2962
+ windows
2963
+ }
2964
+
2965
+ pub(crate) fn attach_window_names_for_state_agents<'a>(
2966
+ state: &serde_json::Value,
2967
+ agent_ids: impl IntoIterator<Item = &'a str>,
2968
+ ) -> Vec<String> {
2969
+ let windows = agent_ids
2970
+ .into_iter()
2971
+ .map(|agent_id| {
2972
+ state
2973
+ .get("agents")
2974
+ .and_then(serde_json::Value::as_object)
2975
+ .and_then(|agents| agents.get(agent_id))
2976
+ .and_then(|agent| {
2977
+ agent
2978
+ .get("layout_window")
2979
+ .or_else(|| agent.get("window"))
2980
+ .and_then(serde_json::Value::as_str)
2981
+ .filter(|window| !window.is_empty())
2982
+ })
2983
+ .unwrap_or(agent_id)
2984
+ .to_string()
2985
+ })
2986
+ .collect::<Vec<_>>();
2987
+ attach_window_names_with_managed_leader(state, windows)
2988
+ }
2989
+
2467
2990
  fn quick_start_attach_window_names(state: &serde_json::Value) -> Vec<String> {
2468
- let mut windows = state
2991
+ let windows = state
2469
2992
  .get("agents")
2470
2993
  .and_then(serde_json::Value::as_object)
2471
2994
  .map(|agents| {
@@ -2482,11 +3005,28 @@ fn quick_start_attach_window_names(state: &serde_json::Value) -> Vec<String> {
2482
3005
  .collect::<Vec<_>>()
2483
3006
  })
2484
3007
  .unwrap_or_default();
3008
+ attach_window_names_with_managed_leader(state, windows)
3009
+ }
3010
+
3011
+ fn attach_window_names_with_managed_leader(
3012
+ state: &serde_json::Value,
3013
+ mut windows: Vec<String>,
3014
+ ) -> Vec<String> {
3015
+ if state_uses_managed_leader(state) {
3016
+ windows.push("leader".to_string());
3017
+ }
2485
3018
  windows.sort();
2486
3019
  windows.dedup();
2487
3020
  windows
2488
3021
  }
2489
3022
 
3023
+ fn state_uses_managed_leader(state: &serde_json::Value) -> bool {
3024
+ state
3025
+ .get("is_external_leader")
3026
+ .and_then(serde_json::Value::as_bool)
3027
+ .is_some_and(|external| !external)
3028
+ }
3029
+
2490
3030
  /// BUG-7 helper: derive a [`QuickStartReadiness`] verdict from the just-written
2491
3031
  /// runtime state. Reads `agents[*].status`; any non-`running` agent flips the
2492
3032
  /// verdict to `Degraded { unhealthy_agents }` (sorted, deduped); otherwise
@@ -2870,7 +3410,7 @@ fn add_agent_with_transport_at_paths(
2870
3410
  role_file_path.display()
2871
3411
  )));
2872
3412
  }
2873
- if agent_id_exists_in_team_dir(team_dir, agent_id) {
3413
+ if runtime_agent_exists(&owner_state, agent_id) {
2874
3414
  return Err(LifecycleError::RequirementUnmet(format!(
2875
3415
  "agent id already exists: {agent_id}"
2876
3416
  )));
@@ -3027,29 +3567,74 @@ fn inject_agent_into_spec(
3027
3567
  };
3028
3568
  // agents 列表追加。
3029
3569
  match pairs.iter_mut().find(|(k, _)| k == "agents") {
3030
- Some((_, Value::List(agents))) => agents.push(agent),
3570
+ Some((_, Value::List(agents))) => {
3571
+ if !agents
3572
+ .iter()
3573
+ .any(|existing| yaml_agent_id(existing) == Some(agent_id))
3574
+ {
3575
+ agents.push(agent);
3576
+ }
3577
+ }
3031
3578
  _ => return Err(LifecycleError::Compile("spec.agents missing or not a list".to_string())),
3032
3579
  }
3033
3580
  // routing.rules 追加 route-<id>(与 compile_team 同形)。
3034
3581
  if let Some((_, Value::Map(routing))) = pairs.iter_mut().find(|(k, _)| k == "routing") {
3035
3582
  if let Some((_, Value::List(rules))) = routing.iter_mut().find(|(k, _)| k == "rules") {
3036
- rules.push(Value::Map(vec![
3037
- ("id".to_string(), Value::Str(format!("route-{agent_id}"))),
3038
- (
3039
- "match".to_string(),
3040
- Value::Map(vec![(
3041
- "assignee".to_string(),
3042
- Value::List(vec![Value::Str(agent_id.to_string())]),
3043
- )]),
3044
- ),
3045
- ("assign_to".to_string(), Value::Str(agent_id.to_string())),
3046
- ("priority".to_string(), Value::Int(10)),
3047
- ]));
3583
+ if !rules
3584
+ .iter()
3585
+ .any(|rule| yaml_route_assigns_to(rule) == Some(agent_id))
3586
+ {
3587
+ rules.push(Value::Map(vec![
3588
+ ("id".to_string(), Value::Str(format!("route-{agent_id}"))),
3589
+ (
3590
+ "match".to_string(),
3591
+ Value::Map(vec![(
3592
+ "assignee".to_string(),
3593
+ Value::List(vec![Value::Str(agent_id.to_string())]),
3594
+ )]),
3595
+ ),
3596
+ ("assign_to".to_string(), Value::Str(agent_id.to_string())),
3597
+ ("priority".to_string(), Value::Int(10)),
3598
+ ]));
3599
+ }
3048
3600
  }
3049
3601
  }
3050
3602
  Ok(())
3051
3603
  }
3052
3604
 
3605
+ fn runtime_agent_exists(state: &serde_json::Value, agent_id: &AgentId) -> bool {
3606
+ state
3607
+ .get("agents")
3608
+ .and_then(serde_json::Value::as_object)
3609
+ .is_some_and(|agents| agents.contains_key(agent_id.as_str()))
3610
+ }
3611
+
3612
+ fn yaml_agent_id(agent: &Value) -> Option<&str> {
3613
+ let Value::Map(pairs) = agent else {
3614
+ return None;
3615
+ };
3616
+ pairs
3617
+ .iter()
3618
+ .find(|(key, _)| key == "id")
3619
+ .and_then(|(_, value)| match value {
3620
+ Value::Str(id) => Some(id.as_str()),
3621
+ _ => None,
3622
+ })
3623
+ }
3624
+
3625
+ fn yaml_route_assigns_to(rule: &Value) -> Option<&str> {
3626
+ let Value::Map(pairs) = rule else {
3627
+ return None;
3628
+ };
3629
+ pairs
3630
+ .iter()
3631
+ .find(|(key, _)| key == "assign_to")
3632
+ .and_then(|(_, value)| match value {
3633
+ Value::Str(id) => Some(id.as_str()),
3634
+ _ => None,
3635
+ })
3636
+ }
3637
+
3053
3638
  /// `fork_agent(workspace, source_agent_id, as_agent_id, ...)`(`lifecycle/operations.py:284`)。
3054
3639
  /// native session fork(provider 须 supports_session_fork ∧ auth_mode!=compatible_api);
3055
3640
  /// 失败回滚,每条失败臂 `adapter.cleanup_mcp`。
@@ -3751,15 +4336,7 @@ fn initial_runtime_state(
3751
4336
  }
3752
4337
  agents.insert(id.to_string(), value);
3753
4338
  }
3754
- let requested_display = spec
3755
- .get("runtime")
3756
- .and_then(|runtime| runtime.get("display_backend"))
3757
- .and_then(Value::as_str)
3758
- .and_then(|backend| {
3759
- serde_json::from_value::<DisplayBackend>(serde_json::json!(backend)).ok()
3760
- });
3761
- let display_backend =
3762
- crate::lifecycle::display::resolve_display_backend(requested_display, None).backend;
4339
+ let display_backend = spec_display_backend(spec);
3763
4340
  let mut state = serde_json::Map::new();
3764
4341
  state.insert(
3765
4342
  "spec_path".to_string(),
@@ -3927,6 +4504,14 @@ pub(crate) fn write_spec_atomic(spec_path: &Path, spec: &Value) -> Result<(), Li
3927
4504
  }
3928
4505
 
3929
4506
  pub(crate) fn override_spec_session_name(spec: &mut Value, session_name: &str) {
4507
+ override_spec_runtime_str(spec, "session_name", session_name);
4508
+ }
4509
+
4510
+ fn override_spec_display_backend(spec: &mut Value, display_backend: &str) {
4511
+ override_spec_runtime_str(spec, "display_backend", display_backend);
4512
+ }
4513
+
4514
+ fn override_spec_runtime_str(spec: &mut Value, key: &str, value: &str) {
3930
4515
  let Value::Map(root) = spec else { return };
3931
4516
  let runtime_slot = root
3932
4517
  .iter_mut()
@@ -3934,28 +4519,19 @@ pub(crate) fn override_spec_session_name(spec: &mut Value, session_name: &str) {
3934
4519
  .map(|(_, v)| v);
3935
4520
  match runtime_slot {
3936
4521
  Some(Value::Map(runtime)) => {
3937
- if let Some((_, existing)) = runtime.iter_mut().find(|(k, _)| k == "session_name") {
3938
- *existing = Value::Str(session_name.to_string());
4522
+ if let Some((_, existing)) = runtime.iter_mut().find(|(k, _)| k == key) {
4523
+ *existing = Value::Str(value.to_string());
3939
4524
  } else {
3940
- runtime.push((
3941
- "session_name".to_string(),
3942
- Value::Str(session_name.to_string()),
3943
- ));
4525
+ runtime.push((key.to_string(), Value::Str(value.to_string())));
3944
4526
  }
3945
4527
  }
3946
4528
  Some(other) => {
3947
- *other = Value::Map(vec![(
3948
- "session_name".to_string(),
3949
- Value::Str(session_name.to_string()),
3950
- )]);
4529
+ *other = Value::Map(vec![(key.to_string(), Value::Str(value.to_string()))]);
3951
4530
  }
3952
4531
  None => {
3953
4532
  root.push((
3954
4533
  "runtime".to_string(),
3955
- Value::Map(vec![(
3956
- "session_name".to_string(),
3957
- Value::Str(session_name.to_string()),
3958
- )]),
4534
+ Value::Map(vec![(key.to_string(), Value::Str(value.to_string()))]),
3959
4535
  ));
3960
4536
  }
3961
4537
  }
@@ -4104,20 +4680,5 @@ fn team_workspace(team_dir: &Path) -> PathBuf {
4104
4680
  })
4105
4681
  }
4106
4682
 
4107
- fn agent_id_exists_in_team_dir(team_dir: &Path, agent_id: &AgentId) -> bool {
4108
- let spec_path = team_dir.join("team.spec.yaml");
4109
- if let Ok(text) = std::fs::read_to_string(&spec_path) {
4110
- if let Ok(spec) = yaml::loads(&text) {
4111
- return spec_agents(&spec)
4112
- .into_iter()
4113
- .any(|existing| existing.as_str() == agent_id.as_str());
4114
- }
4115
- }
4116
- team_dir
4117
- .join("agents")
4118
- .join(format!("{}.md", agent_id.as_str()))
4119
- .exists()
4120
- }
4121
-
4122
4683
  mod plan;
4123
4684
  pub use plan::{handle_report_result, start_plan};