@team-agent/installer 0.5.44 → 0.5.46

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 (28) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/emit.rs +44 -33
  4. package/crates/team-agent/src/cli/mod.rs +6 -0
  5. package/crates/team-agent/src/cli/named_address.rs +206 -4
  6. package/crates/team-agent/src/cli/send.rs +105 -2
  7. package/crates/team-agent/src/cli/spec.rs +1 -1
  8. package/crates/team-agent/src/cli/status_port.rs +70 -2
  9. package/crates/team-agent/src/cli/tests/run_delegation.rs +7 -4
  10. package/crates/team-agent/src/coordinator/steps/abnormal.rs +14 -22
  11. package/crates/team-agent/src/lifecycle/restart/agent.rs +103 -27
  12. package/crates/team-agent/src/lifecycle/restart/common.rs +136 -0
  13. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +168 -0
  14. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +7 -7
  15. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +1 -1
  16. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +3 -0
  17. package/crates/team-agent/src/lifecycle/types.rs +3 -0
  18. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +10 -0
  19. package/crates/team-agent/src/mcp_server/tools.rs +67 -8
  20. package/crates/team-agent/src/model/mod.rs +6 -0
  21. package/crates/team-agent/src/model/name_similarity.rs +266 -0
  22. package/crates/team-agent/src/provider/session/capture.rs +113 -22
  23. package/crates/team-agent/src/provider/session_scan/codex.rs +51 -4
  24. package/crates/team-agent/src/tmux_backend/tests.rs +116 -10
  25. package/crates/team-agent/src/tmux_backend.rs +5 -16
  26. package/crates/team-agent/src/transport/tests/wire.rs +6 -0
  27. package/crates/team-agent/src/transport.rs +8 -2
  28. package/package.json +4 -4
@@ -8,6 +8,7 @@ use crate::provider::{
8
8
  CaptureSessionContext, CapturedSession, CapturedSessionCandidate, Provider, ProviderAdapter,
9
9
  ProviderError, SessionId,
10
10
  };
11
+ use crate::state::identity_keys::SessionAttributionKey;
11
12
 
12
13
  pub const SESSION_CAPTURE_CONVERGENCE_DEADLINE_MS: u64 = 12_000;
13
14
  pub const SESSION_CAPTURE_CONVERGENCE_POLL_MS: u64 = 250;
@@ -638,6 +639,7 @@ pub fn incomplete_interacted_resumable_agent_ids(state: &Value) -> Vec<String> {
638
639
  struct PendingSessionCapture {
639
640
  agent_id: String,
640
641
  provider: Provider,
642
+ team_key: String,
641
643
  context: CaptureSessionContext,
642
644
  }
643
645
 
@@ -673,6 +675,13 @@ where
673
675
  Some(PendingSessionCapture {
674
676
  agent_id: agent_id.to_string(),
675
677
  provider,
678
+ team_key: agent
679
+ .get("owner_team_id")
680
+ .or_else(|| agent.get("team_key"))
681
+ .and_then(Value::as_str)
682
+ .filter(|s| !s.is_empty())
683
+ .unwrap_or("current")
684
+ .to_string(),
676
685
  context: CaptureSessionContext {
677
686
  agent_id: agent_id.to_string(),
678
687
  spawn_cwd: PathBuf::from(spawn_cwd),
@@ -883,14 +892,14 @@ fn allocate_session_candidates(
883
892
  .as_ref()
884
893
  .is_some_and(|sid| sid.as_str() == expected.as_str())
885
894
  })
886
- .filter(|candidate| !candidate_keys_collide(candidate, claimed))
895
+ .filter(|candidate| !candidate_keys_collide(item, candidate, claimed))
887
896
  .collect();
888
897
  // Uniqueness requirement: only assign when the expected id maps to
889
898
  // exactly one available candidate. Multiple matches or a colliding
890
899
  // single match leave the agent for the ambiguity path below.
891
900
  if exact_matches.len() == 1 {
892
901
  let candidate = exact_matches[0].clone();
893
- claimed.extend(captured_provider_session_keys(&candidate.captured));
902
+ claimed.extend(captured_provider_session_keys(item, &candidate.captured));
894
903
  assignments.insert(item.agent_id.clone(), candidate);
895
904
  }
896
905
  }
@@ -903,8 +912,9 @@ fn allocate_session_candidates(
903
912
  candidates_by_agent.get(&item.agent_id),
904
913
  claimed,
905
914
  CandidateMatchKind::PositiveAgentId,
915
+ item,
906
916
  ) {
907
- claimed.extend(captured_provider_session_keys(&candidate.captured));
917
+ claimed.extend(captured_provider_session_keys(item, &candidate.captured));
908
918
  assignments.insert(item.agent_id.clone(), candidate);
909
919
  }
910
920
  }
@@ -916,8 +926,9 @@ fn allocate_session_candidates(
916
926
  candidates_by_agent.get(&item.agent_id),
917
927
  claimed,
918
928
  CandidateMatchKind::PathAgentId,
929
+ item,
919
930
  ) {
920
- claimed.extend(captured_provider_session_keys(&candidate.captured));
931
+ claimed.extend(captured_provider_session_keys(item, &candidate.captured));
921
932
  assignments.insert(item.agent_id.clone(), candidate);
922
933
  }
923
934
  }
@@ -953,9 +964,10 @@ fn allocate_session_candidates(
953
964
  candidates_by_agent.get(&item.agent_id),
954
965
  claimed,
955
966
  CandidateMatchKind::Any,
967
+ item,
956
968
  ) {
957
969
  Some(candidate) => {
958
- claimed.extend(captured_provider_session_keys(&candidate.captured));
970
+ claimed.extend(captured_provider_session_keys(item, &candidate.captured));
959
971
  assignments.insert(item.agent_id.clone(), candidate);
960
972
  }
961
973
  None => {
@@ -995,11 +1007,14 @@ fn allocate_global_one_to_one(
995
1007
  let Some(agent_candidates) = candidates_by_agent.get(agent_id) else {
996
1008
  return;
997
1009
  };
1010
+ let Some(owner) = pending.iter().find(|item| item.agent_id == *agent_id) else {
1011
+ continue;
1012
+ };
998
1013
  for candidate in agent_candidates {
999
- if candidate_keys_collide(candidate, claimed) {
1014
+ if candidate_keys_collide(owner, candidate, claimed) {
1000
1015
  continue;
1001
1016
  }
1002
- let key = candidate_key(candidate);
1017
+ let key = candidate_key(owner, candidate);
1003
1018
  if key.is_empty() {
1004
1019
  continue;
1005
1020
  }
@@ -1010,7 +1025,9 @@ fn allocate_global_one_to_one(
1010
1025
  return;
1011
1026
  }
1012
1027
  for (agent_id, candidate) in remaining_agents.into_iter().zip(candidates.into_values()) {
1013
- claimed.extend(captured_provider_session_keys(&candidate.captured));
1028
+ if let Some(item) = pending.iter().find(|item| item.agent_id == agent_id) {
1029
+ claimed.extend(captured_provider_session_keys(item, &candidate.captured));
1030
+ }
1014
1031
  assignments.insert(agent_id, candidate);
1015
1032
  }
1016
1033
  }
@@ -1035,6 +1052,7 @@ fn unique_available_candidate(
1035
1052
  candidates: Option<&Vec<CapturedSessionCandidate>>,
1036
1053
  claimed: &BTreeSet<String>,
1037
1054
  match_kind: CandidateMatchKind,
1055
+ owner: &PendingSessionCapture,
1038
1056
  ) -> Option<CapturedSessionCandidate> {
1039
1057
  let matches = candidates?
1040
1058
  .iter()
@@ -1043,7 +1061,7 @@ fn unique_available_candidate(
1043
1061
  CandidateMatchKind::PathAgentId => candidate.agent_path_match,
1044
1062
  CandidateMatchKind::Any => true,
1045
1063
  })
1046
- .filter(|candidate| !candidate_keys_collide(candidate, claimed))
1064
+ .filter(|candidate| !candidate_keys_collide(owner, candidate, claimed))
1047
1065
  .cloned()
1048
1066
  .collect::<Vec<_>>();
1049
1067
  if matches.len() == 1 {
@@ -1061,16 +1079,17 @@ enum CandidateMatchKind {
1061
1079
  }
1062
1080
 
1063
1081
  fn candidate_keys_collide(
1082
+ owner: &PendingSessionCapture,
1064
1083
  candidate: &CapturedSessionCandidate,
1065
1084
  claimed: &BTreeSet<String>,
1066
1085
  ) -> bool {
1067
- captured_provider_session_keys(&candidate.captured)
1086
+ captured_provider_session_keys(owner, &candidate.captured)
1068
1087
  .iter()
1069
1088
  .any(|key| claimed.contains(key))
1070
1089
  }
1071
1090
 
1072
- fn candidate_key(candidate: &CapturedSessionCandidate) -> String {
1073
- captured_provider_session_keys(&candidate.captured)
1091
+ fn candidate_key(owner: &PendingSessionCapture, candidate: &CapturedSessionCandidate) -> String {
1092
+ captured_provider_session_keys(owner, &candidate.captured)
1074
1093
  .into_iter()
1075
1094
  .collect::<Vec<_>>()
1076
1095
  .join("|")
@@ -1130,12 +1149,13 @@ fn claimed_provider_session_keys(
1130
1149
  pending_ids: &BTreeSet<String>,
1131
1150
  ) -> BTreeSet<String> {
1132
1151
  let mut keys = BTreeSet::new();
1152
+ let team_key = crate::state::projection::team_state_key(state);
1133
1153
  // 1. Non-pending worker sessions (existing behaviour).
1134
1154
  for (agent_id, agent) in agents {
1135
1155
  if pending_ids.contains(agent_id) {
1136
1156
  continue;
1137
1157
  }
1138
- push_provider_session_keys(&mut keys, agent);
1158
+ push_provider_session_keys(&mut keys, &team_key, agent_id, agent);
1139
1159
  }
1140
1160
  // 2. P0 (lane-046-capture-gap): leader anchor sessions. The leader's
1141
1161
  // own provider transcript must never be attributed to a worker. Scan
@@ -1152,14 +1172,39 @@ fn claimed_provider_session_keys(
1152
1172
  keys
1153
1173
  }
1154
1174
 
1155
- fn push_provider_session_keys(keys: &mut BTreeSet<String>, value: &Value) {
1175
+ fn push_provider_session_keys(
1176
+ keys: &mut BTreeSet<String>,
1177
+ fallback_team_key: &str,
1178
+ fallback_agent_id: &str,
1179
+ value: &Value,
1180
+ ) {
1181
+ let provider = value
1182
+ .get("provider")
1183
+ .and_then(Value::as_str)
1184
+ .and_then(parse_provider)
1185
+ .unwrap_or(Provider::Fake);
1186
+ let team_key = value
1187
+ .get("owner_team_id")
1188
+ .or_else(|| value.get("team_key"))
1189
+ .and_then(Value::as_str)
1190
+ .filter(|s| !s.is_empty())
1191
+ .unwrap_or(fallback_team_key);
1192
+ let agent_id = value
1193
+ .get("agent_id")
1194
+ .and_then(Value::as_str)
1195
+ .filter(|s| !s.is_empty())
1196
+ .unwrap_or(fallback_agent_id);
1156
1197
  for field in ["session_id", "provider_session_id"] {
1157
1198
  if let Some(session_id) = value
1158
1199
  .get(field)
1159
1200
  .and_then(Value::as_str)
1160
1201
  .filter(|s| !s.is_empty())
1161
1202
  {
1162
- keys.insert(format!("session:{session_id}"));
1203
+ if let Some(key) = SessionAttributionKey::new(provider, team_key, agent_id, session_id)
1204
+ {
1205
+ keys.insert(session_attribution_key_string(&key));
1206
+ }
1207
+ keys.insert(global_session_attribution_key_string(session_id));
1163
1208
  }
1164
1209
  }
1165
1210
  for field in ["rollout_path", "transcript_path"] {
@@ -1168,7 +1213,10 @@ fn push_provider_session_keys(keys: &mut BTreeSet<String>, value: &Value) {
1168
1213
  .and_then(Value::as_str)
1169
1214
  .filter(|s| !s.is_empty())
1170
1215
  {
1171
- keys.insert(format!("rollout:{path}"));
1216
+ keys.insert(transcript_attribution_key_string(
1217
+ provider, team_key, agent_id, path,
1218
+ ));
1219
+ keys.insert(global_transcript_attribution_key_string(path));
1172
1220
  }
1173
1221
  }
1174
1222
  }
@@ -1176,25 +1224,68 @@ fn push_provider_session_keys(keys: &mut BTreeSet<String>, value: &Value) {
1176
1224
  fn push_leader_provider_session_keys(keys: &mut BTreeSet<String>, scope: &Value) {
1177
1225
  for anchor in ["leader_receiver", "team_owner"] {
1178
1226
  if let Some(node) = scope.get(anchor) {
1179
- push_provider_session_keys(keys, node);
1227
+ push_provider_session_keys(keys, "leader", "leader", node);
1180
1228
  }
1181
1229
  }
1182
1230
  }
1183
1231
 
1184
- fn captured_provider_session_keys(captured: &CapturedSession) -> BTreeSet<String> {
1232
+ fn captured_provider_session_keys(
1233
+ owner: &PendingSessionCapture,
1234
+ captured: &CapturedSession,
1235
+ ) -> BTreeSet<String> {
1185
1236
  let mut keys = BTreeSet::new();
1237
+ let team_key = owner.team_key.as_str();
1186
1238
  if let Some(session_id) = &captured.session_id {
1187
- keys.insert(format!("session:{}", session_id.as_str()));
1239
+ if let Some(key) = SessionAttributionKey::new(
1240
+ owner.provider,
1241
+ team_key,
1242
+ owner.agent_id.as_str(),
1243
+ session_id.as_str(),
1244
+ ) {
1245
+ keys.insert(session_attribution_key_string(&key));
1246
+ }
1247
+ keys.insert(global_session_attribution_key_string(session_id.as_str()));
1188
1248
  }
1189
1249
  if let Some(rollout_path) = &captured.rollout_path {
1190
- keys.insert(format!(
1191
- "rollout:{}",
1192
- rollout_path.as_path().to_string_lossy()
1250
+ let path = rollout_path.as_path().to_string_lossy();
1251
+ keys.insert(transcript_attribution_key_string(
1252
+ owner.provider,
1253
+ team_key,
1254
+ owner.agent_id.as_str(),
1255
+ &path,
1193
1256
  ));
1257
+ keys.insert(global_transcript_attribution_key_string(&path));
1194
1258
  }
1195
1259
  keys
1196
1260
  }
1197
1261
 
1262
+ fn session_attribution_key_string(key: &SessionAttributionKey) -> String {
1263
+ format!(
1264
+ "SessionAttributionKey(provider={:?},team={},agent={},session={})",
1265
+ key.provider(),
1266
+ key.team_key(),
1267
+ key.agent_id(),
1268
+ key.session_id()
1269
+ )
1270
+ }
1271
+
1272
+ fn transcript_attribution_key_string(
1273
+ provider: Provider,
1274
+ team_key: &str,
1275
+ agent_id: &str,
1276
+ path: &str,
1277
+ ) -> String {
1278
+ format!("TranscriptAttributionKey(provider={provider:?},team={team_key},agent={agent_id},path={path})")
1279
+ }
1280
+
1281
+ fn global_session_attribution_key_string(session_id: &str) -> String {
1282
+ format!("GlobalSessionAttributionKey(session={session_id})")
1283
+ }
1284
+
1285
+ fn global_transcript_attribution_key_string(path: &str) -> String {
1286
+ format!("GlobalTranscriptAttributionKey(path={path})")
1287
+ }
1288
+
1198
1289
  #[cfg(test)]
1199
1290
  pub(crate) mod test_support {
1200
1291
  use super::*;
@@ -14,10 +14,13 @@ pub(super) fn apply_spawned_at_filter(
14
14
  Some(p) => p.as_path(),
15
15
  None => return false,
16
16
  };
17
- std::fs::metadata(path)
18
- .and_then(|meta| meta.modified())
19
- .map(|mtime| mtime >= cutoff)
20
- .unwrap_or(false)
17
+ match codex_rollout_created_at(path) {
18
+ Some(created_at) => created_at >= cutoff,
19
+ None => std::fs::metadata(path)
20
+ .and_then(|meta| meta.modified())
21
+ .map(|mtime| mtime >= cutoff)
22
+ .unwrap_or(false),
23
+ }
21
24
  });
22
25
  }
23
26
 
@@ -27,6 +30,50 @@ pub(super) fn parse_spawned_at(raw: &str) -> Option<std::time::SystemTime> {
27
30
  .map(|dt| std::time::SystemTime::from(dt.with_timezone(&chrono::Utc)))
28
31
  }
29
32
 
33
+ fn codex_rollout_created_at(path: &std::path::Path) -> Option<std::time::SystemTime> {
34
+ created_at_from_rollout_head(path).or_else(|| created_at_from_rollout_filename(path))
35
+ }
36
+
37
+ fn created_at_from_rollout_head(path: &std::path::Path) -> Option<std::time::SystemTime> {
38
+ let text = super::common::read_head_text(path, super::common::CAPTURE_HEAD_BYTES).ok()?;
39
+ super::common::parse_session_records(&text)
40
+ .iter()
41
+ .find_map(record_created_at)
42
+ .and_then(|raw| parse_spawned_at(&raw))
43
+ }
44
+
45
+ fn record_created_at(record: &serde_json::Value) -> Option<String> {
46
+ record
47
+ .get("created_at")
48
+ .and_then(serde_json::Value::as_str)
49
+ .or_else(|| {
50
+ record
51
+ .get("session_meta")
52
+ .and_then(|v| v.get("payload"))
53
+ .or_else(|| record.get("payload"))
54
+ .and_then(|v| v.get("created_at"))
55
+ .and_then(serde_json::Value::as_str)
56
+ })
57
+ .map(ToString::to_string)
58
+ }
59
+
60
+ fn created_at_from_rollout_filename(path: &std::path::Path) -> Option<std::time::SystemTime> {
61
+ let name = path.file_name()?.to_str()?;
62
+ let start = name.find("rollout-")? + "rollout-".len();
63
+ let stamp = name.get(start..start + 19)?;
64
+ if stamp.as_bytes().get(10).copied() != Some(b'T') {
65
+ return None;
66
+ }
67
+ let raw = format!(
68
+ "{}T{}:{}:{}+00:00",
69
+ &stamp[0..10],
70
+ &stamp[11..13],
71
+ &stamp[14..16],
72
+ &stamp[17..19]
73
+ );
74
+ parse_spawned_at(&raw)
75
+ }
76
+
30
77
  #[cfg(test)]
31
78
  mod tests {
32
79
  use super::*;
@@ -39,6 +39,36 @@ struct MockCommandRunner {
39
39
  default: MockResp,
40
40
  }
41
41
 
42
+ /// Models the real build-before-destroy overlap: the spawn command creates `%new`, while a
43
+ /// name-based lookup still resolves the same-named old window `%old`.
44
+ struct SameNameSpawnRunner {
45
+ recorded: RecordedArgv,
46
+ }
47
+
48
+ impl CommandRunner for SameNameSpawnRunner {
49
+ fn run(&self, argv: &[String]) -> Result<CommandOutput, std::io::Error> {
50
+ self.recorded.lock().unwrap().push(argv.to_vec());
51
+ let output = match argv.get(1).map(String::as_str) {
52
+ Some("new-window") => ok("%new\n"),
53
+ Some("display-message") => ok("%old\n"),
54
+ Some("list-panes") => ok(
55
+ "%old\tteamsess\t0\tdeveloper\t0\t/dev/ttys001\tnode\t1\t/work/dir\t1\t0\t101\n\
56
+ %new\tteamsess\t1\tdeveloper\t0\t/dev/ttys002\tnode\t1\t/work/dir\t1\t0\t202\n",
57
+ ),
58
+ _ => fail(64, "unexpected scripted tmux command"),
59
+ };
60
+ Ok(output)
61
+ }
62
+
63
+ fn run_with_stdin(
64
+ &self,
65
+ argv: &[String],
66
+ _stdin: &str,
67
+ ) -> Result<CommandOutput, std::io::Error> {
68
+ self.run(argv)
69
+ }
70
+ }
71
+
42
72
  impl CommandRunner for MockCommandRunner {
43
73
  fn run(&self, argv: &[String]) -> Result<CommandOutput, std::io::Error> {
44
74
  self.recorded.lock().unwrap().push(argv.to_vec());
@@ -366,8 +396,7 @@ fn spawn_first_frames_via_new_session_builder_and_parses_pane_id() {
366
396
  let (be, rec) = backend_with(
367
397
  MockResp::Out(ok("")),
368
398
  vec![
369
- MockResp::Out(ok("")),
370
- MockResp::Out(ok("%3")),
399
+ MockResp::Out(ok("%3\n")),
371
400
  MockResp::Out(ok(pane_inventory)),
372
401
  ],
373
402
  );
@@ -383,7 +412,8 @@ fn spawn_first_frames_via_new_session_builder_and_parses_pane_id() {
383
412
  &env,
384
413
  )
385
414
  .expect("spawn_first");
386
- let argv = rec.lock().unwrap()[0].clone();
415
+ let calls = rec.lock().unwrap().clone();
416
+ let argv = calls[0].clone();
387
417
  let cmd = argv.last().expect("the sh -lc command string").clone();
388
418
  assert_eq!(
389
419
  argv,
@@ -400,6 +430,12 @@ fn spawn_first_frames_via_new_session_builder_and_parses_pane_id() {
400
430
  "SpawnResult.pane_id must parse from the tmux output"
401
431
  );
402
432
  assert_eq!(result.child_pid, Some(123));
433
+ assert!(
434
+ calls
435
+ .iter()
436
+ .all(|call| call.get(1).is_none_or(|arg| arg != "display-message")),
437
+ "spawn_first identity must come from new-session stdout, never a display-message lookup; calls={calls:?}"
438
+ );
403
439
  }
404
440
 
405
441
  #[test]
@@ -408,8 +444,7 @@ fn spawn_into_frames_via_new_window_builder() {
408
444
  let (be, rec) = backend_with(
409
445
  MockResp::Out(ok("")),
410
446
  vec![
411
- MockResp::Out(ok("")),
412
- MockResp::Out(ok("%4")),
447
+ MockResp::Out(ok("%4\n")),
413
448
  MockResp::Out(ok(pane_inventory)),
414
449
  ],
415
450
  );
@@ -424,7 +459,8 @@ fn spawn_into_frames_via_new_window_builder() {
424
459
  &BTreeMap::new(),
425
460
  )
426
461
  .expect("spawn_into");
427
- let argv = rec.lock().unwrap()[0].clone();
462
+ let calls = rec.lock().unwrap().clone();
463
+ let argv = calls[0].clone();
428
464
  let cmd = argv.last().expect("the sh -lc command string").clone();
429
465
  assert_eq!(
430
466
  argv,
@@ -432,16 +468,58 @@ fn spawn_into_frames_via_new_window_builder() {
432
468
  "spawn_into must frame via tmux_spawn_argv first=false (new-window -t <s> -n <w> sh -lc <cmd>)"
433
469
  );
434
470
  assert_eq!(result.pane_id.as_str(), "%4");
471
+ assert!(
472
+ calls
473
+ .iter()
474
+ .all(|call| call.get(1).is_none_or(|arg| arg != "display-message")),
475
+ "spawn_into identity must come from new-window stdout, never a display-message lookup; calls={calls:?}"
476
+ );
477
+ }
478
+
479
+ #[test]
480
+ fn spawn_into_same_named_replacement_returns_identity_created_by_spawn_command() {
481
+ let recorded = Arc::new(Mutex::new(Vec::new()));
482
+ let be = TmuxBackend::with_runner(Box::new(SameNameSpawnRunner {
483
+ recorded: Arc::clone(&recorded),
484
+ }));
485
+
486
+ let result = be
487
+ .spawn_into(
488
+ &SessionName::new("teamsess"),
489
+ &WindowName::new("developer"),
490
+ &svec(&["provider-bin"]),
491
+ Path::new("/work/dir"),
492
+ &BTreeMap::new(),
493
+ )
494
+ .expect("same-name replacement spawn");
495
+ let calls = recorded.lock().unwrap().clone();
496
+ let name_based_identity_lookup = calls.iter().any(|call| {
497
+ call.get(1).is_some_and(|arg| arg == "display-message")
498
+ && call
499
+ .iter()
500
+ .position(|arg| arg == "-t")
501
+ .and_then(|index| call.get(index + 1))
502
+ .is_some_and(|target| target == "teamsess:developer")
503
+ });
504
+
505
+ assert_eq!(
506
+ result.pane_id.as_str(),
507
+ "%new",
508
+ "spawn_into must return the pane identity atomically emitted by this new-window command, not the same-named old window; calls={calls:?}"
509
+ );
510
+ assert!(
511
+ !name_based_identity_lookup,
512
+ "spawn identity must not be re-derived through ambiguous session:window display-message; calls={calls:?}"
513
+ );
435
514
  }
436
515
 
437
516
  #[test]
438
- fn spawn_with_command_refuses_display_message_pane_owned_by_other_window() {
517
+ fn spawn_with_command_refuses_spawn_pane_owned_by_other_window() {
439
518
  let pane_inventory = "%5\tteamsess\t1\tw2\t0\t/dev/ttys005\tnode\t1\t/work/dir\t1\t0\t125\n";
440
519
  let (be, _rec) = backend_with(
441
520
  MockResp::Out(ok("")),
442
521
  vec![
443
- MockResp::Out(ok("")),
444
- MockResp::Out(ok("%5")),
522
+ MockResp::Out(ok("%5\n")),
445
523
  MockResp::Out(ok(pane_inventory)),
446
524
  ],
447
525
  );
@@ -453,7 +531,7 @@ fn spawn_with_command_refuses_display_message_pane_owned_by_other_window() {
453
531
  Path::new("/work/dir"),
454
532
  &BTreeMap::new(),
455
533
  )
456
- .expect_err("display-message fallback to w2 pane must fail closed");
534
+ .expect_err("spawn stdout pane owned by w2 must fail closed");
457
535
  let msg = err.to_string();
458
536
  assert!(
459
537
  msg.contains("requested=teamsess:w1")
@@ -463,6 +541,34 @@ fn spawn_with_command_refuses_display_message_pane_owned_by_other_window() {
463
541
  );
464
542
  }
465
543
 
544
+ #[test]
545
+ fn spawn_with_command_empty_stdout_fails_closed_without_identity_fallback() {
546
+ let (be, rec) = backend_with(MockResp::Out(ok("")), vec![MockResp::Out(ok(""))]);
547
+ let err = be
548
+ .spawn_into(
549
+ &SessionName::new("teamsess"),
550
+ &WindowName::new("developer"),
551
+ &svec(&["provider-bin"]),
552
+ Path::new("/work/dir"),
553
+ &BTreeMap::new(),
554
+ )
555
+ .expect_err("empty new-window stdout must fail closed");
556
+ let msg = err.to_string().to_ascii_lowercase();
557
+ let calls = rec.lock().unwrap().clone();
558
+ let used_identity_fallback = calls
559
+ .iter()
560
+ .any(|call| call.get(1).is_some_and(|arg| arg == "display-message"));
561
+
562
+ assert!(
563
+ msg.contains("spawn") && (msg.contains("empty") || msg.contains("no pane id")),
564
+ "empty spawn stdout error must name the missing spawn identity; error={err}; calls={calls:?}"
565
+ );
566
+ assert!(
567
+ !used_identity_fallback,
568
+ "empty spawn stdout must not fall back to ambiguous display-message identity lookup; calls={calls:?}"
569
+ );
570
+ }
571
+
466
572
  #[test]
467
573
  fn spawn_split_selects_even_horizontal_not_tiled() {
468
574
  let (be, rec) = backend_with(
@@ -765,25 +765,14 @@ impl TmuxBackend {
765
765
  first: bool,
766
766
  ) -> Result<SpawnResult, TransportError> {
767
767
  let spawn_argv = tmux_spawn_argv(session, window, command, first);
768
- self.run_spawn(&spawn_argv)?;
769
- let pane_argv = vec![
770
- "tmux".to_string(),
771
- "display-message".to_string(),
772
- "-p".to_string(),
773
- "-t".to_string(),
774
- format!("{}:{}", session.as_str(), window.as_str()),
775
- "#{pane_id}".to_string(),
776
- ];
777
- let output = self.run_spawn(&pane_argv)?;
768
+ let output = self.run_spawn(&spawn_argv)?;
778
769
  let pane = output.stdout.trim();
779
- // T3-5 (harvest §1): never fabricate a `%0` pane id on an empty reply — a fake
780
- // pane id mis-addresses every later inject/capture/kill. Surface the miss.
781
770
  if pane.is_empty() {
782
771
  return Err(TransportError::Subprocess {
783
- argv: pane_argv,
772
+ argv: spawn_argv,
784
773
  code: output.code,
785
774
  stderr: format!(
786
- "tmux display-message returned no pane id for {}:{}",
775
+ "tmux spawn returned no pane id for {}:{}",
787
776
  session.as_str(),
788
777
  window.as_str()
789
778
  ),
@@ -822,10 +811,10 @@ impl TmuxBackend {
822
811
  })
823
812
  .unwrap_or_else(|| "<missing-from-list-targets>".to_string());
824
813
  Err(TransportError::Subprocess {
825
- argv: pane_argv,
814
+ argv: spawn_argv,
826
815
  code: output.code,
827
816
  stderr: format!(
828
- "tmux spawn pane ownership mismatch: requested={}:{} observed_pane={} observed={}",
817
+ "tmux spawn pane identity mismatch: requested={}:{} observed_pane={} observed={}",
829
818
  session.as_str(),
830
819
  window.as_str(),
831
820
  pane_id.as_str(),
@@ -238,6 +238,9 @@ fn spawn_first_returns_stable_addressable_target_then_reachable() {
238
238
  "tmux",
239
239
  "new-session",
240
240
  "-d",
241
+ "-P",
242
+ "-F",
243
+ "#{pane_id}",
241
244
  "-s",
242
245
  "team-sess",
243
246
  "-n",
@@ -287,6 +290,9 @@ fn spawn_into_then_list_targets_enumerates_it() {
287
290
  "tmux",
288
291
  "new-window",
289
292
  "-d",
293
+ "-P",
294
+ "-F",
295
+ "#{pane_id}",
290
296
  "-t",
291
297
  "team-sess",
292
298
  "-n",
@@ -918,8 +918,8 @@ pub fn tmux_query_argv(pane: &PaneId, field: PaneField) -> Vec<String> {
918
918
 
919
919
  /// spawn argv:首个 session 用 new-session,后续 worker 用 new-window。
920
920
  /// golden(terminal.py:44-45 / runtime.py:1019-1020):
921
- /// first → `new-session -d -s <s> -n <w> sh -lc <cmd>`
922
- /// into → `new-window -t <s> -n <w> sh -lc <cmd>`
921
+ /// first → `new-session -d -P -F #{pane_id} -s <s> -n <w> sh -lc <cmd>`
922
+ /// into → `new-window -d -P -F #{pane_id} -t <s> -n <w> sh -lc <cmd>`
923
923
  /// `argv` 被组装成单条 `sh -lc` 命令字符串(provider 启动行)。
924
924
  pub fn tmux_spawn_argv(
925
925
  session: &SessionName,
@@ -932,6 +932,9 @@ pub fn tmux_spawn_argv(
932
932
  "tmux".to_string(),
933
933
  "new-session".to_string(),
934
934
  "-d".to_string(),
935
+ "-P".to_string(),
936
+ "-F".to_string(),
937
+ "#{pane_id}".to_string(),
935
938
  "-s".to_string(),
936
939
  session.as_str().to_string(),
937
940
  "-n".to_string(),
@@ -952,6 +955,9 @@ pub fn tmux_spawn_argv(
952
955
  "tmux".to_string(),
953
956
  "new-window".to_string(),
954
957
  "-d".to_string(),
958
+ "-P".to_string(),
959
+ "-F".to_string(),
960
+ "#{pane_id}".to_string(),
955
961
  "-t".to_string(),
956
962
  session.as_str().to_string(),
957
963
  "-n".to_string(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.44",
3
+ "version": "0.5.46",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.5.44",
24
- "@team-agent/cli-darwin-x64": "0.5.44",
25
- "@team-agent/cli-linux-x64": "0.5.44"
23
+ "@team-agent/cli-darwin-arm64": "0.5.46",
24
+ "@team-agent/cli-darwin-x64": "0.5.46",
25
+ "@team-agent/cli-linux-x64": "0.5.46"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",