@team-agent/installer 0.5.65 → 0.5.68

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 (208) hide show
  1. package/Cargo.lock +8 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/Cargo.toml +6 -0
  4. package/crates/team-agent/src/cli/adapters.rs +6 -1
  5. package/crates/team-agent/src/cli/diagnose.rs +82 -0
  6. package/crates/team-agent/src/cli/emit.rs +3 -3
  7. package/crates/team-agent/src/cli/grok_slot.rs +299 -0
  8. package/crates/team-agent/src/cli/leader.rs +21 -7
  9. package/crates/team-agent/src/cli/leaders.rs +125 -1
  10. package/crates/team-agent/src/cli/mod.rs +20 -0
  11. package/crates/team-agent/src/cli/send/presentation.rs +7 -1
  12. package/crates/team-agent/src/cli/spec.rs +2 -0
  13. package/crates/team-agent/src/cli/status_port/compact.rs +28 -0
  14. package/crates/team-agent/src/cli/status_port/snapshot.rs +25 -1
  15. package/crates/team-agent/src/cli/tests/base.rs +26 -3
  16. package/crates/team-agent/src/cli/tests/compile.rs +1 -1
  17. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +129 -19
  18. package/crates/team-agent/src/cli/tests/mod.rs +2 -2
  19. package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -1
  20. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +387 -6
  21. package/crates/team-agent/src/cli/tests/status_send.rs +79 -10
  22. package/crates/team-agent/src/cli/tests/verb_validate.rs +1 -1
  23. package/crates/team-agent/src/communication_mode/mod.rs +6 -4
  24. package/crates/team-agent/src/compiler/tests.rs +23 -20
  25. package/crates/team-agent/src/compiler.rs +84 -5
  26. package/crates/team-agent/src/conpty/backend.rs +16 -0
  27. package/crates/team-agent/src/coordinator/backoff.rs +55 -0
  28. package/crates/team-agent/src/coordinator/conpty_shim.rs +82 -0
  29. package/crates/team-agent/src/coordinator/health.rs +173 -0
  30. package/crates/team-agent/src/coordinator/mod.rs +31 -0
  31. package/crates/team-agent/src/coordinator/orphan.rs +31 -0
  32. package/crates/team-agent/src/coordinator/runtime_detectors.rs +30 -0
  33. package/crates/team-agent/src/coordinator/runtime_observation.rs +25 -0
  34. package/crates/team-agent/src/coordinator/steps/abnormal.rs +59 -0
  35. package/crates/team-agent/src/coordinator/steps/delivery.rs +10 -0
  36. package/crates/team-agent/src/coordinator/steps/health_sync.rs +10 -0
  37. package/crates/team-agent/src/coordinator/steps/mod.rs +22 -0
  38. package/crates/team-agent/src/coordinator/steps/persist.rs +10 -0
  39. package/crates/team-agent/src/coordinator/steps/runtime_prompts.rs +10 -0
  40. package/crates/team-agent/src/coordinator/steps/session_gate.rs +10 -0
  41. package/crates/team-agent/src/coordinator/tick.rs +133 -22
  42. package/crates/team-agent/src/coordinator/types.rs +49 -0
  43. package/crates/team-agent/src/db/message_store.rs +39 -5
  44. package/crates/team-agent/src/layout/worker_env.rs +20 -3
  45. package/crates/team-agent/src/layout/worker_window_helpers.rs +2 -0
  46. package/crates/team-agent/src/leader/provider_attribution.rs +53 -0
  47. package/crates/team-agent/src/leader/registry.rs +65 -0
  48. package/crates/team-agent/src/leader/start.rs +1080 -58
  49. package/crates/team-agent/src/leader/tests/team_in_team_state_scope_red.rs +1 -1
  50. package/crates/team-agent/src/lifecycle/display.rs +56 -0
  51. package/crates/team-agent/src/lifecycle/helpers.rs +57 -0
  52. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +332 -14
  53. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +69 -2
  54. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +196 -2
  55. package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +101 -11
  56. package/crates/team-agent/src/lifecycle/launch/cursor_create_chat.rs +229 -0
  57. package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +332 -0
  58. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +204 -457
  59. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +24 -0
  60. package/crates/team-agent/src/lifecycle/launch/grok_per_seat.rs +260 -0
  61. package/crates/team-agent/src/lifecycle/launch/identity.rs +146 -0
  62. package/crates/team-agent/src/lifecycle/launch/layout.rs +60 -0
  63. package/crates/team-agent/src/lifecycle/launch/leader_context.rs +112 -0
  64. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +530 -0
  65. package/crates/team-agent/src/lifecycle/launch/ownership.rs +40 -0
  66. package/crates/team-agent/src/lifecycle/launch/plan.rs +31 -0
  67. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +66 -0
  68. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +78 -0
  69. package/crates/team-agent/src/lifecycle/launch/readiness.rs +85 -38
  70. package/crates/team-agent/src/lifecycle/launch/role_source.rs +44 -44
  71. package/crates/team-agent/src/lifecycle/launch/spawn.rs +101 -4
  72. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +244 -48
  73. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +109 -2
  74. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +214 -1
  75. package/crates/team-agent/src/lifecycle/launch.rs +209 -43
  76. package/crates/team-agent/src/lifecycle/lock.rs +42 -1
  77. package/crates/team-agent/src/lifecycle/mod.rs +11 -0
  78. package/crates/team-agent/src/lifecycle/pane_input_lock.rs +161 -0
  79. package/crates/team-agent/src/lifecycle/profile_launch.rs +98 -0
  80. package/crates/team-agent/src/lifecycle/profile_smoke.rs +51 -1
  81. package/crates/team-agent/src/lifecycle/restart/agent.rs +213 -3
  82. package/crates/team-agent/src/lifecycle/restart/common.rs +373 -18
  83. package/crates/team-agent/src/lifecycle/restart/orchestrator.rs +27 -0
  84. package/crates/team-agent/src/lifecycle/restart/preflight.rs +25 -0
  85. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +165 -15
  86. package/crates/team-agent/src/lifecycle/restart/remove.rs +196 -5
  87. package/crates/team-agent/src/lifecycle/restart/selection.rs +72 -0
  88. package/crates/team-agent/src/lifecycle/restart/team_state.rs +22 -0
  89. package/crates/team-agent/src/lifecycle/restart.rs +29 -0
  90. package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +1 -1
  91. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +3 -4
  92. package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +1 -1
  93. package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +2 -2
  94. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +4 -4
  95. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +309 -0
  96. package/crates/team-agent/src/lifecycle/tests/clone_fork_copilot_perms_red.rs +159 -174
  97. package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +2 -2
  98. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +15 -13
  99. package/crates/team-agent/src/lifecycle/tests/core.rs +4 -18
  100. package/crates/team-agent/src/lifecycle/tests/core_034_real_red.rs +1 -1
  101. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +287 -0
  102. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +229 -0
  103. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +353 -0
  104. package/crates/team-agent/src/lifecycle/tests/display_adaptive_red.rs +1 -1
  105. package/crates/team-agent/src/lifecycle/tests/f032_startup_prompt_best_effort_red.rs +1 -1
  106. package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +784 -0
  107. package/crates/team-agent/src/lifecycle/tests/gate_fixtures.rs +562 -0
  108. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +239 -0
  109. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +498 -0
  110. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +250 -0
  111. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +293 -0
  112. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +3 -0
  113. package/crates/team-agent/src/lifecycle/tests/host_cotenant_death_p0_contract.rs +3 -3
  114. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +65 -40
  115. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +20 -32
  116. package/crates/team-agent/src/lifecycle/tests/lifecycle_rollback_red.rs +17 -9
  117. package/crates/team-agent/src/lifecycle/tests/mcp_tool_name_format_red.rs +69 -0
  118. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +24 -23
  119. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +98 -62
  120. package/crates/team-agent/src/lifecycle/tests/quick_start_worker_readiness_red.rs +2 -2
  121. package/crates/team-agent/src/lifecycle/tests/restart_build_before_destroy_0540_contract.rs +1 -1
  122. package/crates/team-agent/src/lifecycle/tests/restart_liveness_red.rs +1 -1
  123. package/crates/team-agent/src/lifecycle/tests/restart_rebind_hotfix_252_red.rs +3 -1
  124. package/crates/team-agent/src/lifecycle/tests/restart_session_capture_red.rs +2 -2
  125. package/crates/team-agent/src/lifecycle/tests/resume_recover_red.rs +1 -1
  126. package/crates/team-agent/src/lifecycle/tests/stale_team_saveconflict_contract.rs +1 -1
  127. package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +41 -8
  128. package/crates/team-agent/src/lifecycle/tests/status_credential_redaction_contract.rs +3 -3
  129. package/crates/team-agent/src/lifecycle/tests/subprep_codex_trust_roundtrip_red.rs +1 -1
  130. package/crates/team-agent/src/lifecycle/tests/swallow_batch4_semantics_red.rs +1 -1
  131. package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +1 -1
  132. package/crates/team-agent/src/lifecycle/tests/team_in_team_sibling_quick_start_red.rs +1 -1
  133. package/crates/team-agent/src/lifecycle/tests/team_in_team_state_scope_red.rs +1 -1
  134. package/crates/team-agent/src/lifecycle/tests/team_key_retirement_txn_red.rs +1 -1
  135. package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +60 -2
  136. package/crates/team-agent/src/lifecycle/tests/upgrade_compat_0211_red.rs +1 -1
  137. package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +2 -2
  138. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +69 -26
  139. package/crates/team-agent/src/lifecycle/tests.rs +23 -15
  140. package/crates/team-agent/src/lifecycle/types.rs +61 -2
  141. package/crates/team-agent/src/lifecycle/worker_command_context.rs +200 -33
  142. package/crates/team-agent/src/mcp_server/tests/scoped.rs +100 -1
  143. package/crates/team-agent/src/mcp_server/tests.rs +196 -2
  144. package/crates/team-agent/src/messaging/delivery.rs +405 -40
  145. package/crates/team-agent/src/messaging/helpers.rs +2 -0
  146. package/crates/team-agent/src/messaging/leader_receiver.rs +53 -1
  147. package/crates/team-agent/src/messaging/results.rs +4 -0
  148. package/crates/team-agent/src/messaging/send.rs +34 -0
  149. package/crates/team-agent/src/messaging/tests/dup_inject.rs +406 -0
  150. package/crates/team-agent/src/messaging/tests/e23.rs +9 -0
  151. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +66 -0
  152. package/crates/team-agent/src/messaging/tests/main_preserved.rs +1 -1
  153. package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
  154. package/crates/team-agent/src/messaging/types.rs +4 -1
  155. package/crates/team-agent/src/model/enums.rs +14 -5
  156. package/crates/team-agent/src/model/permissions.rs +3 -0
  157. package/crates/team-agent/src/model/spec.rs +129 -6
  158. package/crates/team-agent/src/model/testdata/spec_invalid_a.yaml +3 -1
  159. package/crates/team-agent/src/model/testdata/team.spec.golden.yaml +3 -1
  160. package/crates/team-agent/src/model/testdata/team.spec.yaml +3 -1
  161. package/crates/team-agent/src/os_probe.rs +73 -2
  162. package/crates/team-agent/src/provider/adapter.rs +231 -4
  163. package/crates/team-agent/src/provider/adapters/claude.rs +5 -2
  164. package/crates/team-agent/src/provider/adapters/codex.rs +5 -2
  165. package/crates/team-agent/src/provider/adapters/copilot.rs +5 -2
  166. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +86 -0
  167. package/crates/team-agent/src/provider/adapters/grok.rs +157 -0
  168. package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
  169. package/crates/team-agent/src/provider/bypass_flags.rs +124 -0
  170. package/crates/team-agent/src/provider/classify.rs +4 -2
  171. package/crates/team-agent/src/provider/faults.rs +2 -1
  172. package/crates/team-agent/src/provider/mod.rs +13 -0
  173. package/crates/team-agent/src/provider/session/capture.rs +158 -20
  174. package/crates/team-agent/src/provider/session/context_fork/claude.rs +38 -1
  175. package/crates/team-agent/src/provider/session/context_fork/codex.rs +72 -1
  176. package/crates/team-agent/src/provider/session/context_fork/outcome.rs +57 -1
  177. package/crates/team-agent/src/provider/session/context_fork.rs +77 -3
  178. package/crates/team-agent/src/provider/session/mod.rs +18 -0
  179. package/crates/team-agent/src/provider/session/resume.rs +57 -0
  180. package/crates/team-agent/src/provider/session_scan/claude.rs +125 -1
  181. package/crates/team-agent/src/provider/session_scan/codex.rs +94 -1
  182. package/crates/team-agent/src/provider/session_scan/common.rs +204 -2
  183. package/crates/team-agent/src/provider/session_scan/copilot.rs +33 -1
  184. package/crates/team-agent/src/provider/session_scan/cursor.rs +538 -0
  185. package/crates/team-agent/src/provider/session_scan/grok.rs +288 -0
  186. package/crates/team-agent/src/provider/session_scan.rs +8 -0
  187. package/crates/team-agent/src/provider/submit_now.rs +94 -0
  188. package/crates/team-agent/src/provider/tests/adapter.rs +304 -0
  189. package/crates/team-agent/src/provider/types.rs +4 -2
  190. package/crates/team-agent/src/provider/wire.rs +23 -1
  191. package/crates/team-agent/src/state/persist.rs +301 -8
  192. package/crates/team-agent/src/state/repository.rs +5 -0
  193. package/crates/team-agent/src/tmux_backend/tests.rs +1737 -42
  194. package/crates/team-agent/src/tmux_backend.rs +1083 -75
  195. package/crates/team-agent/src/transport/test_support.rs +20 -0
  196. package/crates/team-agent/src/transport/tests/wire.rs +28 -2
  197. package/crates/team-agent/src/transport.rs +265 -1
  198. package/npm/install.mjs +129 -73
  199. package/package.json +4 -4
  200. package/skills/team-agent/SKILL.md +33 -238
  201. package/skills/team-agent/command-coverage.json +31 -0
  202. package/crates/team-agent/src/lifecycle/launch/approval.rs +0 -134
  203. package/crates/team-agent/src/lifecycle/launch/fork_agent/completion.rs +0 -59
  204. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +0 -483
  205. package/crates/team-agent/src/lifecycle/launch/fork_pending.rs +0 -109
  206. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +0 -447
  207. package/crates/team-agent/src/lifecycle/tests/codex_mcp_approval_inheritance_red.rs +0 -1187
  208. package/crates/team-agent/src/lifecycle/tests/worker_dangerous_bypass_argv_red.rs +0 -327
@@ -1,10 +1,17 @@
1
1
  //!
2
2
  //! leader::start — leader_start_plan / leader_session_name(派生 tmux session 名)。
3
+ //! Managed launch windows: first leader keeps provider_wire; later live
4
+ //! leaders in the same A-22 session get a unique suffix so attach targets
5
+ //! SESSION:WINDOW without claude_code collisions. state.json records every
6
+ //! live pane in leader_receiver.bound_panes and does not overwrite the first
7
+ //! pane_id.
3
8
 
4
9
  use std::collections::BTreeMap;
5
10
  use std::io::{IsTerminal, Read, Write};
6
11
  use std::path::{Path, PathBuf};
7
12
  use std::process::{Command, Stdio};
13
+ use std::sync::atomic::{AtomicBool, Ordering};
14
+ use std::time::Duration;
8
15
 
9
16
  #[cfg(target_os = "linux")]
10
17
  use std::os::fd::AsRawFd;
@@ -15,7 +22,8 @@ use crate::model::pane_authority_refusal as pane_refusal;
15
22
  use crate::provider::{get_adapter, Provider};
16
23
  use crate::tmux_backend::TmuxBackend;
17
24
  use crate::transport::{
18
- PaneId, PaneInfo, PaneLiveness, SessionName, SpawnResult, Target, Transport, WindowName,
25
+ CaptureRange, PaneId, PaneInfo, PaneLiveness, SessionName, SpawnResult, Target, Transport,
26
+ WindowName,
19
27
  };
20
28
 
21
29
  use super::helpers::{
@@ -268,12 +276,22 @@ fn leader_start_plan_with_ambient_authority(
268
276
  LeaderStartMode::NewTmuxSession
269
277
  };
270
278
  let leader_env = leader_env_for_identity(provider, &identity);
279
+ // First managed leader keeps `provider_wire` (`claude_code`). A second
280
+ // live leader in the same A-22 session gets a nonce suffix so attach
281
+ // targets a unique `SESSION:WINDOW` instead of colliding.
282
+ let managed_window = match (mode, session_name.as_ref()) {
283
+ (LeaderStartMode::ManagedTmuxClient, Some(session)) => Some(
284
+ managed_leader_window_for_launch(provider, workspace, session),
285
+ ),
286
+ _ => None,
287
+ };
271
288
  let argv = start_argv(
272
289
  mode,
273
290
  provider,
274
291
  provider_args,
275
292
  workspace,
276
293
  session_name.as_ref(),
294
+ managed_window.as_ref(),
277
295
  &leader_env,
278
296
  managed_client_attach_mode,
279
297
  )?;
@@ -297,13 +315,9 @@ fn leader_start_plan_with_ambient_authority(
297
315
  argv,
298
316
  provider_argv,
299
317
  // 0.3.28 Step 2: leader window inside the dedicated leader session is
300
- // named after the provider wire (e.g. `claude`, `codex`, `copilot`),
301
- // never the literal string `leader`. Python parity (see
302
- // `leader/__init__.py:114-131`). This eliminates the `WorkerWindowNamedLeader`
303
- // topology violation surface — the worker session never has a window
304
- // named `leader` either, because the leader session is disjoint.
305
- leader_window: (mode == LeaderStartMode::ManagedTmuxClient)
306
- .then(|| WindowName::new(provider_wire(provider))),
318
+ // named after the provider wire (e.g. `claude_code`), never the
319
+ // literal string `leader`. Occupied names get a per-launch suffix.
320
+ leader_window: managed_window,
307
321
  is_external_leader: external_path,
308
322
  leader_env: plan_env,
309
323
  identity: Some(identity),
@@ -398,7 +412,7 @@ fn execute_leader_plan_after_ambient_authority(
398
412
  } else if plan.is_external_leader {
399
413
  persist_external_leader_topology_marker(plan, workspace)?;
400
414
  }
401
- let process = run_leader_argv(&argv, &plan.leader_env, plan, workspace)?;
415
+ let process = run_leader_argv(&argv, &plan.leader_env, plan, workspace, None)?;
402
416
  let code = process.status.code();
403
417
  if !process.status.success() {
404
418
  return Err(LeaderError::Start(leader_launcher_failure(&process)));
@@ -899,12 +913,67 @@ fn managed_leader_session_from_candidates(
899
913
  .unwrap_or_else(|| managed_leader_session_name(provider, workspace))
900
914
  }
901
915
 
916
+ /// Base window is `provider_wire` (`claude_code`). A second live leader in
917
+ /// the same session cannot reuse that name: tmux then fails
918
+ /// `attach-session -t SESSION:claude_code` with `can't find window`.
919
+ fn managed_leader_window_from_occupied(
920
+ base: &str,
921
+ occupied: impl IntoIterator<Item = String>,
922
+ nonce: &str,
923
+ ) -> WindowName {
924
+ let occupied = occupied
925
+ .into_iter()
926
+ .collect::<std::collections::BTreeSet<_>>();
927
+ if !occupied.contains(base) {
928
+ return WindowName::new(base);
929
+ }
930
+ let mut candidate = format!("{base}-{nonce}");
931
+ let mut n = 2u32;
932
+ while occupied.contains(&candidate) {
933
+ candidate = format!("{base}-{nonce}-{n}");
934
+ n = n.saturating_add(1);
935
+ if n > 32 {
936
+ break;
937
+ }
938
+ }
939
+ WindowName::new(candidate)
940
+ }
941
+
942
+ fn managed_leader_window_for_launch(
943
+ provider: Provider,
944
+ workspace: &Path,
945
+ session: &SessionName,
946
+ ) -> WindowName {
947
+ let base = provider_wire(provider);
948
+ let transport = crate::transport_factory::tmux_workspace_transport(workspace);
949
+ let session_exists = transport.has_session(session).unwrap_or(false);
950
+ if !session_exists {
951
+ return WindowName::new(base);
952
+ }
953
+ let occupied = match transport.list_windows(session) {
954
+ Ok(windows) => windows
955
+ .into_iter()
956
+ .map(|window| window.as_str().to_string())
957
+ .collect::<Vec<_>>(),
958
+ // Cannot prove the base name is free: suffix rather than collide.
959
+ Err(_) => vec![base.to_string()],
960
+ };
961
+ let pid = std::process::id();
962
+ let epoch_nanos = std::time::SystemTime::now()
963
+ .duration_since(std::time::UNIX_EPOCH)
964
+ .map(|d| d.as_nanos())
965
+ .unwrap_or(0);
966
+ let nonce = format!("{pid:x}-{epoch_nanos:x}");
967
+ managed_leader_window_from_occupied(base, occupied, &nonce)
968
+ }
969
+
902
970
  fn start_argv(
903
971
  mode: LeaderStartMode,
904
972
  provider: Provider,
905
973
  provider_args: &[String],
906
974
  workspace: &Path,
907
975
  session_name: Option<&SessionName>,
976
+ leader_window: Option<&WindowName>,
908
977
  leader_env: &BTreeMap<String, String>,
909
978
  managed_client_attach_mode: Option<ManagedClientAttachMode>,
910
979
  ) -> Result<Vec<String>, LeaderError> {
@@ -921,10 +990,15 @@ fn start_argv(
921
990
  "managed leader session missing".to_string(),
922
991
  ));
923
992
  };
993
+ let Some(window) = leader_window else {
994
+ return Err(LeaderError::Start(
995
+ "managed leader window missing".to_string(),
996
+ ));
997
+ };
924
998
  let attach_mode = managed_client_attach_mode.ok_or_else(|| {
925
999
  LeaderError::Start("managed client attach mode missing".to_string())
926
1000
  })?;
927
- managed_client_argv(workspace, session, provider, attach_mode)
1001
+ managed_client_argv(workspace, session, window, attach_mode)
928
1002
  }
929
1003
  LeaderStartMode::AttachExisting => {
930
1004
  let Some(session) = session_name else {
@@ -996,13 +1070,13 @@ fn normalized_provider_args(provider_args: &[String]) -> impl Iterator<Item = St
996
1070
  fn managed_client_argv(
997
1071
  workspace: &Path,
998
1072
  session: &SessionName,
999
- provider: Provider,
1073
+ window: &WindowName,
1000
1074
  attach_mode: ManagedClientAttachMode,
1001
1075
  ) -> Result<Vec<String>, LeaderError> {
1002
- // 0.3.28 Step 2: leader window inside the dedicated leader session is
1003
- // named after `provider_wire(provider)` (e.g. `claude`, `codex`, `fake`),
1004
- // never the literal `leader`. Pre-0.3.28 this hardcoded `:leader`.
1005
- let target = format!("{}:{}", session.as_str(), provider_wire(provider));
1076
+ // Attach to the launch's actual window (base `provider_wire` or a unique
1077
+ // suffix). Targeting only `provider_wire` collides when two live leaders
1078
+ // share the A-22 session (`can't find window: claude_code`).
1079
+ let target = format!("{}:{}", session.as_str(), window.as_str());
1006
1080
  let argv = match attach_mode {
1007
1081
  ManagedClientAttachMode::SwitchClient => vec![
1008
1082
  "tmux".to_string(),
@@ -1059,6 +1133,16 @@ fn execute_managed_leader_plan_attempt(
1059
1133
  workspace,
1060
1134
  session_existed_before,
1061
1135
  )?;
1136
+ // A-55 (window rename race): tmux defaults `allow-rename` + `automatic-rename`
1137
+ // ON, so the pane's shell wrapper can print an OSC title-set escape during
1138
+ // init (zshrc/starship/PROMPT_COMMAND) and rename the window away from WINDOW
1139
+ // between spawn-poll and attach — making `attach-session -t SESSION:WINDOW`
1140
+ // fail with "can't find window: WINDOW". Lock both off right after spawn,
1141
+ // before run_leader_argv. Best-effort: a failure must NOT abort the launcher
1142
+ // (old tmux may lack the option), but it is audited below.
1143
+ let (rename_result, rename_stderr) =
1144
+ lock_down_window_rename(&transport, session, &spawned.window);
1145
+ write_window_rename_lockdown_event(workspace, &rename_result, &rename_stderr);
1062
1146
  if let Err(error) = persist_managed_leader_binding(plan, workspace, &spawned) {
1063
1147
  cleanup_managed_leader_resources(
1064
1148
  &transport,
@@ -1075,7 +1159,20 @@ fn execute_managed_leader_plan_attempt(
1075
1159
  workspace.to_path_buf(),
1076
1160
  spawned.pane_id.as_str().to_string(),
1077
1161
  );
1078
- let process = match run_leader_argv(&plan.argv, &BTreeMap::new(), plan, workspace) {
1162
+ let process = match run_leader_argv(
1163
+ &plan.argv,
1164
+ &BTreeMap::new(),
1165
+ plan,
1166
+ workspace,
1167
+ Some(ManagedAttachWatch {
1168
+ transport: &transport,
1169
+ spawned: &spawned,
1170
+ session,
1171
+ session_existed_before,
1172
+ workspace,
1173
+ provider_label: provider_command_name(plan.provider),
1174
+ }),
1175
+ ) {
1079
1176
  Ok(process) => process,
1080
1177
  Err(error) => {
1081
1178
  cleanup_managed_leader_resources(
@@ -1089,6 +1186,14 @@ fn execute_managed_leader_plan_attempt(
1089
1186
  return Err(error);
1090
1187
  }
1091
1188
  };
1189
+ if process.intentional_teardown {
1190
+ return Ok(LeaderLaunchOutcome {
1191
+ status: LeaderLaunchStatus::Exited,
1192
+ exit_code: Some(0),
1193
+ session_name: plan.session_name.clone(),
1194
+ reason: None,
1195
+ });
1196
+ }
1092
1197
  let code = process.status.code();
1093
1198
  if !process.status.success() {
1094
1199
  cleanup_managed_leader_resources(
@@ -1131,14 +1236,16 @@ fn managed_leader_attach_recovery_plan(
1131
1236
  workspace: &Path,
1132
1237
  ) -> Result<LeaderStartPlan, LeaderError> {
1133
1238
  let session = managed_leader_session_for_launch(plan.provider, workspace);
1239
+ let window = managed_leader_window_for_launch(plan.provider, workspace, &session);
1134
1240
  let attach_mode = if plan.argv.iter().any(|arg| arg == "switch-client") {
1135
1241
  ManagedClientAttachMode::SwitchClient
1136
1242
  } else {
1137
1243
  ManagedClientAttachMode::AttachSession
1138
1244
  };
1139
- let argv = managed_client_argv(workspace, &session, plan.provider, attach_mode)?;
1245
+ let argv = managed_client_argv(workspace, &session, &window, attach_mode)?;
1140
1246
  let mut retry_plan = plan.clone();
1141
1247
  retry_plan.session_name = Some(session);
1248
+ retry_plan.leader_window = Some(window);
1142
1249
  retry_plan.argv = argv;
1143
1250
  Ok(retry_plan)
1144
1251
  }
@@ -1190,6 +1297,222 @@ fn ensure_managed_leader_pane(
1190
1297
  }
1191
1298
  }
1192
1299
 
1300
+ struct ManagedAttachWatch<'a> {
1301
+ transport: &'a dyn Transport,
1302
+ spawned: &'a SpawnResult,
1303
+ session: &'a SessionName,
1304
+ session_existed_before: bool,
1305
+ workspace: &'a Path,
1306
+ provider_label: &'a str,
1307
+ }
1308
+
1309
+ #[cfg(unix)]
1310
+ static MANAGED_ATTACH_TEARDOWN: AtomicBool = AtomicBool::new(false);
1311
+
1312
+ #[cfg(unix)]
1313
+ struct ManagedTermGuard {
1314
+ old: libc::sighandler_t,
1315
+ }
1316
+
1317
+ #[cfg(unix)]
1318
+ impl ManagedTermGuard {
1319
+ fn install() -> Self {
1320
+ MANAGED_ATTACH_TEARDOWN.store(false, Ordering::SeqCst);
1321
+ let handler = managed_term_handler as *const () as libc::sighandler_t;
1322
+ let old = unsafe { libc::signal(libc::SIGTERM, handler) };
1323
+ Self { old }
1324
+ }
1325
+ }
1326
+
1327
+ #[cfg(unix)]
1328
+ impl Drop for ManagedTermGuard {
1329
+ fn drop(&mut self) {
1330
+ unsafe {
1331
+ libc::signal(libc::SIGTERM, self.old);
1332
+ }
1333
+ MANAGED_ATTACH_TEARDOWN.store(false, Ordering::SeqCst);
1334
+ }
1335
+ }
1336
+
1337
+ #[cfg(unix)]
1338
+ extern "C" fn managed_term_handler(_: libc::c_int) {
1339
+ MANAGED_ATTACH_TEARDOWN.store(true, Ordering::SeqCst);
1340
+ }
1341
+
1342
+ fn managed_attach_should_teardown(watch: &ManagedAttachWatch<'_>) -> bool {
1343
+ let marker = crate::tmux_backend::leader_provider_exit_marker(watch.provider_label);
1344
+ let target = Target::Pane(watch.spawned.pane_id.clone());
1345
+ if let Ok(cap) = watch.transport.capture(&target, CaptureRange::Tail(80)) {
1346
+ if cap.text.contains(&marker) || cap.text.contains("[team-agent] Provider exited") {
1347
+ return true;
1348
+ }
1349
+ }
1350
+ matches!(
1351
+ leader_provider_health(
1352
+ watch.transport,
1353
+ &watch.spawned.pane_id,
1354
+ watch.provider_label
1355
+ ),
1356
+ LeaderProviderHealth::ProviderExited | LeaderProviderHealth::Unreachable
1357
+ )
1358
+ }
1359
+
1360
+ fn bound_pane_snapshot(
1361
+ pane_id: &str,
1362
+ session_name: &serde_json::Value,
1363
+ window_name: &serde_json::Value,
1364
+ tmux_socket: &serde_json::Value,
1365
+ ) -> serde_json::Value {
1366
+ serde_json::json!({
1367
+ "pane_id": pane_id,
1368
+ "session_name": session_name,
1369
+ "window_name": window_name,
1370
+ "tmux_socket": tmux_socket,
1371
+ })
1372
+ }
1373
+
1374
+ fn merge_bound_panes(
1375
+ existing: Option<&serde_json::Value>,
1376
+ new_pane: serde_json::Value,
1377
+ ) -> Vec<serde_json::Value> {
1378
+ let new_id = new_pane
1379
+ .get("pane_id")
1380
+ .and_then(serde_json::Value::as_str)
1381
+ .unwrap_or("")
1382
+ .to_string();
1383
+ let mut panes = Vec::new();
1384
+ if let Some(existing) = existing {
1385
+ if let Some(arr) = existing
1386
+ .get("bound_panes")
1387
+ .and_then(serde_json::Value::as_array)
1388
+ {
1389
+ panes.extend(arr.iter().cloned());
1390
+ } else if let Some(old_pane) = existing.get("pane_id").and_then(serde_json::Value::as_str) {
1391
+ panes.push(bound_pane_snapshot(
1392
+ old_pane,
1393
+ existing
1394
+ .get("session_name")
1395
+ .unwrap_or(&serde_json::Value::Null),
1396
+ existing
1397
+ .get("window_name")
1398
+ .unwrap_or(&serde_json::Value::Null),
1399
+ existing
1400
+ .get("tmux_socket")
1401
+ .unwrap_or(&serde_json::Value::Null),
1402
+ ));
1403
+ }
1404
+ }
1405
+ if !new_id.is_empty()
1406
+ && !panes.iter().any(|pane| {
1407
+ pane.get("pane_id").and_then(serde_json::Value::as_str) == Some(new_id.as_str())
1408
+ })
1409
+ {
1410
+ panes.push(new_pane);
1411
+ }
1412
+ panes
1413
+ }
1414
+
1415
+ fn apply_clear_to_leader_receiver(
1416
+ recv: &mut serde_json::Map<String, serde_json::Value>,
1417
+ pane_id: &str,
1418
+ ) -> bool {
1419
+ let mut changed = false;
1420
+ if let Some(arr) = recv
1421
+ .get_mut("bound_panes")
1422
+ .and_then(serde_json::Value::as_array_mut)
1423
+ {
1424
+ let before = arr.len();
1425
+ arr.retain(|pane| pane.get("pane_id").and_then(serde_json::Value::as_str) != Some(pane_id));
1426
+ if arr.len() != before {
1427
+ changed = true;
1428
+ }
1429
+ }
1430
+ let scalar_match = recv.get("pane_id").and_then(serde_json::Value::as_str) == Some(pane_id);
1431
+ if scalar_match {
1432
+ let promoted = recv
1433
+ .get("bound_panes")
1434
+ .and_then(serde_json::Value::as_array)
1435
+ .and_then(|arr| arr.first())
1436
+ .cloned();
1437
+ if let Some(next) = promoted {
1438
+ recv.insert("status".to_string(), serde_json::json!("attached"));
1439
+ recv.insert(
1440
+ "pane_id".to_string(),
1441
+ next.get("pane_id")
1442
+ .cloned()
1443
+ .unwrap_or(serde_json::Value::Null),
1444
+ );
1445
+ recv.insert(
1446
+ "pane".to_string(),
1447
+ next.get("pane_id")
1448
+ .cloned()
1449
+ .unwrap_or(serde_json::Value::Null),
1450
+ );
1451
+ if let Some(session) = next.get("session_name") {
1452
+ recv.insert("session_name".to_string(), session.clone());
1453
+ }
1454
+ if let Some(window) = next.get("window_name") {
1455
+ recv.insert("window_name".to_string(), window.clone());
1456
+ }
1457
+ } else {
1458
+ recv.insert("status".to_string(), serde_json::json!("exited"));
1459
+ recv.remove("pane_id");
1460
+ recv.remove("pane");
1461
+ }
1462
+ changed = true;
1463
+ }
1464
+ changed
1465
+ }
1466
+
1467
+ fn clear_managed_leader_receiver_pane(workspace: &Path, pane_id: &str) {
1468
+ let Ok(mut state) = crate::state::persist::load_runtime_state(workspace) else {
1469
+ return;
1470
+ };
1471
+ let mut changed = false;
1472
+ if let Some(teams) = state
1473
+ .get_mut("teams")
1474
+ .and_then(serde_json::Value::as_object_mut)
1475
+ {
1476
+ for (_key, team) in teams.iter_mut() {
1477
+ let Some(team_obj) = team.as_object_mut() else {
1478
+ continue;
1479
+ };
1480
+ if let Some(recv) = team_obj
1481
+ .get_mut("leader_receiver")
1482
+ .and_then(serde_json::Value::as_object_mut)
1483
+ {
1484
+ if apply_clear_to_leader_receiver(recv, pane_id) {
1485
+ changed = true;
1486
+ }
1487
+ }
1488
+ if let Some(owner) = team_obj
1489
+ .get_mut("team_owner")
1490
+ .and_then(serde_json::Value::as_object_mut)
1491
+ {
1492
+ if owner.get("pane_id").and_then(serde_json::Value::as_str) == Some(pane_id) {
1493
+ owner.remove("pane_id");
1494
+ changed = true;
1495
+ }
1496
+ }
1497
+ }
1498
+ }
1499
+ if !changed {
1500
+ return;
1501
+ }
1502
+ let team_key = state
1503
+ .get("active_team_key")
1504
+ .and_then(serde_json::Value::as_str)
1505
+ .unwrap_or("current")
1506
+ .to_string();
1507
+ let _ = crate::state::repository::StateRepository::new(workspace).save(
1508
+ crate::state::repository::StateWriteIntent::LeaderStartBinding {
1509
+ team_key: &team_key,
1510
+ transport_kind: "managed",
1511
+ },
1512
+ &state,
1513
+ );
1514
+ }
1515
+
1193
1516
  fn cleanup_managed_leader_resources(
1194
1517
  transport: &dyn Transport,
1195
1518
  session: &SessionName,
@@ -1198,23 +1521,36 @@ fn cleanup_managed_leader_resources(
1198
1521
  workspace: &Path,
1199
1522
  reason: &str,
1200
1523
  ) {
1201
- let (action, result) = if session_existed_before {
1202
- (
1203
- "kill_pane",
1204
- transport
1205
- .kill_pane(&spawned.pane_id)
1206
- .map(|_| "ok".to_string())
1207
- .unwrap_or_else(|error| error.to_string()),
1208
- )
1209
- } else {
1210
- (
1211
- "kill_session",
1212
- transport
1213
- .kill_session(session)
1214
- .map(|_| "ok".to_string())
1215
- .unwrap_or_else(|error| error.to_string()),
1216
- )
1524
+ // Own window only — never kill-server (same socket may host other teams).
1525
+ // Last window in the session lets tmux destroy the session itself.
1526
+ let window_target = Target::SessionWindow {
1527
+ session: spawned.session.clone(),
1528
+ window: spawned.window.clone(),
1217
1529
  };
1530
+ let window_result = transport
1531
+ .kill_window(&window_target)
1532
+ .map(|_| "ok".to_string())
1533
+ .unwrap_or_else(|error| error.to_string());
1534
+ let mut action = "kill_window".to_string();
1535
+ let mut result = window_result;
1536
+ if !session_existed_before {
1537
+ match transport.list_windows(session) {
1538
+ Ok(windows) if windows.is_empty() => {
1539
+ let session_result = transport
1540
+ .kill_session(session)
1541
+ .map(|_| "ok".to_string())
1542
+ .unwrap_or_else(|error| error.to_string());
1543
+ action = "kill_window+kill_session".to_string();
1544
+ result = format!("{result};kill_session={session_result}");
1545
+ }
1546
+ Ok(_) => {}
1547
+ Err(error) => {
1548
+ action = "kill_window+list_windows_err".to_string();
1549
+ result = format!("{result};list_windows={error}");
1550
+ }
1551
+ }
1552
+ }
1553
+ clear_managed_leader_receiver_pane(workspace, spawned.pane_id.as_str());
1218
1554
  write_leader_startup_prompt_event(
1219
1555
  workspace,
1220
1556
  "leader.launcher.rollback",
@@ -1401,17 +1737,12 @@ fn persist_managed_leader_binding(
1401
1737
  .ok_or_else(|| LeaderError::Start("managed leader identity missing".to_string()))?;
1402
1738
  let mut state = crate::state::persist::load_runtime_state(workspace)
1403
1739
  .unwrap_or_else(|_| serde_json::json!({}));
1404
- let owner_epoch = state
1405
- .get("owner_epoch")
1406
- .and_then(serde_json::Value::as_u64)
1407
- .or_else(|| {
1408
- state
1409
- .get("team_owner")
1410
- .and_then(|owner| owner.get("owner_epoch"))
1411
- .and_then(serde_json::Value::as_u64)
1412
- })
1413
- .unwrap_or(0)
1414
- .saturating_add(1);
1740
+ let team_key = identity.team_id.as_str().to_string();
1741
+ let existing_recv = state
1742
+ .get("teams")
1743
+ .and_then(|teams| teams.get(&team_key))
1744
+ .and_then(|team| team.get("leader_receiver"))
1745
+ .cloned();
1415
1746
  let now = chrono::Utc::now().to_rfc3339();
1416
1747
  let socket = crate::tmux_backend::socket_path_for_workspace(workspace)
1417
1748
  .map(|path| path.to_string_lossy().to_string())
@@ -1420,27 +1751,81 @@ fn persist_managed_leader_binding(
1420
1751
  let session = spawned.session.as_str().to_string();
1421
1752
  let window = spawned.window.as_str().to_string();
1422
1753
  let pane = spawned.pane_id.as_str().to_string();
1754
+ let keep_existing_scalar = existing_recv
1755
+ .as_ref()
1756
+ .and_then(|recv| recv.get("pane_id"))
1757
+ .and_then(serde_json::Value::as_str)
1758
+ .is_some_and(|old| old != pane);
1759
+ let current_epoch = state
1760
+ .get("owner_epoch")
1761
+ .and_then(serde_json::Value::as_u64)
1762
+ .or_else(|| {
1763
+ state
1764
+ .get("team_owner")
1765
+ .and_then(|owner| owner.get("owner_epoch"))
1766
+ .and_then(serde_json::Value::as_u64)
1767
+ })
1768
+ .unwrap_or(0);
1769
+ let owner_epoch = if keep_existing_scalar {
1770
+ existing_recv
1771
+ .as_ref()
1772
+ .and_then(|recv| recv.get("owner_epoch"))
1773
+ .and_then(serde_json::Value::as_u64)
1774
+ .unwrap_or(current_epoch)
1775
+ } else {
1776
+ current_epoch.saturating_add(1)
1777
+ };
1423
1778
  let attach_mode = if plan.argv.iter().any(|arg| arg == "switch-client") {
1424
1779
  "switch-client"
1425
1780
  } else {
1426
1781
  "attach-session"
1427
1782
  };
1783
+ let new_binding = bound_pane_snapshot(
1784
+ &pane,
1785
+ &serde_json::json!(session),
1786
+ &serde_json::json!(window),
1787
+ &serde_json::json!(socket),
1788
+ );
1789
+ let bound_panes = merge_bound_panes(existing_recv.as_ref(), new_binding);
1790
+ let (scalar_pane, scalar_session, scalar_window, scalar_attached_at) =
1791
+ match existing_recv.as_ref() {
1792
+ Some(recv) if keep_existing_scalar => (
1793
+ recv.get("pane_id")
1794
+ .and_then(serde_json::Value::as_str)
1795
+ .unwrap_or(pane.as_str())
1796
+ .to_string(),
1797
+ recv.get("session_name")
1798
+ .and_then(serde_json::Value::as_str)
1799
+ .unwrap_or(session.as_str())
1800
+ .to_string(),
1801
+ recv.get("window_name")
1802
+ .and_then(serde_json::Value::as_str)
1803
+ .unwrap_or(window.as_str())
1804
+ .to_string(),
1805
+ recv.get("attached_at")
1806
+ .and_then(serde_json::Value::as_str)
1807
+ .unwrap_or(now.as_str())
1808
+ .to_string(),
1809
+ ),
1810
+ _ => (pane.clone(), session.clone(), window.clone(), now.clone()),
1811
+ };
1428
1812
  let receiver = serde_json::json!({
1429
1813
  "mode": "direct_tmux",
1430
1814
  "status": "attached",
1431
1815
  "provider": provider.clone(),
1432
- "pane_id": pane,
1433
- "pane": pane,
1434
- "session_name": session,
1435
- "window_name": window,
1816
+ "pane_id": scalar_pane,
1817
+ "pane": scalar_pane,
1818
+ "session_name": scalar_session,
1819
+ "window_name": scalar_window,
1436
1820
  "tmux_socket": socket,
1437
1821
  "leader_session_uuid": identity.leader_session_uuid,
1438
1822
  "owner_epoch": owner_epoch,
1439
- "attached_at": now,
1823
+ "attached_at": scalar_attached_at,
1440
1824
  "discovery": "managed_launcher",
1825
+ "bound_panes": bound_panes,
1441
1826
  });
1442
1827
  let owner = serde_json::json!({
1443
- "pane_id": pane,
1828
+ "pane_id": scalar_pane,
1444
1829
  "provider": provider.clone(),
1445
1830
  "machine_fingerprint": identity.machine_fingerprint,
1446
1831
  "leader_session_uuid": identity.leader_session_uuid,
@@ -1898,6 +2283,7 @@ fn run_leader_argv(
1898
2283
  env: &BTreeMap<String, String>,
1899
2284
  plan: &LeaderStartPlan,
1900
2285
  workspace: &Path,
2286
+ managed_watch: Option<ManagedAttachWatch<'_>>,
1901
2287
  ) -> Result<LeaderProcessExit, LeaderError> {
1902
2288
  let Some(program) = argv.first() else {
1903
2289
  return Err(LeaderError::Start(
@@ -2003,7 +2389,13 @@ fn run_leader_argv(
2003
2389
  if plan.mode == LeaderStartMode::ExecProvider {
2004
2390
  spawn_exec_provider_startup_prompt_handler(plan.provider, workspace.to_path_buf());
2005
2391
  }
2006
- let status = child.wait().map_err(LeaderError::Io)?;
2392
+ #[cfg(unix)]
2393
+ let _term_guard = managed_watch.as_ref().map(|_| ManagedTermGuard::install());
2394
+ let (status, intentional_teardown) = if let Some(watch) = managed_watch.as_ref() {
2395
+ wait_managed_attach_child(&mut child, watch)?
2396
+ } else {
2397
+ (child.wait().map_err(LeaderError::Io)?, false)
2398
+ };
2007
2399
  let stdout = match stdout_reader {
2008
2400
  Some(reader) => match reader.join() {
2009
2401
  Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
@@ -2040,9 +2432,88 @@ fn run_leader_argv(
2040
2432
  status,
2041
2433
  stderr,
2042
2434
  diagnostics_path,
2435
+ intentional_teardown,
2043
2436
  })
2044
2437
  }
2045
2438
 
2439
+ fn wait_managed_attach_child(
2440
+ child: &mut std::process::Child,
2441
+ watch: &ManagedAttachWatch<'_>,
2442
+ ) -> Result<(std::process::ExitStatus, bool), LeaderError> {
2443
+ loop {
2444
+ if let Some(status) = child.try_wait().map_err(LeaderError::Io)? {
2445
+ return Ok((status, false));
2446
+ }
2447
+ let signalled = {
2448
+ #[cfg(unix)]
2449
+ {
2450
+ MANAGED_ATTACH_TEARDOWN.load(Ordering::SeqCst)
2451
+ }
2452
+ #[cfg(not(unix))]
2453
+ {
2454
+ false
2455
+ }
2456
+ };
2457
+ if signalled || managed_attach_should_teardown(watch) {
2458
+ // `managed_attach_should_teardown` costs a tmux round-trip, and the
2459
+ // attach child can lose that race: it dies BY ITSELF inside the
2460
+ // window (`open terminal failed: not a terminal`, or a client whose
2461
+ // attach was rejected). Re-check before we touch anything. A child
2462
+ // that already failed on its own was never torn down by us, so it
2463
+ // must NOT be reported as an intentional teardown — its non-zero
2464
+ // status has to reach the launcher failure path
2465
+ // (`execute_managed_leader_plan_attempt`), which is what surfaces the
2466
+ // error and drives attach recovery. Only the FAILING self-exit is
2467
+ // diverted: a clean self-exit keeps the pre-existing behaviour
2468
+ // (cleanup + intentional teardown + exit 0), and an explicit signal
2469
+ // teardown stays intentional regardless.
2470
+ if !signalled {
2471
+ if let Some(status) = child.try_wait().map_err(LeaderError::Io)? {
2472
+ if !status.success() {
2473
+ return Ok((status, false));
2474
+ }
2475
+ }
2476
+ }
2477
+ let reason = if signalled {
2478
+ "launcher_signal"
2479
+ } else {
2480
+ "provider_exited"
2481
+ };
2482
+ cleanup_managed_leader_resources(
2483
+ watch.transport,
2484
+ watch.session,
2485
+ watch.spawned,
2486
+ watch.session_existed_before,
2487
+ watch.workspace,
2488
+ reason,
2489
+ );
2490
+ let status = terminate_managed_attach_child(child)?;
2491
+ return Ok((status, true));
2492
+ }
2493
+ std::thread::sleep(Duration::from_millis(200));
2494
+ }
2495
+ }
2496
+
2497
+ fn terminate_managed_attach_child(
2498
+ child: &mut std::process::Child,
2499
+ ) -> Result<std::process::ExitStatus, LeaderError> {
2500
+ if let Some(status) = child.try_wait().map_err(LeaderError::Io)? {
2501
+ return Ok(status);
2502
+ }
2503
+ #[cfg(unix)]
2504
+ unsafe {
2505
+ libc::kill(child.id() as i32, libc::SIGTERM);
2506
+ }
2507
+ for _ in 0..20 {
2508
+ std::thread::sleep(Duration::from_millis(50));
2509
+ if let Some(status) = child.try_wait().map_err(LeaderError::Io)? {
2510
+ return Ok(status);
2511
+ }
2512
+ }
2513
+ let _ = child.kill();
2514
+ child.wait().map_err(LeaderError::Io)
2515
+ }
2516
+
2046
2517
  fn launcher_diagnostics_path(workspace: &Path) -> PathBuf {
2047
2518
  let stamp = chrono::Utc::now().format("%Y%m%d-%H%M%S%.6f");
2048
2519
  workspace.join(".team").join("logs").join(format!(
@@ -2082,6 +2553,7 @@ struct LeaderProcessExit {
2082
2553
  status: std::process::ExitStatus,
2083
2554
  stderr: String,
2084
2555
  diagnostics_path: PathBuf,
2556
+ intentional_teardown: bool,
2085
2557
  }
2086
2558
 
2087
2559
  fn push_bounded_stderr(captured: &mut Vec<u8>, chunk: &[u8]) {
@@ -2246,6 +2718,44 @@ fn write_leader_startup_prompt_event(workspace: &Path, event: &str, fields: serd
2246
2718
  let _ = crate::event_log::EventLog::new(workspace).write(event, fields);
2247
2719
  }
2248
2720
 
2721
+ /// A-55 (window rename race): lock the freshly-spawned leader window against
2722
+ /// rename — `allow-rename off` (refuse pane-internal OSC title-set renames)
2723
+ /// AND `automatic-rename off` (refuse tmux's own command-driven rename). Both
2724
+ /// must be set; tmux treats them as independent. Best-effort: a failure must
2725
+ /// NOT abort the launcher (old tmux may lack the option) — return the outcome
2726
+ /// for audit instead of propagating an error. Returns (result, tmux_stderr).
2727
+ fn lock_down_window_rename(
2728
+ transport: &dyn Transport,
2729
+ session: &SessionName,
2730
+ window: &WindowName,
2731
+ ) -> (String, String) {
2732
+ let allow_rename = transport.set_window_option(session, window, "allow-rename", "off");
2733
+ let automatic_rename = transport.set_window_option(session, window, "automatic-rename", "off");
2734
+ let (result, stderr) = match (&allow_rename, &automatic_rename) {
2735
+ (Ok(()), Ok(())) => ("ok".to_string(), String::new()),
2736
+ (Ok(()), Err(error)) => ("failed".to_string(), error.to_string()),
2737
+ (Err(error), Ok(())) => ("failed".to_string(), error.to_string()),
2738
+ (Err(a), Err(b)) => (
2739
+ "failed".to_string(),
2740
+ format!("allow-rename: {a}; automatic-rename: {b}"),
2741
+ ),
2742
+ };
2743
+ (result, stderr)
2744
+ }
2745
+
2746
+ /// A-55: audit the rename-lockdown outcome to the event log. Failure does not
2747
+ /// abort the launcher; record ok/failed + tmux stderr for regression triage.
2748
+ fn write_window_rename_lockdown_event(workspace: &Path, result: &str, stderr: &str) {
2749
+ write_leader_startup_prompt_event(
2750
+ workspace,
2751
+ "leader.launcher.window_rename_lockdown",
2752
+ serde_json::json!({
2753
+ "result": result,
2754
+ "tmux_stderr": stderr,
2755
+ }),
2756
+ );
2757
+ }
2758
+
2249
2759
  fn ensure_tmux_installed() -> Result<(), LeaderError> {
2250
2760
  match Command::new("tmux").arg("-V").output() {
2251
2761
  Ok(output) if output.status.success() => Ok(()),
@@ -2265,6 +2775,8 @@ fn provider_command_name(provider: Provider) -> &'static str {
2265
2775
  // (leader/start.rs:192-204 派生)自动覆盖前缀保护。
2266
2776
  Provider::Copilot => "copilot",
2267
2777
  Provider::GeminiCli => "gemini",
2778
+ Provider::Grok => "grok",
2779
+ Provider::CursorAgent => "agent",
2268
2780
  Provider::Fake => "fake",
2269
2781
  }
2270
2782
  }
@@ -2348,10 +2860,13 @@ mod tests {
2348
2860
  };
2349
2861
 
2350
2862
  use super::{
2351
- ensure_managed_provider_live_after_attach, execute_leader_plan_after_ambient_authority,
2352
- format_launcher_failure, handle_exec_provider_startup_prompts,
2353
- is_interactive_shell_basename, push_bounded_stderr, shlex_quote,
2354
- VerifiedAmbientPaneAuthority, LEADER_STDERR_LIMIT,
2863
+ clear_managed_leader_receiver_pane, ensure_managed_provider_live_after_attach,
2864
+ execute_leader_plan_after_ambient_authority, format_launcher_failure,
2865
+ handle_exec_provider_startup_prompts, is_interactive_shell_basename,
2866
+ lock_down_window_rename, managed_client_argv, managed_leader_window_from_occupied,
2867
+ persist_managed_leader_binding, push_bounded_stderr, shlex_quote,
2868
+ write_window_rename_lockdown_event, ManagedClientAttachMode, VerifiedAmbientPaneAuthority,
2869
+ LEADER_STDERR_LIMIT,
2355
2870
  };
2356
2871
 
2357
2872
  struct ScriptedTransport {
@@ -2359,6 +2874,10 @@ mod tests {
2359
2874
  sent: Mutex<Vec<(Target, Vec<Key>)>>,
2360
2875
  liveness: PaneLiveness,
2361
2876
  targets: Vec<PaneInfo>,
2877
+ window_options: Mutex<Vec<(String, String)>>,
2878
+ window_option_error: Mutex<Option<String>>,
2879
+ kills: Mutex<Vec<String>>,
2880
+ windows: Mutex<Vec<WindowName>>,
2362
2881
  }
2363
2882
 
2364
2883
  impl ScriptedTransport {
@@ -2368,6 +2887,10 @@ mod tests {
2368
2887
  sent: Mutex::new(Vec::new()),
2369
2888
  liveness: PaneLiveness::Unknown,
2370
2889
  targets: Vec::new(),
2890
+ window_options: Mutex::new(Vec::new()),
2891
+ window_option_error: Mutex::new(None),
2892
+ kills: Mutex::new(Vec::new()),
2893
+ windows: Mutex::new(Vec::new()),
2371
2894
  }
2372
2895
  }
2373
2896
 
@@ -2377,6 +2900,10 @@ mod tests {
2377
2900
  sent: Mutex::new(Vec::new()),
2378
2901
  liveness,
2379
2902
  targets: Vec::new(),
2903
+ window_options: Mutex::new(Vec::new()),
2904
+ window_option_error: Mutex::new(None),
2905
+ kills: Mutex::new(Vec::new()),
2906
+ windows: Mutex::new(Vec::new()),
2380
2907
  }
2381
2908
  }
2382
2909
 
@@ -2386,6 +2913,25 @@ mod tests {
2386
2913
  sent: Mutex::new(Vec::new()),
2387
2914
  liveness,
2388
2915
  targets,
2916
+ window_options: Mutex::new(Vec::new()),
2917
+ window_option_error: Mutex::new(None),
2918
+ kills: Mutex::new(Vec::new()),
2919
+ windows: Mutex::new(Vec::new()),
2920
+ }
2921
+ }
2922
+
2923
+ /// A-55: a ScriptedTransport whose `set_window_option` always fails with
2924
+ /// the given tmux stderr — proves the lockdown never aborts the launcher.
2925
+ fn with_window_option_error(error: &str) -> Self {
2926
+ Self {
2927
+ screens: Mutex::new(Vec::new()),
2928
+ sent: Mutex::new(Vec::new()),
2929
+ liveness: PaneLiveness::Unknown,
2930
+ targets: Vec::new(),
2931
+ window_options: Mutex::new(Vec::new()),
2932
+ window_option_error: Mutex::new(Some(error.to_string())),
2933
+ kills: Mutex::new(Vec::new()),
2934
+ windows: Mutex::new(Vec::new()),
2389
2935
  }
2390
2936
  }
2391
2937
 
@@ -2395,6 +2941,28 @@ mod tests {
2395
2941
  Err(poisoned) => poisoned.into_inner().clone(),
2396
2942
  }
2397
2943
  }
2944
+
2945
+ /// All `set-window-option option value` pairs, in call order.
2946
+ fn window_options(&self) -> Vec<(String, String)> {
2947
+ match self.window_options.lock() {
2948
+ Ok(guard) => guard.clone(),
2949
+ Err(poisoned) => poisoned.into_inner().clone(),
2950
+ }
2951
+ }
2952
+
2953
+ fn record_kill(&self, action: &str) {
2954
+ match self.kills.lock() {
2955
+ Ok(mut guard) => guard.push(action.to_string()),
2956
+ Err(poisoned) => poisoned.into_inner().push(action.to_string()),
2957
+ }
2958
+ }
2959
+
2960
+ fn kills(&self) -> Vec<String> {
2961
+ match self.kills.lock() {
2962
+ Ok(guard) => guard.clone(),
2963
+ Err(poisoned) => poisoned.into_inner().clone(),
2964
+ }
2965
+ }
2398
2966
  }
2399
2967
 
2400
2968
  impl Transport for ScriptedTransport {
@@ -2499,7 +3067,10 @@ mod tests {
2499
3067
  }
2500
3068
 
2501
3069
  fn list_windows(&self, _session: &SessionName) -> Result<Vec<WindowName>, TransportError> {
2502
- Ok(Vec::new())
3070
+ match self.windows.lock() {
3071
+ Ok(guard) => Ok(guard.clone()),
3072
+ Err(poisoned) => Ok(poisoned.into_inner().clone()),
3073
+ }
2503
3074
  }
2504
3075
 
2505
3076
  fn set_session_env(
@@ -2511,11 +3082,50 @@ mod tests {
2511
3082
  Ok(SetEnvOutcome::Applied)
2512
3083
  }
2513
3084
 
2514
- fn kill_session(&self, _session: &SessionName) -> Result<(), TransportError> {
3085
+ fn set_window_option(
3086
+ &self,
3087
+ _session: &SessionName,
3088
+ _window: &WindowName,
3089
+ option: &str,
3090
+ value: &str,
3091
+ ) -> Result<(), TransportError> {
3092
+ if let Some(stderr) = self
3093
+ .window_option_error
3094
+ .lock()
3095
+ .ok()
3096
+ .and_then(|guard| guard.clone())
3097
+ {
3098
+ return Err(TransportError::Subprocess {
3099
+ argv: vec!["tmux".to_string(), "set-window-option".to_string()],
3100
+ code: Some(1),
3101
+ stderr,
3102
+ });
3103
+ }
3104
+ match self.window_options.lock() {
3105
+ Ok(mut guard) => guard.push((option.to_string(), value.to_string())),
3106
+ Err(poisoned) => poisoned
3107
+ .into_inner()
3108
+ .push((option.to_string(), value.to_string())),
3109
+ }
3110
+ Ok(())
3111
+ }
3112
+
3113
+ fn kill_server(&self) -> Result<(), TransportError> {
3114
+ self.record_kill("kill_server");
2515
3115
  Ok(())
2516
3116
  }
2517
3117
 
2518
- fn kill_window(&self, _target: &Target) -> Result<(), TransportError> {
3118
+ fn kill_session(&self, session: &SessionName) -> Result<(), TransportError> {
3119
+ self.record_kill(&format!("kill_session:{}", session.as_str()));
3120
+ Ok(())
3121
+ }
3122
+
3123
+ fn kill_window(&self, target: &Target) -> Result<(), TransportError> {
3124
+ self.record_kill(&format!("kill_window:{target:?}"));
3125
+ match self.windows.lock() {
3126
+ Ok(mut guard) => guard.clear(),
3127
+ Err(poisoned) => poisoned.into_inner().clear(),
3128
+ }
2519
3129
  Ok(())
2520
3130
  }
2521
3131
 
@@ -2988,4 +3598,416 @@ mod tests {
2988
3598
  assert_eq!(captured.len(), LEADER_STDERR_LIMIT);
2989
3599
  assert!(captured.ends_with(b"XYZ"));
2990
3600
  }
3601
+
3602
+ #[test]
3603
+ fn managed_leader_launch_disables_window_rename() {
3604
+ let transport = ScriptedTransport::with_liveness(PaneLiveness::Live);
3605
+ let session = SessionName::new("team-agent-leader-claude_code-demo");
3606
+ let window = WindowName::new("claude_code");
3607
+
3608
+ let (result, stderr) = lock_down_window_rename(&transport, &session, &window);
3609
+
3610
+ assert_eq!(result, "ok", "both set-window-option calls must succeed");
3611
+ assert!(stderr.is_empty());
3612
+ let options = transport.window_options();
3613
+ assert_eq!(
3614
+ options,
3615
+ vec![
3616
+ ("allow-rename".to_string(), "off".to_string()),
3617
+ ("automatic-rename".to_string(), "off".to_string()),
3618
+ ],
3619
+ "both rename lockdown options must be issued, in order"
3620
+ );
3621
+ }
3622
+
3623
+ #[test]
3624
+ fn launcher_survives_set_window_option_failure() {
3625
+ let workspace =
3626
+ std::env::temp_dir().join(format!("ta-rename-lockdown-fail-{}", std::process::id()));
3627
+ std::fs::create_dir_all(&workspace).unwrap();
3628
+ let transport = ScriptedTransport::with_window_option_error(
3629
+ "set-window-option: unknown option: allow-rename",
3630
+ );
3631
+ let session = SessionName::new("team-agent-leader-claude_code-demo");
3632
+ let window = WindowName::new("claude_code");
3633
+
3634
+ // Lockdown must NOT abort the launcher: it returns a failed outcome
3635
+ // (recorded for audit) instead of propagating an error.
3636
+ let (result, stderr) = lock_down_window_rename(&transport, &session, &window);
3637
+
3638
+ assert_eq!(
3639
+ result, "failed",
3640
+ "a set-window-option failure is audited, not fatal"
3641
+ );
3642
+ assert!(
3643
+ stderr.contains("unknown option"),
3644
+ "tmux stderr must surface in the audit payload: {stderr}"
3645
+ );
3646
+
3647
+ // The audit event lands in the workspace event log with result=failed.
3648
+ write_window_rename_lockdown_event(&workspace, &result, &stderr);
3649
+ let events = crate::event_log::EventLog::new(&workspace).tail(0).unwrap();
3650
+ let lockdown = events
3651
+ .iter()
3652
+ .find(|event| event["event"] == "leader.launcher.window_rename_lockdown")
3653
+ .expect("rename_lockdown event must be written even on failure");
3654
+ assert_eq!(lockdown["result"], "failed");
3655
+ assert!(
3656
+ lockdown["tmux_stderr"]
3657
+ .as_str()
3658
+ .unwrap_or_default()
3659
+ .contains("unknown option"),
3660
+ "event must carry tmux stderr for regression triage: {lockdown}"
3661
+ );
3662
+
3663
+ std::fs::remove_dir_all(&workspace).ok();
3664
+ }
3665
+
3666
+ #[test]
3667
+ fn cleanup_managed_leader_kills_own_window_not_server() {
3668
+ let workspace =
3669
+ std::env::temp_dir().join(format!("ta-cleanup-own-window-{}", std::process::id()));
3670
+ std::fs::create_dir_all(&workspace).unwrap();
3671
+ let spawned = managed_spawn_result();
3672
+ let transport = ScriptedTransport::with_liveness(PaneLiveness::Live);
3673
+ super::cleanup_managed_leader_resources(
3674
+ &transport,
3675
+ &spawned.session,
3676
+ &spawned,
3677
+ false,
3678
+ &workspace,
3679
+ "test_own_window",
3680
+ );
3681
+ let kills = transport.kills();
3682
+ assert!(
3683
+ kills.iter().any(|k| k.starts_with("kill_window:")),
3684
+ "cleanup must kill the launched window: {kills:?}"
3685
+ );
3686
+ assert!(
3687
+ !kills.iter().any(|k| k == "kill_server"),
3688
+ "cleanup must not kill-server: {kills:?}"
3689
+ );
3690
+ let _ = std::fs::remove_dir_all(&workspace);
3691
+ }
3692
+
3693
+ #[test]
3694
+ fn cleanup_managed_leader_clears_stale_pane_pointer() {
3695
+ let workspace =
3696
+ std::env::temp_dir().join(format!("ta-cleanup-pane-pointer-{}", std::process::id()));
3697
+ std::fs::create_dir_all(workspace.join(".team").join("runtime")).unwrap();
3698
+ let state = serde_json::json!({
3699
+ "active_team_key": "current",
3700
+ "teams": {
3701
+ "current": {
3702
+ "leader_receiver": {
3703
+ "status": "attached",
3704
+ "pane_id": "%42",
3705
+ "pane": "%42"
3706
+ },
3707
+ "team_owner": { "pane_id": "%42" }
3708
+ }
3709
+ }
3710
+ });
3711
+ std::fs::write(
3712
+ crate::state::persist::runtime_state_path(&workspace),
3713
+ serde_json::to_vec_pretty(&state).unwrap(),
3714
+ )
3715
+ .unwrap();
3716
+ super::clear_managed_leader_receiver_pane(&workspace, "%42");
3717
+ let loaded = crate::state::persist::load_runtime_state(&workspace).unwrap();
3718
+ let recv = &loaded["teams"]["current"]["leader_receiver"];
3719
+ assert_eq!(recv["status"], serde_json::json!("exited"));
3720
+ assert!(
3721
+ recv.get("pane_id").is_none(),
3722
+ "dangling pane_id must be gone: {recv}"
3723
+ );
3724
+ assert!(
3725
+ loaded["teams"]["current"]["team_owner"]
3726
+ .get("pane_id")
3727
+ .is_none(),
3728
+ "owner pane_id must be gone: {loaded}"
3729
+ );
3730
+ let _ = std::fs::remove_dir_all(&workspace);
3731
+ }
3732
+
3733
+ #[test]
3734
+ fn managed_attach_teardown_detects_provider_exit_marker() {
3735
+ let spawned = managed_spawn_result();
3736
+ let transport = ScriptedTransport::new(vec![
3737
+ "[team-agent] claude exited with 1\n[team-agent] Provider exited; this pane no longer accepts input.".to_string(),
3738
+ ]);
3739
+ let watch = super::ManagedAttachWatch {
3740
+ transport: &transport,
3741
+ spawned: &spawned,
3742
+ session: &spawned.session,
3743
+ session_existed_before: false,
3744
+ workspace: Path::new("/tmp"),
3745
+ provider_label: "claude",
3746
+ };
3747
+ assert!(
3748
+ super::managed_attach_should_teardown(&watch),
3749
+ "exit marker in capture must trigger teardown"
3750
+ );
3751
+ }
3752
+
3753
+ #[test]
3754
+ fn managed_attach_teardown_does_not_fire_on_live_provider_screen() {
3755
+ let spawned = managed_spawn_result();
3756
+ let transport = ScriptedTransport::new(vec!["Welcome to Claude Code".to_string()]);
3757
+ let watch = super::ManagedAttachWatch {
3758
+ transport: &transport,
3759
+ spawned: &spawned,
3760
+ session: &spawned.session,
3761
+ session_existed_before: false,
3762
+ workspace: Path::new("/tmp"),
3763
+ provider_label: "claude",
3764
+ };
3765
+ assert!(
3766
+ !super::managed_attach_should_teardown(&watch),
3767
+ "live welcome screen must not teardown"
3768
+ );
3769
+ }
3770
+
3771
+ #[test]
3772
+ fn managed_attach_wait_watches_provider_exit_grep_guard() {
3773
+ let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
3774
+ let start_rs = manifest.join("src").join("leader").join("start.rs");
3775
+ let contents = std::fs::read_to_string(&start_rs).expect("read leader/start.rs");
3776
+ let start = contents
3777
+ .find("fn run_leader_argv(")
3778
+ .expect("run_leader_argv must exist");
3779
+ let end = contents[start + 1..]
3780
+ .find("\nfn ")
3781
+ .map(|offset| start + 1 + offset)
3782
+ .unwrap_or(contents.len());
3783
+ let body = &contents[start..end];
3784
+ assert!(
3785
+ body.contains("wait_managed_attach_child"),
3786
+ "managed attach wait must watch provider lifetime; body excerpt: {body}"
3787
+ );
3788
+ let cleanup = contents
3789
+ .find("fn cleanup_managed_leader_resources(")
3790
+ .expect("cleanup_managed_leader_resources must exist");
3791
+ let cleanup_end = contents[cleanup + 1..]
3792
+ .find("\nfn ")
3793
+ .map(|offset| cleanup + 1 + offset)
3794
+ .unwrap_or(contents.len());
3795
+ let cleanup_body = &contents[cleanup..cleanup_end];
3796
+ assert!(
3797
+ !cleanup_body.contains("kill_server"),
3798
+ "cleanup must not call kill_server; body excerpt: {cleanup_body}"
3799
+ );
3800
+ assert!(
3801
+ cleanup_body.contains("kill_window"),
3802
+ "cleanup must kill own window; body excerpt: {cleanup_body}"
3803
+ );
3804
+ assert!(
3805
+ cleanup_body.contains("clear_managed_leader_receiver_pane"),
3806
+ "cleanup must clear state.json pane pointer; body excerpt: {cleanup_body}"
3807
+ );
3808
+ }
3809
+
3810
+ fn persist_test_identity(workspace: &Path) -> LeaderIdentity {
3811
+ LeaderIdentity {
3812
+ leader_session_uuid: LeaderSessionUuid::derive(
3813
+ "fp",
3814
+ &workspace.to_string_lossy(),
3815
+ "tester",
3816
+ "current",
3817
+ )
3818
+ .unwrap(),
3819
+ leader_session_uuid_source: LeaderSessionUuidSource::Derived,
3820
+ machine_fingerprint: "fp".to_string(),
3821
+ workspace_abspath: workspace.to_path_buf(),
3822
+ os_user: "tester".to_string(),
3823
+ team_id: TeamKey::new("current"),
3824
+ }
3825
+ }
3826
+
3827
+ fn persist_test_plan(workspace: &Path) -> LeaderStartPlan {
3828
+ LeaderStartPlan {
3829
+ mode: LeaderStartMode::ManagedTmuxClient,
3830
+ provider: Provider::ClaudeCode,
3831
+ workspace: workspace.to_path_buf(),
3832
+ socket: LeaderLaunchSocket::Workspace,
3833
+ session_name: Some(SessionName::new("team-agent-leader-claude_code-demo")),
3834
+ argv: vec!["tmux".to_string(), "attach-session".to_string()],
3835
+ provider_argv: vec!["claude".to_string()],
3836
+ leader_window: Some(WindowName::new("claude_code")),
3837
+ is_external_leader: false,
3838
+ leader_env: BTreeMap::new(),
3839
+ identity: Some(persist_test_identity(workspace)),
3840
+ detached: false,
3841
+ }
3842
+ }
3843
+
3844
+ #[test]
3845
+ fn multi_leader_window_keeps_base_when_free() {
3846
+ let window =
3847
+ managed_leader_window_from_occupied("claude_code", Vec::<String>::new(), "deadbeef");
3848
+ assert_eq!(window.as_str(), "claude_code");
3849
+ }
3850
+
3851
+ #[test]
3852
+ fn multi_leader_window_suffixes_when_taken() {
3853
+ let window = managed_leader_window_from_occupied(
3854
+ "claude_code",
3855
+ vec!["claude_code".to_string()],
3856
+ "aa-bb",
3857
+ );
3858
+ assert_eq!(window.as_str(), "claude_code-aa-bb");
3859
+ assert_ne!(window.as_str(), "claude_code");
3860
+ }
3861
+
3862
+ #[test]
3863
+ fn multi_leader_attach_targets_unique_window() {
3864
+ let session = SessionName::new("team-agent-leader-claude_code-demo");
3865
+ let window = WindowName::new("claude_code-aa-bb");
3866
+ let argv = managed_client_argv(
3867
+ Path::new("/tmp"),
3868
+ &session,
3869
+ &window,
3870
+ ManagedClientAttachMode::AttachSession,
3871
+ )
3872
+ .expect("argv");
3873
+ let target = argv
3874
+ .windows(2)
3875
+ .find(|pair| pair[0] == "-t")
3876
+ .map(|pair| pair[1].as_str())
3877
+ .expect("-t target");
3878
+ assert_eq!(
3879
+ target,
3880
+ "team-agent-leader-claude_code-demo:claude_code-aa-bb"
3881
+ );
3882
+ assert!(
3883
+ !target.ends_with(":claude_code"),
3884
+ "attach must not collapse to the colliding provider_wire name: {target}"
3885
+ );
3886
+ }
3887
+
3888
+ #[test]
3889
+ fn multi_leader_persist_keeps_first_pane_id() {
3890
+ let workspace =
3891
+ std::env::temp_dir().join(format!("ta-multi-leader-persist-{}", std::process::id()));
3892
+ std::fs::create_dir_all(workspace.join(".team").join("runtime")).unwrap();
3893
+ let plan = persist_test_plan(&workspace);
3894
+ let first = SpawnResult {
3895
+ pane_id: PaneId::new("%0"),
3896
+ session: SessionName::new("team-agent-leader-claude_code-demo"),
3897
+ window: WindowName::new("claude_code"),
3898
+ child_pid: Some(1),
3899
+ };
3900
+ persist_managed_leader_binding(&plan, &workspace, &first).expect("first persist");
3901
+ let second = SpawnResult {
3902
+ pane_id: PaneId::new("%1"),
3903
+ session: SessionName::new("team-agent-leader-claude_code-demo"),
3904
+ window: WindowName::new("claude_code-aa-bb"),
3905
+ child_pid: Some(2),
3906
+ };
3907
+ persist_managed_leader_binding(&plan, &workspace, &second).expect("second persist");
3908
+ let loaded = crate::state::persist::load_runtime_state(&workspace).unwrap();
3909
+ let recv = &loaded["teams"]["current"]["leader_receiver"];
3910
+ assert_eq!(
3911
+ recv["pane_id"],
3912
+ serde_json::json!("%0"),
3913
+ "second launch must not overwrite the first pane pointer: {recv}"
3914
+ );
3915
+ let panes = recv["bound_panes"].as_array().expect("bound_panes");
3916
+ let ids: Vec<&str> = panes
3917
+ .iter()
3918
+ .filter_map(|pane| pane.get("pane_id").and_then(serde_json::Value::as_str))
3919
+ .collect();
3920
+ assert!(ids.contains(&"%0"), "bound_panes missing first: {recv}");
3921
+ assert!(ids.contains(&"%1"), "bound_panes missing second: {recv}");
3922
+ let _ = std::fs::remove_dir_all(&workspace);
3923
+ }
3924
+
3925
+ #[test]
3926
+ fn multi_leader_clear_promotes_remaining() {
3927
+ let workspace =
3928
+ std::env::temp_dir().join(format!("ta-multi-leader-promote-{}", std::process::id()));
3929
+ std::fs::create_dir_all(workspace.join(".team").join("runtime")).unwrap();
3930
+ let state = serde_json::json!({
3931
+ "active_team_key": "current",
3932
+ "teams": {
3933
+ "current": {
3934
+ "leader_receiver": {
3935
+ "status": "attached",
3936
+ "pane_id": "%0",
3937
+ "pane": "%0",
3938
+ "session_name": "s",
3939
+ "window_name": "claude_code",
3940
+ "bound_panes": [
3941
+ {
3942
+ "pane_id": "%0",
3943
+ "session_name": "s",
3944
+ "window_name": "claude_code"
3945
+ },
3946
+ {
3947
+ "pane_id": "%1",
3948
+ "session_name": "s",
3949
+ "window_name": "claude_code-2"
3950
+ }
3951
+ ]
3952
+ },
3953
+ "team_owner": { "pane_id": "%0" }
3954
+ }
3955
+ }
3956
+ });
3957
+ std::fs::write(
3958
+ crate::state::persist::runtime_state_path(&workspace),
3959
+ serde_json::to_vec_pretty(&state).unwrap(),
3960
+ )
3961
+ .unwrap();
3962
+ clear_managed_leader_receiver_pane(&workspace, "%0");
3963
+ let loaded = crate::state::persist::load_runtime_state(&workspace).unwrap();
3964
+ let recv = &loaded["teams"]["current"]["leader_receiver"];
3965
+ assert_eq!(recv["pane_id"], serde_json::json!("%1"));
3966
+ assert_eq!(recv["status"], serde_json::json!("attached"));
3967
+ let panes = recv["bound_panes"].as_array().expect("bound_panes");
3968
+ assert_eq!(panes.len(), 1);
3969
+ assert_eq!(panes[0]["pane_id"], serde_json::json!("%1"));
3970
+ assert!(
3971
+ loaded["teams"]["current"]["team_owner"]
3972
+ .get("pane_id")
3973
+ .is_none(),
3974
+ "owner pane for the exited leader must be gone: {loaded}"
3975
+ );
3976
+ let _ = std::fs::remove_dir_all(&workspace);
3977
+ }
3978
+
3979
+ #[test]
3980
+ fn multi_leader_launch_window_dedup_contract() {
3981
+ let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
3982
+ let start_rs = manifest.join("src").join("leader").join("start.rs");
3983
+ let contents = std::fs::read_to_string(&start_rs).expect("read leader/start.rs");
3984
+ let start = contents
3985
+ .find("fn leader_start_plan_with_ambient_authority(")
3986
+ .expect("leader_start_plan_with_ambient_authority must exist");
3987
+ let end = contents[start + 1..]
3988
+ .find("\nfn ")
3989
+ .map(|offset| start + 1 + offset)
3990
+ .unwrap_or(contents.len());
3991
+ let body = &contents[start..end];
3992
+ assert!(
3993
+ body.contains("managed_leader_window_for_launch(provider, workspace, session)"),
3994
+ "managed launch must pick a unique window in the A-22 session; body excerpt: {body}"
3995
+ );
3996
+ let argv_start = contents
3997
+ .find("fn managed_client_argv(")
3998
+ .expect("managed_client_argv must exist");
3999
+ let argv_end = contents[argv_start + 1..]
4000
+ .find("\nfn ")
4001
+ .map(|offset| argv_start + 1 + offset)
4002
+ .unwrap_or(contents.len());
4003
+ let argv_body = &contents[argv_start..argv_end];
4004
+ assert!(
4005
+ argv_body.contains("window.as_str()"),
4006
+ "attach target must use the launch window, not a hardcoded wire name; body excerpt: {argv_body}"
4007
+ );
4008
+ assert!(
4009
+ !argv_body.contains("provider_wire(provider)"),
4010
+ "attach target must not collapse to provider_wire; body excerpt: {argv_body}"
4011
+ );
4012
+ }
2991
4013
  }