@team-agent/installer 0.3.14 → 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 (36) 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 +60 -10
  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/lifecycle/display.rs +3 -3
  14. package/crates/team-agent/src/lifecycle/launch.rs +560 -69
  15. package/crates/team-agent/src/lifecycle/restart/agent.rs +137 -17
  16. package/crates/team-agent/src/lifecycle/restart/common.rs +38 -2
  17. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +72 -1
  18. package/crates/team-agent/src/lifecycle/tests/core.rs +14 -5
  19. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +668 -8
  20. package/crates/team-agent/src/lifecycle/types.rs +5 -0
  21. package/crates/team-agent/src/lifecycle/worker_command_context.rs +8 -0
  22. package/crates/team-agent/src/mcp_server/wire.rs +153 -3
  23. package/crates/team-agent/src/messaging/leader_receiver.rs +276 -43
  24. package/crates/team-agent/src/messaging/mod.rs +6 -3
  25. package/crates/team-agent/src/messaging/results.rs +240 -158
  26. package/crates/team-agent/src/messaging/send.rs +3 -2
  27. package/crates/team-agent/src/messaging/tests/e23.rs +35 -0
  28. package/crates/team-agent/src/messaging/tests/mod.rs +6 -2
  29. package/crates/team-agent/src/messaging/tests/runtime.rs +9 -9
  30. package/crates/team-agent/src/os_probe.rs +11 -0
  31. package/crates/team-agent/src/state/persist.rs +6 -0
  32. package/crates/team-agent/src/tmux_backend.rs +85 -0
  33. package/crates/team-agent/src/transport/test_support.rs +46 -1
  34. package/crates/team-agent/src/transport.rs +27 -0
  35. package/package.json +4 -4
  36. 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(
@@ -1279,7 +1575,11 @@ fn running_agent_state(
1279
1575
  .get("profile")
1280
1576
  .map(yaml_value_to_json)
1281
1577
  .unwrap_or(serde_json::Value::Null);
1282
- 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);
1283
1583
  let mcp_config = crate::provider::get_adapter(provider)
1284
1584
  .mcp_config(auth_mode)
1285
1585
  .map_err(|e| LifecycleError::Provider(e.to_string()))?;
@@ -1325,6 +1625,25 @@ fn running_agent_state(
1325
1625
  );
1326
1626
  if let Some(started_agent) = started_agent {
1327
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
+ }
1328
1647
  }
1329
1648
  state.insert(
1330
1649
  "spawn_cwd".to_string(),
@@ -2016,6 +2335,17 @@ fn transport_has_session(transport: &dyn Transport, session_name: &SessionName)
2016
2335
  }
2017
2336
  }
2018
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
+
2019
2349
  fn parse_provider(raw: &str) -> Option<Provider> {
2020
2350
  match raw {
2021
2351
  "claude" => Some(Provider::Claude),
@@ -2242,6 +2572,7 @@ pub fn quick_start_in_workspace(
2242
2572
  team_id: Option<&str>,
2243
2573
  ) -> Result<QuickStartReport, LifecycleError> {
2244
2574
  let workspace = explicit_quick_start_workspace(workspace);
2575
+ let transport = quick_start_tmux_backend(&workspace);
2245
2576
  quick_start_with_transport_in_workspace(
2246
2577
  &workspace,
2247
2578
  agents_dir,
@@ -2249,11 +2580,55 @@ pub fn quick_start_in_workspace(
2249
2580
  yes,
2250
2581
  fresh,
2251
2582
  team_id,
2252
- // CP-1: per-team socket bound to the selected run workspace.
2253
- &crate::tmux_backend::TmuxBackend::for_workspace(&workspace),
2583
+ &transport,
2584
+ )
2585
+ }
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,
2254
2607
  )
2255
2608
  }
2256
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
+
2257
2632
  fn explicit_quick_start_workspace(workspace: &Path) -> PathBuf {
2258
2633
  std::fs::canonicalize(workspace).unwrap_or_else(|_| {
2259
2634
  if workspace.is_absolute() {
@@ -2290,6 +2665,21 @@ pub fn quick_start_with_transport_in_workspace(
2290
2665
  fresh: bool,
2291
2666
  team_id: Option<&str>,
2292
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,
2293
2683
  ) -> Result<QuickStartReport, LifecycleError> {
2294
2684
  // B-7 / 036b N38 三行 fail-fast — TEAM_AGENT_LEADER_PANE_ID 主动路径在 quick-start
2295
2685
  // 入口验活;死/缺(Dead)的 pane 必须明确报错,不可 silent bind 到 spawner /
@@ -2308,6 +2698,9 @@ pub fn quick_start_with_transport_in_workspace(
2308
2698
  let workspace = workspace.to_path_buf();
2309
2699
  let mut spec = crate::compiler::compile_team(agents_dir)
2310
2700
  .map_err(|e| LifecycleError::Compile(e.to_string()))?;
2701
+ if !open_display {
2702
+ override_spec_display_backend(&mut spec, "none");
2703
+ }
2311
2704
  let requested_team = quick_start_requested_team_key(team_id, name)
2312
2705
  .map(str::to_string)
2313
2706
  .or_else(|| spec_team_id(&spec));
@@ -2343,7 +2736,13 @@ pub fn quick_start_with_transport_in_workspace(
2343
2736
  .as_ref()
2344
2737
  .map(|session| {
2345
2738
  let windows = quick_start_attach_window_names(&state);
2346
- 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
+ }),
2347
2746
  &workspace,
2348
2747
  session,
2349
2748
  windows.iter().map(String::as_str),
@@ -2394,7 +2793,8 @@ pub fn quick_start_with_transport_in_workspace(
2394
2793
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
2395
2794
  let resolved_spec_path =
2396
2795
  std::fs::canonicalize(&spec_path).unwrap_or_else(|_| spec_path.clone());
2397
- 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);
2398
2798
  save_launched_team_state_for_key(&workspace, &state, Some(&state_team_key))?;
2399
2799
  annotate_persisted_team_depth(
2400
2800
  &workspace,
@@ -2434,13 +2834,12 @@ pub fn quick_start_with_transport_in_workspace(
2434
2834
  // asynchronously after spawn), so the verdict is PendingToolLoad — never
2435
2835
  // bare Ready.
2436
2836
  let worker_readiness = quick_start_worker_readiness(&workspace, &state_team_key);
2437
- 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(),
2438
2840
  &workspace,
2439
2841
  &session_name,
2440
- launch
2441
- .started
2442
- .iter()
2443
- .map(|started| started.agent_id.as_str()),
2842
+ attach_windows.iter().map(String::as_str),
2444
2843
  );
2445
2844
  let mut next_actions = vec![format!(
2446
2845
  "team compiled; real spawn is behind the transport/provider boundary; {coordinator_action}"
@@ -2464,6 +2863,77 @@ pub fn quick_start_with_transport_in_workspace(
2464
2863
  })
2465
2864
  }
2466
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
+
2467
2937
  fn quick_start_attach_window_names(state: &serde_json::Value) -> Vec<String> {
2468
2938
  let mut windows = state
2469
2939
  .get("agents")
@@ -2870,7 +3340,7 @@ fn add_agent_with_transport_at_paths(
2870
3340
  role_file_path.display()
2871
3341
  )));
2872
3342
  }
2873
- if agent_id_exists_in_team_dir(team_dir, agent_id) {
3343
+ if runtime_agent_exists(&owner_state, agent_id) {
2874
3344
  return Err(LifecycleError::RequirementUnmet(format!(
2875
3345
  "agent id already exists: {agent_id}"
2876
3346
  )));
@@ -3027,29 +3497,74 @@ fn inject_agent_into_spec(
3027
3497
  };
3028
3498
  // agents 列表追加。
3029
3499
  match pairs.iter_mut().find(|(k, _)| k == "agents") {
3030
- 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
+ }
3031
3508
  _ => return Err(LifecycleError::Compile("spec.agents missing or not a list".to_string())),
3032
3509
  }
3033
3510
  // routing.rules 追加 route-<id>(与 compile_team 同形)。
3034
3511
  if let Some((_, Value::Map(routing))) = pairs.iter_mut().find(|(k, _)| k == "routing") {
3035
3512
  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
- ]));
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
+ }
3048
3530
  }
3049
3531
  }
3050
3532
  Ok(())
3051
3533
  }
3052
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
+
3053
3568
  /// `fork_agent(workspace, source_agent_id, as_agent_id, ...)`(`lifecycle/operations.py:284`)。
3054
3569
  /// native session fork(provider 须 supports_session_fork ∧ auth_mode!=compatible_api);
3055
3570
  /// 失败回滚,每条失败臂 `adapter.cleanup_mcp`。
@@ -3751,15 +4266,7 @@ fn initial_runtime_state(
3751
4266
  }
3752
4267
  agents.insert(id.to_string(), value);
3753
4268
  }
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;
4269
+ let display_backend = spec_display_backend(spec);
3763
4270
  let mut state = serde_json::Map::new();
3764
4271
  state.insert(
3765
4272
  "spec_path".to_string(),
@@ -3927,6 +4434,14 @@ pub(crate) fn write_spec_atomic(spec_path: &Path, spec: &Value) -> Result<(), Li
3927
4434
  }
3928
4435
 
3929
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) {
3930
4445
  let Value::Map(root) = spec else { return };
3931
4446
  let runtime_slot = root
3932
4447
  .iter_mut()
@@ -3934,28 +4449,19 @@ pub(crate) fn override_spec_session_name(spec: &mut Value, session_name: &str) {
3934
4449
  .map(|(_, v)| v);
3935
4450
  match runtime_slot {
3936
4451
  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());
4452
+ if let Some((_, existing)) = runtime.iter_mut().find(|(k, _)| k == key) {
4453
+ *existing = Value::Str(value.to_string());
3939
4454
  } else {
3940
- runtime.push((
3941
- "session_name".to_string(),
3942
- Value::Str(session_name.to_string()),
3943
- ));
4455
+ runtime.push((key.to_string(), Value::Str(value.to_string())));
3944
4456
  }
3945
4457
  }
3946
4458
  Some(other) => {
3947
- *other = Value::Map(vec![(
3948
- "session_name".to_string(),
3949
- Value::Str(session_name.to_string()),
3950
- )]);
4459
+ *other = Value::Map(vec![(key.to_string(), Value::Str(value.to_string()))]);
3951
4460
  }
3952
4461
  None => {
3953
4462
  root.push((
3954
4463
  "runtime".to_string(),
3955
- Value::Map(vec![(
3956
- "session_name".to_string(),
3957
- Value::Str(session_name.to_string()),
3958
- )]),
4464
+ Value::Map(vec![(key.to_string(), Value::Str(value.to_string()))]),
3959
4465
  ));
3960
4466
  }
3961
4467
  }
@@ -4104,20 +4610,5 @@ fn team_workspace(team_dir: &Path) -> PathBuf {
4104
4610
  })
4105
4611
  }
4106
4612
 
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
4613
  mod plan;
4123
4614
  pub use plan::{handle_report_result, start_plan};