@team-agent/installer 0.3.27 → 0.3.29

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 (27) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/mod.rs +14 -0
  4. package/crates/team-agent/src/layout/manager.rs +241 -0
  5. package/crates/team-agent/src/layout/mod.rs +29 -0
  6. package/crates/team-agent/src/layout/overlay.rs +122 -0
  7. package/crates/team-agent/src/layout/placement.rs +47 -0
  8. package/crates/team-agent/src/layout/recovery.rs +101 -0
  9. package/crates/team-agent/src/layout/sessions.rs +277 -0
  10. package/crates/team-agent/src/layout/worker_env.rs +267 -0
  11. package/crates/team-agent/src/layout/worker_window_helpers.rs +35 -0
  12. package/crates/team-agent/src/leader/start.rs +32 -14
  13. package/crates/team-agent/src/leader/tests/identity.rs +30 -15
  14. package/crates/team-agent/src/lib.rs +3 -0
  15. package/crates/team-agent/src/lifecycle/launch.rs +58 -65
  16. package/crates/team-agent/src/lifecycle/restart/agent.rs +18 -10
  17. package/crates/team-agent/src/lifecycle/restart/common.rs +16 -9
  18. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +26 -0
  19. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +49 -75
  20. package/crates/team-agent/src/messaging/delivery.rs +17 -6
  21. package/crates/team-agent/src/messaging/results.rs +143 -0
  22. package/crates/team-agent/src/tmux_backend/tests.rs +94 -15
  23. package/crates/team-agent/src/tmux_backend.rs +164 -62
  24. package/crates/team-agent/src/transport/test_support.rs +3 -1
  25. package/crates/team-agent/src/transport/tests/mod.rs +4 -2
  26. package/crates/team-agent/src/transport.rs +15 -0
  27. package/package.json +4 -4
@@ -959,6 +959,18 @@ fn format_report_result_notification(
959
959
  if let Some(tests) = format_report_result_tests(envelope) {
960
960
  lines.push(tests);
961
961
  }
962
+ if let Some(changes) = format_report_result_changes(envelope) {
963
+ lines.push(changes);
964
+ }
965
+ if let Some(risks) = format_report_result_risks(envelope) {
966
+ lines.push(risks);
967
+ }
968
+ if let Some(artifacts) = format_report_result_artifacts(envelope) {
969
+ lines.push(artifacts);
970
+ }
971
+ if let Some(next_actions) = format_report_result_next_actions(envelope) {
972
+ lines.push(next_actions);
973
+ }
962
974
  lines.push(format!("Result id: {result_id}"));
963
975
  lines.push(
964
976
  "Team Agent stored this result. The coordinator/collect path will update team_state.md; no manual polling loop is needed."
@@ -986,6 +998,97 @@ fn format_report_result_tests(envelope: &serde_json::Value) -> Option<String> {
986
998
  }
987
999
  }
988
1000
 
1001
+ fn report_result_array<'a>(
1002
+ envelope: &'a serde_json::Value,
1003
+ key: &str,
1004
+ ) -> Option<&'a Vec<serde_json::Value>> {
1005
+ let values = envelope.get(key).and_then(serde_json::Value::as_array)?;
1006
+ if values.is_empty() {
1007
+ None
1008
+ } else {
1009
+ Some(values)
1010
+ }
1011
+ }
1012
+
1013
+ fn report_field<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a str> {
1014
+ value
1015
+ .get(key)
1016
+ .and_then(serde_json::Value::as_str)
1017
+ .filter(|text| !text.is_empty())
1018
+ }
1019
+
1020
+ fn report_field_any<'a>(value: &'a serde_json::Value, keys: &[&str]) -> Option<&'a str> {
1021
+ keys.iter().find_map(|key| report_field(value, key))
1022
+ }
1023
+
1024
+ fn format_report_result_changes(envelope: &serde_json::Value) -> Option<String> {
1025
+ let parts = report_result_array(envelope, "changes")?
1026
+ .iter()
1027
+ .filter_map(|change| {
1028
+ let path = report_field_any(change, &["path", "file", "filepath", "filename"])?;
1029
+ let kind = report_field_any(change, &["kind", "type", "action"]).unwrap_or("changed");
1030
+ let description =
1031
+ report_field_any(change, &["description", "summary", "detail", "details", "message"])
1032
+ .unwrap_or(path);
1033
+ Some(format!("{kind} {path}: {description}"))
1034
+ })
1035
+ .collect::<Vec<_>>();
1036
+ if parts.is_empty() {
1037
+ None
1038
+ } else {
1039
+ Some(format!("Changes: {}", parts.join(", ")))
1040
+ }
1041
+ }
1042
+
1043
+ fn format_report_result_risks(envelope: &serde_json::Value) -> Option<String> {
1044
+ let parts = report_result_array(envelope, "risks")?
1045
+ .iter()
1046
+ .filter_map(|risk| {
1047
+ let severity = report_field_any(risk, &["severity", "level"]).unwrap_or("low");
1048
+ let description =
1049
+ report_field_any(risk, &["description", "summary", "detail", "message"])?;
1050
+ Some(format!("{severity}: {description}"))
1051
+ })
1052
+ .collect::<Vec<_>>();
1053
+ if parts.is_empty() {
1054
+ None
1055
+ } else {
1056
+ Some(format!("Risks: {}", parts.join(", ")))
1057
+ }
1058
+ }
1059
+
1060
+ fn format_report_result_artifacts(envelope: &serde_json::Value) -> Option<String> {
1061
+ let parts = report_result_array(envelope, "artifacts")?
1062
+ .iter()
1063
+ .filter_map(|artifact| {
1064
+ let path = report_field_any(artifact, &["path", "file", "filepath", "filename"])?;
1065
+ let description =
1066
+ report_field_any(artifact, &["description", "summary", "detail"]).unwrap_or(path);
1067
+ Some(format!("{path}: {description}"))
1068
+ })
1069
+ .collect::<Vec<_>>();
1070
+ if parts.is_empty() {
1071
+ None
1072
+ } else {
1073
+ Some(format!("Artifacts: {}", parts.join(", ")))
1074
+ }
1075
+ }
1076
+
1077
+ fn format_report_result_next_actions(envelope: &serde_json::Value) -> Option<String> {
1078
+ let parts = report_result_array(envelope, "next_actions")?
1079
+ .iter()
1080
+ .filter_map(|action| {
1081
+ report_field_any(action, &["description", "summary", "action", "todo", "message"])
1082
+ .map(|text| text.to_string())
1083
+ })
1084
+ .collect::<Vec<_>>();
1085
+ if parts.is_empty() {
1086
+ None
1087
+ } else {
1088
+ Some(format!("Next actions: {}", parts.join(", ")))
1089
+ }
1090
+ }
1091
+
989
1092
  /// `_collect_results_and_notify_watchers` (`results.py:430`):coordinator tick 调用 —— collect +
990
1093
  /// notify_result_watchers 编排。daemon-path → Result。
991
1094
  pub fn collect_results_and_notify_watchers(
@@ -999,3 +1102,43 @@ pub fn collect_results_and_notify_watchers(
999
1102
  "notified": notified
1000
1103
  }))
1001
1104
  }
1105
+
1106
+ #[cfg(test)]
1107
+ mod tests {
1108
+ use super::format_report_result_notification;
1109
+
1110
+ #[test]
1111
+ fn report_result_notification_includes_full_envelope_sections() {
1112
+ let envelope = serde_json::json!({
1113
+ "schema_version": "result_envelope_v1",
1114
+ "task_id": "task-1",
1115
+ "agent_id": "worker",
1116
+ "status": "success",
1117
+ "summary": "done",
1118
+ "changes": [
1119
+ {"path": "src/a.rs", "kind": "modified", "description": "patched delivery"}
1120
+ ],
1121
+ "tests": [
1122
+ {"command": "cargo test", "status": "passed"}
1123
+ ],
1124
+ "risks": [
1125
+ {"severity": "low", "description": "none known"}
1126
+ ],
1127
+ "artifacts": [
1128
+ {"path": ".team/artifacts/evidence.md", "description": "evidence"}
1129
+ ],
1130
+ "next_actions": [
1131
+ {"description": "ship after review"}
1132
+ ]
1133
+ });
1134
+ let notification =
1135
+ format_report_result_notification("res_1", "task-1", "worker", "success", &envelope);
1136
+ assert!(notification.contains("Task task-1 reported success from worker: done"));
1137
+ assert!(notification.contains("Tests: cargo test=passed"));
1138
+ assert!(notification.contains("Changes: modified src/a.rs: patched delivery"));
1139
+ assert!(notification.contains("Risks: low: none known"));
1140
+ assert!(notification.contains("Artifacts: .team/artifacts/evidence.md: evidence"));
1141
+ assert!(notification.contains("Next actions: ship after review"));
1142
+ assert!(notification.contains("Result id: res_1"));
1143
+ }
1144
+ }
@@ -573,6 +573,40 @@ CaptureMissingToken, not the static CaptureContainsToken false-positive"
573
573
  );
574
574
  }
575
575
 
576
+ #[test]
577
+ fn inject_skip_consumption_payload_sends_enter_without_phase2_poll() {
578
+ let text = "hello leader [team-agent-token:skip]";
579
+ let (be, rec) = backend_with(MockResp::Out(ok(text)), vec![]);
580
+ let report = be
581
+ .inject(
582
+ &Target::Pane(PaneId::new("%7")),
583
+ &InjectPayload::TextSkipConsumptionPoll(text.to_string()),
584
+ Key::Enter,
585
+ true,
586
+ )
587
+ .expect("inject");
588
+ let calls = rec.lock().unwrap().clone();
589
+
590
+ assert_eq!(
591
+ report.submit_verification,
592
+ SubmitVerification::EnterSentWithoutPlaceholderCheck
593
+ );
594
+ assert_eq!(report.attempts, 1);
595
+ assert!(
596
+ calls.iter().any(|argv| {
597
+ argv.get(1).map(String::as_str) == Some("send-keys")
598
+ && argv.contains(&"Enter".to_string())
599
+ }),
600
+ "skip-consumption payload must still submit once; calls={calls:?}"
601
+ );
602
+ assert!(
603
+ !calls.iter().any(|argv| {
604
+ argv == &tmux_capture_argv(&PaneId::new("%7"), CaptureRange::Tail(40))
605
+ }),
606
+ "leader-bound skip payload must not run Phase 2 consumption polls; calls={calls:?}"
607
+ );
608
+ }
609
+
576
610
  // ═════════════════════════════════════════════════════════════════════════════
577
611
  // E46 0.3.24 task#327 P0 — submit verification false-positive / false-negative.
578
612
  //
@@ -595,15 +629,15 @@ CaptureMissingToken, not the static CaptureContainsToken false-positive"
595
629
  /// report `SubmitConsumptionUnverified`, NOT
596
630
  /// `EnterSentWithoutPlaceholderCheck`. Otherwise delivery emits delivered
597
631
  /// + message.delivered (假阳) even though the worker never consumed it.
598
- /// 0.3.27 amendment: when token IS visible in the pane (Phase 1 confirmed)
599
- /// but NOT consumed from bottom 5 after all retries, the grace fallback
600
- /// treats "paste landed but composer didn't clear" as
601
- /// EnterSentWithoutPlaceholderCheck. This handles bare-shell panes (MCP sim)
602
- /// and busy-agent TUIs (E55) that keep the token visible after submit.
603
- /// SubmitConsumptionUnverified is reserved for the case where the token was
604
- /// NEVER visible (paste truly failed / dropped).
632
+ /// 0.3.28-final E55 truth source: grace fallback DELETED.
633
+ /// `consumed=Some(false)` (token still in bottom 5 after all retries)
634
+ /// MUST report SubmitConsumptionUnverified unconditionally. Pre-final
635
+ /// returned EnterSentWithoutPlaceholderCheck when Phase-1 saw the token
636
+ /// at all (token_visible_for_report=Some(true)) but Phase-1 visibility
637
+ /// only proves the paste LANDED, not that the provider CONSUMED it.
638
+ /// That masked E55 busy-agent false positives as delivered=true.
605
639
  #[test]
606
- fn e46_inject_text_with_token_visible_but_unconsumed_uses_grace_fallback() {
640
+ fn e46_inject_text_with_token_visible_but_unconsumed_reports_unverified() {
607
641
  let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_red1]";
608
642
  let (be, _rec) = backend_with(MockResp::Out(ok(token_text)), vec![]);
609
643
  let report = be
@@ -614,20 +648,52 @@ CaptureMissingToken, not the static CaptureContainsToken false-positive"
614
648
  true,
615
649
  )
616
650
  .expect("inject runs");
617
- // 0.3.27 grace fallback: token visible + not consumed from bottom
618
- // EnterSentWithoutPlaceholderCheck (paste landed, consumption unclear).
651
+ // 0.3.28-final: no grace fallback. Token visible + not consumed →
652
+ // SubmitConsumptionUnverified (delivery treats as not delivered).
619
653
  assert_eq!(
620
654
  report.submit_verification,
621
- SubmitVerification::EnterSentWithoutPlaceholderCheck,
622
- "0.3.27: when token is visible but not consumed from bottom 5 \
623
- after all retries, grace fallback degrades to \
624
- EnterSentWithoutPlaceholderCheck (paste landed, bare-shell/busy \
625
- pane parity). Got {:?}",
655
+ SubmitVerification::SubmitConsumptionUnverified,
656
+ "0.3.28-final: when token still in bottom 5 after all retries, \
657
+ MUST be SubmitConsumptionUnverified grace fallback masking \
658
+ this as EnterSentWithoutPlaceholderCheck caused E55 false \
659
+ positives (paste landed but busy agent never consumed). \
660
+ Got {:?}",
626
661
  report.submit_verification
627
662
  );
628
663
  assert_eq!(report.turn_verification, TurnVerification::NotYetObserved);
629
664
  }
630
665
 
666
+ #[test]
667
+ fn e46_unconsumed_token_with_live_busy_state_is_treated_as_processing() {
668
+ let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_busy]";
669
+ let busy_tail = format!("{token_text}\n● Working (1s · esc to interrupt)\n");
670
+ let (be, _rec) = backend_with(MockResp::Out(ok(&busy_tail)), vec![]);
671
+ let report = be
672
+ .inject(
673
+ &Target::Pane(PaneId::new("%7")),
674
+ &InjectPayload::Text(token_text.to_string()),
675
+ Key::Enter,
676
+ true,
677
+ )
678
+ .expect("inject runs");
679
+ assert_eq!(
680
+ report.submit_verification,
681
+ SubmitVerification::EnterSentWithoutPlaceholderCheck,
682
+ "busy provider state means the turn is being processed even if \
683
+ the token has not yet scrolled out of the bottom capture"
684
+ );
685
+ let diagnostics = report.submit_diagnostics.expect("diagnostics");
686
+ assert!(
687
+ diagnostics
688
+ .attempts_detail
689
+ .last()
690
+ .map(|obs| obs.pane_tail_excerpt.to_ascii_lowercase().contains("working"))
691
+ .unwrap_or(false),
692
+ "busy-state capture should be recorded in attempts_detail: {:?}",
693
+ diagnostics.attempts_detail
694
+ );
695
+ }
696
+
631
697
  /// 0.3.27: empty pane captures → consumption poll sees "no token in
632
698
  /// bottom 5" → consumed=true → EnterSentWithoutPlaceholderCheck. This is
633
699
  /// the generous default for panes where the capture can't distinguish
@@ -967,6 +1033,19 @@ CaptureMissingToken, not the static CaptureContainsToken false-positive"
967
1033
  EnterSentWithoutPlaceholderCheck; got {:?}",
968
1034
  report.submit_verification
969
1035
  );
1036
+ let diagnostics = report.submit_diagnostics.expect("diagnostics");
1037
+ assert!(
1038
+ !diagnostics.attempts_detail.is_empty(),
1039
+ "E50: E46 consumption gate must preserve per-capture diagnostics"
1040
+ );
1041
+ assert_eq!(diagnostics.attempts_detail[0].attempt_index, 1);
1042
+ assert!(
1043
+ diagnostics.attempts_detail[0]
1044
+ .pane_tail_excerpt
1045
+ .contains("Pasted content"),
1046
+ "E50: recorded pane tail should contain the post-submit capture; got {:?}",
1047
+ diagnostics.attempts_detail[0]
1048
+ );
970
1049
  }
971
1050
 
972
1051
  /// **E50 PR-1 secret scrubbing**: `scrub_pane_excerpt` must strip ANSI
@@ -29,7 +29,8 @@ use crate::transport::{
29
29
  tmux_query_argv, tmux_send_keys_argv, tmux_spawn_argv, AttachOutcome, BackendKind,
30
30
  CaptureRange, CapturedText, InjectPayload, InjectReport, InjectStage, InjectVerification, Key,
31
31
  PaneField, PaneId, PaneInfo, PaneMode, SessionName, SetEnvOutcome, SpawnResult,
32
- SubmitVerification, Target, Transport, TransportError, TurnVerification, WindowName,
32
+ SubmitAttemptObservation, SubmitVerification, Target, Transport, TransportError,
33
+ TurnVerification, WindowName,
33
34
  };
34
35
 
35
36
  /// Result of running an external command — the typed output of the OS edge.
@@ -815,10 +816,12 @@ fn buffer_name_for_text(text: &str) -> String {
815
816
  fn inject_verification_for_payload(payload: &InjectPayload) -> InjectVerification {
816
817
  match payload {
817
818
  InjectPayload::Empty => InjectVerification::EmptyTextSendKeys,
818
- InjectPayload::Text(text) if text.contains("[team-agent-token:") => {
819
+ InjectPayload::Text(text) | InjectPayload::TextSkipConsumptionPoll(text)
820
+ if text.contains("[team-agent-token:") =>
821
+ {
819
822
  InjectVerification::CaptureContainsToken
820
823
  }
821
- InjectPayload::Text(_) => InjectVerification::NoToken,
824
+ InjectPayload::Text(_) | InjectPayload::TextSkipConsumptionPoll(_) => InjectVerification::NoToken,
822
825
  }
823
826
  }
824
827
 
@@ -826,15 +829,11 @@ fn inject_verification_for_payload(payload: &InjectPayload) -> InjectVerificatio
826
829
  /// (`[team-agent-token:<id>]`). Use the full marker, not only the prefix, so an old
827
830
  /// scrollback token cannot verify a new message.
828
831
  fn payload_token_marker(payload: &InjectPayload) -> Option<&str> {
829
- match payload {
830
- InjectPayload::Text(text) => {
831
- let start = text.find("[team-agent-token:")?;
832
- let marker = &text[start..];
833
- let end = marker.find(']')?;
834
- Some(&marker[..=end])
835
- }
836
- _ => None,
837
- }
832
+ let text = payload.text()?;
833
+ let start = text.find("[team-agent-token:")?;
834
+ let marker = &text[start..];
835
+ let end = marker.find(']')?;
836
+ Some(&marker[..=end])
838
837
  }
839
838
 
840
839
  fn token_visible_in_capture(
@@ -940,6 +939,74 @@ fn token_in_bottom_n(text: &str, marker: &str, n: usize) -> bool {
940
939
  .any(|line| line.contains(marker))
941
940
  }
942
941
 
942
+ fn marker_position_from_bottom(text: &str, marker: &str) -> Option<u32> {
943
+ let mut from_bottom = 0u32;
944
+ for line in text.lines().rev().filter(|line| !line.trim().is_empty()) {
945
+ if line.contains(marker) {
946
+ return Some(from_bottom);
947
+ }
948
+ from_bottom = from_bottom.saturating_add(1);
949
+ }
950
+ None
951
+ }
952
+
953
+ fn provider_busy_signal_in_tail(text: &str) -> bool {
954
+ text.lines()
955
+ .rev()
956
+ .filter(|line| !line.trim().is_empty())
957
+ .take(15)
958
+ .any(|line| {
959
+ let lower = line.to_ascii_lowercase();
960
+ lower.contains("working")
961
+ || lower.contains("thinking")
962
+ || lower.contains("esc to interrupt")
963
+ || line.contains('●')
964
+ || line.contains('⏳')
965
+ || line.contains('⠋')
966
+ || line.contains('⠙')
967
+ || line.contains('⠹')
968
+ || line.contains('⠸')
969
+ || line.contains('⠼')
970
+ || line.contains('⠴')
971
+ || line.contains('⠦')
972
+ || line.contains('⠧')
973
+ || line.contains('⠇')
974
+ || line.contains('⠏')
975
+ || line.contains('✶')
976
+ || line.contains('✢')
977
+ })
978
+ }
979
+
980
+ fn submit_attempt_observation(
981
+ attempt_index: u32,
982
+ captured: &CapturedText,
983
+ marker: Option<&str>,
984
+ elapsed_ms: u64,
985
+ ) -> SubmitAttemptObservation {
986
+ let marker_position = marker.and_then(|m| marker_position_from_bottom(&captured.text, m));
987
+ let (matched, matched_literal, where_in_tail) = if let Some(marker) = marker {
988
+ (
989
+ token_in_bottom_n(&captured.text, marker, 15),
990
+ marker_position.map(|_| marker.to_string()),
991
+ marker_position,
992
+ )
993
+ } else if let Some((literal, where_in_tail)) = pasted_prompt_match(&captured.text) {
994
+ (true, Some(literal.to_string()), Some(where_in_tail))
995
+ } else {
996
+ (false, None, None)
997
+ };
998
+ let (pane_tail_excerpt, pane_tail_lines) = scrub_pane_excerpt(&captured.text, 20);
999
+ SubmitAttemptObservation {
1000
+ attempt_index,
1001
+ matched,
1002
+ matched_literal,
1003
+ where_in_tail,
1004
+ pane_tail_excerpt,
1005
+ pane_tail_lines,
1006
+ elapsed_ms,
1007
+ }
1008
+ }
1009
+
943
1010
  /// 0.3.27: check if a pasted-content prompt literal (`pasted content` / `pasted text`)
944
1011
  /// appears in the bottom N non-empty lines. Narrower than the full-Tail(80) check
945
1012
  /// that caused scrollback ghost matches (E50 defect B).
@@ -1184,15 +1251,15 @@ fn post_submit_input_consumed(
1184
1251
  let Some(marker) = payload_token_marker(payload) else {
1185
1252
  return Ok(None);
1186
1253
  };
1187
- let captured = backend.capture(target, CaptureRange::Tail(30))?;
1254
+ let captured = backend.capture(target, CaptureRange::Tail(40))?;
1188
1255
  // The token may legitimately appear in scrollback (a successful submit
1189
1256
  // pushes it into history). We only treat the BOTTOM-of-pane region (last
1190
1257
  // few lines, where the input area lives) as the consumption signal. Tail
1191
1258
  // 30 lines is small enough that the input area still dominates if the
1192
1259
  // submit didn't go through, while a successful submit has pushed the
1193
- // token marker out of the bottom 5 lines by the time the response
1260
+ // token marker out of the bottom 15 lines by the time the response
1194
1261
  // composer redraws.
1195
- let tail_lines: Vec<&str> = captured.text.lines().rev().take(5).collect();
1262
+ let tail_lines: Vec<&str> = captured.text.lines().rev().take(15).collect();
1196
1263
  let token_in_tail = tail_lines.iter().any(|line| line.contains(marker));
1197
1264
  Ok(Some(!token_in_tail))
1198
1265
  }
@@ -1333,7 +1400,7 @@ impl Transport for TmuxBackend {
1333
1400
  let argv = tmux_empty_inject_argv(&pane, submit);
1334
1401
  self.run_ok(&argv)?;
1335
1402
  }
1336
- InjectPayload::Text(text) => {
1403
+ InjectPayload::Text(text) | InjectPayload::TextSkipConsumptionPoll(text) => {
1337
1404
  let buffer = buffer_name_for_text(text);
1338
1405
  for argv in tmux_inject_text_argv(&pane, &buffer, text, bracketed) {
1339
1406
  let stage = inject_stage_for_argv(&argv);
@@ -1389,7 +1456,7 @@ impl Transport for TmuxBackend {
1389
1456
 
1390
1457
  // Phase 2: submit_and_verify — unified Escape+Enter+poll loop.
1391
1458
  let use_escape = bracketed
1392
- && matches!(payload, InjectPayload::Text(_))
1459
+ && payload.text().is_some()
1393
1460
  && matches!(submit, Key::Enter);
1394
1461
  let escape_argv = if use_escape {
1395
1462
  Some(tmux_send_keys_argv(&pane, &[Key::Escape]))
@@ -1424,16 +1491,33 @@ impl Transport for TmuxBackend {
1424
1491
  let max_submit_attempts: u32 = 3;
1425
1492
  let mut consumption_attempts: u32 = 0;
1426
1493
  let mut consumed: Option<bool> = None;
1494
+ let mut attempts_detail: Vec<SubmitAttemptObservation> = Vec::new();
1427
1495
 
1428
- for attempt in 0..max_submit_attempts {
1496
+ let poll_consumption = !payload.skip_consumption_poll();
1497
+ if !poll_consumption {
1498
+ if self.run_inject_stage(&submit_argv, InjectStage::Submit).is_ok() {
1499
+ consumption_attempts = 1;
1500
+ }
1501
+ }
1502
+ let submit_attempt_limit = if poll_consumption { max_submit_attempts } else { 0 };
1503
+
1504
+ for attempt in 0..submit_attempt_limit {
1505
+ let attempt_index = attempt + 1;
1506
+ let attempt_start = std::time::Instant::now();
1429
1507
  // Before resending (attempt > 0), re-check if the token
1430
1508
  // already disappeared — guards against double-submit (C3).
1431
1509
  // Capture failures are non-fatal (tmux may not be running
1432
1510
  // in MCP sim / test env).
1433
1511
  if attempt > 0 {
1434
1512
  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) {
1513
+ if let Ok(cap) = self.capture(target, CaptureRange::Tail(40)) {
1514
+ attempts_detail.push(submit_attempt_observation(
1515
+ attempt_index,
1516
+ &cap,
1517
+ marker,
1518
+ attempt_start.elapsed().as_millis() as u64,
1519
+ ));
1520
+ if !token_in_bottom_n(&cap.text, m, 15) {
1437
1521
  consumed = Some(true);
1438
1522
  break;
1439
1523
  }
@@ -1441,25 +1525,15 @@ impl Transport for TmuxBackend {
1441
1525
  }
1442
1526
  }
1443
1527
 
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
- }
1528
+ // 0.3.28-final (E55 false-positive truth source):
1529
+ // Escape retry is DELETED. The researcher established
1530
+ // Escape on Claude TUI with [Pasted content] visible
1531
+ // may CLEAR the composer content rather than exit paste
1532
+ // mode sending Escape+Enter on retry would submit an
1533
+ // empty message and hide the genuine consumption failure
1534
+ // under a fake-success path. Python parity: ONLY ever
1535
+ // send Enter, never Escape.
1536
+ let _ = escape_argv;
1463
1537
 
1464
1538
  // Enter — send-keys failure is degraded (tmux may not have
1465
1539
  // the pane in sim/test env). Break to consumed=None path
@@ -1478,16 +1552,22 @@ impl Transport for TmuxBackend {
1478
1552
  .unwrap_or(Some(false));
1479
1553
  }
1480
1554
 
1481
- // Poll: token disappeared from bottom 5 lines = consumed.
1555
+ // Poll: token disappeared from bottom 15 lines = consumed.
1482
1556
  // Capture failures → consumed=None (non-blocking).
1483
1557
  if let Some(m) = marker {
1484
1558
  let mut found_consumed = false;
1485
1559
  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)) {
1560
+ for _ in 0..12 {
1561
+ std::thread::sleep(Duration::from_millis(100));
1562
+ match self.capture(target, CaptureRange::Tail(40)) {
1489
1563
  Ok(cap) => {
1490
- if !token_in_bottom_n(&cap.text, m, 5) {
1564
+ attempts_detail.push(submit_attempt_observation(
1565
+ attempt_index,
1566
+ &cap,
1567
+ marker,
1568
+ attempt_start.elapsed().as_millis() as u64,
1569
+ ));
1570
+ if !token_in_bottom_n(&cap.text, m, 15) {
1491
1571
  found_consumed = true;
1492
1572
  break;
1493
1573
  }
@@ -1512,23 +1592,45 @@ impl Transport for TmuxBackend {
1512
1592
 
1513
1593
  let submit_verification = match consumed {
1514
1594
  Some(true) => SubmitVerification::EnterSentWithoutPlaceholderCheck,
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 {
1595
+ // 0.3.28-final (E55 truth source): consumed=Some(false)
1596
+ // means we ran ALL retries and the token never left
1597
+ // bottom 15 lines. That is a genuine consumption failure
1598
+ // unless a provider busy marker proves the TUI is already
1599
+ // processing the turn and simply has not scrolled yet;
1600
+ // it MUST NOT be masked. Pre-final used a grace fallback
1601
+ // that returned EnterSentWithoutPlaceholderCheck whenever
1602
+ // token_visible_for_report=Some(true) but Phase-1 token
1603
+ // visibility only proves the paste landed, NOT that the
1604
+ // provider consumed it. The fallback caused
1605
+ // delivered=true under E55 (busy-agent paste-landed-not-
1606
+ // consumed false positive). Do not restore that grace
1607
+ // fallback; only a live busy-state signal upgrades this to
1608
+ // EnterSentWithoutPlaceholderCheck.
1609
+ Some(false) => match self.capture(target, CaptureRange::Tail(15)) {
1610
+ Ok(cap) => {
1611
+ attempts_detail.push(submit_attempt_observation(
1612
+ consumption_attempts.max(1),
1613
+ &cap,
1614
+ marker,
1615
+ inject_start.elapsed().as_millis() as u64,
1616
+ ));
1617
+ let busy = provider_busy_signal_in_tail(&cap.text);
1618
+ eprintln!(
1619
+ "team-agent submit consumption fallback: consumed=false busy_state={busy}"
1620
+ );
1621
+ if busy {
1622
+ SubmitVerification::EnterSentWithoutPlaceholderCheck
1623
+ } else {
1624
+ SubmitVerification::SubmitConsumptionUnverified
1625
+ }
1626
+ }
1627
+ Err(err) => {
1628
+ eprintln!(
1629
+ "team-agent submit consumption fallback: consumed=false busy_capture_error={err}"
1630
+ );
1529
1631
  SubmitVerification::SubmitConsumptionUnverified
1530
1632
  }
1531
- }
1633
+ },
1532
1634
  None => submit_verification_for_key(submit),
1533
1635
  };
1534
1636
  let total_elapsed_ms = inject_start.elapsed().as_millis() as u64;
@@ -1541,14 +1643,14 @@ impl Transport for TmuxBackend {
1541
1643
  submit_verification,
1542
1644
  turn_verification: match payload {
1543
1645
  InjectPayload::Empty => TurnVerification::NotRequired,
1544
- InjectPayload::Text(_) => TurnVerification::NotYetObserved,
1646
+ InjectPayload::Text(_) | InjectPayload::TextSkipConsumptionPoll(_) => TurnVerification::NotYetObserved,
1545
1647
  },
1546
1648
  attempts: consumption_attempts,
1547
1649
  submit_diagnostics: Some(crate::transport::SubmitDiagnostics {
1548
1650
  appear_gate_elapsed_ms: 0,
1549
1651
  appear_gate_matched: false,
1550
1652
  total_elapsed_ms,
1551
- attempts_detail: Vec::new(),
1653
+ attempts_detail,
1552
1654
  }),
1553
1655
  });
1554
1656
  }
@@ -1562,7 +1664,7 @@ impl Transport for TmuxBackend {
1562
1664
  submit_verification: submit_verification_for_key(submit),
1563
1665
  turn_verification: match payload {
1564
1666
  InjectPayload::Empty => TurnVerification::NotRequired,
1565
- InjectPayload::Text(_) => TurnVerification::NotYetObserved,
1667
+ InjectPayload::Text(_) | InjectPayload::TextSkipConsumptionPoll(_) => TurnVerification::NotYetObserved,
1566
1668
  },
1567
1669
  attempts: 1,
1568
1670
  // E50 PR-1: Empty payload / non-Text fallthrough path — no submit