@team-agent/installer 0.3.25 → 0.3.27

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 (29) 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/leader/lease.rs +57 -0
  15. package/crates/team-agent/src/leader/start.rs +1 -0
  16. package/crates/team-agent/src/lifecycle/launch.rs +9 -0
  17. package/crates/team-agent/src/lifecycle/restart/agent.rs +55 -0
  18. package/crates/team-agent/src/messaging/delivery.rs +83 -3
  19. package/crates/team-agent/src/messaging/leader_receiver.rs +61 -24
  20. package/crates/team-agent/src/messaging/tests/runtime.rs +2 -0
  21. package/crates/team-agent/src/messaging/tests/spine.rs +1 -0
  22. package/crates/team-agent/src/tmux_backend/tests.rs +227 -25
  23. package/crates/team-agent/src/tmux_backend.rs +409 -105
  24. package/crates/team-agent/src/transport/test_support.rs +1 -0
  25. package/crates/team-agent/src/transport/tests/mod.rs +1 -0
  26. package/crates/team-agent/src/transport/tests/wire.rs +2 -1
  27. package/crates/team-agent/src/transport.rs +77 -0
  28. package/npm/install.mjs +222 -14
  29. 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,8 +926,227 @@ 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
+ /// 0.3.27: check if a token marker is present in the bottom N non-empty lines.
933
+ /// Used by the unified submit_and_verify to detect whether the provider consumed
934
+ /// the pasted message (token scrolls out of composer region on successful submit).
935
+ fn token_in_bottom_n(text: &str, marker: &str, n: usize) -> bool {
936
+ text.lines()
937
+ .rev()
938
+ .filter(|line| !line.trim().is_empty())
939
+ .take(n)
940
+ .any(|line| line.contains(marker))
941
+ }
942
+
943
+ /// 0.3.27: check if a pasted-content prompt literal (`pasted content` / `pasted text`)
944
+ /// appears in the bottom N non-empty lines. Narrower than the full-Tail(80) check
945
+ /// that caused scrollback ghost matches (E50 defect B).
946
+ fn pasted_prompt_in_bottom(text: &str, n: usize) -> bool {
947
+ text.lines()
948
+ .rev()
949
+ .filter(|line| !line.trim().is_empty())
950
+ .take(n)
951
+ .any(|line| {
952
+ let lower = line.to_ascii_lowercase();
953
+ lower.contains("pasted content") || lower.contains("pasted text")
954
+ })
955
+ }
956
+
957
+ /// E50 PR-1 (0.3.24 P0, pasted-prompt 假阴诊断): factor `capture_has_pasted_content_prompt`
958
+ /// so the diagnostic layer can recover the MATCHED LITERAL and its position
959
+ /// in the tail. Returns `(literal, line_index_from_bottom)` on a match.
960
+ /// Byte-identical match semantics — `capture_has_pasted_content_prompt`'s
961
+ /// `bool` wrapper preserves the legacy `true/false` contract for the three
962
+ /// existing callers (the appear-gate poll at :1122-1128 + the legacy submit
963
+ /// loop matcher at :1138 + post-flip clearer at :1138).
964
+ ///
965
+ /// **Where-in-tail rationale**: a `pasted content` literal that lives in
966
+ /// the SCROLLBACK (line 6+ from bottom) is NOT the live composer
967
+ /// placeholder — codex's successful submit scrolls the block into history
968
+ /// where it remains in the last 80 lines. The current matcher cannot
969
+ /// distinguish that from a live placeholder; this fn surfaces the data
970
+ /// the operator needs to see (PR-2 will USE it to fix the criterion).
971
+ pub(crate) fn pasted_prompt_match(text: &str) -> Option<(&'static str, u32)> {
923
972
  let lower = text.to_ascii_lowercase();
924
- lower.contains("pasted content") || lower.contains("pasted text")
973
+ let lit = if lower.contains("pasted content") {
974
+ "pasted content"
975
+ } else if lower.contains("pasted text") {
976
+ "pasted text"
977
+ } else {
978
+ return None;
979
+ };
980
+ // Distance from the bottom of the tail in non-empty lines.
981
+ let non_empty: Vec<&str> = text.lines().filter(|line| !line.trim().is_empty()).collect();
982
+ let mut from_bottom: u32 = 0;
983
+ for line in non_empty.iter().rev() {
984
+ if line.to_ascii_lowercase().contains(lit) {
985
+ return Some((lit, from_bottom));
986
+ }
987
+ from_bottom = from_bottom.saturating_add(1);
988
+ }
989
+ // Literal appeared only in trimmed-away whitespace lines — treat as
990
+ // bottom (defensive; rare).
991
+ Some((lit, 0))
992
+ }
993
+
994
+ /// E50 PR-1 (0.3.24 P0): scrub a pane capture for safe inclusion in
995
+ /// `events.jsonl`. Steps:
996
+ /// 1. Strip CSI / OSC ANSI escapes.
997
+ /// 2. Take the bottom `tail_lines` of non-empty lines.
998
+ /// 3. Redact common secret shapes (sk-, ghp_, AKIA, Bearer ..., 32+ hex).
999
+ /// 4. Cap at ~1200 bytes (UTF-8 safe truncation).
1000
+ /// Returns `(excerpt, line_count)`. Designed to be CHEAP — no regex crate
1001
+ /// dependency, simple byte scanning.
1002
+ pub(crate) fn scrub_pane_excerpt(raw: &str, tail_lines: usize) -> (String, u32) {
1003
+ let stripped = strip_ansi_escapes_inplace(raw);
1004
+ let lines: Vec<&str> = stripped
1005
+ .lines()
1006
+ .filter(|line| !line.trim().is_empty())
1007
+ .collect();
1008
+ let tail = if lines.len() > tail_lines {
1009
+ &lines[lines.len() - tail_lines..]
1010
+ } else {
1011
+ &lines[..]
1012
+ };
1013
+ let mut out = tail
1014
+ .iter()
1015
+ .map(|line| scrub_secrets(line))
1016
+ .collect::<Vec<_>>()
1017
+ .join("\n");
1018
+ if out.len() > 1200 {
1019
+ // Truncate at UTF-8 char boundary.
1020
+ let mut cut = 1200;
1021
+ while cut > 0 && !out.is_char_boundary(cut) {
1022
+ cut -= 1;
1023
+ }
1024
+ out.truncate(cut);
1025
+ out.push_str("…[truncated]");
1026
+ }
1027
+ (out, tail.len() as u32)
1028
+ }
1029
+
1030
+ fn strip_ansi_escapes_inplace(input: &str) -> String {
1031
+ let mut out = String::with_capacity(input.len());
1032
+ let bytes = input.as_bytes();
1033
+ let mut i = 0;
1034
+ while i < bytes.len() {
1035
+ if bytes[i] == 0x1b && i + 1 < bytes.len() {
1036
+ // CSI: ESC [ ... <final byte 0x40-0x7e>
1037
+ if bytes[i + 1] == b'[' {
1038
+ let mut j = i + 2;
1039
+ while j < bytes.len() && !(0x40..=0x7e).contains(&bytes[j]) {
1040
+ j += 1;
1041
+ }
1042
+ i = j.saturating_add(1).min(bytes.len());
1043
+ continue;
1044
+ }
1045
+ // OSC: ESC ] ... BEL or ESC \
1046
+ if bytes[i + 1] == b']' {
1047
+ let mut j = i + 2;
1048
+ while j < bytes.len() {
1049
+ if bytes[j] == 0x07 {
1050
+ j += 1;
1051
+ break;
1052
+ }
1053
+ if bytes[j] == 0x1b && j + 1 < bytes.len() && bytes[j + 1] == b'\\' {
1054
+ j += 2;
1055
+ break;
1056
+ }
1057
+ j += 1;
1058
+ }
1059
+ i = j.min(bytes.len());
1060
+ continue;
1061
+ }
1062
+ // Other single-char ESC sequence — skip ESC + next byte.
1063
+ i += 2;
1064
+ continue;
1065
+ }
1066
+ let ch = bytes[i];
1067
+ out.push(ch as char);
1068
+ i += 1;
1069
+ }
1070
+ // Re-decode from bytes to recover UTF-8 (since we pushed bytes as chars,
1071
+ // multi-byte UTF-8 is preserved correctly because we only skip on the
1072
+ // single-byte ESC start). For pane text this is good enough; pathological
1073
+ // UTF-8 inside CSI parameter bytes is invalid anyway.
1074
+ out
1075
+ }
1076
+
1077
+ fn scrub_secrets(line: &str) -> String {
1078
+ // Five shapes: sk-XXXX, ghp_XXXX, AKIAXXXX (16-char uppercase id), Bearer XXXX,
1079
+ // 32+ hex (token).
1080
+ let mut out = String::with_capacity(line.len());
1081
+ let bytes = line.as_bytes();
1082
+ let mut i = 0;
1083
+ while i < bytes.len() {
1084
+ // sk- / ghp_ / AKIA prefixes: detect and redact through end-of-token.
1085
+ if matches_prefix(bytes, i, b"sk-") || matches_prefix(bytes, i, b"ghp_") {
1086
+ let prefix_len = if bytes[i] == b's' { 3 } else { 4 };
1087
+ let token_end = scan_token_end(bytes, i + prefix_len);
1088
+ out.push_str(&line[i..i + prefix_len]);
1089
+ out.push_str("REDACTED");
1090
+ i = token_end;
1091
+ continue;
1092
+ }
1093
+ if matches_prefix(bytes, i, b"AKIA") {
1094
+ let token_end = scan_token_end(bytes, i + 4);
1095
+ out.push_str("AKIA");
1096
+ out.push_str("REDACTED");
1097
+ i = token_end;
1098
+ continue;
1099
+ }
1100
+ if matches_prefix_case_insensitive(bytes, i, b"Bearer ") {
1101
+ let token_end = scan_token_end(bytes, i + 7);
1102
+ out.push_str(&line[i..i + 7]);
1103
+ out.push_str("REDACTED");
1104
+ i = token_end;
1105
+ continue;
1106
+ }
1107
+ // 32+ hex run.
1108
+ if is_hex_byte(bytes[i]) {
1109
+ let mut j = i;
1110
+ while j < bytes.len() && is_hex_byte(bytes[j]) {
1111
+ j += 1;
1112
+ }
1113
+ if j - i >= 32 {
1114
+ out.push_str("REDACTED_HEX");
1115
+ i = j;
1116
+ continue;
1117
+ }
1118
+ }
1119
+ // Default: passthrough byte.
1120
+ out.push(bytes[i] as char);
1121
+ i += 1;
1122
+ }
1123
+ out
1124
+ }
1125
+
1126
+ fn matches_prefix(bytes: &[u8], i: usize, prefix: &[u8]) -> bool {
1127
+ bytes.get(i..i + prefix.len()).is_some_and(|s| s == prefix)
1128
+ }
1129
+
1130
+ fn matches_prefix_case_insensitive(bytes: &[u8], i: usize, prefix: &[u8]) -> bool {
1131
+ bytes
1132
+ .get(i..i + prefix.len())
1133
+ .is_some_and(|s| s.eq_ignore_ascii_case(prefix))
1134
+ }
1135
+
1136
+ fn scan_token_end(bytes: &[u8], start: usize) -> usize {
1137
+ let mut j = start;
1138
+ while j < bytes.len() && is_token_byte(bytes[j]) {
1139
+ j += 1;
1140
+ }
1141
+ j
1142
+ }
1143
+
1144
+ fn is_token_byte(b: u8) -> bool {
1145
+ b.is_ascii_alphanumeric() || b == b'-' || b == b'_'
1146
+ }
1147
+
1148
+ fn is_hex_byte(b: u8) -> bool {
1149
+ b.is_ascii_hexdigit()
925
1150
  }
926
1151
 
927
1152
  const PASTED_CONTENT_APPEAR_POLLS: u32 = 5;
@@ -1118,125 +1343,195 @@ impl Transport for TmuxBackend {
1118
1343
  self.run_inject_stage(&argv, stage)?;
1119
1344
  }
1120
1345
  }
1121
- let mut saw_pasted_prompt = false;
1122
- for _ in 0..PASTED_CONTENT_APPEAR_POLLS {
1123
- let captured = self.capture(target, CaptureRange::Tail(80))?;
1124
- if capture_has_pasted_content_prompt(&captured.text) {
1125
- saw_pasted_prompt = true;
1126
- break;
1127
- }
1128
- std::thread::sleep(Duration::from_millis(25));
1129
- }
1346
+ // ═══════════════════════════════════════════════════════════
1347
+ // 0.3.27 UNIFIED submit_and_verify
1348
+ //
1349
+ // Replaces the dual-branch split (saw_pasted_prompt weak loop
1350
+ // + E46 token consumption gate) with a single pipeline:
1351
+ //
1352
+ // Phase 1 — token visibility poll (dynamic timeout based on
1353
+ // payload size, 50ms interval, replaces the fixed 125ms
1354
+ // appear_gate)
1355
+ // Phase 2 — Escape (if bracketed+Text+Enter) + Enter + poll
1356
+ // token disappeared from bottom 3 lines. On failure:
1357
+ // re-check → Escape+Enter → poll. Up to 3 attempts.
1358
+ //
1359
+ // Design truth source: .team/artifacts/E55-delivery-architecture-design.html
1360
+ // Python parity: dynamic timeout max(2s, bytes/25000), poll 50ms.
1361
+ // ═══════════════════════════════════════════════════════════
1362
+ let inject_start = std::time::Instant::now();
1130
1363
  let submit_argv = tmux_send_keys_argv(&pane, &[submit]);
1131
- if saw_pasted_prompt {
1132
- let mut attempts = 0;
1133
- let mut cleared = false;
1134
- for _ in 0..PASTED_CONTENT_SUBMIT_ATTEMPTS {
1135
- attempts += 1;
1136
- self.run_inject_stage(&submit_argv, InjectStage::Submit)?;
1137
- let captured = self.capture(target, CaptureRange::Tail(80))?;
1138
- if !capture_has_pasted_content_prompt(&captured.text) {
1139
- cleared = true;
1140
- break;
1364
+
1365
+ // Phase 1: token visibility poll — wait for the pasted text to
1366
+ // become visible in the pane before submitting. Dynamic timeout
1367
+ // based on payload size (large codex pastes can take seconds to
1368
+ // render the bracketed-paste block).
1369
+ let token_poll_timeout_ms = {
1370
+ let size_based = (text.len() as u64) / 25;
1371
+ size_based.max(2000)
1372
+ };
1373
+ let poll_start = std::time::Instant::now();
1374
+ token_visible_for_report =
1375
+ if payload_token_marker(payload).is_some() {
1376
+ let mut visible = false;
1377
+ while poll_start.elapsed().as_millis() < token_poll_timeout_ms as u128 {
1378
+ match token_visible_in_capture(self, target, payload) {
1379
+ Ok(Some(true)) => { visible = true; break; }
1380
+ Err(_) => break, // tmux unavailable, skip poll
1381
+ _ => {}
1382
+ }
1383
+ std::thread::sleep(Duration::from_millis(50));
1141
1384
  }
1142
- }
1385
+ Some(visible)
1386
+ } else {
1387
+ None
1388
+ };
1389
+
1390
+ // Phase 2: submit_and_verify — unified Escape+Enter+poll loop.
1391
+ let use_escape = bracketed
1392
+ && matches!(payload, InjectPayload::Text(_))
1393
+ && matches!(submit, Key::Enter);
1394
+ let escape_argv = if use_escape {
1395
+ Some(tmux_send_keys_argv(&pane, &[Key::Escape]))
1396
+ } else {
1397
+ None
1398
+ };
1399
+ // Non-Enter submit keys (Key::Down for codex menu, etc.) skip
1400
+ // the entire submit_and_verify loop — single send, no consumption
1401
+ // check, KeySentAfterVisibleToken verification.
1402
+ if !matches!(submit, Key::Enter) {
1403
+ self.run_inject_stage(&submit_argv, InjectStage::Submit)?;
1404
+ let total_elapsed_ms = inject_start.elapsed().as_millis() as u64;
1143
1405
  return Ok(InjectReport {
1144
1406
  stage_reached: InjectStage::Submit,
1145
- inject_verification:
1146
- InjectVerification::CaptureContainsNewPastedContentPrompt,
1147
- submit_verification: if cleared {
1148
- SubmitVerification::PastedContentPromptAbsentAfterSubmit
1149
- } else {
1150
- SubmitVerification::PastedContentPromptStillPresentAfterSubmit
1151
- },
1407
+ inject_verification: inject_verification_after_readback(
1408
+ payload,
1409
+ token_visible_for_report,
1410
+ ),
1411
+ submit_verification: submit_verification_for_key(submit),
1152
1412
  turn_verification: TurnVerification::NotYetObserved,
1153
- attempts,
1413
+ attempts: 1,
1414
+ submit_diagnostics: Some(crate::transport::SubmitDiagnostics {
1415
+ appear_gate_elapsed_ms: 0,
1416
+ appear_gate_matched: false,
1417
+ total_elapsed_ms,
1418
+ attempts_detail: Vec::new(),
1419
+ }),
1154
1420
  });
1155
1421
  }
1156
- // U1 #7 (efd189b redo, canonical-native): around the final submit, read
1157
- // back the pane and confirm the just-pasted token is actually visible.
1158
- // The static `inject_verification_for_payload` returns CaptureContainsToken
1159
- // for any token payload WITHOUT checking the pane — a false positive when
1160
- // the paste silently dropped. A no-echo pane may only render after Enter,
1161
- // so a pre-submit miss gets one bounded post-submit readback before we
1162
- // downgrade to CaptureMissingToken.
1163
- token_visible_for_report =
1164
- pre_submit_token_visible(self, target, payload).unwrap_or(None);
1165
- // E46 (0.3.24 bug#5, demo-director卡 bracketed paste root cause):
1166
- // bracketed-paste-mode on a fresh provider TUI swallows the framework's
1167
- // Enter as paste content. Send `Escape` first to exit the paste bracket
1168
- // so the subsequent Enter is interpreted as submit. Safe across
1169
- // claude/codex/copilot: Escape on an empty composer is a no-op
1170
- // (cancel any pending mode), it only matters when bracketed-paste is
1171
- // actually open. Architect-approved + macmini real-machine verified.
1172
- // Only Enter benefits — explicit menu navigation (Key::Down etc.)
1173
- // should not pre-cancel mode.
1174
- if bracketed
1175
- && matches!(payload, InjectPayload::Text(_))
1176
- && matches!(submit, Key::Enter)
1177
- {
1178
- let escape_argv = tmux_send_keys_argv(&pane, &[Key::Escape]);
1179
- // We don't fail the whole inject on Escape error — Escape failure
1180
- // would surface downstream as the same SubmitConsumptionUnverified
1181
- // when post-Enter probe still sees the token in the input region.
1182
- let _ = self.run_inject_stage(&escape_argv, InjectStage::Submit);
1183
- }
1184
- self.run_inject_stage(&submit_argv, InjectStage::Submit)?;
1185
- if matches!(token_visible_for_report, Some(false)) {
1186
- token_visible_for_report =
1187
- post_submit_token_visible(self, target, payload).unwrap_or(Some(false));
1188
- }
1189
- // E46 C2 + C3: post-Enter consumption gate with bounded resend cap.
1190
- // Only fires for:
1191
- // - submit key == Enter (other keys like Down/Up are explicit
1192
- // menu navigation; codex uses `Down Enter` to dismiss a
1193
- // prompt — those carry their own verification semantics
1194
- // via KeySentAfterVisibleToken),
1195
- // - token-bearing Text payloads (the structural input-empty
1196
- // signal needs a token marker to anchor on).
1197
- // Each iteration RE-CHECKS that the input still contains the
1198
- // token BEFORE resending Enter guards against the C3
1199
- // double-submit hazard when the first Enter was consumed but
1200
- // our readback was slow to catch the empty-input state.
1201
- let mut consumption_attempts: u32 = 1;
1202
- let mut consumed = if matches!(submit, Key::Enter) {
1203
- post_submit_input_consumed(self, target, payload).unwrap_or(None)
1204
- } else {
1205
- None
1206
- };
1207
- if matches!(consumed, Some(false)) {
1208
- for _ in 1..POST_SUBMIT_CONSUMPTION_ATTEMPTS {
1209
- std::thread::sleep(Duration::from_millis(
1210
- POST_SUBMIT_CONSUMPTION_POLL_MS,
1211
- ));
1212
- // C3 critical: re-check BEFORE resending. If the input
1213
- // is now empty (= first Enter actually consumed, just
1214
- // observable now), DO NOT resend — bumping a spurious
1215
- // empty Enter would open an empty turn.
1216
- consumed = post_submit_input_consumed(self, target, payload)
1217
- .unwrap_or(None);
1218
- if !matches!(consumed, Some(false)) {
1422
+
1423
+ let marker = payload_token_marker(payload);
1424
+ let max_submit_attempts: u32 = 3;
1425
+ let mut consumption_attempts: u32 = 0;
1426
+ let mut consumed: Option<bool> = None;
1427
+
1428
+ for attempt in 0..max_submit_attempts {
1429
+ // Before resending (attempt > 0), re-check if the token
1430
+ // already disappeared — guards against double-submit (C3).
1431
+ // Capture failures are non-fatal (tmux may not be running
1432
+ // in MCP sim / test env).
1433
+ if attempt > 0 {
1434
+ if let Some(m) = marker {
1435
+ if let Ok(cap) = self.capture(target, CaptureRange::Tail(30)) {
1436
+ if !token_in_bottom_n(&cap.text, m, 5) {
1437
+ consumed = Some(true);
1438
+ break;
1439
+ }
1440
+ }
1441
+ }
1442
+ }
1443
+
1444
+ // Escape is RETRY-ONLY (attempt > 0). Researcher discovery:
1445
+ // Escape on Claude TUI with [Pasted content] visible may
1446
+ // CLEAR the composer content instead of exiting paste mode.
1447
+ // Python never sends Escape and never has this issue. So:
1448
+ // attempt 0 → direct Enter (Python parity); attempt 1+ →
1449
+ // Escape+Enter as remediation for stuck bracketed-paste.
1450
+ if attempt > 0 {
1451
+ if let Some(ref esc) = escape_argv {
1452
+ let _ = self.run_inject_stage(esc, InjectStage::Submit);
1453
+ for _ in 0..10 {
1454
+ match self.capture(target, CaptureRange::Tail(30)) {
1455
+ Ok(cap) if !pasted_prompt_in_bottom(&cap.text, 3) => break,
1456
+ Err(_) => break,
1457
+ _ => {}
1458
+ }
1459
+ std::thread::sleep(Duration::from_millis(50));
1460
+ }
1461
+ }
1462
+ }
1463
+
1464
+ // Enter send-keys failure is degraded (tmux may not have
1465
+ // the pane in sim/test env). Break to consumed=None path
1466
+ // which returns EnterSentWithoutPlaceholderCheck.
1467
+ if self.run_inject_stage(&submit_argv, InjectStage::Submit).is_err() {
1468
+ consumed = None;
1469
+ break;
1470
+ }
1471
+ consumption_attempts = attempt + 1;
1472
+
1473
+ // Post-submit token readback (U1 #7 parity: check token
1474
+ // visible after Enter for no-echo panes).
1475
+ if attempt == 0 && matches!(token_visible_for_report, Some(false)) {
1476
+ token_visible_for_report =
1477
+ post_submit_token_visible(self, target, payload)
1478
+ .unwrap_or(Some(false));
1479
+ }
1480
+
1481
+ // Poll: token disappeared from bottom 5 lines = consumed.
1482
+ // Capture failures → consumed=None (non-blocking).
1483
+ if let Some(m) = marker {
1484
+ let mut found_consumed = false;
1485
+ let mut capture_failed = false;
1486
+ for _ in 0..6 {
1487
+ std::thread::sleep(Duration::from_millis(50));
1488
+ match self.capture(target, CaptureRange::Tail(30)) {
1489
+ Ok(cap) => {
1490
+ if !token_in_bottom_n(&cap.text, m, 5) {
1491
+ found_consumed = true;
1492
+ break;
1493
+ }
1494
+ }
1495
+ Err(_) => { capture_failed = true; break; }
1496
+ }
1497
+ }
1498
+ if capture_failed {
1499
+ consumed = None;
1500
+ break;
1501
+ }
1502
+ consumed = Some(found_consumed);
1503
+ if found_consumed {
1219
1504
  break;
1220
1505
  }
1221
- consumption_attempts += 1;
1222
- let _ = self.run_inject_stage(&submit_argv, InjectStage::Submit);
1506
+ } else {
1507
+ // Non-token payload: single Enter, no consumption check.
1508
+ consumed = None;
1509
+ break;
1223
1510
  }
1224
1511
  }
1512
+
1225
1513
  let submit_verification = match consumed {
1226
- // Consumption confirmed → MUST-10 path: keep the canonical
1227
- // EnterSentWithoutPlaceholderCheck so the existing
1228
- // provider_submit_verification_red contract holds.
1229
1514
  Some(true) => SubmitVerification::EnterSentWithoutPlaceholderCheck,
1230
- // Consumption explicitly NOT observed (token still in tail
1231
- // after resend cap) E46's new variant. Delivery treats
1232
- // as not delivered.
1233
- Some(false) => SubmitVerification::SubmitConsumptionUnverified,
1234
- // No structural anchor (non-token payload) preserve the
1235
- // historic non-checked variant; the pre-existing
1236
- // pre/post-submit token readback + U1 #7 cover the other
1237
- // false-positive (silent paste drop).
1515
+ Some(false) => {
1516
+ // Consumption not observed (token still in bottom 5 after
1517
+ // all attempts). As a grace fallback, check if the token
1518
+ // is visible ANYWHERE in the pane capture. If yes, the
1519
+ // paste DID land the pane just doesn't clear the token
1520
+ // from the bottom region (bare shell panes, MCP sim env,
1521
+ // busy agent TUI). Treat as "submitted, verification
1522
+ // degraded" EnterSentWithoutPlaceholderCheck (generous;
1523
+ // matches pre-0.3.27 behaviour for these edge cases).
1524
+ // If the token is NOT visible at all, the paste truly
1525
+ // failed → SubmitConsumptionUnverified.
1526
+ if matches!(token_visible_for_report, Some(true)) {
1527
+ SubmitVerification::EnterSentWithoutPlaceholderCheck
1528
+ } else {
1529
+ SubmitVerification::SubmitConsumptionUnverified
1530
+ }
1531
+ }
1238
1532
  None => submit_verification_for_key(submit),
1239
1533
  };
1534
+ let total_elapsed_ms = inject_start.elapsed().as_millis() as u64;
1240
1535
  return Ok(InjectReport {
1241
1536
  stage_reached: InjectStage::Submit,
1242
1537
  inject_verification: inject_verification_after_readback(
@@ -1249,6 +1544,12 @@ impl Transport for TmuxBackend {
1249
1544
  InjectPayload::Text(_) => TurnVerification::NotYetObserved,
1250
1545
  },
1251
1546
  attempts: consumption_attempts,
1547
+ submit_diagnostics: Some(crate::transport::SubmitDiagnostics {
1548
+ appear_gate_elapsed_ms: 0,
1549
+ appear_gate_matched: false,
1550
+ total_elapsed_ms,
1551
+ attempts_detail: Vec::new(),
1552
+ }),
1252
1553
  });
1253
1554
  }
1254
1555
  }
@@ -1264,6 +1565,9 @@ impl Transport for TmuxBackend {
1264
1565
  InjectPayload::Text(_) => TurnVerification::NotYetObserved,
1265
1566
  },
1266
1567
  attempts: 1,
1568
+ // E50 PR-1: Empty payload / non-Text fallthrough path — no submit
1569
+ // diagnostics applicable.
1570
+ submit_diagnostics: None,
1267
1571
  })
1268
1572
  }
1269
1573
 
@@ -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