@team-agent/installer 0.5.52 → 0.5.54

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 (87) 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 +111 -3
  5. package/crates/team-agent/src/cli/mod.rs +155 -4
  6. package/crates/team-agent/src/cli/send/persist.rs +1 -0
  7. package/crates/team-agent/src/cli/send/presentation.rs +1 -0
  8. package/crates/team-agent/src/cli/send.rs +1 -0
  9. package/crates/team-agent/src/cli/spec.rs +4 -1
  10. package/crates/team-agent/src/cli/tests/lane_c.rs +3 -3
  11. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
  12. package/crates/team-agent/src/cli/tests/named_address.rs +1 -0
  13. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  14. package/crates/team-agent/src/cli/types.rs +12 -0
  15. package/crates/team-agent/src/coordinator/tests/basics.rs +4 -4
  16. package/crates/team-agent/src/db/message_store.rs +31 -2
  17. package/crates/team-agent/src/db/migration.rs +7 -6
  18. package/crates/team-agent/src/db/schema.rs +18 -5
  19. package/crates/team-agent/src/diagnose/orphans.rs +24 -3
  20. package/crates/team-agent/src/kill_audit.rs +67 -0
  21. package/crates/team-agent/src/leader/lease.rs +324 -57
  22. package/crates/team-agent/src/leader/rediscover/tests.rs +3 -0
  23. package/crates/team-agent/src/leader/rediscover.rs +6 -0
  24. package/crates/team-agent/src/leader/start.rs +144 -23
  25. package/crates/team-agent/src/leader/tests/idle.rs +3 -0
  26. package/crates/team-agent/src/leader/types.rs +6 -0
  27. package/crates/team-agent/src/lib.rs +1 -0
  28. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +1 -1
  29. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +5 -0
  30. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +4 -0
  31. package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +106 -0
  32. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +216 -208
  33. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +36 -0
  34. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +238 -0
  35. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +101 -5
  36. package/crates/team-agent/src/lifecycle/launch/role_source.rs +170 -0
  37. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +1 -1
  38. package/crates/team-agent/src/lifecycle/launch.rs +14 -2
  39. package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -1
  40. package/crates/team-agent/src/lifecycle/restart/common.rs +12 -0
  41. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +13 -3
  42. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +110 -12
  43. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +8 -2
  44. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +1 -0
  45. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +3 -1
  46. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -0
  47. package/crates/team-agent/src/lifecycle/types.rs +17 -0
  48. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +40 -5
  49. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -1
  50. package/crates/team-agent/src/mcp_server/normalize.rs +8 -0
  51. package/crates/team-agent/src/mcp_server/tests/wire.rs +243 -167
  52. package/crates/team-agent/src/mcp_server/tools.rs +89 -4
  53. package/crates/team-agent/src/mcp_server/types.rs +7 -0
  54. package/crates/team-agent/src/mcp_server/wire.rs +65 -4
  55. package/crates/team-agent/src/messaging/delivery.rs +2 -0
  56. package/crates/team-agent/src/messaging/helpers.rs +1 -0
  57. package/crates/team-agent/src/messaging/leader_channel.rs +32 -5
  58. package/crates/team-agent/src/messaging/leader_receiver.rs +100 -21
  59. package/crates/team-agent/src/messaging/mod.rs +2 -1
  60. package/crates/team-agent/src/messaging/persist.rs +68 -2
  61. package/crates/team-agent/src/messaging/presentation.rs +307 -0
  62. package/crates/team-agent/src/messaging/results.rs +130 -3
  63. package/crates/team-agent/src/messaging/selftest.rs +1 -0
  64. package/crates/team-agent/src/messaging/send.rs +54 -2
  65. package/crates/team-agent/src/messaging/tests/runtime.rs +85 -24
  66. package/crates/team-agent/src/messaging/types.rs +2 -0
  67. package/crates/team-agent/src/messaging/watchers.rs +1 -0
  68. package/crates/team-agent/src/provider/adapter.rs +54 -13
  69. package/crates/team-agent/src/provider/adapters/claude_fork.rs +122 -0
  70. package/crates/team-agent/src/provider/adapters/copilot_fork.rs +306 -0
  71. package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
  72. package/crates/team-agent/src/provider/session/capture.rs +395 -52
  73. package/crates/team-agent/src/provider/session/context_fork.rs +499 -0
  74. package/crates/team-agent/src/provider/session/mod.rs +6 -0
  75. package/crates/team-agent/src/provider/session_scan/claude.rs +1 -1
  76. package/crates/team-agent/src/provider/session_scan/codex.rs +144 -27
  77. package/crates/team-agent/src/provider/session_scan/common/tests.rs +112 -0
  78. package/crates/team-agent/src/provider/session_scan/common.rs +53 -92
  79. package/crates/team-agent/src/provider/session_scan/copilot.rs +47 -3
  80. package/crates/team-agent/src/provider/session_scan.rs +3 -3
  81. package/crates/team-agent/src/provider/tests/copilot_fork.rs +191 -0
  82. package/crates/team-agent/src/provider/tests.rs +1 -0
  83. package/crates/team-agent/src/tmux_backend/tests.rs +51 -16
  84. package/crates/team-agent/src/tmux_backend.rs +29 -2
  85. package/package.json +4 -4
  86. package/schemas/result-envelope.schema.json +10 -0
  87. package/skills/team-agent/SKILL.md +11 -3
@@ -3,8 +3,11 @@
3
3
 
4
4
  use std::collections::BTreeSet;
5
5
  use std::path::{Path, PathBuf};
6
+ use std::sync::atomic::{AtomicU64, Ordering};
7
+ use std::time::{SystemTime, UNIX_EPOCH};
6
8
 
7
9
  use serde_json::{json, Value};
10
+ use sha2::{Digest, Sha256};
8
11
 
9
12
  use crate::message_store::MessageStore;
10
13
  use crate::model::enums::PaneLiveness;
@@ -357,6 +360,29 @@ fn push_state_tmux_endpoints(state: &Value, out: &mut BTreeSet<String>) {
357
360
  }
358
361
  }
359
362
 
363
+ fn state_records_pane_endpoint(state: &Value, pane_id: &str, endpoint: &str) -> bool {
364
+ let record_matches = |record: &Value| {
365
+ record.get("pane_id").and_then(Value::as_str) == Some(pane_id)
366
+ && record
367
+ .get("tmux_socket")
368
+ .and_then(Value::as_str)
369
+ .is_some_and(|recorded| tmux_endpoints_match(recorded, endpoint))
370
+ };
371
+ ["team_owner", "leader_receiver"]
372
+ .iter()
373
+ .any(|key| state.get(*key).is_some_and(&record_matches))
374
+ || state
375
+ .get("teams")
376
+ .and_then(Value::as_object)
377
+ .is_some_and(|teams| {
378
+ teams.values().any(|team| {
379
+ ["team_owner", "leader_receiver"]
380
+ .iter()
381
+ .any(|key| team.get(*key).is_some_and(&record_matches))
382
+ })
383
+ })
384
+ }
385
+
360
386
  fn requeue_exhausted_watchers_after_attach(
361
387
  workspace: &Path,
362
388
  state: &Value,
@@ -489,41 +515,50 @@ pub fn claim_leader(
489
515
  .unwrap_or_default();
490
516
  let raw_state = crate::state::persist::load_runtime_state_without_migrations(workspace)?;
491
517
  let event_log = crate::event_log::EventLog::new(workspace);
518
+ let active_team = crate::messaging::leader_receiver::active_team_key(workspace, &raw_state);
519
+ let requested_team = team
520
+ .filter(|team| !team.is_empty())
521
+ .or_else(|| {
522
+ raw_state
523
+ .get("active_team_key")
524
+ .and_then(Value::as_str)
525
+ .filter(|team| !team.is_empty())
526
+ })
527
+ .unwrap_or(active_team.as_str());
528
+ let team_id = TeamKey::new(requested_team.to_string());
492
529
  let targets = claim_leader_targets(workspace, &raw_state);
493
- let caller_candidate = targets
494
- .iter()
495
- .filter(|target| target.info.pane_id.as_str() == caller)
496
- .min_by_key(|target| target.source.priority());
530
+ let caller_candidate =
531
+ select_claim_authority(workspace, &raw_state, team_id.as_str(), &targets, &caller);
497
532
  let caller_pane_info = caller_candidate.map(|target| &target.info);
498
- let caller_target = caller_candidate.and_then(|target| {
499
- claim_target_from_pane_info(workspace, &target.info).map(|mut claim_target| {
500
- claim_target.endpoint = target.endpoint.clone();
533
+ let observed_endpoint = caller_candidate.and_then(|candidate| {
534
+ authoritative_observed_endpoint(workspace, &raw_state, &targets, candidate)
535
+ });
536
+ let mut caller_target = caller_candidate.and_then(|target| {
537
+ claim_target_from_pane_info(&target.info).map(|mut claim_target| {
538
+ claim_target.endpoint.clone_from(&observed_endpoint);
501
539
  claim_target
502
540
  })
503
541
  });
504
- let env_team = std::env::var("TEAM_AGENT_TEAM_ID")
505
- .ok()
506
- .filter(|team| !team.is_empty());
507
- let explicit_team = team.filter(|team| !team.is_empty());
508
- let active_team = crate::messaging::leader_receiver::active_team_key(workspace, &raw_state);
509
- let active_team_from_state = raw_state
510
- .get("active_team_key")
511
- .and_then(Value::as_str)
512
- .filter(|team| !team.is_empty());
513
- let requested_team = explicit_team
514
- .or(active_team_from_state)
515
- .or_else(|| {
516
- caller_target
517
- .as_ref()
518
- .and_then(|target| target.team_id.as_deref())
519
- })
520
- .or(env_team.as_deref())
521
- .or(Some(active_team.as_str()));
522
- let team_id = TeamKey::new(
523
- requested_team
524
- .map(str::to_string)
525
- .unwrap_or_else(|| active_team.clone()),
526
- );
542
+ if let (Some(candidate), Some(target)) = (caller_candidate, caller_target.as_mut()) {
543
+ if candidate.info.current_path.as_deref().is_some_and(|path| {
544
+ !crate::messaging::leader_channel::path_is_in_workspace(path, workspace)
545
+ }) {
546
+ let endpoint = target.endpoint.as_deref().ok_or_else(|| {
547
+ LeaderError::Validation(
548
+ "explicit cross-workspace claim requires a canonical tmux endpoint".to_string(),
549
+ )
550
+ })?;
551
+ if !Path::new(endpoint).is_absolute() {
552
+ return Err(LeaderError::Validation(
553
+ "explicit cross-workspace claim requires an absolute tmux endpoint".to_string(),
554
+ ));
555
+ }
556
+ let nonce = next_pane_binding_nonce(workspace, endpoint, &candidate.info.pane_id);
557
+ target.scope_authority = Some("explicit_claim".to_string());
558
+ target.authorized_team_workspace = Some(canonical_workspace(workspace));
559
+ target.binding_nonce = Some(nonce);
560
+ }
561
+ }
527
562
  let scoped_team = if team_id.as_str() == active_team
528
563
  || raw_state
529
564
  .get("teams")
@@ -695,6 +730,7 @@ fn claim_lease_no_incident_with_target(
695
730
  let non_empty_caller_pane = NonEmptyPaneId::try_from_pane(caller_pane)?;
696
731
  let bound_endpoint_matches_caller = bound_endpoint_matches_current_process(state);
697
732
  if bound_pane_id.as_deref() == Some(caller_pane.as_str()) && bound_endpoint_matches_caller {
733
+ write_claim_target_pane_nonce(caller_target)?;
698
734
  let current_endpoint = crate::tmux_backend::socket_name_from_tmux_env();
699
735
  let observed_endpoint = caller_target.and_then(|target| target.endpoint.as_deref());
700
736
  let convergence_candidate = observed_endpoint.or(current_endpoint.as_deref());
@@ -718,6 +754,7 @@ fn claim_lease_no_incident_with_target(
718
754
  pane_info,
719
755
  observed_endpoint.or(current_endpoint.as_deref()),
720
756
  scoped_team.or(team),
757
+ caller_target,
721
758
  )
722
759
  });
723
760
  if converged || observation_refreshed {
@@ -883,6 +920,7 @@ fn claim_lease_no_incident_with_target(
883
920
  } else {
884
921
  LeaseReason::VacantAcquired
885
922
  };
923
+ write_claim_target_pane_nonce(caller_target)?;
886
924
  let mut identity = leader_identity_context(workspace, Some(team_id.as_str()), Some(state))?;
887
925
  if let Some(uuid) = caller_target.and_then(|target| target.leader_session_uuid.as_ref()) {
888
926
  identity.leader_session_uuid = uuid.clone();
@@ -898,6 +936,13 @@ fn claim_lease_no_incident_with_target(
898
936
  Discovery::ClaimLeader,
899
937
  caller_target.and_then(|target| target.pane_info.clone()),
900
938
  );
939
+ if let Some(target) = caller_target {
940
+ receiver.scope_authority.clone_from(&target.scope_authority);
941
+ receiver
942
+ .authorized_team_workspace
943
+ .clone_from(&target.authorized_team_workspace);
944
+ receiver.binding_nonce.clone_from(&target.binding_nonce);
945
+ }
901
946
  if let Some(endpoint) = observed_endpoint.as_ref() {
902
947
  receiver.tmux_socket = Some(endpoint.clone());
903
948
  }
@@ -1023,6 +1068,8 @@ fn apply_endpoint_convergence(
1023
1068
  "new_tmux_endpoint": candidate_endpoint,
1024
1069
  "candidate_source": candidate_source,
1025
1070
  "owner_epoch": owner_epoch.0,
1071
+ "persisted": false,
1072
+ "checked_paths": convergence_checked_paths(team_id.as_str()),
1026
1073
  })),
1027
1074
  false,
1028
1075
  ),
@@ -1038,6 +1085,8 @@ fn apply_endpoint_convergence(
1038
1085
  "reason": reason,
1039
1086
  "candidate_source": candidate_source,
1040
1087
  "owner_epoch": owner_epoch.0,
1088
+ "persisted": false,
1089
+ "checked_paths": convergence_checked_paths(team_id.as_str()),
1041
1090
  "action": format!(
1042
1091
  "old tmux endpoint {old_endpoint} still has this team's session or pane tuple; run team-agent diagnose --json before retrying restart"
1043
1092
  ),
@@ -1313,32 +1362,89 @@ struct LeaderClaimTarget {
1313
1362
  team_id: Option<String>,
1314
1363
  pane_info: Option<PaneInfo>,
1315
1364
  endpoint: Option<String>,
1365
+ scope_authority: Option<String>,
1366
+ authorized_team_workspace: Option<PathBuf>,
1367
+ binding_nonce: Option<String>,
1316
1368
  }
1317
1369
 
1318
1370
  #[derive(Clone)]
1319
1371
  struct ClaimLeaderTargetCandidate {
1320
1372
  info: PaneInfo,
1321
1373
  endpoint: Option<String>,
1322
- source: ClaimLeaderTargetSource,
1323
1374
  }
1324
1375
 
1325
- #[derive(Clone, Copy, PartialEq, Eq)]
1326
- enum ClaimLeaderTargetSource {
1327
- StateRecorded,
1328
- Workspace,
1329
- CurrentTmux,
1330
- Default,
1376
+ fn select_claim_authority<'a>(
1377
+ workspace: &Path,
1378
+ state: &Value,
1379
+ team_id: &str,
1380
+ targets: &'a [ClaimLeaderTargetCandidate],
1381
+ caller: &str,
1382
+ ) -> Option<&'a ClaimLeaderTargetCandidate> {
1383
+ targets
1384
+ .iter()
1385
+ .filter(|target| {
1386
+ target.info.pane_id.as_str() == caller
1387
+ && target.info.active
1388
+ && super::attribute_pane_provider(&target.info).is_some()
1389
+ })
1390
+ .min_by_key(|target| {
1391
+ let recorded = target
1392
+ .endpoint
1393
+ .as_deref()
1394
+ .is_some_and(|endpoint| state_records_pane_endpoint(state, caller, endpoint));
1395
+ let in_workspace = target.info.current_path.as_deref().is_some_and(|path| {
1396
+ crate::messaging::leader_channel::path_is_in_workspace(path, workspace)
1397
+ });
1398
+ let team_affinity = match target
1399
+ .info
1400
+ .leader_env
1401
+ .get("TEAM_AGENT_TEAM_ID")
1402
+ .filter(|value| !value.is_empty())
1403
+ {
1404
+ Some(observed) if observed == team_id => 0,
1405
+ None => 1,
1406
+ Some(_) => 2,
1407
+ };
1408
+ (
1409
+ !recorded,
1410
+ !in_workspace,
1411
+ team_affinity,
1412
+ target.info.pane_pid.unwrap_or(u32::MAX),
1413
+ target.info.session.as_str().to_string(),
1414
+ target.info.window_index.unwrap_or(u32::MAX),
1415
+ target.info.pane_index.unwrap_or(u32::MAX),
1416
+ target.info.tty.clone().unwrap_or_default(),
1417
+ )
1418
+ })
1331
1419
  }
1332
1420
 
1333
- impl ClaimLeaderTargetSource {
1334
- fn priority(self) -> u8 {
1335
- match self {
1336
- Self::StateRecorded => 0,
1337
- Self::Workspace => 1,
1338
- Self::CurrentTmux => 2,
1339
- Self::Default => 3,
1340
- }
1341
- }
1421
+ fn authoritative_observed_endpoint(
1422
+ workspace: &Path,
1423
+ state: &Value,
1424
+ targets: &[ClaimLeaderTargetCandidate],
1425
+ authority: &ClaimLeaderTargetCandidate,
1426
+ ) -> Option<String> {
1427
+ let recorded = state_recorded_tmux_endpoints(state);
1428
+ let workspace_name = crate::tmux_backend::socket_name_for_workspace(workspace);
1429
+ let workspace_path = crate::tmux_backend::socket_path_for_workspace(workspace);
1430
+ targets
1431
+ .iter()
1432
+ .filter(|candidate| candidate.info == authority.info)
1433
+ .filter_map(|candidate| candidate.endpoint.as_ref())
1434
+ .min_by_key(|endpoint| {
1435
+ let canonical_workspace_endpoint = endpoint.as_str() == workspace_name
1436
+ || workspace_path
1437
+ .as_ref()
1438
+ .is_some_and(|path| path.to_string_lossy().as_ref() == endpoint.as_str());
1439
+ (
1440
+ !state_records_pane_endpoint(state, authority.info.pane_id.as_str(), endpoint),
1441
+ !recorded.contains(endpoint.as_str()),
1442
+ !canonical_workspace_endpoint,
1443
+ !Path::new(endpoint.as_str()).is_absolute(),
1444
+ endpoint.as_str(),
1445
+ )
1446
+ })
1447
+ .cloned()
1342
1448
  }
1343
1449
 
1344
1450
  fn claim_leader_targets(workspace: &Path, state: &Value) -> Vec<ClaimLeaderTargetCandidate> {
@@ -1354,7 +1460,6 @@ fn claim_leader_targets(workspace: &Path, state: &Value) -> Vec<ClaimLeaderTarge
1354
1460
  .map(|info| ClaimLeaderTargetCandidate {
1355
1461
  info,
1356
1462
  endpoint: resolved_endpoint.clone(),
1357
- source: ClaimLeaderTargetSource::CurrentTmux,
1358
1463
  }),
1359
1464
  );
1360
1465
  }
@@ -1369,7 +1474,6 @@ fn claim_leader_targets(workspace: &Path, state: &Value) -> Vec<ClaimLeaderTarge
1369
1474
  .map(|info| ClaimLeaderTargetCandidate {
1370
1475
  info,
1371
1476
  endpoint: resolved_endpoint.clone(),
1372
- source: ClaimLeaderTargetSource::StateRecorded,
1373
1477
  }),
1374
1478
  );
1375
1479
  }
@@ -1383,7 +1487,6 @@ fn claim_leader_targets(workspace: &Path, state: &Value) -> Vec<ClaimLeaderTarge
1383
1487
  .map(|info| ClaimLeaderTargetCandidate {
1384
1488
  info,
1385
1489
  endpoint: workspace_endpoint.clone(),
1386
- source: ClaimLeaderTargetSource::Workspace,
1387
1490
  }),
1388
1491
  );
1389
1492
  let default_backend = crate::transport_factory::tmux_default_transport();
@@ -1396,21 +1499,16 @@ fn claim_leader_targets(workspace: &Path, state: &Value) -> Vec<ClaimLeaderTarge
1396
1499
  .map(|info| ClaimLeaderTargetCandidate {
1397
1500
  info,
1398
1501
  endpoint: default_endpoint.clone(),
1399
- source: ClaimLeaderTargetSource::Default,
1400
1502
  }),
1401
1503
  );
1402
1504
  targets
1403
1505
  }
1404
1506
 
1405
- fn claim_target_from_pane_info(workspace: &Path, target: &PaneInfo) -> Option<LeaderClaimTarget> {
1507
+ fn claim_target_from_pane_info(target: &PaneInfo) -> Option<LeaderClaimTarget> {
1406
1508
  if !target.active {
1407
1509
  return None;
1408
1510
  }
1409
1511
  let provider = super::attribute_pane_provider(target)?;
1410
- let current_path = target.current_path.as_deref()?;
1411
- if !crate::state::owner_gate::workspace_paths_match(current_path, workspace) {
1412
- return None;
1413
- }
1414
1512
  Some(LeaderClaimTarget {
1415
1513
  provider,
1416
1514
  leader_session_uuid: target_leader_session_uuid(target),
@@ -1421,9 +1519,74 @@ fn claim_target_from_pane_info(workspace: &Path, target: &PaneInfo) -> Option<Le
1421
1519
  .cloned(),
1422
1520
  pane_info: Some(target.clone()),
1423
1521
  endpoint: None,
1522
+ scope_authority: None,
1523
+ authorized_team_workspace: None,
1524
+ binding_nonce: None,
1424
1525
  })
1425
1526
  }
1426
1527
 
1528
+ fn workspace_claim_target_from_pane_info(
1529
+ workspace: &Path,
1530
+ target: &PaneInfo,
1531
+ ) -> Option<LeaderClaimTarget> {
1532
+ let current_path = target.current_path.as_deref()?;
1533
+ if !crate::state::owner_gate::workspace_paths_match(current_path, workspace) {
1534
+ return None;
1535
+ }
1536
+ claim_target_from_pane_info(target)
1537
+ }
1538
+
1539
+ fn write_claim_target_pane_nonce(target: Option<&LeaderClaimTarget>) -> Result<(), LeaderError> {
1540
+ let Some(target) = target.filter(|target| target.scope_authority.is_some()) else {
1541
+ return Ok(());
1542
+ };
1543
+ let endpoint = target.endpoint.as_deref().ok_or_else(|| {
1544
+ LeaderError::Validation("explicit claim is missing its tmux endpoint".to_string())
1545
+ })?;
1546
+ let pane = target
1547
+ .pane_info
1548
+ .as_ref()
1549
+ .map(|info| &info.pane_id)
1550
+ .ok_or_else(|| {
1551
+ LeaderError::Validation("explicit claim is missing its pane observation".to_string())
1552
+ })?;
1553
+ let nonce = target.binding_nonce.as_deref().ok_or_else(|| {
1554
+ LeaderError::Validation("explicit claim is missing its pane binding nonce".to_string())
1555
+ })?;
1556
+ tmux_backend_for_endpoint(endpoint)
1557
+ .set_pane_binding_nonce(pane, nonce)
1558
+ .map_err(|error| {
1559
+ LeaderError::Validation(format!(
1560
+ "failed to bind the explicit leader pane instance: {error}"
1561
+ ))
1562
+ })
1563
+ }
1564
+
1565
+ fn canonical_workspace(workspace: &Path) -> PathBuf {
1566
+ workspace
1567
+ .canonicalize()
1568
+ .unwrap_or_else(|_| workspace.to_path_buf())
1569
+ }
1570
+
1571
+ fn next_pane_binding_nonce(workspace: &Path, endpoint: &str, pane: &PaneId) -> String {
1572
+ static COUNTER: AtomicU64 = AtomicU64::new(0);
1573
+ let nanos = SystemTime::now()
1574
+ .duration_since(UNIX_EPOCH)
1575
+ .map_or(0, |duration| duration.as_nanos());
1576
+ let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
1577
+ let material = format!(
1578
+ "{}\0{endpoint}\0{}\0{}\0{nanos}\0{counter}",
1579
+ canonical_workspace(workspace).display(),
1580
+ pane.as_str(),
1581
+ std::process::id(),
1582
+ );
1583
+ let digest = Sha256::digest(material.as_bytes());
1584
+ digest[..16]
1585
+ .iter()
1586
+ .map(|byte| format!("{byte:02x}"))
1587
+ .collect()
1588
+ }
1589
+
1427
1590
  fn target_leader_session_uuid(target: &PaneInfo) -> Option<crate::model::ids::LeaderSessionUuid> {
1428
1591
  target
1429
1592
  .leader_env
@@ -1447,7 +1610,7 @@ fn validate_attach_target(
1447
1610
  // a real Codex/Claude/Copilot binary. This is a targeted escape
1448
1611
  // hatch — `Provider::Fake` is not selectable from the user-facing
1449
1612
  // provider list, only wired in test/fixture flows.
1450
- let claim_target = match claim_target_from_pane_info(workspace, target) {
1613
+ let claim_target = match workspace_claim_target_from_pane_info(workspace, target) {
1451
1614
  Some(target) => Some(target),
1452
1615
  None if matches!(requested_provider, Provider::Fake) => None,
1453
1616
  None => return Err("leader_pane_validation_failed"),
@@ -1553,7 +1716,7 @@ impl TargetScanLiveness {
1553
1716
  let live_panes = targets
1554
1717
  .iter()
1555
1718
  .filter_map(|target| {
1556
- let claim_target = claim_target_from_pane_info(workspace, target)?;
1719
+ let claim_target = workspace_claim_target_from_pane_info(workspace, target)?;
1557
1720
  if let Some(owner_uuid) = owner_uuid.as_deref() {
1558
1721
  let target_uuid = claim_target.leader_session_uuid.as_ref()?.as_str();
1559
1722
  if target_uuid != owner_uuid {
@@ -1656,6 +1819,9 @@ fn make_receiver(
1656
1819
  pane_tty: target.as_ref().and_then(|t| t.tty.clone()),
1657
1820
  pane_current_command: target.as_ref().and_then(|t| t.current_command.clone()),
1658
1821
  tmux_socket: crate::tmux_backend::socket_name_from_tmux_env(),
1822
+ scope_authority: None,
1823
+ authorized_team_workspace: None,
1824
+ binding_nonce: None,
1659
1825
  fingerprint: target.as_ref().map(receiver_fingerprint),
1660
1826
  leader_session_uuid: Some(uuid.clone()),
1661
1827
  owner_epoch: Some(epoch),
@@ -1685,6 +1851,7 @@ fn refresh_already_bound_receiver_observation(
1685
1851
  observed: &PaneInfo,
1686
1852
  observed_endpoint: Option<&str>,
1687
1853
  team_key: Option<&str>,
1854
+ claim_target: Option<&LeaderClaimTarget>,
1688
1855
  ) -> bool {
1689
1856
  let attached_at = now_ts();
1690
1857
  let mut refreshed = state
@@ -1696,6 +1863,7 @@ fn refresh_already_bound_receiver_observation(
1696
1863
  observed,
1697
1864
  observed_endpoint,
1698
1865
  attached_at.as_str(),
1866
+ claim_target,
1699
1867
  )
1700
1868
  });
1701
1869
  if let Some(team_key) = team_key {
@@ -1710,6 +1878,7 @@ fn refresh_already_bound_receiver_observation(
1710
1878
  observed,
1711
1879
  observed_endpoint,
1712
1880
  attached_at.as_str(),
1881
+ claim_target,
1713
1882
  )
1714
1883
  });
1715
1884
  }
@@ -1721,6 +1890,7 @@ fn refresh_receiver_observation(
1721
1890
  observed: &PaneInfo,
1722
1891
  observed_endpoint: Option<&str>,
1723
1892
  attached_at: &str,
1893
+ claim_target: Option<&LeaderClaimTarget>,
1724
1894
  ) -> bool {
1725
1895
  let before = Value::Object(receiver.clone());
1726
1896
  receiver.insert(
@@ -1775,6 +1945,26 @@ fn refresh_receiver_observation(
1775
1945
  Value::String(endpoint.to_string()),
1776
1946
  );
1777
1947
  }
1948
+ if let Some(target) = claim_target.filter(|target| target.scope_authority.is_some()) {
1949
+ receiver.insert(
1950
+ "scope_authority".to_string(),
1951
+ Value::String(target.scope_authority.clone().unwrap_or_default()),
1952
+ );
1953
+ receiver.insert(
1954
+ "authorized_team_workspace".to_string(),
1955
+ Value::String(
1956
+ target
1957
+ .authorized_team_workspace
1958
+ .as_deref()
1959
+ .map(|path| path.to_string_lossy().into_owned())
1960
+ .unwrap_or_default(),
1961
+ ),
1962
+ );
1963
+ receiver.insert(
1964
+ "binding_nonce".to_string(),
1965
+ Value::String(target.binding_nonce.clone().unwrap_or_default()),
1966
+ );
1967
+ }
1778
1968
  receiver.insert(
1779
1969
  "attached_at".to_string(),
1780
1970
  Value::String(attached_at.to_string()),
@@ -2303,6 +2493,7 @@ mod tests {
2303
2493
  &observed_pane(),
2304
2494
  Some("/tmp/team-agent-live.sock"),
2305
2495
  Some("team-a"),
2496
+ None,
2306
2497
  ));
2307
2498
  let receiver = &state["leader_receiver"];
2308
2499
  assert_eq!(receiver["owner_epoch"], json!(10));
@@ -2321,4 +2512,80 @@ mod tests {
2321
2512
  json!(10)
2322
2513
  );
2323
2514
  }
2515
+
2516
+ #[test]
2517
+ fn already_bound_explicit_claim_persists_cross_workspace_authority() {
2518
+ let mut state = json!({
2519
+ "leader_receiver": {"status": "attached", "pane_id": "%7"},
2520
+ "teams": {"team-a": {"leader_receiver": {
2521
+ "status": "attached", "pane_id": "%7"
2522
+ }}}
2523
+ });
2524
+ let target = LeaderClaimTarget {
2525
+ provider: Provider::Codex,
2526
+ leader_session_uuid: None,
2527
+ team_id: Some("team-a".to_string()),
2528
+ pane_info: Some(observed_pane()),
2529
+ endpoint: Some("/tmp/team-agent-live.sock".to_string()),
2530
+ scope_authority: Some("explicit_claim".to_string()),
2531
+ authorized_team_workspace: Some(PathBuf::from("/tmp/workspace-a")),
2532
+ binding_nonce: Some("nonce-7".to_string()),
2533
+ };
2534
+
2535
+ assert!(refresh_already_bound_receiver_observation(
2536
+ &mut state,
2537
+ &observed_pane(),
2538
+ Some("/tmp/team-agent-live.sock"),
2539
+ Some("team-a"),
2540
+ Some(&target),
2541
+ ));
2542
+ for receiver in [
2543
+ &state["leader_receiver"],
2544
+ &state["teams"]["team-a"]["leader_receiver"],
2545
+ ] {
2546
+ assert_eq!(receiver["scope_authority"], json!("explicit_claim"));
2547
+ assert_eq!(
2548
+ receiver["authorized_team_workspace"],
2549
+ json!("/tmp/workspace-a")
2550
+ );
2551
+ assert_eq!(receiver["binding_nonce"], json!("nonce-7"));
2552
+ }
2553
+ }
2554
+
2555
+ #[test]
2556
+ fn pane_binding_nonce_rotates_per_explicit_claim() {
2557
+ let workspace = Path::new("/tmp/workspace-a");
2558
+ let endpoint = "/tmp/team-agent-live.sock";
2559
+ let pane = PaneId::new("%7");
2560
+ let first = next_pane_binding_nonce(workspace, endpoint, &pane);
2561
+ let second = next_pane_binding_nonce(workspace, endpoint, &pane);
2562
+ assert_eq!(first.len(), 32);
2563
+ assert!(first.chars().all(|ch| ch.is_ascii_hexdigit()));
2564
+ assert_ne!(first, second);
2565
+ }
2566
+
2567
+ #[test]
2568
+ fn claim_authority_preserves_recorded_absolute_observation() {
2569
+ let workspace = Path::new("/tmp/workspace-a");
2570
+ let absolute = "/tmp/tmux-501/ta-live";
2571
+ let state = json!({
2572
+ "leader_receiver": {"pane_id": "%9", "tmux_socket": absolute}
2573
+ });
2574
+ let pane = observed_pane();
2575
+ let targets = vec![
2576
+ ClaimLeaderTargetCandidate {
2577
+ info: pane.clone(),
2578
+ endpoint: Some(crate::tmux_backend::socket_name_for_workspace(workspace)),
2579
+ },
2580
+ ClaimLeaderTargetCandidate {
2581
+ info: pane,
2582
+ endpoint: Some(absolute.to_string()),
2583
+ },
2584
+ ];
2585
+
2586
+ assert_eq!(
2587
+ authoritative_observed_endpoint(workspace, &state, &targets, &targets[0]).as_deref(),
2588
+ Some(absolute)
2589
+ );
2590
+ }
2324
2591
  }
@@ -32,6 +32,9 @@ fn receiver(pane: &str, uuid: &str, epoch: u64) -> LeaderReceiver {
32
32
  pane_tty: None,
33
33
  pane_current_command: None,
34
34
  tmux_socket: None,
35
+ scope_authority: None,
36
+ authorized_team_workspace: None,
37
+ binding_nonce: None,
35
38
  fingerprint: None,
36
39
  leader_session_uuid: serde_json::from_value(Value::String(uuid.to_string())).ok(),
37
40
  owner_epoch: Some(OwnerEpoch(epoch)),
@@ -1059,6 +1059,9 @@ fn receiver_from_candidate(
1059
1059
  pane_tty: target.tty.clone(),
1060
1060
  pane_current_command: target.current_command.clone(),
1061
1061
  tmux_socket: prior.tmux_socket.clone(),
1062
+ scope_authority: prior.scope_authority.clone(),
1063
+ authorized_team_workspace: prior.authorized_team_workspace.clone(),
1064
+ binding_nonce: prior.binding_nonce.clone(),
1062
1065
  fingerprint: target.fingerprint.clone(),
1063
1066
  leader_session_uuid: uuid,
1064
1067
  owner_epoch: Some(epoch),
@@ -1082,6 +1085,9 @@ fn empty_prior(provider: Provider, epoch: OwnerEpoch) -> LeaderReceiver {
1082
1085
  pane_tty: None,
1083
1086
  pane_current_command: None,
1084
1087
  tmux_socket: None,
1088
+ scope_authority: None,
1089
+ authorized_team_workspace: None,
1090
+ binding_nonce: None,
1085
1091
  fingerprint: None,
1086
1092
  leader_session_uuid: None,
1087
1093
  owner_epoch: Some(epoch),