@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/emit.rs +1 -0
- package/crates/team-agent/src/cli/mod.rs +193 -7
- package/crates/team-agent/src/cli/send.rs +106 -0
- package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +270 -13
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +7 -0
- package/crates/team-agent/src/coordinator/tests/energy.rs +1 -0
- package/crates/team-agent/src/coordinator/tests/health_sync.rs +1 -0
- package/crates/team-agent/src/coordinator/tests/spine.rs +1 -0
- package/crates/team-agent/src/coordinator/tests/takeover.rs +1 -0
- package/crates/team-agent/src/leader/lease.rs +57 -0
- package/crates/team-agent/src/leader/start.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch.rs +9 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +55 -0
- package/crates/team-agent/src/messaging/delivery.rs +83 -3
- package/crates/team-agent/src/messaging/leader_receiver.rs +61 -24
- package/crates/team-agent/src/messaging/tests/runtime.rs +2 -0
- package/crates/team-agent/src/messaging/tests/spine.rs +1 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +227 -25
- package/crates/team-agent/src/tmux_backend.rs +409 -105
- package/crates/team-agent/src/transport/test_support.rs +1 -0
- package/crates/team-agent/src/transport/tests/mod.rs +1 -0
- package/crates/team-agent/src/transport/tests/wire.rs +2 -1
- package/crates/team-agent/src/transport.rs +77 -0
- package/npm/install.mjs +222 -14
- 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
|
-
|
|
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
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
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
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
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
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
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
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
//
|
|
1180
|
-
//
|
|
1181
|
-
//
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
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
|
-
|
|
1222
|
-
|
|
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
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
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
|
|
|
@@ -271,7 +271,8 @@
|
|
|
271
271
|
"sh",
|
|
272
272
|
false,
|
|
273
273
|
),
|
|
274
|
-
|
|
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
|
|