@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
@@ -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());
@@ -712,25 +748,47 @@ fn claim_lease_no_incident_with_target(
712
748
  candidate_source,
713
749
  pre_epoch,
714
750
  );
715
- if converged {
751
+ let observation_refreshed = caller_pane_info.is_some_and(|pane_info| {
752
+ refresh_already_bound_receiver_observation(
753
+ state,
754
+ pane_info,
755
+ observed_endpoint.or(current_endpoint.as_deref()),
756
+ scoped_team.or(team),
757
+ caller_target,
758
+ )
759
+ });
760
+ if converged || observation_refreshed {
716
761
  write_claim_state(workspace, state, scoped_team, team)?;
717
- topology_convergence = verify_persisted_topology_convergence(
718
- workspace,
719
- team_id.as_str(),
720
- topology_convergence,
721
- pre_epoch,
722
- )?;
723
- if topology_convergence
724
- .as_ref()
725
- .and_then(|metadata| metadata.get("status"))
726
- .and_then(Value::as_str)
727
- != Some("converged")
728
- {
729
- return Ok(convergence_persistence_refusal(
762
+ if observation_refreshed {
763
+ event_log.write(
764
+ "leader_receiver.observation_refreshed",
765
+ json!({
766
+ "pane_id": caller_pane.as_str(),
767
+ "owner_epoch": pre_epoch.0,
768
+ "owner_changed": false,
769
+ "source": "claim_leader_already_bound",
770
+ }),
771
+ )?;
772
+ }
773
+ if converged {
774
+ topology_convergence = verify_persisted_topology_convergence(
775
+ workspace,
776
+ team_id.as_str(),
730
777
  topology_convergence,
731
778
  pre_epoch,
732
- Some(caller_pane.clone()),
733
- ));
779
+ )?;
780
+ if topology_convergence
781
+ .as_ref()
782
+ .and_then(|metadata| metadata.get("status"))
783
+ .and_then(Value::as_str)
784
+ != Some("converged")
785
+ {
786
+ return Ok(convergence_persistence_refusal(
787
+ topology_convergence,
788
+ pre_epoch,
789
+ Some(caller_pane.clone()),
790
+ ));
791
+ }
734
792
  }
735
793
  }
736
794
  return Ok(LeaseResult {
@@ -862,6 +920,7 @@ fn claim_lease_no_incident_with_target(
862
920
  } else {
863
921
  LeaseReason::VacantAcquired
864
922
  };
923
+ write_claim_target_pane_nonce(caller_target)?;
865
924
  let mut identity = leader_identity_context(workspace, Some(team_id.as_str()), Some(state))?;
866
925
  if let Some(uuid) = caller_target.and_then(|target| target.leader_session_uuid.as_ref()) {
867
926
  identity.leader_session_uuid = uuid.clone();
@@ -877,6 +936,13 @@ fn claim_lease_no_incident_with_target(
877
936
  Discovery::ClaimLeader,
878
937
  caller_target.and_then(|target| target.pane_info.clone()),
879
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
+ }
880
946
  if let Some(endpoint) = observed_endpoint.as_ref() {
881
947
  receiver.tmux_socket = Some(endpoint.clone());
882
948
  }
@@ -1002,6 +1068,8 @@ fn apply_endpoint_convergence(
1002
1068
  "new_tmux_endpoint": candidate_endpoint,
1003
1069
  "candidate_source": candidate_source,
1004
1070
  "owner_epoch": owner_epoch.0,
1071
+ "persisted": false,
1072
+ "checked_paths": convergence_checked_paths(team_id.as_str()),
1005
1073
  })),
1006
1074
  false,
1007
1075
  ),
@@ -1017,6 +1085,8 @@ fn apply_endpoint_convergence(
1017
1085
  "reason": reason,
1018
1086
  "candidate_source": candidate_source,
1019
1087
  "owner_epoch": owner_epoch.0,
1088
+ "persisted": false,
1089
+ "checked_paths": convergence_checked_paths(team_id.as_str()),
1020
1090
  "action": format!(
1021
1091
  "old tmux endpoint {old_endpoint} still has this team's session or pane tuple; run team-agent diagnose --json before retrying restart"
1022
1092
  ),
@@ -1292,32 +1362,89 @@ struct LeaderClaimTarget {
1292
1362
  team_id: Option<String>,
1293
1363
  pane_info: Option<PaneInfo>,
1294
1364
  endpoint: Option<String>,
1365
+ scope_authority: Option<String>,
1366
+ authorized_team_workspace: Option<PathBuf>,
1367
+ binding_nonce: Option<String>,
1295
1368
  }
1296
1369
 
1297
1370
  #[derive(Clone)]
1298
1371
  struct ClaimLeaderTargetCandidate {
1299
1372
  info: PaneInfo,
1300
1373
  endpoint: Option<String>,
1301
- source: ClaimLeaderTargetSource,
1302
1374
  }
1303
1375
 
1304
- #[derive(Clone, Copy, PartialEq, Eq)]
1305
- enum ClaimLeaderTargetSource {
1306
- StateRecorded,
1307
- Workspace,
1308
- CurrentTmux,
1309
- 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
+ })
1310
1419
  }
1311
1420
 
1312
- impl ClaimLeaderTargetSource {
1313
- fn priority(self) -> u8 {
1314
- match self {
1315
- Self::StateRecorded => 0,
1316
- Self::Workspace => 1,
1317
- Self::CurrentTmux => 2,
1318
- Self::Default => 3,
1319
- }
1320
- }
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()
1321
1448
  }
1322
1449
 
1323
1450
  fn claim_leader_targets(workspace: &Path, state: &Value) -> Vec<ClaimLeaderTargetCandidate> {
@@ -1333,7 +1460,6 @@ fn claim_leader_targets(workspace: &Path, state: &Value) -> Vec<ClaimLeaderTarge
1333
1460
  .map(|info| ClaimLeaderTargetCandidate {
1334
1461
  info,
1335
1462
  endpoint: resolved_endpoint.clone(),
1336
- source: ClaimLeaderTargetSource::CurrentTmux,
1337
1463
  }),
1338
1464
  );
1339
1465
  }
@@ -1348,7 +1474,6 @@ fn claim_leader_targets(workspace: &Path, state: &Value) -> Vec<ClaimLeaderTarge
1348
1474
  .map(|info| ClaimLeaderTargetCandidate {
1349
1475
  info,
1350
1476
  endpoint: resolved_endpoint.clone(),
1351
- source: ClaimLeaderTargetSource::StateRecorded,
1352
1477
  }),
1353
1478
  );
1354
1479
  }
@@ -1362,7 +1487,6 @@ fn claim_leader_targets(workspace: &Path, state: &Value) -> Vec<ClaimLeaderTarge
1362
1487
  .map(|info| ClaimLeaderTargetCandidate {
1363
1488
  info,
1364
1489
  endpoint: workspace_endpoint.clone(),
1365
- source: ClaimLeaderTargetSource::Workspace,
1366
1490
  }),
1367
1491
  );
1368
1492
  let default_backend = crate::transport_factory::tmux_default_transport();
@@ -1375,21 +1499,16 @@ fn claim_leader_targets(workspace: &Path, state: &Value) -> Vec<ClaimLeaderTarge
1375
1499
  .map(|info| ClaimLeaderTargetCandidate {
1376
1500
  info,
1377
1501
  endpoint: default_endpoint.clone(),
1378
- source: ClaimLeaderTargetSource::Default,
1379
1502
  }),
1380
1503
  );
1381
1504
  targets
1382
1505
  }
1383
1506
 
1384
- fn claim_target_from_pane_info(workspace: &Path, target: &PaneInfo) -> Option<LeaderClaimTarget> {
1507
+ fn claim_target_from_pane_info(target: &PaneInfo) -> Option<LeaderClaimTarget> {
1385
1508
  if !target.active {
1386
1509
  return None;
1387
1510
  }
1388
1511
  let provider = super::attribute_pane_provider(target)?;
1389
- let current_path = target.current_path.as_deref()?;
1390
- if !crate::state::owner_gate::workspace_paths_match(current_path, workspace) {
1391
- return None;
1392
- }
1393
1512
  Some(LeaderClaimTarget {
1394
1513
  provider,
1395
1514
  leader_session_uuid: target_leader_session_uuid(target),
@@ -1400,9 +1519,74 @@ fn claim_target_from_pane_info(workspace: &Path, target: &PaneInfo) -> Option<Le
1400
1519
  .cloned(),
1401
1520
  pane_info: Some(target.clone()),
1402
1521
  endpoint: None,
1522
+ scope_authority: None,
1523
+ authorized_team_workspace: None,
1524
+ binding_nonce: None,
1403
1525
  })
1404
1526
  }
1405
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
+
1406
1590
  fn target_leader_session_uuid(target: &PaneInfo) -> Option<crate::model::ids::LeaderSessionUuid> {
1407
1591
  target
1408
1592
  .leader_env
@@ -1426,7 +1610,7 @@ fn validate_attach_target(
1426
1610
  // a real Codex/Claude/Copilot binary. This is a targeted escape
1427
1611
  // hatch — `Provider::Fake` is not selectable from the user-facing
1428
1612
  // provider list, only wired in test/fixture flows.
1429
- let claim_target = match claim_target_from_pane_info(workspace, target) {
1613
+ let claim_target = match workspace_claim_target_from_pane_info(workspace, target) {
1430
1614
  Some(target) => Some(target),
1431
1615
  None if matches!(requested_provider, Provider::Fake) => None,
1432
1616
  None => return Err("leader_pane_validation_failed"),
@@ -1532,7 +1716,7 @@ impl TargetScanLiveness {
1532
1716
  let live_panes = targets
1533
1717
  .iter()
1534
1718
  .filter_map(|target| {
1535
- let claim_target = claim_target_from_pane_info(workspace, target)?;
1719
+ let claim_target = workspace_claim_target_from_pane_info(workspace, target)?;
1536
1720
  if let Some(owner_uuid) = owner_uuid.as_deref() {
1537
1721
  let target_uuid = claim_target.leader_session_uuid.as_ref()?.as_str();
1538
1722
  if target_uuid != owner_uuid {
@@ -1635,6 +1819,9 @@ fn make_receiver(
1635
1819
  pane_tty: target.as_ref().and_then(|t| t.tty.clone()),
1636
1820
  pane_current_command: target.as_ref().and_then(|t| t.current_command.clone()),
1637
1821
  tmux_socket: crate::tmux_backend::socket_name_from_tmux_env(),
1822
+ scope_authority: None,
1823
+ authorized_team_workspace: None,
1824
+ binding_nonce: None,
1638
1825
  fingerprint: target.as_ref().map(receiver_fingerprint),
1639
1826
  leader_session_uuid: Some(uuid.clone()),
1640
1827
  owner_epoch: Some(epoch),
@@ -1659,6 +1846,132 @@ fn receiver_fingerprint(target: &PaneInfo) -> String {
1659
1846
  )
1660
1847
  }
1661
1848
 
1849
+ fn refresh_already_bound_receiver_observation(
1850
+ state: &mut Value,
1851
+ observed: &PaneInfo,
1852
+ observed_endpoint: Option<&str>,
1853
+ team_key: Option<&str>,
1854
+ claim_target: Option<&LeaderClaimTarget>,
1855
+ ) -> bool {
1856
+ let attached_at = now_ts();
1857
+ let mut refreshed = state
1858
+ .get_mut("leader_receiver")
1859
+ .and_then(Value::as_object_mut)
1860
+ .is_some_and(|receiver| {
1861
+ refresh_receiver_observation(
1862
+ receiver,
1863
+ observed,
1864
+ observed_endpoint,
1865
+ attached_at.as_str(),
1866
+ claim_target,
1867
+ )
1868
+ });
1869
+ if let Some(team_key) = team_key {
1870
+ refreshed |= state
1871
+ .get_mut("teams")
1872
+ .and_then(|teams| teams.get_mut(team_key))
1873
+ .and_then(|entry| entry.get_mut("leader_receiver"))
1874
+ .and_then(Value::as_object_mut)
1875
+ .is_some_and(|receiver| {
1876
+ refresh_receiver_observation(
1877
+ receiver,
1878
+ observed,
1879
+ observed_endpoint,
1880
+ attached_at.as_str(),
1881
+ claim_target,
1882
+ )
1883
+ });
1884
+ }
1885
+ refreshed
1886
+ }
1887
+
1888
+ fn refresh_receiver_observation(
1889
+ receiver: &mut serde_json::Map<String, Value>,
1890
+ observed: &PaneInfo,
1891
+ observed_endpoint: Option<&str>,
1892
+ attached_at: &str,
1893
+ claim_target: Option<&LeaderClaimTarget>,
1894
+ ) -> bool {
1895
+ let before = Value::Object(receiver.clone());
1896
+ receiver.insert(
1897
+ "session_name".to_string(),
1898
+ Value::String(observed.session.as_str().to_string()),
1899
+ );
1900
+ receiver.insert(
1901
+ "window_index".to_string(),
1902
+ observed
1903
+ .window_index
1904
+ .map(|value| Value::String(value.to_string()))
1905
+ .unwrap_or(Value::Null),
1906
+ );
1907
+ receiver.insert(
1908
+ "window_name".to_string(),
1909
+ observed
1910
+ .window_name
1911
+ .as_ref()
1912
+ .map(|value| Value::String(value.as_str().to_string()))
1913
+ .unwrap_or(Value::Null),
1914
+ );
1915
+ receiver.insert(
1916
+ "pane_index".to_string(),
1917
+ observed
1918
+ .pane_index
1919
+ .map(|value| Value::String(value.to_string()))
1920
+ .unwrap_or(Value::Null),
1921
+ );
1922
+ receiver.insert(
1923
+ "pane_tty".to_string(),
1924
+ observed
1925
+ .tty
1926
+ .as_ref()
1927
+ .map(|value| Value::String(value.clone()))
1928
+ .unwrap_or(Value::Null),
1929
+ );
1930
+ receiver.insert(
1931
+ "pane_current_command".to_string(),
1932
+ observed
1933
+ .current_command
1934
+ .as_ref()
1935
+ .map(|value| Value::String(value.clone()))
1936
+ .unwrap_or(Value::Null),
1937
+ );
1938
+ receiver.insert(
1939
+ "fingerprint".to_string(),
1940
+ Value::String(receiver_fingerprint(observed)),
1941
+ );
1942
+ if let Some(endpoint) = observed_endpoint.filter(|value| !value.is_empty()) {
1943
+ receiver.insert(
1944
+ "tmux_socket".to_string(),
1945
+ Value::String(endpoint.to_string()),
1946
+ );
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
+ }
1968
+ receiver.insert(
1969
+ "attached_at".to_string(),
1970
+ Value::String(attached_at.to_string()),
1971
+ );
1972
+ Value::Object(receiver.clone()) != before
1973
+ }
1974
+
1662
1975
  fn make_owner(
1663
1976
  provider: Provider,
1664
1977
  pane: &NonEmptyPaneId,
@@ -2124,6 +2437,22 @@ fn readable_team_snapshot_path(w: &BP, n: &str) -> BF /* B0_DIAGNOSTIC_LEGACY_SN
2124
2437
  mod tests {
2125
2438
  use super::*;
2126
2439
 
2440
+ fn observed_pane() -> PaneInfo {
2441
+ PaneInfo {
2442
+ pane_id: PaneId::new("%7"),
2443
+ session: crate::transport::SessionName::new("new-session"),
2444
+ window_index: Some(3),
2445
+ window_name: Some(crate::transport::WindowName::new("new-window")),
2446
+ pane_index: Some(1),
2447
+ tty: Some("/dev/ttys007".to_string()),
2448
+ current_command: Some("codex".to_string()),
2449
+ current_path: None,
2450
+ active: true,
2451
+ pane_pid: Some(700),
2452
+ leader_env: std::collections::BTreeMap::new(),
2453
+ }
2454
+ }
2455
+
2127
2456
  #[test]
2128
2457
  fn attribute_command_provider_recognizes_copilot() {
2129
2458
  assert_eq!(
@@ -2135,4 +2464,128 @@ mod tests {
2135
2464
  Some(Provider::Copilot)
2136
2465
  );
2137
2466
  }
2467
+
2468
+ #[test]
2469
+ fn already_bound_refresh_updates_observations_without_reclaiming() {
2470
+ let mut state = json!({
2471
+ "leader_receiver": {
2472
+ "status": "attached",
2473
+ "pane_id": "%7",
2474
+ "owner_epoch": 10,
2475
+ "owner_agent_id": "leader",
2476
+ "session_name": "old-session",
2477
+ "pane_tty": "/dev/old",
2478
+ "attached_at": "2026-01-01T00:00:00Z"
2479
+ },
2480
+ "teams": {"team-a": {"leader_receiver": {
2481
+ "status": "attached",
2482
+ "pane_id": "%7",
2483
+ "owner_epoch": 10,
2484
+ "owner_agent_id": "leader",
2485
+ "session_name": "old-session",
2486
+ "pane_tty": "/dev/old",
2487
+ "attached_at": "2026-01-01T00:00:00Z"
2488
+ }}}
2489
+ });
2490
+
2491
+ assert!(refresh_already_bound_receiver_observation(
2492
+ &mut state,
2493
+ &observed_pane(),
2494
+ Some("/tmp/team-agent-live.sock"),
2495
+ Some("team-a"),
2496
+ None,
2497
+ ));
2498
+ let receiver = &state["leader_receiver"];
2499
+ assert_eq!(receiver["owner_epoch"], json!(10));
2500
+ assert_eq!(receiver["owner_agent_id"], json!("leader"));
2501
+ assert_eq!(receiver["session_name"], json!("new-session"));
2502
+ assert_eq!(receiver["window_name"], json!("new-window"));
2503
+ assert_eq!(receiver["pane_tty"], json!("/dev/ttys007"));
2504
+ assert_eq!(receiver["tmux_socket"], json!("/tmp/team-agent-live.sock"));
2505
+ assert_ne!(receiver["attached_at"], json!("2026-01-01T00:00:00Z"));
2506
+ assert_eq!(
2507
+ state["teams"]["team-a"]["leader_receiver"]["session_name"],
2508
+ json!("new-session")
2509
+ );
2510
+ assert_eq!(
2511
+ state["teams"]["team-a"]["leader_receiver"]["owner_epoch"],
2512
+ json!(10)
2513
+ );
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
+ }
2138
2591
  }