@team-agent/installer 0.3.13 → 0.3.15

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 (38) 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/mod.rs +228 -85
  6. package/crates/team-agent/src/cli/send.rs +252 -0
  7. package/crates/team-agent/src/cli/tests/run_delegation.rs +15 -3
  8. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +159 -2
  9. package/crates/team-agent/src/cli/types.rs +30 -1
  10. package/crates/team-agent/src/compiler/tests.rs +2 -2
  11. package/crates/team-agent/src/compiler.rs +1 -1
  12. package/crates/team-agent/src/fake_worker.rs +32 -145
  13. package/crates/team-agent/src/leader/start.rs +279 -4
  14. package/crates/team-agent/src/lifecycle/display.rs +3 -3
  15. package/crates/team-agent/src/lifecycle/launch.rs +692 -92
  16. package/crates/team-agent/src/lifecycle/restart/agent.rs +137 -17
  17. package/crates/team-agent/src/lifecycle/restart/common.rs +88 -42
  18. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +197 -33
  19. package/crates/team-agent/src/lifecycle/restart/selection.rs +9 -6
  20. package/crates/team-agent/src/lifecycle/tests/core.rs +195 -50
  21. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +934 -72
  22. package/crates/team-agent/src/lifecycle/types.rs +5 -0
  23. package/crates/team-agent/src/lifecycle/worker_command_context.rs +8 -0
  24. package/crates/team-agent/src/mcp_server/wire.rs +153 -3
  25. package/crates/team-agent/src/messaging/leader_receiver.rs +276 -43
  26. package/crates/team-agent/src/messaging/mod.rs +6 -3
  27. package/crates/team-agent/src/messaging/results.rs +240 -158
  28. package/crates/team-agent/src/messaging/send.rs +3 -2
  29. package/crates/team-agent/src/messaging/tests/e23.rs +35 -0
  30. package/crates/team-agent/src/messaging/tests/mod.rs +6 -2
  31. package/crates/team-agent/src/messaging/tests/runtime.rs +9 -9
  32. package/crates/team-agent/src/os_probe.rs +11 -0
  33. package/crates/team-agent/src/state/persist.rs +6 -0
  34. package/crates/team-agent/src/tmux_backend.rs +90 -0
  35. package/crates/team-agent/src/transport/test_support.rs +46 -1
  36. package/crates/team-agent/src/transport.rs +31 -0
  37. package/package.json +4 -4
  38. 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(
@@ -1059,23 +1355,13 @@ fn unbound_launched_provider(launched: &serde_json::Value) -> Option<String> {
1059
1355
  {
1060
1356
  return Some(provider);
1061
1357
  }
1062
- let workspace = launched
1063
- .get("workspace")
1064
- .and_then(serde_json::Value::as_str)
1065
- .filter(|workspace| !workspace.is_empty())?;
1066
1358
  let pane = launched
1067
1359
  .get("team_owner")
1068
1360
  .and_then(|owner| owner.get("pane_id"))
1069
1361
  .and_then(serde_json::Value::as_str)
1070
1362
  .filter(|pane| !pane.is_empty())?;
1071
1363
  let target = PaneId::new(pane);
1072
- crate::tmux_backend::TmuxBackend::for_workspace(Path::new(workspace))
1073
- .list_targets()
1074
- .ok()?
1075
- .into_iter()
1076
- .find(|info| info.pane_id == target)
1077
- .and_then(|info| crate::leader::attribute_pane_provider(&info))
1078
- .and_then(provider_wire_string)
1364
+ attributed_provider_for_pane_across_tmux_sockets(&target).and_then(provider_wire_string)
1079
1365
  }
1080
1366
 
1081
1367
  fn provider_wire_string(provider: Provider) -> Option<String> {
@@ -1084,9 +1370,38 @@ fn provider_wire_string(provider: Provider) -> Option<String> {
1084
1370
  .and_then(|value| value.as_str().map(str::to_string))
1085
1371
  }
1086
1372
 
1373
+ fn attributed_provider_for_pane_across_tmux_sockets(pane: &PaneId) -> Option<Provider> {
1374
+ crate::tmux_backend::tmux_socket_endpoints()
1375
+ .into_iter()
1376
+ .filter_map(|endpoint| {
1377
+ crate::tmux_backend::TmuxBackend::for_tmux_endpoint(&endpoint)
1378
+ .list_targets()
1379
+ .ok()
1380
+ })
1381
+ .flatten()
1382
+ .find(|info| info.pane_id == *pane)
1383
+ .and_then(|info| crate::leader::attribute_pane_provider(&info))
1384
+ }
1385
+
1386
+ fn caller_provider_for_seed_with_lookup(
1387
+ caller: &crate::state::owner_gate::CallerIdentity,
1388
+ lookup_pane_provider: impl Fn(&PaneId) -> Option<Provider>,
1389
+ ) -> Option<String> {
1390
+ if !caller.provider.is_empty() {
1391
+ if let Some(provider) = parse_provider(&caller.provider).and_then(provider_wire_string) {
1392
+ return Some(provider);
1393
+ }
1394
+ }
1395
+ (!caller.pane_id.is_empty())
1396
+ .then(|| PaneId::new(&caller.pane_id))
1397
+ .and_then(|pane| lookup_pane_provider(&pane))
1398
+ .and_then(provider_wire_string)
1399
+ }
1400
+
1087
1401
  #[cfg(test)]
1088
1402
  mod e22_unbound_owner_provider_tests {
1089
1403
  use super::*;
1404
+ use crate::state::owner_gate::CallerIdentity;
1090
1405
 
1091
1406
  #[test]
1092
1407
  fn unbound_owner_preserves_explicit_copilot_provider() {
@@ -1137,6 +1452,83 @@ mod e22_unbound_owner_provider_tests {
1137
1452
  "unattributed unbound owner must not silently become codex: {launched}"
1138
1453
  );
1139
1454
  }
1455
+
1456
+ fn caller(provider: &str, pane_id: &str) -> CallerIdentity {
1457
+ CallerIdentity {
1458
+ pane_id: pane_id.to_string(),
1459
+ provider: provider.to_string(),
1460
+ machine_fingerprint: "machine".to_string(),
1461
+ leader_session_uuid: "leader-uuid".to_string(),
1462
+ leader_session_uuid_source: "derived".to_string(),
1463
+ }
1464
+ }
1465
+
1466
+ #[test]
1467
+ fn env_seed_attributes_in_tmux_node_form_copilot_from_caller_pane() {
1468
+ let mut state = serde_json::json!({
1469
+ "workspace": "/tmp/team-agent-e22",
1470
+ "leader": {"provider": "copilot"},
1471
+ });
1472
+
1473
+ assert!(seed_launched_owner_from_caller_with_provider_lookup(
1474
+ &mut state,
1475
+ caller("", "%0"),
1476
+ |pane| (pane.as_str() == "%0").then_some(Provider::Copilot),
1477
+ ));
1478
+
1479
+ assert_eq!(
1480
+ state
1481
+ .pointer("/team_owner/provider")
1482
+ .and_then(serde_json::Value::as_str),
1483
+ Some("copilot")
1484
+ );
1485
+ assert_eq!(
1486
+ state
1487
+ .pointer("/leader_receiver/provider")
1488
+ .and_then(serde_json::Value::as_str),
1489
+ Some("copilot")
1490
+ );
1491
+ }
1492
+
1493
+ #[test]
1494
+ fn env_seed_unknown_caller_pane_does_not_default_codex() {
1495
+ let mut state = serde_json::json!({
1496
+ "workspace": "/tmp/team-agent-e22",
1497
+ "leader": {"provider": "copilot"},
1498
+ });
1499
+
1500
+ assert!(seed_launched_owner_from_caller_with_provider_lookup(
1501
+ &mut state,
1502
+ caller("", "%0"),
1503
+ |_| None,
1504
+ ));
1505
+ assert_eq!(
1506
+ state
1507
+ .pointer("/team_owner/pane_id")
1508
+ .and_then(serde_json::Value::as_str),
1509
+ Some("%0")
1510
+ );
1511
+ assert_eq!(
1512
+ state
1513
+ .pointer("/leader_receiver/pane_id")
1514
+ .and_then(serde_json::Value::as_str),
1515
+ Some("%0")
1516
+ );
1517
+ assert!(
1518
+ state
1519
+ .pointer("/leader_receiver/provider")
1520
+ .and_then(serde_json::Value::as_str)
1521
+ != Some("codex"),
1522
+ "unknown caller pane must not silently seed a codex receiver: {state}"
1523
+ );
1524
+ assert!(
1525
+ state
1526
+ .pointer("/team_owner/provider")
1527
+ .and_then(serde_json::Value::as_str)
1528
+ != Some("codex"),
1529
+ "unknown caller pane must not silently become codex: {state}"
1530
+ );
1531
+ }
1140
1532
  }
1141
1533
 
1142
1534
  fn owner_pane_belongs_to_other_team(
@@ -1183,7 +1575,11 @@ fn running_agent_state(
1183
1575
  .get("profile")
1184
1576
  .map(yaml_value_to_json)
1185
1577
  .unwrap_or(serde_json::Value::Null);
1186
- let window = agent.get("window").and_then(Value::as_str).unwrap_or(id);
1578
+ let window = started_agent
1579
+ .and_then(|started| started.layout_window.as_ref())
1580
+ .map(WindowName::as_str)
1581
+ .or_else(|| agent.get("window").and_then(Value::as_str))
1582
+ .unwrap_or(id);
1187
1583
  let mcp_config = crate::provider::get_adapter(provider)
1188
1584
  .mcp_config(auth_mode)
1189
1585
  .map_err(|e| LifecycleError::Provider(e.to_string()))?;
@@ -1229,6 +1625,25 @@ fn running_agent_state(
1229
1625
  );
1230
1626
  if let Some(started_agent) = started_agent {
1231
1627
  persist_started_agent_plan_state(&mut state, started_agent);
1628
+ if let Some(layout_window) = started_agent.layout_window.as_ref() {
1629
+ state.insert(
1630
+ "layout_window".to_string(),
1631
+ serde_json::json!(layout_window.as_str()),
1632
+ );
1633
+ }
1634
+ if let Some(layout_index) = started_agent.layout_index {
1635
+ state.insert("layout_index".to_string(), serde_json::json!(layout_index));
1636
+ }
1637
+ if let Some(pane_index) = started_agent.pane_index {
1638
+ state.insert("pane_index".to_string(), serde_json::json!(pane_index));
1639
+ }
1640
+ if !matches!(started_agent.display, WorkerDisplay::Blocked { .. }) {
1641
+ state.insert(
1642
+ "display".to_string(),
1643
+ serde_json::to_value(&started_agent.display)
1644
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?,
1645
+ );
1646
+ }
1232
1647
  }
1233
1648
  state.insert(
1234
1649
  "spawn_cwd".to_string(),
@@ -1920,6 +2335,17 @@ fn transport_has_session(transport: &dyn Transport, session_name: &SessionName)
1920
2335
  }
1921
2336
  }
1922
2337
 
2338
+ fn spec_display_backend(spec: &Value) -> DisplayBackend {
2339
+ let requested = spec
2340
+ .get("runtime")
2341
+ .and_then(|runtime| runtime.get("display_backend"))
2342
+ .and_then(Value::as_str)
2343
+ .and_then(|backend| {
2344
+ serde_json::from_value::<DisplayBackend>(serde_json::json!(backend)).ok()
2345
+ });
2346
+ crate::lifecycle::display::resolve_display_backend(requested, None).backend
2347
+ }
2348
+
1923
2349
  fn parse_provider(raw: &str) -> Option<Provider> {
1924
2350
  match raw {
1925
2351
  "claude" => Some(Provider::Claude),
@@ -2146,6 +2572,7 @@ pub fn quick_start_in_workspace(
2146
2572
  team_id: Option<&str>,
2147
2573
  ) -> Result<QuickStartReport, LifecycleError> {
2148
2574
  let workspace = explicit_quick_start_workspace(workspace);
2575
+ let transport = quick_start_tmux_backend(&workspace);
2149
2576
  quick_start_with_transport_in_workspace(
2150
2577
  &workspace,
2151
2578
  agents_dir,
@@ -2153,11 +2580,55 @@ pub fn quick_start_in_workspace(
2153
2580
  yes,
2154
2581
  fresh,
2155
2582
  team_id,
2156
- // CP-1: per-team socket bound to the selected run workspace.
2157
- &crate::tmux_backend::TmuxBackend::for_workspace(&workspace),
2583
+ &transport,
2158
2584
  )
2159
2585
  }
2160
2586
 
2587
+ pub fn quick_start_in_workspace_with_display(
2588
+ workspace: &Path,
2589
+ agents_dir: &Path,
2590
+ name: Option<&str>,
2591
+ yes: bool,
2592
+ fresh: bool,
2593
+ team_id: Option<&str>,
2594
+ open_display: bool,
2595
+ ) -> Result<QuickStartReport, LifecycleError> {
2596
+ let workspace = explicit_quick_start_workspace(workspace);
2597
+ let transport = quick_start_tmux_backend(&workspace);
2598
+ quick_start_with_transport_in_workspace_with_display(
2599
+ &workspace,
2600
+ agents_dir,
2601
+ name,
2602
+ yes,
2603
+ fresh,
2604
+ team_id,
2605
+ &transport,
2606
+ open_display,
2607
+ )
2608
+ }
2609
+
2610
+ pub(crate) fn quick_start_tmux_backend(workspace: &Path) -> crate::tmux_backend::TmuxBackend {
2611
+ if let Some(endpoint) = crate::tmux_backend::socket_name_from_tmux_env() {
2612
+ crate::tmux_backend::TmuxBackend::for_tmux_endpoint(&endpoint)
2613
+ } else {
2614
+ crate::tmux_backend::TmuxBackend::for_workspace(workspace)
2615
+ }
2616
+ }
2617
+
2618
+ pub(crate) fn selected_tmux_socket_source(
2619
+ transport: &dyn Transport,
2620
+ workspace: &Path,
2621
+ ) -> Option<&'static str> {
2622
+ let endpoint = transport.tmux_endpoint()?;
2623
+ if crate::tmux_backend::socket_name_from_tmux_env().as_deref() == Some(endpoint.as_str()) {
2624
+ Some("leader_env")
2625
+ } else if endpoint == crate::tmux_backend::socket_name_for_workspace(workspace) {
2626
+ Some("workspace")
2627
+ } else {
2628
+ None
2629
+ }
2630
+ }
2631
+
2161
2632
  fn explicit_quick_start_workspace(workspace: &Path) -> PathBuf {
2162
2633
  std::fs::canonicalize(workspace).unwrap_or_else(|_| {
2163
2634
  if workspace.is_absolute() {
@@ -2194,6 +2665,21 @@ pub fn quick_start_with_transport_in_workspace(
2194
2665
  fresh: bool,
2195
2666
  team_id: Option<&str>,
2196
2667
  transport: &dyn Transport,
2668
+ ) -> Result<QuickStartReport, LifecycleError> {
2669
+ quick_start_with_transport_in_workspace_with_display(
2670
+ workspace, agents_dir, name, yes, fresh, team_id, transport, true,
2671
+ )
2672
+ }
2673
+
2674
+ pub fn quick_start_with_transport_in_workspace_with_display(
2675
+ workspace: &Path,
2676
+ agents_dir: &Path,
2677
+ name: Option<&str>,
2678
+ yes: bool,
2679
+ fresh: bool,
2680
+ team_id: Option<&str>,
2681
+ transport: &dyn Transport,
2682
+ open_display: bool,
2197
2683
  ) -> Result<QuickStartReport, LifecycleError> {
2198
2684
  // B-7 / 036b N38 三行 fail-fast — TEAM_AGENT_LEADER_PANE_ID 主动路径在 quick-start
2199
2685
  // 入口验活;死/缺(Dead)的 pane 必须明确报错,不可 silent bind 到 spawner /
@@ -2212,6 +2698,9 @@ pub fn quick_start_with_transport_in_workspace(
2212
2698
  let workspace = workspace.to_path_buf();
2213
2699
  let mut spec = crate::compiler::compile_team(agents_dir)
2214
2700
  .map_err(|e| LifecycleError::Compile(e.to_string()))?;
2701
+ if !open_display {
2702
+ override_spec_display_backend(&mut spec, "none");
2703
+ }
2215
2704
  let requested_team = quick_start_requested_team_key(team_id, name)
2216
2705
  .map(str::to_string)
2217
2706
  .or_else(|| spec_team_id(&spec));
@@ -2247,7 +2736,13 @@ pub fn quick_start_with_transport_in_workspace(
2247
2736
  .as_ref()
2248
2737
  .map(|session| {
2249
2738
  let windows = quick_start_attach_window_names(&state);
2250
- crate::tmux_backend::attach_commands_for_windows(
2739
+ attach_commands_for_runtime_windows(
2740
+ state
2741
+ .get("tmux_endpoint")
2742
+ .and_then(serde_json::Value::as_str)
2743
+ .or_else(|| {
2744
+ state.get("tmux_socket").and_then(serde_json::Value::as_str)
2745
+ }),
2251
2746
  &workspace,
2252
2747
  session,
2253
2748
  windows.iter().map(String::as_str),
@@ -2298,7 +2793,8 @@ pub fn quick_start_with_transport_in_workspace(
2298
2793
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
2299
2794
  let resolved_spec_path =
2300
2795
  std::fs::canonicalize(&spec_path).unwrap_or_else(|_| spec_path.clone());
2301
- let state = initial_runtime_state(&spec, &resolved_spec_path, &workspace, agents_dir);
2796
+ let mut state = initial_runtime_state(&spec, &resolved_spec_path, &workspace, agents_dir);
2797
+ annotate_runtime_tmux_endpoint(&mut state, transport, &workspace);
2302
2798
  save_launched_team_state_for_key(&workspace, &state, Some(&state_team_key))?;
2303
2799
  annotate_persisted_team_depth(
2304
2800
  &workspace,
@@ -2338,13 +2834,12 @@ pub fn quick_start_with_transport_in_workspace(
2338
2834
  // asynchronously after spawn), so the verdict is PendingToolLoad — never
2339
2835
  // bare Ready.
2340
2836
  let worker_readiness = quick_start_worker_readiness(&workspace, &state_team_key);
2341
- let attach_commands = crate::tmux_backend::attach_commands_for_windows(
2837
+ let attach_windows = started_attach_window_names(&launch.started);
2838
+ let attach_commands = attach_commands_for_runtime_windows(
2839
+ launch.tmux_endpoint.as_deref(),
2342
2840
  &workspace,
2343
2841
  &session_name,
2344
- launch
2345
- .started
2346
- .iter()
2347
- .map(|started| started.agent_id.as_str()),
2842
+ attach_windows.iter().map(String::as_str),
2348
2843
  );
2349
2844
  let mut next_actions = vec![format!(
2350
2845
  "team compiled; real spawn is behind the transport/provider boundary; {coordinator_action}"
@@ -2368,6 +2863,77 @@ pub fn quick_start_with_transport_in_workspace(
2368
2863
  })
2369
2864
  }
2370
2865
 
2866
+ fn annotate_runtime_tmux_endpoint(state: &mut serde_json::Value, transport: &dyn Transport, workspace: &Path) {
2867
+ let Some(endpoint) = transport.tmux_endpoint() else {
2868
+ return;
2869
+ };
2870
+ if let Some(obj) = state.as_object_mut() {
2871
+ obj.insert("tmux_endpoint".to_string(), serde_json::json!(endpoint));
2872
+ obj.insert(
2873
+ "tmux_socket".to_string(),
2874
+ obj.get("tmux_endpoint")
2875
+ .cloned()
2876
+ .unwrap_or(serde_json::Value::Null),
2877
+ );
2878
+ if let Some(source) = selected_tmux_socket_source(transport, workspace) {
2879
+ obj.insert("tmux_socket_source".to_string(), serde_json::json!(source));
2880
+ }
2881
+ }
2882
+ }
2883
+
2884
+ fn attach_commands_for_runtime_windows<'a>(
2885
+ endpoint: Option<&str>,
2886
+ workspace: &Path,
2887
+ session_name: &SessionName,
2888
+ window_names: impl IntoIterator<Item = &'a str>,
2889
+ ) -> Vec<String> {
2890
+ let windows = window_names.into_iter().collect::<Vec<_>>();
2891
+ let attach = if let Some(endpoint) = endpoint.filter(|endpoint| !endpoint.is_empty()) {
2892
+ if Path::new(endpoint).is_absolute() {
2893
+ windows
2894
+ .iter()
2895
+ .map(|window_name| {
2896
+ format!(
2897
+ "tmux -S {} attach -t {}:{}",
2898
+ endpoint,
2899
+ session_name.as_str(),
2900
+ window_name
2901
+ )
2902
+ })
2903
+ .collect::<Vec<_>>()
2904
+ } else {
2905
+ crate::tmux_backend::attach_commands_for_windows(
2906
+ workspace,
2907
+ session_name,
2908
+ windows.iter().copied(),
2909
+ )
2910
+ }
2911
+ } else {
2912
+ crate::tmux_backend::attach_commands_for_windows(
2913
+ workspace,
2914
+ session_name,
2915
+ windows.iter().copied(),
2916
+ )
2917
+ };
2918
+ attach
2919
+ }
2920
+
2921
+ fn started_attach_window_names(started: &[StartedAgent]) -> Vec<String> {
2922
+ let mut windows = started
2923
+ .iter()
2924
+ .map(|started| {
2925
+ started
2926
+ .layout_window
2927
+ .as_ref()
2928
+ .map(|window| window.as_str().to_string())
2929
+ .unwrap_or_else(|| started.agent_id.as_str().to_string())
2930
+ })
2931
+ .collect::<Vec<_>>();
2932
+ windows.sort();
2933
+ windows.dedup();
2934
+ windows
2935
+ }
2936
+
2371
2937
  fn quick_start_attach_window_names(state: &serde_json::Value) -> Vec<String> {
2372
2938
  let mut windows = state
2373
2939
  .get("agents")
@@ -2774,7 +3340,7 @@ fn add_agent_with_transport_at_paths(
2774
3340
  role_file_path.display()
2775
3341
  )));
2776
3342
  }
2777
- if agent_id_exists_in_team_dir(team_dir, agent_id) {
3343
+ if runtime_agent_exists(&owner_state, agent_id) {
2778
3344
  return Err(LifecycleError::RequirementUnmet(format!(
2779
3345
  "agent id already exists: {agent_id}"
2780
3346
  )));
@@ -2931,29 +3497,74 @@ fn inject_agent_into_spec(
2931
3497
  };
2932
3498
  // agents 列表追加。
2933
3499
  match pairs.iter_mut().find(|(k, _)| k == "agents") {
2934
- Some((_, Value::List(agents))) => agents.push(agent),
3500
+ Some((_, Value::List(agents))) => {
3501
+ if !agents
3502
+ .iter()
3503
+ .any(|existing| yaml_agent_id(existing) == Some(agent_id))
3504
+ {
3505
+ agents.push(agent);
3506
+ }
3507
+ }
2935
3508
  _ => return Err(LifecycleError::Compile("spec.agents missing or not a list".to_string())),
2936
3509
  }
2937
3510
  // routing.rules 追加 route-<id>(与 compile_team 同形)。
2938
3511
  if let Some((_, Value::Map(routing))) = pairs.iter_mut().find(|(k, _)| k == "routing") {
2939
3512
  if let Some((_, Value::List(rules))) = routing.iter_mut().find(|(k, _)| k == "rules") {
2940
- rules.push(Value::Map(vec![
2941
- ("id".to_string(), Value::Str(format!("route-{agent_id}"))),
2942
- (
2943
- "match".to_string(),
2944
- Value::Map(vec![(
2945
- "assignee".to_string(),
2946
- Value::List(vec![Value::Str(agent_id.to_string())]),
2947
- )]),
2948
- ),
2949
- ("assign_to".to_string(), Value::Str(agent_id.to_string())),
2950
- ("priority".to_string(), Value::Int(10)),
2951
- ]));
3513
+ if !rules
3514
+ .iter()
3515
+ .any(|rule| yaml_route_assigns_to(rule) == Some(agent_id))
3516
+ {
3517
+ rules.push(Value::Map(vec![
3518
+ ("id".to_string(), Value::Str(format!("route-{agent_id}"))),
3519
+ (
3520
+ "match".to_string(),
3521
+ Value::Map(vec![(
3522
+ "assignee".to_string(),
3523
+ Value::List(vec![Value::Str(agent_id.to_string())]),
3524
+ )]),
3525
+ ),
3526
+ ("assign_to".to_string(), Value::Str(agent_id.to_string())),
3527
+ ("priority".to_string(), Value::Int(10)),
3528
+ ]));
3529
+ }
2952
3530
  }
2953
3531
  }
2954
3532
  Ok(())
2955
3533
  }
2956
3534
 
3535
+ fn runtime_agent_exists(state: &serde_json::Value, agent_id: &AgentId) -> bool {
3536
+ state
3537
+ .get("agents")
3538
+ .and_then(serde_json::Value::as_object)
3539
+ .is_some_and(|agents| agents.contains_key(agent_id.as_str()))
3540
+ }
3541
+
3542
+ fn yaml_agent_id(agent: &Value) -> Option<&str> {
3543
+ let Value::Map(pairs) = agent else {
3544
+ return None;
3545
+ };
3546
+ pairs
3547
+ .iter()
3548
+ .find(|(key, _)| key == "id")
3549
+ .and_then(|(_, value)| match value {
3550
+ Value::Str(id) => Some(id.as_str()),
3551
+ _ => None,
3552
+ })
3553
+ }
3554
+
3555
+ fn yaml_route_assigns_to(rule: &Value) -> Option<&str> {
3556
+ let Value::Map(pairs) = rule else {
3557
+ return None;
3558
+ };
3559
+ pairs
3560
+ .iter()
3561
+ .find(|(key, _)| key == "assign_to")
3562
+ .and_then(|(_, value)| match value {
3563
+ Value::Str(id) => Some(id.as_str()),
3564
+ _ => None,
3565
+ })
3566
+ }
3567
+
2957
3568
  /// `fork_agent(workspace, source_agent_id, as_agent_id, ...)`(`lifecycle/operations.py:284`)。
2958
3569
  /// native session fork(provider 须 supports_session_fork ∧ auth_mode!=compatible_api);
2959
3570
  /// 失败回滚,每条失败臂 `adapter.cleanup_mcp`。
@@ -3655,15 +4266,7 @@ fn initial_runtime_state(
3655
4266
  }
3656
4267
  agents.insert(id.to_string(), value);
3657
4268
  }
3658
- let requested_display = spec
3659
- .get("runtime")
3660
- .and_then(|runtime| runtime.get("display_backend"))
3661
- .and_then(Value::as_str)
3662
- .and_then(|backend| {
3663
- serde_json::from_value::<DisplayBackend>(serde_json::json!(backend)).ok()
3664
- });
3665
- let display_backend =
3666
- crate::lifecycle::display::resolve_display_backend(requested_display, None).backend;
4269
+ let display_backend = spec_display_backend(spec);
3667
4270
  let mut state = serde_json::Map::new();
3668
4271
  state.insert(
3669
4272
  "spec_path".to_string(),
@@ -3711,19 +4314,26 @@ fn seed_launched_owner_from_env(state: &mut serde_json::Value) -> bool {
3711
4314
  ) else {
3712
4315
  return false;
3713
4316
  };
3714
- let provider = if caller.provider.is_empty() {
3715
- "codex".to_string()
3716
- } else {
3717
- caller.provider
3718
- };
3719
- let pane_id = caller.pane_id;
3720
- if pane_id.is_empty() {
4317
+ seed_launched_owner_from_caller_with_provider_lookup(
4318
+ state,
4319
+ caller,
4320
+ attributed_provider_for_pane_across_tmux_sockets,
4321
+ )
4322
+ }
4323
+
4324
+ fn seed_launched_owner_from_caller_with_provider_lookup(
4325
+ state: &mut serde_json::Value,
4326
+ caller: crate::state::owner_gate::CallerIdentity,
4327
+ lookup_pane_provider: impl Fn(&PaneId) -> Option<Provider>,
4328
+ ) -> bool {
4329
+ if caller.pane_id.is_empty() {
3721
4330
  return false;
3722
4331
  }
4332
+ let provider = caller_provider_for_seed_with_lookup(&caller, lookup_pane_provider);
4333
+ let pane_id = caller.pane_id;
3723
4334
  let owner_epoch = 1u64;
3724
- let owner = serde_json::json!({
4335
+ let mut owner = serde_json::json!({
3725
4336
  "pane_id": pane_id,
3726
- "provider": provider.clone(),
3727
4337
  "machine_fingerprint": caller.machine_fingerprint,
3728
4338
  "leader_session_uuid": caller.leader_session_uuid,
3729
4339
  "owner_epoch": owner_epoch,
@@ -3733,17 +4343,23 @@ fn seed_launched_owner_from_env(state: &mut serde_json::Value) -> bool {
3733
4343
  .or_else(|_| std::env::var("USERNAME"))
3734
4344
  .unwrap_or_default(),
3735
4345
  });
3736
- let receiver = serde_json::json!({
4346
+ let mut receiver = serde_json::json!({
3737
4347
  "mode": "direct_tmux",
3738
4348
  "status": "attached",
3739
- "provider": provider,
3740
4349
  "pane_id": owner.get("pane_id").cloned().unwrap_or(serde_json::Value::Null),
3741
4350
  "pane": owner.get("pane_id").cloned().unwrap_or(serde_json::Value::Null),
3742
4351
  "leader_session_uuid": owner.get("leader_session_uuid").cloned().unwrap_or(serde_json::Value::Null),
3743
4352
  "owner_epoch": owner_epoch,
3744
4353
  "discovery": "quick_start",
3745
4354
  });
3746
- let mut receiver = receiver;
4355
+ if let Some(provider) = provider.as_ref() {
4356
+ if let Some(owner) = owner.as_object_mut() {
4357
+ owner.insert("provider".to_string(), serde_json::json!(provider));
4358
+ }
4359
+ if let Some(receiver) = receiver.as_object_mut() {
4360
+ receiver.insert("provider".to_string(), serde_json::json!(provider));
4361
+ }
4362
+ }
3747
4363
  if let (Some(receiver), Some(socket)) = (
3748
4364
  receiver.as_object_mut(),
3749
4365
  crate::tmux_backend::socket_name_from_tmux_env(),
@@ -3818,6 +4434,14 @@ pub(crate) fn write_spec_atomic(spec_path: &Path, spec: &Value) -> Result<(), Li
3818
4434
  }
3819
4435
 
3820
4436
  pub(crate) fn override_spec_session_name(spec: &mut Value, session_name: &str) {
4437
+ override_spec_runtime_str(spec, "session_name", session_name);
4438
+ }
4439
+
4440
+ fn override_spec_display_backend(spec: &mut Value, display_backend: &str) {
4441
+ override_spec_runtime_str(spec, "display_backend", display_backend);
4442
+ }
4443
+
4444
+ fn override_spec_runtime_str(spec: &mut Value, key: &str, value: &str) {
3821
4445
  let Value::Map(root) = spec else { return };
3822
4446
  let runtime_slot = root
3823
4447
  .iter_mut()
@@ -3825,28 +4449,19 @@ pub(crate) fn override_spec_session_name(spec: &mut Value, session_name: &str) {
3825
4449
  .map(|(_, v)| v);
3826
4450
  match runtime_slot {
3827
4451
  Some(Value::Map(runtime)) => {
3828
- if let Some((_, existing)) = runtime.iter_mut().find(|(k, _)| k == "session_name") {
3829
- *existing = Value::Str(session_name.to_string());
4452
+ if let Some((_, existing)) = runtime.iter_mut().find(|(k, _)| k == key) {
4453
+ *existing = Value::Str(value.to_string());
3830
4454
  } else {
3831
- runtime.push((
3832
- "session_name".to_string(),
3833
- Value::Str(session_name.to_string()),
3834
- ));
4455
+ runtime.push((key.to_string(), Value::Str(value.to_string())));
3835
4456
  }
3836
4457
  }
3837
4458
  Some(other) => {
3838
- *other = Value::Map(vec![(
3839
- "session_name".to_string(),
3840
- Value::Str(session_name.to_string()),
3841
- )]);
4459
+ *other = Value::Map(vec![(key.to_string(), Value::Str(value.to_string()))]);
3842
4460
  }
3843
4461
  None => {
3844
4462
  root.push((
3845
4463
  "runtime".to_string(),
3846
- Value::Map(vec![(
3847
- "session_name".to_string(),
3848
- Value::Str(session_name.to_string()),
3849
- )]),
4464
+ Value::Map(vec![(key.to_string(), Value::Str(value.to_string()))]),
3850
4465
  ));
3851
4466
  }
3852
4467
  }
@@ -3995,20 +4610,5 @@ fn team_workspace(team_dir: &Path) -> PathBuf {
3995
4610
  })
3996
4611
  }
3997
4612
 
3998
- fn agent_id_exists_in_team_dir(team_dir: &Path, agent_id: &AgentId) -> bool {
3999
- let spec_path = team_dir.join("team.spec.yaml");
4000
- if let Ok(text) = std::fs::read_to_string(&spec_path) {
4001
- if let Ok(spec) = yaml::loads(&text) {
4002
- return spec_agents(&spec)
4003
- .into_iter()
4004
- .any(|existing| existing.as_str() == agent_id.as_str());
4005
- }
4006
- }
4007
- team_dir
4008
- .join("agents")
4009
- .join(format!("{}.md", agent_id.as_str()))
4010
- .exists()
4011
- }
4012
-
4013
4613
  mod plan;
4014
4614
  pub use plan::{handle_report_result, start_plan};