@team-agent/installer 0.5.66 → 0.5.69

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 (178) hide show
  1. package/Cargo.lock +8 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/Cargo.toml +1 -0
  4. package/crates/team-agent/src/cli/adapters.rs +5 -0
  5. package/crates/team-agent/src/cli/diagnose.rs +156 -0
  6. package/crates/team-agent/src/cli/emit.rs +6 -5
  7. package/crates/team-agent/src/cli/grok_slot.rs +299 -0
  8. package/crates/team-agent/src/cli/leader.rs +22 -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 +3 -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/missing_subcommands.rs +129 -19
  17. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +387 -6
  18. package/crates/team-agent/src/cli/tests/status_send.rs +79 -10
  19. package/crates/team-agent/src/communication_mode/mod.rs +6 -4
  20. package/crates/team-agent/src/compiler.rs +121 -5
  21. package/crates/team-agent/src/coordinator/backoff.rs +55 -0
  22. package/crates/team-agent/src/coordinator/conpty_shim.rs +82 -0
  23. package/crates/team-agent/src/coordinator/health.rs +173 -0
  24. package/crates/team-agent/src/coordinator/mod.rs +31 -0
  25. package/crates/team-agent/src/coordinator/orphan.rs +31 -0
  26. package/crates/team-agent/src/coordinator/runtime_detectors.rs +31 -0
  27. package/crates/team-agent/src/coordinator/runtime_observation.rs +25 -0
  28. package/crates/team-agent/src/coordinator/steps/abnormal.rs +59 -0
  29. package/crates/team-agent/src/coordinator/steps/delivery.rs +10 -0
  30. package/crates/team-agent/src/coordinator/steps/health_sync.rs +10 -0
  31. package/crates/team-agent/src/coordinator/steps/mod.rs +22 -0
  32. package/crates/team-agent/src/coordinator/steps/persist.rs +10 -0
  33. package/crates/team-agent/src/coordinator/steps/runtime_prompts.rs +10 -0
  34. package/crates/team-agent/src/coordinator/steps/session_gate.rs +10 -0
  35. package/crates/team-agent/src/coordinator/tick.rs +133 -22
  36. package/crates/team-agent/src/coordinator/types.rs +49 -0
  37. package/crates/team-agent/src/db/message_store.rs +39 -5
  38. package/crates/team-agent/src/layout/worker_env.rs +20 -3
  39. package/crates/team-agent/src/layout/worker_window_helpers.rs +3 -0
  40. package/crates/team-agent/src/leader/provider_attribution.rs +53 -0
  41. package/crates/team-agent/src/leader/registry.rs +65 -0
  42. package/crates/team-agent/src/leader/start.rs +973 -75
  43. package/crates/team-agent/src/lifecycle/display.rs +56 -0
  44. package/crates/team-agent/src/lifecycle/helpers.rs +57 -0
  45. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +398 -26
  46. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +80 -0
  47. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +60 -0
  48. package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +101 -11
  49. package/crates/team-agent/src/lifecycle/launch/cursor_create_chat.rs +229 -0
  50. package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +548 -0
  51. package/crates/team-agent/src/lifecycle/launch/cursor_mcp_iso.rs +120 -0
  52. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +208 -456
  53. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +24 -0
  54. package/crates/team-agent/src/lifecycle/launch/grok_per_seat.rs +260 -0
  55. package/crates/team-agent/src/lifecycle/launch/identity.rs +146 -0
  56. package/crates/team-agent/src/lifecycle/launch/layout.rs +60 -0
  57. package/crates/team-agent/src/lifecycle/launch/leader_context.rs +112 -0
  58. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +482 -1
  59. package/crates/team-agent/src/lifecycle/launch/ownership.rs +40 -0
  60. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +830 -0
  61. package/crates/team-agent/src/lifecycle/launch/plan.rs +31 -0
  62. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +66 -0
  63. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +87 -3
  64. package/crates/team-agent/src/lifecycle/launch/readiness.rs +85 -38
  65. package/crates/team-agent/src/lifecycle/launch/role_source.rs +44 -44
  66. package/crates/team-agent/src/lifecycle/launch/spawn.rs +127 -25
  67. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +140 -0
  68. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +109 -0
  69. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +214 -1
  70. package/crates/team-agent/src/lifecycle/launch.rs +107 -29
  71. package/crates/team-agent/src/lifecycle/lock.rs +42 -1
  72. package/crates/team-agent/src/lifecycle/mod.rs +11 -0
  73. package/crates/team-agent/src/lifecycle/pane_input_lock.rs +161 -0
  74. package/crates/team-agent/src/lifecycle/profile_launch.rs +99 -0
  75. package/crates/team-agent/src/lifecycle/profile_smoke.rs +52 -1
  76. package/crates/team-agent/src/lifecycle/restart/agent.rs +214 -160
  77. package/crates/team-agent/src/lifecycle/restart/common.rs +513 -37
  78. package/crates/team-agent/src/lifecycle/restart/orchestrator.rs +27 -0
  79. package/crates/team-agent/src/lifecycle/restart/preflight.rs +25 -0
  80. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +106 -0
  81. package/crates/team-agent/src/lifecycle/restart/remove.rs +233 -6
  82. package/crates/team-agent/src/lifecycle/restart/selection.rs +72 -0
  83. package/crates/team-agent/src/lifecycle/restart/team_state.rs +22 -0
  84. package/crates/team-agent/src/lifecycle/restart.rs +29 -0
  85. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +2 -3
  86. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +24 -3
  87. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +309 -0
  88. package/crates/team-agent/src/lifecycle/tests/clone_fork_copilot_perms_red.rs +150 -157
  89. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +2 -2
  90. package/crates/team-agent/src/lifecycle/tests/core.rs +4 -0
  91. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +464 -0
  92. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +247 -0
  93. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +353 -0
  94. package/crates/team-agent/src/lifecycle/tests/fixtures/pi_list_models_g0.stdout.txt +222 -0
  95. package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +787 -0
  96. package/crates/team-agent/src/lifecycle/tests/gate_fixtures.rs +562 -0
  97. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +239 -0
  98. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +485 -0
  99. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +250 -0
  100. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +293 -0
  101. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +1 -1
  102. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +63 -38
  103. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +3 -2
  104. package/crates/team-agent/src/lifecycle/tests/lifecycle_rollback_red.rs +16 -8
  105. package/crates/team-agent/src/lifecycle/tests/mcp_tool_name_format_red.rs +69 -0
  106. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +20 -19
  107. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +97 -61
  108. package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +97 -0
  109. package/crates/team-agent/src/lifecycle/tests/pi_command_red.rs +128 -0
  110. package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +207 -0
  111. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +838 -0
  112. package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +320 -0
  113. package/crates/team-agent/src/lifecycle/tests/pi_lifecycle_doctor_red.rs +98 -0
  114. package/crates/team-agent/src/lifecycle/tests/pi_session_delivery_red.rs +216 -0
  115. package/crates/team-agent/src/lifecycle/tests/restart_rebind_hotfix_252_red.rs +3 -0
  116. package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +40 -7
  117. package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +59 -1
  118. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +69 -26
  119. package/crates/team-agent/src/lifecycle/tests.rs +30 -13
  120. package/crates/team-agent/src/lifecycle/types.rs +61 -0
  121. package/crates/team-agent/src/lifecycle/worker_command_context.rs +143 -12
  122. package/crates/team-agent/src/mcp_server/tests/scoped.rs +100 -1
  123. package/crates/team-agent/src/mcp_server/tests.rs +196 -2
  124. package/crates/team-agent/src/messaging/delivery.rs +430 -41
  125. package/crates/team-agent/src/messaging/helpers.rs +2 -0
  126. package/crates/team-agent/src/messaging/leader_receiver.rs +53 -1
  127. package/crates/team-agent/src/messaging/results.rs +4 -0
  128. package/crates/team-agent/src/messaging/send.rs +34 -0
  129. package/crates/team-agent/src/messaging/tests/dup_inject.rs +406 -0
  130. package/crates/team-agent/src/messaging/tests/e23.rs +9 -0
  131. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +66 -0
  132. package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
  133. package/crates/team-agent/src/messaging/types.rs +4 -1
  134. package/crates/team-agent/src/model/enums.rs +17 -6
  135. package/crates/team-agent/src/model/permissions.rs +3 -0
  136. package/crates/team-agent/src/os_probe.rs +73 -2
  137. package/crates/team-agent/src/provider/adapter.rs +259 -6
  138. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +89 -0
  139. package/crates/team-agent/src/provider/adapters/grok.rs +157 -0
  140. package/crates/team-agent/src/provider/adapters/mod.rs +3 -0
  141. package/crates/team-agent/src/provider/adapters/pi.rs +152 -0
  142. package/crates/team-agent/src/provider/bypass_flags.rs +47 -2
  143. package/crates/team-agent/src/provider/classify.rs +4 -2
  144. package/crates/team-agent/src/provider/faults.rs +2 -1
  145. package/crates/team-agent/src/provider/mod.rs +11 -0
  146. package/crates/team-agent/src/provider/session/capture.rs +158 -20
  147. package/crates/team-agent/src/provider/session/context_fork/claude.rs +38 -1
  148. package/crates/team-agent/src/provider/session/context_fork/codex.rs +72 -1
  149. package/crates/team-agent/src/provider/session/context_fork/outcome.rs +57 -1
  150. package/crates/team-agent/src/provider/session/context_fork.rs +78 -3
  151. package/crates/team-agent/src/provider/session/mod.rs +18 -0
  152. package/crates/team-agent/src/provider/session/resume.rs +57 -0
  153. package/crates/team-agent/src/provider/session_scan/claude.rs +125 -1
  154. package/crates/team-agent/src/provider/session_scan/codex.rs +94 -1
  155. package/crates/team-agent/src/provider/session_scan/common.rs +205 -2
  156. package/crates/team-agent/src/provider/session_scan/copilot.rs +33 -1
  157. package/crates/team-agent/src/provider/session_scan/cursor.rs +538 -0
  158. package/crates/team-agent/src/provider/session_scan/grok.rs +288 -0
  159. package/crates/team-agent/src/provider/session_scan/pi.rs +172 -0
  160. package/crates/team-agent/src/provider/session_scan.rs +12 -0
  161. package/crates/team-agent/src/provider/submit_now.rs +94 -0
  162. package/crates/team-agent/src/provider/tests/adapter.rs +384 -0
  163. package/crates/team-agent/src/provider/types.rs +4 -2
  164. package/crates/team-agent/src/provider/wire.rs +29 -1
  165. package/crates/team-agent/src/state/persist.rs +301 -8
  166. package/crates/team-agent/src/state/repository.rs +5 -0
  167. package/crates/team-agent/src/tmux_backend/tests.rs +1737 -42
  168. package/crates/team-agent/src/tmux_backend.rs +1116 -75
  169. package/crates/team-agent/src/transport/tests/wire.rs +28 -2
  170. package/crates/team-agent/src/transport.rs +248 -1
  171. package/npm/install.mjs +129 -73
  172. package/package.json +4 -4
  173. package/skills/team-agent/SKILL.md +32 -239
  174. package/skills/team-agent/command-coverage.json +31 -0
  175. package/crates/team-agent/src/lifecycle/launch/fork_agent/completion.rs +0 -59
  176. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +0 -488
  177. package/crates/team-agent/src/lifecycle/launch/fork_pending.rs +0 -109
  178. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +0 -447
@@ -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,70 @@ 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
+ };
288
+ let provider_argv = if provider == Provider::Pi && mode != LeaderStartMode::AttachExisting {
289
+ let parsed = crate::lifecycle::launch::pi_mcp::parse_pi_leader_args(provider_args)
290
+ .map_err(|error| LeaderError::Start(error.to_string()))?;
291
+ let mcp_config = adapter
292
+ .mcp_config(crate::model::enums::AuthMode::Subscription)
293
+ .map_err(|error| LeaderError::Start(error.to_string()))?;
294
+ let mcp_config = crate::lifecycle::launch::resolve_mcp_config(
295
+ mcp_config,
296
+ workspace,
297
+ "leader",
298
+ identity.team_id.as_str(),
299
+ );
300
+ let prompt = crate::lifecycle::worker_command_context::compile_pi_leader_system_prompt();
301
+ let tools = ["mcp_team", "fs_read", "fs_list", "fs_write", "execute_bash"];
302
+ let team_mcp_tools = [
303
+ "assign_task",
304
+ "send_message",
305
+ "update_state",
306
+ "get_team_status",
307
+ "stop_agent",
308
+ "reset_agent",
309
+ "add_agent",
310
+ "clone_agent",
311
+ "fork_agent",
312
+ "request_human",
313
+ "stuck_list",
314
+ "stuck_cancel",
315
+ ];
316
+ crate::lifecycle::launch::pi_mcp::materialize_pi_plan(
317
+ crate::lifecycle::launch::pi_mcp::PiMaterializeRequest {
318
+ workspace,
319
+ team_id: identity.team_id.as_str(),
320
+ agent_id: "leader",
321
+ model: &parsed.model,
322
+ effort: parsed.effort,
323
+ system_prompt: &prompt,
324
+ tool_categories: &tools,
325
+ team_mcp_tools: &team_mcp_tools,
326
+ mcp_config: &mcp_config,
327
+ },
328
+ )
329
+ .map_err(|error| LeaderError::Start(error.to_string()))?
330
+ .argv
331
+ } else if provider == Provider::Pi {
332
+ Vec::new()
333
+ } else {
334
+ provider_command_argv(provider, provider_args)
335
+ };
271
336
  let argv = start_argv(
272
337
  mode,
273
338
  provider,
274
- provider_args,
339
+ &provider_argv,
275
340
  workspace,
276
341
  session_name.as_ref(),
342
+ managed_window.as_ref(),
277
343
  &leader_env,
278
344
  managed_client_attach_mode,
279
345
  )?;
@@ -287,7 +353,6 @@ fn leader_start_plan_with_ambient_authority(
287
353
  } else {
288
354
  leader_env.clone()
289
355
  };
290
- let provider_argv = provider_command_argv(provider, provider_args);
291
356
  Ok(LeaderStartPlan {
292
357
  mode,
293
358
  provider,
@@ -297,13 +362,9 @@ fn leader_start_plan_with_ambient_authority(
297
362
  argv,
298
363
  provider_argv,
299
364
  // 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))),
365
+ // named after the provider wire (e.g. `claude_code`), never the
366
+ // literal string `leader`. Occupied names get a per-launch suffix.
367
+ leader_window: managed_window,
307
368
  is_external_leader: external_path,
308
369
  leader_env: plan_env,
309
370
  identity: Some(identity),
@@ -398,7 +459,7 @@ fn execute_leader_plan_after_ambient_authority(
398
459
  } else if plan.is_external_leader {
399
460
  persist_external_leader_topology_marker(plan, workspace)?;
400
461
  }
401
- let process = run_leader_argv(&argv, &plan.leader_env, plan, workspace)?;
462
+ let process = run_leader_argv(&argv, &plan.leader_env, plan, workspace, None)?;
402
463
  let code = process.status.code();
403
464
  if !process.status.success() {
404
465
  return Err(LeaderError::Start(leader_launcher_failure(&process)));
@@ -899,32 +960,87 @@ fn managed_leader_session_from_candidates(
899
960
  .unwrap_or_else(|| managed_leader_session_name(provider, workspace))
900
961
  }
901
962
 
963
+ /// Base window is `provider_wire` (`claude_code`). A second live leader in
964
+ /// the same session cannot reuse that name: tmux then fails
965
+ /// `attach-session -t SESSION:claude_code` with `can't find window`.
966
+ fn managed_leader_window_from_occupied(
967
+ base: &str,
968
+ occupied: impl IntoIterator<Item = String>,
969
+ nonce: &str,
970
+ ) -> WindowName {
971
+ let occupied = occupied
972
+ .into_iter()
973
+ .collect::<std::collections::BTreeSet<_>>();
974
+ if !occupied.contains(base) {
975
+ return WindowName::new(base);
976
+ }
977
+ let mut candidate = format!("{base}-{nonce}");
978
+ let mut n = 2u32;
979
+ while occupied.contains(&candidate) {
980
+ candidate = format!("{base}-{nonce}-{n}");
981
+ n = n.saturating_add(1);
982
+ if n > 32 {
983
+ break;
984
+ }
985
+ }
986
+ WindowName::new(candidate)
987
+ }
988
+
989
+ fn managed_leader_window_for_launch(
990
+ provider: Provider,
991
+ workspace: &Path,
992
+ session: &SessionName,
993
+ ) -> WindowName {
994
+ let base = provider_wire(provider);
995
+ let transport = crate::transport_factory::tmux_workspace_transport(workspace);
996
+ let session_exists = transport.has_session(session).unwrap_or(false);
997
+ if !session_exists {
998
+ return WindowName::new(base);
999
+ }
1000
+ let occupied = match transport.list_windows(session) {
1001
+ Ok(windows) => windows
1002
+ .into_iter()
1003
+ .map(|window| window.as_str().to_string())
1004
+ .collect::<Vec<_>>(),
1005
+ // Cannot prove the base name is free: suffix rather than collide.
1006
+ Err(_) => vec![base.to_string()],
1007
+ };
1008
+ let pid = std::process::id();
1009
+ let epoch_nanos = std::time::SystemTime::now()
1010
+ .duration_since(std::time::UNIX_EPOCH)
1011
+ .map(|d| d.as_nanos())
1012
+ .unwrap_or(0);
1013
+ let nonce = format!("{pid:x}-{epoch_nanos:x}");
1014
+ managed_leader_window_from_occupied(base, occupied, &nonce)
1015
+ }
1016
+
902
1017
  fn start_argv(
903
1018
  mode: LeaderStartMode,
904
1019
  provider: Provider,
905
- provider_args: &[String],
1020
+ provider_argv: &[String],
906
1021
  workspace: &Path,
907
1022
  session_name: Option<&SessionName>,
1023
+ leader_window: Option<&WindowName>,
908
1024
  leader_env: &BTreeMap<String, String>,
909
1025
  managed_client_attach_mode: Option<ManagedClientAttachMode>,
910
1026
  ) -> Result<Vec<String>, LeaderError> {
911
- let provider_cmd = provider_command_name(provider).to_string();
912
1027
  match mode {
913
- LeaderStartMode::ExecProvider => {
914
- let mut argv = vec![provider_cmd];
915
- argv.extend(normalized_provider_args(provider_args));
916
- Ok(argv)
917
- }
1028
+ LeaderStartMode::ExecProvider => Ok(provider_argv.to_vec()),
918
1029
  LeaderStartMode::ManagedTmuxClient => {
919
1030
  let Some(session) = session_name else {
920
1031
  return Err(LeaderError::Start(
921
1032
  "managed leader session missing".to_string(),
922
1033
  ));
923
1034
  };
1035
+ let Some(window) = leader_window else {
1036
+ return Err(LeaderError::Start(
1037
+ "managed leader window missing".to_string(),
1038
+ ));
1039
+ };
924
1040
  let attach_mode = managed_client_attach_mode.ok_or_else(|| {
925
1041
  LeaderError::Start("managed client attach mode missing".to_string())
926
1042
  })?;
927
- managed_client_argv(workspace, session, provider, attach_mode)
1043
+ managed_client_argv(workspace, session, window, attach_mode)
928
1044
  }
929
1045
  LeaderStartMode::AttachExisting => {
930
1046
  let Some(session) = session_name else {
@@ -947,13 +1063,11 @@ fn start_argv(
947
1063
  if let Some(path) = std::env::var_os("PATH").and_then(|p| p.into_string().ok()) {
948
1064
  exports.push(shlex_quote(&format!("PATH={path}")));
949
1065
  }
950
- let mut provider_argv = vec![provider_cmd];
951
- provider_argv.extend(normalized_provider_args(provider_args));
952
1066
  let shell = format!(
953
1067
  "cd {} && export {} && exec {}",
954
1068
  shlex_quote(&resolved_workspace.to_string_lossy()),
955
1069
  exports.join(" "),
956
- shell_join(&provider_argv)
1070
+ shell_join(provider_argv)
957
1071
  );
958
1072
  let argv = vec![
959
1073
  "tmux".to_string(),
@@ -996,13 +1110,13 @@ fn normalized_provider_args(provider_args: &[String]) -> impl Iterator<Item = St
996
1110
  fn managed_client_argv(
997
1111
  workspace: &Path,
998
1112
  session: &SessionName,
999
- provider: Provider,
1113
+ window: &WindowName,
1000
1114
  attach_mode: ManagedClientAttachMode,
1001
1115
  ) -> 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));
1116
+ // Attach to the launch's actual window (base `provider_wire` or a unique
1117
+ // suffix). Targeting only `provider_wire` collides when two live leaders
1118
+ // share the A-22 session (`can't find window: claude_code`).
1119
+ let target = format!("{}:{}", session.as_str(), window.as_str());
1006
1120
  let argv = match attach_mode {
1007
1121
  ManagedClientAttachMode::SwitchClient => vec![
1008
1122
  "tmux".to_string(),
@@ -1085,7 +1199,20 @@ fn execute_managed_leader_plan_attempt(
1085
1199
  workspace.to_path_buf(),
1086
1200
  spawned.pane_id.as_str().to_string(),
1087
1201
  );
1088
- let process = match run_leader_argv(&plan.argv, &BTreeMap::new(), plan, workspace) {
1202
+ let process = match run_leader_argv(
1203
+ &plan.argv,
1204
+ &BTreeMap::new(),
1205
+ plan,
1206
+ workspace,
1207
+ Some(ManagedAttachWatch {
1208
+ transport: &transport,
1209
+ spawned: &spawned,
1210
+ session,
1211
+ session_existed_before,
1212
+ workspace,
1213
+ provider_label: provider_command_name(plan.provider),
1214
+ }),
1215
+ ) {
1089
1216
  Ok(process) => process,
1090
1217
  Err(error) => {
1091
1218
  cleanup_managed_leader_resources(
@@ -1099,6 +1226,14 @@ fn execute_managed_leader_plan_attempt(
1099
1226
  return Err(error);
1100
1227
  }
1101
1228
  };
1229
+ if process.intentional_teardown {
1230
+ return Ok(LeaderLaunchOutcome {
1231
+ status: LeaderLaunchStatus::Exited,
1232
+ exit_code: Some(0),
1233
+ session_name: plan.session_name.clone(),
1234
+ reason: None,
1235
+ });
1236
+ }
1102
1237
  let code = process.status.code();
1103
1238
  if !process.status.success() {
1104
1239
  cleanup_managed_leader_resources(
@@ -1141,14 +1276,16 @@ fn managed_leader_attach_recovery_plan(
1141
1276
  workspace: &Path,
1142
1277
  ) -> Result<LeaderStartPlan, LeaderError> {
1143
1278
  let session = managed_leader_session_for_launch(plan.provider, workspace);
1279
+ let window = managed_leader_window_for_launch(plan.provider, workspace, &session);
1144
1280
  let attach_mode = if plan.argv.iter().any(|arg| arg == "switch-client") {
1145
1281
  ManagedClientAttachMode::SwitchClient
1146
1282
  } else {
1147
1283
  ManagedClientAttachMode::AttachSession
1148
1284
  };
1149
- let argv = managed_client_argv(workspace, &session, plan.provider, attach_mode)?;
1285
+ let argv = managed_client_argv(workspace, &session, &window, attach_mode)?;
1150
1286
  let mut retry_plan = plan.clone();
1151
1287
  retry_plan.session_name = Some(session);
1288
+ retry_plan.leader_window = Some(window);
1152
1289
  retry_plan.argv = argv;
1153
1290
  Ok(retry_plan)
1154
1291
  }
@@ -1200,6 +1337,222 @@ fn ensure_managed_leader_pane(
1200
1337
  }
1201
1338
  }
1202
1339
 
1340
+ struct ManagedAttachWatch<'a> {
1341
+ transport: &'a dyn Transport,
1342
+ spawned: &'a SpawnResult,
1343
+ session: &'a SessionName,
1344
+ session_existed_before: bool,
1345
+ workspace: &'a Path,
1346
+ provider_label: &'a str,
1347
+ }
1348
+
1349
+ #[cfg(unix)]
1350
+ static MANAGED_ATTACH_TEARDOWN: AtomicBool = AtomicBool::new(false);
1351
+
1352
+ #[cfg(unix)]
1353
+ struct ManagedTermGuard {
1354
+ old: libc::sighandler_t,
1355
+ }
1356
+
1357
+ #[cfg(unix)]
1358
+ impl ManagedTermGuard {
1359
+ fn install() -> Self {
1360
+ MANAGED_ATTACH_TEARDOWN.store(false, Ordering::SeqCst);
1361
+ let handler = managed_term_handler as *const () as libc::sighandler_t;
1362
+ let old = unsafe { libc::signal(libc::SIGTERM, handler) };
1363
+ Self { old }
1364
+ }
1365
+ }
1366
+
1367
+ #[cfg(unix)]
1368
+ impl Drop for ManagedTermGuard {
1369
+ fn drop(&mut self) {
1370
+ unsafe {
1371
+ libc::signal(libc::SIGTERM, self.old);
1372
+ }
1373
+ MANAGED_ATTACH_TEARDOWN.store(false, Ordering::SeqCst);
1374
+ }
1375
+ }
1376
+
1377
+ #[cfg(unix)]
1378
+ extern "C" fn managed_term_handler(_: libc::c_int) {
1379
+ MANAGED_ATTACH_TEARDOWN.store(true, Ordering::SeqCst);
1380
+ }
1381
+
1382
+ fn managed_attach_should_teardown(watch: &ManagedAttachWatch<'_>) -> bool {
1383
+ let marker = crate::tmux_backend::leader_provider_exit_marker(watch.provider_label);
1384
+ let target = Target::Pane(watch.spawned.pane_id.clone());
1385
+ if let Ok(cap) = watch.transport.capture(&target, CaptureRange::Tail(80)) {
1386
+ if cap.text.contains(&marker) || cap.text.contains("[team-agent] Provider exited") {
1387
+ return true;
1388
+ }
1389
+ }
1390
+ matches!(
1391
+ leader_provider_health(
1392
+ watch.transport,
1393
+ &watch.spawned.pane_id,
1394
+ watch.provider_label
1395
+ ),
1396
+ LeaderProviderHealth::ProviderExited | LeaderProviderHealth::Unreachable
1397
+ )
1398
+ }
1399
+
1400
+ fn bound_pane_snapshot(
1401
+ pane_id: &str,
1402
+ session_name: &serde_json::Value,
1403
+ window_name: &serde_json::Value,
1404
+ tmux_socket: &serde_json::Value,
1405
+ ) -> serde_json::Value {
1406
+ serde_json::json!({
1407
+ "pane_id": pane_id,
1408
+ "session_name": session_name,
1409
+ "window_name": window_name,
1410
+ "tmux_socket": tmux_socket,
1411
+ })
1412
+ }
1413
+
1414
+ fn merge_bound_panes(
1415
+ existing: Option<&serde_json::Value>,
1416
+ new_pane: serde_json::Value,
1417
+ ) -> Vec<serde_json::Value> {
1418
+ let new_id = new_pane
1419
+ .get("pane_id")
1420
+ .and_then(serde_json::Value::as_str)
1421
+ .unwrap_or("")
1422
+ .to_string();
1423
+ let mut panes = Vec::new();
1424
+ if let Some(existing) = existing {
1425
+ if let Some(arr) = existing
1426
+ .get("bound_panes")
1427
+ .and_then(serde_json::Value::as_array)
1428
+ {
1429
+ panes.extend(arr.iter().cloned());
1430
+ } else if let Some(old_pane) = existing.get("pane_id").and_then(serde_json::Value::as_str) {
1431
+ panes.push(bound_pane_snapshot(
1432
+ old_pane,
1433
+ existing
1434
+ .get("session_name")
1435
+ .unwrap_or(&serde_json::Value::Null),
1436
+ existing
1437
+ .get("window_name")
1438
+ .unwrap_or(&serde_json::Value::Null),
1439
+ existing
1440
+ .get("tmux_socket")
1441
+ .unwrap_or(&serde_json::Value::Null),
1442
+ ));
1443
+ }
1444
+ }
1445
+ if !new_id.is_empty()
1446
+ && !panes.iter().any(|pane| {
1447
+ pane.get("pane_id").and_then(serde_json::Value::as_str) == Some(new_id.as_str())
1448
+ })
1449
+ {
1450
+ panes.push(new_pane);
1451
+ }
1452
+ panes
1453
+ }
1454
+
1455
+ fn apply_clear_to_leader_receiver(
1456
+ recv: &mut serde_json::Map<String, serde_json::Value>,
1457
+ pane_id: &str,
1458
+ ) -> bool {
1459
+ let mut changed = false;
1460
+ if let Some(arr) = recv
1461
+ .get_mut("bound_panes")
1462
+ .and_then(serde_json::Value::as_array_mut)
1463
+ {
1464
+ let before = arr.len();
1465
+ arr.retain(|pane| pane.get("pane_id").and_then(serde_json::Value::as_str) != Some(pane_id));
1466
+ if arr.len() != before {
1467
+ changed = true;
1468
+ }
1469
+ }
1470
+ let scalar_match = recv.get("pane_id").and_then(serde_json::Value::as_str) == Some(pane_id);
1471
+ if scalar_match {
1472
+ let promoted = recv
1473
+ .get("bound_panes")
1474
+ .and_then(serde_json::Value::as_array)
1475
+ .and_then(|arr| arr.first())
1476
+ .cloned();
1477
+ if let Some(next) = promoted {
1478
+ recv.insert("status".to_string(), serde_json::json!("attached"));
1479
+ recv.insert(
1480
+ "pane_id".to_string(),
1481
+ next.get("pane_id")
1482
+ .cloned()
1483
+ .unwrap_or(serde_json::Value::Null),
1484
+ );
1485
+ recv.insert(
1486
+ "pane".to_string(),
1487
+ next.get("pane_id")
1488
+ .cloned()
1489
+ .unwrap_or(serde_json::Value::Null),
1490
+ );
1491
+ if let Some(session) = next.get("session_name") {
1492
+ recv.insert("session_name".to_string(), session.clone());
1493
+ }
1494
+ if let Some(window) = next.get("window_name") {
1495
+ recv.insert("window_name".to_string(), window.clone());
1496
+ }
1497
+ } else {
1498
+ recv.insert("status".to_string(), serde_json::json!("exited"));
1499
+ recv.remove("pane_id");
1500
+ recv.remove("pane");
1501
+ }
1502
+ changed = true;
1503
+ }
1504
+ changed
1505
+ }
1506
+
1507
+ fn clear_managed_leader_receiver_pane(workspace: &Path, pane_id: &str) {
1508
+ let Ok(mut state) = crate::state::persist::load_runtime_state(workspace) else {
1509
+ return;
1510
+ };
1511
+ let mut changed = false;
1512
+ if let Some(teams) = state
1513
+ .get_mut("teams")
1514
+ .and_then(serde_json::Value::as_object_mut)
1515
+ {
1516
+ for (_key, team) in teams.iter_mut() {
1517
+ let Some(team_obj) = team.as_object_mut() else {
1518
+ continue;
1519
+ };
1520
+ if let Some(recv) = team_obj
1521
+ .get_mut("leader_receiver")
1522
+ .and_then(serde_json::Value::as_object_mut)
1523
+ {
1524
+ if apply_clear_to_leader_receiver(recv, pane_id) {
1525
+ changed = true;
1526
+ }
1527
+ }
1528
+ if let Some(owner) = team_obj
1529
+ .get_mut("team_owner")
1530
+ .and_then(serde_json::Value::as_object_mut)
1531
+ {
1532
+ if owner.get("pane_id").and_then(serde_json::Value::as_str) == Some(pane_id) {
1533
+ owner.remove("pane_id");
1534
+ changed = true;
1535
+ }
1536
+ }
1537
+ }
1538
+ }
1539
+ if !changed {
1540
+ return;
1541
+ }
1542
+ let team_key = state
1543
+ .get("active_team_key")
1544
+ .and_then(serde_json::Value::as_str)
1545
+ .unwrap_or("current")
1546
+ .to_string();
1547
+ let _ = crate::state::repository::StateRepository::new(workspace).save(
1548
+ crate::state::repository::StateWriteIntent::LeaderStartBinding {
1549
+ team_key: &team_key,
1550
+ transport_kind: "managed",
1551
+ },
1552
+ &state,
1553
+ );
1554
+ }
1555
+
1203
1556
  fn cleanup_managed_leader_resources(
1204
1557
  transport: &dyn Transport,
1205
1558
  session: &SessionName,
@@ -1208,23 +1561,36 @@ fn cleanup_managed_leader_resources(
1208
1561
  workspace: &Path,
1209
1562
  reason: &str,
1210
1563
  ) {
1211
- let (action, result) = if session_existed_before {
1212
- (
1213
- "kill_pane",
1214
- transport
1215
- .kill_pane(&spawned.pane_id)
1216
- .map(|_| "ok".to_string())
1217
- .unwrap_or_else(|error| error.to_string()),
1218
- )
1219
- } else {
1220
- (
1221
- "kill_session",
1222
- transport
1223
- .kill_session(session)
1224
- .map(|_| "ok".to_string())
1225
- .unwrap_or_else(|error| error.to_string()),
1226
- )
1564
+ // Own window only — never kill-server (same socket may host other teams).
1565
+ // Last window in the session lets tmux destroy the session itself.
1566
+ let window_target = Target::SessionWindow {
1567
+ session: spawned.session.clone(),
1568
+ window: spawned.window.clone(),
1227
1569
  };
1570
+ let window_result = transport
1571
+ .kill_window(&window_target)
1572
+ .map(|_| "ok".to_string())
1573
+ .unwrap_or_else(|error| error.to_string());
1574
+ let mut action = "kill_window".to_string();
1575
+ let mut result = window_result;
1576
+ if !session_existed_before {
1577
+ match transport.list_windows(session) {
1578
+ Ok(windows) if windows.is_empty() => {
1579
+ let session_result = transport
1580
+ .kill_session(session)
1581
+ .map(|_| "ok".to_string())
1582
+ .unwrap_or_else(|error| error.to_string());
1583
+ action = "kill_window+kill_session".to_string();
1584
+ result = format!("{result};kill_session={session_result}");
1585
+ }
1586
+ Ok(_) => {}
1587
+ Err(error) => {
1588
+ action = "kill_window+list_windows_err".to_string();
1589
+ result = format!("{result};list_windows={error}");
1590
+ }
1591
+ }
1592
+ }
1593
+ clear_managed_leader_receiver_pane(workspace, spawned.pane_id.as_str());
1228
1594
  write_leader_startup_prompt_event(
1229
1595
  workspace,
1230
1596
  "leader.launcher.rollback",
@@ -1411,17 +1777,12 @@ fn persist_managed_leader_binding(
1411
1777
  .ok_or_else(|| LeaderError::Start("managed leader identity missing".to_string()))?;
1412
1778
  let mut state = crate::state::persist::load_runtime_state(workspace)
1413
1779
  .unwrap_or_else(|_| serde_json::json!({}));
1414
- let owner_epoch = state
1415
- .get("owner_epoch")
1416
- .and_then(serde_json::Value::as_u64)
1417
- .or_else(|| {
1418
- state
1419
- .get("team_owner")
1420
- .and_then(|owner| owner.get("owner_epoch"))
1421
- .and_then(serde_json::Value::as_u64)
1422
- })
1423
- .unwrap_or(0)
1424
- .saturating_add(1);
1780
+ let team_key = identity.team_id.as_str().to_string();
1781
+ let existing_recv = state
1782
+ .get("teams")
1783
+ .and_then(|teams| teams.get(&team_key))
1784
+ .and_then(|team| team.get("leader_receiver"))
1785
+ .cloned();
1425
1786
  let now = chrono::Utc::now().to_rfc3339();
1426
1787
  let socket = crate::tmux_backend::socket_path_for_workspace(workspace)
1427
1788
  .map(|path| path.to_string_lossy().to_string())
@@ -1430,27 +1791,81 @@ fn persist_managed_leader_binding(
1430
1791
  let session = spawned.session.as_str().to_string();
1431
1792
  let window = spawned.window.as_str().to_string();
1432
1793
  let pane = spawned.pane_id.as_str().to_string();
1794
+ let keep_existing_scalar = existing_recv
1795
+ .as_ref()
1796
+ .and_then(|recv| recv.get("pane_id"))
1797
+ .and_then(serde_json::Value::as_str)
1798
+ .is_some_and(|old| old != pane);
1799
+ let current_epoch = state
1800
+ .get("owner_epoch")
1801
+ .and_then(serde_json::Value::as_u64)
1802
+ .or_else(|| {
1803
+ state
1804
+ .get("team_owner")
1805
+ .and_then(|owner| owner.get("owner_epoch"))
1806
+ .and_then(serde_json::Value::as_u64)
1807
+ })
1808
+ .unwrap_or(0);
1809
+ let owner_epoch = if keep_existing_scalar {
1810
+ existing_recv
1811
+ .as_ref()
1812
+ .and_then(|recv| recv.get("owner_epoch"))
1813
+ .and_then(serde_json::Value::as_u64)
1814
+ .unwrap_or(current_epoch)
1815
+ } else {
1816
+ current_epoch.saturating_add(1)
1817
+ };
1433
1818
  let attach_mode = if plan.argv.iter().any(|arg| arg == "switch-client") {
1434
1819
  "switch-client"
1435
1820
  } else {
1436
1821
  "attach-session"
1437
1822
  };
1823
+ let new_binding = bound_pane_snapshot(
1824
+ &pane,
1825
+ &serde_json::json!(session),
1826
+ &serde_json::json!(window),
1827
+ &serde_json::json!(socket),
1828
+ );
1829
+ let bound_panes = merge_bound_panes(existing_recv.as_ref(), new_binding);
1830
+ let (scalar_pane, scalar_session, scalar_window, scalar_attached_at) =
1831
+ match existing_recv.as_ref() {
1832
+ Some(recv) if keep_existing_scalar => (
1833
+ recv.get("pane_id")
1834
+ .and_then(serde_json::Value::as_str)
1835
+ .unwrap_or(pane.as_str())
1836
+ .to_string(),
1837
+ recv.get("session_name")
1838
+ .and_then(serde_json::Value::as_str)
1839
+ .unwrap_or(session.as_str())
1840
+ .to_string(),
1841
+ recv.get("window_name")
1842
+ .and_then(serde_json::Value::as_str)
1843
+ .unwrap_or(window.as_str())
1844
+ .to_string(),
1845
+ recv.get("attached_at")
1846
+ .and_then(serde_json::Value::as_str)
1847
+ .unwrap_or(now.as_str())
1848
+ .to_string(),
1849
+ ),
1850
+ _ => (pane.clone(), session.clone(), window.clone(), now.clone()),
1851
+ };
1438
1852
  let receiver = serde_json::json!({
1439
1853
  "mode": "direct_tmux",
1440
1854
  "status": "attached",
1441
1855
  "provider": provider.clone(),
1442
- "pane_id": pane,
1443
- "pane": pane,
1444
- "session_name": session,
1445
- "window_name": window,
1856
+ "pane_id": scalar_pane,
1857
+ "pane": scalar_pane,
1858
+ "session_name": scalar_session,
1859
+ "window_name": scalar_window,
1446
1860
  "tmux_socket": socket,
1447
1861
  "leader_session_uuid": identity.leader_session_uuid,
1448
1862
  "owner_epoch": owner_epoch,
1449
- "attached_at": now,
1863
+ "attached_at": scalar_attached_at,
1450
1864
  "discovery": "managed_launcher",
1865
+ "bound_panes": bound_panes,
1451
1866
  });
1452
1867
  let owner = serde_json::json!({
1453
- "pane_id": pane,
1868
+ "pane_id": scalar_pane,
1454
1869
  "provider": provider.clone(),
1455
1870
  "machine_fingerprint": identity.machine_fingerprint,
1456
1871
  "leader_session_uuid": identity.leader_session_uuid,
@@ -1908,6 +2323,7 @@ fn run_leader_argv(
1908
2323
  env: &BTreeMap<String, String>,
1909
2324
  plan: &LeaderStartPlan,
1910
2325
  workspace: &Path,
2326
+ managed_watch: Option<ManagedAttachWatch<'_>>,
1911
2327
  ) -> Result<LeaderProcessExit, LeaderError> {
1912
2328
  let Some(program) = argv.first() else {
1913
2329
  return Err(LeaderError::Start(
@@ -2013,7 +2429,13 @@ fn run_leader_argv(
2013
2429
  if plan.mode == LeaderStartMode::ExecProvider {
2014
2430
  spawn_exec_provider_startup_prompt_handler(plan.provider, workspace.to_path_buf());
2015
2431
  }
2016
- let status = child.wait().map_err(LeaderError::Io)?;
2432
+ #[cfg(unix)]
2433
+ let _term_guard = managed_watch.as_ref().map(|_| ManagedTermGuard::install());
2434
+ let (status, intentional_teardown) = if let Some(watch) = managed_watch.as_ref() {
2435
+ wait_managed_attach_child(&mut child, watch)?
2436
+ } else {
2437
+ (child.wait().map_err(LeaderError::Io)?, false)
2438
+ };
2017
2439
  let stdout = match stdout_reader {
2018
2440
  Some(reader) => match reader.join() {
2019
2441
  Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
@@ -2050,9 +2472,88 @@ fn run_leader_argv(
2050
2472
  status,
2051
2473
  stderr,
2052
2474
  diagnostics_path,
2475
+ intentional_teardown,
2053
2476
  })
2054
2477
  }
2055
2478
 
2479
+ fn wait_managed_attach_child(
2480
+ child: &mut std::process::Child,
2481
+ watch: &ManagedAttachWatch<'_>,
2482
+ ) -> Result<(std::process::ExitStatus, bool), LeaderError> {
2483
+ loop {
2484
+ if let Some(status) = child.try_wait().map_err(LeaderError::Io)? {
2485
+ return Ok((status, false));
2486
+ }
2487
+ let signalled = {
2488
+ #[cfg(unix)]
2489
+ {
2490
+ MANAGED_ATTACH_TEARDOWN.load(Ordering::SeqCst)
2491
+ }
2492
+ #[cfg(not(unix))]
2493
+ {
2494
+ false
2495
+ }
2496
+ };
2497
+ if signalled || managed_attach_should_teardown(watch) {
2498
+ // `managed_attach_should_teardown` costs a tmux round-trip, and the
2499
+ // attach child can lose that race: it dies BY ITSELF inside the
2500
+ // window (`open terminal failed: not a terminal`, or a client whose
2501
+ // attach was rejected). Re-check before we touch anything. A child
2502
+ // that already failed on its own was never torn down by us, so it
2503
+ // must NOT be reported as an intentional teardown — its non-zero
2504
+ // status has to reach the launcher failure path
2505
+ // (`execute_managed_leader_plan_attempt`), which is what surfaces the
2506
+ // error and drives attach recovery. Only the FAILING self-exit is
2507
+ // diverted: a clean self-exit keeps the pre-existing behaviour
2508
+ // (cleanup + intentional teardown + exit 0), and an explicit signal
2509
+ // teardown stays intentional regardless.
2510
+ if !signalled {
2511
+ if let Some(status) = child.try_wait().map_err(LeaderError::Io)? {
2512
+ if !status.success() {
2513
+ return Ok((status, false));
2514
+ }
2515
+ }
2516
+ }
2517
+ let reason = if signalled {
2518
+ "launcher_signal"
2519
+ } else {
2520
+ "provider_exited"
2521
+ };
2522
+ cleanup_managed_leader_resources(
2523
+ watch.transport,
2524
+ watch.session,
2525
+ watch.spawned,
2526
+ watch.session_existed_before,
2527
+ watch.workspace,
2528
+ reason,
2529
+ );
2530
+ let status = terminate_managed_attach_child(child)?;
2531
+ return Ok((status, true));
2532
+ }
2533
+ std::thread::sleep(Duration::from_millis(200));
2534
+ }
2535
+ }
2536
+
2537
+ fn terminate_managed_attach_child(
2538
+ child: &mut std::process::Child,
2539
+ ) -> Result<std::process::ExitStatus, LeaderError> {
2540
+ if let Some(status) = child.try_wait().map_err(LeaderError::Io)? {
2541
+ return Ok(status);
2542
+ }
2543
+ #[cfg(unix)]
2544
+ unsafe {
2545
+ libc::kill(child.id() as i32, libc::SIGTERM);
2546
+ }
2547
+ for _ in 0..20 {
2548
+ std::thread::sleep(Duration::from_millis(50));
2549
+ if let Some(status) = child.try_wait().map_err(LeaderError::Io)? {
2550
+ return Ok(status);
2551
+ }
2552
+ }
2553
+ let _ = child.kill();
2554
+ child.wait().map_err(LeaderError::Io)
2555
+ }
2556
+
2056
2557
  fn launcher_diagnostics_path(workspace: &Path) -> PathBuf {
2057
2558
  let stamp = chrono::Utc::now().format("%Y%m%d-%H%M%S%.6f");
2058
2559
  workspace.join(".team").join("logs").join(format!(
@@ -2092,6 +2593,7 @@ struct LeaderProcessExit {
2092
2593
  status: std::process::ExitStatus,
2093
2594
  stderr: String,
2094
2595
  diagnostics_path: PathBuf,
2596
+ intentional_teardown: bool,
2095
2597
  }
2096
2598
 
2097
2599
  fn push_bounded_stderr(captured: &mut Vec<u8>, chunk: &[u8]) {
@@ -2313,6 +2815,9 @@ fn provider_command_name(provider: Provider) -> &'static str {
2313
2815
  // (leader/start.rs:192-204 派生)自动覆盖前缀保护。
2314
2816
  Provider::Copilot => "copilot",
2315
2817
  Provider::GeminiCli => "gemini",
2818
+ Provider::Grok => "grok",
2819
+ Provider::CursorAgent => "agent",
2820
+ Provider::Pi => "pi",
2316
2821
  Provider::Fake => "fake",
2317
2822
  }
2318
2823
  }
@@ -2396,10 +2901,13 @@ mod tests {
2396
2901
  };
2397
2902
 
2398
2903
  use super::{
2399
- ensure_managed_provider_live_after_attach, execute_leader_plan_after_ambient_authority,
2400
- format_launcher_failure, handle_exec_provider_startup_prompts,
2401
- is_interactive_shell_basename, lock_down_window_rename, push_bounded_stderr, shlex_quote,
2402
- write_window_rename_lockdown_event, VerifiedAmbientPaneAuthority, LEADER_STDERR_LIMIT,
2904
+ clear_managed_leader_receiver_pane, ensure_managed_provider_live_after_attach,
2905
+ execute_leader_plan_after_ambient_authority, format_launcher_failure,
2906
+ handle_exec_provider_startup_prompts, is_interactive_shell_basename,
2907
+ lock_down_window_rename, managed_client_argv, managed_leader_window_from_occupied,
2908
+ persist_managed_leader_binding, push_bounded_stderr, shlex_quote,
2909
+ write_window_rename_lockdown_event, ManagedClientAttachMode, VerifiedAmbientPaneAuthority,
2910
+ LEADER_STDERR_LIMIT,
2403
2911
  };
2404
2912
 
2405
2913
  struct ScriptedTransport {
@@ -2409,6 +2917,8 @@ mod tests {
2409
2917
  targets: Vec<PaneInfo>,
2410
2918
  window_options: Mutex<Vec<(String, String)>>,
2411
2919
  window_option_error: Mutex<Option<String>>,
2920
+ kills: Mutex<Vec<String>>,
2921
+ windows: Mutex<Vec<WindowName>>,
2412
2922
  }
2413
2923
 
2414
2924
  impl ScriptedTransport {
@@ -2420,6 +2930,8 @@ mod tests {
2420
2930
  targets: Vec::new(),
2421
2931
  window_options: Mutex::new(Vec::new()),
2422
2932
  window_option_error: Mutex::new(None),
2933
+ kills: Mutex::new(Vec::new()),
2934
+ windows: Mutex::new(Vec::new()),
2423
2935
  }
2424
2936
  }
2425
2937
 
@@ -2431,6 +2943,8 @@ mod tests {
2431
2943
  targets: Vec::new(),
2432
2944
  window_options: Mutex::new(Vec::new()),
2433
2945
  window_option_error: Mutex::new(None),
2946
+ kills: Mutex::new(Vec::new()),
2947
+ windows: Mutex::new(Vec::new()),
2434
2948
  }
2435
2949
  }
2436
2950
 
@@ -2442,6 +2956,8 @@ mod tests {
2442
2956
  targets,
2443
2957
  window_options: Mutex::new(Vec::new()),
2444
2958
  window_option_error: Mutex::new(None),
2959
+ kills: Mutex::new(Vec::new()),
2960
+ windows: Mutex::new(Vec::new()),
2445
2961
  }
2446
2962
  }
2447
2963
 
@@ -2455,6 +2971,8 @@ mod tests {
2455
2971
  targets: Vec::new(),
2456
2972
  window_options: Mutex::new(Vec::new()),
2457
2973
  window_option_error: Mutex::new(Some(error.to_string())),
2974
+ kills: Mutex::new(Vec::new()),
2975
+ windows: Mutex::new(Vec::new()),
2458
2976
  }
2459
2977
  }
2460
2978
 
@@ -2472,6 +2990,20 @@ mod tests {
2472
2990
  Err(poisoned) => poisoned.into_inner().clone(),
2473
2991
  }
2474
2992
  }
2993
+
2994
+ fn record_kill(&self, action: &str) {
2995
+ match self.kills.lock() {
2996
+ Ok(mut guard) => guard.push(action.to_string()),
2997
+ Err(poisoned) => poisoned.into_inner().push(action.to_string()),
2998
+ }
2999
+ }
3000
+
3001
+ fn kills(&self) -> Vec<String> {
3002
+ match self.kills.lock() {
3003
+ Ok(guard) => guard.clone(),
3004
+ Err(poisoned) => poisoned.into_inner().clone(),
3005
+ }
3006
+ }
2475
3007
  }
2476
3008
 
2477
3009
  impl Transport for ScriptedTransport {
@@ -2576,7 +3108,10 @@ mod tests {
2576
3108
  }
2577
3109
 
2578
3110
  fn list_windows(&self, _session: &SessionName) -> Result<Vec<WindowName>, TransportError> {
2579
- Ok(Vec::new())
3111
+ match self.windows.lock() {
3112
+ Ok(guard) => Ok(guard.clone()),
3113
+ Err(poisoned) => Ok(poisoned.into_inner().clone()),
3114
+ }
2580
3115
  }
2581
3116
 
2582
3117
  fn set_session_env(
@@ -2616,11 +3151,22 @@ mod tests {
2616
3151
  Ok(())
2617
3152
  }
2618
3153
 
2619
- fn kill_session(&self, _session: &SessionName) -> Result<(), TransportError> {
3154
+ fn kill_server(&self) -> Result<(), TransportError> {
3155
+ self.record_kill("kill_server");
2620
3156
  Ok(())
2621
3157
  }
2622
3158
 
2623
- fn kill_window(&self, _target: &Target) -> Result<(), TransportError> {
3159
+ fn kill_session(&self, session: &SessionName) -> Result<(), TransportError> {
3160
+ self.record_kill(&format!("kill_session:{}", session.as_str()));
3161
+ Ok(())
3162
+ }
3163
+
3164
+ fn kill_window(&self, target: &Target) -> Result<(), TransportError> {
3165
+ self.record_kill(&format!("kill_window:{target:?}"));
3166
+ match self.windows.lock() {
3167
+ Ok(mut guard) => guard.clear(),
3168
+ Err(poisoned) => poisoned.into_inner().clear(),
3169
+ }
2624
3170
  Ok(())
2625
3171
  }
2626
3172
 
@@ -3130,7 +3676,10 @@ mod tests {
3130
3676
  // (recorded for audit) instead of propagating an error.
3131
3677
  let (result, stderr) = lock_down_window_rename(&transport, &session, &window);
3132
3678
 
3133
- assert_eq!(result, "failed", "a set-window-option failure is audited, not fatal");
3679
+ assert_eq!(
3680
+ result, "failed",
3681
+ "a set-window-option failure is audited, not fatal"
3682
+ );
3134
3683
  assert!(
3135
3684
  stderr.contains("unknown option"),
3136
3685
  "tmux stderr must surface in the audit payload: {stderr}"
@@ -3141,16 +3690,365 @@ mod tests {
3141
3690
  let events = crate::event_log::EventLog::new(&workspace).tail(0).unwrap();
3142
3691
  let lockdown = events
3143
3692
  .iter()
3144
- .find(|event| {
3145
- event["event"] == "leader.launcher.window_rename_lockdown"
3146
- })
3693
+ .find(|event| event["event"] == "leader.launcher.window_rename_lockdown")
3147
3694
  .expect("rename_lockdown event must be written even on failure");
3148
3695
  assert_eq!(lockdown["result"], "failed");
3149
3696
  assert!(
3150
- lockdown["tmux_stderr"].as_str().unwrap_or_default().contains("unknown option"),
3697
+ lockdown["tmux_stderr"]
3698
+ .as_str()
3699
+ .unwrap_or_default()
3700
+ .contains("unknown option"),
3151
3701
  "event must carry tmux stderr for regression triage: {lockdown}"
3152
3702
  );
3153
3703
 
3154
3704
  std::fs::remove_dir_all(&workspace).ok();
3155
3705
  }
3706
+
3707
+ #[test]
3708
+ fn cleanup_managed_leader_kills_own_window_not_server() {
3709
+ let workspace =
3710
+ std::env::temp_dir().join(format!("ta-cleanup-own-window-{}", std::process::id()));
3711
+ std::fs::create_dir_all(&workspace).unwrap();
3712
+ let spawned = managed_spawn_result();
3713
+ let transport = ScriptedTransport::with_liveness(PaneLiveness::Live);
3714
+ super::cleanup_managed_leader_resources(
3715
+ &transport,
3716
+ &spawned.session,
3717
+ &spawned,
3718
+ false,
3719
+ &workspace,
3720
+ "test_own_window",
3721
+ );
3722
+ let kills = transport.kills();
3723
+ assert!(
3724
+ kills.iter().any(|k| k.starts_with("kill_window:")),
3725
+ "cleanup must kill the launched window: {kills:?}"
3726
+ );
3727
+ assert!(
3728
+ !kills.iter().any(|k| k == "kill_server"),
3729
+ "cleanup must not kill-server: {kills:?}"
3730
+ );
3731
+ let _ = std::fs::remove_dir_all(&workspace);
3732
+ }
3733
+
3734
+ #[test]
3735
+ fn cleanup_managed_leader_clears_stale_pane_pointer() {
3736
+ let workspace =
3737
+ std::env::temp_dir().join(format!("ta-cleanup-pane-pointer-{}", std::process::id()));
3738
+ std::fs::create_dir_all(workspace.join(".team").join("runtime")).unwrap();
3739
+ let state = serde_json::json!({
3740
+ "active_team_key": "current",
3741
+ "teams": {
3742
+ "current": {
3743
+ "leader_receiver": {
3744
+ "status": "attached",
3745
+ "pane_id": "%42",
3746
+ "pane": "%42"
3747
+ },
3748
+ "team_owner": { "pane_id": "%42" }
3749
+ }
3750
+ }
3751
+ });
3752
+ std::fs::write(
3753
+ crate::state::persist::runtime_state_path(&workspace),
3754
+ serde_json::to_vec_pretty(&state).unwrap(),
3755
+ )
3756
+ .unwrap();
3757
+ super::clear_managed_leader_receiver_pane(&workspace, "%42");
3758
+ let loaded = crate::state::persist::load_runtime_state(&workspace).unwrap();
3759
+ let recv = &loaded["teams"]["current"]["leader_receiver"];
3760
+ assert_eq!(recv["status"], serde_json::json!("exited"));
3761
+ assert!(
3762
+ recv.get("pane_id").is_none(),
3763
+ "dangling pane_id must be gone: {recv}"
3764
+ );
3765
+ assert!(
3766
+ loaded["teams"]["current"]["team_owner"]
3767
+ .get("pane_id")
3768
+ .is_none(),
3769
+ "owner pane_id must be gone: {loaded}"
3770
+ );
3771
+ let _ = std::fs::remove_dir_all(&workspace);
3772
+ }
3773
+
3774
+ #[test]
3775
+ fn managed_attach_teardown_detects_provider_exit_marker() {
3776
+ let spawned = managed_spawn_result();
3777
+ let transport = ScriptedTransport::new(vec![
3778
+ "[team-agent] claude exited with 1\n[team-agent] Provider exited; this pane no longer accepts input.".to_string(),
3779
+ ]);
3780
+ let watch = super::ManagedAttachWatch {
3781
+ transport: &transport,
3782
+ spawned: &spawned,
3783
+ session: &spawned.session,
3784
+ session_existed_before: false,
3785
+ workspace: Path::new("/tmp"),
3786
+ provider_label: "claude",
3787
+ };
3788
+ assert!(
3789
+ super::managed_attach_should_teardown(&watch),
3790
+ "exit marker in capture must trigger teardown"
3791
+ );
3792
+ }
3793
+
3794
+ #[test]
3795
+ fn managed_attach_teardown_does_not_fire_on_live_provider_screen() {
3796
+ let spawned = managed_spawn_result();
3797
+ let transport = ScriptedTransport::new(vec!["Welcome to Claude Code".to_string()]);
3798
+ let watch = super::ManagedAttachWatch {
3799
+ transport: &transport,
3800
+ spawned: &spawned,
3801
+ session: &spawned.session,
3802
+ session_existed_before: false,
3803
+ workspace: Path::new("/tmp"),
3804
+ provider_label: "claude",
3805
+ };
3806
+ assert!(
3807
+ !super::managed_attach_should_teardown(&watch),
3808
+ "live welcome screen must not teardown"
3809
+ );
3810
+ }
3811
+
3812
+ #[test]
3813
+ fn managed_attach_wait_watches_provider_exit_grep_guard() {
3814
+ let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
3815
+ let start_rs = manifest.join("src").join("leader").join("start.rs");
3816
+ let contents = std::fs::read_to_string(&start_rs).expect("read leader/start.rs");
3817
+ let start = contents
3818
+ .find("fn run_leader_argv(")
3819
+ .expect("run_leader_argv must exist");
3820
+ let end = contents[start + 1..]
3821
+ .find("\nfn ")
3822
+ .map(|offset| start + 1 + offset)
3823
+ .unwrap_or(contents.len());
3824
+ let body = &contents[start..end];
3825
+ assert!(
3826
+ body.contains("wait_managed_attach_child"),
3827
+ "managed attach wait must watch provider lifetime; body excerpt: {body}"
3828
+ );
3829
+ let cleanup = contents
3830
+ .find("fn cleanup_managed_leader_resources(")
3831
+ .expect("cleanup_managed_leader_resources must exist");
3832
+ let cleanup_end = contents[cleanup + 1..]
3833
+ .find("\nfn ")
3834
+ .map(|offset| cleanup + 1 + offset)
3835
+ .unwrap_or(contents.len());
3836
+ let cleanup_body = &contents[cleanup..cleanup_end];
3837
+ assert!(
3838
+ !cleanup_body.contains("kill_server"),
3839
+ "cleanup must not call kill_server; body excerpt: {cleanup_body}"
3840
+ );
3841
+ assert!(
3842
+ cleanup_body.contains("kill_window"),
3843
+ "cleanup must kill own window; body excerpt: {cleanup_body}"
3844
+ );
3845
+ assert!(
3846
+ cleanup_body.contains("clear_managed_leader_receiver_pane"),
3847
+ "cleanup must clear state.json pane pointer; body excerpt: {cleanup_body}"
3848
+ );
3849
+ }
3850
+
3851
+ fn persist_test_identity(workspace: &Path) -> LeaderIdentity {
3852
+ LeaderIdentity {
3853
+ leader_session_uuid: LeaderSessionUuid::derive(
3854
+ "fp",
3855
+ &workspace.to_string_lossy(),
3856
+ "tester",
3857
+ "current",
3858
+ )
3859
+ .unwrap(),
3860
+ leader_session_uuid_source: LeaderSessionUuidSource::Derived,
3861
+ machine_fingerprint: "fp".to_string(),
3862
+ workspace_abspath: workspace.to_path_buf(),
3863
+ os_user: "tester".to_string(),
3864
+ team_id: TeamKey::new("current"),
3865
+ }
3866
+ }
3867
+
3868
+ fn persist_test_plan(workspace: &Path) -> LeaderStartPlan {
3869
+ LeaderStartPlan {
3870
+ mode: LeaderStartMode::ManagedTmuxClient,
3871
+ provider: Provider::ClaudeCode,
3872
+ workspace: workspace.to_path_buf(),
3873
+ socket: LeaderLaunchSocket::Workspace,
3874
+ session_name: Some(SessionName::new("team-agent-leader-claude_code-demo")),
3875
+ argv: vec!["tmux".to_string(), "attach-session".to_string()],
3876
+ provider_argv: vec!["claude".to_string()],
3877
+ leader_window: Some(WindowName::new("claude_code")),
3878
+ is_external_leader: false,
3879
+ leader_env: BTreeMap::new(),
3880
+ identity: Some(persist_test_identity(workspace)),
3881
+ detached: false,
3882
+ }
3883
+ }
3884
+
3885
+ #[test]
3886
+ fn multi_leader_window_keeps_base_when_free() {
3887
+ let window =
3888
+ managed_leader_window_from_occupied("claude_code", Vec::<String>::new(), "deadbeef");
3889
+ assert_eq!(window.as_str(), "claude_code");
3890
+ }
3891
+
3892
+ #[test]
3893
+ fn multi_leader_window_suffixes_when_taken() {
3894
+ let window = managed_leader_window_from_occupied(
3895
+ "claude_code",
3896
+ vec!["claude_code".to_string()],
3897
+ "aa-bb",
3898
+ );
3899
+ assert_eq!(window.as_str(), "claude_code-aa-bb");
3900
+ assert_ne!(window.as_str(), "claude_code");
3901
+ }
3902
+
3903
+ #[test]
3904
+ fn multi_leader_attach_targets_unique_window() {
3905
+ let session = SessionName::new("team-agent-leader-claude_code-demo");
3906
+ let window = WindowName::new("claude_code-aa-bb");
3907
+ let argv = managed_client_argv(
3908
+ Path::new("/tmp"),
3909
+ &session,
3910
+ &window,
3911
+ ManagedClientAttachMode::AttachSession,
3912
+ )
3913
+ .expect("argv");
3914
+ let target = argv
3915
+ .windows(2)
3916
+ .find(|pair| pair[0] == "-t")
3917
+ .map(|pair| pair[1].as_str())
3918
+ .expect("-t target");
3919
+ assert_eq!(
3920
+ target,
3921
+ "team-agent-leader-claude_code-demo:claude_code-aa-bb"
3922
+ );
3923
+ assert!(
3924
+ !target.ends_with(":claude_code"),
3925
+ "attach must not collapse to the colliding provider_wire name: {target}"
3926
+ );
3927
+ }
3928
+
3929
+ #[test]
3930
+ fn multi_leader_persist_keeps_first_pane_id() {
3931
+ let workspace =
3932
+ std::env::temp_dir().join(format!("ta-multi-leader-persist-{}", std::process::id()));
3933
+ std::fs::create_dir_all(workspace.join(".team").join("runtime")).unwrap();
3934
+ let plan = persist_test_plan(&workspace);
3935
+ let first = SpawnResult {
3936
+ pane_id: PaneId::new("%0"),
3937
+ session: SessionName::new("team-agent-leader-claude_code-demo"),
3938
+ window: WindowName::new("claude_code"),
3939
+ child_pid: Some(1),
3940
+ };
3941
+ persist_managed_leader_binding(&plan, &workspace, &first).expect("first persist");
3942
+ let second = SpawnResult {
3943
+ pane_id: PaneId::new("%1"),
3944
+ session: SessionName::new("team-agent-leader-claude_code-demo"),
3945
+ window: WindowName::new("claude_code-aa-bb"),
3946
+ child_pid: Some(2),
3947
+ };
3948
+ persist_managed_leader_binding(&plan, &workspace, &second).expect("second persist");
3949
+ let loaded = crate::state::persist::load_runtime_state(&workspace).unwrap();
3950
+ let recv = &loaded["teams"]["current"]["leader_receiver"];
3951
+ assert_eq!(
3952
+ recv["pane_id"],
3953
+ serde_json::json!("%0"),
3954
+ "second launch must not overwrite the first pane pointer: {recv}"
3955
+ );
3956
+ let panes = recv["bound_panes"].as_array().expect("bound_panes");
3957
+ let ids: Vec<&str> = panes
3958
+ .iter()
3959
+ .filter_map(|pane| pane.get("pane_id").and_then(serde_json::Value::as_str))
3960
+ .collect();
3961
+ assert!(ids.contains(&"%0"), "bound_panes missing first: {recv}");
3962
+ assert!(ids.contains(&"%1"), "bound_panes missing second: {recv}");
3963
+ let _ = std::fs::remove_dir_all(&workspace);
3964
+ }
3965
+
3966
+ #[test]
3967
+ fn multi_leader_clear_promotes_remaining() {
3968
+ let workspace =
3969
+ std::env::temp_dir().join(format!("ta-multi-leader-promote-{}", std::process::id()));
3970
+ std::fs::create_dir_all(workspace.join(".team").join("runtime")).unwrap();
3971
+ let state = serde_json::json!({
3972
+ "active_team_key": "current",
3973
+ "teams": {
3974
+ "current": {
3975
+ "leader_receiver": {
3976
+ "status": "attached",
3977
+ "pane_id": "%0",
3978
+ "pane": "%0",
3979
+ "session_name": "s",
3980
+ "window_name": "claude_code",
3981
+ "bound_panes": [
3982
+ {
3983
+ "pane_id": "%0",
3984
+ "session_name": "s",
3985
+ "window_name": "claude_code"
3986
+ },
3987
+ {
3988
+ "pane_id": "%1",
3989
+ "session_name": "s",
3990
+ "window_name": "claude_code-2"
3991
+ }
3992
+ ]
3993
+ },
3994
+ "team_owner": { "pane_id": "%0" }
3995
+ }
3996
+ }
3997
+ });
3998
+ std::fs::write(
3999
+ crate::state::persist::runtime_state_path(&workspace),
4000
+ serde_json::to_vec_pretty(&state).unwrap(),
4001
+ )
4002
+ .unwrap();
4003
+ clear_managed_leader_receiver_pane(&workspace, "%0");
4004
+ let loaded = crate::state::persist::load_runtime_state(&workspace).unwrap();
4005
+ let recv = &loaded["teams"]["current"]["leader_receiver"];
4006
+ assert_eq!(recv["pane_id"], serde_json::json!("%1"));
4007
+ assert_eq!(recv["status"], serde_json::json!("attached"));
4008
+ let panes = recv["bound_panes"].as_array().expect("bound_panes");
4009
+ assert_eq!(panes.len(), 1);
4010
+ assert_eq!(panes[0]["pane_id"], serde_json::json!("%1"));
4011
+ assert!(
4012
+ loaded["teams"]["current"]["team_owner"]
4013
+ .get("pane_id")
4014
+ .is_none(),
4015
+ "owner pane for the exited leader must be gone: {loaded}"
4016
+ );
4017
+ let _ = std::fs::remove_dir_all(&workspace);
4018
+ }
4019
+
4020
+ #[test]
4021
+ fn multi_leader_launch_window_dedup_contract() {
4022
+ let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
4023
+ let start_rs = manifest.join("src").join("leader").join("start.rs");
4024
+ let contents = std::fs::read_to_string(&start_rs).expect("read leader/start.rs");
4025
+ let start = contents
4026
+ .find("fn leader_start_plan_with_ambient_authority(")
4027
+ .expect("leader_start_plan_with_ambient_authority must exist");
4028
+ let end = contents[start + 1..]
4029
+ .find("\nfn ")
4030
+ .map(|offset| start + 1 + offset)
4031
+ .unwrap_or(contents.len());
4032
+ let body = &contents[start..end];
4033
+ assert!(
4034
+ body.contains("managed_leader_window_for_launch(provider, workspace, session)"),
4035
+ "managed launch must pick a unique window in the A-22 session; body excerpt: {body}"
4036
+ );
4037
+ let argv_start = contents
4038
+ .find("fn managed_client_argv(")
4039
+ .expect("managed_client_argv must exist");
4040
+ let argv_end = contents[argv_start + 1..]
4041
+ .find("\nfn ")
4042
+ .map(|offset| argv_start + 1 + offset)
4043
+ .unwrap_or(contents.len());
4044
+ let argv_body = &contents[argv_start..argv_end];
4045
+ assert!(
4046
+ argv_body.contains("window.as_str()"),
4047
+ "attach target must use the launch window, not a hardcoded wire name; body excerpt: {argv_body}"
4048
+ );
4049
+ assert!(
4050
+ !argv_body.contains("provider_wire(provider)"),
4051
+ "attach target must not collapse to provider_wire; body excerpt: {argv_body}"
4052
+ );
4053
+ }
3156
4054
  }