@team-agent/installer 0.5.66 → 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 (164) 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 +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/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 +59 -0
  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 +30 -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 +2 -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 +920 -63
  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 +332 -12
  46. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +62 -0
  47. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +59 -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 +332 -0
  51. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +204 -456
  52. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +24 -0
  53. package/crates/team-agent/src/lifecycle/launch/grok_per_seat.rs +260 -0
  54. package/crates/team-agent/src/lifecycle/launch/identity.rs +146 -0
  55. package/crates/team-agent/src/lifecycle/launch/layout.rs +60 -0
  56. package/crates/team-agent/src/lifecycle/launch/leader_context.rs +112 -0
  57. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +530 -0
  58. package/crates/team-agent/src/lifecycle/launch/ownership.rs +40 -0
  59. package/crates/team-agent/src/lifecycle/launch/plan.rs +31 -0
  60. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +66 -0
  61. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +78 -0
  62. package/crates/team-agent/src/lifecycle/launch/readiness.rs +85 -38
  63. package/crates/team-agent/src/lifecycle/launch/role_source.rs +44 -44
  64. package/crates/team-agent/src/lifecycle/launch/spawn.rs +62 -2
  65. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +139 -0
  66. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +109 -0
  67. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +214 -1
  68. package/crates/team-agent/src/lifecycle/launch.rs +99 -29
  69. package/crates/team-agent/src/lifecycle/lock.rs +42 -1
  70. package/crates/team-agent/src/lifecycle/mod.rs +11 -0
  71. package/crates/team-agent/src/lifecycle/pane_input_lock.rs +161 -0
  72. package/crates/team-agent/src/lifecycle/profile_launch.rs +98 -0
  73. package/crates/team-agent/src/lifecycle/profile_smoke.rs +51 -1
  74. package/crates/team-agent/src/lifecycle/restart/agent.rs +124 -0
  75. package/crates/team-agent/src/lifecycle/restart/common.rs +369 -11
  76. package/crates/team-agent/src/lifecycle/restart/orchestrator.rs +27 -0
  77. package/crates/team-agent/src/lifecycle/restart/preflight.rs +25 -0
  78. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +106 -0
  79. package/crates/team-agent/src/lifecycle/restart/remove.rs +196 -5
  80. package/crates/team-agent/src/lifecycle/restart/selection.rs +72 -0
  81. package/crates/team-agent/src/lifecycle/restart/team_state.rs +22 -0
  82. package/crates/team-agent/src/lifecycle/restart.rs +29 -0
  83. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +2 -3
  84. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +309 -0
  85. package/crates/team-agent/src/lifecycle/tests/clone_fork_copilot_perms_red.rs +150 -157
  86. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +2 -2
  87. package/crates/team-agent/src/lifecycle/tests/core.rs +4 -0
  88. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +287 -0
  89. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +229 -0
  90. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +353 -0
  91. package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +784 -0
  92. package/crates/team-agent/src/lifecycle/tests/gate_fixtures.rs +562 -0
  93. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +239 -0
  94. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +498 -0
  95. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +250 -0
  96. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +293 -0
  97. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +63 -38
  98. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +3 -2
  99. package/crates/team-agent/src/lifecycle/tests/lifecycle_rollback_red.rs +16 -8
  100. package/crates/team-agent/src/lifecycle/tests/mcp_tool_name_format_red.rs +69 -0
  101. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +20 -19
  102. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +97 -61
  103. package/crates/team-agent/src/lifecycle/tests/restart_rebind_hotfix_252_red.rs +2 -0
  104. package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +40 -7
  105. package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +59 -1
  106. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +69 -26
  107. package/crates/team-agent/src/lifecycle/tests.rs +23 -13
  108. package/crates/team-agent/src/lifecycle/types.rs +61 -0
  109. package/crates/team-agent/src/lifecycle/worker_command_context.rs +85 -11
  110. package/crates/team-agent/src/mcp_server/tests/scoped.rs +100 -1
  111. package/crates/team-agent/src/mcp_server/tests.rs +196 -2
  112. package/crates/team-agent/src/messaging/delivery.rs +405 -40
  113. package/crates/team-agent/src/messaging/helpers.rs +2 -0
  114. package/crates/team-agent/src/messaging/leader_receiver.rs +53 -1
  115. package/crates/team-agent/src/messaging/results.rs +4 -0
  116. package/crates/team-agent/src/messaging/send.rs +34 -0
  117. package/crates/team-agent/src/messaging/tests/dup_inject.rs +406 -0
  118. package/crates/team-agent/src/messaging/tests/e23.rs +9 -0
  119. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +66 -0
  120. package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
  121. package/crates/team-agent/src/messaging/types.rs +4 -1
  122. package/crates/team-agent/src/model/enums.rs +14 -5
  123. package/crates/team-agent/src/model/permissions.rs +3 -0
  124. package/crates/team-agent/src/os_probe.rs +73 -2
  125. package/crates/team-agent/src/provider/adapter.rs +231 -4
  126. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +86 -0
  127. package/crates/team-agent/src/provider/adapters/grok.rs +157 -0
  128. package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
  129. package/crates/team-agent/src/provider/bypass_flags.rs +46 -2
  130. package/crates/team-agent/src/provider/classify.rs +4 -2
  131. package/crates/team-agent/src/provider/faults.rs +2 -1
  132. package/crates/team-agent/src/provider/mod.rs +11 -0
  133. package/crates/team-agent/src/provider/session/capture.rs +158 -20
  134. package/crates/team-agent/src/provider/session/context_fork/claude.rs +38 -1
  135. package/crates/team-agent/src/provider/session/context_fork/codex.rs +72 -1
  136. package/crates/team-agent/src/provider/session/context_fork/outcome.rs +57 -1
  137. package/crates/team-agent/src/provider/session/context_fork.rs +77 -3
  138. package/crates/team-agent/src/provider/session/mod.rs +18 -0
  139. package/crates/team-agent/src/provider/session/resume.rs +57 -0
  140. package/crates/team-agent/src/provider/session_scan/claude.rs +125 -1
  141. package/crates/team-agent/src/provider/session_scan/codex.rs +94 -1
  142. package/crates/team-agent/src/provider/session_scan/common.rs +204 -2
  143. package/crates/team-agent/src/provider/session_scan/copilot.rs +33 -1
  144. package/crates/team-agent/src/provider/session_scan/cursor.rs +538 -0
  145. package/crates/team-agent/src/provider/session_scan/grok.rs +288 -0
  146. package/crates/team-agent/src/provider/session_scan.rs +8 -0
  147. package/crates/team-agent/src/provider/submit_now.rs +94 -0
  148. package/crates/team-agent/src/provider/tests/adapter.rs +304 -0
  149. package/crates/team-agent/src/provider/types.rs +4 -2
  150. package/crates/team-agent/src/provider/wire.rs +23 -1
  151. package/crates/team-agent/src/state/persist.rs +301 -8
  152. package/crates/team-agent/src/state/repository.rs +5 -0
  153. package/crates/team-agent/src/tmux_backend/tests.rs +1737 -42
  154. package/crates/team-agent/src/tmux_backend.rs +1064 -75
  155. package/crates/team-agent/src/transport/tests/wire.rs +28 -2
  156. package/crates/team-agent/src/transport.rs +248 -1
  157. package/npm/install.mjs +129 -73
  158. package/package.json +4 -4
  159. package/skills/team-agent/SKILL.md +33 -238
  160. package/skills/team-agent/command-coverage.json +31 -0
  161. package/crates/team-agent/src/lifecycle/launch/fork_agent/completion.rs +0 -59
  162. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +0 -488
  163. package/crates/team-agent/src/lifecycle/launch/fork_pending.rs +0 -109
  164. 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,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(),
@@ -1085,7 +1159,20 @@ fn execute_managed_leader_plan_attempt(
1085
1159
  workspace.to_path_buf(),
1086
1160
  spawned.pane_id.as_str().to_string(),
1087
1161
  );
1088
- 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
+ ) {
1089
1176
  Ok(process) => process,
1090
1177
  Err(error) => {
1091
1178
  cleanup_managed_leader_resources(
@@ -1099,6 +1186,14 @@ fn execute_managed_leader_plan_attempt(
1099
1186
  return Err(error);
1100
1187
  }
1101
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
+ }
1102
1197
  let code = process.status.code();
1103
1198
  if !process.status.success() {
1104
1199
  cleanup_managed_leader_resources(
@@ -1141,14 +1236,16 @@ fn managed_leader_attach_recovery_plan(
1141
1236
  workspace: &Path,
1142
1237
  ) -> Result<LeaderStartPlan, LeaderError> {
1143
1238
  let session = managed_leader_session_for_launch(plan.provider, workspace);
1239
+ let window = managed_leader_window_for_launch(plan.provider, workspace, &session);
1144
1240
  let attach_mode = if plan.argv.iter().any(|arg| arg == "switch-client") {
1145
1241
  ManagedClientAttachMode::SwitchClient
1146
1242
  } else {
1147
1243
  ManagedClientAttachMode::AttachSession
1148
1244
  };
1149
- let argv = managed_client_argv(workspace, &session, plan.provider, attach_mode)?;
1245
+ let argv = managed_client_argv(workspace, &session, &window, attach_mode)?;
1150
1246
  let mut retry_plan = plan.clone();
1151
1247
  retry_plan.session_name = Some(session);
1248
+ retry_plan.leader_window = Some(window);
1152
1249
  retry_plan.argv = argv;
1153
1250
  Ok(retry_plan)
1154
1251
  }
@@ -1200,6 +1297,222 @@ fn ensure_managed_leader_pane(
1200
1297
  }
1201
1298
  }
1202
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
+
1203
1516
  fn cleanup_managed_leader_resources(
1204
1517
  transport: &dyn Transport,
1205
1518
  session: &SessionName,
@@ -1208,23 +1521,36 @@ fn cleanup_managed_leader_resources(
1208
1521
  workspace: &Path,
1209
1522
  reason: &str,
1210
1523
  ) {
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
- )
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(),
1227
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());
1228
1554
  write_leader_startup_prompt_event(
1229
1555
  workspace,
1230
1556
  "leader.launcher.rollback",
@@ -1411,17 +1737,12 @@ fn persist_managed_leader_binding(
1411
1737
  .ok_or_else(|| LeaderError::Start("managed leader identity missing".to_string()))?;
1412
1738
  let mut state = crate::state::persist::load_runtime_state(workspace)
1413
1739
  .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);
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();
1425
1746
  let now = chrono::Utc::now().to_rfc3339();
1426
1747
  let socket = crate::tmux_backend::socket_path_for_workspace(workspace)
1427
1748
  .map(|path| path.to_string_lossy().to_string())
@@ -1430,27 +1751,81 @@ fn persist_managed_leader_binding(
1430
1751
  let session = spawned.session.as_str().to_string();
1431
1752
  let window = spawned.window.as_str().to_string();
1432
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
+ };
1433
1778
  let attach_mode = if plan.argv.iter().any(|arg| arg == "switch-client") {
1434
1779
  "switch-client"
1435
1780
  } else {
1436
1781
  "attach-session"
1437
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
+ };
1438
1812
  let receiver = serde_json::json!({
1439
1813
  "mode": "direct_tmux",
1440
1814
  "status": "attached",
1441
1815
  "provider": provider.clone(),
1442
- "pane_id": pane,
1443
- "pane": pane,
1444
- "session_name": session,
1445
- "window_name": window,
1816
+ "pane_id": scalar_pane,
1817
+ "pane": scalar_pane,
1818
+ "session_name": scalar_session,
1819
+ "window_name": scalar_window,
1446
1820
  "tmux_socket": socket,
1447
1821
  "leader_session_uuid": identity.leader_session_uuid,
1448
1822
  "owner_epoch": owner_epoch,
1449
- "attached_at": now,
1823
+ "attached_at": scalar_attached_at,
1450
1824
  "discovery": "managed_launcher",
1825
+ "bound_panes": bound_panes,
1451
1826
  });
1452
1827
  let owner = serde_json::json!({
1453
- "pane_id": pane,
1828
+ "pane_id": scalar_pane,
1454
1829
  "provider": provider.clone(),
1455
1830
  "machine_fingerprint": identity.machine_fingerprint,
1456
1831
  "leader_session_uuid": identity.leader_session_uuid,
@@ -1908,6 +2283,7 @@ fn run_leader_argv(
1908
2283
  env: &BTreeMap<String, String>,
1909
2284
  plan: &LeaderStartPlan,
1910
2285
  workspace: &Path,
2286
+ managed_watch: Option<ManagedAttachWatch<'_>>,
1911
2287
  ) -> Result<LeaderProcessExit, LeaderError> {
1912
2288
  let Some(program) = argv.first() else {
1913
2289
  return Err(LeaderError::Start(
@@ -2013,7 +2389,13 @@ fn run_leader_argv(
2013
2389
  if plan.mode == LeaderStartMode::ExecProvider {
2014
2390
  spawn_exec_provider_startup_prompt_handler(plan.provider, workspace.to_path_buf());
2015
2391
  }
2016
- 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
+ };
2017
2399
  let stdout = match stdout_reader {
2018
2400
  Some(reader) => match reader.join() {
2019
2401
  Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
@@ -2050,9 +2432,88 @@ fn run_leader_argv(
2050
2432
  status,
2051
2433
  stderr,
2052
2434
  diagnostics_path,
2435
+ intentional_teardown,
2053
2436
  })
2054
2437
  }
2055
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
+
2056
2517
  fn launcher_diagnostics_path(workspace: &Path) -> PathBuf {
2057
2518
  let stamp = chrono::Utc::now().format("%Y%m%d-%H%M%S%.6f");
2058
2519
  workspace.join(".team").join("logs").join(format!(
@@ -2092,6 +2553,7 @@ struct LeaderProcessExit {
2092
2553
  status: std::process::ExitStatus,
2093
2554
  stderr: String,
2094
2555
  diagnostics_path: PathBuf,
2556
+ intentional_teardown: bool,
2095
2557
  }
2096
2558
 
2097
2559
  fn push_bounded_stderr(captured: &mut Vec<u8>, chunk: &[u8]) {
@@ -2313,6 +2775,8 @@ fn provider_command_name(provider: Provider) -> &'static str {
2313
2775
  // (leader/start.rs:192-204 派生)自动覆盖前缀保护。
2314
2776
  Provider::Copilot => "copilot",
2315
2777
  Provider::GeminiCli => "gemini",
2778
+ Provider::Grok => "grok",
2779
+ Provider::CursorAgent => "agent",
2316
2780
  Provider::Fake => "fake",
2317
2781
  }
2318
2782
  }
@@ -2396,10 +2860,13 @@ mod tests {
2396
2860
  };
2397
2861
 
2398
2862
  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,
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,
2403
2870
  };
2404
2871
 
2405
2872
  struct ScriptedTransport {
@@ -2409,6 +2876,8 @@ mod tests {
2409
2876
  targets: Vec<PaneInfo>,
2410
2877
  window_options: Mutex<Vec<(String, String)>>,
2411
2878
  window_option_error: Mutex<Option<String>>,
2879
+ kills: Mutex<Vec<String>>,
2880
+ windows: Mutex<Vec<WindowName>>,
2412
2881
  }
2413
2882
 
2414
2883
  impl ScriptedTransport {
@@ -2420,6 +2889,8 @@ mod tests {
2420
2889
  targets: Vec::new(),
2421
2890
  window_options: Mutex::new(Vec::new()),
2422
2891
  window_option_error: Mutex::new(None),
2892
+ kills: Mutex::new(Vec::new()),
2893
+ windows: Mutex::new(Vec::new()),
2423
2894
  }
2424
2895
  }
2425
2896
 
@@ -2431,6 +2902,8 @@ mod tests {
2431
2902
  targets: Vec::new(),
2432
2903
  window_options: Mutex::new(Vec::new()),
2433
2904
  window_option_error: Mutex::new(None),
2905
+ kills: Mutex::new(Vec::new()),
2906
+ windows: Mutex::new(Vec::new()),
2434
2907
  }
2435
2908
  }
2436
2909
 
@@ -2442,6 +2915,8 @@ mod tests {
2442
2915
  targets,
2443
2916
  window_options: Mutex::new(Vec::new()),
2444
2917
  window_option_error: Mutex::new(None),
2918
+ kills: Mutex::new(Vec::new()),
2919
+ windows: Mutex::new(Vec::new()),
2445
2920
  }
2446
2921
  }
2447
2922
 
@@ -2455,6 +2930,8 @@ mod tests {
2455
2930
  targets: Vec::new(),
2456
2931
  window_options: Mutex::new(Vec::new()),
2457
2932
  window_option_error: Mutex::new(Some(error.to_string())),
2933
+ kills: Mutex::new(Vec::new()),
2934
+ windows: Mutex::new(Vec::new()),
2458
2935
  }
2459
2936
  }
2460
2937
 
@@ -2472,6 +2949,20 @@ mod tests {
2472
2949
  Err(poisoned) => poisoned.into_inner().clone(),
2473
2950
  }
2474
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
+ }
2475
2966
  }
2476
2967
 
2477
2968
  impl Transport for ScriptedTransport {
@@ -2576,7 +3067,10 @@ mod tests {
2576
3067
  }
2577
3068
 
2578
3069
  fn list_windows(&self, _session: &SessionName) -> Result<Vec<WindowName>, TransportError> {
2579
- Ok(Vec::new())
3070
+ match self.windows.lock() {
3071
+ Ok(guard) => Ok(guard.clone()),
3072
+ Err(poisoned) => Ok(poisoned.into_inner().clone()),
3073
+ }
2580
3074
  }
2581
3075
 
2582
3076
  fn set_session_env(
@@ -2616,11 +3110,22 @@ mod tests {
2616
3110
  Ok(())
2617
3111
  }
2618
3112
 
2619
- fn kill_session(&self, _session: &SessionName) -> Result<(), TransportError> {
3113
+ fn kill_server(&self) -> Result<(), TransportError> {
3114
+ self.record_kill("kill_server");
3115
+ Ok(())
3116
+ }
3117
+
3118
+ fn kill_session(&self, session: &SessionName) -> Result<(), TransportError> {
3119
+ self.record_kill(&format!("kill_session:{}", session.as_str()));
2620
3120
  Ok(())
2621
3121
  }
2622
3122
 
2623
- fn kill_window(&self, _target: &Target) -> Result<(), TransportError> {
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
+ }
2624
3129
  Ok(())
2625
3130
  }
2626
3131
 
@@ -3130,7 +3635,10 @@ mod tests {
3130
3635
  // (recorded for audit) instead of propagating an error.
3131
3636
  let (result, stderr) = lock_down_window_rename(&transport, &session, &window);
3132
3637
 
3133
- assert_eq!(result, "failed", "a set-window-option failure is audited, not fatal");
3638
+ assert_eq!(
3639
+ result, "failed",
3640
+ "a set-window-option failure is audited, not fatal"
3641
+ );
3134
3642
  assert!(
3135
3643
  stderr.contains("unknown option"),
3136
3644
  "tmux stderr must surface in the audit payload: {stderr}"
@@ -3141,16 +3649,365 @@ mod tests {
3141
3649
  let events = crate::event_log::EventLog::new(&workspace).tail(0).unwrap();
3142
3650
  let lockdown = events
3143
3651
  .iter()
3144
- .find(|event| {
3145
- event["event"] == "leader.launcher.window_rename_lockdown"
3146
- })
3652
+ .find(|event| event["event"] == "leader.launcher.window_rename_lockdown")
3147
3653
  .expect("rename_lockdown event must be written even on failure");
3148
3654
  assert_eq!(lockdown["result"], "failed");
3149
3655
  assert!(
3150
- lockdown["tmux_stderr"].as_str().unwrap_or_default().contains("unknown option"),
3656
+ lockdown["tmux_stderr"]
3657
+ .as_str()
3658
+ .unwrap_or_default()
3659
+ .contains("unknown option"),
3151
3660
  "event must carry tmux stderr for regression triage: {lockdown}"
3152
3661
  );
3153
3662
 
3154
3663
  std::fs::remove_dir_all(&workspace).ok();
3155
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
+ }
3156
4013
  }