@team-agent/installer 0.3.15 → 0.3.17

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.
@@ -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()
@@ -183,6 +205,9 @@ pub fn execute_leader_plan(
183
205
  .unwrap_or_else(|| "signal".to_string())
184
206
  )));
185
207
  }
208
+ if plan.is_external_leader {
209
+ persist_external_leader_topology_marker(plan, workspace)?;
210
+ }
186
211
  if detached {
187
212
  Ok(LeaderLaunchOutcome {
188
213
  status: LeaderLaunchStatus::Detached,
@@ -236,6 +261,12 @@ fn start_argv(
236
261
  argv.extend(provider_args.iter().cloned());
237
262
  Ok(argv)
238
263
  }
264
+ LeaderStartMode::ManagedTmuxClient => {
265
+ let Some(session) = session_name else {
266
+ return Err(LeaderError::Start("managed leader session missing".to_string()));
267
+ };
268
+ managed_client_argv(workspace, session)
269
+ }
239
270
  LeaderStartMode::AttachExisting => {
240
271
  let Some(session) = session_name else {
241
272
  return Err(LeaderError::Start("attach session missing".to_string()));
@@ -283,6 +314,259 @@ fn start_argv(
283
314
  }
284
315
  }
285
316
 
317
+ fn provider_command_argv(provider: Provider, provider_args: &[String]) -> Vec<String> {
318
+ let mut argv = vec![provider_command_name(provider).to_string()];
319
+ argv.extend(provider_args.iter().cloned());
320
+ argv
321
+ }
322
+
323
+ fn managed_team_session_name(identity: &LeaderIdentity) -> SessionName {
324
+ let team = identity.team_id.as_str();
325
+ if team.starts_with("team-") {
326
+ SessionName::new(team.to_string())
327
+ } else {
328
+ SessionName::new(format!("team-{team}"))
329
+ }
330
+ }
331
+
332
+ fn managed_client_argv(workspace: &Path, session: &SessionName) -> Result<Vec<String>, LeaderError> {
333
+ let target = format!("{}:leader", session.as_str());
334
+ let argv = if std::env::var_os("TMUX").is_some() {
335
+ vec![
336
+ "tmux".to_string(),
337
+ "switch-client".to_string(),
338
+ "-t".to_string(),
339
+ target,
340
+ ]
341
+ } else {
342
+ vec![
343
+ "tmux".to_string(),
344
+ "attach-session".to_string(),
345
+ "-t".to_string(),
346
+ target,
347
+ ]
348
+ };
349
+ Ok(TmuxBackend::argv_for_workspace(workspace, &argv))
350
+ }
351
+
352
+ fn validate_managed_tmux_context(workspace: &Path) -> Result<(), LeaderError> {
353
+ let Some(current_endpoint) = crate::tmux_backend::socket_name_from_tmux_env() else {
354
+ return Ok(());
355
+ };
356
+ let expected = crate::tmux_backend::socket_name_for_workspace(workspace);
357
+ if tmux_endpoint_matches_socket_name(&current_endpoint, &expected) {
358
+ return Ok(());
359
+ }
360
+ Err(LeaderError::Start(format!(
361
+ "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"
362
+ )))
363
+ }
364
+
365
+ fn tmux_endpoint_matches_socket_name(endpoint: &str, socket_name: &str) -> bool {
366
+ endpoint == socket_name
367
+ || Path::new(endpoint)
368
+ .file_name()
369
+ .and_then(|name| name.to_str())
370
+ == Some(socket_name)
371
+ }
372
+
373
+ fn execute_managed_leader_plan(
374
+ plan: &LeaderStartPlan,
375
+ workspace: &Path,
376
+ ) -> Result<LeaderLaunchOutcome, LeaderError> {
377
+ let Some(session) = plan.session_name.as_ref() else {
378
+ return Err(LeaderError::Start("managed leader session missing".to_string()));
379
+ };
380
+ let Some(window) = plan.leader_window.as_ref() else {
381
+ return Err(LeaderError::Start("managed leader window missing".to_string()));
382
+ };
383
+ let transport = TmuxBackend::for_workspace(workspace);
384
+ let spawned = ensure_managed_leader_pane(&transport, session, window, plan, workspace)?;
385
+ persist_managed_leader_binding(plan, workspace, &spawned)?;
386
+ spawn_managed_provider_startup_prompt_handler(
387
+ plan.provider,
388
+ workspace.to_path_buf(),
389
+ spawned.pane_id.as_str().to_string(),
390
+ );
391
+ let status = run_leader_argv(&plan.argv, &BTreeMap::new(), plan, workspace)?;
392
+ let code = status.code();
393
+ if !status.success() {
394
+ return Err(LeaderError::Start(format!(
395
+ "leader launcher exited with status {}",
396
+ code.map(|c| c.to_string())
397
+ .unwrap_or_else(|| "signal".to_string())
398
+ )));
399
+ }
400
+ Ok(LeaderLaunchOutcome {
401
+ status: LeaderLaunchStatus::Exited,
402
+ exit_code: code,
403
+ session_name: plan.session_name.clone(),
404
+ reason: None,
405
+ })
406
+ }
407
+
408
+ fn ensure_managed_leader_pane(
409
+ transport: &TmuxBackend,
410
+ session: &SessionName,
411
+ window: &WindowName,
412
+ plan: &LeaderStartPlan,
413
+ workspace: &Path,
414
+ ) -> Result<SpawnResult, LeaderError> {
415
+ if transport.has_session(session).unwrap_or(false) {
416
+ if transport
417
+ .list_windows(session)
418
+ .unwrap_or_default()
419
+ .iter()
420
+ .any(|existing| existing.as_str() == window.as_str())
421
+ {
422
+ if let Some(existing) = transport
423
+ .list_targets()
424
+ .unwrap_or_default()
425
+ .into_iter()
426
+ .find(|pane| {
427
+ pane.session.as_str() == session.as_str()
428
+ && pane.window_name.as_ref().map(WindowName::as_str)
429
+ == Some(window.as_str())
430
+ })
431
+ {
432
+ return Ok(SpawnResult {
433
+ pane_id: existing.pane_id,
434
+ session: session.clone(),
435
+ window: window.clone(),
436
+ child_pid: existing.pane_pid,
437
+ });
438
+ }
439
+ }
440
+ transport
441
+ .spawn_into(session, window, &plan.provider_argv, workspace, &plan.leader_env)
442
+ .map_err(|error| LeaderError::Start(error.to_string()))
443
+ } else {
444
+ transport
445
+ .spawn_first(session, window, &plan.provider_argv, workspace, &plan.leader_env)
446
+ .map_err(|error| LeaderError::Start(error.to_string()))
447
+ }
448
+ }
449
+
450
+ fn persist_managed_leader_binding(
451
+ plan: &LeaderStartPlan,
452
+ workspace: &Path,
453
+ spawned: &SpawnResult,
454
+ ) -> Result<(), LeaderError> {
455
+ let identity = plan
456
+ .identity
457
+ .as_ref()
458
+ .ok_or_else(|| LeaderError::Start("managed leader identity missing".to_string()))?;
459
+ let mut state = crate::state::persist::load_runtime_state(workspace)
460
+ .unwrap_or_else(|_| serde_json::json!({}));
461
+ let owner_epoch = state
462
+ .get("owner_epoch")
463
+ .and_then(serde_json::Value::as_u64)
464
+ .or_else(|| {
465
+ state
466
+ .get("team_owner")
467
+ .and_then(|owner| owner.get("owner_epoch"))
468
+ .and_then(serde_json::Value::as_u64)
469
+ })
470
+ .unwrap_or(0)
471
+ .saturating_add(1);
472
+ let now = chrono::Utc::now().to_rfc3339();
473
+ let socket = crate::tmux_backend::socket_name_for_workspace(workspace);
474
+ let provider = serde_json::to_value(plan.provider)?;
475
+ let session = spawned.session.as_str().to_string();
476
+ let window = spawned.window.as_str().to_string();
477
+ let pane = spawned.pane_id.as_str().to_string();
478
+ let receiver = serde_json::json!({
479
+ "mode": "direct_tmux",
480
+ "status": "attached",
481
+ "provider": provider.clone(),
482
+ "pane_id": pane,
483
+ "pane": pane,
484
+ "session_name": session,
485
+ "window_name": window,
486
+ "tmux_socket": socket,
487
+ "leader_session_uuid": identity.leader_session_uuid,
488
+ "owner_epoch": owner_epoch,
489
+ "attached_at": now,
490
+ "discovery": "managed_launcher",
491
+ });
492
+ let owner = serde_json::json!({
493
+ "pane_id": pane,
494
+ "provider": provider.clone(),
495
+ "machine_fingerprint": identity.machine_fingerprint,
496
+ "leader_session_uuid": identity.leader_session_uuid,
497
+ "owner_epoch": owner_epoch,
498
+ "claimed_at": now,
499
+ "claimed_via": "claim-leader",
500
+ "os_user": identity.os_user,
501
+ });
502
+ if let Some(obj) = state.as_object_mut() {
503
+ obj.insert("session_name".to_string(), serde_json::json!(session));
504
+ obj.insert(
505
+ "active_team_key".to_string(),
506
+ serde_json::json!(identity.team_id.as_str()),
507
+ );
508
+ obj.insert("tmux_socket".to_string(), serde_json::json!(socket));
509
+ obj.insert("is_external_leader".to_string(), serde_json::json!(false));
510
+ obj.insert(
511
+ "leader_client".to_string(),
512
+ serde_json::json!({
513
+ "diagnostic_only": true,
514
+ "attach_mode": if std::env::var_os("TMUX").is_some() { "switch-client" } else { "attach-session" },
515
+ "tmux": std::env::var("TMUX").ok(),
516
+ }),
517
+ );
518
+ obj.insert("leader_receiver".to_string(), receiver);
519
+ obj.insert("team_owner".to_string(), owner);
520
+ obj.insert("owner_epoch".to_string(), serde_json::json!(owner_epoch));
521
+ }
522
+ let entry = crate::state::projection::compact_team_state(&state);
523
+ if let Some(obj) = state.as_object_mut() {
524
+ let teams = obj
525
+ .entry("teams".to_string())
526
+ .or_insert_with(|| serde_json::json!({}));
527
+ if let Some(teams) = teams.as_object_mut() {
528
+ teams.insert(identity.team_id.as_str().to_string(), entry);
529
+ }
530
+ }
531
+ crate::state::persist::save_runtime_state(workspace, &state)?;
532
+ Ok(())
533
+ }
534
+
535
+ fn persist_external_leader_topology_marker(
536
+ plan: &LeaderStartPlan,
537
+ workspace: &Path,
538
+ ) -> Result<(), LeaderError> {
539
+ let identity = plan
540
+ .identity
541
+ .as_ref()
542
+ .ok_or_else(|| LeaderError::Start("external leader identity missing".to_string()))?;
543
+ let mut state = crate::state::persist::load_runtime_state(workspace)
544
+ .unwrap_or_else(|_| serde_json::json!({}));
545
+ if let Some(obj) = state.as_object_mut() {
546
+ obj.entry("workspace".to_string()).or_insert_with(|| {
547
+ serde_json::json!(resolve_workspace_for_hash(workspace).to_string_lossy().to_string())
548
+ });
549
+ obj.entry("active_team_key".to_string())
550
+ .or_insert_with(|| serde_json::json!(identity.team_id.as_str()));
551
+ if let Some(session) = plan.session_name.as_ref() {
552
+ obj.entry("session_name".to_string())
553
+ .or_insert_with(|| serde_json::json!(session.as_str()));
554
+ }
555
+ obj.insert("is_external_leader".to_string(), serde_json::json!(true));
556
+ }
557
+ let entry = crate::state::projection::compact_team_state(&state);
558
+ if let Some(obj) = state.as_object_mut() {
559
+ let teams = obj
560
+ .entry("teams".to_string())
561
+ .or_insert_with(|| serde_json::json!({}));
562
+ if let Some(teams) = teams.as_object_mut() {
563
+ teams.insert(identity.team_id.as_str().to_string(), entry);
564
+ }
565
+ }
566
+ crate::state::persist::save_runtime_state(workspace, &state)?;
567
+ Ok(())
568
+ }
569
+
286
570
  fn insert_detach_flag(argv: &mut Vec<String>) -> bool {
287
571
  if argv.iter().any(|arg| arg == "-d") {
288
572
  return false;
@@ -342,6 +626,24 @@ fn spawn_exec_provider_startup_prompt_handler(provider: Provider, workspace: Pat
342
626
  });
343
627
  }
344
628
 
629
+ fn spawn_managed_provider_startup_prompt_handler(
630
+ provider: Provider,
631
+ workspace: PathBuf,
632
+ pane_id: String,
633
+ ) {
634
+ std::thread::spawn(move || {
635
+ let transport = TmuxBackend::for_workspace(&workspace);
636
+ let _ = handle_exec_provider_startup_prompts(
637
+ provider,
638
+ &workspace,
639
+ &pane_id,
640
+ &transport,
641
+ 30,
642
+ 0.5,
643
+ );
644
+ });
645
+ }
646
+
345
647
  fn tmux_transport_for_current_pane() -> TmuxBackend {
346
648
  crate::tmux_backend::socket_name_from_tmux_env()
347
649
  .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]
@@ -14,7 +14,7 @@ use super::*;
14
14
  fn attach_leader_errors_when_pane_not_resolvable() {
15
15
  let ws = std::env::temp_dir().join(format!("ta_rs_attach_{}", std::process::id()));
16
16
  std::fs::create_dir_all(&ws).unwrap();
17
- let r = attach_leader(&ws, Some(&PaneId::new("%1")), Provider::Codex);
17
+ let r = attach_leader(&ws, None, Some(&PaneId::new("%1")), Provider::Codex);
18
18
  // 不可解析 pane → Err(Validation),错误串提及 pane not found。
19
19
  match r {
20
20
  Err(LeaderError::Validation(msg)) => {
@@ -39,7 +39,7 @@ use super::*;
39
39
  let ws = std::env::temp_dir().join(format!("ta_rs_attach_ok_{}", std::process::id()));
40
40
  std::fs::create_dir_all(&ws).unwrap();
41
41
  let pane = PaneId::new("%1");
42
- let r = attach_leader(&ws, Some(&pane), Provider::Codex).unwrap();
42
+ let r = attach_leader(&ws, None, Some(&pane), Provider::Codex).unwrap();
43
43
  assert!(r.ok);
44
44
  assert_eq!(r.status, LeaseStatus::Claimed);
45
45
  let owner = r.owner.as_ref().expect("attach 成功必带 owner");
@@ -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>,