@team-agent/installer 0.3.24 → 0.3.26

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 (33) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/emit.rs +1 -0
  4. package/crates/team-agent/src/cli/mod.rs +193 -7
  5. package/crates/team-agent/src/cli/send.rs +106 -0
  6. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
  7. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +270 -13
  8. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  9. package/crates/team-agent/src/cli/types.rs +7 -0
  10. package/crates/team-agent/src/coordinator/tests/energy.rs +1 -0
  11. package/crates/team-agent/src/coordinator/tests/health_sync.rs +1 -0
  12. package/crates/team-agent/src/coordinator/tests/spine.rs +1 -0
  13. package/crates/team-agent/src/coordinator/tests/takeover.rs +1 -0
  14. package/crates/team-agent/src/coordinator/tick.rs +55 -7
  15. package/crates/team-agent/src/leader/lease.rs +57 -0
  16. package/crates/team-agent/src/leader/start.rs +1 -0
  17. package/crates/team-agent/src/lifecycle/launch.rs +217 -19
  18. package/crates/team-agent/src/lifecycle/restart/agent.rs +116 -0
  19. package/crates/team-agent/src/lifecycle/restart/common.rs +66 -1
  20. package/crates/team-agent/src/lifecycle/restart.rs +19 -2
  21. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +994 -0
  22. package/crates/team-agent/src/messaging/delivery.rs +91 -1
  23. package/crates/team-agent/src/messaging/helpers.rs +64 -24
  24. package/crates/team-agent/src/messaging/tests/runtime.rs +2 -0
  25. package/crates/team-agent/src/messaging/tests/spine.rs +158 -4
  26. package/crates/team-agent/src/tmux_backend/tests.rs +414 -2
  27. package/crates/team-agent/src/tmux_backend.rs +366 -2
  28. package/crates/team-agent/src/transport/test_support.rs +1 -0
  29. package/crates/team-agent/src/transport/tests/mod.rs +1 -0
  30. package/crates/team-agent/src/transport/tests/wire.rs +2 -1
  31. package/crates/team-agent/src/transport.rs +103 -1
  32. package/npm/install.mjs +312 -39
  33. package/package.json +4 -4
@@ -642,9 +642,15 @@ impl TmuxBackend {
642
642
  ) -> Result<SpawnResult, TransportError> {
643
643
  let command = shell_command(argv, cwd, env, env_unset);
644
644
  let target = format!("{}:{}", session.as_str(), window.as_str());
645
+ // E53 (0.3.26, adaptive layout same-session tabs): `-d` prevents the
646
+ // new split pane from stealing focus from the leader's active pane.
647
+ // Same rationale as the `-d` on `new-window` in transport.rs; for
648
+ // adaptive layout the leader and all workers share the same tmux
649
+ // session, and every focus-stealing spawn is a disruption.
645
650
  let split_argv = vec![
646
651
  "tmux".to_string(),
647
652
  "split-window".to_string(),
653
+ "-d".to_string(),
648
654
  "-t".to_string(),
649
655
  target.clone(),
650
656
  "-h".to_string(),
@@ -920,13 +926,252 @@ fn submit_verification_for_key(key: Key) -> SubmitVerification {
920
926
  }
921
927
 
922
928
  fn capture_has_pasted_content_prompt(text: &str) -> bool {
929
+ pasted_prompt_match(text).is_some()
930
+ }
931
+
932
+ /// E50 PR-1 (0.3.24 P0, pasted-prompt 假阴诊断): factor `capture_has_pasted_content_prompt`
933
+ /// so the diagnostic layer can recover the MATCHED LITERAL and its position
934
+ /// in the tail. Returns `(literal, line_index_from_bottom)` on a match.
935
+ /// Byte-identical match semantics — `capture_has_pasted_content_prompt`'s
936
+ /// `bool` wrapper preserves the legacy `true/false` contract for the three
937
+ /// existing callers (the appear-gate poll at :1122-1128 + the legacy submit
938
+ /// loop matcher at :1138 + post-flip clearer at :1138).
939
+ ///
940
+ /// **Where-in-tail rationale**: a `pasted content` literal that lives in
941
+ /// the SCROLLBACK (line 6+ from bottom) is NOT the live composer
942
+ /// placeholder — codex's successful submit scrolls the block into history
943
+ /// where it remains in the last 80 lines. The current matcher cannot
944
+ /// distinguish that from a live placeholder; this fn surfaces the data
945
+ /// the operator needs to see (PR-2 will USE it to fix the criterion).
946
+ pub(crate) fn pasted_prompt_match(text: &str) -> Option<(&'static str, u32)> {
923
947
  let lower = text.to_ascii_lowercase();
924
- lower.contains("pasted content") || lower.contains("pasted text")
948
+ let lit = if lower.contains("pasted content") {
949
+ "pasted content"
950
+ } else if lower.contains("pasted text") {
951
+ "pasted text"
952
+ } else {
953
+ return None;
954
+ };
955
+ // Distance from the bottom of the tail in non-empty lines.
956
+ let non_empty: Vec<&str> = text.lines().filter(|line| !line.trim().is_empty()).collect();
957
+ let mut from_bottom: u32 = 0;
958
+ for line in non_empty.iter().rev() {
959
+ if line.to_ascii_lowercase().contains(lit) {
960
+ return Some((lit, from_bottom));
961
+ }
962
+ from_bottom = from_bottom.saturating_add(1);
963
+ }
964
+ // Literal appeared only in trimmed-away whitespace lines — treat as
965
+ // bottom (defensive; rare).
966
+ Some((lit, 0))
967
+ }
968
+
969
+ /// E50 PR-1 (0.3.24 P0): scrub a pane capture for safe inclusion in
970
+ /// `events.jsonl`. Steps:
971
+ /// 1. Strip CSI / OSC ANSI escapes.
972
+ /// 2. Take the bottom `tail_lines` of non-empty lines.
973
+ /// 3. Redact common secret shapes (sk-, ghp_, AKIA, Bearer ..., 32+ hex).
974
+ /// 4. Cap at ~1200 bytes (UTF-8 safe truncation).
975
+ /// Returns `(excerpt, line_count)`. Designed to be CHEAP — no regex crate
976
+ /// dependency, simple byte scanning.
977
+ pub(crate) fn scrub_pane_excerpt(raw: &str, tail_lines: usize) -> (String, u32) {
978
+ let stripped = strip_ansi_escapes_inplace(raw);
979
+ let lines: Vec<&str> = stripped
980
+ .lines()
981
+ .filter(|line| !line.trim().is_empty())
982
+ .collect();
983
+ let tail = if lines.len() > tail_lines {
984
+ &lines[lines.len() - tail_lines..]
985
+ } else {
986
+ &lines[..]
987
+ };
988
+ let mut out = tail
989
+ .iter()
990
+ .map(|line| scrub_secrets(line))
991
+ .collect::<Vec<_>>()
992
+ .join("\n");
993
+ if out.len() > 1200 {
994
+ // Truncate at UTF-8 char boundary.
995
+ let mut cut = 1200;
996
+ while cut > 0 && !out.is_char_boundary(cut) {
997
+ cut -= 1;
998
+ }
999
+ out.truncate(cut);
1000
+ out.push_str("…[truncated]");
1001
+ }
1002
+ (out, tail.len() as u32)
1003
+ }
1004
+
1005
+ fn strip_ansi_escapes_inplace(input: &str) -> String {
1006
+ let mut out = String::with_capacity(input.len());
1007
+ let bytes = input.as_bytes();
1008
+ let mut i = 0;
1009
+ while i < bytes.len() {
1010
+ if bytes[i] == 0x1b && i + 1 < bytes.len() {
1011
+ // CSI: ESC [ ... <final byte 0x40-0x7e>
1012
+ if bytes[i + 1] == b'[' {
1013
+ let mut j = i + 2;
1014
+ while j < bytes.len() && !(0x40..=0x7e).contains(&bytes[j]) {
1015
+ j += 1;
1016
+ }
1017
+ i = j.saturating_add(1).min(bytes.len());
1018
+ continue;
1019
+ }
1020
+ // OSC: ESC ] ... BEL or ESC \
1021
+ if bytes[i + 1] == b']' {
1022
+ let mut j = i + 2;
1023
+ while j < bytes.len() {
1024
+ if bytes[j] == 0x07 {
1025
+ j += 1;
1026
+ break;
1027
+ }
1028
+ if bytes[j] == 0x1b && j + 1 < bytes.len() && bytes[j + 1] == b'\\' {
1029
+ j += 2;
1030
+ break;
1031
+ }
1032
+ j += 1;
1033
+ }
1034
+ i = j.min(bytes.len());
1035
+ continue;
1036
+ }
1037
+ // Other single-char ESC sequence — skip ESC + next byte.
1038
+ i += 2;
1039
+ continue;
1040
+ }
1041
+ let ch = bytes[i];
1042
+ out.push(ch as char);
1043
+ i += 1;
1044
+ }
1045
+ // Re-decode from bytes to recover UTF-8 (since we pushed bytes as chars,
1046
+ // multi-byte UTF-8 is preserved correctly because we only skip on the
1047
+ // single-byte ESC start). For pane text this is good enough; pathological
1048
+ // UTF-8 inside CSI parameter bytes is invalid anyway.
1049
+ out
1050
+ }
1051
+
1052
+ fn scrub_secrets(line: &str) -> String {
1053
+ // Five shapes: sk-XXXX, ghp_XXXX, AKIAXXXX (16-char uppercase id), Bearer XXXX,
1054
+ // 32+ hex (token).
1055
+ let mut out = String::with_capacity(line.len());
1056
+ let bytes = line.as_bytes();
1057
+ let mut i = 0;
1058
+ while i < bytes.len() {
1059
+ // sk- / ghp_ / AKIA prefixes: detect and redact through end-of-token.
1060
+ if matches_prefix(bytes, i, b"sk-") || matches_prefix(bytes, i, b"ghp_") {
1061
+ let prefix_len = if bytes[i] == b's' { 3 } else { 4 };
1062
+ let token_end = scan_token_end(bytes, i + prefix_len);
1063
+ out.push_str(&line[i..i + prefix_len]);
1064
+ out.push_str("REDACTED");
1065
+ i = token_end;
1066
+ continue;
1067
+ }
1068
+ if matches_prefix(bytes, i, b"AKIA") {
1069
+ let token_end = scan_token_end(bytes, i + 4);
1070
+ out.push_str("AKIA");
1071
+ out.push_str("REDACTED");
1072
+ i = token_end;
1073
+ continue;
1074
+ }
1075
+ if matches_prefix_case_insensitive(bytes, i, b"Bearer ") {
1076
+ let token_end = scan_token_end(bytes, i + 7);
1077
+ out.push_str(&line[i..i + 7]);
1078
+ out.push_str("REDACTED");
1079
+ i = token_end;
1080
+ continue;
1081
+ }
1082
+ // 32+ hex run.
1083
+ if is_hex_byte(bytes[i]) {
1084
+ let mut j = i;
1085
+ while j < bytes.len() && is_hex_byte(bytes[j]) {
1086
+ j += 1;
1087
+ }
1088
+ if j - i >= 32 {
1089
+ out.push_str("REDACTED_HEX");
1090
+ i = j;
1091
+ continue;
1092
+ }
1093
+ }
1094
+ // Default: passthrough byte.
1095
+ out.push(bytes[i] as char);
1096
+ i += 1;
1097
+ }
1098
+ out
1099
+ }
1100
+
1101
+ fn matches_prefix(bytes: &[u8], i: usize, prefix: &[u8]) -> bool {
1102
+ bytes.get(i..i + prefix.len()).is_some_and(|s| s == prefix)
1103
+ }
1104
+
1105
+ fn matches_prefix_case_insensitive(bytes: &[u8], i: usize, prefix: &[u8]) -> bool {
1106
+ bytes
1107
+ .get(i..i + prefix.len())
1108
+ .is_some_and(|s| s.eq_ignore_ascii_case(prefix))
1109
+ }
1110
+
1111
+ fn scan_token_end(bytes: &[u8], start: usize) -> usize {
1112
+ let mut j = start;
1113
+ while j < bytes.len() && is_token_byte(bytes[j]) {
1114
+ j += 1;
1115
+ }
1116
+ j
1117
+ }
1118
+
1119
+ fn is_token_byte(b: u8) -> bool {
1120
+ b.is_ascii_alphanumeric() || b == b'-' || b == b'_'
1121
+ }
1122
+
1123
+ fn is_hex_byte(b: u8) -> bool {
1124
+ b.is_ascii_hexdigit()
925
1125
  }
926
1126
 
927
1127
  const PASTED_CONTENT_APPEAR_POLLS: u32 = 5;
928
1128
  const PASTED_CONTENT_SUBMIT_ATTEMPTS: u32 = 3;
929
1129
 
1130
+ /// E46 (0.3.24 bug#5): bounded resend cap for post-Enter consumption probe.
1131
+ /// Mirrors PASTED_CONTENT_SUBMIT_ATTEMPTS shape: try Enter then bounded
1132
+ /// re-checks of the pane's input region. Each iteration first re-checks that
1133
+ /// the input still has content before resending Enter — guards against double
1134
+ /// submission when the first Enter was consumed but our readback was slow.
1135
+ const POST_SUBMIT_CONSUMPTION_ATTEMPTS: u32 = 3;
1136
+ const POST_SUBMIT_CONSUMPTION_POLL_MS: u64 = 60;
1137
+
1138
+ /// E46 (0.3.24 bug#5, C5 provider-agnostic detector): the pane's input region
1139
+ /// is "consumed" when the token text that was just visible BEFORE the Enter
1140
+ /// is no longer present in the captured tail. Structural signal — no
1141
+ /// provider-specific UI string. Works across claude / codex / copilot because
1142
+ /// every provider's composer clears the input area after a successful submit
1143
+ /// (the content scrolls into history, leaving the prompt empty).
1144
+ ///
1145
+ /// Returns:
1146
+ /// * `Some(true)` — token was visible BEFORE submit and is GONE from
1147
+ /// the visible input area now → consumption confirmed.
1148
+ /// * `Some(false)` — token still visible (or other reason to think not yet
1149
+ /// consumed).
1150
+ /// * `None` — payload has no token marker (peer message without token,
1151
+ /// empty payload) so we can't structurally check; caller treats this as
1152
+ /// non-blocking (the pre-existing `EnterSentWithoutPlaceholderCheck`
1153
+ /// path).
1154
+ fn post_submit_input_consumed(
1155
+ backend: &TmuxBackend,
1156
+ target: &Target,
1157
+ payload: &InjectPayload,
1158
+ ) -> Result<Option<bool>, TransportError> {
1159
+ let Some(marker) = payload_token_marker(payload) else {
1160
+ return Ok(None);
1161
+ };
1162
+ let captured = backend.capture(target, CaptureRange::Tail(30))?;
1163
+ // The token may legitimately appear in scrollback (a successful submit
1164
+ // pushes it into history). We only treat the BOTTOM-of-pane region (last
1165
+ // few lines, where the input area lives) as the consumption signal. Tail
1166
+ // 30 lines is small enough that the input area still dominates if the
1167
+ // submit didn't go through, while a successful submit has pushed the
1168
+ // token marker out of the bottom 5 lines by the time the response
1169
+ // composer redraws.
1170
+ let tail_lines: Vec<&str> = captured.text.lines().rev().take(5).collect();
1171
+ let token_in_tail = tail_lines.iter().any(|line| line.contains(marker));
1172
+ Ok(Some(!token_in_tail))
1173
+ }
1174
+
930
1175
  fn shell_command(
931
1176
  argv: &[String],
932
1177
  cwd: &Path,
@@ -1073,6 +1318,10 @@ impl Transport for TmuxBackend {
1073
1318
  self.run_inject_stage(&argv, stage)?;
1074
1319
  }
1075
1320
  }
1321
+ // E50 PR-1 (0.3.24 P0): record appear-gate timing + per-attempt
1322
+ // observations. Pure diagnostic — no behavioural change. The
1323
+ // existing matcher / windows / actions are byte-identical.
1324
+ let appear_gate_start = std::time::Instant::now();
1076
1325
  let mut saw_pasted_prompt = false;
1077
1326
  for _ in 0..PASTED_CONTENT_APPEAR_POLLS {
1078
1327
  let captured = self.capture(target, CaptureRange::Tail(80))?;
@@ -1082,8 +1331,25 @@ impl Transport for TmuxBackend {
1082
1331
  }
1083
1332
  std::thread::sleep(Duration::from_millis(25));
1084
1333
  }
1334
+ let appear_gate_elapsed_ms = appear_gate_start.elapsed().as_millis() as u64;
1085
1335
  let submit_argv = tmux_send_keys_argv(&pane, &[submit]);
1086
- if saw_pasted_prompt {
1336
+ // E50 PR-2 Fix-B (0.3.26 P0): when the payload carries a token
1337
+ // marker (ALL delivery messages do via render_message), SKIP the
1338
+ // legacy pasted-prompt weak loop even if `saw_pasted_prompt` is
1339
+ // true. Fall through to the E46 token-based consumption gate
1340
+ // (Escape pre-Enter + post_submit_input_consumed + resend cap
1341
+ // with C3 re-check), which has NONE of the two defects:
1342
+ // A. E46 gate uses 60ms sleep + 3 retries (not zero-sleep).
1343
+ // B. E46 gate anchors on the token marker in the bottom 5
1344
+ // lines (composer region), not a substring match on
1345
+ // Tail(80) that catches scrollback residue.
1346
+ //
1347
+ // Non-token payloads (rare: test harness / trust prompt Empty)
1348
+ // still use the legacy loop for backward compatibility with
1349
+ // provider_submit_verification_red.rs::PastePromptRunner tests.
1350
+ let has_token = payload_token_marker(payload).is_some();
1351
+ if saw_pasted_prompt && !has_token {
1352
+ // Legacy weak loop preserved for non-token payloads (test harness).
1087
1353
  let mut attempts = 0;
1088
1354
  let mut cleared = false;
1089
1355
  for _ in 0..PASTED_CONTENT_SUBMIT_ATTEMPTS {
@@ -1106,6 +1372,7 @@ impl Transport for TmuxBackend {
1106
1372
  },
1107
1373
  turn_verification: TurnVerification::NotYetObserved,
1108
1374
  attempts,
1375
+ submit_diagnostics: None,
1109
1376
  });
1110
1377
  }
1111
1378
  // U1 #7 (efd189b redo, canonical-native): around the final submit, read
@@ -1117,11 +1384,105 @@ impl Transport for TmuxBackend {
1117
1384
  // downgrade to CaptureMissingToken.
1118
1385
  token_visible_for_report =
1119
1386
  pre_submit_token_visible(self, target, payload).unwrap_or(None);
1387
+ // E46 (0.3.24 bug#5, demo-director卡 bracketed paste root cause):
1388
+ // bracketed-paste-mode on a fresh provider TUI swallows the framework's
1389
+ // Enter as paste content. Send `Escape` first to exit the paste bracket
1390
+ // so the subsequent Enter is interpreted as submit. Safe across
1391
+ // claude/codex/copilot: Escape on an empty composer is a no-op
1392
+ // (cancel any pending mode), it only matters when bracketed-paste is
1393
+ // actually open. Architect-approved + macmini real-machine verified.
1394
+ // Only Enter benefits — explicit menu navigation (Key::Down etc.)
1395
+ // should not pre-cancel mode.
1396
+ if bracketed
1397
+ && matches!(payload, InjectPayload::Text(_))
1398
+ && matches!(submit, Key::Enter)
1399
+ {
1400
+ let escape_argv = tmux_send_keys_argv(&pane, &[Key::Escape]);
1401
+ // We don't fail the whole inject on Escape error — Escape failure
1402
+ // would surface downstream as the same SubmitConsumptionUnverified
1403
+ // when post-Enter probe still sees the token in the input region.
1404
+ let _ = self.run_inject_stage(&escape_argv, InjectStage::Submit);
1405
+ }
1120
1406
  self.run_inject_stage(&submit_argv, InjectStage::Submit)?;
1121
1407
  if matches!(token_visible_for_report, Some(false)) {
1122
1408
  token_visible_for_report =
1123
1409
  post_submit_token_visible(self, target, payload).unwrap_or(Some(false));
1124
1410
  }
1411
+ // E46 C2 + C3: post-Enter consumption gate with bounded resend cap.
1412
+ // Only fires for:
1413
+ // - submit key == Enter (other keys like Down/Up are explicit
1414
+ // menu navigation; codex uses `Down Enter` to dismiss a
1415
+ // prompt — those carry their own verification semantics
1416
+ // via KeySentAfterVisibleToken),
1417
+ // - token-bearing Text payloads (the structural input-empty
1418
+ // signal needs a token marker to anchor on).
1419
+ // Each iteration RE-CHECKS that the input still contains the
1420
+ // token BEFORE resending Enter — guards against the C3
1421
+ // double-submit hazard when the first Enter was consumed but
1422
+ // our readback was slow to catch the empty-input state.
1423
+ let mut consumption_attempts: u32 = 1;
1424
+ let mut consumed = if matches!(submit, Key::Enter) {
1425
+ post_submit_input_consumed(self, target, payload).unwrap_or(None)
1426
+ } else {
1427
+ None
1428
+ };
1429
+ if matches!(consumed, Some(false)) {
1430
+ for _ in 1..POST_SUBMIT_CONSUMPTION_ATTEMPTS {
1431
+ std::thread::sleep(Duration::from_millis(
1432
+ POST_SUBMIT_CONSUMPTION_POLL_MS,
1433
+ ));
1434
+ // C3 critical: re-check BEFORE resending. If the input
1435
+ // is now empty (= first Enter actually consumed, just
1436
+ // observable now), DO NOT resend — bumping a spurious
1437
+ // empty Enter would open an empty turn.
1438
+ consumed = post_submit_input_consumed(self, target, payload)
1439
+ .unwrap_or(None);
1440
+ if !matches!(consumed, Some(false)) {
1441
+ break;
1442
+ }
1443
+ consumption_attempts += 1;
1444
+ let _ = self.run_inject_stage(&submit_argv, InjectStage::Submit);
1445
+ }
1446
+ }
1447
+ let submit_verification = match consumed {
1448
+ // Consumption confirmed → MUST-10 path: keep the canonical
1449
+ // EnterSentWithoutPlaceholderCheck so the existing
1450
+ // provider_submit_verification_red contract holds.
1451
+ Some(true) => SubmitVerification::EnterSentWithoutPlaceholderCheck,
1452
+ // Consumption explicitly NOT observed (token still in tail
1453
+ // after resend cap) → E46's new variant. Delivery treats
1454
+ // as not delivered.
1455
+ Some(false) => SubmitVerification::SubmitConsumptionUnverified,
1456
+ // No structural anchor (non-token payload) → preserve the
1457
+ // historic non-checked variant; the pre-existing
1458
+ // pre/post-submit token readback + U1 #7 cover the other
1459
+ // false-positive (silent paste drop).
1460
+ None => submit_verification_for_key(submit),
1461
+ };
1462
+ // E50 PR-1: surface appear-gate timing on the E46 token path too.
1463
+ // saw_pasted_prompt=false here; the placeholder never appeared
1464
+ // within PASTED_CONTENT_APPEAR_POLLS, so the route is E46. The
1465
+ // gate elapsed_ms still helps operators see how long we waited
1466
+ // (e.g. codex slow-render).
1467
+ return Ok(InjectReport {
1468
+ stage_reached: InjectStage::Submit,
1469
+ inject_verification: inject_verification_after_readback(
1470
+ payload,
1471
+ token_visible_for_report,
1472
+ ),
1473
+ submit_verification,
1474
+ turn_verification: match payload {
1475
+ InjectPayload::Empty => TurnVerification::NotRequired,
1476
+ InjectPayload::Text(_) => TurnVerification::NotYetObserved,
1477
+ },
1478
+ attempts: consumption_attempts,
1479
+ submit_diagnostics: Some(crate::transport::SubmitDiagnostics {
1480
+ appear_gate_elapsed_ms,
1481
+ appear_gate_matched: false,
1482
+ total_elapsed_ms: appear_gate_elapsed_ms,
1483
+ attempts_detail: Vec::new(),
1484
+ }),
1485
+ });
1125
1486
  }
1126
1487
  }
1127
1488
  Ok(InjectReport {
@@ -1136,6 +1497,9 @@ impl Transport for TmuxBackend {
1136
1497
  InjectPayload::Text(_) => TurnVerification::NotYetObserved,
1137
1498
  },
1138
1499
  attempts: 1,
1500
+ // E50 PR-1: Empty payload / non-Text fallthrough path — no submit
1501
+ // diagnostics applicable.
1502
+ submit_diagnostics: None,
1139
1503
  })
1140
1504
  }
1141
1505
 
@@ -277,6 +277,7 @@ impl OfflineTransport {
277
277
  submit_verification: SubmitVerification::EnterSentWithoutPlaceholderCheck,
278
278
  turn_verification: TurnVerification::NotYetObserved,
279
279
  attempts: 1,
280
+ submit_diagnostics: None,
280
281
  }
281
282
  }
282
283
  }
@@ -74,6 +74,7 @@
74
74
  submit_verification,
75
75
  turn_verification,
76
76
  attempts: 1,
77
+ submit_diagnostics: None,
77
78
  })
78
79
  }
79
80
  fn send_keys(&self, _target: &Target, _keys: &[Key]) -> Result<(), TransportError> {
@@ -271,7 +271,8 @@
271
271
  "sh",
272
272
  false,
273
273
  ),
274
- vec!["tmux", "new-window", "-t", "team-sess", "-n", "worker-2", "sh", "-lc", "sh"]
274
+ // E53 (0.3.26): new-window now carries `-d` (detached, no focus steal).
275
+ vec!["tmux", "new-window", "-d", "-t", "team-sess", "-n", "worker-2", "sh", "-lc", "sh"]
275
276
  );
276
277
  }
277
278
 
@@ -157,6 +157,13 @@ pub enum Key {
157
157
  Char(char),
158
158
  /// `\x03`。
159
159
  CtrlC,
160
+ /// E46 (0.3.24 bug#5): pre-submit Escape to exit bracketed-paste mode on
161
+ /// fresh provider TUIs (claude). When a bracketed paste lands on a TUI
162
+ /// whose composer is still initialising, the framework's plain `Enter`
163
+ /// gets interpreted as paste content, not submit. Sending `Escape` first
164
+ /// closes the paste bracket so the subsequent `Enter` submits.
165
+ /// Real-machine truth source: macmini demo-director repro.
166
+ Escape,
160
167
  /// tmux `-X cancel` / `q` / `d`;非 tmux 后端无 copy-mode 概念 → no-op。
161
168
  CancelMode,
162
169
  }
@@ -287,7 +294,10 @@ pub enum InjectVerification {
287
294
  /// `{key}_sent_after_visible_token` 是模板 → 用携带 Key 的 variant 表达。
288
295
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
289
296
  pub enum SubmitVerification {
290
- /// `enter_sent_without_placeholder_check`。
297
+ /// `enter_sent_without_placeholder_check`。MUST-10:此 variant 代表 paste+Enter
298
+ /// 完成且无需 placeholder probe(纯 peer 消息路径)。**保留** ⇒ delivered 语义;
299
+ /// E46 后只有 post-Enter consumption 确认通过(input 清空 / Working 信号)才返回
300
+ /// 此 variant — fresh TUI 未消费走 [`Self::SubmitConsumptionUnverified`]。
291
301
  EnterSentWithoutPlaceholderCheck,
292
302
  /// `pasted_content_prompt_absent_after_submit`。
293
303
  PastedContentPromptAbsentAfterSubmit,
@@ -297,6 +307,14 @@ pub enum SubmitVerification {
297
307
  KeySentAfterVisibleToken { key: Key },
298
308
  /// `send_keys_failed`。
299
309
  SendKeysFailed,
310
+ /// E46 (0.3.24 bug#5, demo-director 卡 bracketed paste): Enter 已发但
311
+ /// post-Enter 接收侧消费信号(input 行清空 / provider 进 Working)在 bounded
312
+ /// resend 上限内未观察到。**delivery 不当作 delivered**;走 submitted_unverified /
313
+ /// failed 路径。区别于
314
+ /// [`Self::PastedContentPromptStillPresentAfterSubmit`](claude paste-prompt
315
+ /// 折叠场景)— 本 variant 是结构化 input-empty 检测,适配 demo-director
316
+ /// 直接渲染文本路径。
317
+ SubmitConsumptionUnverified,
300
318
  }
301
319
 
302
320
  /// turn-boundary 观测(tmux_io.py:224-260,09-transport.md 表 §40)。
@@ -319,6 +337,75 @@ pub struct InjectReport {
319
337
  /// Gap42:仅 metadata,`not_yet_observed` 也算成功。
320
338
  pub turn_verification: TurnVerification,
321
339
  pub attempts: u32,
340
+ /// E50 PR-1 (0.3.24 P0, pasted-prompt 假阴诊断): per-attempt observations
341
+ /// emitted by the paste-prompt + Enter loop in `tmux_backend.rs::inject`.
342
+ /// `None` when the inject path did not exercise the diagnostic
343
+ /// instrumentation (peer payloads, empty payloads, non-bracketed Text,
344
+ /// or any path that bypasses `capture_has_pasted_content_prompt`). When
345
+ /// present, downstream `send.unverified` / `send.failed` events surface
346
+ /// it as `submit_attempts_detail[]` so operators see live pane state
347
+ /// per attempt (matched literal, where-in-tail offset, scrubbed pane
348
+ /// excerpt, elapsed ms) — the missing forensic data the user has been
349
+ /// asking for for many rounds.
350
+ ///
351
+ /// Tests / mocks should default this to `None` via `Default`. Wire
352
+ /// byte-lock (`transport::tests::wire`) is untouched: this field is NOT
353
+ /// serialised on the typed wire — it lives only in the in-process
354
+ /// `InjectReport` and is rendered into JSON manually by the delivery
355
+ /// layer when emitting forensic events.
356
+ pub submit_diagnostics: Option<SubmitDiagnostics>,
357
+ }
358
+
359
+ /// E50 PR-1 diagnostic payload — one entry per submit attempt in the
360
+ /// pasted-prompt branch + (informational) for the appear-gate poll.
361
+ #[derive(Debug, Clone, PartialEq, Eq, Default)]
362
+ pub struct SubmitDiagnostics {
363
+ /// Time spent in the appear-gate (poll for the pasted-content placeholder
364
+ /// before Enter). When `saw_pasted_prompt == false` this is the time we
365
+ /// spent polling before falling through to the E46 token path.
366
+ pub appear_gate_elapsed_ms: u64,
367
+ /// Did the appear-gate ever match the `pasted content` / `pasted text`
368
+ /// literal? When `false`, the inject took the E46 token path; the
369
+ /// `attempts_detail` may still capture observations the operator wants.
370
+ pub appear_gate_matched: bool,
371
+ /// Total elapsed across the whole submit gate (appear-gate + Enter
372
+ /// loop). Useful to triage real-machine slow-paste (codex large-paste
373
+ /// collapse can take >100 s while the framework's loop is sub-second).
374
+ pub total_elapsed_ms: u64,
375
+ /// Per-attempt observations of the post-Enter capture in the pasted-
376
+ /// prompt loop. Empty when `saw_pasted_prompt == false` and the inject
377
+ /// went through the E46 token path.
378
+ pub attempts_detail: Vec<SubmitAttemptObservation>,
379
+ }
380
+
381
+ /// E50 PR-1 single attempt observation (one Enter + one capture). All fields
382
+ /// are forensic — they exist to make `send.unverified` / `send.failed` events
383
+ /// self-describing rather than requiring the operator to guess.
384
+ #[derive(Debug, Clone, PartialEq, Eq, Default)]
385
+ pub struct SubmitAttemptObservation {
386
+ /// 1-based attempt index (mirrors the `attempts` counter that already
387
+ /// ships in `InjectReport.attempts`).
388
+ pub attempt_index: u32,
389
+ /// Did this attempt's post-Enter capture STILL match the pasted-prompt
390
+ /// literal? `true` means the inject saw the placeholder; `false` means
391
+ /// the placeholder cleared (= submit succeeded by the legacy criterion).
392
+ pub matched: bool,
393
+ /// The matched literal substring (`pasted content` / `pasted text`)
394
+ /// when `matched == true`. `None` otherwise.
395
+ pub matched_literal: Option<String>,
396
+ /// Distance from the bottom of the tail where the match occurred:
397
+ /// `Some(0)` = bottom-most line (composer), `Some(N)` = N lines above
398
+ /// bottom (likely scrollback), `None` = no match. Critical for the
399
+ /// false-negative root cause: a `pasted content` literal in scrollback
400
+ /// is NOT the live composer placeholder.
401
+ pub where_in_tail: Option<u32>,
402
+ /// Scrubbed last 20 / 80 lines of the captured tail, ANSI-stripped,
403
+ /// capped ~1200 bytes. Secrets scrubbed (sk-/ghp_/AKIA/Bearer/hex32+).
404
+ pub pane_tail_excerpt: String,
405
+ /// Number of non-empty lines in `pane_tail_excerpt`.
406
+ pub pane_tail_lines: u32,
407
+ /// Time elapsed for THIS attempt (Enter + capture + match).
408
+ pub elapsed_ms: u64,
322
409
  }
323
410
 
324
411
  // ─────────────────────────────────────────────────────────────────────────────
@@ -621,6 +708,10 @@ pub fn tmux_key_name(key: Key) -> &'static str {
621
708
  Key::Char('8') => "8",
622
709
  Key::Char('9') => "9",
623
710
  Key::CtrlC => "C-c",
711
+ // E46 (0.3.24 bug#5): tmux supports `Escape` as a key name; sending
712
+ // it as a send-keys arg emits `\x1b` to the pane (closes bracketed
713
+ // paste mode on a stuck TUI composer).
714
+ Key::Escape => "Escape",
624
715
  Key::CancelMode | Key::Char(_) => "",
625
716
  }
626
717
  }
@@ -739,9 +830,17 @@ pub fn tmux_spawn_argv(
739
830
  command.to_string(),
740
831
  ]
741
832
  } else {
833
+ // E53 (0.3.26, adaptive layout same-session tabs): `-d` makes the new
834
+ // window start without switching the client's active window to it. The
835
+ // leader stays on its own window; the worker opens as a background tab.
836
+ // Without `-d` every `new-window` call yanks the leader's terminal to
837
+ // the freshly spawned worker window, disrupting whatever the leader is
838
+ // doing. `-d` matches the Python golden: the managed leader and all
839
+ // workers share the same tmux session (= same terminal window tabs).
742
840
  vec![
743
841
  "tmux".to_string(),
744
842
  "new-window".to_string(),
843
+ "-d".to_string(),
745
844
  "-t".to_string(),
746
845
  session.as_str().to_string(),
747
846
  "-n".to_string(),
@@ -850,6 +949,9 @@ pub fn submit_verification_wire(v: SubmitVerification) -> String {
850
949
  format!("{}_sent_after_visible_token", tmux_key_name(key))
851
950
  }
852
951
  SubmitVerification::SendKeysFailed => "send_keys_failed".to_string(),
952
+ SubmitVerification::SubmitConsumptionUnverified => {
953
+ "submit_consumption_unverified".to_string()
954
+ }
853
955
  }
854
956
  }
855
957