@team-agent/installer 0.5.51 → 0.5.52

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.
Files changed (27) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/named_address.rs +35 -67
  4. package/crates/team-agent/src/cli/tests/main_preserved.rs +1 -0
  5. package/crates/team-agent/src/leader/lease.rs +202 -16
  6. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +7 -1
  7. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1 -1
  8. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +8 -8
  9. package/crates/team-agent/src/lifecycle/restart/agent.rs +19 -3
  10. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +100 -1
  11. package/crates/team-agent/src/lifecycle/restart/remove.rs +73 -10
  12. package/crates/team-agent/src/lifecycle/restart.rs +25 -1
  13. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +331 -0
  14. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +12 -0
  15. package/crates/team-agent/src/mcp_server/tests/tools.rs +18 -1
  16. package/crates/team-agent/src/mcp_server/tests.rs +11 -4
  17. package/crates/team-agent/src/messaging/delivery.rs +126 -147
  18. package/crates/team-agent/src/messaging/leader_channel.rs +171 -0
  19. package/crates/team-agent/src/messaging/mod.rs +5 -0
  20. package/crates/team-agent/src/messaging/tests/leader_channel.rs +169 -0
  21. package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
  22. package/crates/team-agent/src/state/persist.rs +282 -20
  23. package/crates/team-agent/src/state/projection.rs +245 -30
  24. package/crates/team-agent/src/state/repository/intent.rs +13 -0
  25. package/crates/team-agent/src/state/repository.rs +36 -12
  26. package/crates/team-agent/src/state/selector.rs +9 -18
  27. package/package.json +4 -4
@@ -705,6 +705,7 @@ fn spine_add_agent_rejects_duplicate_agent_id() {
705
705
  #[test]
706
706
  fn e5_add_agent_does_not_copy_role_into_platform_dir_and_injects_into_spec() {
707
707
  let team = quick_start_team_dir(QS_VALID_ROLE);
708
+ seed_canonical_team_identity(&team, "teamdir");
708
709
  // External role file OUTSIDE team_dir/agents, with recognizable body content.
709
710
  let role = team.parent().unwrap().join("w2-external-role.md");
710
711
  let role_body = "---\nname: w2\nrole: Second Worker\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nUNIQUE-E5-BUG1-MARKER body for w2.\n";
@@ -1154,6 +1155,12 @@ pub(super) fn seed_healthy_coordinator(workspace: &std::path::Path) {
1154
1155
  .unwrap();
1155
1156
  }
1156
1157
 
1158
+ fn seed_canonical_team_identity(workspace: &std::path::Path, team_key: &str) {
1159
+ let mut state = crate::state::persist::load_runtime_state(workspace).unwrap();
1160
+ state["active_team_key"] = json!(team_key);
1161
+ crate::state::persist::save_runtime_state(workspace, &state).unwrap();
1162
+ }
1163
+
1157
1164
  // RED — quick_start_with_transport must drive the REAL spawn path: launch.dry_run==false, started
1158
1165
  // non-empty (one per compiled agent), and the transport records >=1 spawn carrying that agent's
1159
1166
  // provider build_command. Today the dry_run=true bug -> launch.started empty + ZERO spawns recorded ->
@@ -2263,6 +2270,7 @@ fn add_agent_with_transport_spawns_new_worker_not_stub() {
2263
2270
  let role_file = team.join("worker2-role.md"); // OUTSIDE agents/ -> not a duplicate of an existing agent
2264
2271
  std::fs::write(&role_file, DELEG_ROLE_WORKER2).unwrap();
2265
2272
  seed_healthy_coordinator(&team);
2273
+ seed_canonical_team_identity(&team, "addteam");
2266
2274
  let transport = OfflineTransport::new();
2267
2275
 
2268
2276
  let _result = add_agent_with_transport(
@@ -3331,6 +3339,7 @@ fn add_agent_reachability_gate_returns_error_when_spawn_pane_not_addressable() {
3331
3339
  let role_file = team.join("worker2-role.md");
3332
3340
  std::fs::write(&role_file, DELEG_ROLE_WORKER2).unwrap();
3333
3341
  seed_healthy_coordinator(&team);
3342
+ seed_canonical_team_identity(&team, "reachgate");
3334
3343
 
3335
3344
  // Models "spawn to wrong socket" / "spawn succeeded but pane orphan": the
3336
3345
  // OfflineTransport records the spawn but reports the new pane is NOT
@@ -3390,6 +3399,7 @@ fn add_agent_reachability_gate_passes_when_spawn_pane_addressable() {
3390
3399
  let role_file = team.join("worker2-role.md");
3391
3400
  std::fs::write(&role_file, DELEG_ROLE_WORKER2).unwrap();
3392
3401
  seed_healthy_coordinator(&team);
3402
+ seed_canonical_team_identity(&team, "gatepass");
3393
3403
 
3394
3404
  // Default OfflineTransport: spawned_panes_addressable=true, liveness=Unknown
3395
3405
  // (treated as not-Dead by the gate's fallthrough).
@@ -4149,6 +4159,7 @@ fn e42_re_add_and_remove_agree_on_existence_after_add() {
4149
4159
  let role_file = team.join("newagent.md");
4150
4160
  std::fs::write(&role_file, DELEG_ROLE_WORKER2).unwrap();
4151
4161
  seed_healthy_coordinator(&team);
4162
+ seed_canonical_team_identity(&team, "e42sym");
4152
4163
  let transport = OfflineTransport::new();
4153
4164
  // (1) first add
4154
4165
  let r1 = crate::lifecycle::add_agent_with_transport(
@@ -4205,6 +4216,7 @@ fn e42_add_writes_to_canonical_runtime_spec_and_state_consistently() {
4205
4216
  let role_file = team.join("newagent.md");
4206
4217
  std::fs::write(&role_file, DELEG_ROLE_WORKER2).unwrap();
4207
4218
  seed_healthy_coordinator(&team);
4219
+ seed_canonical_team_identity(&team, "e42proj");
4208
4220
  let transport = OfflineTransport::new();
4209
4221
  let _ = crate::lifecycle::add_agent_with_transport(
4210
4222
  &team,
@@ -311,8 +311,25 @@
311
311
  // ════════════════════════════════════════════════════════════════════════
312
312
  #[test]
313
313
  fn update_state_returns_ok_and_state_file_path() {
314
+ let ws = seed_state_ws(
315
+ "update-state",
316
+ &json!({
317
+ "active_team_key": "teamA",
318
+ "team_key": "teamA",
319
+ "session_name": "teamA",
320
+ "status": "alive",
321
+ "teams": {
322
+ "teamA": {
323
+ "team_key": "teamA",
324
+ "session_name": "teamA",
325
+ "status": "alive",
326
+ "agents": {}
327
+ }
328
+ }
329
+ }),
330
+ );
314
331
  let tools = TeamOrchestratorTools::with_identity(
315
- &unique_ws("update-state"),
332
+ &ws,
316
333
  Some(AgentId::new("leader")),
317
334
  Some(TeamKey::new("teamA")),
318
335
  );
@@ -22,12 +22,19 @@ fn keys(v: &Value) -> Vec<String> {
22
22
  /// `/tmp/ws`, or they flake under parallel cargo (sqlite "database is locked" / NotFound).
23
23
  /// Pure-function / dispatch-shape tests that never touch fs/db keep a dummy fixed path.
24
24
  fn unique_ws(tag: &str) -> std::path::PathBuf {
25
+ use std::io::ErrorKind;
25
26
  use std::sync::atomic::{AtomicU64, Ordering};
26
27
  static N: AtomicU64 = AtomicU64::new(0);
27
- let n = N.fetch_add(1, Ordering::Relaxed);
28
- let p = std::env::temp_dir().join(format!("ta-rs-mcp-{tag}-{}-{n}", std::process::id()));
29
- std::fs::create_dir_all(&p).unwrap();
30
- p
28
+ loop {
29
+ let n = N.fetch_add(1, Ordering::Relaxed);
30
+ let p =
31
+ std::env::temp_dir().join(format!("ta-rs-mcp-{tag}-{}-{n}", std::process::id()));
32
+ match std::fs::create_dir(&p) {
33
+ Ok(()) => return p,
34
+ Err(error) if error.kind() == ErrorKind::AlreadyExists => {}
35
+ Err(error) => panic!("create unique workspace {}: {error}", p.display()),
36
+ }
37
+ }
31
38
  }
32
39
 
33
40
  include!("tests/normalize.rs");
@@ -223,16 +223,41 @@ pub fn deliver_pending_message(
223
223
  return observe_pending_leader_acceptance(store, event_log, state, message_id);
224
224
  }
225
225
  if message.recipient == "leader" && message.status == "queued_until_leader_attach" {
226
- if leader_receiver_status(state) != Some("attached") {
226
+ let Some(receiver) = leader_receiver_value(state) else {
227
227
  return Ok(leader_acceptance_pending_outcome(
228
228
  message_id,
229
229
  "queued_until_leader_attach",
230
230
  ));
231
- }
231
+ };
232
+ let channel_transport =
233
+ delivery_transport_for_recipient(workspace, transport, state, "leader");
234
+ let resolution = crate::messaging::resolve_live_leader_channel(
235
+ workspace,
236
+ receiver,
237
+ channel_transport.as_transport(),
238
+ );
239
+ let crate::messaging::LeaderChannelResolution::Live(channel) = resolution else {
240
+ return Ok(leader_acceptance_pending_outcome(
241
+ message_id,
242
+ "queued_until_leader_attach",
243
+ ));
244
+ };
245
+ let metadata_drift = match &channel {
246
+ crate::messaging::LiveLeaderChannel::DirectTmux(channel) => {
247
+ channel.metadata_drift.clone()
248
+ }
249
+ crate::messaging::LiveLeaderChannel::CodexAppServer(_) => Vec::new(),
250
+ };
232
251
  store.mark(message_id, "accepted", None)?;
233
252
  event_log.write(
234
253
  "leader_receiver.mailbox_requeued",
235
- serde_json::json!({"message_id": message_id}),
254
+ serde_json::json!({
255
+ "message_id": message_id,
256
+ "channel_live": true,
257
+ "metadata_drift": metadata_drift,
258
+ "requeue_trigger": "delivery_revalidation",
259
+ "revalidated_at": chrono::Utc::now().to_rfc3339(),
260
+ }),
236
261
  )?;
237
262
  }
238
263
  let attempt = if store.claim_for_delivery(message_id)? {
@@ -252,88 +277,108 @@ pub fn deliver_pending_message(
252
277
  });
253
278
  };
254
279
  if message.recipient == "leader" {
255
- if let Some(receiver) = leader_receiver_value(state) {
256
- if let Some(outcome) = leader_receiver_transport_conflict_outcome(
280
+ let Some(receiver) = leader_receiver_value(state) else {
281
+ store.mark(message_id, "failed", Some("leader_not_attached"))?;
282
+ event_log.write(
283
+ "leader_receiver.delivery_blocked",
284
+ serde_json::json!({
285
+ "message_id": message_id,
286
+ "sender": message.sender,
287
+ "reason": "leader_receiver_not_attached",
288
+ "error": "leader_not_attached",
289
+ "channel_reason": "receiver_missing",
290
+ "channel": "rebind_required",
291
+ "action": "run team-agent attach-leader or team-agent takeover",
292
+ "status": "unbound",
293
+ }),
294
+ )?;
295
+ return Ok(DeliveryOutcome {
296
+ ok: false,
297
+ status: DeliveryStatus::Refused,
298
+ message_status: MessageStatusShadow("failed".to_string()),
299
+ message_id: Some(message_id.to_string()),
300
+ verification: Some(
301
+ "run team-agent attach-leader or team-agent takeover".to_string(),
302
+ ),
303
+ stage: None,
304
+ reason: Some(DeliveryRefusal::LeaderNotAttached),
305
+ channel: Some("rebind_required".to_string()),
306
+ });
307
+ };
308
+ if let Some(outcome) = leader_receiver_transport_conflict_outcome(
309
+ store,
310
+ event_log,
311
+ message_id,
312
+ receiver,
313
+ &message.sender,
314
+ )? {
315
+ return Ok(outcome);
316
+ }
317
+ let channel_transport =
318
+ delivery_transport_for_recipient(workspace, transport, state, "leader");
319
+ match crate::messaging::resolve_live_leader_channel(
320
+ workspace,
321
+ receiver,
322
+ channel_transport.as_transport(),
323
+ ) {
324
+ crate::messaging::LeaderChannelResolution::Live(_) => {}
325
+ crate::messaging::LeaderChannelResolution::ProbeFailed(error) => {
326
+ store.mark(message_id, "target_resolved", Some(&error))?;
327
+ return Ok(DeliveryOutcome {
328
+ ok: false,
329
+ status: DeliveryStatus::Degraded,
330
+ message_status: MessageStatusShadow("target_resolved".to_string()),
331
+ message_id: Some(message_id.to_string()),
332
+ verification: Some(error),
333
+ stage: None,
334
+ reason: None,
335
+ channel: Some("rebind_required".to_string()),
336
+ });
337
+ }
338
+ crate::messaging::LeaderChannelResolution::Unbound(reason) => {
339
+ let receiver_status = receiver
340
+ .get("status")
341
+ .and_then(serde_json::Value::as_str)
342
+ .unwrap_or("unbound");
343
+ store.mark(message_id, "failed", Some("leader_not_attached"))?;
344
+ event_log.write(
345
+ "leader_receiver.delivery_blocked",
346
+ serde_json::json!({
347
+ "message_id": message_id,
348
+ "sender": message.sender,
349
+ "reason": "leader_receiver_not_attached",
350
+ "error": "leader_not_attached",
351
+ "channel_reason": format!("{reason:?}"),
352
+ "channel": "rebind_required",
353
+ "action": "run team-agent attach-leader or team-agent takeover",
354
+ "status": receiver_status,
355
+ }),
356
+ )?;
357
+ return Ok(DeliveryOutcome {
358
+ ok: false,
359
+ status: DeliveryStatus::Refused,
360
+ message_status: MessageStatusShadow("failed".to_string()),
361
+ message_id: Some(message_id.to_string()),
362
+ verification: Some(
363
+ "run team-agent attach-leader or team-agent takeover".to_string(),
364
+ ),
365
+ stage: None,
366
+ reason: Some(DeliveryRefusal::LeaderNotAttached),
367
+ channel: Some("rebind_required".to_string()),
368
+ });
369
+ }
370
+ }
371
+ if crate::codex_app_server::receiver_is_app_server(receiver) {
372
+ return deliver_leader_via_app_server(
257
373
  store,
258
374
  event_log,
375
+ state,
259
376
  message_id,
260
- receiver,
261
- &message.sender,
262
- )? {
263
- return Ok(outcome);
264
- }
265
- // 0.5.4 gate054 cross-team leak fix: a `leader_receiver.status`
266
- // other than `"attached"` (restart demotes killed-session receivers
267
- // to `"rebind_required"` — lifecycle/restart/rebuild.rs:1311) MUST
268
- // refuse delivery before any pane injection or cross-server pane-id
269
- // probing. Without this gate, a stale receiver on team A's socket
270
- // could otherwise fall back to the same pane id on the main tmux
271
- // server (delivery.rs:1150-1201) and inject A's leader traffic into
272
- // team B's leader pane. See .team/artifacts/0.5.4-gate-crossteam-notify-triage.md.
273
- if let Some(status) = leader_receiver_status(state) {
274
- if status != "attached" {
275
- store.mark(message_id, "failed", Some("leader_not_attached"))?;
276
- event_log.write(
277
- "leader_receiver.delivery_blocked",
278
- serde_json::json!({
279
- "message_id": message_id,
280
- "sender": message.sender,
281
- "reason": "leader_receiver_not_attached",
282
- "channel": "rebind_required",
283
- "action": "run team-agent attach-leader or team-agent takeover",
284
- "status": status,
285
- }),
286
- )?;
287
- return Ok(DeliveryOutcome {
288
- ok: false,
289
- status: DeliveryStatus::Refused,
290
- message_status: MessageStatusShadow("failed".to_string()),
291
- message_id: Some(message_id.to_string()),
292
- verification: Some(
293
- "run team-agent attach-leader or team-agent takeover".to_string(),
294
- ),
295
- stage: None,
296
- reason: Some(DeliveryRefusal::LeaderNotAttached),
297
- channel: Some("rebind_required".to_string()),
298
- });
299
- }
300
- }
301
- if crate::codex_app_server::receiver_is_app_server(receiver) {
302
- return deliver_leader_via_app_server(
303
- store,
304
- event_log,
305
- state,
306
- message_id,
307
- &message,
308
- canonical_owner_team_id.as_deref(),
309
- );
310
- }
377
+ &message,
378
+ canonical_owner_team_id.as_deref(),
379
+ );
311
380
  }
312
381
  }
313
- if message.recipient == "leader" && leader_receiver_has_noncanonical_tmux_socket(state) {
314
- store.mark(message_id, "failed", Some("leader_not_attached"))?;
315
- event_log.write(
316
- "leader_receiver.delivery_blocked",
317
- serde_json::json!({
318
- "message_id": message_id,
319
- "sender": message.sender,
320
- "reason": "leader_not_attached",
321
- "channel": "rebind_required",
322
- "action": "run team-agent claim-leader or team-agent takeover",
323
- "error": "leader_receiver.tmux_socket is not a canonical full socket path",
324
- }),
325
- )?;
326
- return Ok(DeliveryOutcome {
327
- ok: false,
328
- status: DeliveryStatus::Refused,
329
- message_status: MessageStatusShadow("failed".to_string()),
330
- message_id: Some(message_id.to_string()),
331
- verification: Some("run team-agent claim-leader or team-agent takeover".to_string()),
332
- stage: None,
333
- reason: Some(DeliveryRefusal::LeaderNotAttached),
334
- channel: Some("rebind_required".to_string()),
335
- });
336
- }
337
382
  let delivery_transport =
338
383
  delivery_transport_for_recipient(workspace, transport, state, &message.recipient);
339
384
  let transport = delivery_transport.as_transport();
@@ -369,32 +414,6 @@ pub fn deliver_pending_message(
369
414
  });
370
415
  }
371
416
  };
372
- // Do not inject queued leader messages into a synthetic "leader" window.
373
- if message.recipient == "leader"
374
- && !leader_receiver_pane_is_usable(transport, state, &live_targets)
375
- {
376
- store.mark(message_id, "failed", Some("leader_not_attached"))?;
377
- event_log.write(
378
- "leader_receiver.delivery_blocked",
379
- serde_json::json!({
380
- "message_id": message_id,
381
- "sender": message.sender,
382
- "reason": "leader_not_attached",
383
- "channel": "rebind_required",
384
- "action": "run team-agent claim-leader or team-agent takeover",
385
- }),
386
- )?;
387
- return Ok(DeliveryOutcome {
388
- ok: false,
389
- status: DeliveryStatus::Refused,
390
- message_status: MessageStatusShadow("failed".to_string()),
391
- message_id: Some(message_id.to_string()),
392
- verification: Some("run team-agent claim-leader or team-agent takeover".to_string()),
393
- stage: None,
394
- reason: Some(DeliveryRefusal::LeaderNotAttached),
395
- channel: Some("rebind_required".to_string()),
396
- });
397
- }
398
417
  let resolved = resolve_inject_target(state, &message.recipient, transport, &live_targets);
399
418
  if let Some(stale) = resolved.stale_binding.as_ref() {
400
419
  event_log.write(
@@ -1533,32 +1552,6 @@ fn leader_receiver_pane_binding(state: &serde_json::Value) -> Option<PaneSocketB
1533
1552
  .or_else(|| only_team_entry(state).and_then(leader_receiver_pane_binding_in_state))
1534
1553
  }
1535
1554
 
1536
- /// `state` is "usable" for leader delivery when the bound `leader_receiver.pane_id`
1537
- /// is present and alive (in `live_targets` or `liveness` probe returns not-Dead).
1538
- ///
1539
- /// **0.3.24 excision (U1-A real-machine RED v2)**: the wave-2 session+window drift
1540
- /// fallback was removed. Drift now fails loudly as `leader_not_attached` —
1541
- /// see resolve_inject_target and `.team/artifacts/u1-a-realmachine-v2-fix-or-excise.md`.
1542
- fn leader_receiver_pane_is_usable(
1543
- transport: &dyn Transport,
1544
- state: &serde_json::Value,
1545
- live_targets: &[PaneInfo],
1546
- ) -> bool {
1547
- let Some(pane_id) = leader_receiver_pane_id(state) else {
1548
- return false;
1549
- };
1550
- if live_targets
1551
- .iter()
1552
- .any(|target| target.pane_id.as_str() == pane_id)
1553
- {
1554
- return true;
1555
- }
1556
- !matches!(
1557
- transport.liveness(&PaneId::new(pane_id)),
1558
- Ok(PaneLiveness::Dead)
1559
- )
1560
- }
1561
-
1562
1555
  /// 0.5.x Phase 1d Batch 4: the `Owned` variant now carries a boxed
1563
1556
  /// `Transport` trait object rather than a concrete `TmuxBackend`. All
1564
1557
  /// existing leader-fallback sites still supply a `TmuxBackend` here
@@ -1631,7 +1624,7 @@ fn delivery_transport_for_recipient<'a>(
1631
1624
  // switching transports on a match can cross the team/tmux boundary and
1632
1625
  // inject A's leader traffic into team B's leader pane. If the pane is
1633
1626
  // absent on the recorded endpoint, let the downstream
1634
- // `leader_receiver_pane_is_usable` guard produce `rebind_required`
1627
+ // the shared leader-channel resolver produce `rebind_required`
1635
1628
  // instead of falling back to another server.
1636
1629
  let endpoint_backend = crate::transport_factory::tmux_endpoint_transport(socket);
1637
1630
  DeliveryTransport::Owned(Box::new(endpoint_backend))
@@ -1673,20 +1666,6 @@ fn leader_receiver_tmux_socket(state: &serde_json::Value) -> Option<&str> {
1673
1666
  leader_receiver_field(state, "tmux_socket")
1674
1667
  }
1675
1668
 
1676
- /// 0.5.4 gate054 cross-team leak fix: read the bound `leader_receiver.status`
1677
- /// through the same team-scope resolution as [`leader_receiver_tmux_socket`].
1678
- /// Restart marks demoted receivers as `"rebind_required"`
1679
- /// (lifecycle/restart/rebuild.rs:1311); delivery must refuse before injecting
1680
- /// or cross-server pane-id probing (delivery.rs:1150-1201).
1681
- fn leader_receiver_status(state: &serde_json::Value) -> Option<&str> {
1682
- leader_receiver_field(state, "status")
1683
- }
1684
-
1685
- fn leader_receiver_has_noncanonical_tmux_socket(state: &serde_json::Value) -> bool {
1686
- leader_receiver_tmux_socket(state)
1687
- .is_some_and(|socket| socket != "default" && !std::path::Path::new(socket).is_absolute())
1688
- }
1689
-
1690
1669
  fn leader_receiver_field<'a>(state: &'a serde_json::Value, field: &str) -> Option<&'a str> {
1691
1670
  leader_receiver_field_in_state(state, field)
1692
1671
  .or_else(|| {
@@ -0,0 +1,171 @@
1
+ use std::path::Path;
2
+
3
+ use serde_json::Value;
4
+
5
+ use crate::codex_app_server::AppServerBinding;
6
+ use crate::transport::{PaneInfo, Transport};
7
+
8
+ #[derive(Debug, Clone, PartialEq, Eq)]
9
+ pub enum LiveLeaderChannel {
10
+ DirectTmux(DirectTmuxLeaderChannel),
11
+ CodexAppServer(AppServerBinding),
12
+ }
13
+
14
+ #[derive(Debug, Clone, PartialEq, Eq)]
15
+ pub struct DirectTmuxLeaderChannel {
16
+ pub pane_id: String,
17
+ pub tmux_socket: Option<String>,
18
+ pub observed: PaneInfo,
19
+ pub metadata_drift: Vec<&'static str>,
20
+ }
21
+
22
+ #[derive(Debug, Clone, PartialEq, Eq)]
23
+ pub enum LeaderChannelResolution {
24
+ Live(LiveLeaderChannel),
25
+ Unbound(LeaderChannelUnbound),
26
+ ProbeFailed(String),
27
+ }
28
+
29
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
30
+ pub enum LeaderChannelUnbound {
31
+ ReceiverNotAttached,
32
+ TransportConflict,
33
+ MissingPaneId,
34
+ NonCanonicalTmuxSocket,
35
+ EndpointMismatch,
36
+ PaneNotLive,
37
+ PaneWorkspaceMismatch,
38
+ AppServerBindingInvalid,
39
+ }
40
+
41
+ /// Resolve the canonical receiver's live physical channel without mutating
42
+ /// ownership. For direct tmux, `(absolute socket, pane id)` plus an available
43
+ /// pane cwd inside the target workspace is the authority. Session/window/tty/
44
+ /// fingerprint are observations and can only report drift.
45
+ pub fn resolve_live_leader_channel(
46
+ workspace: &Path,
47
+ receiver: &Value,
48
+ transport: &dyn Transport,
49
+ ) -> LeaderChannelResolution {
50
+ if receiver
51
+ .get("status")
52
+ .and_then(Value::as_str)
53
+ .is_some_and(|status| status != "attached")
54
+ {
55
+ return LeaderChannelResolution::Unbound(LeaderChannelUnbound::ReceiverNotAttached);
56
+ }
57
+ if receiver_transport_conflicts(receiver) {
58
+ return LeaderChannelResolution::Unbound(LeaderChannelUnbound::TransportConflict);
59
+ }
60
+ if crate::codex_app_server::receiver_is_app_server(receiver) {
61
+ return match crate::codex_app_server::binding_from_receiver(receiver) {
62
+ Ok(binding) => {
63
+ LeaderChannelResolution::Live(LiveLeaderChannel::CodexAppServer(binding))
64
+ }
65
+ Err(_) => {
66
+ LeaderChannelResolution::Unbound(LeaderChannelUnbound::AppServerBindingInvalid)
67
+ }
68
+ };
69
+ }
70
+
71
+ let Some(pane_id) = receiver
72
+ .get("pane_id")
73
+ .and_then(Value::as_str)
74
+ .filter(|pane| !pane.is_empty())
75
+ else {
76
+ return LeaderChannelResolution::Unbound(LeaderChannelUnbound::MissingPaneId);
77
+ };
78
+ let tmux_socket = receiver
79
+ .get("tmux_socket")
80
+ .and_then(Value::as_str)
81
+ .filter(|socket| !socket.is_empty());
82
+ if tmux_socket.is_some_and(|socket| !std::path::Path::new(socket).is_absolute()) {
83
+ return LeaderChannelResolution::Unbound(LeaderChannelUnbound::NonCanonicalTmuxSocket);
84
+ }
85
+ if let Some(expected) = tmux_socket {
86
+ if transport.tmux_endpoint().as_deref() != Some(expected) {
87
+ return LeaderChannelResolution::Unbound(LeaderChannelUnbound::EndpointMismatch);
88
+ }
89
+ }
90
+ let targets = match transport.list_targets() {
91
+ Ok(targets) => targets,
92
+ Err(error) => return LeaderChannelResolution::ProbeFailed(error.to_string()),
93
+ };
94
+ let Some(observed) = targets
95
+ .into_iter()
96
+ .find(|target| target.pane_id.as_str() == pane_id)
97
+ else {
98
+ return LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneNotLive);
99
+ };
100
+ if observed
101
+ .current_path
102
+ .as_deref()
103
+ .is_some_and(|path| !path_is_in_workspace(path, workspace))
104
+ {
105
+ return LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneWorkspaceMismatch);
106
+ }
107
+ let metadata_drift = receiver_metadata_drift(receiver, &observed);
108
+ LeaderChannelResolution::Live(LiveLeaderChannel::DirectTmux(DirectTmuxLeaderChannel {
109
+ pane_id: pane_id.to_string(),
110
+ tmux_socket: tmux_socket.map(str::to_string),
111
+ observed,
112
+ metadata_drift,
113
+ }))
114
+ }
115
+
116
+ fn path_is_in_workspace(path: &Path, workspace: &Path) -> bool {
117
+ let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
118
+ let workspace = workspace
119
+ .canonicalize()
120
+ .unwrap_or_else(|_| workspace.to_path_buf());
121
+ path == workspace || path.starts_with(workspace)
122
+ }
123
+
124
+ fn receiver_transport_conflicts(receiver: &Value) -> bool {
125
+ let mode = receiver.get("mode").and_then(Value::as_str);
126
+ let transport_kind = receiver.get("transport_kind").and_then(Value::as_str);
127
+ matches!((mode, transport_kind), (Some(mode), Some(kind)) if !mode.is_empty() && !kind.is_empty() && mode != kind)
128
+ }
129
+
130
+ fn receiver_metadata_drift(receiver: &Value, observed: &PaneInfo) -> Vec<&'static str> {
131
+ let mut drift = Vec::new();
132
+ if string_field(receiver, "session_name")
133
+ .is_some_and(|expected| expected != observed.session.as_str())
134
+ {
135
+ drift.push("session_name");
136
+ }
137
+ if string_field(receiver, "window_name").is_some_and(|expected| {
138
+ observed.window_name.as_ref().map(|value| value.as_str()) != Some(expected)
139
+ }) {
140
+ drift.push("window_name");
141
+ }
142
+ if string_field(receiver, "pane_tty")
143
+ .is_some_and(|expected| observed.tty.as_deref() != Some(expected))
144
+ {
145
+ drift.push("pane_tty");
146
+ }
147
+ let observed_fingerprint = format!(
148
+ "{}|{}|{}|{}",
149
+ observed.session.as_str(),
150
+ observed
151
+ .window_index
152
+ .map_or_else(String::new, |value| value.to_string()),
153
+ observed
154
+ .pane_index
155
+ .map_or_else(String::new, |value| value.to_string()),
156
+ observed.tty.as_deref().unwrap_or("")
157
+ );
158
+ if string_field(receiver, "fingerprint")
159
+ .is_some_and(|expected| expected != observed_fingerprint)
160
+ {
161
+ drift.push("fingerprint");
162
+ }
163
+ drift
164
+ }
165
+
166
+ fn string_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
167
+ value
168
+ .get(key)
169
+ .and_then(Value::as_str)
170
+ .filter(|value| !value.is_empty())
171
+ }
@@ -64,6 +64,7 @@ pub mod activity;
64
64
  pub mod address;
65
65
  pub mod delivery;
66
66
  pub mod helpers;
67
+ pub mod leader_channel;
67
68
  pub mod leader_receiver;
68
69
  pub mod peers;
69
70
  pub mod persist;
@@ -86,6 +87,10 @@ pub use delivery::{
86
87
  tmux_pane_width,
87
88
  };
88
89
  pub use helpers::fail_leader_delivery;
90
+ pub use leader_channel::{
91
+ resolve_live_leader_channel, DirectTmuxLeaderChannel, LeaderChannelResolution,
92
+ LeaderChannelUnbound, LiveLeaderChannel,
93
+ };
89
94
  pub use leader_receiver::{
90
95
  deliver_to_leader_fallback_pane, enqueue_leader_mailbox_until_attach,
91
96
  mirror_peer_message_to_leader, send_to_leader_receiver,