@team-agent/installer 0.3.15 → 0.3.16

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.
@@ -36,6 +36,10 @@ use super::*;
36
36
  for key in [
37
37
  "team",
38
38
  "session_name",
39
+ "leader_topology",
40
+ "is_external_leader",
41
+ "leader_attach_command",
42
+ "leader_client",
39
43
  "tmux_session_present",
40
44
  "leader_receiver",
41
45
  "agents",
@@ -61,6 +65,27 @@ use super::*;
61
65
  let _ = std::fs::remove_dir_all(&ws);
62
66
  }
63
67
 
68
+ #[test]
69
+ fn status_port_status_reports_managed_leader_topology_and_attach_command() {
70
+ let ws = seed_status_workspace();
71
+ let mut state = crate::state::persist::load_runtime_state(&ws).unwrap();
72
+ if let Some(obj) = state.as_object_mut() {
73
+ obj.insert("is_external_leader".to_string(), json!(false));
74
+ obj.insert("session_name".to_string(), json!("team-current"));
75
+ }
76
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
77
+
78
+ let v = status_port::status(&ws, /*compact=*/ true, /*detail=*/ false).expect("status");
79
+
80
+ assert_eq!(v["leader_topology"], json!("managed"));
81
+ assert_eq!(v["is_external_leader"], json!(false));
82
+ let attach = v["leader_attach_command"]
83
+ .as_str()
84
+ .expect("managed status includes attach command");
85
+ assert!(attach.contains("attach -t team-current:leader"), "{attach}");
86
+ let _ = std::fs::remove_dir_all(&ws);
87
+ }
88
+
64
89
  #[test]
65
90
  fn status_port_status_detail_full_keeps_uncompacted_events() {
66
91
  // cmd_status --json --detail -> status_port::status(compact=false): the FULL dict
@@ -202,6 +202,8 @@ pub struct LeaderLauncherArgs {
202
202
  pub confirm_attach: bool,
203
203
  /// `--attach-session <name>` / `--attach-session=<name>`。
204
204
  pub attach_session: Option<String>,
205
+ /// 0.3.16 topology opt-out: keep the old external/current-pane leader launcher path.
206
+ pub external_leader: bool,
205
207
  }
206
208
 
207
209
  // =============================================================================
@@ -7,7 +7,7 @@ use std::process::{Command, Stdio};
7
7
 
8
8
  use crate::provider::{get_adapter, Provider};
9
9
  use crate::tmux_backend::TmuxBackend;
10
- use crate::transport::{PaneId, SessionName, Target, Transport};
10
+ use crate::transport::{PaneId, SessionName, SpawnResult, Target, Transport, WindowName};
11
11
 
12
12
  use super::helpers::{
13
13
  provider_wire, resolve_workspace_for_hash, sanitize_session_folder, sha1_hex_prefix,
@@ -29,6 +29,7 @@ pub fn leader_start_plan(
29
29
  attach_existing: bool,
30
30
  confirm_attach: bool,
31
31
  attach_session: Option<&SessionName>,
32
+ external_leader: bool,
32
33
  ) -> Result<LeaderStartPlan, LeaderError> {
33
34
  if attach_session.is_some() && !confirm_attach {
34
35
  return Err(LeaderError::Start(
@@ -49,15 +50,24 @@ pub fn leader_start_plan(
49
50
  command_name
50
51
  )));
51
52
  }
52
- let identity = leader_identity_context(workspace, None, None)?;
53
- let session_name = attach_session
54
- .cloned()
55
- .or_else(|| Some(leader_session_name(provider, workspace)));
53
+ let state = crate::state::persist::load_runtime_state(workspace).ok();
54
+ let identity = leader_identity_context(workspace, None, state.as_ref())?;
55
+ let external_path = external_leader || attach_existing || attach_session.is_some();
56
+ let session_name = if external_path {
57
+ attach_session
58
+ .cloned()
59
+ .or_else(|| Some(leader_session_name(provider, workspace)))
60
+ } else {
61
+ Some(managed_team_session_name(&identity))
62
+ };
56
63
  let in_tmux = std::env::var_os("TMUX").is_some();
57
- if !in_tmux {
64
+ if !in_tmux || !external_path {
58
65
  ensure_tmux_installed()?;
59
66
  }
60
- let existing_session = if !in_tmux && !attach_existing && attach_session.is_none() {
67
+ if !external_path {
68
+ validate_managed_tmux_context(workspace)?;
69
+ }
70
+ let existing_session = if external_path && !in_tmux && !attach_existing && attach_session.is_none() {
61
71
  match session_name.as_ref() {
62
72
  Some(session) => tmux_session_exists(workspace, session)?,
63
73
  None => false,
@@ -65,7 +75,9 @@ pub fn leader_start_plan(
65
75
  } else {
66
76
  false
67
77
  };
68
- let mode = if in_tmux {
78
+ let mode = if !external_path {
79
+ LeaderStartMode::ManagedTmuxClient
80
+ } else if in_tmux {
69
81
  LeaderStartMode::ExecProvider
70
82
  } else if attach_existing || attach_session.is_some() || existing_session {
71
83
  LeaderStartMode::AttachExisting
@@ -86,6 +98,7 @@ pub fn leader_start_plan(
86
98
  } else {
87
99
  leader_env.clone()
88
100
  };
101
+ let provider_argv = provider_command_argv(provider, provider_args);
89
102
  Ok(LeaderStartPlan {
90
103
  mode,
91
104
  provider,
@@ -93,6 +106,10 @@ pub fn leader_start_plan(
93
106
  socket: LeaderLaunchSocket::Workspace,
94
107
  session_name,
95
108
  argv,
109
+ provider_argv,
110
+ leader_window: (mode == LeaderStartMode::ManagedTmuxClient)
111
+ .then(|| WindowName::new("leader")),
112
+ is_external_leader: external_path,
96
113
  leader_env: plan_env,
97
114
  identity: Some(identity),
98
115
  detached: false,
@@ -142,6 +159,7 @@ pub fn start_leader(
142
159
  attach_existing: bool,
143
160
  confirm_attach: bool,
144
161
  attach_session: Option<&SessionName>,
162
+ external_leader: bool,
145
163
  ) -> Result<(), LeaderError> {
146
164
  let plan = leader_start_plan(
147
165
  provider,
@@ -150,6 +168,7 @@ pub fn start_leader(
150
168
  attach_existing,
151
169
  confirm_attach,
152
170
  attach_session,
171
+ external_leader,
153
172
  )?;
154
173
  crate::event_log::EventLog::new(workspace).write(
155
174
  super::LeaderEvent::LeaderStart.name(),
@@ -170,6 +189,9 @@ pub fn execute_leader_plan(
170
189
  plan: &LeaderStartPlan,
171
190
  workspace: &Path,
172
191
  ) -> Result<LeaderLaunchOutcome, LeaderError> {
192
+ if plan.mode == LeaderStartMode::ManagedTmuxClient {
193
+ return execute_managed_leader_plan(plan, workspace);
194
+ }
173
195
  let mut argv = plan.argv.clone();
174
196
  let detached = plan.mode == LeaderStartMode::NewTmuxSession
175
197
  && !std::io::stdin().is_terminal()
@@ -236,6 +258,12 @@ fn start_argv(
236
258
  argv.extend(provider_args.iter().cloned());
237
259
  Ok(argv)
238
260
  }
261
+ LeaderStartMode::ManagedTmuxClient => {
262
+ let Some(session) = session_name else {
263
+ return Err(LeaderError::Start("managed leader session missing".to_string()));
264
+ };
265
+ managed_client_argv(workspace, session)
266
+ }
239
267
  LeaderStartMode::AttachExisting => {
240
268
  let Some(session) = session_name else {
241
269
  return Err(LeaderError::Start("attach session missing".to_string()));
@@ -283,6 +311,224 @@ fn start_argv(
283
311
  }
284
312
  }
285
313
 
314
+ fn provider_command_argv(provider: Provider, provider_args: &[String]) -> Vec<String> {
315
+ let mut argv = vec![provider_command_name(provider).to_string()];
316
+ argv.extend(provider_args.iter().cloned());
317
+ argv
318
+ }
319
+
320
+ fn managed_team_session_name(identity: &LeaderIdentity) -> SessionName {
321
+ let team = identity.team_id.as_str();
322
+ if team.starts_with("team-") {
323
+ SessionName::new(team.to_string())
324
+ } else {
325
+ SessionName::new(format!("team-{team}"))
326
+ }
327
+ }
328
+
329
+ fn managed_client_argv(workspace: &Path, session: &SessionName) -> Result<Vec<String>, LeaderError> {
330
+ let target = format!("{}:leader", session.as_str());
331
+ let argv = if std::env::var_os("TMUX").is_some() {
332
+ vec![
333
+ "tmux".to_string(),
334
+ "switch-client".to_string(),
335
+ "-t".to_string(),
336
+ target,
337
+ ]
338
+ } else {
339
+ vec![
340
+ "tmux".to_string(),
341
+ "attach-session".to_string(),
342
+ "-t".to_string(),
343
+ target,
344
+ ]
345
+ };
346
+ Ok(TmuxBackend::argv_for_workspace(workspace, &argv))
347
+ }
348
+
349
+ fn validate_managed_tmux_context(workspace: &Path) -> Result<(), LeaderError> {
350
+ let Some(current_endpoint) = crate::tmux_backend::socket_name_from_tmux_env() else {
351
+ return Ok(());
352
+ };
353
+ let expected = crate::tmux_backend::socket_name_for_workspace(workspace);
354
+ if tmux_endpoint_matches_socket_name(&current_endpoint, &expected) {
355
+ return Ok(());
356
+ }
357
+ Err(LeaderError::Start(format!(
358
+ "Error: managed leader launch refused inside a different tmux server\nReason: current TMUX socket is {current_endpoint}, but this team uses {expected}\nAction: detach and run from a normal shell, or use --external-leader for the external leader topology"
359
+ )))
360
+ }
361
+
362
+ fn tmux_endpoint_matches_socket_name(endpoint: &str, socket_name: &str) -> bool {
363
+ endpoint == socket_name
364
+ || Path::new(endpoint)
365
+ .file_name()
366
+ .and_then(|name| name.to_str())
367
+ == Some(socket_name)
368
+ }
369
+
370
+ fn execute_managed_leader_plan(
371
+ plan: &LeaderStartPlan,
372
+ workspace: &Path,
373
+ ) -> Result<LeaderLaunchOutcome, LeaderError> {
374
+ let Some(session) = plan.session_name.as_ref() else {
375
+ return Err(LeaderError::Start("managed leader session missing".to_string()));
376
+ };
377
+ let Some(window) = plan.leader_window.as_ref() else {
378
+ return Err(LeaderError::Start("managed leader window missing".to_string()));
379
+ };
380
+ let transport = TmuxBackend::for_workspace(workspace);
381
+ let spawned = ensure_managed_leader_pane(&transport, session, window, plan, workspace)?;
382
+ persist_managed_leader_binding(plan, workspace, &spawned)?;
383
+ spawn_managed_provider_startup_prompt_handler(
384
+ plan.provider,
385
+ workspace.to_path_buf(),
386
+ spawned.pane_id.as_str().to_string(),
387
+ );
388
+ let status = run_leader_argv(&plan.argv, &BTreeMap::new(), plan, workspace)?;
389
+ let code = status.code();
390
+ if !status.success() {
391
+ return Err(LeaderError::Start(format!(
392
+ "leader launcher exited with status {}",
393
+ code.map(|c| c.to_string())
394
+ .unwrap_or_else(|| "signal".to_string())
395
+ )));
396
+ }
397
+ Ok(LeaderLaunchOutcome {
398
+ status: LeaderLaunchStatus::Exited,
399
+ exit_code: code,
400
+ session_name: plan.session_name.clone(),
401
+ reason: None,
402
+ })
403
+ }
404
+
405
+ fn ensure_managed_leader_pane(
406
+ transport: &TmuxBackend,
407
+ session: &SessionName,
408
+ window: &WindowName,
409
+ plan: &LeaderStartPlan,
410
+ workspace: &Path,
411
+ ) -> Result<SpawnResult, LeaderError> {
412
+ if transport.has_session(session).unwrap_or(false) {
413
+ if transport
414
+ .list_windows(session)
415
+ .unwrap_or_default()
416
+ .iter()
417
+ .any(|existing| existing.as_str() == window.as_str())
418
+ {
419
+ if let Some(existing) = transport
420
+ .list_targets()
421
+ .unwrap_or_default()
422
+ .into_iter()
423
+ .find(|pane| {
424
+ pane.session.as_str() == session.as_str()
425
+ && pane.window_name.as_ref().map(WindowName::as_str)
426
+ == Some(window.as_str())
427
+ })
428
+ {
429
+ return Ok(SpawnResult {
430
+ pane_id: existing.pane_id,
431
+ session: session.clone(),
432
+ window: window.clone(),
433
+ child_pid: existing.pane_pid,
434
+ });
435
+ }
436
+ }
437
+ transport
438
+ .spawn_into(session, window, &plan.provider_argv, workspace, &plan.leader_env)
439
+ .map_err(|error| LeaderError::Start(error.to_string()))
440
+ } else {
441
+ transport
442
+ .spawn_first(session, window, &plan.provider_argv, workspace, &plan.leader_env)
443
+ .map_err(|error| LeaderError::Start(error.to_string()))
444
+ }
445
+ }
446
+
447
+ fn persist_managed_leader_binding(
448
+ plan: &LeaderStartPlan,
449
+ workspace: &Path,
450
+ spawned: &SpawnResult,
451
+ ) -> Result<(), LeaderError> {
452
+ let identity = plan
453
+ .identity
454
+ .as_ref()
455
+ .ok_or_else(|| LeaderError::Start("managed leader identity missing".to_string()))?;
456
+ let mut state = crate::state::persist::load_runtime_state(workspace)
457
+ .unwrap_or_else(|_| serde_json::json!({}));
458
+ let owner_epoch = state
459
+ .get("owner_epoch")
460
+ .and_then(serde_json::Value::as_u64)
461
+ .or_else(|| {
462
+ state
463
+ .get("team_owner")
464
+ .and_then(|owner| owner.get("owner_epoch"))
465
+ .and_then(serde_json::Value::as_u64)
466
+ })
467
+ .unwrap_or(0)
468
+ .saturating_add(1);
469
+ let now = chrono::Utc::now().to_rfc3339();
470
+ let socket = crate::tmux_backend::socket_name_for_workspace(workspace);
471
+ let provider = serde_json::to_value(plan.provider)?;
472
+ let session = spawned.session.as_str().to_string();
473
+ let window = spawned.window.as_str().to_string();
474
+ let pane = spawned.pane_id.as_str().to_string();
475
+ let receiver = serde_json::json!({
476
+ "mode": "direct_tmux",
477
+ "status": "attached",
478
+ "provider": provider.clone(),
479
+ "pane_id": pane,
480
+ "pane": pane,
481
+ "session_name": session,
482
+ "window_name": window,
483
+ "tmux_socket": socket,
484
+ "leader_session_uuid": identity.leader_session_uuid,
485
+ "owner_epoch": owner_epoch,
486
+ "attached_at": now,
487
+ "discovery": "managed_launcher",
488
+ });
489
+ let owner = serde_json::json!({
490
+ "pane_id": pane,
491
+ "provider": provider.clone(),
492
+ "machine_fingerprint": identity.machine_fingerprint,
493
+ "leader_session_uuid": identity.leader_session_uuid,
494
+ "owner_epoch": owner_epoch,
495
+ "claimed_at": now,
496
+ "claimed_via": "claim-leader",
497
+ "os_user": identity.os_user,
498
+ });
499
+ if let Some(obj) = state.as_object_mut() {
500
+ obj.insert("session_name".to_string(), serde_json::json!(session));
501
+ obj.insert(
502
+ "active_team_key".to_string(),
503
+ serde_json::json!(identity.team_id.as_str()),
504
+ );
505
+ obj.insert("tmux_socket".to_string(), serde_json::json!(socket));
506
+ obj.insert("is_external_leader".to_string(), serde_json::json!(false));
507
+ obj.insert(
508
+ "leader_client".to_string(),
509
+ serde_json::json!({
510
+ "diagnostic_only": true,
511
+ "attach_mode": if std::env::var_os("TMUX").is_some() { "switch-client" } else { "attach-session" },
512
+ "tmux": std::env::var("TMUX").ok(),
513
+ }),
514
+ );
515
+ obj.insert("leader_receiver".to_string(), receiver);
516
+ obj.insert("team_owner".to_string(), owner);
517
+ obj.insert("owner_epoch".to_string(), serde_json::json!(owner_epoch));
518
+ }
519
+ let entry = crate::state::projection::compact_team_state(&state);
520
+ if let Some(obj) = state.as_object_mut() {
521
+ let teams = obj
522
+ .entry("teams".to_string())
523
+ .or_insert_with(|| serde_json::json!({}));
524
+ if let Some(teams) = teams.as_object_mut() {
525
+ teams.insert(identity.team_id.as_str().to_string(), entry);
526
+ }
527
+ }
528
+ crate::state::persist::save_runtime_state(workspace, &state)?;
529
+ Ok(())
530
+ }
531
+
286
532
  fn insert_detach_flag(argv: &mut Vec<String>) -> bool {
287
533
  if argv.iter().any(|arg| arg == "-d") {
288
534
  return false;
@@ -342,6 +588,24 @@ fn spawn_exec_provider_startup_prompt_handler(provider: Provider, workspace: Pat
342
588
  });
343
589
  }
344
590
 
591
+ fn spawn_managed_provider_startup_prompt_handler(
592
+ provider: Provider,
593
+ workspace: PathBuf,
594
+ pane_id: String,
595
+ ) {
596
+ std::thread::spawn(move || {
597
+ let transport = TmuxBackend::for_workspace(&workspace);
598
+ let _ = handle_exec_provider_startup_prompts(
599
+ provider,
600
+ &workspace,
601
+ &pane_id,
602
+ &transport,
603
+ 30,
604
+ 0.5,
605
+ );
606
+ });
607
+ }
608
+
345
609
  fn tmux_transport_for_current_pane() -> TmuxBackend {
346
610
  crate::tmux_backend::socket_name_from_tmux_env()
347
611
  .map(|endpoint| TmuxBackend::for_tmux_endpoint(&endpoint))
@@ -94,6 +94,7 @@ use super::*;
94
94
  #[test]
95
95
  fn leader_start_mode_serializes_to_python_strings() {
96
96
  assert_eq!(serde_json::to_string(&LeaderStartMode::ExecProvider).unwrap(), "\"exec_provider\"");
97
+ assert_eq!(serde_json::to_string(&LeaderStartMode::ManagedTmuxClient).unwrap(), "\"managed_tmux_client\"");
97
98
  assert_eq!(serde_json::to_string(&LeaderStartMode::NewTmuxSession).unwrap(), "\"new_tmux_session\"");
98
99
  assert_eq!(serde_json::to_string(&LeaderStartMode::AttachExisting).unwrap(), "\"attach_existing\"");
99
100
  }
@@ -59,35 +59,131 @@ use super::*;
59
59
  #[test]
60
60
  #[serial_test::serial(env)]
61
61
  fn leader_start_plan_pins_mode_and_leader_env() {
62
+ let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
63
+ let _e = EnvGuard::apply(&[("TMUX", None), ("TMUX_PANE", None)]);
62
64
  let ws = std::env::temp_dir().join(format!("ta_rs_lsp_{}", std::process::id()));
63
65
  std::fs::create_dir_all(&ws).unwrap();
64
- let plan = leader_start_plan(Provider::Fake, &[], &ws, false, false, None).unwrap();
66
+ let plan = leader_start_plan(Provider::Fake, &[], &ws, false, false, None, false).unwrap();
65
67
  assert_eq!(plan.provider, Provider::Fake);
66
- if std::env::var_os("TMUX").is_some() {
67
- // 已在 tmux 内 → exec in-place。
68
- assert_eq!(plan.mode, LeaderStartMode::ExecProvider);
69
- } else {
70
- // 不在 tmux → 新建 tmux session(测试环境假定 tmux 可用;否则 Err(Start))。
71
- assert_eq!(plan.mode, LeaderStartMode::NewTmuxSession);
72
- // session_name 由派生公式确定。
73
- assert_eq!(plan.session_name.as_ref(), Some(&leader_session_name(Provider::Fake, &ws)));
74
- // plan 边界 detached 恒 false(`-d` 插入在 start_leader 层,非此处)
75
- assert!(!plan.detached, "leader_start_plan 返回值 detached false");
76
- // leader_env 携带 5 个 TEAM_AGENT_* 导出键(_leader_provider_env)。
77
- for key in [
78
- "TEAM_AGENT_LEADER_PROVIDER",
79
- "TEAM_AGENT_LEADER_SESSION_UUID",
80
- "TEAM_AGENT_MACHINE_FINGERPRINT",
81
- "TEAM_AGENT_WORKSPACE",
82
- "TEAM_AGENT_TEAM_ID",
83
- ] {
84
- assert!(plan.leader_env.contains_key(key), "leader_env 缺导出键 {key}");
85
- }
86
- assert_eq!(
87
- plan.leader_env.get("TEAM_AGENT_LEADER_PROVIDER").map(String::as_str),
88
- Some("fake")
89
- );
68
+ assert_eq!(plan.mode, LeaderStartMode::ManagedTmuxClient);
69
+ assert_eq!(
70
+ plan.session_name.as_ref().map(SessionName::as_str),
71
+ Some("team-current")
72
+ );
73
+ assert_eq!(plan.leader_window.as_ref().map(WindowName::as_str), Some("leader"));
74
+ assert!(!plan.is_external_leader);
75
+ assert!(
76
+ plan.argv.iter().any(|arg| arg == "attach-session"),
77
+ "no-tmux managed launch attaches the user client to the team leader window: {:?}",
78
+ plan.argv
79
+ );
80
+ assert!(
81
+ plan.argv.iter().any(|arg| arg == "team-current:leader"),
82
+ "managed client target must be the team leader window: {:?}",
83
+ plan.argv
84
+ );
85
+ // plan 边界 detached 恒 false(`-d` 插入在 start_leader 层,非此处)。
86
+ assert!(!plan.detached, "leader_start_plan 返回值 detached 恒 false");
87
+ // leader_env 携带 5 个 TEAM_AGENT_* 导出键(_leader_provider_env)。
88
+ for key in [
89
+ "TEAM_AGENT_LEADER_PROVIDER",
90
+ "TEAM_AGENT_LEADER_SESSION_UUID",
91
+ "TEAM_AGENT_MACHINE_FINGERPRINT",
92
+ "TEAM_AGENT_WORKSPACE",
93
+ "TEAM_AGENT_TEAM_ID",
94
+ ] {
95
+ assert!(plan.leader_env.contains_key(key), "leader_env 缺导出键 {key}");
90
96
  }
97
+ assert_eq!(
98
+ plan.leader_env.get("TEAM_AGENT_LEADER_PROVIDER").map(String::as_str),
99
+ Some("fake")
100
+ );
101
+ }
102
+
103
+ #[test]
104
+ #[serial_test::serial(env)]
105
+ fn leader_start_plan_external_leader_keeps_exec_provider_in_tmux() {
106
+ let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
107
+ let _e = EnvGuard::apply(&[("TMUX", Some("/private/tmp/tmux-501/default,88432,187"))]);
108
+ let ws = std::env::temp_dir().join(format!("ta_rs_lsp_external_{}", std::process::id()));
109
+ std::fs::create_dir_all(&ws).unwrap();
110
+
111
+ let plan = leader_start_plan(Provider::Fake, &[], &ws, false, false, None, true).unwrap();
112
+
113
+ assert_eq!(plan.mode, LeaderStartMode::ExecProvider);
114
+ assert!(plan.is_external_leader);
115
+ assert_eq!(plan.leader_window, None);
116
+ assert_eq!(plan.argv, vec!["fake".to_string()]);
117
+ }
118
+
119
+ #[test]
120
+ #[serial_test::serial(env)]
121
+ fn managed_leader_reuses_existing_team_session_name_without_double_prefix() {
122
+ let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
123
+ let _e = EnvGuard::apply(&[("TMUX", None), ("TMUX_PANE", None)]);
124
+ let ws = std::env::temp_dir().join(format!("ta_rs_lsp_existing_{}", std::process::id()));
125
+ std::fs::create_dir_all(&ws).unwrap();
126
+ crate::state::persist::save_runtime_state(
127
+ &ws,
128
+ &serde_json::json!({"session_name": "team-alpha"}),
129
+ )
130
+ .unwrap();
131
+
132
+ let plan = leader_start_plan(Provider::Fake, &[], &ws, false, false, None, false).unwrap();
133
+
134
+ assert_eq!(plan.mode, LeaderStartMode::ManagedTmuxClient);
135
+ assert_eq!(
136
+ plan.session_name.as_ref().map(SessionName::as_str),
137
+ Some("team-alpha")
138
+ );
139
+ assert!(
140
+ !plan.argv.iter().any(|arg| arg.contains("team-team-alpha")),
141
+ "managed session name must not gain a second team- prefix: {:?}",
142
+ plan.argv
143
+ );
144
+ }
145
+
146
+ #[test]
147
+ #[serial_test::serial(env)]
148
+ fn managed_leader_in_same_tmux_server_switches_client() {
149
+ let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
150
+ let ws = std::env::temp_dir().join(format!("ta_rs_lsp_switch_{}", std::process::id()));
151
+ std::fs::create_dir_all(&ws).unwrap();
152
+ let socket = crate::tmux_backend::socket_name_for_workspace(&ws);
153
+ let endpoint = format!("/private/tmp/tmux-501/{socket},88432,187");
154
+ let _e = EnvGuard::apply(&[("TMUX", Some(&endpoint))]);
155
+
156
+ let plan = leader_start_plan(Provider::Fake, &[], &ws, false, false, None, false).unwrap();
157
+
158
+ assert_eq!(plan.mode, LeaderStartMode::ManagedTmuxClient);
159
+ assert!(
160
+ plan.argv.iter().any(|arg| arg == "switch-client"),
161
+ "same-server managed launch must switch the existing tmux client: {:?}",
162
+ plan.argv
163
+ );
164
+ assert!(
165
+ plan.argv.iter().any(|arg| arg == "team-current:leader"),
166
+ "managed switch target must be the team leader window: {:?}",
167
+ plan.argv
168
+ );
169
+ }
170
+
171
+ #[test]
172
+ #[serial_test::serial(env)]
173
+ fn managed_leader_in_different_tmux_server_refuses_with_n38_hint() {
174
+ let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
175
+ let _e = EnvGuard::apply(&[("TMUX", Some("/private/tmp/tmux-501/default,88432,187"))]);
176
+ let ws = std::env::temp_dir().join(format!("ta_rs_lsp_refuse_{}", std::process::id()));
177
+ std::fs::create_dir_all(&ws).unwrap();
178
+
179
+ let err = leader_start_plan(Provider::Fake, &[], &ws, false, false, None, false)
180
+ .unwrap_err()
181
+ .to_string();
182
+
183
+ assert!(err.contains("Error:"), "{err}");
184
+ assert!(err.contains("Reason:"), "{err}");
185
+ assert!(err.contains("Action:"), "{err}");
186
+ assert!(err.contains("--external-leader"), "{err}");
91
187
  }
92
188
 
93
189
  #[test]
@@ -145,6 +145,9 @@ pub enum RereadReason {
145
145
  pub enum LeaderStartMode {
146
146
  /// `os.environ["TMUX"]` 已在 tmux 内 → exec provider in-place。
147
147
  ExecProvider,
148
+ /// Managed launcher topology: provider pane lives in the team session's `leader` window;
149
+ /// the invoking terminal is only a tmux client attached/switched to that pane.
150
+ ManagedTmuxClient,
148
151
  /// 无 tmux session → 新建 tmux session。
149
152
  NewTmuxSession,
150
153
  /// session 已存在 / `--attach-session` → attach。
@@ -331,6 +334,12 @@ pub struct LeaderStartPlan {
331
334
  pub session_name: Option<SessionName>,
332
335
  /// 要 exec 的 argv(provider argv 或 tmux argv)。
333
336
  pub argv: Vec<String>,
337
+ /// For managed launches, the provider command argv spawned inside the leader pane.
338
+ pub provider_argv: Vec<String>,
339
+ /// For managed launches, the window that hosts the leader provider pane.
340
+ pub leader_window: Option<WindowName>,
341
+ /// True for external/current-pane leader compatibility paths.
342
+ pub is_external_leader: bool,
334
343
  /// `new_tmux_session` / `exec_provider` 的 leader env 导出。
335
344
  pub leader_env: BTreeMap<String, String>,
336
345
  pub identity: Option<LeaderIdentity>,