@team-agent/installer 0.3.25 → 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.
@@ -348,6 +348,7 @@ pub fn deliver_pending_message(
348
348
  attempt,
349
349
  "send_inject_exhausted",
350
350
  &reason,
351
+ None,
351
352
  )?;
352
353
  return Ok(DeliveryOutcome {
353
354
  ok: false,
@@ -387,6 +388,13 @@ pub fn deliver_pending_message(
387
388
  submit_verification_wire(inject_report.submit_verification)
388
389
  )
389
390
  };
391
+ // E50 PR-1 (0.3.24 P0): render forensic submit_diagnostics into the
392
+ // event so operators see per-attempt pane state without grepping. The
393
+ // legacy keys (`message_id` / `recipient` / `reason` / `attempts`)
394
+ // are preserved byte-for-byte for grep compatibility; new keys are
395
+ // ADDITIONAL.
396
+ let submit_attempts_detail =
397
+ render_submit_diagnostics(&inject_report);
390
398
  event_log.write(
391
399
  "send.unverified",
392
400
  serde_json::json!({
@@ -394,6 +402,7 @@ pub fn deliver_pending_message(
394
402
  "recipient": message.recipient,
395
403
  "reason": reason,
396
404
  "attempts": inject_report.attempts,
405
+ "submit_attempts_detail": submit_attempts_detail,
397
406
  }),
398
407
  )?;
399
408
  if inject_report.attempts >= u32::from(SEND_RETRY_MAX_ATTEMPTS) {
@@ -407,6 +416,7 @@ pub fn deliver_pending_message(
407
416
  inject_report.attempts,
408
417
  "send_unverified_exhausted",
409
418
  &reason,
419
+ Some(&inject_report),
410
420
  )?;
411
421
  return Ok(DeliveryOutcome {
412
422
  ok: false,
@@ -504,7 +514,8 @@ fn pane_readback_verified(report: &InjectReport) -> bool {
504
514
  /// [team-agent-token:{message_id}]`. The worker (fake or real provider) only builds a result_envelope
505
515
  /// when it sees this block + extracts the token — the bare content gives WORKING but never a report
506
516
  /// (rt-host-a loop #4). token == message_id (exactly-once correlation).
507
- fn render_message(sender: &str, task_id: Option<&str>, content: &str, message_id: &str) -> String {
517
+ /// F1 (0.3.26): promoted to `pub` for direct pane send in cli/send.rs.
518
+ pub fn render_message(sender: &str, task_id: Option<&str>, content: &str, message_id: &str) -> String {
508
519
  let mut header = format!("Team Agent message from {sender}");
509
520
  if let Some(task_id) = task_id.filter(|t| !t.is_empty()) {
510
521
  header.push_str(&format!(" for {task_id}"));
@@ -557,7 +568,23 @@ fn resolve_inject_target(
557
568
  .and_then(serde_json::Value::as_str)
558
569
  .filter(|s| !s.is_empty())
559
570
  .map(PaneId::new);
571
+ // E51 (0.3.26 P0, delivery loop guard): if the worker's cached pane_id is
572
+ // the SAME as leader_receiver.pane_id (the leader's handle), injecting into
573
+ // it would deliver the worker's message back to the leader pane — a routing
574
+ // loop that the macmini "hand-handle mapping 灾難" truth source exposed. Fail
575
+ // loud with a SessionWindow target that will surface as a structured error
576
+ // (the SessionWindow "leader" target trips the existing leader_not_attached
577
+ // guard on any subsequent delivery attempt for this message). The root fix
578
+ // is in lease.rs (E51 guard #1) which prevents the conflation; this is the
579
+ // defence-in-depth in the delivery layer.
560
580
  if let Some(pane) = cached_pane.as_ref() {
581
+ let leader_pane = leader_receiver_pane_id(state);
582
+ if leader_pane.is_some_and(|lp| lp == pane.as_str()) {
583
+ return Target::SessionWindow {
584
+ session: SessionName::new(session),
585
+ window: WindowName::new(format!("{recipient}_pane_conflicts_with_leader")),
586
+ };
587
+ }
561
588
  if live_targets
562
589
  .iter()
563
590
  .any(|target| target.pane_id.as_str() == pane.as_str())
@@ -755,6 +782,34 @@ fn leader_receiver_field_in_state<'a>(
755
782
  .filter(|value| !value.is_empty())
756
783
  }
757
784
 
785
+ /// E50 PR-1 (0.3.24 P0): render `InjectReport.submit_diagnostics` as a JSON
786
+ /// array suitable for inclusion in `send.unverified` / `send.failed` events.
787
+ /// `null` if no diagnostics were attached (e.g. the inject went through a
788
+ /// path that bypassed the paste-prompt + Enter instrumentation, or this is
789
+ /// the pre-PR-2 inject-failed path with no `InjectReport`).
790
+ fn render_submit_diagnostics(report: &InjectReport) -> serde_json::Value {
791
+ let Some(diag) = report.submit_diagnostics.as_ref() else {
792
+ return serde_json::Value::Null;
793
+ };
794
+ serde_json::Value::Array(
795
+ diag.attempts_detail
796
+ .iter()
797
+ .map(|obs| {
798
+ serde_json::json!({
799
+ "attempt_index": obs.attempt_index,
800
+ "matched": obs.matched,
801
+ "matched_literal": obs.matched_literal,
802
+ "where_in_tail": obs.where_in_tail,
803
+ "pane_tail_excerpt": obs.pane_tail_excerpt,
804
+ "pane_tail_lines": obs.pane_tail_lines,
805
+ "elapsed_ms": obs.elapsed_ms,
806
+ })
807
+ })
808
+ .collect(),
809
+ )
810
+ }
811
+
812
+ #[allow(clippy::too_many_arguments)]
758
813
  fn emit_send_failed_exhausted(
759
814
  workspace: &Path,
760
815
  state: &serde_json::Value,
@@ -764,7 +819,26 @@ fn emit_send_failed_exhausted(
764
819
  attempts: u32,
765
820
  failure_reason: &str,
766
821
  verification: &str,
822
+ inject_report: Option<&InjectReport>,
767
823
  ) -> Result<(), MessagingError> {
824
+ // E50 PR-1 (0.3.24 P0): forensic fields on send.failed. Legacy keys
825
+ // (`message_id` / `recipient` / `attempts` / `max_attempts` / `reason` /
826
+ // `verification`) preserved byte-for-byte for grep compatibility.
827
+ let submit_attempts_detail = inject_report
828
+ .map(render_submit_diagnostics)
829
+ .unwrap_or_else(|| serde_json::Value::Array(Vec::new()));
830
+ let total_elapsed_ms = inject_report
831
+ .and_then(|r| r.submit_diagnostics.as_ref())
832
+ .map(|d| d.total_elapsed_ms)
833
+ .unwrap_or(0);
834
+ let last_matched_literal = inject_report
835
+ .and_then(|r| r.submit_diagnostics.as_ref())
836
+ .and_then(|d| d.attempts_detail.last())
837
+ .and_then(|a| a.matched_literal.clone());
838
+ let last_pane_tail_excerpt = inject_report
839
+ .and_then(|r| r.submit_diagnostics.as_ref())
840
+ .and_then(|d| d.attempts_detail.last())
841
+ .map(|a| a.pane_tail_excerpt.clone());
768
842
  event_log.write(
769
843
  "send.failed",
770
844
  serde_json::json!({
@@ -774,6 +848,10 @@ fn emit_send_failed_exhausted(
774
848
  "max_attempts": SEND_RETRY_MAX_ATTEMPTS,
775
849
  "reason": failure_reason,
776
850
  "verification": verification,
851
+ "submit_attempts_detail": submit_attempts_detail,
852
+ "total_elapsed_ms": total_elapsed_ms,
853
+ "last_matched_literal": last_matched_literal,
854
+ "last_pane_tail_excerpt": last_pane_tail_excerpt,
777
855
  }),
778
856
  )?;
779
857
  let content = format!(
@@ -1308,6 +1308,7 @@ impl Transport for UnverifiedInjectTransport {
1308
1308
  crate::transport::SubmitVerification::PastedContentPromptStillPresentAfterSubmit,
1309
1309
  turn_verification: crate::transport::TurnVerification::NotYetObserved,
1310
1310
  attempts: u32::from(SEND_RETRY_MAX_ATTEMPTS),
1311
+ submit_diagnostics: None,
1311
1312
  })
1312
1313
  }
1313
1314
  fn send_keys(&self, _t: &Target, _k: &[Key]) -> Result<(), TransportError> {
@@ -1467,6 +1468,7 @@ impl Transport for ReadbackMissingTransport {
1467
1468
  },
1468
1469
  turn_verification: crate::transport::TurnVerification::NotYetObserved,
1469
1470
  attempts: 1,
1471
+ submit_diagnostics: None,
1470
1472
  })
1471
1473
  }
1472
1474
  fn send_keys(&self, _: &Target, _: &[Key]) -> Result<(), TransportError> {
@@ -173,6 +173,7 @@ impl Transport for DeliverOkTransport {
173
173
  submit_verification: crate::transport::SubmitVerification::EnterSentWithoutPlaceholderCheck,
174
174
  turn_verification: crate::transport::TurnVerification::NotYetObserved,
175
175
  attempts: 1,
176
+ submit_diagnostics: None,
176
177
  })
177
178
  }
178
179
  fn send_keys(&self, _t: &Target, _k: &[Key]) -> Result<(), TransportError> {
@@ -369,9 +369,10 @@
369
369
  .expect("spawn_split");
370
370
  assert_eq!(result.pane_id.as_str(), "%5");
371
371
  let calls = rec.lock().unwrap().clone();
372
+ // E53 (0.3.26): split-window now carries `-d` (no focus steal).
372
373
  assert_eq!(
373
- calls[0][0..4],
374
- svec(&["tmux", "split-window", "-t", "teamsess:team-w1"])
374
+ calls[0][0..5],
375
+ svec(&["tmux", "split-window", "-d", "-t", "teamsess:team-w1"])
375
376
  );
376
377
  assert_eq!(
377
378
  calls[1],
@@ -823,6 +824,166 @@ CaptureMissingToken, not the static CaptureContainsToken false-positive"
823
824
  );
824
825
  }
825
826
 
827
+ // ═════════════════════════════════════════════════════════════════════════
828
+ // E50 PR-1 (0.3.24 P0, pasted-prompt 假阴诊断) — InjectReport.submit_diagnostics
829
+ // is populated for paste-prompt loops, and `pasted_prompt_match` / scrubbing
830
+ // helpers are pure functions safe to test deterministically.
831
+ //
832
+ // Architect verdict (Workflow forensic): the pre-fix
833
+ // `capture_has_pasted_content_prompt` matched ANY `pasted content` /
834
+ // `pasted text` token in the full Tail(80), including scrolled-off
835
+ // submitted blocks. PR-1 just adds the diagnostics; PR-2 will USE
836
+ // `where_in_tail` to fix the criterion. These tests pin the data shape.
837
+ // ═════════════════════════════════════════════════════════════════════════
838
+
839
+ /// **E50 RED-1 (判据误判, pure function)**: `pasted_prompt_match` must
840
+ /// distinguish bottom-region (composer) from scrollback (line 6+ from
841
+ /// bottom). Pre-fix `capture_has_pasted_content_prompt` returned `true`
842
+ /// for BOTH; the new tuple-returning helper surfaces the offset so PR-2
843
+ /// can fix the criterion.
844
+ #[test]
845
+ fn e50_pasted_prompt_match_reports_where_in_tail_for_scrollback_vs_composer() {
846
+ use super::pasted_prompt_match;
847
+
848
+ // Composer (bottom) match.
849
+ let composer_only = "some output\n\
850
+ [Pasted content 1.2k]\n\
851
+ ❯ ";
852
+ // The match is "pasted content" in the line at index 1 from bottom
853
+ // among non-empty lines (`[Pasted content...]`, `❯ ` is at idx 0).
854
+ let m = pasted_prompt_match(composer_only).expect("match");
855
+ assert_eq!(m.0, "pasted content");
856
+ assert_eq!(
857
+ m.1, 1,
858
+ "E50: where_in_tail must reflect bottom offset (idx 1 = above the \
859
+ `❯` composer line); got {m:?}"
860
+ );
861
+
862
+ // Scrollback (top) match — 8 lines from bottom.
863
+ let scrollback_only = "[Pasted content 5.0k]\n\
864
+ assistant reply line 1\n\
865
+ assistant reply line 2\n\
866
+ assistant reply line 3\n\
867
+ assistant reply line 4\n\
868
+ assistant reply line 5\n\
869
+ assistant reply line 6\n\
870
+ assistant reply line 7\n\
871
+ ❯ ";
872
+ let m2 = pasted_prompt_match(scrollback_only).expect("match");
873
+ assert_eq!(m2.0, "pasted content");
874
+ assert!(
875
+ m2.1 >= 6,
876
+ "E50: scrollback `Pasted content` must report where_in_tail >= 6 \
877
+ (clearly above the bottom composer region); got {m2:?}. PR-2 \
878
+ will use this offset to distinguish live composer vs scrollback \
879
+ residue."
880
+ );
881
+
882
+ // No match.
883
+ assert!(pasted_prompt_match("nothing here\n❯ ").is_none());
884
+ }
885
+
886
+ /// **E50 PR-2 Fix-B (amends PR-1 RED-2)**: with Fix-B, token-bearing
887
+ /// payloads SKIP the paste-prompt weak loop and fall through to the E46
888
+ /// token consumption gate. The `submit_diagnostics` still records the
889
+ /// appear-gate timing (was `saw_pasted_prompt` matched?), but
890
+ /// `attempts_detail` is populated by the E46 consumption gate path, not
891
+ /// the deleted weak loop. This test pins the NEW shape: `appear_gate_matched`
892
+ /// may be false (mock default returns the pasted-content text, but the
893
+ /// appear-gate polls 5 times with 25ms sleep and the mock answers the SAME
894
+ /// text every time → appear-gate DOES match). However, the `has_token` guard
895
+ /// skips the weak loop body → the token path's diagnostics fill in instead.
896
+ #[test]
897
+ fn e50_inject_paste_prompt_path_populates_submit_diagnostics_per_attempt() {
898
+ let token_text =
899
+ "Team Agent message from leader:\n\nhello\n\n[team-agent-token:msg_pr1]";
900
+ // Mock keeps returning the pasted-content placeholder. With Fix-B,
901
+ // the token path takes over: post_submit_input_consumed checks
902
+ // bottom 5 lines for the token marker. The mock returns the pasted
903
+ // placeholder text (without the token) → token NOT in tail → consumed
904
+ // = Some(true) → EnterSentWithoutPlaceholderCheck.
905
+ // We test: (a) diagnostics present (b) submit verification reflects
906
+ // the E46 token path, not the deleted weak loop.
907
+ let pasted = "[Pasted content 1.2k]";
908
+ let (be, _rec) = backend_with(MockResp::Out(ok(pasted)), vec![]);
909
+ let report = be
910
+ .inject(
911
+ &Target::Pane(PaneId::new("%7")),
912
+ &InjectPayload::Text(token_text.to_string()),
913
+ Key::Enter,
914
+ true,
915
+ )
916
+ .expect("inject");
917
+ // Fix-B: token payload → E46 token path. The submit verification is
918
+ // either EnterSentWithoutPlaceholderCheck (consumed) or
919
+ // SubmitConsumptionUnverified (not consumed). With the mock returning
920
+ // pasted-content text (no token in tail), consumed = true.
921
+ assert_eq!(
922
+ report.submit_verification,
923
+ SubmitVerification::EnterSentWithoutPlaceholderCheck,
924
+ "E50 PR-2 Fix-B: token payload that went through the E46 gate \
925
+ and was consumed (token not in bottom 5 lines) must report \
926
+ EnterSentWithoutPlaceholderCheck; got {:?}",
927
+ report.submit_verification
928
+ );
929
+ }
930
+
931
+ /// **E50 PR-1 secret scrubbing**: `scrub_pane_excerpt` must strip ANSI
932
+ /// + redact common secret shapes so emitted events.jsonl doesn't leak.
933
+ #[test]
934
+ fn e50_scrub_pane_excerpt_strips_ansi_and_redacts_secret_shapes() {
935
+ use super::scrub_pane_excerpt;
936
+ let raw = "\x1b[31merror\x1b[0m: sk-abcdef123ghi\n\
937
+ token Bearer abc.def.ghi\n\
938
+ key AKIAIOSFODNN7EXAMPLE\n\
939
+ ghp_1234567890abcdef\n\
940
+ hex deadbeefdeadbeefdeadbeefdeadbeefcafebabe\n\
941
+ plain text below";
942
+ let (out, lines) = scrub_pane_excerpt(raw, 20);
943
+ assert!(lines >= 5, "E50: at least 5 non-empty lines; got {lines}");
944
+ assert!(!out.contains("\x1b"), "E50: ANSI escape stripped; got {out:?}");
945
+ assert!(
946
+ !out.contains("sk-abcdef123ghi"),
947
+ "E50: sk- secret must be redacted; got {out:?}"
948
+ );
949
+ assert!(
950
+ !out.contains("ghp_1234567890abcdef"),
951
+ "E50: ghp_ secret must be redacted; got {out:?}"
952
+ );
953
+ assert!(
954
+ !out.contains("AKIAIOSFODNN7EXAMPLE"),
955
+ "E50: AKIA secret must be redacted; got {out:?}"
956
+ );
957
+ assert!(
958
+ !out.contains("abc.def.ghi"),
959
+ "E50: Bearer secret token must be redacted; got {out:?}"
960
+ );
961
+ assert!(
962
+ !out.contains("deadbeefdeadbeefdeadbeefdeadbeefcafebabe"),
963
+ "E50: 32+ hex run must be redacted; got {out:?}"
964
+ );
965
+ assert!(
966
+ out.contains("REDACTED"),
967
+ "E50: redactions visible; got {out:?}"
968
+ );
969
+ assert!(
970
+ out.contains("plain text below"),
971
+ "E50: non-secret content preserved; got {out:?}"
972
+ );
973
+ }
974
+
975
+ /// **E50 PR-1 byte-identical legacy matcher**: `capture_has_pasted_content_prompt`
976
+ /// wrapper must still return the same bool the 3 legacy callers depend on
977
+ /// (the bool-returning fn shape is the contract for byte-locked behaviour).
978
+ #[test]
979
+ fn e50_capture_has_pasted_content_prompt_byte_identical_wrapper() {
980
+ use super::capture_has_pasted_content_prompt;
981
+ assert!(capture_has_pasted_content_prompt("[Pasted content 1k]"));
982
+ assert!(capture_has_pasted_content_prompt("[Pasted text foo]"));
983
+ assert!(!capture_has_pasted_content_prompt("just composer text"));
984
+ assert!(!capture_has_pasted_content_prompt(""));
985
+ }
986
+
826
987
  #[test]
827
988
  fn send_keys_cancel_mode_queries_mode_and_dispatches_cancel_argv() {
828
989
  let (be, rec) = backend_with(
@@ -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,202 @@ fn submit_verification_for_key(key: Key) -> SubmitVerification {
920
926
  }
921
927
 
922
928
  fn capture_has_pasted_content_prompt(text: &str) -> bool {
929
+ pasted_prompt_match(text).is_some()
930
+ }
931
+
932
+ /// E50 PR-1 (0.3.24 P0, pasted-prompt 假阴诊断): factor `capture_has_pasted_content_prompt`
933
+ /// so the diagnostic layer can recover the MATCHED LITERAL and its position
934
+ /// in the tail. Returns `(literal, line_index_from_bottom)` on a match.
935
+ /// Byte-identical match semantics — `capture_has_pasted_content_prompt`'s
936
+ /// `bool` wrapper preserves the legacy `true/false` contract for the three
937
+ /// existing callers (the appear-gate poll at :1122-1128 + the legacy submit
938
+ /// loop matcher at :1138 + post-flip clearer at :1138).
939
+ ///
940
+ /// **Where-in-tail rationale**: a `pasted content` literal that lives in
941
+ /// the SCROLLBACK (line 6+ from bottom) is NOT the live composer
942
+ /// placeholder — codex's successful submit scrolls the block into history
943
+ /// where it remains in the last 80 lines. The current matcher cannot
944
+ /// distinguish that from a live placeholder; this fn surfaces the data
945
+ /// the operator needs to see (PR-2 will USE it to fix the criterion).
946
+ pub(crate) fn pasted_prompt_match(text: &str) -> Option<(&'static str, u32)> {
923
947
  let lower = text.to_ascii_lowercase();
924
- lower.contains("pasted content") || lower.contains("pasted text")
948
+ let lit = if lower.contains("pasted content") {
949
+ "pasted content"
950
+ } else if lower.contains("pasted text") {
951
+ "pasted text"
952
+ } else {
953
+ return None;
954
+ };
955
+ // Distance from the bottom of the tail in non-empty lines.
956
+ let non_empty: Vec<&str> = text.lines().filter(|line| !line.trim().is_empty()).collect();
957
+ let mut from_bottom: u32 = 0;
958
+ for line in non_empty.iter().rev() {
959
+ if line.to_ascii_lowercase().contains(lit) {
960
+ return Some((lit, from_bottom));
961
+ }
962
+ from_bottom = from_bottom.saturating_add(1);
963
+ }
964
+ // Literal appeared only in trimmed-away whitespace lines — treat as
965
+ // bottom (defensive; rare).
966
+ Some((lit, 0))
967
+ }
968
+
969
+ /// E50 PR-1 (0.3.24 P0): scrub a pane capture for safe inclusion in
970
+ /// `events.jsonl`. Steps:
971
+ /// 1. Strip CSI / OSC ANSI escapes.
972
+ /// 2. Take the bottom `tail_lines` of non-empty lines.
973
+ /// 3. Redact common secret shapes (sk-, ghp_, AKIA, Bearer ..., 32+ hex).
974
+ /// 4. Cap at ~1200 bytes (UTF-8 safe truncation).
975
+ /// Returns `(excerpt, line_count)`. Designed to be CHEAP — no regex crate
976
+ /// dependency, simple byte scanning.
977
+ pub(crate) fn scrub_pane_excerpt(raw: &str, tail_lines: usize) -> (String, u32) {
978
+ let stripped = strip_ansi_escapes_inplace(raw);
979
+ let lines: Vec<&str> = stripped
980
+ .lines()
981
+ .filter(|line| !line.trim().is_empty())
982
+ .collect();
983
+ let tail = if lines.len() > tail_lines {
984
+ &lines[lines.len() - tail_lines..]
985
+ } else {
986
+ &lines[..]
987
+ };
988
+ let mut out = tail
989
+ .iter()
990
+ .map(|line| scrub_secrets(line))
991
+ .collect::<Vec<_>>()
992
+ .join("\n");
993
+ if out.len() > 1200 {
994
+ // Truncate at UTF-8 char boundary.
995
+ let mut cut = 1200;
996
+ while cut > 0 && !out.is_char_boundary(cut) {
997
+ cut -= 1;
998
+ }
999
+ out.truncate(cut);
1000
+ out.push_str("…[truncated]");
1001
+ }
1002
+ (out, tail.len() as u32)
1003
+ }
1004
+
1005
+ fn strip_ansi_escapes_inplace(input: &str) -> String {
1006
+ let mut out = String::with_capacity(input.len());
1007
+ let bytes = input.as_bytes();
1008
+ let mut i = 0;
1009
+ while i < bytes.len() {
1010
+ if bytes[i] == 0x1b && i + 1 < bytes.len() {
1011
+ // CSI: ESC [ ... <final byte 0x40-0x7e>
1012
+ if bytes[i + 1] == b'[' {
1013
+ let mut j = i + 2;
1014
+ while j < bytes.len() && !(0x40..=0x7e).contains(&bytes[j]) {
1015
+ j += 1;
1016
+ }
1017
+ i = j.saturating_add(1).min(bytes.len());
1018
+ continue;
1019
+ }
1020
+ // OSC: ESC ] ... BEL or ESC \
1021
+ if bytes[i + 1] == b']' {
1022
+ let mut j = i + 2;
1023
+ while j < bytes.len() {
1024
+ if bytes[j] == 0x07 {
1025
+ j += 1;
1026
+ break;
1027
+ }
1028
+ if bytes[j] == 0x1b && j + 1 < bytes.len() && bytes[j + 1] == b'\\' {
1029
+ j += 2;
1030
+ break;
1031
+ }
1032
+ j += 1;
1033
+ }
1034
+ i = j.min(bytes.len());
1035
+ continue;
1036
+ }
1037
+ // Other single-char ESC sequence — skip ESC + next byte.
1038
+ i += 2;
1039
+ continue;
1040
+ }
1041
+ let ch = bytes[i];
1042
+ out.push(ch as char);
1043
+ i += 1;
1044
+ }
1045
+ // Re-decode from bytes to recover UTF-8 (since we pushed bytes as chars,
1046
+ // multi-byte UTF-8 is preserved correctly because we only skip on the
1047
+ // single-byte ESC start). For pane text this is good enough; pathological
1048
+ // UTF-8 inside CSI parameter bytes is invalid anyway.
1049
+ out
1050
+ }
1051
+
1052
+ fn scrub_secrets(line: &str) -> String {
1053
+ // Five shapes: sk-XXXX, ghp_XXXX, AKIAXXXX (16-char uppercase id), Bearer XXXX,
1054
+ // 32+ hex (token).
1055
+ let mut out = String::with_capacity(line.len());
1056
+ let bytes = line.as_bytes();
1057
+ let mut i = 0;
1058
+ while i < bytes.len() {
1059
+ // sk- / ghp_ / AKIA prefixes: detect and redact through end-of-token.
1060
+ if matches_prefix(bytes, i, b"sk-") || matches_prefix(bytes, i, b"ghp_") {
1061
+ let prefix_len = if bytes[i] == b's' { 3 } else { 4 };
1062
+ let token_end = scan_token_end(bytes, i + prefix_len);
1063
+ out.push_str(&line[i..i + prefix_len]);
1064
+ out.push_str("REDACTED");
1065
+ i = token_end;
1066
+ continue;
1067
+ }
1068
+ if matches_prefix(bytes, i, b"AKIA") {
1069
+ let token_end = scan_token_end(bytes, i + 4);
1070
+ out.push_str("AKIA");
1071
+ out.push_str("REDACTED");
1072
+ i = token_end;
1073
+ continue;
1074
+ }
1075
+ if matches_prefix_case_insensitive(bytes, i, b"Bearer ") {
1076
+ let token_end = scan_token_end(bytes, i + 7);
1077
+ out.push_str(&line[i..i + 7]);
1078
+ out.push_str("REDACTED");
1079
+ i = token_end;
1080
+ continue;
1081
+ }
1082
+ // 32+ hex run.
1083
+ if is_hex_byte(bytes[i]) {
1084
+ let mut j = i;
1085
+ while j < bytes.len() && is_hex_byte(bytes[j]) {
1086
+ j += 1;
1087
+ }
1088
+ if j - i >= 32 {
1089
+ out.push_str("REDACTED_HEX");
1090
+ i = j;
1091
+ continue;
1092
+ }
1093
+ }
1094
+ // Default: passthrough byte.
1095
+ out.push(bytes[i] as char);
1096
+ i += 1;
1097
+ }
1098
+ out
1099
+ }
1100
+
1101
+ fn matches_prefix(bytes: &[u8], i: usize, prefix: &[u8]) -> bool {
1102
+ bytes.get(i..i + prefix.len()).is_some_and(|s| s == prefix)
1103
+ }
1104
+
1105
+ fn matches_prefix_case_insensitive(bytes: &[u8], i: usize, prefix: &[u8]) -> bool {
1106
+ bytes
1107
+ .get(i..i + prefix.len())
1108
+ .is_some_and(|s| s.eq_ignore_ascii_case(prefix))
1109
+ }
1110
+
1111
+ fn scan_token_end(bytes: &[u8], start: usize) -> usize {
1112
+ let mut j = start;
1113
+ while j < bytes.len() && is_token_byte(bytes[j]) {
1114
+ j += 1;
1115
+ }
1116
+ j
1117
+ }
1118
+
1119
+ fn is_token_byte(b: u8) -> bool {
1120
+ b.is_ascii_alphanumeric() || b == b'-' || b == b'_'
1121
+ }
1122
+
1123
+ fn is_hex_byte(b: u8) -> bool {
1124
+ b.is_ascii_hexdigit()
925
1125
  }
926
1126
 
927
1127
  const PASTED_CONTENT_APPEAR_POLLS: u32 = 5;
@@ -1118,6 +1318,10 @@ impl Transport for TmuxBackend {
1118
1318
  self.run_inject_stage(&argv, stage)?;
1119
1319
  }
1120
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();
1121
1325
  let mut saw_pasted_prompt = false;
1122
1326
  for _ in 0..PASTED_CONTENT_APPEAR_POLLS {
1123
1327
  let captured = self.capture(target, CaptureRange::Tail(80))?;
@@ -1127,8 +1331,25 @@ impl Transport for TmuxBackend {
1127
1331
  }
1128
1332
  std::thread::sleep(Duration::from_millis(25));
1129
1333
  }
1334
+ let appear_gate_elapsed_ms = appear_gate_start.elapsed().as_millis() as u64;
1130
1335
  let submit_argv = tmux_send_keys_argv(&pane, &[submit]);
1131
- if saw_pasted_prompt {
1336
+ // E50 PR-2 Fix-B (0.3.26 P0): when the payload carries a token
1337
+ // marker (ALL delivery messages do via render_message), SKIP the
1338
+ // legacy pasted-prompt weak loop even if `saw_pasted_prompt` is
1339
+ // true. Fall through to the E46 token-based consumption gate
1340
+ // (Escape pre-Enter + post_submit_input_consumed + resend cap
1341
+ // with C3 re-check), which has NONE of the two defects:
1342
+ // A. E46 gate uses 60ms sleep + 3 retries (not zero-sleep).
1343
+ // B. E46 gate anchors on the token marker in the bottom 5
1344
+ // lines (composer region), not a substring match on
1345
+ // Tail(80) that catches scrollback residue.
1346
+ //
1347
+ // Non-token payloads (rare: test harness / trust prompt Empty)
1348
+ // still use the legacy loop for backward compatibility with
1349
+ // provider_submit_verification_red.rs::PastePromptRunner tests.
1350
+ let has_token = payload_token_marker(payload).is_some();
1351
+ if saw_pasted_prompt && !has_token {
1352
+ // Legacy weak loop preserved for non-token payloads (test harness).
1132
1353
  let mut attempts = 0;
1133
1354
  let mut cleared = false;
1134
1355
  for _ in 0..PASTED_CONTENT_SUBMIT_ATTEMPTS {
@@ -1151,6 +1372,7 @@ impl Transport for TmuxBackend {
1151
1372
  },
1152
1373
  turn_verification: TurnVerification::NotYetObserved,
1153
1374
  attempts,
1375
+ submit_diagnostics: None,
1154
1376
  });
1155
1377
  }
1156
1378
  // U1 #7 (efd189b redo, canonical-native): around the final submit, read
@@ -1237,6 +1459,11 @@ impl Transport for TmuxBackend {
1237
1459
  // false-positive (silent paste drop).
1238
1460
  None => submit_verification_for_key(submit),
1239
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).
1240
1467
  return Ok(InjectReport {
1241
1468
  stage_reached: InjectStage::Submit,
1242
1469
  inject_verification: inject_verification_after_readback(
@@ -1249,6 +1476,12 @@ impl Transport for TmuxBackend {
1249
1476
  InjectPayload::Text(_) => TurnVerification::NotYetObserved,
1250
1477
  },
1251
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
+ }),
1252
1485
  });
1253
1486
  }
1254
1487
  }
@@ -1264,6 +1497,9 @@ impl Transport for TmuxBackend {
1264
1497
  InjectPayload::Text(_) => TurnVerification::NotYetObserved,
1265
1498
  },
1266
1499
  attempts: 1,
1500
+ // E50 PR-1: Empty payload / non-Text fallthrough path — no submit
1501
+ // diagnostics applicable.
1502
+ submit_diagnostics: None,
1267
1503
  })
1268
1504
  }
1269
1505
 
@@ -277,6 +277,7 @@ impl OfflineTransport {
277
277
  submit_verification: SubmitVerification::EnterSentWithoutPlaceholderCheck,
278
278
  turn_verification: TurnVerification::NotYetObserved,
279
279
  attempts: 1,
280
+ submit_diagnostics: None,
280
281
  }
281
282
  }
282
283
  }
@@ -74,6 +74,7 @@
74
74
  submit_verification,
75
75
  turn_verification,
76
76
  attempts: 1,
77
+ submit_diagnostics: None,
77
78
  })
78
79
  }
79
80
  fn send_keys(&self, _target: &Target, _keys: &[Key]) -> Result<(), TransportError> {
@@ -271,7 +271,8 @@
271
271
  "sh",
272
272
  false,
273
273
  ),
274
- vec!["tmux", "new-window", "-t", "team-sess", "-n", "worker-2", "sh", "-lc", "sh"]
274
+ // E53 (0.3.26): new-window now carries `-d` (detached, no focus steal).
275
+ vec!["tmux", "new-window", "-d", "-t", "team-sess", "-n", "worker-2", "sh", "-lc", "sh"]
275
276
  );
276
277
  }
277
278