@team-agent/installer 0.3.21 → 0.3.22

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 (29) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/emit.rs +9 -1
  4. package/crates/team-agent/src/cli/mod.rs +158 -2
  5. package/crates/team-agent/src/cli/status.rs +61 -0
  6. package/crates/team-agent/src/cli/status_port.rs +2 -2
  7. package/crates/team-agent/src/cli/tests/base.rs +26 -0
  8. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +233 -9
  9. package/crates/team-agent/src/coordinator/tests/energy.rs +548 -0
  10. package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
  11. package/crates/team-agent/src/coordinator/tick.rs +452 -17
  12. package/crates/team-agent/src/lifecycle/display.rs +23 -302
  13. package/crates/team-agent/src/lifecycle/launch.rs +38 -0
  14. package/crates/team-agent/src/lifecycle/restart/agent.rs +27 -6
  15. package/crates/team-agent/src/lifecycle/restart/common.rs +76 -0
  16. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +295 -13
  17. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +30 -0
  18. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +89 -5
  19. package/crates/team-agent/src/messaging/delivery.rs +324 -95
  20. package/crates/team-agent/src/messaging/scheduler.rs +95 -73
  21. package/crates/team-agent/src/messaging/send.rs +10 -0
  22. package/crates/team-agent/src/messaging/tests/runtime.rs +460 -0
  23. package/crates/team-agent/src/messaging/tests/spine.rs +51 -2
  24. package/crates/team-agent/src/session_capture.rs +261 -1
  25. package/crates/team-agent/src/tmux_backend/tests.rs +97 -1
  26. package/crates/team-agent/src/tmux_backend.rs +138 -2
  27. package/crates/team-agent/src/transport/test_support.rs +25 -0
  28. package/crates/team-agent/src/transport.rs +11 -0
  29. package/package.json +4 -4
@@ -107,8 +107,21 @@ fn p2_scheduler_unknown_kind_surfaces_error() {
107
107
  )
108
108
  .unwrap();
109
109
  let log = EventLog::new(&ws);
110
- let r = fire_due_scheduled_events(&ws, &store, &NoopTransport, &log);
111
- assert!(r.is_err(), "an unknown scheduled event kind must surface an error, not a silent ok=false");
110
+ // U1 #5: unknown kind is isolated to its own row (marked terminal 'failed' +
111
+ // scheduler.event_failed), NOT propagated as a pass-level Err that would halt the
112
+ // batch. Failure stays loud (evented) but does not take the whole scheduler down.
113
+ let fired = fire_due_scheduled_events(&ws, &store, &NoopTransport, &log)
114
+ .expect("an unknown scheduled event kind must be isolated to its row, not halt the pass");
115
+ assert_eq!(fired.len(), 1);
116
+ assert_eq!(scheduled_status_of(&store, fired[0]), "failed");
117
+ let events = log.tail(0).unwrap();
118
+ assert!(
119
+ events.iter().any(|event| {
120
+ event.get("event").and_then(serde_json::Value::as_str) == Some("scheduler.event_failed")
121
+ && event.get("event_id").and_then(serde_json::Value::as_i64) == Some(fired[0])
122
+ }),
123
+ "unknown scheduled event kind must leave scheduler.event_failed; events={events:?}"
124
+ );
112
125
  }
113
126
 
114
127
  // ═════════════════════════════════════════════════════════════════════════
@@ -313,6 +326,42 @@ fn spine_scheduler_marks_failed_when_result_not_ok() {
313
326
  );
314
327
  }
315
328
 
329
+ // U1 #5 (RED) — a single poison scheduled event (malformed payload_json that errors at
330
+ // `serde_json::from_str?`) must NOT halt the whole pass: it must be marked terminal 'failed'
331
+ // + emit `scheduler.event_failed`, and a healthy event seeded AFTER it must still fire.
332
+ // Today the bare `?` aborts `fire_due_scheduled_events`, the healthy event never fires → RED.
333
+ #[test]
334
+ fn spine_scheduler_poison_event_does_not_halt_batch() {
335
+ let ws = tmp_ws("sched-poison");
336
+ let store = store_for(&ws);
337
+ let log = EventLog::new(&ws);
338
+ // earlier due_at → selected first; malformed JSON payload errors at from_str.
339
+ let poison = seed_event_due(&store, "send", "2000-01-01T00:00:00+00:00", "{not valid json");
340
+ // later due_at → selected after poison; healthy.
341
+ let healthy = seed_event_due(&store, "health_ping", "2000-01-02T00:00:00+00:00", "{}");
342
+
343
+ let fired = fire_due_scheduled_events(&ws, &store, &NoopTransport, &log)
344
+ .expect("a poison event must not propagate as a pass-level Err (must isolate + continue)");
345
+
346
+ // poison marked terminal failed (not re-fired every tick).
347
+ assert_eq!(
348
+ scheduled_status_of(&store, poison),
349
+ "failed",
350
+ "U1 #5: a poison scheduled event must be marked terminal 'failed', not halt the pass"
351
+ );
352
+ // healthy event AFTER the poison still fired.
353
+ assert!(
354
+ fired.contains(&healthy),
355
+ "U1 #5: a healthy event after a poison one must still fire (batch not halted); fired={fired:?}"
356
+ );
357
+ // failure is loud: scheduler.event_failed event emitted for the poison.
358
+ let events = read_event_log(&ws);
359
+ assert!(
360
+ events.iter().any(|e| e.get("event").and_then(|v| v.as_str()) == Some("scheduler.event_failed")),
361
+ "U1 #5: a poison event must emit scheduler.event_failed (failure must be loud, not silent)"
362
+ );
363
+ }
364
+
316
365
  // #6b — due events fire in (due_at, id) order (core.py due_scheduled_events), not id order.
317
366
  #[test]
318
367
  fn spine_scheduler_orders_due_events_by_due_at_then_id() {
@@ -19,9 +19,16 @@ pub struct CapturePassReport {
19
19
  pub pending: Vec<String>,
20
20
  pub assigned: Vec<String>,
21
21
  pub ambiguous: Vec<AmbiguousSessionCapture>,
22
+ pub capture_failures: Vec<SessionCaptureFailure>,
22
23
  pub candidate_count_by_agent: BTreeMap<String, usize>,
23
24
  }
24
25
 
26
+ #[derive(Debug, Clone, PartialEq, Eq)]
27
+ pub struct SessionCaptureFailure {
28
+ pub agent_id: String,
29
+ pub error: String,
30
+ }
31
+
25
32
  #[derive(Debug, Clone, PartialEq, Eq)]
26
33
  pub struct AmbiguousSessionCapture {
27
34
  pub agent_id: String,
@@ -140,13 +147,24 @@ where
140
147
  return Ok(CapturePassReport::default());
141
148
  };
142
149
  let mut pending = Vec::new();
150
+ let mut capture_failures = Vec::new();
143
151
  let mut candidates_by_agent = BTreeMap::new();
144
152
  for (agent_id, agent) in agent_map {
145
153
  let Some(capture) = pending_session_capture(agent_id, agent, adapter_for) else {
146
154
  continue;
147
155
  };
148
156
  let adapter = adapter_for(capture.provider);
149
- let candidates = adapter.capture_session_candidates(&capture.context, timeout_s)?;
157
+ let candidates = match adapter.capture_session_candidates(&capture.context, timeout_s) {
158
+ Ok(candidates) => candidates,
159
+ Err(error) => {
160
+ capture_failures.push(SessionCaptureFailure {
161
+ agent_id: capture.agent_id.clone(),
162
+ error: error.to_string(),
163
+ });
164
+ pending.push(capture);
165
+ continue;
166
+ }
167
+ };
150
168
  candidates_by_agent.insert(capture.agent_id.clone(), candidates);
151
169
  pending.push(capture);
152
170
  }
@@ -164,6 +182,7 @@ where
164
182
  };
165
183
  let mut report = CapturePassReport {
166
184
  pending: pending.iter().map(|item| item.agent_id.clone()).collect(),
185
+ capture_failures,
167
186
  candidate_count_by_agent: candidates_by_agent
168
187
  .iter()
169
188
  .map(|(agent_id, candidates)| (agent_id.clone(), candidates.len()))
@@ -615,3 +634,244 @@ fn parse_provider(raw: &str) -> Option<Provider> {
615
634
  _ => None,
616
635
  }
617
636
  }
637
+
638
+ #[cfg(test)]
639
+ pub(crate) mod test_support {
640
+ use super::*;
641
+
642
+ #[derive(Clone)]
643
+ pub(crate) struct CaptureCandidatesAdapter {
644
+ provider: Provider,
645
+ fail_agent_id: Option<String>,
646
+ error: String,
647
+ }
648
+
649
+ impl CaptureCandidatesAdapter {
650
+ pub(crate) fn new(provider: Provider, fail_agent_id: Option<&str>, error: &str) -> Self {
651
+ Self {
652
+ provider,
653
+ fail_agent_id: fail_agent_id.map(str::to_string),
654
+ error: error.to_string(),
655
+ }
656
+ }
657
+ }
658
+
659
+ impl ProviderAdapter for CaptureCandidatesAdapter {
660
+ fn provider(&self) -> Provider {
661
+ self.provider
662
+ }
663
+
664
+ fn caps(&self) -> crate::provider::ProviderCaps {
665
+ crate::provider::ProviderCaps {
666
+ resume: true,
667
+ fork: false,
668
+ native_mcp_config: false,
669
+ writes_global_settings: false,
670
+ }
671
+ }
672
+
673
+ fn is_installed(&self) -> bool {
674
+ true
675
+ }
676
+
677
+ fn version(&self) -> Result<String, ProviderError> {
678
+ Ok("test".to_string())
679
+ }
680
+
681
+ fn auth_hint(
682
+ &self,
683
+ _auth_mode: crate::provider::AuthMode,
684
+ ) -> crate::provider::AuthHintStatus {
685
+ crate::provider::AuthHintStatus::Unknown
686
+ }
687
+
688
+ fn build_command(
689
+ &self,
690
+ _auth_mode: crate::provider::AuthMode,
691
+ _mcp_config: Option<&crate::provider::McpConfig>,
692
+ _system_prompt: Option<&str>,
693
+ _model: Option<&str>,
694
+ ) -> Result<Vec<String>, ProviderError> {
695
+ Err(ProviderError::CapabilityUnsupported(
696
+ "test adapter".to_string(),
697
+ ))
698
+ }
699
+
700
+ fn build_command_with_tools(
701
+ &self,
702
+ _auth_mode: crate::provider::AuthMode,
703
+ _mcp_config: Option<&crate::provider::McpConfig>,
704
+ _system_prompt: Option<&str>,
705
+ _model: Option<&str>,
706
+ _tools: &[&str],
707
+ ) -> Result<Vec<String>, ProviderError> {
708
+ Err(ProviderError::CapabilityUnsupported(
709
+ "test adapter".to_string(),
710
+ ))
711
+ }
712
+
713
+ fn capture_session_id(
714
+ &self,
715
+ _agent_id: &str,
716
+ _spawn_cwd: &std::path::Path,
717
+ _timeout_s: u64,
718
+ ) -> Result<Option<CapturedSession>, ProviderError> {
719
+ Err(ProviderError::CapabilityUnsupported(
720
+ "test adapter".to_string(),
721
+ ))
722
+ }
723
+
724
+ fn capture_session_candidates(
725
+ &self,
726
+ context: &CaptureSessionContext,
727
+ _timeout_s: u64,
728
+ ) -> Result<Vec<CapturedSessionCandidate>, ProviderError> {
729
+ if self.fail_agent_id.as_deref() == Some(context.agent_id.as_str()) {
730
+ return Err(ProviderError::Io(self.error.clone()));
731
+ }
732
+ Ok(Vec::new())
733
+ }
734
+
735
+ fn recover_session_id(
736
+ &self,
737
+ _agent_id: &str,
738
+ _spawn_cwd: &std::path::Path,
739
+ ) -> Result<Option<SessionId>, ProviderError> {
740
+ Err(ProviderError::CapabilityUnsupported(
741
+ "test adapter".to_string(),
742
+ ))
743
+ }
744
+
745
+ fn session_is_resumable(
746
+ &self,
747
+ _session_id: Option<&SessionId>,
748
+ _auth_mode: crate::provider::AuthMode,
749
+ ) -> Result<bool, ProviderError> {
750
+ Ok(true)
751
+ }
752
+
753
+ fn build_resume_command(
754
+ &self,
755
+ _session_id: Option<&SessionId>,
756
+ _auth_mode: crate::provider::AuthMode,
757
+ _mcp_config: Option<&crate::provider::McpConfig>,
758
+ ) -> Result<Vec<String>, ProviderError> {
759
+ Err(ProviderError::CapabilityUnsupported(
760
+ "test adapter".to_string(),
761
+ ))
762
+ }
763
+
764
+ fn build_resume_command_with_context(
765
+ &self,
766
+ _session_id: Option<&SessionId>,
767
+ _auth_mode: crate::provider::AuthMode,
768
+ _mcp_config: Option<&crate::provider::McpConfig>,
769
+ _system_prompt: Option<&str>,
770
+ _model: Option<&str>,
771
+ _tools: &[&str],
772
+ ) -> Result<Vec<String>, ProviderError> {
773
+ Err(ProviderError::CapabilityUnsupported(
774
+ "test adapter".to_string(),
775
+ ))
776
+ }
777
+
778
+ fn fork(
779
+ &self,
780
+ _session_id: Option<&SessionId>,
781
+ _auth_mode: crate::provider::AuthMode,
782
+ _mcp_config: Option<&crate::provider::McpConfig>,
783
+ ) -> Result<Vec<String>, ProviderError> {
784
+ Err(ProviderError::CapabilityUnsupported(
785
+ "test adapter".to_string(),
786
+ ))
787
+ }
788
+
789
+ fn fork_with_context(
790
+ &self,
791
+ _session_id: Option<&SessionId>,
792
+ _auth_mode: crate::provider::AuthMode,
793
+ _mcp_config: Option<&crate::provider::McpConfig>,
794
+ _system_prompt: Option<&str>,
795
+ _model: Option<&str>,
796
+ _tools: &[&str],
797
+ ) -> Result<Vec<String>, ProviderError> {
798
+ Err(ProviderError::CapabilityUnsupported(
799
+ "test adapter".to_string(),
800
+ ))
801
+ }
802
+
803
+ fn mcp_config(
804
+ &self,
805
+ _auth_mode: crate::provider::AuthMode,
806
+ ) -> Result<crate::provider::McpConfig, ProviderError> {
807
+ Err(ProviderError::CapabilityUnsupported(
808
+ "test adapter".to_string(),
809
+ ))
810
+ }
811
+
812
+ fn install_mcp(&self, _config: &crate::provider::McpConfig) -> Result<(), ProviderError> {
813
+ Err(ProviderError::CapabilityUnsupported(
814
+ "test adapter".to_string(),
815
+ ))
816
+ }
817
+
818
+ fn status_patterns(&self) -> Result<crate::provider::StatusPatterns, ProviderError> {
819
+ Err(ProviderError::CapabilityUnsupported(
820
+ "test adapter".to_string(),
821
+ ))
822
+ }
823
+
824
+ fn validate_model(&self, _model: &str) -> Result<bool, ProviderError> {
825
+ Ok(true)
826
+ }
827
+ }
828
+ }
829
+
830
+ #[cfg(test)]
831
+ #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
832
+ mod u1_tests {
833
+ use super::*;
834
+
835
+ #[test]
836
+ fn capture_pass_keeps_pending_agent_when_one_adapter_capture_fails() {
837
+ let mut state = serde_json::json!({
838
+ "agents": {
839
+ "bad": {
840
+ "provider": "codex",
841
+ "status": "running",
842
+ "spawn_cwd": "/tmp/u1-bad"
843
+ },
844
+ "good": {
845
+ "provider": "codex",
846
+ "status": "running",
847
+ "spawn_cwd": "/tmp/u1-good"
848
+ }
849
+ }
850
+ });
851
+ let mut adapter_for = |provider| {
852
+ Box::new(test_support::CaptureCandidatesAdapter::new(
853
+ provider,
854
+ Some("bad"),
855
+ "capture exploded",
856
+ )) as Box<dyn ProviderAdapter>
857
+ };
858
+
859
+ let report = capture_missing_provider_sessions_once(&mut state, &mut adapter_for, true, 0)
860
+ .expect("one agent capture failure must not abort the whole pass");
861
+
862
+ assert_eq!(report.pending, vec!["bad".to_string(), "good".to_string()]);
863
+ assert_eq!(report.assigned, Vec::<String>::new());
864
+ assert_eq!(
865
+ report.candidate_count_by_agent.get("good"),
866
+ Some(&0),
867
+ "the non-failing agent must still be probed"
868
+ );
869
+ assert_eq!(
870
+ report.capture_failures,
871
+ vec![SessionCaptureFailure {
872
+ agent_id: "bad".to_string(),
873
+ error: "provider io error: capture exploded".to_string(),
874
+ }]
875
+ );
876
+ }
877
+ }
@@ -349,6 +349,81 @@
349
349
  assert_eq!(result.pane_id.as_str(), "%4");
350
350
  }
351
351
 
352
+ #[test]
353
+ fn spawn_split_selects_even_horizontal_not_tiled() {
354
+ let (be, rec) = backend_with(
355
+ MockResp::Out(ok("")),
356
+ vec![MockResp::Out(ok("%5")), MockResp::Out(ok(""))],
357
+ );
358
+ let s = SessionName::new("teamsess");
359
+ let w = WindowName::new("team-w1");
360
+ let result = be
361
+ .spawn_split_with_env_unset(
362
+ &s,
363
+ &w,
364
+ &svec(&["provider-bin"]),
365
+ Path::new("/work/dir"),
366
+ &BTreeMap::new(),
367
+ &[],
368
+ )
369
+ .expect("spawn_split");
370
+ assert_eq!(result.pane_id.as_str(), "%5");
371
+ let calls = rec.lock().unwrap().clone();
372
+ assert_eq!(
373
+ calls[0][0..4],
374
+ svec(&["tmux", "split-window", "-t", "teamsess:team-w1"])
375
+ );
376
+ assert_eq!(
377
+ calls[1],
378
+ svec(&[
379
+ "tmux",
380
+ "select-layout",
381
+ "-t",
382
+ "teamsess:team-w1",
383
+ "even-horizontal",
384
+ ])
385
+ );
386
+ assert!(
387
+ !calls.iter().flatten().any(|arg| arg == "tiled"),
388
+ "adaptive split must not leave tmux tiled layout in the command stream: {calls:?}"
389
+ );
390
+ }
391
+
392
+ #[test]
393
+ fn configure_adaptive_pane_title_sets_border_and_pane_title() {
394
+ let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
395
+ be.configure_adaptive_pane_title(
396
+ &SessionName::new("teamsess"),
397
+ &WindowName::new("team-w1"),
398
+ &PaneId::new("%7"),
399
+ "builder",
400
+ )
401
+ .expect("configure title");
402
+ let calls = rec.lock().unwrap().clone();
403
+ assert_eq!(
404
+ calls,
405
+ vec![
406
+ svec(&[
407
+ "tmux",
408
+ "set-window-option",
409
+ "-t",
410
+ "teamsess:team-w1",
411
+ "pane-border-status",
412
+ "bottom",
413
+ ]),
414
+ svec(&[
415
+ "tmux",
416
+ "set-window-option",
417
+ "-t",
418
+ "teamsess:team-w1",
419
+ "pane-border-format",
420
+ " #{pane_title} ",
421
+ ]),
422
+ svec(&["tmux", "select-pane", "-t", "%7", "-T", "builder"]),
423
+ ]
424
+ );
425
+ }
426
+
352
427
  // ── 3. set_session_env: argv = `tmux set-environment -t <s> <k> <v>`; success -> Applied ───────
353
428
  #[test]
354
429
  fn set_session_env_argv_and_applied_outcome() {
@@ -414,8 +489,10 @@
414
489
 
415
490
  #[test]
416
491
  fn inject_large_text_load_buffer_writes_stdin_and_token_report() {
417
- let (be, rec, stdin_rec) = backend_with_stdin(MockResp::Out(ok("")), vec![]);
418
492
  let text = format!("{}{}", "x".repeat(16 * 1024), " [team-agent-token:abc]");
493
+ // U1 #7: the pre-submit readback captures the pane; a pane that ECHOES the token
494
+ // back (default capture returns the text) → CaptureContainsToken (true positive).
495
+ let (be, rec, stdin_rec) = backend_with_stdin(MockResp::Out(ok(&text)), vec![]);
419
496
  let report = be
420
497
  .inject(&Target::Pane(PaneId::new("%7")), &InjectPayload::Text(text.clone()), Key::Down, true)
421
498
  .expect("inject large text");
@@ -430,6 +507,25 @@
430
507
  assert_eq!(stdin_rec.lock().unwrap()[0], text);
431
508
  }
432
509
 
510
+ // U1 #7 (RED→GREEN) — pre-submit pane readback: a token payload whose token is NOT
511
+ // visible in the pane before submit (paste silently dropped) must report
512
+ // CaptureMissingToken, not the static false-positive CaptureContainsToken.
513
+ #[test]
514
+ fn inject_token_not_visible_in_pane_reports_capture_missing_token() {
515
+ let text = format!("{}{}", "x".repeat(16 * 1024), " [team-agent-token:zzz]");
516
+ // default capture returns empty → token not visible → readback says missing.
517
+ let (be, _rec, _stdin) = backend_with_stdin(MockResp::Out(ok("")), vec![]);
518
+ let report = be
519
+ .inject(&Target::Pane(PaneId::new("%9")), &InjectPayload::Text(text), Key::Down, true)
520
+ .expect("inject runs");
521
+ assert_eq!(
522
+ report.inject_verification,
523
+ InjectVerification::CaptureMissingToken,
524
+ "U1 #7: a token that never appeared in the pane must read back as \
525
+ CaptureMissingToken, not the static CaptureContainsToken false-positive"
526
+ );
527
+ }
528
+
433
529
  #[test]
434
530
  fn send_keys_cancel_mode_queries_mode_and_dispatches_cancel_argv() {
435
531
  let (be, rec) = backend_with(
@@ -669,7 +669,7 @@ impl TmuxBackend {
669
669
  "select-layout".to_string(),
670
670
  "-t".to_string(),
671
671
  target,
672
- "tiled".to_string(),
672
+ "even-horizontal".to_string(),
673
673
  ];
674
674
  self.run_ok(&layout_argv)?;
675
675
  Ok(SpawnResult {
@@ -816,6 +816,90 @@ fn inject_verification_for_payload(payload: &InjectPayload) -> InjectVerificatio
816
816
  }
817
817
  }
818
818
 
819
+ /// U1 #7: the exact delivery-token marker a token payload carries
820
+ /// (`[team-agent-token:<id>]`). Use the full marker, not only the prefix, so an old
821
+ /// scrollback token cannot verify a new message.
822
+ fn payload_token_marker(payload: &InjectPayload) -> Option<&str> {
823
+ match payload {
824
+ InjectPayload::Text(text) => {
825
+ let start = text.find("[team-agent-token:")?;
826
+ let marker = &text[start..];
827
+ let end = marker.find(']')?;
828
+ Some(&marker[..=end])
829
+ }
830
+ _ => None,
831
+ }
832
+ }
833
+
834
+ fn token_visible_in_capture(
835
+ backend: &TmuxBackend,
836
+ target: &Target,
837
+ payload: &InjectPayload,
838
+ ) -> Result<Option<bool>, TransportError> {
839
+ match payload_token_marker(payload) {
840
+ None => Ok(None),
841
+ Some(marker) => {
842
+ let captured = backend.capture(target, CaptureRange::Tail(80))?;
843
+ Ok(Some(captured.text.contains(marker)))
844
+ }
845
+ }
846
+ }
847
+
848
+ /// U1 #7: capture the pane just before submit and report whether the just-pasted
849
+ /// token marker is actually visible. `Ok(None)` for non-token payloads (nothing to
850
+ /// check). `Ok(Some(false))` means the paste silently dropped — a false-positive that
851
+ /// the static `inject_verification_for_payload` would have reported as success.
852
+ fn pre_submit_token_visible(
853
+ backend: &TmuxBackend,
854
+ target: &Target,
855
+ payload: &InjectPayload,
856
+ ) -> Result<Option<bool>, TransportError> {
857
+ token_visible_in_capture(backend, target, payload)
858
+ }
859
+
860
+ const TOKEN_POST_SUBMIT_READBACK_POLLS: u32 = 5;
861
+
862
+ /// Some non-echo panes, including the integration harness' `stty -echo; cat`, only
863
+ /// render the injected line after the submit key. If pre-submit readback missed the
864
+ /// token, do a bounded post-submit check before reporting `CaptureMissingToken`.
865
+ fn post_submit_token_visible(
866
+ backend: &TmuxBackend,
867
+ target: &Target,
868
+ payload: &InjectPayload,
869
+ ) -> Result<Option<bool>, TransportError> {
870
+ if payload_token_marker(payload).is_none() {
871
+ return Ok(None);
872
+ }
873
+ for attempt in 0..TOKEN_POST_SUBMIT_READBACK_POLLS {
874
+ if let Some(true) = token_visible_in_capture(backend, target, payload)? {
875
+ return Ok(Some(true));
876
+ }
877
+ if attempt + 1 < TOKEN_POST_SUBMIT_READBACK_POLLS {
878
+ std::thread::sleep(Duration::from_millis(25));
879
+ }
880
+ }
881
+ Ok(Some(false))
882
+ }
883
+
884
+ /// U1 #7: downgrade the static token verification to `CaptureMissingToken` when the
885
+ /// pre-submit readback did not see the token in the pane. A `None` readback (non-token
886
+ /// payload, or capture unavailable) falls back to the static verification.
887
+ fn inject_verification_after_readback(
888
+ payload: &InjectPayload,
889
+ token_visible_before_submit: Option<bool>,
890
+ ) -> InjectVerification {
891
+ match (payload, token_visible_before_submit) {
892
+ (_, Some(visible)) if payload_token_marker(payload).is_some() => {
893
+ if visible {
894
+ InjectVerification::CaptureContainsToken
895
+ } else {
896
+ InjectVerification::CaptureMissingToken
897
+ }
898
+ }
899
+ _ => inject_verification_for_payload(payload),
900
+ }
901
+ }
902
+
819
903
  fn submit_verification_for_key(key: Key) -> SubmitVerification {
820
904
  match key {
821
905
  Key::Enter => SubmitVerification::EnterSentWithoutPlaceholderCheck,
@@ -960,6 +1044,8 @@ impl Transport for TmuxBackend {
960
1044
  bracketed: bool,
961
1045
  ) -> Result<InjectReport, TransportError> {
962
1046
  let pane = pane_from_target(target);
1047
+ // U1 #7: pane readback signal for the non-pasted-prompt text path.
1048
+ let mut token_visible_for_report: Option<bool> = None;
963
1049
  match payload {
964
1050
  InjectPayload::Empty => {
965
1051
  let argv = tmux_empty_inject_argv(&pane, submit);
@@ -1010,12 +1096,28 @@ impl Transport for TmuxBackend {
1010
1096
  attempts,
1011
1097
  });
1012
1098
  }
1099
+ // U1 #7 (efd189b redo, canonical-native): around the final submit, read
1100
+ // back the pane and confirm the just-pasted token is actually visible.
1101
+ // The static `inject_verification_for_payload` returns CaptureContainsToken
1102
+ // for any token payload WITHOUT checking the pane — a false positive when
1103
+ // the paste silently dropped. A no-echo pane may only render after Enter,
1104
+ // so a pre-submit miss gets one bounded post-submit readback before we
1105
+ // downgrade to CaptureMissingToken.
1106
+ token_visible_for_report =
1107
+ pre_submit_token_visible(self, target, payload).unwrap_or(None);
1013
1108
  self.run_inject_stage(&submit_argv, InjectStage::Submit)?;
1109
+ if matches!(token_visible_for_report, Some(false)) {
1110
+ token_visible_for_report =
1111
+ post_submit_token_visible(self, target, payload).unwrap_or(Some(false));
1112
+ }
1014
1113
  }
1015
1114
  }
1016
1115
  Ok(InjectReport {
1017
1116
  stage_reached: InjectStage::Submit,
1018
- inject_verification: inject_verification_for_payload(payload),
1117
+ inject_verification: inject_verification_after_readback(
1118
+ payload,
1119
+ token_visible_for_report,
1120
+ ),
1019
1121
  submit_verification: submit_verification_for_key(submit),
1020
1122
  turn_verification: match payload {
1021
1123
  InjectPayload::Empty => TurnVerification::NotRequired,
@@ -1207,6 +1309,40 @@ impl Transport for TmuxBackend {
1207
1309
  .collect())
1208
1310
  }
1209
1311
 
1312
+ fn configure_adaptive_pane_title(
1313
+ &self,
1314
+ session: &SessionName,
1315
+ window: &WindowName,
1316
+ pane: &PaneId,
1317
+ title: &str,
1318
+ ) -> Result<(), TransportError> {
1319
+ let target = format!("{}:{}", session.as_str(), window.as_str());
1320
+ self.run_ok(&[
1321
+ "tmux".to_string(),
1322
+ "set-window-option".to_string(),
1323
+ "-t".to_string(),
1324
+ target.clone(),
1325
+ "pane-border-status".to_string(),
1326
+ "bottom".to_string(),
1327
+ ])?;
1328
+ self.run_ok(&[
1329
+ "tmux".to_string(),
1330
+ "set-window-option".to_string(),
1331
+ "-t".to_string(),
1332
+ target,
1333
+ "pane-border-format".to_string(),
1334
+ " #{pane_title} ".to_string(),
1335
+ ])?;
1336
+ self.run_ok(&[
1337
+ "tmux".to_string(),
1338
+ "select-pane".to_string(),
1339
+ "-t".to_string(),
1340
+ pane.as_str().to_string(),
1341
+ "-T".to_string(),
1342
+ title.to_string(),
1343
+ ])
1344
+ }
1345
+
1210
1346
  fn set_session_env(
1211
1347
  &self,
1212
1348
  session: &SessionName,