@team-agent/installer 0.3.18 → 0.3.20

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.
package/Cargo.lock CHANGED
@@ -566,7 +566,7 @@ dependencies = [
566
566
 
567
567
  [[package]]
568
568
  name = "team-agent"
569
- version = "0.3.18"
569
+ version = "0.3.20"
570
570
  dependencies = [
571
571
  "anyhow",
572
572
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent"]
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.3.18"
12
+ version = "0.3.20"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -32,9 +32,10 @@ pub fn run(argv: &[String], cwd: &Path) -> ExitCode {
32
32
  return emit_missing_subcommand_usage();
33
33
  };
34
34
  if command == "codex" || command == "claude" || command == "copilot" {
35
- return cmd_leader_passthrough(command, &argv[1..], cwd)
36
- .map(emit_result)
37
- .unwrap_or(ExitCode::Error);
35
+ return match cmd_leader_passthrough(command, &argv[1..], cwd) {
36
+ Ok(result) => emit_result(result),
37
+ Err(error) => emit_cli_error(command, &argv[1..], cwd, &error),
38
+ };
38
39
  }
39
40
  if matches!(command, "-h" | "--help" | "help") {
40
41
  println!("{}", command_help(None));
@@ -23,7 +23,14 @@ pub fn leader_launcher_args(values: &[String]) -> Result<LeaderLauncherArgs, Cli
23
23
  while idx < values.len() {
24
24
  let token = &values[idx];
25
25
  if token == "--" {
26
- out.provider_args.extend(values.iter().skip(idx).cloned());
26
+ for value in values.iter().skip(idx + 1) {
27
+ if is_leader_launcher_flag(value) {
28
+ return Err(CliError::Runtime(format!(
29
+ "Team Agent launcher flag {value} must appear before --"
30
+ )));
31
+ }
32
+ }
33
+ out.provider_args.extend(values.iter().skip(idx + 1).cloned());
27
34
  break;
28
35
  } else if token == "--attach" || token == "--attach-existing" {
29
36
  out.attach_existing = true;
@@ -49,6 +56,13 @@ pub fn leader_launcher_args(values: &[String]) -> Result<LeaderLauncherArgs, Cli
49
56
  Ok(out)
50
57
  }
51
58
 
59
+ fn is_leader_launcher_flag(value: &str) -> bool {
60
+ matches!(
61
+ value,
62
+ "--external-leader" | "--attach" | "--attach-existing" | "--confirm" | "--attach-session"
63
+ ) || value.starts_with("--attach-session=")
64
+ }
65
+
52
66
  fn leader_launcher_json(values: &[String]) -> bool {
53
67
  values
54
68
  .iter()
@@ -86,17 +86,18 @@ use super::*;
86
86
  assert!(!got.attach_existing);
87
87
  assert_eq!(
88
88
  got.provider_args,
89
- vec!["--".to_string(), "--model".to_string(), "opus".to_string()]
89
+ vec!["--model".to_string(), "opus".to_string()]
90
90
  );
91
91
  }
92
92
 
93
93
  #[test]
94
- fn leader_launcher_args_external_leader_after_dashdash_is_provider_arg() {
95
- let got = leader_launcher_args(&["--".into(), "--external-leader".into()]).unwrap();
96
- assert!(!got.external_leader);
97
- assert_eq!(
98
- got.provider_args,
99
- vec!["--".to_string(), "--external-leader".to_string()]
94
+ fn leader_launcher_args_external_leader_after_dashdash_errors() {
95
+ let err = leader_launcher_args(&["--".into(), "--external-leader".into()])
96
+ .expect_err("Team Agent flags after -- must not be silently passed to provider");
97
+ assert!(
98
+ err.to_string()
99
+ .contains("Team Agent launcher flag --external-leader must appear before --"),
100
+ "unexpected error: {err}"
100
101
  );
101
102
  }
102
103
 
@@ -116,22 +117,22 @@ use super::*;
116
117
  }
117
118
 
118
119
  #[test]
119
- fn leader_launcher_args_dashdash_passthrough_includes_following_flags() {
120
- // golden: ["--attach","--","-x","--confirm"] ->
121
- // provider_args=["--","-x","--confirm"], attach_existing=True, confirm_attach=False
122
- // (everything from `--` onward, INCLUDING the `--` token itself, is provider passthrough)
120
+ fn leader_launcher_args_dashdash_passthrough_strips_separator() {
121
+ // ["--attach","--","-x","--provider-confirm"] ->
122
+ // provider_args=["-x","--provider-confirm"], attach_existing=True, confirm_attach=False
123
+ // Known Team Agent launcher flags after `--` are rejected by a separate guard.
123
124
  let got = leader_launcher_args(&[
124
125
  "--attach".into(),
125
126
  "--".into(),
126
127
  "-x".into(),
127
- "--confirm".into(),
128
+ "--provider-confirm".into(),
128
129
  ])
129
130
  .unwrap();
130
131
  assert!(got.attach_existing);
131
- assert!(!got.confirm_attach, "--confirm AFTER -- must NOT set confirm_attach");
132
+ assert!(!got.confirm_attach);
132
133
  assert_eq!(
133
134
  got.provider_args,
134
- vec!["--".to_string(), "-x".to_string(), "--confirm".to_string()]
135
+ vec!["-x".to_string(), "--provider-confirm".to_string()]
135
136
  );
136
137
  }
137
138
 
@@ -415,6 +415,48 @@ fn run_send_unknown_task_renders_error_not_silent_swallow() {
415
415
  let _ = std::fs::remove_dir_all(&ws);
416
416
  }
417
417
 
418
+ #[test]
419
+ fn run_leader_passthrough_flag_after_dashdash_renders_error_not_silent_swallow() {
420
+ let ws = std::env::temp_dir().join(format!(
421
+ "ta-run-leaderflag-{}-{}",
422
+ std::process::id(),
423
+ std::time::SystemTime::now()
424
+ .duration_since(std::time::UNIX_EPOCH)
425
+ .unwrap()
426
+ .as_nanos()
427
+ ));
428
+ std::fs::create_dir_all(&ws).unwrap();
429
+ let argv: Vec<String> = ["codex", "--", "--external-leader"]
430
+ .iter()
431
+ .map(ToString::to_string)
432
+ .collect();
433
+
434
+ let code = run(&argv, &ws);
435
+
436
+ assert_eq!(
437
+ code,
438
+ ExitCode::Error,
439
+ "misplaced leader flag must fail before provider exec"
440
+ );
441
+ let logs_dir = ws.join(".team").join("logs");
442
+ let mut found = String::new();
443
+ if let Ok(entries) = std::fs::read_dir(&logs_dir) {
444
+ for entry in entries.flatten() {
445
+ let name = entry.file_name().to_string_lossy().to_string();
446
+ if name.starts_with("cli-error-") {
447
+ found = std::fs::read_to_string(entry.path()).unwrap_or_default();
448
+ break;
449
+ }
450
+ }
451
+ }
452
+ assert!(
453
+ found.contains("Team Agent launcher flag --external-leader must appear before --"),
454
+ "leader passthrough run() must render the misplaced flag error; a silent swallow leaves no \
455
+ cli-error log. got log body: {found:?}"
456
+ );
457
+ let _ = std::fs::remove_dir_all(&ws);
458
+ }
459
+
418
460
  // R8 D6 (c-lite offline byte-lock): the CLI requeued_exhausted_watchers return projection, extracted
419
461
  // into a pure helper, must project the golden event's watcher_ids STRING list (leader/__init__.py:56) —
420
462
  // NOT the Rust `requeued` Vec<WatcherNotice> objects.
@@ -101,6 +101,12 @@ impl CliError {
101
101
  .to_string(),
102
102
  ]);
103
103
  }
104
+ } else if error.contains("Team Agent launcher flag")
105
+ && error.contains("must appear before --")
106
+ {
107
+ payload.action = String::from(
108
+ "move the Team Agent launcher flag before `--`; only provider flags belong after `--`",
109
+ );
104
110
  }
105
111
  payload
106
112
  }
@@ -34,21 +34,42 @@ pub struct DaemonArgs {
34
34
  /// §10:返 `Result`(顶层 bin 用 anyhow 收;§12 边界)。
35
35
  pub fn run_daemon(args: DaemonArgs) -> Result<(), DaemonError> {
36
36
  // CP-1: the daemon's whole tick surface (has_session / capture / inject / list_windows / kill)
37
- // runs through this backend bind it to the per-team socket so a dying shared `default` server
38
- // can no longer tear the team down. The daemon knows its --workspace.
37
+ // runs through this backend. Prefer the persisted runtime endpoint so attached explicit-socket
38
+ // teams are checked on the same socket as lifecycle worker operations.
39
+ let state = crate::state::persist::load_runtime_state(args.workspace.as_path()).ok();
40
+ let tmux_selection = crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(
41
+ args.workspace.as_path(),
42
+ state.as_ref(),
43
+ );
44
+ let tmux_metadata = DaemonTmuxEndpointMetadata {
45
+ tmux_endpoint_used: tmux_selection.tmux_endpoint_used.clone(),
46
+ tmux_endpoint_source: tmux_selection.tmux_endpoint_source.as_str(),
47
+ };
39
48
  let coordinator = Coordinator::new(
40
49
  args.workspace.clone(),
41
50
  Box::new(RealProviderRegistry),
42
- Box::new(crate::tmux_backend::TmuxBackend::for_workspace(
43
- args.workspace.as_path(),
44
- )),
51
+ Box::new(tmux_selection.backend),
45
52
  );
46
- run_daemon_with_coordinator(&args, &coordinator)
53
+ run_daemon_with_coordinator_and_boot_tmux(&args, &coordinator, Some(tmux_metadata))
47
54
  }
48
55
 
49
56
  pub fn run_daemon_with_coordinator(
50
57
  args: &DaemonArgs,
51
58
  coordinator: &Coordinator,
59
+ ) -> Result<(), DaemonError> {
60
+ run_daemon_with_coordinator_and_boot_tmux(args, coordinator, None)
61
+ }
62
+
63
+ #[derive(Debug, Clone)]
64
+ struct DaemonTmuxEndpointMetadata {
65
+ tmux_endpoint_used: Option<String>,
66
+ tmux_endpoint_source: &'static str,
67
+ }
68
+
69
+ fn run_daemon_with_coordinator_and_boot_tmux(
70
+ args: &DaemonArgs,
71
+ coordinator: &Coordinator,
72
+ tmux_metadata: Option<DaemonTmuxEndpointMetadata>,
52
73
  ) -> Result<(), DaemonError> {
53
74
  let runtime_dir = crate::model::paths::runtime_dir(args.workspace.as_path());
54
75
  std::fs::create_dir_all(&runtime_dir)?;
@@ -57,13 +78,26 @@ pub fn run_daemon_with_coordinator(
57
78
  write_coordinator_metadata(&args.workspace, pid, MetadataSource::Boot)?;
58
79
 
59
80
  let event_log = EventLog::new(args.workspace.as_path());
60
- event_log.write(
61
- "coordinator.boot",
62
- serde_json::json!({
63
- "workspace": args.workspace.as_path().to_string_lossy(),
64
- "once": args.once,
65
- }),
66
- )?;
81
+ let mut boot_event = serde_json::json!({
82
+ "workspace": args.workspace.as_path().to_string_lossy(),
83
+ "once": args.once,
84
+ });
85
+ if let Some(metadata) = tmux_metadata {
86
+ if let Some(object) = boot_event.as_object_mut() {
87
+ object.insert(
88
+ "tmux_endpoint_source".to_string(),
89
+ serde_json::Value::String(metadata.tmux_endpoint_source.to_string()),
90
+ );
91
+ object.insert(
92
+ "tmux_endpoint_used".to_string(),
93
+ metadata
94
+ .tmux_endpoint_used
95
+ .map(serde_json::Value::String)
96
+ .unwrap_or(serde_json::Value::Null),
97
+ );
98
+ }
99
+ }
100
+ event_log.write("coordinator.boot", boot_event)?;
67
101
  let tick_interval = match args.tick_interval_sec {
68
102
  Some(v) if v > 0.0 => v,
69
103
  _ => resolve_tick_interval(&args.workspace)?,
@@ -209,6 +209,156 @@ fn coord_over_staged_tmux(
209
209
  (coord, dir, seen)
210
210
  }
211
211
 
212
+ fn coord_over_runtime_state_tmux_endpoint(
213
+ session_name: &str,
214
+ endpoint: &str,
215
+ steps: Vec<RunnerStep>,
216
+ last: RunnerStep,
217
+ ) -> (
218
+ Coordinator,
219
+ std::path::PathBuf,
220
+ std::sync::Arc<std::sync::Mutex<Vec<Vec<String>>>>,
221
+ Option<String>,
222
+ &'static str,
223
+ ) {
224
+ use std::sync::atomic::{AtomicU64, Ordering};
225
+ static N: AtomicU64 = AtomicU64::new(0);
226
+ let dir = std::env::temp_dir().join(format!(
227
+ "ta-rs-e27-coord-explicit-{}-{}",
228
+ std::process::id(),
229
+ N.fetch_add(1, Ordering::Relaxed)
230
+ ));
231
+ std::fs::create_dir_all(crate::model::paths::runtime_dir(&dir)).unwrap();
232
+ let _ = crate::message_store::MessageStore::open(&dir).unwrap();
233
+ let state = serde_json::json!({
234
+ "session_name": session_name,
235
+ "tmux_endpoint": endpoint,
236
+ "tmux_socket": endpoint,
237
+ "agents": {},
238
+ });
239
+ crate::state::persist::save_runtime_state(&dir, &state).unwrap();
240
+ let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
241
+ let runner = StagedTmuxRunner {
242
+ steps: std::sync::Mutex::new(steps.into_iter().collect()),
243
+ last,
244
+ seen: std::sync::Arc::clone(&seen),
245
+ };
246
+ let selection = crate::tmux_backend::tmux_backend_with_runner_for_runtime_state_or_workspace(
247
+ Box::new(runner),
248
+ &dir,
249
+ Some(&state),
250
+ );
251
+ let endpoint_used = selection.tmux_endpoint_used.clone();
252
+ let endpoint_source = selection.tmux_endpoint_source.as_str();
253
+ let reg: Box<dyn ProviderRegistry> = Box::new(MockRegistry::new(&[], &[]));
254
+ let coord = Coordinator::for_test(
255
+ WorkspacePath::new(dir.clone()),
256
+ reg,
257
+ Box::new(selection.backend),
258
+ None,
259
+ None,
260
+ );
261
+ (coord, dir, seen, endpoint_used, endpoint_source)
262
+ }
263
+
264
+ #[test]
265
+ fn e27_coordinator_tick_uses_runtime_explicit_endpoint_for_session_gate() {
266
+ let session = "team-e27-explicit-restart";
267
+ let endpoint = "/tmp/e27-explicit-restart-test.sock";
268
+ let (coord, dir, seen, endpoint_used, endpoint_source) = coord_over_runtime_state_tmux_endpoint(
269
+ session,
270
+ endpoint,
271
+ vec![RunnerStep::Exit(true)],
272
+ RunnerStep::Exit(true),
273
+ );
274
+ assert_eq!(endpoint_used.as_deref(), Some(endpoint));
275
+ assert_eq!(endpoint_source, "state.tmux_endpoint");
276
+
277
+ let report = coord
278
+ .tick()
279
+ .expect("explicit endpoint with present session should tick");
280
+ assert!(
281
+ report.ok,
282
+ "present explicit endpoint session should keep tick ok"
283
+ );
284
+ assert!(
285
+ !report.stop,
286
+ "present explicit endpoint session must not trip the session-missing stop gate"
287
+ );
288
+ let calls = seen.lock().unwrap().clone();
289
+ assert!(
290
+ calls
291
+ .iter()
292
+ .all(|argv| !argv.iter().any(|part| part == "-L")),
293
+ "explicit endpoint coordinator must not fall back to workspace -L socket; got {calls:?}"
294
+ );
295
+ assert_eq!(
296
+ calls.first(),
297
+ Some(&vec![
298
+ "tmux".to_string(),
299
+ "-S".to_string(),
300
+ endpoint.to_string(),
301
+ "has-session".to_string(),
302
+ "-t".to_string(),
303
+ session.to_string(),
304
+ ]),
305
+ "session gate must probe the persisted explicit endpoint"
306
+ );
307
+ let events = read_event_log_dir(&dir);
308
+ assert!(
309
+ events
310
+ .iter()
311
+ .all(|event| event.get("event").and_then(|v| v.as_str())
312
+ != Some("coordinator.session_missing")),
313
+ "present explicit endpoint session must not emit coordinator.session_missing; got {events:?}"
314
+ );
315
+ }
316
+
317
+ #[test]
318
+ fn e27_coordinator_tick_still_stops_when_explicit_endpoint_session_is_missing() {
319
+ let session = "team-e27-explicit-restart";
320
+ let endpoint = "/tmp/e27-explicit-restart-test.sock";
321
+ let (coord, dir, seen, endpoint_used, endpoint_source) = coord_over_runtime_state_tmux_endpoint(
322
+ session,
323
+ endpoint,
324
+ vec![RunnerStep::Exit(false)],
325
+ RunnerStep::Exit(false),
326
+ );
327
+ assert_eq!(endpoint_used.as_deref(), Some(endpoint));
328
+ assert_eq!(endpoint_source, "state.tmux_endpoint");
329
+
330
+ let report = coord
331
+ .tick()
332
+ .expect("definitive missing session is a typed stop report");
333
+ assert!(!report.ok, "missing explicit endpoint session => ok=false");
334
+ assert!(
335
+ report.stop,
336
+ "genuine missing session on the selected explicit endpoint must still stop"
337
+ );
338
+ assert_eq!(report.reason, Some(TickStopReason::TmuxSessionMissing));
339
+ let calls = seen.lock().unwrap().clone();
340
+ assert_eq!(
341
+ calls.first(),
342
+ Some(&vec![
343
+ "tmux".to_string(),
344
+ "-S".to_string(),
345
+ endpoint.to_string(),
346
+ "has-session".to_string(),
347
+ "-t".to_string(),
348
+ session.to_string(),
349
+ ]),
350
+ "negative control must also probe the persisted explicit endpoint"
351
+ );
352
+ let events = read_event_log_dir(&dir);
353
+ assert!(
354
+ events
355
+ .iter()
356
+ .any(|event| event.get("event").and_then(|v| v.as_str())
357
+ == Some("coordinator.session_missing")),
358
+ "genuine explicit endpoint miss must still emit coordinator.session_missing; got {events:?}"
359
+ );
360
+ }
361
+
212
362
  // ── 2(a) tick TOLERATES a has-session TIMEOUT as Err (NOT a definitive miss) — LOCK ───────────────
213
363
  #[test]
214
364
  fn tick_tolerates_has_session_timeout_as_transport_err_not_session_missing() {
@@ -144,6 +144,7 @@ fn attach_leader_targets(workspace: &Path, state: &Value) -> Vec<AttachLeaderTar
144
144
  let mut targets = Vec::new();
145
145
  for endpoint in state_recorded_tmux_endpoints(state) {
146
146
  let backend = tmux_backend_for_endpoint(&endpoint);
147
+ let resolved_endpoint = backend.tmux_endpoint();
147
148
  targets.extend(
148
149
  backend
149
150
  .list_targets()
@@ -151,7 +152,7 @@ fn attach_leader_targets(workspace: &Path, state: &Value) -> Vec<AttachLeaderTar
151
152
  .into_iter()
152
153
  .map(|info| AttachLeaderTarget {
153
154
  info,
154
- endpoint: Some(endpoint.clone()),
155
+ endpoint: resolved_endpoint.clone(),
155
156
  }),
156
157
  );
157
158
  }
@@ -168,10 +169,8 @@ fn attach_leader_targets(workspace: &Path, state: &Value) -> Vec<AttachLeaderTar
168
169
  fn tmux_backend_for_endpoint(endpoint: &str) -> crate::tmux_backend::TmuxBackend {
169
170
  if endpoint.is_empty() || endpoint == "default" {
170
171
  crate::tmux_backend::TmuxBackend::new()
171
- } else if Path::new(endpoint).is_absolute() {
172
- crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint)
173
172
  } else {
174
- crate::tmux_backend::TmuxBackend::for_socket_name(endpoint)
173
+ crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint)
175
174
  }
176
175
  }
177
176
 
@@ -196,6 +196,9 @@ pub fn execute_leader_plan(
196
196
  let detached = plan.mode == LeaderStartMode::NewTmuxSession
197
197
  && !std::io::stdin().is_terminal()
198
198
  && insert_detach_flag(&mut argv);
199
+ if plan.is_external_leader {
200
+ persist_external_leader_topology_marker(plan, workspace)?;
201
+ }
199
202
  let status = run_leader_argv(&argv, &plan.leader_env, plan, workspace)?;
200
203
  let code = status.code();
201
204
  if !status.success() {
@@ -205,9 +208,6 @@ pub fn execute_leader_plan(
205
208
  .unwrap_or_else(|| "signal".to_string())
206
209
  )));
207
210
  }
208
- if plan.is_external_leader {
209
- persist_external_leader_topology_marker(plan, workspace)?;
210
- }
211
211
  if detached {
212
212
  Ok(LeaderLaunchOutcome {
213
213
  status: LeaderLaunchStatus::Detached,
@@ -258,7 +258,7 @@ fn start_argv(
258
258
  match mode {
259
259
  LeaderStartMode::ExecProvider => {
260
260
  let mut argv = vec![provider_cmd];
261
- argv.extend(provider_args.iter().cloned());
261
+ argv.extend(normalized_provider_args(provider_args));
262
262
  Ok(argv)
263
263
  }
264
264
  LeaderStartMode::ManagedTmuxClient => {
@@ -289,7 +289,7 @@ fn start_argv(
289
289
  exports.push(shlex_quote(&format!("PATH={path}")));
290
290
  }
291
291
  let mut provider_argv = vec![provider_cmd];
292
- provider_argv.extend(provider_args.iter().cloned());
292
+ provider_argv.extend(normalized_provider_args(provider_args));
293
293
  let shell = format!(
294
294
  "cd {} && export {} && exec {}",
295
295
  shlex_quote(&resolved_workspace.to_string_lossy()),
@@ -316,10 +316,17 @@ fn start_argv(
316
316
 
317
317
  fn provider_command_argv(provider: Provider, provider_args: &[String]) -> Vec<String> {
318
318
  let mut argv = vec![provider_command_name(provider).to_string()];
319
- argv.extend(provider_args.iter().cloned());
319
+ argv.extend(normalized_provider_args(provider_args));
320
320
  argv
321
321
  }
322
322
 
323
+ fn normalized_provider_args(provider_args: &[String]) -> impl Iterator<Item = String> + '_ {
324
+ provider_args
325
+ .iter()
326
+ .skip(usize::from(provider_args.first().is_some_and(|arg| arg == "--")))
327
+ .cloned()
328
+ }
329
+
323
330
  fn managed_team_session_name(identity: &LeaderIdentity) -> SessionName {
324
331
  let team = identity.team_id.as_str();
325
332
  if team.starts_with("team-") {
@@ -777,7 +784,12 @@ mod tests {
777
784
  use std::path::Path;
778
785
  use std::sync::Mutex;
779
786
 
787
+ use crate::leader::{
788
+ LeaderIdentity, LeaderLaunchSocket, LeaderSessionUuidSource, LeaderStartMode,
789
+ LeaderStartPlan,
790
+ };
780
791
  use crate::model::enums::PaneLiveness;
792
+ use crate::model::ids::{LeaderSessionUuid, TeamKey};
781
793
  use crate::provider::{Provider, COPILOT_READY_MARKER, COPILOT_TRUST_PROMPT_MARKER};
782
794
  use crate::transport::{
783
795
  AttachOutcome, BackendKind, CaptureRange, CapturedText, InjectPayload, InjectReport,
@@ -786,7 +798,7 @@ mod tests {
786
798
  TurnVerification, WindowName,
787
799
  };
788
800
 
789
- use super::handle_exec_provider_startup_prompts;
801
+ use super::{execute_leader_plan, handle_exec_provider_startup_prompts, shlex_quote};
790
802
 
791
803
  struct ScriptedTransport {
792
804
  screens: Mutex<Vec<String>>,
@@ -937,6 +949,57 @@ mod tests {
937
949
  }
938
950
  }
939
951
 
952
+ #[test]
953
+ fn external_exec_provider_persists_topology_before_provider_exec() {
954
+ let workspace = std::env::temp_dir().join(format!(
955
+ "ta-external-pre-exec-{}",
956
+ std::process::id()
957
+ ));
958
+ std::fs::create_dir_all(&workspace).unwrap();
959
+ let state_path = crate::state::persist::runtime_state_path(&workspace);
960
+ let command = format!(
961
+ "test -f {path} && grep -q is_external_leader {path}",
962
+ path = shlex_quote(&state_path.to_string_lossy())
963
+ );
964
+ let identity = LeaderIdentity {
965
+ leader_session_uuid: LeaderSessionUuid::derive(
966
+ "fp",
967
+ &workspace.to_string_lossy(),
968
+ "tester",
969
+ "current",
970
+ )
971
+ .unwrap(),
972
+ leader_session_uuid_source: LeaderSessionUuidSource::Derived,
973
+ machine_fingerprint: "fp".to_string(),
974
+ workspace_abspath: workspace.clone(),
975
+ os_user: "tester".to_string(),
976
+ team_id: TeamKey::new("current"),
977
+ };
978
+ let plan = LeaderStartPlan {
979
+ mode: LeaderStartMode::ExecProvider,
980
+ provider: Provider::Codex,
981
+ workspace: workspace.clone(),
982
+ socket: LeaderLaunchSocket::Workspace,
983
+ session_name: None,
984
+ argv: vec!["sh".to_string(), "-c".to_string(), command],
985
+ provider_argv: vec!["codex".to_string()],
986
+ leader_window: None,
987
+ is_external_leader: true,
988
+ leader_env: BTreeMap::new(),
989
+ identity: Some(identity),
990
+ detached: false,
991
+ };
992
+
993
+ let outcome = execute_leader_plan(&plan, &workspace)
994
+ .expect("external marker must be present before provider argv runs");
995
+
996
+ assert_eq!(outcome.status, crate::leader::LeaderLaunchStatus::Exited);
997
+ let state = crate::state::persist::load_runtime_state(&workspace).unwrap();
998
+ assert_eq!(state["is_external_leader"], serde_json::json!(true));
999
+ assert_eq!(state["teams"]["current"]["is_external_leader"], serde_json::json!(true));
1000
+ let _ = std::fs::remove_dir_all(&workspace);
1001
+ }
1002
+
940
1003
  #[test]
941
1004
  fn exec_provider_leader_startup_prompt_handler_reuses_copilot_adapter() {
942
1005
  let transport = ScriptedTransport::new(vec![
@@ -108,12 +108,20 @@ use super::*;
108
108
  let ws = std::env::temp_dir().join(format!("ta_rs_lsp_external_{}", std::process::id()));
109
109
  std::fs::create_dir_all(&ws).unwrap();
110
110
 
111
- let plan = leader_start_plan(Provider::Fake, &[], &ws, false, false, None, true).unwrap();
111
+ let provider_args = vec!["--".to_string(), "--model".to_string(), "opus".to_string()];
112
+ let plan = leader_start_plan(Provider::Fake, &provider_args, &ws, false, false, None, true).unwrap();
112
113
 
113
114
  assert_eq!(plan.mode, LeaderStartMode::ExecProvider);
114
115
  assert!(plan.is_external_leader);
115
116
  assert_eq!(plan.leader_window, None);
116
- assert_eq!(plan.argv, vec!["fake".to_string()]);
117
+ assert_eq!(
118
+ plan.argv,
119
+ vec!["fake".to_string(), "--model".to_string(), "opus".to_string()]
120
+ );
121
+ assert_eq!(
122
+ plan.provider_argv,
123
+ vec!["fake".to_string(), "--model".to_string(), "opus".to_string()]
124
+ );
117
125
  }
118
126
 
119
127
  #[test]
@@ -17,6 +17,8 @@ pub fn start_agent(
17
17
  team: Option<&str>,
18
18
  ) -> Result<StartAgentOutcome, LifecycleError> {
19
19
  let paths = lifecycle_paths(workspace, team)?;
20
+ let transport =
21
+ lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
20
22
  start_agent_at_paths(
21
23
  &paths.run_workspace,
22
24
  &paths.spec_workspace,
@@ -25,7 +27,7 @@ pub fn start_agent(
25
27
  open_display,
26
28
  allow_fresh,
27
29
  team,
28
- &crate::tmux_backend::TmuxBackend::for_workspace(&paths.run_workspace),
30
+ &transport,
29
31
  )
30
32
  }
31
33
 
@@ -267,11 +269,15 @@ pub fn stop_agent(
267
269
  agent_id: &AgentId,
268
270
  team: Option<&str>,
269
271
  ) -> Result<StopAgentReport, LifecycleError> {
270
- stop_agent_with_transport(
271
- workspace,
272
+ let paths = lifecycle_paths(workspace, team)?;
273
+ let transport =
274
+ lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
275
+ stop_agent_at_paths(
276
+ &paths.run_workspace,
277
+ &paths.spec_workspace,
272
278
  agent_id,
273
279
  team,
274
- &crate::tmux_backend::TmuxBackend::for_workspace(&lifecycle_run_workspace(workspace)?),
280
+ &transport,
275
281
  )
276
282
  }
277
283
 
@@ -473,13 +479,22 @@ pub fn reset_agent(
473
479
  open_display: bool,
474
480
  team: Option<&str>,
475
481
  ) -> Result<ResetAgentOutcome, LifecycleError> {
476
- reset_agent_with_transport(
477
- workspace,
482
+ if !discard_session {
483
+ return Ok(ResetAgentOutcome::Refused {
484
+ reason: ResetRefusal::DiscardSessionRequired,
485
+ });
486
+ }
487
+ let paths = lifecycle_paths(workspace, team)?;
488
+ let transport =
489
+ lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
490
+ reset_agent_at_paths(
491
+ &paths.run_workspace,
492
+ &paths.spec_workspace,
478
493
  agent_id,
479
494
  discard_session,
480
495
  open_display,
481
496
  team,
482
- &crate::tmux_backend::TmuxBackend::for_workspace(&lifecycle_run_workspace(workspace)?),
497
+ &transport,
483
498
  )
484
499
  }
485
500
 
@@ -208,6 +208,38 @@ pub(super) fn start_coordinator_for_workspace(workspace: &Path) -> Result<bool,
208
208
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))
209
209
  }
210
210
 
211
+ pub(super) fn lifecycle_worker_tmux_backend_for_selected_state(
212
+ run_workspace: &Path,
213
+ team: Option<&str>,
214
+ ) -> Result<crate::tmux_backend::TmuxBackend, LifecycleError> {
215
+ let (state, refusal) = crate::state::projection::resolve_team_scoped_state(run_workspace, team)
216
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
217
+ if let Some(refusal) = refusal {
218
+ let reason = refusal
219
+ .get("reason")
220
+ .and_then(|v| v.as_str())
221
+ .unwrap_or("team_target_unresolved");
222
+ let detail = refusal
223
+ .get("error")
224
+ .or_else(|| refusal.get("message"))
225
+ .and_then(|v| v.as_str())
226
+ .unwrap_or("");
227
+ return Err(LifecycleError::TeamSelect(format!("{reason}: {detail}")));
228
+ }
229
+ Ok(state
230
+ .as_ref()
231
+ .map(|state| lifecycle_worker_tmux_backend_for_state(run_workspace, state))
232
+ .unwrap_or_else(|| crate::tmux_backend::TmuxBackend::for_workspace(run_workspace)))
233
+ }
234
+
235
+ pub(super) fn lifecycle_worker_tmux_backend_for_state(
236
+ run_workspace: &Path,
237
+ state: &serde_json::Value,
238
+ ) -> crate::tmux_backend::TmuxBackend {
239
+ crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(run_workspace, Some(state))
240
+ .backend
241
+ }
242
+
211
243
  pub(super) fn persist_effective_approval_policy_for_restart(
212
244
  agent: &mut serde_json::Map<String, serde_json::Value>,
213
245
  safety: &DangerousApproval,
@@ -22,12 +22,14 @@ pub fn restart_with_session_convergence_deadline(
22
22
  team: Option<&str>,
23
23
  session_converge_deadline_ms: Option<u64>,
24
24
  ) -> Result<RestartReport, LifecycleError> {
25
- let run_ws = lifecycle_run_workspace(workspace)?;
25
+ let paths = lifecycle_paths(workspace, team)?;
26
+ let transport =
27
+ lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
26
28
  restart_with_transport_with_session_convergence_deadline(
27
29
  workspace,
28
30
  allow_fresh,
29
31
  team,
30
- &crate::tmux_backend::TmuxBackend::for_workspace(&run_ws),
32
+ &transport,
31
33
  session_converge_deadline_ms,
32
34
  None,
33
35
  )
@@ -13,13 +13,17 @@ pub fn remove_agent(
13
13
  force: bool,
14
14
  team: Option<&str>,
15
15
  ) -> Result<RemoveAgentOutcome, LifecycleError> {
16
- remove_agent_with_transport(
17
- workspace,
16
+ let paths = lifecycle_paths(workspace, team)?;
17
+ let transport =
18
+ lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
19
+ remove_agent_at_paths(
20
+ &paths.run_workspace,
21
+ &paths.spec_workspace,
18
22
  agent_id,
19
23
  from_spec,
20
24
  force,
21
25
  team,
22
- &crate::tmux_backend::TmuxBackend::for_workspace(&lifecycle_run_workspace(workspace)?),
26
+ &transport,
23
27
  )
24
28
  }
25
29
 
@@ -260,6 +260,228 @@ fn bug_a_stop_agent_team_dir_input_kills_existing_window_real_machine() {
260
260
  assert!(report.stopped, "existing worker window must be killed, not silently reported absent");
261
261
  }
262
262
 
263
+ struct EnvVarGuard {
264
+ key: &'static str,
265
+ previous: Option<String>,
266
+ }
267
+
268
+ impl EnvVarGuard {
269
+ fn set(key: &'static str, value: &str) -> Self {
270
+ let previous = std::env::var(key).ok();
271
+ unsafe {
272
+ std::env::set_var(key, value);
273
+ }
274
+ Self { key, previous }
275
+ }
276
+ }
277
+
278
+ impl Drop for EnvVarGuard {
279
+ fn drop(&mut self) {
280
+ unsafe {
281
+ if let Some(value) = self.previous.take() {
282
+ std::env::set_var(self.key, value);
283
+ } else {
284
+ std::env::remove_var(self.key);
285
+ }
286
+ }
287
+ }
288
+ }
289
+
290
+ struct TmuxShim {
291
+ log: std::path::PathBuf,
292
+ _path: EnvVarGuard,
293
+ _log: EnvVarGuard,
294
+ _endpoint: EnvVarGuard,
295
+ _session: EnvVarGuard,
296
+ _pane: EnvVarGuard,
297
+ _real_tmux: EnvVarGuard,
298
+ }
299
+
300
+ fn install_e27_tmux_shim(expected_endpoint: &str, session_name: &str) -> TmuxShim {
301
+ use std::os::unix::fs::PermissionsExt;
302
+
303
+ let root = temp_ws().join("e27_tmux_shim");
304
+ let bin = root.join("bin");
305
+ std::fs::create_dir_all(&bin).unwrap();
306
+ let log = root.join("tmux-argv.log");
307
+ let tmux = bin.join("tmux");
308
+ let real_tmux = std::process::Command::new("sh")
309
+ .arg("-lc")
310
+ .arg("command -v tmux || true")
311
+ .output()
312
+ .ok()
313
+ .and_then(|out| String::from_utf8(out.stdout).ok())
314
+ .map(|s| s.trim().to_string())
315
+ .filter(|s| !s.is_empty())
316
+ .unwrap_or_default();
317
+ std::fs::write(
318
+ &tmux,
319
+ r#"#!/bin/sh
320
+ set -eu
321
+ log="${TEAM_AGENT_E27_TMUX_LOG}"
322
+ expected="${TEAM_AGENT_E27_EXPECTED_ENDPOINT}"
323
+ session="${TEAM_AGENT_E27_SESSION_NAME}"
324
+ pane="${TEAM_AGENT_E27_PANE_ID}"
325
+ track=0
326
+ case "$*" in
327
+ *"$expected"*|*"$session"*|*"$pane"*) track=1 ;;
328
+ esac
329
+ if [ "$track" != 1 ]; then
330
+ if [ -n "${TEAM_AGENT_E27_REAL_TMUX}" ]; then
331
+ exec "${TEAM_AGENT_E27_REAL_TMUX}" "$@"
332
+ fi
333
+ exit 127
334
+ fi
335
+ printf '%s\n' "$*" >> "$log"
336
+ case "$*" in
337
+ *"-S $expected"*) ;;
338
+ *)
339
+ echo "missing expected socket $expected: $*" >&2
340
+ exit 18
341
+ ;;
342
+ esac
343
+ case "$*" in
344
+ *"display-message -p -t $pane #{pane_id}"*)
345
+ printf '%s\n' "$pane"
346
+ exit 0
347
+ ;;
348
+ *"display-message -p -t $session:alpha #{pane_id}"*)
349
+ printf '%%9288\n'
350
+ exit 0
351
+ ;;
352
+ *"list-windows -t $session -F #{window_name}"*)
353
+ exit 0
354
+ ;;
355
+ *"has-session -t $session"*)
356
+ exit 0
357
+ ;;
358
+ *)
359
+ exit 0
360
+ ;;
361
+ esac
362
+ "#,
363
+ )
364
+ .unwrap();
365
+ let mut perms = std::fs::metadata(&tmux).unwrap().permissions();
366
+ perms.set_mode(0o755);
367
+ std::fs::set_permissions(&tmux, perms).unwrap();
368
+ let old_path = std::env::var("PATH").unwrap_or_default();
369
+ TmuxShim {
370
+ log: log.clone(),
371
+ _path: EnvVarGuard::set("PATH", &format!("{}:{old_path}", bin.display())),
372
+ _log: EnvVarGuard::set("TEAM_AGENT_E27_TMUX_LOG", &log.to_string_lossy()),
373
+ _endpoint: EnvVarGuard::set("TEAM_AGENT_E27_EXPECTED_ENDPOINT", expected_endpoint),
374
+ _session: EnvVarGuard::set("TEAM_AGENT_E27_SESSION_NAME", session_name),
375
+ _pane: EnvVarGuard::set("TEAM_AGENT_E27_PANE_ID", "%9277"),
376
+ _real_tmux: EnvVarGuard::set("TEAM_AGENT_E27_REAL_TMUX", &real_tmux),
377
+ }
378
+ }
379
+
380
+ fn seed_e27_attached_socket_state(ws: &std::path::Path, endpoint: &str, session_name: &str) {
381
+ let mut state = crate::state::persist::load_runtime_state(ws).unwrap();
382
+ let obj = state.as_object_mut().unwrap();
383
+ obj.insert("session_name".to_string(), json!(session_name));
384
+ obj.insert("tmux_endpoint".to_string(), json!(endpoint));
385
+ obj.insert("tmux_socket".to_string(), json!(endpoint));
386
+ let alpha = obj
387
+ .get_mut("agents")
388
+ .and_then(serde_json::Value::as_object_mut)
389
+ .and_then(|agents| agents.get_mut("alpha"))
390
+ .and_then(serde_json::Value::as_object_mut)
391
+ .unwrap();
392
+ alpha.insert("status".to_string(), json!("running"));
393
+ alpha.insert("provider".to_string(), json!("fake"));
394
+ alpha.insert("window".to_string(), json!("alpha"));
395
+ alpha.insert("pane_id".to_string(), json!("%9277"));
396
+ alpha.insert("session_id".to_string(), json!("old-session"));
397
+ crate::state::persist::save_runtime_state(ws, &state).unwrap();
398
+ seed_healthy_coordinator(ws);
399
+ }
400
+
401
+ fn assert_only_expected_socket_used(log: &std::path::Path, expected_endpoint: &str) {
402
+ let raw = std::fs::read_to_string(log).unwrap();
403
+ assert!(!raw.is_empty(), "tmux shim was not invoked");
404
+ assert!(
405
+ !raw.contains("-L ta-"),
406
+ "lifecycle worker operation must not use workspace-derived -L ta-* socket; argv log:\n{raw}"
407
+ );
408
+ for line in raw.lines() {
409
+ assert!(
410
+ line.contains(&format!("-S {expected_endpoint}")),
411
+ "tmux argv must use state endpoint {expected_endpoint}; got line {line:?}; full log:\n{raw}"
412
+ );
413
+ }
414
+ }
415
+
416
+ #[test]
417
+ #[serial_test::serial(env)]
418
+ fn e27_stop_agent_uses_attached_explicit_state_socket() {
419
+ let endpoint = "/tmp/loop13-e27-explicit.sock";
420
+ let session_name = "team-e27-explicit-stop";
421
+ let ws = lanea_team_ws("running");
422
+ seed_e27_attached_socket_state(&ws, endpoint, session_name);
423
+ let shim = install_e27_tmux_shim(endpoint, session_name);
424
+
425
+ let report = stop_agent(&ws, &aid("alpha"), None).expect("stop-agent");
426
+
427
+ assert!(report.stopped, "attached explicit-socket worker should be stopped");
428
+ assert_only_expected_socket_used(&shim.log, endpoint);
429
+ let state = crate::state::persist::load_runtime_state(&ws).unwrap();
430
+ assert_eq!(
431
+ state.pointer("/agents/alpha/status").and_then(serde_json::Value::as_str),
432
+ Some("stopped")
433
+ );
434
+ }
435
+
436
+ #[test]
437
+ #[serial_test::serial(env)]
438
+ fn e27_reset_agent_uses_attached_explicit_state_socket_for_stop_and_spawn() {
439
+ let endpoint = "/tmp/loop13-e27-explicit.sock";
440
+ let session_name = "team-e27-explicit-reset";
441
+ let ws = lanea_team_ws("running");
442
+ seed_e27_attached_socket_state(&ws, endpoint, session_name);
443
+ let shim = install_e27_tmux_shim(endpoint, session_name);
444
+
445
+ let outcome = reset_agent(&ws, &aid("alpha"), true, false, None).expect("reset-agent");
446
+
447
+ assert!(
448
+ matches!(outcome, ResetAgentOutcome::Reset { .. }),
449
+ "reset-agent should complete over attached explicit socket; got {outcome:?}"
450
+ );
451
+ assert_only_expected_socket_used(&shim.log, endpoint);
452
+ let raw = std::fs::read_to_string(&shim.log).unwrap();
453
+ assert!(raw.contains("kill-pane"), "reset must stop the old pane first; argv log:\n{raw}");
454
+ assert!(
455
+ raw.contains("new-window") || raw.contains("new-session"),
456
+ "reset must respawn the worker through the same endpoint; argv log:\n{raw}"
457
+ );
458
+ let state = crate::state::persist::load_runtime_state(&ws).unwrap();
459
+ assert_ne!(
460
+ state.pointer("/agents/alpha/session_id").and_then(serde_json::Value::as_str),
461
+ Some("old-session"),
462
+ "discard-session must not preserve the old session id"
463
+ );
464
+ }
465
+
466
+ #[test]
467
+ #[serial_test::serial(env)]
468
+ fn e27_stop_agent_expands_short_state_socket_name() {
469
+ let short = "ta-loop13-e27-short";
470
+ let session_name = "team-e27-short-stop";
471
+ let expected = crate::tmux_backend::socket_path_for_name(short)
472
+ .unwrap()
473
+ .to_string_lossy()
474
+ .to_string();
475
+ let ws = lanea_team_ws("running");
476
+ seed_e27_attached_socket_state(&ws, short, session_name);
477
+ let shim = install_e27_tmux_shim(&expected, session_name);
478
+
479
+ let report = stop_agent(&ws, &aid("alpha"), None).expect("stop-agent short endpoint");
480
+
481
+ assert!(report.stopped, "short endpoint worker should be stopped");
482
+ assert_only_expected_socket_used(&shim.log, &expected);
483
+ }
484
+
263
485
  // ═════════════════════════════════════════════════════════════════════════
264
486
  // WAVE-2 · LANE A v2 — DEEPENED byte-parity REDs (stop / reset / remove / fork).
265
487
  //
@@ -8,6 +8,7 @@
8
8
  #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
9
9
 
10
10
  use std::collections::{BTreeMap, VecDeque};
11
+ use std::os::unix::net::UnixListener;
11
12
  use std::path::Path;
12
13
  use std::sync::{Arc, Mutex};
13
14
 
@@ -251,8 +252,20 @@
251
252
  }
252
253
 
253
254
  #[test]
255
+ #[serial_test::serial(env)]
254
256
  fn leader_receiver_short_endpoint_must_not_reconstruct_tmux_l_socket() {
255
257
  let endpoint = "dl9aa40c88";
258
+ let uid = unsafe { libc::geteuid() };
259
+ let tmp = std::env::temp_dir().join(format!(
260
+ "ta-tmux-short-endpoint-{}",
261
+ std::process::id()
262
+ ));
263
+ let root = tmp.join(format!("tmux-{uid}"));
264
+ std::fs::create_dir_all(&root).unwrap();
265
+ let socket_path = root.join(endpoint);
266
+ let _listener = UnixListener::bind(&socket_path).unwrap();
267
+ let socket_path = socket_path.canonicalize().unwrap();
268
+ let _env = EnvGuard::apply(&[("TMPDIR", Some(tmp.to_str().unwrap()))]);
256
269
  let (be, rec) = {
257
270
  let recorded = Arc::new(Mutex::new(Vec::new()));
258
271
  let runner = MockCommandRunner {
@@ -275,6 +288,14 @@
275
288
  "non-canonical leader endpoints must be rejected or left unbound, never reconstructed as \
276
289
  tmux -L <short> under the coordinator socket root; calls={calls:?}"
277
290
  );
291
+ assert!(
292
+ calls.iter().all(|call| call.starts_with(&[
293
+ "tmux".to_string(),
294
+ "-S".to_string(),
295
+ socket_path.to_string_lossy().to_string()
296
+ ])),
297
+ "short leader endpoints must resolve to the existing physical socket path with -S; calls={calls:?}"
298
+ );
278
299
  }
279
300
 
280
301
  // ── 1. has_session: exit 0 -> true, exit 1 -> false; argv = `tmux has-session -t <s>` ──────────
@@ -188,6 +188,29 @@ impl TmuxSocketEndpoint {
188
188
  }
189
189
  }
190
190
 
191
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
192
+ pub(crate) enum RuntimeTmuxEndpointSource {
193
+ StateTmuxEndpoint,
194
+ StateTmuxSocket,
195
+ WorkspaceFallback,
196
+ }
197
+
198
+ impl RuntimeTmuxEndpointSource {
199
+ pub(crate) fn as_str(self) -> &'static str {
200
+ match self {
201
+ Self::StateTmuxEndpoint => "state.tmux_endpoint",
202
+ Self::StateTmuxSocket => "state.tmux_socket",
203
+ Self::WorkspaceFallback => "workspace_fallback",
204
+ }
205
+ }
206
+ }
207
+
208
+ pub(crate) struct RuntimeTmuxBackendSelection {
209
+ pub(crate) backend: TmuxBackend,
210
+ pub(crate) tmux_endpoint_used: Option<String>,
211
+ pub(crate) tmux_endpoint_source: RuntimeTmuxEndpointSource,
212
+ }
213
+
191
214
  impl TmuxBackend {
192
215
  /// Backend bound to the real `tmux` subprocess on the SHARED default socket (no `-L`).
193
216
  /// Non-team callers + existing argv/unit tests stay unaffected.
@@ -233,6 +256,12 @@ impl TmuxBackend {
233
256
  socket: Some(TmuxSocketEndpoint::Path(endpoint.to_string())),
234
257
  event_workspace: None,
235
258
  }
259
+ } else if let Some(path) = socket_path_for_name(endpoint) {
260
+ Self {
261
+ runner: Box::new(RealCommandRunner),
262
+ socket: Some(TmuxSocketEndpoint::Path(path.to_string_lossy().into_owned())),
263
+ event_workspace: None,
264
+ }
236
265
  } else {
237
266
  Self::new()
238
267
  }
@@ -275,6 +304,12 @@ impl TmuxBackend {
275
304
  socket: None,
276
305
  event_workspace: None,
277
306
  }
307
+ } else if let Some(path) = socket_path_for_name(endpoint) {
308
+ Self {
309
+ runner,
310
+ socket: Some(TmuxSocketEndpoint::Path(path.to_string_lossy().into_owned())),
311
+ event_workspace: None,
312
+ }
278
313
  } else {
279
314
  Self {
280
315
  runner,
@@ -326,6 +361,70 @@ impl TmuxBackend {
326
361
  }
327
362
  }
328
363
 
364
+ pub(crate) fn tmux_backend_for_runtime_state_or_workspace(
365
+ workspace: &Path,
366
+ state: Option<&serde_json::Value>,
367
+ ) -> RuntimeTmuxBackendSelection {
368
+ let (backend, source) =
369
+ if let Some((endpoint, source)) = runtime_tmux_endpoint_from_state(state) {
370
+ (TmuxBackend::for_tmux_endpoint(endpoint), source)
371
+ } else {
372
+ (
373
+ TmuxBackend::for_workspace(workspace),
374
+ RuntimeTmuxEndpointSource::WorkspaceFallback,
375
+ )
376
+ };
377
+ RuntimeTmuxBackendSelection {
378
+ tmux_endpoint_used: backend.tmux_endpoint(),
379
+ backend,
380
+ tmux_endpoint_source: source,
381
+ }
382
+ }
383
+
384
+ #[cfg(test)]
385
+ pub(crate) fn tmux_backend_with_runner_for_runtime_state_or_workspace(
386
+ runner: Box<dyn CommandRunner>,
387
+ workspace: &Path,
388
+ state: Option<&serde_json::Value>,
389
+ ) -> RuntimeTmuxBackendSelection {
390
+ let (backend, source) =
391
+ if let Some((endpoint, source)) = runtime_tmux_endpoint_from_state(state) {
392
+ (
393
+ TmuxBackend::with_runner_for_tmux_endpoint(runner, endpoint),
394
+ source,
395
+ )
396
+ } else {
397
+ (
398
+ TmuxBackend::with_runner_for_workspace(runner, workspace),
399
+ RuntimeTmuxEndpointSource::WorkspaceFallback,
400
+ )
401
+ };
402
+ RuntimeTmuxBackendSelection {
403
+ tmux_endpoint_used: backend.tmux_endpoint(),
404
+ backend,
405
+ tmux_endpoint_source: source,
406
+ }
407
+ }
408
+
409
+ fn runtime_tmux_endpoint_from_state(
410
+ state: Option<&serde_json::Value>,
411
+ ) -> Option<(&str, RuntimeTmuxEndpointSource)> {
412
+ state.and_then(|state| {
413
+ state
414
+ .get("tmux_endpoint")
415
+ .and_then(|v| v.as_str())
416
+ .filter(|endpoint| !endpoint.is_empty())
417
+ .map(|endpoint| (endpoint, RuntimeTmuxEndpointSource::StateTmuxEndpoint))
418
+ .or_else(|| {
419
+ state
420
+ .get("tmux_socket")
421
+ .and_then(|v| v.as_str())
422
+ .filter(|endpoint| !endpoint.is_empty())
423
+ .map(|endpoint| (endpoint, RuntimeTmuxEndpointSource::StateTmuxSocket))
424
+ })
425
+ })
426
+ }
427
+
329
428
  /// CP-1 socket name: SHORT + DETERMINISTIC per canonical workspace path. `ta-` + 12 hex chars of a
330
429
  /// stable FNV-1a hash over the canonicalized path. AF_UNIX `sun_path` is ~104 chars and the socket
331
430
  /// lives at `/tmp/tmux-<uid>/<name>`, so we must NOT use the (~88-char) session name. §10: a
@@ -340,13 +439,7 @@ pub(crate) fn socket_name_for_workspace(workspace: &Path) -> String {
340
439
  }
341
440
 
342
441
  pub(crate) fn socket_path_for_workspace(workspace: &Path) -> Option<PathBuf> {
343
- if let Some(existing) = existing_socket_path_for_workspace(workspace) {
344
- return Some(existing);
345
- }
346
- let uid = unsafe { libc::geteuid() };
347
- let default_root = PathBuf::from(format!("/tmp/tmux-{uid}"));
348
- let default_root = default_root.canonicalize().unwrap_or(default_root);
349
- Some(default_root.join(socket_name_for_workspace(workspace)))
442
+ socket_path_for_name(&socket_name_for_workspace(workspace))
350
443
  }
351
444
 
352
445
  pub(crate) fn socket_probe_missing_for_workspace(workspace: &Path) -> bool {
@@ -354,11 +447,27 @@ pub(crate) fn socket_probe_missing_for_workspace(workspace: &Path) -> bool {
354
447
  }
355
448
 
356
449
  fn existing_socket_path_for_workspace(workspace: &Path) -> Option<PathBuf> {
357
- let socket_name = socket_name_for_workspace(workspace);
450
+ existing_socket_path_for_name(&socket_name_for_workspace(workspace))
451
+ }
452
+
453
+ pub(crate) fn socket_path_for_name(socket_name: &str) -> Option<PathBuf> {
454
+ if socket_name.is_empty() || socket_name == "default" || Path::new(socket_name).is_absolute() {
455
+ return None;
456
+ }
457
+ if let Some(existing) = existing_socket_path_for_name(socket_name) {
458
+ return Some(existing);
459
+ }
460
+ let uid = unsafe { libc::geteuid() };
461
+ let default_root = PathBuf::from(format!("/tmp/tmux-{uid}"));
462
+ let default_root = default_root.canonicalize().unwrap_or(default_root);
463
+ Some(default_root.join(socket_name))
464
+ }
465
+
466
+ fn existing_socket_path_for_name(socket_name: &str) -> Option<PathBuf> {
358
467
  let roots = tmux_socket_roots();
359
468
  for root in &roots {
360
469
  let root = root.canonicalize().unwrap_or_else(|_| root.clone());
361
- let candidate = root.join(&socket_name);
470
+ let candidate = root.join(socket_name);
362
471
  if candidate.exists() {
363
472
  return Some(candidate.canonicalize().unwrap_or(candidate));
364
473
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.3.18",
3
+ "version": "0.3.20",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.3.18",
24
- "@team-agent/cli-darwin-x64": "0.3.18",
25
- "@team-agent/cli-linux-x64": "0.3.18"
23
+ "@team-agent/cli-darwin-arm64": "0.3.20",
24
+ "@team-agent/cli-darwin-x64": "0.3.20",
25
+ "@team-agent/cli-linux-x64": "0.3.20"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",