@team-agent/installer 0.3.25 → 0.3.26

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.
@@ -378,6 +378,12 @@ struct CleanShutdownTransport {
378
378
  kill_server_called: Mutex<bool>,
379
379
  probe_timeout_kind: Option<&'static str>,
380
380
  targets_persist_after_kill: bool,
381
+ // E49 (0.3.24 P0, shutdown kills leader CLI): record per-pane / per-window /
382
+ // per-session kill targets so RED contracts can assert (a) the leader pane is
383
+ // never killed and (b) worker panes ARE killed via the new per-pane path.
384
+ killed_panes: Mutex<Vec<String>>,
385
+ killed_window_targets: Mutex<Vec<String>>,
386
+ killed_sessions: Mutex<Vec<String>>,
381
387
  }
382
388
 
383
389
  impl CleanShutdownTransport {
@@ -388,6 +394,9 @@ impl CleanShutdownTransport {
388
394
  kill_server_called: Mutex::new(false),
389
395
  probe_timeout_kind: None,
390
396
  targets_persist_after_kill: false,
397
+ killed_panes: Mutex::new(Vec::new()),
398
+ killed_window_targets: Mutex::new(Vec::new()),
399
+ killed_sessions: Mutex::new(Vec::new()),
391
400
  }
392
401
  }
393
402
 
@@ -409,6 +418,21 @@ impl CleanShutdownTransport {
409
418
  self.targets_persist_after_kill = true;
410
419
  self
411
420
  }
421
+
422
+ /// E49 (0.3.24 P0): observed per-pane kill targets, in call order.
423
+ fn killed_panes(&self) -> Vec<String> {
424
+ self.killed_panes.lock().unwrap().clone()
425
+ }
426
+
427
+ /// E49 (0.3.24 P0): observed per-window kill targets (target stringified).
428
+ fn killed_window_targets(&self) -> Vec<String> {
429
+ self.killed_window_targets.lock().unwrap().clone()
430
+ }
431
+
432
+ /// E49 (0.3.24 P0): observed per-session kill targets, in call order.
433
+ fn killed_sessions_observed(&self) -> Vec<String> {
434
+ self.killed_sessions.lock().unwrap().clone()
435
+ }
412
436
  }
413
437
 
414
438
  impl Transport for CleanShutdownTransport {
@@ -499,10 +523,14 @@ impl Transport for CleanShutdownTransport {
499
523
  Ok(SetEnvOutcome::Applied)
500
524
  }
501
525
 
502
- fn kill_session(&self, _session: &SessionName) -> Result<(), TransportError> {
526
+ fn kill_session(&self, session: &SessionName) -> Result<(), TransportError> {
503
527
  if let Some(probe) = self.probe_timeout_kind {
504
528
  crate::os_probe::set_probe_timeout_for_test(probe, None, 900);
505
529
  }
530
+ self.killed_sessions
531
+ .lock()
532
+ .unwrap()
533
+ .push(session.as_str().to_string());
506
534
  *self.session_present.lock().unwrap() = false;
507
535
  Ok(())
508
536
  }
@@ -512,7 +540,19 @@ impl Transport for CleanShutdownTransport {
512
540
  Ok(())
513
541
  }
514
542
 
515
- fn kill_window(&self, _target: &Target) -> Result<(), TransportError> {
543
+ fn kill_window(&self, target: &Target) -> Result<(), TransportError> {
544
+ self.killed_window_targets
545
+ .lock()
546
+ .unwrap()
547
+ .push(format!("{target:?}"));
548
+ Ok(())
549
+ }
550
+
551
+ fn kill_pane(&self, pane: &PaneId) -> Result<(), TransportError> {
552
+ self.killed_panes
553
+ .lock()
554
+ .unwrap()
555
+ .push(pane.as_str().to_string());
516
556
  Ok(())
517
557
  }
518
558
 
@@ -730,24 +770,240 @@ fn leader_env_tmux_socket_never_kills_server_even_when_sessions_look_exclusive()
730
770
  );
731
771
  }
732
772
 
773
+ /// E49 (0.3.24 P0, shutdown kills leader CLI): managed-leader topology with
774
+ /// leader anchor pane + worker panes in the SAME tmux session.
775
+ ///
776
+ /// Pre-fix (cli/mod.rs:629-638): `transport.kill_session(state.session_name)`
777
+ /// killed the session unconditionally — including the leader's own pane.
778
+ /// User truth: "执行命令绝不能关掉启动自己的那个 leader CLI". macmini repro:
779
+ /// run `team-agent quick-start ...`, then `team-agent shutdown` from the
780
+ /// same terminal → leader CLI dies.
781
+ ///
782
+ /// Architect-approved fix:
783
+ /// * pre :629 guard: when state is managed (NOT external leader) and the
784
+ /// leader_receiver / team_owner anchor pane is in `state.session_name`,
785
+ /// do NOT call kill_session(state.session_name). Instead kill workers
786
+ /// per-pane via `kill_pane`, deriving pane_ids from state.agents +
787
+ /// teams[*].agents and EXCLUDING the leader anchor pane ids
788
+ /// (`collect_state_leader_anchor_pane_ids`).
789
+ /// * cli/mod.rs:384-435 managed_leader_socket_cleanup: spare any session
790
+ /// carrying a leader anchor; never kill_session it.
791
+ ///
792
+ /// Pre-fix test asserted `killed_sessions=["team-current"]` — that was
793
+ /// LOCKING THE BUG (the architect explicitly named it). Post-fix:
794
+ /// * spared_sessions contains "team-current"
795
+ /// * killed_sessions does NOT contain "team-current"
796
+ /// * killed_panes contains worker panes %w1, %w2
797
+ /// * killed_panes does NOT contain %leader
798
+ /// * no kill_server call
733
799
  #[test]
734
- fn managed_leader_shutdown_never_kills_server_even_when_socket_looks_exclusive() {
735
- let ws = tmp_shutdown_workspace("managed-leader-no-kill-server");
800
+ fn e49_managed_leader_shutdown_spares_leader_session_and_kills_workers_per_pane() {
801
+ let ws = tmp_shutdown_workspace("e49-managed-leader-spares-session");
736
802
  crate::state::persist::save_runtime_state(
737
803
  &ws,
738
804
  &json!({
739
805
  "session_name": "team-current",
740
806
  "is_external_leader": false,
741
- "agents": {}
807
+ "leader_receiver": {"pane_id": "%leader"},
808
+ "team_owner": {"pane_id": "%leader"},
809
+ "agents": {
810
+ "w1": {"status": "running", "provider": "codex", "window": "w1", "pane_id": "%w1"},
811
+ "w2": {"status": "running", "provider": "codex", "window": "w2", "pane_id": "%w2"}
812
+ }
813
+ }),
814
+ )
815
+ .unwrap();
816
+ let transport = CleanShutdownTransport::new()
817
+ .with_targets(vec![
818
+ // Leader anchor pane
819
+ PaneInfo {
820
+ pane_id: PaneId::new("%leader"),
821
+ session: SessionName::new("team-current"),
822
+ window_index: Some(0),
823
+ window_name: Some(WindowName::new("leader")),
824
+ pane_index: Some(0),
825
+ tty: None,
826
+ current_command: Some("codex".to_string()),
827
+ current_path: None,
828
+ active: true,
829
+ pane_pid: None,
830
+ leader_env: BTreeMap::new(),
831
+ },
832
+ // Worker pane 1
833
+ PaneInfo {
834
+ pane_id: PaneId::new("%w1"),
835
+ session: SessionName::new("team-current"),
836
+ window_index: Some(1),
837
+ window_name: Some(WindowName::new("w1")),
838
+ pane_index: Some(0),
839
+ tty: None,
840
+ current_command: Some("codex".to_string()),
841
+ current_path: None,
842
+ active: false,
843
+ pane_pid: None,
844
+ leader_env: BTreeMap::new(),
845
+ },
846
+ // Worker pane 2
847
+ PaneInfo {
848
+ pane_id: PaneId::new("%w2"),
849
+ session: SessionName::new("team-current"),
850
+ window_index: Some(2),
851
+ window_name: Some(WindowName::new("w2")),
852
+ pane_index: Some(0),
853
+ tty: None,
854
+ current_command: Some("codex".to_string()),
855
+ current_path: None,
856
+ active: false,
857
+ pane_pid: None,
858
+ leader_env: BTreeMap::new(),
859
+ },
860
+ ])
861
+ .with_targets_persist_after_kill();
862
+
863
+ let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
864
+ .expect("shutdown should complete");
865
+
866
+ let killed_sessions = transport.killed_sessions_observed();
867
+ assert!(
868
+ !killed_sessions.iter().any(|s| s == "team-current"),
869
+ "E49 (RED): managed-leader shutdown must NEVER kill_session(team-current) — \
870
+ that ends the leader's own pane (the user's CLI). Got transport \
871
+ killed_sessions={killed_sessions:?}"
872
+ );
873
+ let killed_panes = transport.killed_panes();
874
+ assert!(
875
+ !killed_panes.iter().any(|p| p == "%leader"),
876
+ "E49 (RED): leader anchor pane %leader must NEVER be killed. Got \
877
+ killed_panes={killed_panes:?}"
878
+ );
879
+ assert!(
880
+ killed_panes.iter().any(|p| p == "%w1"),
881
+ "E49: worker pane %w1 must be killed per-pane (workers cleared, leader spared). \
882
+ Got killed_panes={killed_panes:?}"
883
+ );
884
+ assert!(
885
+ killed_panes.iter().any(|p| p == "%w2"),
886
+ "E49: worker pane %w2 must be killed per-pane. Got killed_panes={killed_panes:?}"
887
+ );
888
+ assert!(
889
+ !transport.kill_server_called(),
890
+ "E49: managed topology must never kill the tmux server (would end leader pane)."
891
+ );
892
+ // Report-level assertions: the leader session must appear in spared_sessions,
893
+ // not killed_sessions.
894
+ let out_killed = out["killed_sessions"]
895
+ .as_array()
896
+ .cloned()
897
+ .unwrap_or_default();
898
+ let out_spared = out["spared_sessions"]
899
+ .as_array()
900
+ .cloned()
901
+ .unwrap_or_default();
902
+ assert!(
903
+ !out_killed
904
+ .iter()
905
+ .any(|v| v.as_str() == Some("team-current")),
906
+ "E49: report's killed_sessions must NOT contain the leader session. Got {out_killed:?}"
907
+ );
908
+ assert!(
909
+ out_spared
910
+ .iter()
911
+ .any(|v| v.as_str() == Some("team-current")),
912
+ "E49: report's spared_sessions MUST contain the leader session. Got {out_spared:?}"
913
+ );
914
+ assert_eq!(out["ok"], json!(true));
915
+ }
916
+
917
+ /// E49 regression guard: external-leader topology (is_external_leader=true)
918
+ /// must STILL kill_session unconditionally. In that topology the team session
919
+ /// is a disposable worker session and the leader pane lives elsewhere
920
+ /// (a separate terminal / different socket). Sparing the session here would
921
+ /// leak worker processes after shutdown.
922
+ #[test]
923
+ fn e49_external_leader_shutdown_still_kills_team_session_unconditionally() {
924
+ let ws = tmp_shutdown_workspace("e49-external-leader-still-kills");
925
+ crate::state::persist::save_runtime_state(
926
+ &ws,
927
+ &json!({
928
+ "session_name": "team-external",
929
+ "is_external_leader": true,
930
+ "leader_receiver": {"pane_id": "%leaderelsewhere"},
931
+ "agents": {
932
+ "w1": {"status": "running", "provider": "codex", "window": "w1", "pane_id": "%w1"}
933
+ }
934
+ }),
935
+ )
936
+ .unwrap();
937
+ let transport = CleanShutdownTransport::new()
938
+ .with_targets(vec![
939
+ // Worker pane in team session
940
+ PaneInfo {
941
+ pane_id: PaneId::new("%w1"),
942
+ session: SessionName::new("team-external"),
943
+ window_index: Some(0),
944
+ window_name: Some(WindowName::new("w1")),
945
+ pane_index: Some(0),
946
+ tty: None,
947
+ current_command: Some("codex".to_string()),
948
+ current_path: None,
949
+ active: true,
950
+ pane_pid: None,
951
+ leader_env: BTreeMap::new(),
952
+ },
953
+ // Leader pane is in a DIFFERENT session (external topology)
954
+ PaneInfo {
955
+ pane_id: PaneId::new("%leaderelsewhere"),
956
+ session: SessionName::new("user-shell"),
957
+ window_index: Some(0),
958
+ window_name: Some(WindowName::new("shell")),
959
+ pane_index: Some(0),
960
+ tty: None,
961
+ current_command: Some("zsh".to_string()),
962
+ current_path: None,
963
+ active: true,
964
+ pane_pid: None,
965
+ leader_env: BTreeMap::new(),
966
+ },
967
+ ])
968
+ .with_targets_persist_after_kill();
969
+
970
+ let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
971
+ .expect("shutdown should complete");
972
+
973
+ let killed_sessions = transport.killed_sessions_observed();
974
+ assert!(
975
+ killed_sessions.iter().any(|s| s == "team-external"),
976
+ "E49 regression guard: external-leader topology must STILL kill_session \
977
+ the team session — leader pane lives elsewhere so this is safe. Got \
978
+ transport killed_sessions={killed_sessions:?}"
979
+ );
980
+ assert_eq!(out["ok"], json!(true));
981
+ }
982
+
983
+ /// E49 regression guard: managed topology where the leader anchor pane is NOT
984
+ /// in `state.session_name` (e.g. a stale state from an aborted launch where
985
+ /// only worker panes exist in the session). The pre-fix behaviour (kill_session)
986
+ /// is preserved here — no leader pane is at risk.
987
+ #[test]
988
+ fn e49_managed_leader_without_anchor_in_session_still_kills_session() {
989
+ let ws = tmp_shutdown_workspace("e49-managed-no-anchor-in-session");
990
+ crate::state::persist::save_runtime_state(
991
+ &ws,
992
+ &json!({
993
+ "session_name": "team-orphan",
994
+ "is_external_leader": false,
995
+ "agents": {
996
+ "w1": {"status": "running", "provider": "codex", "window": "w1", "pane_id": "%w1"}
997
+ }
742
998
  }),
743
999
  )
744
1000
  .unwrap();
745
1001
  let transport = CleanShutdownTransport::new()
746
1002
  .with_targets(vec![PaneInfo {
747
- pane_id: PaneId::new("%1"),
748
- session: SessionName::new("team-current"),
1003
+ pane_id: PaneId::new("%w1"),
1004
+ session: SessionName::new("team-orphan"),
749
1005
  window_index: Some(0),
750
- window_name: Some(WindowName::new("leader")),
1006
+ window_name: Some(WindowName::new("w1")),
751
1007
  pane_index: Some(0),
752
1008
  tty: None,
753
1009
  current_command: Some("codex".to_string()),
@@ -761,13 +1017,14 @@ fn managed_leader_shutdown_never_kills_server_even_when_socket_looks_exclusive()
761
1017
  let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
762
1018
  .expect("shutdown should complete");
763
1019
 
764
- assert_eq!(out["ok"], json!(true));
765
- assert_eq!(out["killed_sessions"], json!(["team-current"]));
766
- assert_eq!(out["spared_sessions"], json!([]));
1020
+ let killed_sessions = transport.killed_sessions_observed();
767
1021
  assert!(
768
- !transport.kill_server_called(),
769
- "managed topology must clear the team session/window without kill-server"
1022
+ killed_sessions.iter().any(|s| s == "team-orphan"),
1023
+ "E49 regression guard: when no leader anchor pane is in the session, \
1024
+ the pre-fix kill_session path is preserved (no leader is at risk). \
1025
+ Got transport killed_sessions={killed_sessions:?}"
770
1026
  );
1027
+ assert_eq!(out["ok"], json!(true));
771
1028
  }
772
1029
 
773
1030
  #[test]
@@ -150,6 +150,7 @@ use super::*;
150
150
  confirm_human: false,
151
151
  json: false,
152
152
  message_id: None,
153
+ pane: None,
153
154
  }
154
155
  }
155
156
 
@@ -312,6 +312,13 @@ pub struct SendArgs {
312
312
  /// When set, the store insert uses this id verbatim; a repeat with the same
313
313
  /// id returns a `Duplicate` refusal instead of creating a second row.
314
314
  pub message_id: Option<String>,
315
+ /// F1 (0.3.26): `--pane <pane_id>` — direct pane-id targeting. Mutually
316
+ /// exclusive with `target` / `targets`. When set, the message is injected
317
+ /// directly into the specified tmux pane via `transport.inject`, bypassing
318
+ /// the agent-name → pane-id resolution + team-membership check. This is
319
+ /// the **cross-team communication** primitive: the target pane does not
320
+ /// need to be in the sender's team.
321
+ pub pane: Option<String>,
315
322
  }
316
323
 
317
324
  /// E23 worker-side emergency fallback for `team_orchestrator.send_message`
@@ -471,6 +471,7 @@ impl Transport for EnergyTransport {
471
471
  submit_verification: SubmitVerification::EnterSentWithoutPlaceholderCheck,
472
472
  turn_verification: TurnVerification::NotYetObserved,
473
473
  attempts: 1,
474
+ submit_diagnostics: None,
474
475
  })
475
476
  }
476
477
 
@@ -33,6 +33,7 @@ impl Transport for CapturingTransport {
33
33
  submit_verification: crate::transport::SubmitVerification::EnterSentWithoutPlaceholderCheck,
34
34
  turn_verification: crate::transport::TurnVerification::NotYetObserved,
35
35
  attempts: 1,
36
+ submit_diagnostics: None,
36
37
  })
37
38
  }
38
39
  fn send_keys(&self, _t: &Target, _k: &[Key]) -> Result<(), TransportError> {
@@ -59,6 +59,7 @@ impl Transport for DeliveringTransport {
59
59
  submit_verification: crate::transport::SubmitVerification::EnterSentWithoutPlaceholderCheck,
60
60
  turn_verification: crate::transport::TurnVerification::NotYetObserved,
61
61
  attempts: 1,
62
+ submit_diagnostics: None,
62
63
  })
63
64
  }
64
65
  fn send_keys(&self, t: &Target, k: &[Key]) -> Result<(), TransportError> {
@@ -35,6 +35,7 @@ impl Transport for CountingCaptureTransport {
35
35
  submit_verification: crate::transport::SubmitVerification::EnterSentWithoutPlaceholderCheck,
36
36
  turn_verification: crate::transport::TurnVerification::NotYetObserved,
37
37
  attempts: 1,
38
+ submit_diagnostics: None,
38
39
  })
39
40
  }
40
41
  fn send_keys(&self, _t: &Target, _k: &[Key]) -> Result<(), TransportError> {
@@ -581,6 +581,31 @@ fn claim_lease_no_incident_with_target(
581
581
  ));
582
582
  }
583
583
  }
584
+ // E51 (0.3.26 P0, lease guard): refuse to write the leader binding when the
585
+ // caller pane is a REGISTERED WORKER pane. Without this, claim_leader from a
586
+ // worker's tmux pane overwrites leader_receiver.pane_id with the worker's
587
+ // pane → delivery routes worker messages to itself (loop) and the leader
588
+ // loses its handle (the macmini "hand-handle mapping 灾难" truth source).
589
+ if let Some(worker_id) = registered_worker_for_pane(state, caller_pane) {
590
+ emit_lease_refusal(
591
+ event_log,
592
+ LeaseReason::CallerNotLeaderShaped,
593
+ state,
594
+ bound_pane_id.as_deref(),
595
+ Some(caller_pane.as_str()),
596
+ team_id,
597
+ )?;
598
+ return Ok(refused(
599
+ LeaseReason::CallerNotLeaderShaped,
600
+ &format!(
601
+ "pane {} is registered as worker {worker_id}; \
602
+ run claim-leader from the leader's own pane, not a worker pane",
603
+ caller_pane.as_str()
604
+ ),
605
+ None,
606
+ bound_pane_id.clone().map(PaneId::new),
607
+ ));
608
+ }
584
609
  let reason = if bound_pane_id.is_some() {
585
610
  LeaseReason::PreviousOwnerPaneDead
586
611
  } else {
@@ -974,6 +999,38 @@ fn make_owner(
974
999
  }
975
1000
  }
976
1001
 
1002
+ /// E51 (0.3.26 P0, lease guard): scan state.agents + teams[*].agents for a worker
1003
+ /// whose pane_id matches the caller. Returns the first matching agent_id, or None
1004
+ /// if no registered worker owns this pane. The check is O(N agents) — negligible
1005
+ /// on a team-agent team which rarely exceeds ~20 workers.
1006
+ fn registered_worker_for_pane(state: &Value, caller_pane: &PaneId) -> Option<String> {
1007
+ if let Some(agent_id) = scan_agents_for_pane(state.get("agents"), caller_pane) {
1008
+ return Some(agent_id);
1009
+ }
1010
+ if let Some(teams) = state.get("teams").and_then(Value::as_object) {
1011
+ for (_, team_state) in teams {
1012
+ if let Some(agent_id) = scan_agents_for_pane(team_state.get("agents"), caller_pane) {
1013
+ return Some(agent_id);
1014
+ }
1015
+ }
1016
+ }
1017
+ None
1018
+ }
1019
+
1020
+ fn scan_agents_for_pane(agents: Option<&Value>, caller_pane: &PaneId) -> Option<String> {
1021
+ let map = agents?.as_object()?;
1022
+ for (agent_id, agent) in map {
1023
+ if agent
1024
+ .get("pane_id")
1025
+ .and_then(Value::as_str)
1026
+ .is_some_and(|pane| pane == caller_pane.as_str())
1027
+ {
1028
+ return Some(agent_id.clone());
1029
+ }
1030
+ }
1031
+ None
1032
+ }
1033
+
977
1034
  fn write_binding_to_state(
978
1035
  state: &mut Value,
979
1036
  receiver: &LeaderReceiver,
@@ -865,6 +865,7 @@ mod tests {
865
865
  submit_verification: SubmitVerification::EnterSentWithoutPlaceholderCheck,
866
866
  turn_verification: TurnVerification::NotRequired,
867
867
  attempts: 1,
868
+ submit_diagnostics: None,
868
869
  })
869
870
  }
870
871
 
@@ -95,6 +95,16 @@ pub(crate) fn start_agent_at_paths(
95
95
  } else {
96
96
  window_exists(transport, &session_name, &window)
97
97
  };
98
+ // E51 (0.3.26 P0, restart self-heal): even if the pane is structurally
99
+ // alive, it must not be considered "this agent's pane" if it's actually
100
+ // the leader anchor pane or another agent's pane. When lease corruption
101
+ // (E51 root cause) left `agent.pane_id == leader_receiver.pane_id`,
102
+ // start_agent would short-circuit to Noop (the pane IS live — it's just
103
+ // the wrong pane). Force a fresh spawn by treating the pane as not-live
104
+ // when it conflicts with the leader or a different agent.
105
+ let agent_live = agent_live && !pane_conflicts_with_leader_or_other(
106
+ &state, agent_id, &agent,
107
+ );
98
108
  if !force && agent_live {
99
109
  mark_agent_running_noop(&mut state, agent_id, &session_name, &window)?;
100
110
  let team_key = restart_projection_team_key(&state, team);
@@ -274,6 +284,51 @@ fn spawned_pane_is_reachable(
274
284
  false
275
285
  }
276
286
 
287
+ /// E51 (0.3.26 P0, restart self-heal): returns `true` when the agent's pane_id
288
+ /// is the same as the leader_receiver/team_owner pane_id OR is owned by a
289
+ /// different agent in the state. In both cases `start_agent` must NOT treat the
290
+ /// pane as "this agent's live pane" (it should spawn fresh).
291
+ fn pane_conflicts_with_leader_or_other(
292
+ state: &serde_json::Value,
293
+ agent_id: &crate::model::ids::AgentId,
294
+ agent: &serde_json::Value,
295
+ ) -> bool {
296
+ let Some(pane_id) = agent
297
+ .get("pane_id")
298
+ .and_then(serde_json::Value::as_str)
299
+ .filter(|s| !s.is_empty())
300
+ else {
301
+ return false;
302
+ };
303
+ // Check leader anchor.
304
+ for key in ["leader_receiver", "team_owner"] {
305
+ if state
306
+ .get(key)
307
+ .and_then(|v| v.get("pane_id"))
308
+ .and_then(serde_json::Value::as_str)
309
+ .is_some_and(|lp| lp == pane_id)
310
+ {
311
+ return true;
312
+ }
313
+ }
314
+ // Check other agents.
315
+ if let Some(agents) = state.get("agents").and_then(serde_json::Value::as_object) {
316
+ for (id, other) in agents {
317
+ if id == agent_id.as_str() {
318
+ continue;
319
+ }
320
+ if other
321
+ .get("pane_id")
322
+ .and_then(serde_json::Value::as_str)
323
+ .is_some_and(|op| op == pane_id)
324
+ {
325
+ return true;
326
+ }
327
+ }
328
+ }
329
+ false
330
+ }
331
+
277
332
  fn agent_pane_live(
278
333
  transport: &dyn crate::transport::Transport,
279
334
  agent: &serde_json::Value,