@team-agent/installer 0.5.51 → 0.5.53

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 (79) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +18 -2
  4. package/crates/team-agent/src/cli/emit.rs +17 -0
  5. package/crates/team-agent/src/cli/mod.rs +155 -4
  6. package/crates/team-agent/src/cli/named_address.rs +35 -67
  7. package/crates/team-agent/src/cli/spec.rs +3 -0
  8. package/crates/team-agent/src/cli/tests/main_preserved.rs +1 -0
  9. package/crates/team-agent/src/cli/types.rs +11 -0
  10. package/crates/team-agent/src/diagnose/orphans.rs +24 -3
  11. package/crates/team-agent/src/kill_audit.rs +67 -0
  12. package/crates/team-agent/src/leader/lease.rs +526 -73
  13. package/crates/team-agent/src/leader/rediscover/tests.rs +3 -0
  14. package/crates/team-agent/src/leader/rediscover.rs +6 -0
  15. package/crates/team-agent/src/leader/start.rs +144 -23
  16. package/crates/team-agent/src/leader/tests/idle.rs +3 -0
  17. package/crates/team-agent/src/leader/types.rs +6 -0
  18. package/crates/team-agent/src/lib.rs +1 -0
  19. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +1 -1
  20. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +12 -1
  21. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +4 -0
  22. package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +106 -0
  23. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +216 -208
  24. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +36 -0
  25. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +238 -0
  26. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +101 -5
  27. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1 -1
  28. package/crates/team-agent/src/lifecycle/launch/role_source.rs +170 -0
  29. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +8 -8
  30. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +1 -1
  31. package/crates/team-agent/src/lifecycle/launch.rs +14 -2
  32. package/crates/team-agent/src/lifecycle/restart/agent.rs +20 -4
  33. package/crates/team-agent/src/lifecycle/restart/common.rs +12 -0
  34. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +113 -4
  35. package/crates/team-agent/src/lifecycle/restart/remove.rs +73 -10
  36. package/crates/team-agent/src/lifecycle/restart.rs +25 -1
  37. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +441 -12
  38. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +20 -2
  39. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +1 -0
  40. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +3 -1
  41. package/crates/team-agent/src/lifecycle/types.rs +17 -0
  42. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +40 -5
  43. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -1
  44. package/crates/team-agent/src/mcp_server/tests/tools.rs +18 -1
  45. package/crates/team-agent/src/mcp_server/tests/wire.rs +243 -167
  46. package/crates/team-agent/src/mcp_server/tests.rs +11 -4
  47. package/crates/team-agent/src/mcp_server/tools.rs +18 -4
  48. package/crates/team-agent/src/mcp_server/types.rs +3 -0
  49. package/crates/team-agent/src/mcp_server/wire.rs +30 -7
  50. package/crates/team-agent/src/messaging/delivery.rs +126 -147
  51. package/crates/team-agent/src/messaging/leader_channel.rs +198 -0
  52. package/crates/team-agent/src/messaging/leader_receiver.rs +68 -21
  53. package/crates/team-agent/src/messaging/mod.rs +5 -0
  54. package/crates/team-agent/src/messaging/tests/leader_channel.rs +169 -0
  55. package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
  56. package/crates/team-agent/src/messaging/tests/runtime.rs +34 -23
  57. package/crates/team-agent/src/provider/adapter.rs +54 -13
  58. package/crates/team-agent/src/provider/adapters/claude_fork.rs +122 -0
  59. package/crates/team-agent/src/provider/adapters/copilot_fork.rs +306 -0
  60. package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
  61. package/crates/team-agent/src/provider/session/capture.rs +321 -52
  62. package/crates/team-agent/src/provider/session/context_fork.rs +499 -0
  63. package/crates/team-agent/src/provider/session/mod.rs +6 -0
  64. package/crates/team-agent/src/provider/session_scan/claude.rs +1 -1
  65. package/crates/team-agent/src/provider/session_scan/codex.rs +144 -27
  66. package/crates/team-agent/src/provider/session_scan/common.rs +39 -6
  67. package/crates/team-agent/src/provider/session_scan/copilot.rs +47 -3
  68. package/crates/team-agent/src/provider/session_scan.rs +3 -3
  69. package/crates/team-agent/src/provider/tests/copilot_fork.rs +191 -0
  70. package/crates/team-agent/src/provider/tests.rs +1 -0
  71. package/crates/team-agent/src/state/persist.rs +282 -20
  72. package/crates/team-agent/src/state/projection.rs +245 -30
  73. package/crates/team-agent/src/state/repository/intent.rs +13 -0
  74. package/crates/team-agent/src/state/repository.rs +36 -12
  75. package/crates/team-agent/src/state/selector.rs +9 -18
  76. package/crates/team-agent/src/tmux_backend/tests.rs +51 -16
  77. package/crates/team-agent/src/tmux_backend.rs +29 -2
  78. package/package.json +4 -4
  79. package/skills/team-agent/SKILL.md +8 -3
@@ -33,6 +33,7 @@ pub fn tools_contract() -> Vec<Value> {
33
33
  McpTool::StopAgent,
34
34
  McpTool::ResetAgent,
35
35
  McpTool::AddAgent,
36
+ McpTool::CloneAgent,
36
37
  McpTool::ForkAgent,
37
38
  McpTool::RequestHuman,
38
39
  McpTool::StuckList,
@@ -378,6 +379,7 @@ fn tool_contract(tool: McpTool) -> Value {
378
379
  McpTool::StopAgent => ("Stop a running worker.", vec!["agent_id"]),
379
380
  McpTool::ResetAgent => ("Reset one worker to a fresh session.", vec!["agent_id", "discard_session"]),
380
381
  McpTool::AddAgent => ("Add a first-class worker from a role file.", vec!["new_agent_id", "role_file_path"]),
382
+ McpTool::CloneAgent => ("Clone a worker role into a fresh provider session.", vec!["source_agent_id", "as_agent_id"]),
381
383
  McpTool::ForkAgent => ("Fork a running worker.", vec!["source_agent_id", "as_agent_id"]),
382
384
  McpTool::RequestHuman => ("Ask the leader or user for human input.", vec!["question"]),
383
385
  McpTool::StuckList => ("List manually suppressed idle alerts.", Vec::new()),
@@ -522,6 +524,23 @@ fn tool_properties(tool: McpTool) -> serde_json::Map<String, Value> {
522
524
  string_property("Optional display label."),
523
525
  );
524
526
  }
527
+ McpTool::CloneAgent => {
528
+ insert_property(
529
+ &mut properties,
530
+ "source_agent_id",
531
+ string_property("Agent id to clone from."),
532
+ );
533
+ insert_property(
534
+ &mut properties,
535
+ "as_agent_id",
536
+ string_property("Agent id for the cloned worker."),
537
+ );
538
+ insert_property(
539
+ &mut properties,
540
+ "label",
541
+ string_property("Optional display label."),
542
+ );
543
+ }
525
544
  McpTool::RequestHuman => {
526
545
  insert_property(
527
546
  &mut properties,
@@ -591,13 +610,7 @@ pub(crate) fn dispatch_tool(
591
610
  McpTool::SendMessage => {
592
611
  let target = message_target_from_value(args.get("to"));
593
612
  let content = args.get("content").and_then(Value::as_str).unwrap_or("");
594
- let outcome = tools.send_message(
595
- &target,
596
- content,
597
- None,
598
- None,
599
- None,
600
- )?;
613
+ let outcome = tools.send_message(&target, content, None, None, None)?;
601
614
  match outcome {
602
615
  SendOutcome::WorkerAccepted { .. } => Ok(ToolOk {
603
616
  fields: object_fields(outcome.to_value()),
@@ -668,6 +681,15 @@ pub(crate) fn dispatch_tool(
668
681
  .unwrap_or(""),
669
682
  args.get("label").and_then(Value::as_str),
670
683
  ),
684
+ McpTool::CloneAgent => tools.clone_agent(
685
+ args.get("source_agent_id")
686
+ .and_then(Value::as_str)
687
+ .unwrap_or(""),
688
+ args.get("as_agent_id")
689
+ .and_then(Value::as_str)
690
+ .unwrap_or(""),
691
+ args.get("label").and_then(Value::as_str),
692
+ ),
671
693
  McpTool::RequestHuman => tools.request_human(
672
694
  args.get("question").and_then(Value::as_str).unwrap_or(""),
673
695
  args.get("task_id").and_then(Value::as_str),
@@ -695,6 +717,7 @@ fn scope_ceiling_tool(tool: McpTool) -> bool {
695
717
  | McpTool::GetTeamStatus
696
718
  | McpTool::StopAgent
697
719
  | McpTool::ResetAgent
720
+ | McpTool::CloneAgent
698
721
  | McpTool::ForkAgent
699
722
  )
700
723
  }
@@ -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,198 @@
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
+ && !explicit_claim_authority_matches(workspace, receiver, &observed)
105
+ {
106
+ return LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneWorkspaceMismatch);
107
+ }
108
+ let metadata_drift = receiver_metadata_drift(receiver, &observed);
109
+ LeaderChannelResolution::Live(LiveLeaderChannel::DirectTmux(DirectTmuxLeaderChannel {
110
+ pane_id: pane_id.to_string(),
111
+ tmux_socket: tmux_socket.map(str::to_string),
112
+ observed,
113
+ metadata_drift,
114
+ }))
115
+ }
116
+
117
+ fn explicit_claim_authority_matches(
118
+ workspace: &Path,
119
+ receiver: &Value,
120
+ observed: &PaneInfo,
121
+ ) -> bool {
122
+ if string_field(receiver, "scope_authority") != Some("explicit_claim") {
123
+ return false;
124
+ }
125
+ let Some(authorized_workspace) = string_field(receiver, "authorized_team_workspace") else {
126
+ return false;
127
+ };
128
+ if canonical_path(Path::new(authorized_workspace)) != canonical_path(workspace) {
129
+ return false;
130
+ }
131
+ let Some(recorded_nonce) = string_field(receiver, "binding_nonce") else {
132
+ return false;
133
+ };
134
+ observed
135
+ .leader_env
136
+ .get(crate::tmux_backend::PANE_BINDING_NONCE_METADATA_KEY)
137
+ .filter(|nonce| !nonce.is_empty())
138
+ .is_some_and(|live_nonce| live_nonce == recorded_nonce)
139
+ }
140
+
141
+ pub(crate) fn path_is_in_workspace(path: &Path, workspace: &Path) -> bool {
142
+ let path = canonical_path(path);
143
+ let workspace = canonical_path(workspace);
144
+ path == workspace || path.starts_with(workspace)
145
+ }
146
+
147
+ fn canonical_path(path: &Path) -> std::path::PathBuf {
148
+ path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
149
+ }
150
+
151
+ fn receiver_transport_conflicts(receiver: &Value) -> bool {
152
+ let mode = receiver.get("mode").and_then(Value::as_str);
153
+ let transport_kind = receiver.get("transport_kind").and_then(Value::as_str);
154
+ matches!((mode, transport_kind), (Some(mode), Some(kind)) if !mode.is_empty() && !kind.is_empty() && mode != kind)
155
+ }
156
+
157
+ fn receiver_metadata_drift(receiver: &Value, observed: &PaneInfo) -> Vec<&'static str> {
158
+ let mut drift = Vec::new();
159
+ if string_field(receiver, "session_name")
160
+ .is_some_and(|expected| expected != observed.session.as_str())
161
+ {
162
+ drift.push("session_name");
163
+ }
164
+ if string_field(receiver, "window_name").is_some_and(|expected| {
165
+ observed.window_name.as_ref().map(|value| value.as_str()) != Some(expected)
166
+ }) {
167
+ drift.push("window_name");
168
+ }
169
+ if string_field(receiver, "pane_tty")
170
+ .is_some_and(|expected| observed.tty.as_deref() != Some(expected))
171
+ {
172
+ drift.push("pane_tty");
173
+ }
174
+ let observed_fingerprint = format!(
175
+ "{}|{}|{}|{}",
176
+ observed.session.as_str(),
177
+ observed
178
+ .window_index
179
+ .map_or_else(String::new, |value| value.to_string()),
180
+ observed
181
+ .pane_index
182
+ .map_or_else(String::new, |value| value.to_string()),
183
+ observed.tty.as_deref().unwrap_or("")
184
+ );
185
+ if string_field(receiver, "fingerprint")
186
+ .is_some_and(|expected| expected != observed_fingerprint)
187
+ {
188
+ drift.push("fingerprint");
189
+ }
190
+ drift
191
+ }
192
+
193
+ fn string_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
194
+ value
195
+ .get(key)
196
+ .and_then(Value::as_str)
197
+ .filter(|value| !value.is_empty())
198
+ }