@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.
- 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/coordinator/tick.rs +55 -7
- 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 +217 -19
- package/crates/team-agent/src/lifecycle/restart/agent.rs +116 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +66 -1
- package/crates/team-agent/src/lifecycle/restart.rs +19 -2
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +994 -0
- package/crates/team-agent/src/messaging/delivery.rs +91 -1
- package/crates/team-agent/src/messaging/helpers.rs +64 -24
- package/crates/team-agent/src/messaging/tests/runtime.rs +2 -0
- package/crates/team-agent/src/messaging/tests/spine.rs +158 -4
- package/crates/team-agent/src/tmux_backend/tests.rs +414 -2
- package/crates/team-agent/src/tmux_backend.rs +366 -2
- 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 +103 -1
- package/npm/install.mjs +312 -39
- 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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
|
|
@@ -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
|
|