@team-agent/installer 0.3.17 → 0.3.19

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.17"
569
+ version = "0.3.19"
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.17"
12
+ version = "0.3.19"
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()
@@ -363,10 +363,7 @@ pub mod lifecycle_port {
363
363
  }
364
364
 
365
365
  fn state_uses_external_leader(state: &Value) -> bool {
366
- state
367
- .get("is_external_leader")
368
- .and_then(Value::as_bool)
369
- .unwrap_or(true)
366
+ crate::state::projection::state_is_external_leader(state)
370
367
  }
371
368
 
372
369
  fn managed_leader_socket_cleanup(
@@ -1722,9 +1719,31 @@ pub mod lifecycle_port {
1722
1719
  open_display,
1723
1720
  team,
1724
1721
  ) {
1725
- Ok(report) => {
1726
- Ok(json!({"ok": true, "agent_id": agent, "report": format!("{report:?}")}))
1727
- }
1722
+ Ok(crate::lifecycle::ResetAgentOutcome::Reset {
1723
+ env,
1724
+ start_mode,
1725
+ discarded_session_id,
1726
+ session_id,
1727
+ new_session_id,
1728
+ }) => Ok(json!({
1729
+ "ok": true,
1730
+ "agent_id": env.agent_id.as_str(),
1731
+ "status": "reset",
1732
+ "state_file": env.state_file.to_string_lossy().to_string(),
1733
+ "coordinator_started": env.coordinator_started,
1734
+ "start_mode": start_mode,
1735
+ "discarded_session_id": discarded_session_id.as_ref().map(|id| id.as_str()),
1736
+ "session_id": session_id.as_ref().map(|id| id.as_str()),
1737
+ "new_session_id": new_session_id.as_ref().map(|id| id.as_str()),
1738
+ })),
1739
+ Ok(crate::lifecycle::ResetAgentOutcome::Refused { reason }) => Ok(json!({
1740
+ "ok": false,
1741
+ "agent_id": agent,
1742
+ "status": "refused",
1743
+ "reason": match reason {
1744
+ crate::lifecycle::ResetRefusal::DiscardSessionRequired => "discard_session_required",
1745
+ },
1746
+ })),
1728
1747
  Err(e) => Ok(error_value(e)),
1729
1748
  }
1730
1749
  }
@@ -48,10 +48,7 @@ use rusqlite::params;
48
48
  .cloned()
49
49
  .unwrap_or_else(|| json!({}));
50
50
  let session_name = state.get("session_name").cloned().unwrap_or(Value::Null);
51
- let is_external_leader = state
52
- .get("is_external_leader")
53
- .and_then(Value::as_bool)
54
- .unwrap_or(true);
51
+ let is_external_leader = crate::state::projection::state_is_external_leader(state);
55
52
  let leader_topology = if is_external_leader { "external" } else { "managed" };
56
53
  let leader_attach_command = if is_external_leader {
57
54
  None
@@ -573,8 +570,8 @@ use rusqlite::params;
573
570
  json!({
574
571
  "team": full.get("team").cloned().unwrap_or(Value::Null),
575
572
  "session_name": full.get("session_name").cloned().unwrap_or(Value::Null),
576
- "leader_topology": full.get("leader_topology").cloned().unwrap_or_else(|| json!("external")),
577
- "is_external_leader": full.get("is_external_leader").cloned().unwrap_or(Value::Bool(true)),
573
+ "leader_topology": full.get("leader_topology").cloned().unwrap_or_else(|| json!("managed")),
574
+ "is_external_leader": full.get("is_external_leader").cloned().unwrap_or(Value::Bool(false)),
578
575
  "leader_attach_command": full.get("leader_attach_command").cloned().unwrap_or(Value::Null),
579
576
  "leader_client": full.get("leader_client").cloned().unwrap_or(Value::Null),
580
577
  "tmux_session_present": full.get("tmux_session_present").cloned().unwrap_or(Value::Bool(false)),
@@ -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
 
@@ -309,6 +309,7 @@ fn lsof_cwd_timeout_is_diagnostic_not_shutdown_partial() {
309
309
  &ws,
310
310
  &json!({
311
311
  "session_name": "team-lsof-cwd-timeout",
312
+ "is_external_leader": true,
312
313
  "agents": {
313
314
  "fake_impl": {
314
315
  "status": "running",
@@ -467,6 +468,7 @@ fn leader_env_tmux_socket_never_kills_server_even_when_sessions_look_exclusive()
467
468
  crate::state::persist::save_runtime_state(
468
469
  &ws,
469
470
  &json!({
471
+ "is_external_leader": true,
470
472
  "tmux_socket_source": "leader_env",
471
473
  "agents": {
472
474
  "fake_impl": {
@@ -543,3 +545,42 @@ fn managed_leader_shutdown_never_kills_server_even_when_socket_looks_exclusive()
543
545
  "managed topology must clear the team session/window without kill-server"
544
546
  );
545
547
  }
548
+
549
+ #[test]
550
+ fn shutdown_missing_topology_marker_defaults_to_managed_cleanup() {
551
+ let ws = tmp_shutdown_workspace("missing-topology-marker-managed-cleanup");
552
+ crate::state::persist::save_runtime_state(
553
+ &ws,
554
+ &json!({
555
+ "session_name": "team-current",
556
+ "agents": {}
557
+ }),
558
+ )
559
+ .unwrap();
560
+ let transport = CleanShutdownTransport::new()
561
+ .with_targets(vec![PaneInfo {
562
+ pane_id: PaneId::new("%1"),
563
+ session: SessionName::new("team-current"),
564
+ window_index: Some(0),
565
+ window_name: Some(WindowName::new("leader")),
566
+ pane_index: Some(0),
567
+ tty: None,
568
+ current_command: Some("codex".to_string()),
569
+ current_path: None,
570
+ active: true,
571
+ pane_pid: None,
572
+ leader_env: BTreeMap::new(),
573
+ }])
574
+ .with_targets_persist_after_kill();
575
+
576
+ let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
577
+ .expect("shutdown should complete");
578
+
579
+ assert_eq!(out["ok"], json!(true));
580
+ assert_eq!(out["killed_sessions"], json!(["team-current"]));
581
+ assert_eq!(out["spared_sessions"], json!([]));
582
+ assert!(
583
+ !transport.kill_server_called(),
584
+ "missing topology marker must default to managed cleanup, not external kill-server"
585
+ );
586
+ }
@@ -86,6 +86,27 @@ use super::*;
86
86
  let _ = std::fs::remove_dir_all(&ws);
87
87
  }
88
88
 
89
+ #[test]
90
+ fn status_port_missing_topology_marker_defaults_to_managed() {
91
+ let ws = seed_status_workspace();
92
+ let mut state = crate::state::persist::load_runtime_state(&ws).unwrap();
93
+ if let Some(obj) = state.as_object_mut() {
94
+ obj.remove("is_external_leader");
95
+ obj.insert("session_name".to_string(), json!("team-current"));
96
+ }
97
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
98
+
99
+ let v = status_port::status(&ws, /*compact=*/ true, /*detail=*/ false).expect("status");
100
+
101
+ assert_eq!(v["leader_topology"], json!("managed"));
102
+ assert_eq!(v["is_external_leader"], json!(false));
103
+ let attach = v["leader_attach_command"]
104
+ .as_str()
105
+ .expect("missing marker defaults to managed attach command");
106
+ assert!(attach.contains("attach -t team-current:leader"), "{attach}");
107
+ let _ = std::fs::remove_dir_all(&ws);
108
+ }
109
+
89
110
  #[test]
90
111
  fn status_port_status_detail_full_keeps_uncompacted_events() {
91
112
  // cmd_status --json --detail -> status_port::status(compact=false): the FULL dict
@@ -394,6 +415,48 @@ fn run_send_unknown_task_renders_error_not_silent_swallow() {
394
415
  let _ = std::fs::remove_dir_all(&ws);
395
416
  }
396
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
+
397
460
  // R8 D6 (c-lite offline byte-lock): the CLI requeued_exhausted_watchers return projection, extracted
398
461
  // into a pure helper, must project the golden event's watcher_ids STRING list (leader/__init__.py:56) —
399
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
  }
@@ -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]
@@ -856,6 +856,8 @@ fn save_launched_team_state_for_key(
856
856
  "active_team_key".to_string(),
857
857
  serde_json::Value::String(launched_key.clone()),
858
858
  );
859
+ obj.entry("is_external_leader".to_string())
860
+ .or_insert(serde_json::Value::Bool(false));
859
861
  }
860
862
  promote_launched_binding_from_team_entry(&mut launched, &launched_key);
861
863
  preserve_existing_leader_topology(&existing, &launched_key, &mut launched);
@@ -3021,10 +3023,7 @@ fn attach_window_names_with_managed_leader(
3021
3023
  }
3022
3024
 
3023
3025
  fn state_uses_managed_leader(state: &serde_json::Value) -> bool {
3024
- state
3025
- .get("is_external_leader")
3026
- .and_then(serde_json::Value::as_bool)
3027
- .is_some_and(|external| !external)
3026
+ crate::state::projection::state_is_managed_leader(state)
3028
3027
  }
3029
3028
 
3030
3029
  /// BUG-7 helper: derive a [`QuickStartReadiness`] verdict from the just-written
@@ -3087,7 +3086,7 @@ fn quick_start_session_capture_incomplete_agents(workspace: &Path, team_key: &st
3087
3086
  crate::session_capture::incomplete_interacted_resumable_agent_ids(team_state)
3088
3087
  }
3089
3088
 
3090
- fn launched_team_receiver_is_attached(workspace: &Path, team_key: &str) -> bool {
3089
+ pub(crate) fn launched_team_receiver_is_attached(workspace: &Path, team_key: &str) -> bool {
3091
3090
  let Ok(state) = load_runtime_state(workspace) else {
3092
3091
  return true;
3093
3092
  };
@@ -3097,7 +3096,7 @@ fn launched_team_receiver_is_attached(workspace: &Path, team_key: &str) -> bool
3097
3096
  .and_then(|teams| teams.get(team_key))
3098
3097
  .unwrap_or(&state);
3099
3098
  if team_state.get("leader_receiver").is_none() {
3100
- return true;
3099
+ return crate::state::projection::state_is_external_leader(team_state);
3101
3100
  }
3102
3101
  if team_uses_fake_model_harness(team_state) {
3103
3102
  return true;
@@ -4366,6 +4365,7 @@ fn initial_runtime_state(
4366
4365
  "display_backend".to_string(),
4367
4366
  serde_json::json!(display_backend),
4368
4367
  );
4368
+ state.insert("is_external_leader".to_string(), serde_json::json!(false));
4369
4369
  let mut state = serde_json::Value::Object(state);
4370
4370
  if !seed_launched_owner_from_env(&mut state) {
4371
4371
  let team_id = crate::state::projection::team_state_key(&state);
@@ -206,6 +206,7 @@ pub(crate) fn start_agent_at_paths(
206
206
  start_mode,
207
207
  target: spawn.spawn.pane_id.as_str().to_string(),
208
208
  session_id,
209
+ new_session_id: spawn.plan.expected_session_id.clone(),
209
210
  rollout_path,
210
211
  })
211
212
  }
@@ -528,8 +529,8 @@ fn reset_agent_at_paths(
528
529
  .and_then(|v| v.get(agent_id.as_str()))
529
530
  .and_then(|v| v.get("session_id"))
530
531
  .and_then(|v| v.as_str())
531
- .unwrap_or("")
532
- .to_string();
532
+ .filter(|session| !session.is_empty())
533
+ .map(SessionId::new);
533
534
  crate::lifecycle::launch::ensure_owner_allowed_for_state(&state_before_stop, Some(agent_id))?;
534
535
  let stop = stop_agent_at_paths(workspace, spec_workspace, agent_id, team, transport)?;
535
536
  let mut state = resolve_team_scoped_state_or_refuse(workspace, team)?;
@@ -537,11 +538,22 @@ fn reset_agent_at_paths(
537
538
  discard_agent_session_fields(&mut state, agent_id)?;
538
539
  // golden operations.py (reset): save_team_scoped_state on the team projection — same multi-team
539
540
  // preservation as stop, not a raw save_runtime_state.
540
- crate::state::projection::save_team_scoped_state(workspace, &state)
541
+ crate::state::projection::save_team_scoped_state_with_tombstoned_agents(
542
+ workspace,
543
+ &state,
544
+ &[agent_id.as_str()],
545
+ )
541
546
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
542
547
  // golden operations.py:125: write_team_state after the discard-save (the intermediate stopped snapshot).
543
548
  write_team_state(spec_workspace, &spec, &state)?;
544
- write_reset_tombstone_event(workspace, agent_id, &discarded_session_id)?;
549
+ write_reset_tombstone_event(
550
+ workspace,
551
+ agent_id,
552
+ discarded_session_id
553
+ .as_ref()
554
+ .map(SessionId::as_str)
555
+ .unwrap_or(""),
556
+ )?;
545
557
  let start = start_agent_at_paths(
546
558
  workspace,
547
559
  spec_workspace,
@@ -556,11 +568,34 @@ fn reset_agent_at_paths(
556
568
  write_reset_complete_event(workspace, agent_id, stop.stopped, started)?;
557
569
  match start {
558
570
  StartAgentOutcome::Running {
559
- env, start_mode, ..
560
- } => Ok(ResetAgentOutcome::Reset { env, start_mode }),
571
+ env,
572
+ start_mode,
573
+ session_id,
574
+ new_session_id,
575
+ ..
576
+ } => {
577
+ let output_session_id = if matches!(
578
+ start_mode,
579
+ StartMode::Fresh | StartMode::FreshAfterMissingRollout
580
+ ) {
581
+ new_session_id.clone().or(session_id)
582
+ } else {
583
+ session_id
584
+ };
585
+ Ok(ResetAgentOutcome::Reset {
586
+ env,
587
+ start_mode,
588
+ discarded_session_id,
589
+ session_id: output_session_id,
590
+ new_session_id,
591
+ })
592
+ }
561
593
  StartAgentOutcome::Noop { env, .. } => Ok(ResetAgentOutcome::Reset {
562
594
  env,
563
595
  start_mode: StartMode::Noop,
596
+ discarded_session_id,
597
+ session_id: None,
598
+ new_session_id: None,
564
599
  }),
565
600
  StartAgentOutcome::Paused { .. } => Ok(ResetAgentOutcome::Reset {
566
601
  env: AgentActionEnvelope {
@@ -569,6 +604,9 @@ fn reset_agent_at_paths(
569
604
  coordinator_started: false,
570
605
  },
571
606
  start_mode: StartMode::Noop,
607
+ discarded_session_id,
608
+ session_id: None,
609
+ new_session_id: None,
572
610
  }),
573
611
  }
574
612
  }
@@ -1200,9 +1200,15 @@ fn quick_start_persists_selected_tmux_endpoint_and_attach_commands() {
1200
1200
  .any(|cmd| cmd.contains(&format!("tmux -S {endpoint} attach -t "))),
1201
1201
  "attach commands must use the selected socket endpoint: {attach_commands:?}"
1202
1202
  );
1203
+ assert!(
1204
+ attach_commands.iter().any(|cmd| cmd.contains(":leader")),
1205
+ "fresh managed quick-start must include the leader window attach command: {attach_commands:?}"
1206
+ );
1203
1207
  let (_raw, state) = raw_runtime_state(workspace);
1204
1208
  assert_eq!(state["tmux_endpoint"], json!(endpoint));
1205
1209
  assert_eq!(state["tmux_socket"], json!(endpoint));
1210
+ assert_eq!(state["is_external_leader"], json!(false));
1211
+ assert_eq!(state["teams"]["teamdir"]["is_external_leader"], json!(false));
1206
1212
  }
1207
1213
 
1208
1214
  #[test]
@@ -1249,6 +1255,33 @@ fn quick_start_preserves_managed_leader_topology_and_emits_leader_attach_command
1249
1255
  assert_eq!(state["leader_client"]["diagnostic_only"], json!(true));
1250
1256
  }
1251
1257
 
1258
+ #[test]
1259
+ fn fresh_managed_state_without_receiver_is_not_attached() {
1260
+ let workspace = temp_ws();
1261
+ crate::state::persist::save_runtime_state(
1262
+ &workspace,
1263
+ &json!({
1264
+ "active_team_key": "teamdir",
1265
+ "session_name": "team-teamdir",
1266
+ "is_external_leader": false,
1267
+ "agents": {"implementer": {"status": "running"}},
1268
+ "teams": {
1269
+ "teamdir": {
1270
+ "session_name": "team-teamdir",
1271
+ "is_external_leader": false,
1272
+ "agents": {"implementer": {"status": "running"}}
1273
+ }
1274
+ }
1275
+ }),
1276
+ )
1277
+ .unwrap();
1278
+
1279
+ assert!(
1280
+ !crate::lifecycle::launch::launched_team_receiver_is_attached(&workspace, "teamdir"),
1281
+ "managed topology with missing leader_receiver must not be reported attached"
1282
+ );
1283
+ }
1284
+
1252
1285
  #[test]
1253
1286
  fn attach_window_names_for_state_agents_include_managed_leader_and_layout_windows() {
1254
1287
  let state = json!({
@@ -2225,9 +2258,11 @@ fn quick_start_seeds_tasks_key_from_compiled_spec() {
2225
2258
  // cross-team reads cannot accidentally pick up another team's binding from the root.
2226
2259
  // The post-launch hook drops the top-level owner triple when the launched pane is
2227
2260
  // unbound (empty pane_id), and only the per-team entry retains the binding.
2228
- // Order remains the golden prefix (spec_path display_backend) + new suffix
2229
- // (active_team_key, teams). display_backend stays the resolved backend from
2230
- // display/backend.py:12-29 (default adaptive), not the raw optional spec field.
2261
+ // R1: topology is explicit; fresh managed teams carry `is_external_leader=false`
2262
+ // before the team-in-team suffix. Order remains the golden prefix
2263
+ // (spec_path … display_backend) + topology marker + new suffix (active_team_key,
2264
+ // teams). display_backend stays the resolved backend from display/backend.py:12-29
2265
+ // (default adaptive), not the raw optional spec field.
2231
2266
  #[test]
2232
2267
  #[serial(env)]
2233
2268
  fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
@@ -2264,11 +2299,13 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
2264
2299
  "agents",
2265
2300
  "tasks",
2266
2301
  "display_backend",
2302
+ "is_external_leader",
2267
2303
  "active_team_key",
2268
2304
  "teams",
2269
2305
  ],
2270
2306
  "state.json top-level key order must match golden launch/core.py:62-71 \
2271
- plus Bug 1/2 team-in-team suffix (active_team_key, teams). \
2307
+ plus R1 topology marker and Bug 1/2 team-in-team suffix \
2308
+ (is_external_leader, active_team_key, teams). \
2272
2309
  Bug 2 owner team-scope (N1/N12/N18/N29) deliberately keeps owner / \
2273
2310
  leader_receiver / owner_epoch OFF the top level — they live ONLY under \
2274
2311
  teams[<active_team_key>] so per-team isolation has a single source of \
@@ -2308,6 +2345,7 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
2308
2345
  json!("adaptive"),
2309
2346
  "adaptive layout directive: resolve_display_backend(None, source='launch') defaults to adaptive"
2310
2347
  );
2348
+ assert_eq!(state["is_external_leader"], json!(false));
2311
2349
  }
2312
2350
 
2313
2351
  // Stage B1 — golden launch/core.py:238-255 writes the running agent state only after
@@ -535,6 +535,7 @@ pub enum StartAgentOutcome {
535
535
  start_mode: StartMode,
536
536
  target: String,
537
537
  session_id: Option<SessionId>,
538
+ new_session_id: Option<SessionId>,
538
539
  rollout_path: Option<RolloutPath>,
539
540
  },
540
541
  /// 窗口已存在且非 force → noop(`start.py:134`)。
@@ -565,6 +566,9 @@ pub enum ResetAgentOutcome {
565
566
  Reset {
566
567
  env: AgentActionEnvelope,
567
568
  start_mode: StartMode,
569
+ discarded_session_id: Option<SessionId>,
570
+ session_id: Option<SessionId>,
571
+ new_session_id: Option<SessionId>,
568
572
  },
569
573
  /// 未传 discard_session → 拒绝(不丢上下文的误用保护)。
570
574
  Refused { reason: ResetRefusal },
@@ -60,7 +60,13 @@ pub(crate) fn reset_agent(
60
60
  )
61
61
  .map_err(tool_runtime_error)?
62
62
  {
63
- ResetAgentOutcome::Reset { env, start_mode } => Ok(ToolOk {
63
+ ResetAgentOutcome::Reset {
64
+ env,
65
+ start_mode,
66
+ discarded_session_id,
67
+ session_id,
68
+ new_session_id,
69
+ } => Ok(ToolOk {
64
70
  fields: object_fields(serde_json::json!({
65
71
  "ok": true,
66
72
  "agent_id": env.agent_id.as_str(),
@@ -68,6 +74,9 @@ pub(crate) fn reset_agent(
68
74
  "state_file": env.state_file.to_string_lossy().to_string(),
69
75
  "coordinator_started": env.coordinator_started,
70
76
  "start_mode": enum_value(start_mode),
77
+ "discarded_session_id": discarded_session_id.as_ref().map(|id| id.as_str()),
78
+ "session_id": session_id.as_ref().map(|id| id.as_str()),
79
+ "new_session_id": new_session_id.as_ref().map(|id| id.as_str()),
71
80
  })),
72
81
  }),
73
82
  ResetAgentOutcome::Refused { reason } => Ok(ToolOk {
@@ -170,13 +170,38 @@ impl Drop for RuntimeLock {
170
170
  /// `save_runtime_state`(bug-084)。`state` 是 state.json 的内存 Value(插入序保留)。
171
171
  /// 注:Python 在此还调 `_migrate_state_identity`(identity slice 落地后接入;本 slice 不改 state 内容)。
172
172
  pub fn save_runtime_state(workspace: &Path, state: &Value) -> Result<(), StateError> {
173
- save_runtime_state_with_deleted_agents(workspace, state, &[])
173
+ save_runtime_state_with_merge_exceptions(workspace, state, &[], None, &[])
174
174
  }
175
175
 
176
176
  pub(crate) fn save_runtime_state_with_deleted_agents(
177
177
  workspace: &Path,
178
178
  state: &Value,
179
179
  deleted_agent_ids: &[&str],
180
+ ) -> Result<(), StateError> {
181
+ save_runtime_state_with_merge_exceptions(workspace, state, deleted_agent_ids, None, &[])
182
+ }
183
+
184
+ pub(crate) fn save_runtime_state_with_team_tombstoned_agents(
185
+ workspace: &Path,
186
+ state: &Value,
187
+ tombstoned_team_key: &str,
188
+ tombstoned_agent_ids: &[&str],
189
+ ) -> Result<(), StateError> {
190
+ save_runtime_state_with_merge_exceptions(
191
+ workspace,
192
+ state,
193
+ &[],
194
+ Some(tombstoned_team_key),
195
+ tombstoned_agent_ids,
196
+ )
197
+ }
198
+
199
+ fn save_runtime_state_with_merge_exceptions(
200
+ workspace: &Path,
201
+ state: &Value,
202
+ deleted_agent_ids: &[&str],
203
+ skip_capture_backfill_team_key: Option<&str>,
204
+ skip_capture_backfill_agent_ids: &[&str],
180
205
  ) -> Result<(), StateError> {
181
206
  let path = runtime_state_path(workspace);
182
207
  if cache_equals(&path, state) {
@@ -218,7 +243,19 @@ pub(crate) fn save_runtime_state_with_deleted_agents(
218
243
  .filter(|id| !id.is_empty())
219
244
  .map(str::to_string)
220
245
  .collect::<BTreeSet<_>>();
221
- preserve_latest_roster_entries(&mut migrated, &latest, &deleted);
246
+ let skip_capture_backfill = skip_capture_backfill_agent_ids
247
+ .iter()
248
+ .copied()
249
+ .filter(|id| !id.is_empty())
250
+ .map(str::to_string)
251
+ .collect::<BTreeSet<_>>();
252
+ preserve_latest_roster_entries(
253
+ &mut migrated,
254
+ &latest,
255
+ &deleted,
256
+ skip_capture_backfill_team_key,
257
+ &skip_capture_backfill,
258
+ );
222
259
  }
223
260
  // 字节对拍 Python json.dumps(indent=2, ensure_ascii=False)(无尾换行)。
224
261
  let payload = serde_json::to_string_pretty(&migrated)?;
@@ -269,19 +306,34 @@ fn read_latest_state_under_lock(workspace: &Path, path: &Path) -> Option<Value>
269
306
  Some(latest)
270
307
  }
271
308
 
272
- fn preserve_latest_roster_entries(incoming: &mut Value, latest: &Value, deleted_agent_ids: &BTreeSet<String>) {
309
+ fn preserve_latest_roster_entries(
310
+ incoming: &mut Value,
311
+ latest: &Value,
312
+ deleted_agent_ids: &BTreeSet<String>,
313
+ skip_capture_backfill_team_key: Option<&str>,
314
+ skip_capture_backfill_agent_ids: &BTreeSet<String>,
315
+ ) {
273
316
  // A0/R1: the projection gate only guards the TOP-LEVEL passes (top-level agents and
274
317
  // the top-level<->active-team cross projections depend on which team is active); the
275
318
  // per-team `teams.<k>.agents` merge below is team-key self-identifying and must run
276
319
  // even when another process flipped session_name/active_team_key between this
277
320
  // writer's load and save.
278
321
  let projection_matches = same_runtime_projection(incoming, latest);
322
+ let active_team = active_team_key(incoming).or_else(|| active_team_key(latest));
323
+ let top_level_team = Some(team_state_key(incoming)).or_else(|| Some(team_state_key(latest)));
324
+ let skip_top_level_capture_backfill =
325
+ should_skip_capture_backfill(top_level_team.as_deref(), skip_capture_backfill_team_key);
279
326
  if projection_matches {
280
- preserve_missing_agents(incoming.get_mut("agents"), latest.get("agents"), deleted_agent_ids);
327
+ preserve_missing_agents(
328
+ incoming.get_mut("agents"),
329
+ latest.get("agents"),
330
+ deleted_agent_ids,
331
+ skip_top_level_capture_backfill,
332
+ skip_capture_backfill_agent_ids,
333
+ );
281
334
  preserve_latest_ownership_fields(incoming, latest);
282
335
  }
283
336
 
284
- let active_team = active_team_key(incoming).or_else(|| active_team_key(latest));
285
337
  if projection_matches {
286
338
  if let Some(active_team) = active_team.as_deref() {
287
339
  let latest_active_agents = latest
@@ -289,7 +341,13 @@ fn preserve_latest_roster_entries(incoming: &mut Value, latest: &Value, deleted_
289
341
  .and_then(Value::as_object)
290
342
  .and_then(|teams| teams.get(active_team))
291
343
  .and_then(|entry| entry.get("agents"));
292
- preserve_missing_agents(incoming.get_mut("agents"), latest_active_agents, deleted_agent_ids);
344
+ preserve_missing_agents(
345
+ incoming.get_mut("agents"),
346
+ latest_active_agents,
347
+ deleted_agent_ids,
348
+ skip_top_level_capture_backfill,
349
+ skip_capture_backfill_agent_ids,
350
+ );
293
351
  }
294
352
  }
295
353
 
@@ -306,6 +364,8 @@ fn preserve_latest_roster_entries(incoming: &mut Value, latest: &Value, deleted_
306
364
  incoming_entry.get_mut("agents"),
307
365
  latest_entry.get("agents"),
308
366
  deleted_agent_ids,
367
+ should_skip_capture_backfill(Some(team), skip_capture_backfill_team_key),
368
+ skip_capture_backfill_agent_ids,
309
369
  );
310
370
  preserve_latest_ownership_fields(incoming_entry, latest_entry);
311
371
  }
@@ -314,13 +374,26 @@ fn preserve_latest_roster_entries(incoming: &mut Value, latest: &Value, deleted_
314
374
  if let Some(active_team) = active_team.as_deref() {
315
375
  let latest_top_agents = latest.get("agents");
316
376
  if let Some(incoming_entry) = incoming_teams.get_mut(active_team) {
317
- preserve_missing_agents(incoming_entry.get_mut("agents"), latest_top_agents, deleted_agent_ids);
377
+ preserve_missing_agents(
378
+ incoming_entry.get_mut("agents"),
379
+ latest_top_agents,
380
+ deleted_agent_ids,
381
+ should_skip_capture_backfill(Some(active_team), skip_capture_backfill_team_key),
382
+ skip_capture_backfill_agent_ids,
383
+ );
318
384
  preserve_latest_ownership_fields(incoming_entry, latest);
319
385
  }
320
386
  }
321
387
  }
322
388
  }
323
389
 
390
+ fn should_skip_capture_backfill(current_team_key: Option<&str>, skip_team_key: Option<&str>) -> bool {
391
+ match skip_team_key {
392
+ Some(skip_team_key) => current_team_key == Some(skip_team_key),
393
+ None => true,
394
+ }
395
+ }
396
+
324
397
  fn preserve_latest_ownership_fields(incoming: &mut Value, latest: &Value) {
325
398
  if !latest_has_preferable_ownership(incoming, latest) {
326
399
  return;
@@ -379,6 +452,8 @@ fn preserve_missing_agents(
379
452
  incoming_agents: Option<&mut Value>,
380
453
  latest_agents: Option<&Value>,
381
454
  deleted_agent_ids: &BTreeSet<String>,
455
+ skip_capture_backfill: bool,
456
+ skip_capture_backfill_agent_ids: &BTreeSet<String>,
382
457
  ) {
383
458
  let Some(incoming_agents) = incoming_agents else {
384
459
  return;
@@ -398,7 +473,9 @@ fn preserve_missing_agents(
398
473
  slot.insert(latest_agent.clone());
399
474
  }
400
475
  serde_json::map::Entry::Occupied(mut existing) => {
401
- backfill_capture_fields(existing.get_mut(), latest_agent);
476
+ if !skip_capture_backfill || !skip_capture_backfill_agent_ids.contains(agent_id) {
477
+ backfill_capture_fields(existing.get_mut(), latest_agent);
478
+ }
402
479
  }
403
480
  }
404
481
  }
@@ -14,7 +14,7 @@ use std::path::Path;
14
14
  use serde_json::{json, Map, Value};
15
15
 
16
16
  use super::StateError;
17
- use crate::state::persist::{load_runtime_state, save_runtime_state_with_deleted_agents};
17
+ use crate::state::persist::{load_runtime_state, save_runtime_state_with_deleted_agents, save_runtime_state_with_team_tombstoned_agents};
18
18
 
19
19
  /// `team_state_key`(`state.py:93`):从 team_dir(.name)/spec_path(.parent.name)派生 team key,
20
20
  /// 跳过 `.team`/`runtime`;兜底 `session_name` 或 `"current"`。
@@ -162,6 +162,17 @@ pub fn compact_team_state(state: &Value) -> Value {
162
162
  }
163
163
  }
164
164
 
165
+ pub fn state_is_external_leader(state: &Value) -> bool {
166
+ state
167
+ .get("is_external_leader")
168
+ .and_then(Value::as_bool)
169
+ .unwrap_or(false)
170
+ }
171
+
172
+ pub fn state_is_managed_leader(state: &Value) -> bool {
173
+ !state_is_external_leader(state)
174
+ }
175
+
165
176
  /// `merge_workspace_team_state`(`state.py:111`):把新启动的 team 并入既有 workspace state。
166
177
  pub fn merge_workspace_team_state(existing: &Value, launched: &Value) -> Value {
167
178
  let launched_key = team_state_key(launched);
@@ -471,6 +482,23 @@ pub(crate) fn save_team_scoped_state_with_deleted_agents(
471
482
  workspace: &Path,
472
483
  team_state: &Value,
473
484
  deleted_agent_ids: &[&str],
485
+ ) -> Result<(), StateError> {
486
+ save_team_scoped_state_with_merge_exceptions(workspace, team_state, deleted_agent_ids, &[])
487
+ }
488
+
489
+ pub(crate) fn save_team_scoped_state_with_tombstoned_agents(
490
+ workspace: &Path,
491
+ team_state: &Value,
492
+ tombstoned_agent_ids: &[&str],
493
+ ) -> Result<(), StateError> {
494
+ save_team_scoped_state_with_merge_exceptions(workspace, team_state, &[], tombstoned_agent_ids)
495
+ }
496
+
497
+ fn save_team_scoped_state_with_merge_exceptions(
498
+ workspace: &Path,
499
+ team_state: &Value,
500
+ deleted_agent_ids: &[&str],
501
+ tombstoned_agent_ids: &[&str],
474
502
  ) -> Result<(), StateError> {
475
503
  let target_key = team_state_key(team_state);
476
504
  let existing = load_runtime_state(workspace)?;
@@ -500,7 +528,15 @@ pub(crate) fn save_team_scoped_state_with_deleted_agents(
500
528
  // not existing_teams and existing_primary_key == target_key → 纯 save(剔 teams)。
501
529
  if existing_teams.is_empty() && existing_primary_key.as_deref() == Some(target_key.as_str()) {
502
530
  let merged = compact_team_state(team_state);
503
- return save_runtime_state_with_deleted_agents(workspace, &merged, deleted_agent_ids);
531
+ if tombstoned_agent_ids.is_empty() {
532
+ return save_runtime_state_with_deleted_agents(workspace, &merged, deleted_agent_ids);
533
+ }
534
+ return save_runtime_state_with_team_tombstoned_agents(
535
+ workspace,
536
+ &merged,
537
+ &target_key,
538
+ tombstoned_agent_ids,
539
+ );
504
540
  }
505
541
  // teams = deepcopy(incoming_teams or existing_teams)
506
542
  let mut teams = match incoming_teams {
@@ -520,7 +556,16 @@ pub(crate) fn save_team_scoped_state_with_deleted_agents(
520
556
  if merged.get("teams").and_then(Value::as_object).is_some_and(Map::is_empty) {
521
557
  merged.remove("teams");
522
558
  }
523
- save_runtime_state_with_deleted_agents(workspace, &Value::Object(merged), deleted_agent_ids)
559
+ if tombstoned_agent_ids.is_empty() {
560
+ save_runtime_state_with_deleted_agents(workspace, &Value::Object(merged), deleted_agent_ids)
561
+ } else {
562
+ save_runtime_state_with_team_tombstoned_agents(
563
+ workspace,
564
+ &Value::Object(merged),
565
+ &target_key,
566
+ tombstoned_agent_ids,
567
+ )
568
+ }
524
569
  }
525
570
 
526
571
  // ---- helpers ----
@@ -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>` ──────────
@@ -233,6 +233,12 @@ impl TmuxBackend {
233
233
  socket: Some(TmuxSocketEndpoint::Path(endpoint.to_string())),
234
234
  event_workspace: None,
235
235
  }
236
+ } else if let Some(path) = socket_path_for_name(endpoint) {
237
+ Self {
238
+ runner: Box::new(RealCommandRunner),
239
+ socket: Some(TmuxSocketEndpoint::Path(path.to_string_lossy().into_owned())),
240
+ event_workspace: None,
241
+ }
236
242
  } else {
237
243
  Self::new()
238
244
  }
@@ -275,6 +281,12 @@ impl TmuxBackend {
275
281
  socket: None,
276
282
  event_workspace: None,
277
283
  }
284
+ } else if let Some(path) = socket_path_for_name(endpoint) {
285
+ Self {
286
+ runner,
287
+ socket: Some(TmuxSocketEndpoint::Path(path.to_string_lossy().into_owned())),
288
+ event_workspace: None,
289
+ }
278
290
  } else {
279
291
  Self {
280
292
  runner,
@@ -340,13 +352,7 @@ pub(crate) fn socket_name_for_workspace(workspace: &Path) -> String {
340
352
  }
341
353
 
342
354
  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)))
355
+ socket_path_for_name(&socket_name_for_workspace(workspace))
350
356
  }
351
357
 
352
358
  pub(crate) fn socket_probe_missing_for_workspace(workspace: &Path) -> bool {
@@ -354,11 +360,27 @@ pub(crate) fn socket_probe_missing_for_workspace(workspace: &Path) -> bool {
354
360
  }
355
361
 
356
362
  fn existing_socket_path_for_workspace(workspace: &Path) -> Option<PathBuf> {
357
- let socket_name = socket_name_for_workspace(workspace);
363
+ existing_socket_path_for_name(&socket_name_for_workspace(workspace))
364
+ }
365
+
366
+ pub(crate) fn socket_path_for_name(socket_name: &str) -> Option<PathBuf> {
367
+ if socket_name.is_empty() || socket_name == "default" || Path::new(socket_name).is_absolute() {
368
+ return None;
369
+ }
370
+ if let Some(existing) = existing_socket_path_for_name(socket_name) {
371
+ return Some(existing);
372
+ }
373
+ let uid = unsafe { libc::geteuid() };
374
+ let default_root = PathBuf::from(format!("/tmp/tmux-{uid}"));
375
+ let default_root = default_root.canonicalize().unwrap_or(default_root);
376
+ Some(default_root.join(socket_name))
377
+ }
378
+
379
+ fn existing_socket_path_for_name(socket_name: &str) -> Option<PathBuf> {
358
380
  let roots = tmux_socket_roots();
359
381
  for root in &roots {
360
382
  let root = root.canonicalize().unwrap_or_else(|_| root.clone());
361
- let candidate = root.join(&socket_name);
383
+ let candidate = root.join(socket_name);
362
384
  if candidate.exists() {
363
385
  return Some(candidate.canonicalize().unwrap_or(candidate));
364
386
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.3.17",
3
+ "version": "0.3.19",
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.17",
24
- "@team-agent/cli-darwin-x64": "0.3.17",
25
- "@team-agent/cli-linux-x64": "0.3.17"
23
+ "@team-agent/cli-darwin-arm64": "0.3.19",
24
+ "@team-agent/cli-darwin-x64": "0.3.19",
25
+ "@team-agent/cli-linux-x64": "0.3.19"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",