@team-agent/installer 0.3.15 → 0.3.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Cargo.lock CHANGED
@@ -566,7 +566,7 @@ dependencies = [
566
566
 
567
567
  [[package]]
568
568
  name = "team-agent"
569
- version = "0.3.15"
569
+ version = "0.3.17"
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.15"
12
+ version = "0.3.17"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -29,6 +29,8 @@ pub fn leader_launcher_args(values: &[String]) -> Result<LeaderLauncherArgs, Cli
29
29
  out.attach_existing = true;
30
30
  } else if token == "--confirm" {
31
31
  out.confirm_attach = true;
32
+ } else if token == "--external-leader" {
33
+ out.external_leader = true;
32
34
  } else if token == "--attach-session" {
33
35
  let Some(value) = values.get(idx + 1) else {
34
36
  return Err(CliError::Runtime(
@@ -148,6 +148,7 @@ pub mod lifecycle_port {
148
148
  attach.attach_existing,
149
149
  attach.confirm_attach,
150
150
  attach_session.as_ref(),
151
+ attach.external_leader,
151
152
  )
152
153
  .map_err(|e| CliError::Runtime(e.to_string()))?;
153
154
  let outcome = crate::leader::start::execute_leader_plan(&plan, cwd)
@@ -157,10 +158,21 @@ pub mod lifecycle_port {
157
158
  crate::leader::LeaderLaunchStatus::Detached => true,
158
159
  crate::leader::LeaderLaunchStatus::NotStarted => false,
159
160
  };
161
+ let leader_attach_command = if plan.is_external_leader {
162
+ None
163
+ } else {
164
+ plan.session_name.as_ref().and_then(|session| {
165
+ crate::tmux_backend::attach_command_for_workspace(cwd, session, "leader")
166
+ })
167
+ };
160
168
  Ok(json!({
161
169
  "ok": ok,
162
170
  "provider": provider,
163
171
  "mode": plan.mode,
172
+ "leader_topology": if plan.is_external_leader { "external" } else { "managed" },
173
+ "is_external_leader": plan.is_external_leader,
174
+ "leader_window": plan.leader_window.as_ref().map(|window| window.as_str().to_string()),
175
+ "leader_attach_command": leader_attach_command,
164
176
  "status": outcome.status,
165
177
  "exit_code": outcome.exit_code,
166
178
  "reason": outcome.reason,
@@ -282,6 +294,9 @@ pub mod lifecycle_port {
282
294
  // no independent ps/tmux re-derivation (N39).
283
295
  let pane_targets = transport.list_targets().unwrap_or_default();
284
296
  let sessions = socket_session_names_from_targets(&pane_targets);
297
+ if !state_uses_external_leader(state) {
298
+ return managed_leader_socket_cleanup(transport, state, &sessions, event_log);
299
+ }
285
300
  let anchor_sessions = anchor_sessions_from_state(state, &pane_targets, event_log);
286
301
  match sessions_to_kill(&sessions, &anchor_sessions) {
287
302
  KillDecision::KillServerExclusive => {
@@ -347,6 +362,66 @@ pub mod lifecycle_port {
347
362
  }
348
363
  }
349
364
 
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)
370
+ }
371
+
372
+ fn managed_leader_socket_cleanup(
373
+ transport: &dyn crate::transport::Transport,
374
+ state: &Value,
375
+ sessions: &[crate::transport::SessionName],
376
+ event_log: &crate::event_log::EventLog,
377
+ ) -> ShutdownSocketCleanup {
378
+ let target = state
379
+ .get("session_name")
380
+ .and_then(Value::as_str)
381
+ .filter(|session| !session.is_empty())
382
+ .map(crate::transport::SessionName::new);
383
+ let mut to_kill = Vec::new();
384
+ if let Some(target) = target {
385
+ if sessions.is_empty()
386
+ || sessions
387
+ .iter()
388
+ .any(|session| session.as_str() == target.as_str())
389
+ {
390
+ to_kill.push(target);
391
+ }
392
+ }
393
+ let spared = sessions
394
+ .iter()
395
+ .filter(|session| {
396
+ !to_kill
397
+ .iter()
398
+ .any(|target| target.as_str() == session.as_str())
399
+ })
400
+ .cloned()
401
+ .collect::<Vec<_>>();
402
+ let _ = event_log.write(
403
+ "shutdown.kill_server_skipped_managed_leader",
404
+ json!({
405
+ "reason": "managed_leader_topology",
406
+ "spared_sessions": spared.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
407
+ "killed_sessions": to_kill.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
408
+ }),
409
+ );
410
+ let mut error = None;
411
+ for session in &to_kill {
412
+ if let Err(err) = transport.kill_session(session) {
413
+ if !tmux_absent_error(&err.to_string()) {
414
+ error.get_or_insert_with(|| err.to_string());
415
+ }
416
+ }
417
+ }
418
+ ShutdownSocketCleanup {
419
+ killed_sessions: to_kill,
420
+ spared_sessions: spared,
421
+ error,
422
+ }
423
+ }
424
+
350
425
  pub fn shutdown_with_transport(
351
426
  workspace: &Path,
352
427
  keep_logs: bool,
@@ -483,8 +558,12 @@ pub mod lifecycle_port {
483
558
  );
484
559
  deadline.check("stop_coordinator")?;
485
560
  let mut coordinator_timeout = false;
561
+ let mut coordinator_post_stop = CoordinatorStopObservation::NotNeeded;
562
+ let mut coordinator_pid_for_report = None;
486
563
  let stopped = if team.is_none() {
487
564
  let wp = crate::coordinator::WorkspacePath::new(run_workspace.clone());
565
+ let coordinator_pid_before_stop = crate::coordinator::coordinator_health(&wp).pid;
566
+ coordinator_pid_for_report = coordinator_pid_before_stop.map(|pid| pid.get());
488
567
  match stop_coordinator_bounded(wp, std::time::Duration::from_millis(900)) {
489
568
  Some(Ok(report)) => Some(report),
490
569
  Some(Err(error)) => {
@@ -493,12 +572,19 @@ pub mod lifecycle_port {
493
572
  }
494
573
  None => {
495
574
  coordinator_timeout = true;
575
+ let wp = crate::coordinator::WorkspacePath::new(run_workspace.clone());
576
+ coordinator_post_stop =
577
+ coordinator_post_stop_observation(&wp, coordinator_pid_before_stop);
496
578
  None
497
579
  }
498
580
  }
499
581
  } else {
500
582
  None
501
583
  };
584
+ if let Some(stopped) = stopped.as_ref().filter(|stopped| !stopped.ok) {
585
+ let wp = crate::coordinator::WorkspacePath::new(run_workspace.clone());
586
+ coordinator_post_stop = coordinator_post_stop_observation(&wp, stopped.pid);
587
+ }
502
588
  let probe_timeout = crate::os_probe::probe_timeout();
503
589
  let probe_timeout_kind = probe_timeout.as_ref().map(|timeout| timeout.probe);
504
590
  // swallow batch 1: a failed ps probe degrades cleanup truthfully — the
@@ -536,32 +622,19 @@ pub mod lifecycle_port {
536
622
  let coordinator_pid = stopped
537
623
  .as_ref()
538
624
  .and_then(|stopped| stopped.pid.map(|p| p.get()));
539
- let coordinator_clean =
540
- !coordinator_timeout && stopped.as_ref().map(|stopped| stopped.ok).unwrap_or(true);
541
- let ok = coordinator_clean
542
- && kill_error.is_none()
543
- && session_residuals.is_empty()
544
- && process_residuals.is_empty()
545
- && !cleanup_truth_degraded
546
- && !coordinator_timeout;
547
- let status = if ok {
548
- "ok"
549
- } else if coordinator_timeout {
550
- "timeout"
551
- } else if cleanup_truth_degraded {
552
- "partial"
553
- } else if kill_error.is_some() {
554
- "failed"
555
- } else {
556
- "partial"
557
- };
558
- let phase = if coordinator_timeout {
559
- Some("stop_coordinator")
560
- } else if cleanup_truth_degraded {
561
- Some("os_probe")
562
- } else {
563
- None
564
- };
625
+ let coordinator_pid = coordinator_pid.or(coordinator_pid_for_report);
626
+ let outcome = classify_shutdown_outcome(ShutdownOutcomeInput {
627
+ kill_error: kill_error.is_some(),
628
+ session_residuals: !session_residuals.is_empty(),
629
+ process_residuals: !process_residuals.is_empty(),
630
+ cleanup_truth_degraded,
631
+ coordinator_timeout,
632
+ coordinator_stop_ok: stopped.as_ref().map(|stopped| stopped.ok),
633
+ coordinator_post_stop,
634
+ });
635
+ let ok = outcome.ok;
636
+ let status = outcome.status;
637
+ let phase = outcome.phase;
565
638
  let probe_timeout_value = probe_timeout.as_ref().map(|timeout| {
566
639
  json!({
567
640
  "probe": timeout.probe,
@@ -618,14 +691,110 @@ pub mod lifecycle_port {
618
691
  /// worker thread — on a timely result it joins immediately; on timeout it gives the
619
692
  /// thread one short grace join window instead of dropping it detached (repeated
620
693
  /// shutdowns no longer accumulate leaked threads racing the same workspace).
694
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
695
+ pub(crate) enum CoordinatorStopObservation {
696
+ NotNeeded,
697
+ Gone,
698
+ Running,
699
+ Unknown,
700
+ }
701
+
702
+ pub(crate) struct ShutdownOutcome {
703
+ pub(crate) ok: bool,
704
+ pub(crate) status: &'static str,
705
+ pub(crate) phase: Option<&'static str>,
706
+ }
707
+
708
+ pub(crate) struct ShutdownOutcomeInput {
709
+ pub(crate) kill_error: bool,
710
+ pub(crate) session_residuals: bool,
711
+ pub(crate) process_residuals: bool,
712
+ pub(crate) cleanup_truth_degraded: bool,
713
+ pub(crate) coordinator_timeout: bool,
714
+ pub(crate) coordinator_stop_ok: Option<bool>,
715
+ pub(crate) coordinator_post_stop: CoordinatorStopObservation,
716
+ }
717
+
718
+ pub(crate) fn classify_shutdown_outcome(input: ShutdownOutcomeInput) -> ShutdownOutcome {
719
+ let coordinator_clean = match input.coordinator_post_stop {
720
+ CoordinatorStopObservation::Gone => true,
721
+ CoordinatorStopObservation::Running | CoordinatorStopObservation::Unknown => false,
722
+ CoordinatorStopObservation::NotNeeded => {
723
+ !input.coordinator_timeout && input.coordinator_stop_ok.unwrap_or(true)
724
+ }
725
+ };
726
+ let ok = coordinator_clean
727
+ && !input.kill_error
728
+ && !input.session_residuals
729
+ && !input.process_residuals
730
+ && !input.cleanup_truth_degraded;
731
+ if ok {
732
+ return ShutdownOutcome {
733
+ ok,
734
+ status: "ok",
735
+ phase: None,
736
+ };
737
+ }
738
+ let (status, phase) = if input.coordinator_timeout && !coordinator_clean {
739
+ ("timeout", Some("stop_coordinator"))
740
+ } else if input.cleanup_truth_degraded {
741
+ ("partial", Some("os_probe"))
742
+ } else if input.kill_error {
743
+ ("failed", None)
744
+ } else {
745
+ ("partial", None)
746
+ };
747
+ ShutdownOutcome { ok, status, phase }
748
+ }
749
+
750
+ fn coordinator_post_stop_observation(
751
+ workspace: &crate::coordinator::WorkspacePath,
752
+ pid: Option<crate::coordinator::Pid>,
753
+ ) -> CoordinatorStopObservation {
754
+ if let Some(pid) = pid {
755
+ match crate::coordinator::pid_is_running(pid) {
756
+ Ok(true) => return CoordinatorStopObservation::Running,
757
+ Ok(false) => return CoordinatorStopObservation::Gone,
758
+ Err(_) => {}
759
+ }
760
+ }
761
+ let health = crate::coordinator::coordinator_health(workspace);
762
+ match health.status {
763
+ crate::coordinator::CoordinatorHealthStatus::Running => {
764
+ CoordinatorStopObservation::Running
765
+ }
766
+ crate::coordinator::CoordinatorHealthStatus::Missing
767
+ | crate::coordinator::CoordinatorHealthStatus::InvalidPid
768
+ | crate::coordinator::CoordinatorHealthStatus::Stale => {
769
+ CoordinatorStopObservation::Gone
770
+ }
771
+ }
772
+ }
773
+
621
774
  fn stop_coordinator_bounded(
622
775
  workspace: crate::coordinator::WorkspacePath,
623
776
  timeout: std::time::Duration,
624
777
  ) -> Option<Result<crate::coordinator::types::StopReport, String>> {
778
+ stop_coordinator_bounded_with(workspace, timeout, |workspace| {
779
+ crate::coordinator::stop_coordinator(workspace).map_err(|error| error.to_string())
780
+ })
781
+ }
782
+
783
+ pub(crate) fn stop_coordinator_bounded_with<F>(
784
+ workspace: crate::coordinator::WorkspacePath,
785
+ timeout: std::time::Duration,
786
+ stop: F,
787
+ ) -> Option<Result<crate::coordinator::types::StopReport, String>>
788
+ where
789
+ F: FnOnce(
790
+ &crate::coordinator::WorkspacePath,
791
+ ) -> Result<crate::coordinator::types::StopReport, String>
792
+ + Send
793
+ + 'static,
794
+ {
625
795
  let (tx, rx) = std::sync::mpsc::channel();
626
796
  let handle = std::thread::spawn(move || {
627
- let result =
628
- crate::coordinator::stop_coordinator(&workspace).map_err(|error| error.to_string());
797
+ let result = stop(&workspace);
629
798
  let _ = tx.send(result);
630
799
  });
631
800
  let outcome = rx.recv_timeout(timeout).ok();
@@ -640,8 +809,7 @@ pub mod lifecycle_port {
640
809
  match rx.recv_timeout(std::time::Duration::from_millis(250)) {
641
810
  Ok(late) => {
642
811
  let _ = handle.join();
643
- let _ = late; // result arrived after the deadline: still a timeout to the caller
644
- None
812
+ Some(late)
645
813
  }
646
814
  Err(_) => {
647
815
  if handle.is_finished() {
@@ -2851,7 +3019,7 @@ pub mod leader_port {
2851
3019
  provider: crate::provider::Provider,
2852
3020
  _confirm: bool,
2853
3021
  ) -> Result<Value, CliError> {
2854
- let result = crate::leader::attach_leader(workspace, pane, provider)
3022
+ let result = crate::leader::attach_leader(workspace, team, pane, provider)
2855
3023
  .map_err(|e| CliError::Runtime(e.to_string()))?;
2856
3024
  let requeued =
2857
3025
  attach_requeued_exhausted_watchers(workspace, result.bound_pane_id.as_ref())?;
@@ -104,6 +104,10 @@ pub fn format_status_summary(data: &Value) -> String {
104
104
  ],
105
105
  "-",
106
106
  );
107
+ let topology = non_empty_str(
108
+ data.get("leader_topology").and_then(Value::as_str),
109
+ "external",
110
+ );
107
111
  let agents = data.get("agents").unwrap_or(&Value::Null);
108
112
  let health = data.get("agent_health").unwrap_or(&Value::Null);
109
113
  let counts = agent_summary_counts(agents, health);
@@ -137,7 +141,7 @@ pub fn format_status_summary(data: &Value) -> String {
137
141
  .map(format_latest_result)
138
142
  .unwrap_or_else(|| "none".to_string());
139
143
  format!(
140
- "coordinator: {coordinator} schema_ok={schema_ok} tmux={tmux}\nreceiver: {pane} cmd={cmd}\n{agent_line}\nqueued: {queued} mailbox messages awaiting delivery\nlatest result: {latest}"
144
+ "coordinator: {coordinator} schema_ok={schema_ok} tmux={tmux}\nreceiver: {pane} cmd={cmd} topology={topology}\n{agent_line}\nqueued: {queued} mailbox messages awaiting delivery\nlatest result: {latest}"
141
145
  )
142
146
  }
143
147
 
@@ -48,6 +48,22 @@ 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);
55
+ let leader_topology = if is_external_leader { "external" } else { "managed" };
56
+ let leader_attach_command = if is_external_leader {
57
+ None
58
+ } else {
59
+ session_name.as_str().and_then(|session| {
60
+ crate::tmux_backend::attach_command_for_workspace(
61
+ workspace,
62
+ &crate::transport::SessionName::new(session.to_string()),
63
+ "leader",
64
+ )
65
+ })
66
+ };
51
67
  let tmux_present = tmux_session_present(workspace, session_name.as_str());
52
68
  let mut readiness_state = state.clone();
53
69
  if let Some(obj) = readiness_state.as_object_mut() {
@@ -58,6 +74,10 @@ use rusqlite::params;
58
74
  "ok": true,
59
75
  "team": state.pointer("/leader/id").cloned().unwrap_or_else(|| json!("leader")),
60
76
  "session_name": state.get("session_name").cloned().unwrap_or(Value::Null),
77
+ "leader_topology": leader_topology,
78
+ "is_external_leader": is_external_leader,
79
+ "leader_attach_command": leader_attach_command,
80
+ "leader_client": state.get("leader_client").cloned().unwrap_or(Value::Null),
61
81
  "tmux_session_present": tmux_present,
62
82
  "all_spawned": readiness.get("all_spawned").cloned().unwrap_or(Value::Bool(false)),
63
83
  "all_attached_receiver": readiness.get("all_attached_receiver").cloned().unwrap_or(Value::Bool(true)),
@@ -553,6 +573,10 @@ use rusqlite::params;
553
573
  json!({
554
574
  "team": full.get("team").cloned().unwrap_or(Value::Null),
555
575
  "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)),
578
+ "leader_attach_command": full.get("leader_attach_command").cloned().unwrap_or(Value::Null),
579
+ "leader_client": full.get("leader_client").cloned().unwrap_or(Value::Null),
556
580
  "tmux_session_present": full.get("tmux_session_present").cloned().unwrap_or(Value::Bool(false)),
557
581
  "all_spawned": full.get("all_spawned").cloned().unwrap_or(Value::Bool(false)),
558
582
  "all_attached_receiver": full.get("all_attached_receiver").cloned().unwrap_or(Value::Bool(true)),
@@ -52,6 +52,7 @@ use super::*;
52
52
  assert!(!got.attach_existing);
53
53
  assert!(!got.confirm_attach);
54
54
  assert_eq!(got.attach_session, None);
55
+ assert!(!got.external_leader);
55
56
  }
56
57
 
57
58
  #[test]
@@ -72,6 +73,33 @@ use super::*;
72
73
  assert!(!got.confirm_attach);
73
74
  }
74
75
 
76
+ #[test]
77
+ fn leader_launcher_args_external_leader_opt_out() {
78
+ let got = leader_launcher_args(&[
79
+ "--external-leader".into(),
80
+ "--".into(),
81
+ "--model".into(),
82
+ "opus".into(),
83
+ ])
84
+ .unwrap();
85
+ assert!(got.external_leader);
86
+ assert!(!got.attach_existing);
87
+ assert_eq!(
88
+ got.provider_args,
89
+ vec!["--".to_string(), "--model".to_string(), "opus".to_string()]
90
+ );
91
+ }
92
+
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()]
100
+ );
101
+ }
102
+
75
103
  #[test]
76
104
  fn leader_launcher_args_attach_session_spaced() {
77
105
  // golden: ["--attach-session","mysess"] -> attach_session="mysess"
@@ -272,7 +300,7 @@ use super::*;
272
300
  fn format_status_summary_full_byte_lock() {
273
301
  // golden:
274
302
  // coordinator: running schema_ok=True tmux=True
275
- // receiver: %3 cmd=codex
303
+ // receiver: %3 cmd=codex topology=external
276
304
  // agents: 2 — running=1 busy=1 idle=0 stopped=0 failed=0 unknown=0
277
305
  // queued: 2 mailbox messages awaiting delivery
278
306
  // latest result: a1 -> did the thing @ -
@@ -287,7 +315,7 @@ use super::*;
287
315
  });
288
316
  let got = format_status_summary(&data);
289
317
  let expected = "coordinator: running schema_ok=true tmux=true\n\
290
- receiver: %3 cmd=codex\n\
318
+ receiver: %3 cmd=codex topology=external\n\
291
319
  agents: 2 — running=1 busy=1 idle=0 stopped=0 failed=0 unknown=0\n\
292
320
  queued: 2 mailbox messages awaiting delivery\n\
293
321
  latest result: a1 -> did the thing @ -";
@@ -299,7 +327,7 @@ latest result: a1 -> did the thing @ -";
299
327
  // golden empty data: stopped/false/false, dashes, 0 counts, none latest.
300
328
  let got = format_status_summary(&json!({}));
301
329
  let expected = "coordinator: stopped schema_ok=false tmux=false\n\
302
- receiver: - cmd=-\n\
330
+ receiver: - cmd=- topology=external\n\
303
331
  agents: 0 — running=0 busy=0 idle=0 stopped=0 failed=0 unknown=0\n\
304
332
  queued: 0 mailbox messages awaiting delivery\n\
305
333
  latest result: none";
@@ -148,35 +148,35 @@ use super::*;
148
148
 
149
149
  #[test]
150
150
  fn red_format_status_summary_empty_pane_id_is_dash() {
151
- // golden: pane_id='' -> 'receiver: - cmd=x'
151
+ // golden: pane_id='' -> 'receiver: - cmd=x topology=external'
152
152
  let line = format_status_summary(&json!({
153
153
  "leader_receiver": {"pane_id": "", "pane_current_command": "x"}
154
154
  }));
155
155
  assert_eq!(
156
156
  line.lines().nth(1).unwrap(),
157
- "receiver: - cmd=x",
157
+ "receiver: - cmd=x topology=external",
158
158
  "empty-string pane_id MUST fall back to '-' (golden `pane_id or '-'`)"
159
159
  );
160
160
  }
161
161
 
162
162
  #[test]
163
163
  fn red_format_status_summary_cmd_falls_back_to_current_command() {
164
- // golden: missing pane_current_command + current_command='claude' -> 'receiver: %3 cmd=claude'
164
+ // golden: missing pane_current_command + current_command='claude' -> 'receiver: %3 cmd=claude topology=external'
165
165
  let line_missing = format_status_summary(&json!({
166
166
  "leader_receiver": {"pane_id": "%3", "current_command": "claude"}
167
167
  }));
168
168
  assert_eq!(
169
169
  line_missing.lines().nth(1).unwrap(),
170
- "receiver: %3 cmd=claude",
170
+ "receiver: %3 cmd=claude topology=external",
171
171
  "cmd MUST fall back to current_command when pane_current_command is absent (golden line 285)"
172
172
  );
173
- // golden: empty pane_current_command + current_command='claude' -> 'receiver: %3 cmd=claude'
173
+ // golden: empty pane_current_command + current_command='claude' -> 'receiver: %3 cmd=claude topology=external'
174
174
  let line_empty = format_status_summary(&json!({
175
175
  "leader_receiver": {"pane_id": "%3", "pane_current_command": "", "current_command": "claude"}
176
176
  }));
177
177
  assert_eq!(
178
178
  line_empty.lines().nth(1).unwrap(),
179
- "receiver: %3 cmd=claude",
179
+ "receiver: %3 cmd=claude topology=external",
180
180
  "empty pane_current_command MUST fall through to current_command (golden falsy `or`)"
181
181
  );
182
182
  }