trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
//! Turnkey task injection: deliver a session's `--task` into its pane once the
//! runtime is ready (issues #1903 / #1299), with delivery-status observability
//! and pane-content readiness hardening (#2364, follow-up from the #2361 review).
//!
//! Why: `tm session new --task "<text>"` (and `tm ticket`, which posts to the
//! same spawn endpoint) stored the task as metadata but never typed it into the
//! freshly-launched Claude Code pane — the session sat idle at an empty prompt
//! and any caller expecting turnkey execution waited forever. The fix reuses the
//! EXISTING `send_input`/`send_line` seam (`tm session send` /
//! `POST .../{id}/send`) to deliver the task, but only after the runtime is
//! ready to accept typed input, replacing the blind fixed sleep the meta-launch
//! prototype used ([`crate::bin` `meta::launch`]) with a bounded readiness poll.
//! What: [`should_inject_task`] is the pure gate (opt-out flag, non-empty task,
//! Claude Code runtime, live session) and [`SessionManager::inject_task_when_ready`]
//! is the readiness-gated delivery. Only `RuntimeKind::ClaudeCode` is injected —
//! the `tcode` adapter already delivers the task in its `run-task` launch command,
//! so injecting there would duplicate it. Delivery updates
//! [`super::injection_status::InjectionStatus`] on the session record at every transition
//! (`Pending` → `Success`/`FailedTimeout`/`FailedSessionDied`) so callers can poll
//! it instead of blind-waiting. The readiness check itself is hardened beyond the
//! bare "`claude` PID exists" probe: [`pane_shows_blocking_modal`] additionally
//! inspects captured pane content (a pane-scoped `capture_pane` of a pane
//! `SessionManager::owned_pane` proved, #9101) for known first-run onboarding /
//! trust-dialog markers, so a modal that would otherwise silently swallow the
//! injected keystrokes instead keeps the loop polling (and, if the modal never
//! clears, correctly reports `FailedTimeout` instead of the #2361-review-flagged
//! silent-discard bug).
//! [`SessionManager::pane_readiness`] (#3591) factors the `runtime_ready` +
//! `pane_shows_blocking_modal` conjunction out of this module's poll loop into
//! one [`PaneReadiness`]-returning probe. `SessionManager::send_input`
//! (`super::manager`, also #3591) reuses this module's modal-detection
//! machinery directly (`pane_shows_blocking_modal` +
//! `capture_readiness_pane`) as a fail-fast gate, but deliberately does NOT
//! reuse the `runtime_ready` half — see [`PaneReadiness`]'s doc for why (in
//! short: `runtime_ready` is `false` by construction for the fallback
//! no-tmux driver and every hermetic test built on it, which made it too
//! blunt an instrument outside this module's own bounded, just-spawned poll
//! loop).
//! Test: `should_inject_*` (pure gate), `pane_shows_blocking_modal_*` (pure
//! marker match), `pane_readiness_*` (the poll loop's probe), and
//! `inject_when_ready_*` (delivery + status transitions via the
//! `FakeTmuxDriver` seam) in this module's `tests` submodule.

use std::time::Duration;

use tracing::{info, warn};

use crate::runtime::RuntimeKind;

use super::injection_status::InjectionStatus;
use super::manager::{ManagedError, SessionManager};
use super::record::{ManagedSessionId, ManagedSessionState, SessionRecord};

/// Max readiness-probe attempts before giving up (issue #1903).
///
/// Why: injection must be BOUNDED — a runtime that never comes up (spawn failed
/// silently, `claude` crashed on launch) must not leave a background task
/// polling forever. With the backoff schedule below this caps the wait at
/// roughly one minute, comfortably longer than Claude Code's 1–3 s launch while
/// still terminating.
const READY_MAX_ATTEMPTS: u32 = 30;

/// Initial delay between readiness probes; doubles up to [`READY_BACKOFF_MAX`].
const READY_BACKOFF_START: Duration = Duration::from_millis(250);

/// Ceiling for the exponential readiness backoff.
const READY_BACKOFF_MAX: Duration = Duration::from_secs(2);

/// Number of trailing pane lines inspected by the blocking-modal probe.
///
/// Why: a first-run onboarding wizard or trust dialog renders near the bottom
/// of the pane; capturing a modest tail is enough to catch it without paying
/// for a full-scrollback capture on every readiness poll.
const MODAL_PROBE_LINES: usize = 60;

/// Known first-run onboarding / trust-dialog copy that indicates the pane is
/// showing a BLOCKING modal rather than its normal ready input prompt
/// (issue #2364b, readiness-probe hardening).
///
/// Why: `runtime_ready`'s `claude`-PID probe is a NECESSARY but not SUFFICIENT
/// readiness signal — the pane exists and the process has exec'd the instant
/// the PID appears, but a first-run onboarding modal or trust prompt on a
/// fresh `CLAUDE_CONFIG_DIR` can still be occupying the TUI's input focus at
/// that moment (per the #2361 review: this precedes the TUI-input-ready
/// state). Typing the task then would have it silently consumed by the modal
/// instead of landing as a task. Rather than trying to positively match Claude
/// Code's normal ready-prompt rendering (which can drift across releases),
/// this is a conservative, deliberately narrow BLOCKLIST of modal copy: as
/// long as none of these markers appear in the captured tail, the pane is
/// presumed to be showing its normal ready state.
/// What: case-sensitive substring markers checked by
/// [`pane_shows_blocking_modal`]. A false negative (unrecognized modal text)
/// leaves behavior unchanged from before this hardening; a false positive
/// (marker coincidentally appears in unrelated pane content) merely delays
/// injection by one backoff step — the bounded retry loop still terminates via
/// [`READY_MAX_ATTEMPTS`], now correctly reporting `FailedTimeout` rather than
/// silently injecting into the modal.
const BLOCKING_MODAL_MARKERS: &[&str] = &[
    "Do you trust the files in this folder",
    "Choose the text style that looks best",
    "Enable bypass permissions mode",
    "Welcome to Claude Code",
];

/// Pure check: does captured pane text show a known first-run/trust modal?
///
/// Why: split out of the readiness loop so the marker match is independently
/// unit-testable without a tmux driver or session record.
/// What: returns `true` iff `pane_text` contains any [`BLOCKING_MODAL_MARKERS`]
/// entry as a substring.
/// Test: `pane_shows_blocking_modal_true_for_trust_dialog`,
/// `pane_shows_blocking_modal_false_for_normal_prompt`.
pub fn pane_shows_blocking_modal(pane_text: &str) -> bool {
    BLOCKING_MODAL_MARKERS.iter().any(|m| pane_text.contains(m))
}

/// Outcome of a one-shot pane-readiness probe used by
/// [`SessionManager::inject_task_when_ready`]'s bounded poll loop (issue #3591
/// factored this out of the loop body).
///
/// Why NOT also used by `send_input`'s gate (issue #3591 design note): the
/// natural instinct is to reuse this wholesale for `send_input` too, but
/// `runtime_ready` looks for a live `claude` PID via `ps`, which is `false`
/// by definition for [`super::real_tmux::FakeNoopTmuxDriver`] (the
/// documented "tmux is not installed" fallback — its `list_sessions` always
/// returns empty, so `session_exists`/`runtime_ready`'s default impls can
/// never report `true`) and for every hermetic test built on
/// `DaemonState::with_root_isolated_managed`, which wires that same fake
/// driver specifically so handler/proxy/connector tests don't need a real
/// tmux. Gating `send_input` on `runtime_ready` was tried and reverted after
/// it broke three such tests
/// (`connectors::tm::tests::create_session_full_lifecycle`,
/// `daemon::managed_routes::proxy::tests::proxy_message_route_focused_sends_unfocused_no_focus`,
/// `daemon::mcp_proxy::tests::mcp_proxy_focus_message_summary_unfocus_round_trip`)
/// — proof that a hard `runtime_ready` gate has too high a false-positive
/// rate outside the narrow, bounded, just-spawned context
/// `inject_task_when_ready` uses it in. `send_input`'s gate instead reuses
/// only the modal-detection half directly (see
/// `super::manager::SessionManager::send_input`), and relies on its OWN
/// state guard (refusing `Provisioning`/`Errored`) for the shell-execution
/// risk this issue is about.
/// What: `Ready` — the runtime PID is up AND the captured pane tail shows no
/// known blocking-modal marker. `RuntimeNotReady` — the PID has not appeared
/// yet (still booting), is definitively absent (crashed out to a bare
/// shell), or the driver has no real signal to offer (see above).
/// `BlockingModal` — the PID is up but the pane tail matches a
/// [`BLOCKING_MODAL_MARKERS`] entry.
/// Test: `pane_readiness_ready_when_pid_up_and_no_modal`,
/// `pane_readiness_not_ready_when_pid_absent`,
/// `pane_readiness_blocking_modal_when_pid_up_but_modal_shown`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum PaneReadiness {
    /// Runtime PID present, no known blocking modal in the captured tail.
    Ready,
    /// The runtime PID has not (yet, or ever) appeared.
    RuntimeNotReady,
    /// PID present but the pane tail matches a known first-run/trust-dialog marker.
    BlockingModal,
}

/// Pure gate: should the spawned session's task be auto-injected into its pane?
///
/// Why: the daemon spawn path must decide — before spending a background task on
/// readiness polling — whether injection applies at all. Keeping the decision a
/// pure function of primitives makes every branch unit-testable without a daemon,
/// a tmux, or a session record.
/// What: returns `true` only when ALL hold: the caller did not opt out
/// (`inject_flag`), the task is non-empty after trimming, the runtime is
/// `ClaudeCode` (the `tcode` adapter already injects the task in its launch
/// command, so re-injecting would duplicate it), and the session reached
/// `Active` (a spawn that errored or was withheld by the intent-conformance
/// front gate stays `Provisioning`/`Errored` — never inject into those).
/// Test: `should_inject_task_true_for_active_claude_code`,
/// `should_inject_task_false_when_opted_out`,
/// `should_inject_task_false_for_empty_task`,
/// `should_inject_task_false_for_tcode`,
/// `should_inject_task_false_when_not_active`.
pub fn should_inject_task(
    inject_flag: bool,
    task: &str,
    runtime: RuntimeKind,
    state: &ManagedSessionState,
) -> bool {
    inject_flag
        && !task.trim().is_empty()
        && runtime == RuntimeKind::ClaudeCode
        && *state == ManagedSessionState::Active
}

impl SessionManager {
    /// Deliver `task` into the session's pane once its runtime is ready (#1903),
    /// tracking delivery status on the record throughout (#2364).
    ///
    /// Why: this is the turnkey half of `--task` — after the pane is up and the
    /// runtime has launched, the task is typed in through the SAME seam
    /// `tm session send` uses ([`Self::send_input`] → `send_line` + Enter), so a
    /// caller that treats `--task` as "start working immediately" gets exactly
    /// that. Delivery MUST wait for readiness: keystrokes sent before `claude`
    /// has exec'd (or while a first-run onboarding/trust modal is occupying
    /// input focus) are lost, so this polls
    /// [`super::driver::ManagedTmuxDriver::runtime_ready`] AND
    /// [`pane_shows_blocking_modal`] with bounded exponential backoff rather
    /// than a blind sleep. Before this fix injection was fire-and-forget — a
    /// backoff exhaustion or a session dying mid-wait was only ever a `warn!`
    /// log line, with no caller-visible signal; every exit path now also
    /// updates [`super::injection_status::InjectionStatus`] via [`Self::set_injection_status`].
    /// What: returns `Ok(false)` early for an empty task (status stays
    /// `NotApplicable` — this mirrors [`should_inject_task`]'s empty-task gate,
    /// so a direct call with a blank task is a documented no-op, not a
    /// reportable failure). Otherwise marks the record `Pending` and polls
    /// readiness up to [`READY_MAX_ATTEMPTS`] times (backoff
    /// [`READY_BACKOFF_START`]→[`READY_BACKOFF_MAX`]): each attempt bails
    /// `FailedSessionDied` (with a warning, `Ok(false)`) if the session
    /// transitioned to `Stopped`/`Errored`/`Decommissioned` while waiting, and
    /// only treats the runtime as ready when BOTH `runtime_ready` reports the
    /// `claude` PID present AND the captured pane tail shows no known
    /// blocking-modal marker (a pane-scoped `capture_pane`). #9101: each probe
    /// first proves the pane with [`Self::owned_pane`]; a refusal marks
    /// `FailedSessionDied` and returns the `Err`, with no pane read or typed
    /// into. Exhausting the budget
    /// (whether from PID absence or a persistently blocked modal) marks
    /// `FailedTimeout`. On readiness it calls [`Self::send_input`]: success
    /// marks `Success` and returns `Ok(true)`; a failure at this late stage
    /// (readiness already confirmed, so the pane became undeliverable-to in
    /// the interim — practically indistinguishable from the session dying)
    /// marks `FailedSessionDied` and propagates the `Err`. The task is NOT
    /// lost on any failure path — it remains on the record as metadata,
    /// exactly as before this fix, so a caller can still deliver it manually
    /// via `tm session send`.
    /// #8942: a live protected-kind record returns `Ok(false)` untouched.
    /// Test: `inject_when_ready_sends_task_via_send_seam`,
    /// `inject_when_ready_skips_empty_task`,
    /// `task_injection_never_types_into_a_supervisor_pane`,
    /// `inject_when_ready_marks_success_status`,
    /// `inject_when_ready_marks_failed_session_died_status`,
    /// `inject_when_ready_times_out_when_modal_never_clears`,
    /// `inject_when_ready_checks_pane_scoped_capture_when_pane_id_known` in
    /// this module's `tests`.
    pub async fn inject_task_when_ready(
        &self,
        id: &ManagedSessionId,
        task: &str,
    ) -> Result<bool, ManagedError> {
        if task.trim().is_empty() {
            return Ok(false);
        }
        let initial = self.get(id).await?;
        // #8942 ruling 4: nothing is ever typed into the Architect's pane.
        if super::supervisor::skip_protected(&initial, "task injection") {
            return Ok(false);
        }
        let name = initial.tmux_name;

        self.set_injection_status(id, InjectionStatus::Pending)
            .await;

        let mut delay = READY_BACKOFF_START;
        let mut ready = false;
        for attempt in 0..READY_MAX_ATTEMPTS {
            if attempt > 0 {
                tokio::time::sleep(delay).await;
                delay = (delay * 2).min(READY_BACKOFF_MAX);
            }
            // Bail if the session died / was torn down while we waited — typing
            // into a dead pane is pointless and `send_input` would reject it.
            let record = self.get(id).await?;
            let state = record.state.clone();
            if matches!(
                state,
                ManagedSessionState::Stopped
                    | ManagedSessionState::Errored
                    | ManagedSessionState::Decommissioned
            ) {
                warn!(
                    id = %id,
                    name = %name,
                    state = %state,
                    "task injection aborted: session no longer live (task retained as metadata)"
                );
                self.set_injection_status(id, InjectionStatus::FailedSessionDied)
                    .await;
                return Ok(false);
            }
            match self.pane_readiness(id, &record) {
                Ok(PaneReadiness::Ready) => {
                    ready = true;
                    break;
                }
                Ok(_) => {}
                // #9101: a pane not proven to be this record's is never polled
                // again, and nothing is typed into it.
                Err(e) => {
                    warn!(id = %id, name = %name, "task injection aborted: {e}; task retained as metadata");
                    self.set_injection_status(id, InjectionStatus::FailedSessionDied)
                        .await;
                    return Err(e);
                }
            }
        }

        if !ready {
            warn!(
                id = %id,
                name = %name,
                "runtime not ready within budget (PID absent or blocked by a first-run modal); \
                 task NOT injected (retained as metadata — deliver it with `tm session send`)"
            );
            self.set_injection_status(id, InjectionStatus::FailedTimeout)
                .await;
            return Ok(false);
        }

        // A `send_input` failure at this point (readiness already confirmed)
        // means the pane became undeliverable-to between the last readiness
        // check and now — practically indistinguishable from the session
        // having died out from under us, so it is recorded as
        // `FailedSessionDied` (#2364) rather than left stuck at `Pending`
        // forever, which would recreate the exact observability gap this
        // issue exists to close.
        if let Err(e) = self.send_input(id, task).await {
            warn!(
                id = %id,
                name = %name,
                "send_input failed after readiness was confirmed: {e}; task retained as metadata"
            );
            self.set_injection_status(id, InjectionStatus::FailedSessionDied)
                .await;
            return Err(e);
        }
        info!(id = %id, name = %name, "injected --task into ready session pane (#1903)");
        self.set_injection_status(id, InjectionStatus::Success)
            .await;
        Ok(true)
    }

    /// Capture the pane tail used by the readiness-modal probe, pane-scoped
    /// when the record captured a `pane_id` at spawn time (#2545 convention).
    ///
    /// Why: split out so [`Self::inject_task_when_ready`]'s poll loop reads as
    /// one clear condition; mirrors [`Self::observe`]'s identical
    /// pane-id-known → `capture_pane` / else → `capture` fallback so the
    /// modal probe never reintroduces a session-scoped read when a pane id is
    /// already known (the sibling-window hijack #2468 closed for every other
    /// pane read). Capture failures degrade to an empty string — a read
    /// failure must never itself look like "modal detected"; it is treated as
    /// "nothing to flag," leaving the PID-presence check as the sole
    /// readiness signal for that attempt (the loop simply retries).
    /// `pub(super)` (#3591): also called directly by [`Self::send_input`]'s
    /// modal-only gate in `super::manager` — see that method's doc for why it
    /// reuses this capture but NOT [`Self::pane_readiness`] wholesale.
    /// What: `capture_pane(name, pane_id, MODAL_PROBE_LINES)`. #9101: callers
    /// pass only a pane [`Self::owned_pane`] proved, so the session-scoped
    /// `capture` fallback is gone.
    /// Test: `inject_when_ready_checks_pane_scoped_capture_when_pane_id_known`;
    /// `send_input`'s reuse is covered by
    /// `manager_send_input_rejected_when_pane_shows_blocking_modal`.
    pub(super) fn capture_readiness_pane(&self, name: &str, pane_id: &str) -> String {
        self.tmux
            .capture_pane(name, pane_id, MODAL_PROBE_LINES)
            .unwrap_or_default()
    }

    /// One-shot readiness probe used by [`Self::inject_task_when_ready`]'s
    /// bounded poll loop (issue #3591 factored this out of the loop body; see
    /// [`PaneReadiness`]'s doc for why `send_input` deliberately does NOT
    /// reuse this wholesale).
    ///
    /// Why: split out so the loop reads as one clear condition instead of the
    /// `runtime_ready(...) && !pane_shows_blocking_modal(...)` conjunction
    /// inline.
    /// What: #9101: first [`Self::owned_pane`], whose refusal is the `Err`, so
    /// no probe reads a pane the record does not own. Then short-circuits to
    /// [`PaneReadiness::RuntimeNotReady`] without paying for a pane capture
    /// when `runtime_ready` is false; otherwise inspects the owned pane's
    /// captured tail for a [`BLOCKING_MODAL_MARKERS`] hit.
    /// Test: `pane_readiness_ready_when_pid_up_and_no_modal`,
    /// `pane_readiness_not_ready_when_pid_absent`,
    /// `pane_readiness_blocking_modal_when_pid_up_but_modal_shown`,
    /// `a_stale_record_after_a_server_restart_never_has_its_task_injected`,
    /// `an_unreadable_pane_identity_stops_task_injection_unread`.
    pub(super) fn pane_readiness(
        &self,
        id: &ManagedSessionId,
        record: &SessionRecord,
    ) -> Result<PaneReadiness, ManagedError> {
        let pane_id = self.owned_pane(id, record)?;
        let name = record.tmux_name.as_str();
        if !self.tmux.runtime_ready(name) {
            return Ok(PaneReadiness::RuntimeNotReady);
        }
        if pane_shows_blocking_modal(&self.capture_readiness_pane(name, &pane_id)) {
            return Ok(PaneReadiness::BlockingModal);
        }
        Ok(PaneReadiness::Ready)
    }

    /// Persist an [`InjectionStatus`] transition on the session record (#2364).
    ///
    /// Why: every exit path of [`Self::inject_task_when_ready`] must leave a
    /// caller-visible trace of what happened — this is the single write point
    /// so the status enum's meaning cannot drift between call sites. Mirrors
    /// [`Self::set_deliverable_id`]'s simple get-mutate-upsert shape (no retry
    /// loop): a lost status update degrades to a stale `Pending`/prior value,
    /// which is a visibility gap, not a correctness hazard (the actual
    /// delivery outcome — sent or not — is unaffected).
    /// What: fetches the current record, overwrites `injection_status`, and
    /// upserts. Errors are logged and swallowed (`fn` returns nothing) so a
    /// transient store failure never aborts injection itself — the caller
    /// already has its own `Result` for the substantive outcome.
    /// Test: `inject_when_ready_marks_success_status`,
    /// `inject_when_ready_marks_failed_session_died_status`,
    /// `inject_when_ready_skips_empty_task` (asserts status stays
    /// `NotApplicable` for the early-return empty-task path).
    async fn set_injection_status(&self, id: &ManagedSessionId, status: InjectionStatus) {
        let mut record = match self.get(id).await {
            Ok(r) => r,
            Err(e) => {
                warn!(id = %id, "set_injection_status({status}): record lookup failed: {e}");
                return;
            }
        };
        record.injection_status = status;
        if let Err(e) = self.store.write().await.upsert(record).await {
            warn!(id = %id, "set_injection_status({status}): persist failed: {e}");
        }
    }

    /// Refuse [`Self::send_input`] (`super::manager`) for a session that is
    /// not safe to type into (issue #3591).
    ///
    /// Why: lives beside the readiness/modal machinery it reuses (and out of
    /// `manager.rs`, which is at its 500-SLOC production cap) rather than a
    /// second copy inline in `send_input` — see [`PaneReadiness`]'s doc for
    /// why this reuses only the modal half, not the `runtime_ready` half.
    /// What: refuses `Provisioning`/`Errored` outright (the shell-execution
    /// risk this issue is about); for `RuntimeKind::ClaudeCode` proves the
    /// pane with [`Self::owned_pane`] (#9101) and refuses
    /// a pane showing a [`BLOCKING_MODAL_MARKERS`] hit. Fail-fast: a single
    /// probe, no retry loop — see `send_input`'s doc for the block-vs-fail-fast
    /// rationale.
    /// Test: `manager_send_input_rejected_for_provisioning_and_errored`,
    /// `manager_send_input_rejected_when_pane_shows_blocking_modal`,
    /// `manager_send_input_skips_modal_probe_for_tcode` (in the sibling
    /// `send_input_gate_tests.rs`).
    pub(super) async fn check_send_input_ready(
        &self,
        id: &ManagedSessionId,
    ) -> Result<(), ManagedError> {
        let record = self.get(id).await?;
        if matches!(
            record.state,
            ManagedSessionState::Provisioning | ManagedSessionState::Errored
        ) {
            return Err(ManagedError::TmuxUnavailable(format!(
                "session {} is {}; cannot inject input",
                record.tmux_name, record.state
            )));
        }
        // #9101: the modal probe reads the pane, so it runs only on a pane
        // proven to be this record's.
        if record.runtime == RuntimeKind::ClaudeCode
            && pane_shows_blocking_modal(
                &self.capture_readiness_pane(&record.tmux_name, &self.owned_pane(id, &record)?),
            )
        {
            return Err(ManagedError::TmuxUnavailable(format!(
                "session {} is showing a blocking onboarding/trust modal; \
                 cannot inject input until it is dismissed",
                record.tmux_name
            )));
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::super::record::ManagedSessionState;
    use super::super::tests::{make_manager, make_manager_with_pane};
    use super::*;
    use tempfile::TempDir;

    #[test]
    fn should_inject_task_true_for_active_claude_code() {
        assert!(should_inject_task(
            true,
            "implement OAuth2",
            RuntimeKind::ClaudeCode,
            &ManagedSessionState::Active,
        ));
    }

    #[test]
    fn should_inject_task_false_when_opted_out() {
        // `--no-inject` / metadata-only: never inject even for a live claude session.
        assert!(!should_inject_task(
            false,
            "implement OAuth2",
            RuntimeKind::ClaudeCode,
            &ManagedSessionState::Active,
        ));
    }

    #[test]
    fn should_inject_task_false_for_empty_task() {
        assert!(!should_inject_task(
            true,
            "   ",
            RuntimeKind::ClaudeCode,
            &ManagedSessionState::Active,
        ));
    }

    #[test]
    fn should_inject_task_false_for_tcode() {
        // tcode injects the task in its own launch command; re-injecting would duplicate it.
        assert!(!should_inject_task(
            true,
            "implement OAuth2",
            RuntimeKind::Tcode,
            &ManagedSessionState::Active,
        ));
    }

    #[test]
    fn should_inject_task_false_when_not_active() {
        // A spawn that errored or was withheld by the front gate must not be injected.
        for state in [
            ManagedSessionState::Provisioning,
            ManagedSessionState::Errored,
            ManagedSessionState::Stopped,
            ManagedSessionState::Decommissioned,
        ] {
            assert!(!should_inject_task(
                true,
                "implement OAuth2",
                RuntimeKind::ClaudeCode,
                &state,
            ));
        }
    }

    #[tokio::test]
    async fn inject_when_ready_sends_task_via_send_seam() {
        // Integration-style: with the FakeTmuxDriver (whose `runtime_ready`
        // defaults to `session_exists` → true after create), injection delivers
        // the task through the SAME `send_line` seam `tm session send` uses.
        let dir = TempDir::new().unwrap();
        // #9101: the send needs a pane the record owns.
        let (mgr, fake) = make_manager_with_pane(&dir).await;
        let record = mgr
            .create("wire up the widget".into(), None, None, None, None, None)
            .await
            .expect("create");
        // `spawn_task_injection` only calls `inject_task_when_ready` once
        // `should_inject_task` has already confirmed the record reached
        // `Active` (see `lifecycle.rs::spawn_task_injection`) — mirror that
        // precondition here rather than leaving the record `Provisioning`
        // (#3591: `send_input`'s new state guard now refuses `Provisioning`).
        {
            let mut store = mgr.store.write().await;
            let mut r = store.get(&record.id).await.unwrap();
            r.state = ManagedSessionState::Active;
            store.upsert(r).await.unwrap();
        }

        let injected = mgr
            .inject_task_when_ready(&record.id, "wire up the widget")
            .await
            .expect("inject");

        assert!(
            injected,
            "task should have been injected into the ready pane"
        );
        let sends = fake.pane_send_calls.lock().unwrap();
        assert_eq!(
            sends.len(),
            1,
            "exactly one send-line should fire: {sends:?}"
        );
        assert_eq!(sends[0].0, record.tmux_name);
        assert_eq!(sends[0].2, "wire up the widget");
    }

    #[tokio::test]
    async fn inject_when_ready_skips_empty_task() {
        // Metadata-vs-inject: an empty task is a no-op — nothing is typed.
        let dir = TempDir::new().unwrap();
        let (mgr, fake) = make_manager(&dir).await;
        let record = mgr
            .create("   ".into(), None, None, None, None, None)
            .await
            .expect("create");

        let injected = mgr
            .inject_task_when_ready(&record.id, "   ")
            .await
            .expect("inject");

        assert!(!injected, "empty task must not be injected");
        assert!(
            fake.send_calls.lock().unwrap().is_empty(),
            "no send-line should fire for an empty task"
        );
        // #2364: the early-return empty-task path never begins polling, so the
        // status must stay the untouched NotApplicable default — not Pending.
        let after = mgr.get(&record.id).await.expect("get");
        assert_eq!(after.injection_status, InjectionStatus::NotApplicable);
    }

    #[test]
    fn pane_shows_blocking_modal_true_for_trust_dialog() {
        let pane = "some banner text\nDo you trust the files in this folder?\n1. Yes  2. No";
        assert!(pane_shows_blocking_modal(pane));
    }

    #[test]
    fn pane_shows_blocking_modal_true_for_onboarding_welcome() {
        let pane = "Welcome to Claude Code!\nLet's get you set up.";
        assert!(pane_shows_blocking_modal(pane));
    }

    #[test]
    fn pane_shows_blocking_modal_false_for_normal_prompt() {
        let pane = "╭──────────────────────╮\n│ >                    │\n╰──────────────────────╯";
        assert!(!pane_shows_blocking_modal(pane));
    }

    #[test]
    fn pane_shows_blocking_modal_false_for_empty_pane() {
        assert!(!pane_shows_blocking_modal(""));
    }

    #[tokio::test]
    async fn pane_readiness_ready_when_pid_up_and_no_modal() {
        // #3591: the happy path both callers (poll loop and send_input's
        // fail-fast gate) rely on — PID up (FakeTmuxDriver's default
        // runtime_ready == session_exists, true right after create) and no
        // capture_responses seeded (empty tail, no marker match).
        let dir = TempDir::new().unwrap();
        let (mgr, _fake) = make_manager_with_pane(&dir).await;
        let record = mgr
            .create("wire up SSO".into(), None, None, None, None, None)
            .await
            .expect("create");

        assert_eq!(
            mgr.pane_readiness(&record.id, &record).expect("owned pane"),
            PaneReadiness::Ready
        );
    }

    #[tokio::test]
    async fn pane_readiness_not_ready_when_pid_absent() {
        // #3591: a pane with no live runtime PID (still booting, or crashed
        // out — modeled here via kill_session, which drops the fake's
        // session_exists/runtime_ready signal) must report RuntimeNotReady,
        // and must NOT pay for a modal-marker capture in that case.
        let dir = TempDir::new().unwrap();
        let (mgr, _fake) = make_manager_with_pane(&dir).await;
        let record = mgr
            .create("wire up SSO".into(), None, None, None, None, None)
            .await
            .expect("create");
        mgr.tmux_driver()
            .kill_session(&record.tmux_name)
            .expect("kill_session");

        assert_eq!(
            mgr.pane_readiness(&record.id, &record).expect("owned pane"),
            PaneReadiness::RuntimeNotReady
        );
    }

    #[tokio::test]
    async fn pane_readiness_blocking_modal_when_pid_up_but_modal_shown() {
        // #3591: PID present but the captured tail matches a known
        // onboarding/trust-dialog marker — must report BlockingModal, not Ready.
        let dir = TempDir::new().unwrap();
        let (mgr, fake) = make_manager_with_pane(&dir).await;
        let record = mgr
            .create("wire up SSO".into(), None, None, None, None, None)
            .await
            .expect("create");
        // #9101: the probe reads only the owned pane.
        fake.capture_responses.lock().unwrap().insert(
            "%1".to_string(),
            "Do you trust the files in this folder?".into(),
        );

        assert_eq!(
            mgr.pane_readiness(&record.id, &record).expect("owned pane"),
            PaneReadiness::BlockingModal
        );
    }

    #[tokio::test]
    async fn inject_when_ready_marks_success_status() {
        // #2364a: a successful delivery must leave the record's
        // injection_status as Success, not just return Ok(true).
        let dir = TempDir::new().unwrap();
        let (mgr, _fake) = make_manager_with_pane(&dir).await;
        let record = mgr
            .create("ship the feature".into(), None, None, None, None, None)
            .await
            .expect("create");
        // See the matching comment in `inject_when_ready_sends_task_via_send_seam`:
        // mirror `spawn_task_injection`'s Active precondition (#3591).
        {
            let mut store = mgr.store.write().await;
            let mut r = store.get(&record.id).await.unwrap();
            r.state = ManagedSessionState::Active;
            store.upsert(r).await.unwrap();
        }

        let injected = mgr
            .inject_task_when_ready(&record.id, "ship the feature")
            .await
            .expect("inject");
        assert!(injected);

        let after = mgr.get(&record.id).await.expect("get");
        assert_eq!(after.injection_status, InjectionStatus::Success);
    }

    #[tokio::test]
    async fn inject_when_ready_marks_failed_session_died_status() {
        // #2364a: a session that dies mid-wait must report FailedSessionDied,
        // not just a silent Ok(false).
        let dir = TempDir::new().unwrap();
        let (mgr, fake) = make_manager(&dir).await;
        let record = mgr
            .create("do the thing".into(), None, None, None, None, None)
            .await
            .expect("create");
        mgr.mark_errored(&record.id, "spawn failed")
            .await
            .expect("mark_errored");

        let injected = mgr
            .inject_task_when_ready(&record.id, "do the thing")
            .await
            .expect("inject");

        assert!(!injected, "a dead session must never receive the task");
        assert!(
            fake.send_calls.lock().unwrap().is_empty(),
            "no send-line should fire once the session is Errored"
        );
        let after = mgr.get(&record.id).await.expect("get");
        assert_eq!(after.injection_status, InjectionStatus::FailedSessionDied);
    }

    #[tokio::test(start_paused = true)]
    async fn inject_when_ready_times_out_when_modal_never_clears() {
        // #2364b: a PID that exists but a pane permanently showing a
        // first-run/trust modal must NEVER receive the task — the readiness
        // loop must keep retrying (not silently inject into the modal) and
        // eventually report FailedTimeout once the backoff budget is spent.
        // `start_paused` fast-forwards Tokio's virtual clock through the
        // ~30-attempt backoff schedule without a real ~60s wall-clock wait.
        let dir = TempDir::new().unwrap();
        let (mgr, fake) = make_manager_with_pane(&dir).await;
        let record = mgr
            .create("wire up SSO".into(), None, None, None, None, None)
            .await
            .expect("create");
        // #9101: the probe reads only the owned pane.
        fake.capture_responses.lock().unwrap().insert(
            "%1".to_string(),
            "Do you trust the files in this folder?".into(),
        );

        let injected = mgr
            .inject_task_when_ready(&record.id, "wire up SSO")
            .await
            .expect("inject");

        assert!(
            !injected,
            "a pane permanently blocked by a modal must never be injected"
        );
        assert!(
            fake.send_calls.lock().unwrap().is_empty(),
            "the modal-guarded loop must never call send_input while blocked"
        );
        let after = mgr.get(&record.id).await.expect("get");
        assert_eq!(after.injection_status, InjectionStatus::FailedTimeout);
    }

    #[tokio::test(start_paused = true)]
    async fn inject_when_ready_checks_pane_scoped_capture_when_pane_id_known() {
        // #2364b + #2545 convention: when the record captured a pane_id at
        // spawn time, the modal probe must read via the PANE-SCOPED
        // capture_pane (keyed by pane_id in the fake), never the
        // session-scoped capture — proves the hardening does not reintroduce
        // a session-scoped read.
        let dir = TempDir::new().unwrap();
        let (mgr, fake) = make_manager(&dir).await;
        *fake.pane_id_override.lock().unwrap() = Some("%7".to_string());
        let record = mgr
            .create("wire up SSO".into(), None, None, None, None, None)
            .await
            .expect("create");
        assert_eq!(
            record.pane_id.as_deref(),
            Some("%7"),
            "create() must have captured the overridden pane_id"
        );
        fake.capture_responses.lock().unwrap().insert(
            "%7".to_string(),
            "Do you trust the files in this folder?".into(),
        );

        let injected = mgr
            .inject_task_when_ready(&record.id, "wire up SSO")
            .await
            .expect("inject");

        assert!(
            !injected,
            "the pane-scoped modal marker must block injection"
        );
        assert!(
            !fake.pane_capture_calls.lock().unwrap().is_empty(),
            "the modal probe must have used the pane-scoped capture_pane path"
        );
        let after = mgr.get(&record.id).await.expect("get");
        assert_eq!(after.injection_status, InjectionStatus::FailedTimeout);
    }
}