@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.
- 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/restart/agent.rs +55 -0
- package/crates/team-agent/src/messaging/delivery.rs +79 -1
- 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 +163 -2
- package/crates/team-agent/src/tmux_backend.rs +238 -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 +77 -0
- package/npm/install.mjs +222 -14
- package/package.json +4 -4
|
@@ -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
|
-
|
|
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..
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
|