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
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
//! Thin adapter over `trusty_common::tmux` + the crate's single
//! tmux-spawning entry point (#2398 architecture consolidation, #3004
//! shared-layer extraction).
//!
//! Why: trusty-mpm hosts each Claude Code session inside a named tmux session
//! (the primary control model — see `docs/research/session-control-models.md`).
//! `TmuxTarget`/`TmuxCommand`/`tmux_argv` used to live here, but issue #3004
//! extracted them (plus `scrollback_option_commands` and the
//! options-before-`new-session` ordering guarantee) into `trusty_common::tmux`
//! once a scrollback bug report against trusty-agents' INDEPENDENT tmux
//! implementation revealed the real problem: #2398/#2399 could only fix the
//! crate they lived in. This module re-exports the shared pure layer
//! unchanged (every existing call site in this crate keeps compiling against
//! `crate::core::tmux::{TmuxTarget, TmuxCommand, tmux_argv, ...}`) and keeps
//! everything that is genuinely trusty-mpm-specific: binary resolution
//! glue, TCC-disclaimed process spawning (issue #2819/#2820 — NOT moved to
//! the shared layer, since it is a macOS/trusty-mpm-signing concern, not a
//! tmux concern), and `create_managed_session`, which layers this crate's
//! own `TrustyToolsConfig` resolution on top of the shared
//! `managed_session_commands` recipe.
//!
//! Originally this module was argv-construction only, with each of the
//! daemon (`daemon::tmux::TmuxDriver`), the CLI (`bin/tm`'s launch/connect/
//! session-start commands), the TUI client (`client::http_client`), and the
//! control-plane backend (`control::backend::tmux`) independently spawning
//! `std::process::Command::new("tmux")`. That let FIVE call sites bypass the
//! #2398 scrollback/mouse ergonomics entirely (a QA-caught regression) simply
//! by not knowing about `daemon::tmux::TmuxDriver::create_session`. Per Bob's
//! architecture directive, this module now ALSO owns the actual process
//! spawning for output-capturing tmux commands
//! ([`run_tmux_with_bin`]/[`run_tmux`]) and the single choke point for
//! creating a session with ergonomics applied first
//! ([`create_managed_session`]) — every session-creating call site in the
//! crate routes through it, so nothing can bypass the scrollback/mouse
//! options again. [`daemon::tmux::TmuxDriver`](crate::daemon::tmux::TmuxDriver)
//! wraps [`run_tmux_with_bin`] rather than spawning independently.
//!
//! Scope note (#2414, closing the #2398 follow-up): the four read-only probes
//! that used to shell out directly — `tmux display-message -p '#S'`
//! (current-session-name lookups in `bin/tm/commands/tmux_attach.rs` and
//! `bin/tm/commands/statusline/branch.rs`), `tmux show-environment`
//! (`bin/tm/commands/guided_inplace.rs`), and
//! `tmux display-message -t <s> -p '#{pane_pid}'`
//! (`core::process::tmux_pane_pid`) — now route through
//! [`display_message_argv`]/[`show_environment_argv`] +
//! [`run_tmux_argv_with_bin`]/[`run_tmux_argv`]. Those are a SEPARATE pair of
//! entry points from [`run_tmux_with_bin`]/[`run_tmux`] because
//! `trusty_common::tmux::TmuxCommand` does not model `display-message`/
//! `show-environment`, and extending that shared enum was out of scope for
//! #2414 (a concurrent branch owns `trusty-common/`); they still fold into the
//! SAME [`run_tmux_argv_with_bin`] → [`crate::core::spawn_disclaim::disclaimed_output`]
//! spawn primitive [`run_tmux_with_bin`] itself calls, so binary resolution and
//! TCC-disclaim wrapping stay unified across every tmux call in the crate even
//! though argv construction for these two sub-commands is local. This mirrors
//! `statusline::branch::git_branch`'s existing pattern of calling
//! `disclaimed_output` directly for a probe with no typed command wrapper.
//! `bin/tm/commands/tmux_attach.rs::tmux_attach` (the actual `attach-session`/
//! `switch-client` spawn) DOES route its binary resolution through
//! [`resolve_tmux_binary_or_bare`] even though its interactive,
//! stdio-inheriting `.status()` call shape does not fit [`run_tmux`]'s
//! output-capturing signature.
//! What: re-exports `TmuxTarget`/`TmuxCommand`/`tmux_argv`/
//! `scrollback_option_commands`/the scrollback defaults from
//! `trusty_common::tmux`; [`resolve_tmux_binary`]/[`resolve_tmux_binary_or_bare`]
//! (binary resolution), [`run_tmux_with_bin`]/[`run_tmux`] (the shared spawn
//! primitive for typed `TmuxCommand`s), [`display_message_argv`]/
//! [`show_environment_argv`]/[`run_tmux_argv_with_bin`]/[`run_tmux_argv`] (the
//! same spawn primitive for the two untyped sub-commands above), and
//! [`create_managed_session`] (the session-creation choke point with
//! ergonomics baked in, config-resolved locally).
//! Test: `cargo test -p trusty-mpm-core` covers the mpm-specific spawn/config
//! glue; argv construction and the options-before-new-session ordering
//! guarantee are tested once in `trusty_common::tmux` and reused here without
//! re-testing (`managed_session_command_sequence_matches_shared_layer`
//! asserts THIS crate's config-resolved call routes through it correctly).

use tracing::warn;

pub use trusty_common::tmux::{
    ALTERNATE_SCREEN_OPTION, DEFAULT_TMUX_ALTERNATE_SCREEN, DEFAULT_TMUX_HISTORY_LIMIT,
    DEFAULT_TMUX_MOUSE, HISTORY_LIMIT_OPTION, MOUSE_OPTION, PANE_LIST_FORMAT, SESSION_LIST_FORMAT,
    TmuxCommand, TmuxTarget, TmuxTargetError, WINDOW_LIST_FORMAT, check_session_name,
    exact_pane_target, exact_session_target, exact_window_target, is_immutable_id,
    managed_session_commands, scrollback_option_commands, shell_attach_command,
    shell_exact_session_target, tmux_argv,
};

/// Resolve the `tmux` binary, preferring live `PATH` and falling back to
/// well-known daemon dirs (Homebrew, user bins) via `trusty_common::bin_resolve`
/// (#2398 architecture consolidation).
///
/// Why: a daemon process under launchd inherits a minimal `PATH` (#1298);
/// resolving through the SAME helper for every caller (daemon, CLI, TUI
/// client) — rather than a bare `"tmux"` PATH lookup for some callers and a
/// well-known-dirs-aware lookup for others — means resolution behavior is
/// identical everywhere, and there is exactly one place to fix if it ever
/// needs to change.
/// What: returns the [`with_tmux_binary`] override when the current task has
/// one, else delegates to `trusty_common::bin_resolve::resolve_binary("tmux")`.
/// Test: `resolve_tmux_binary_does_not_panic`,
/// `with_tmux_binary_scopes_the_override_to_its_future`.
pub fn resolve_tmux_binary() -> Option<std::path::PathBuf> {
    if let Ok(bin) = TMUX_BINARY_OVERRIDE.try_with(Clone::clone) {
        return Some(bin);
    }
    // #6542: a no-op outside a test binary; in one, the first resolution
    // starts that process's config-free private default server.
    crate::core::tmux_test_isolation::ensure_default_server();
    trusty_common::bin_resolve::resolve_binary("tmux")
}

tokio::task_local! {
    /// The tmux binary [`resolve_tmux_binary`] returns inside a
    /// [`with_tmux_binary`] scope.
    static TMUX_BINARY_OVERRIDE: std::path::PathBuf;
}

/// Run `fut` with every tmux resolution inside it returning `bin` (#6542).
///
/// Why: the guided-fallback tests drive a real `launch()`, which creates a
/// tmux session through a dozen call sites that each resolve the binary
/// themselves. A test that only reaps afterwards leaked sessions onto the
/// operator's live server whenever the reap missed. Pointing `bin` at a
/// `tmux -L <private>` shim sends every one of those calls to a server the
/// test owns outright. The scope is task-local, not process-global, so tests
/// running in parallel keep resolving the real binary.
/// What: installs `bin` for [`resolve_tmux_binary`] and
/// [`resolve_tmux_binary_or_bare`] while `fut` is polled. Work moved to
/// another task or thread does not see it.
/// Test: `with_tmux_binary_scopes_the_override_to_its_future`.
pub async fn with_tmux_binary<F: std::future::Future>(
    bin: std::path::PathBuf,
    fut: F,
) -> F::Output {
    TMUX_BINARY_OVERRIDE.scope(bin, fut).await
}

/// [`resolve_tmux_binary`], falling back to the literal `"tmux"` (a plain
/// `PATH` lookup at spawn time) when resolution itself comes up empty.
///
/// Why: callers that do not need [`TmuxDriver::discover`](crate::daemon::tmux::TmuxDriver::discover)'s
/// fail-fast-if-truly-absent contract — the CLI and TUI
/// client, which already surface their own "tmux command failed" error on the
/// subsequent spawn — just want a best-effort binary name to run. Falling
/// back to the bare string preserves their pre-#2398 behavior (trust the
/// interactive shell's own `PATH`) as the worst case.
/// What: returns the resolved absolute path as a `String` when found, else
/// `"tmux"`.
/// Test: `resolve_tmux_binary_or_bare_never_empty`.
pub fn resolve_tmux_binary_or_bare() -> String {
    resolve_tmux_binary()
        .and_then(|p| p.to_str().map(str::to_string))
        .unwrap_or_else(|| "tmux".to_string())
}

/// The `LC_ALL` value [`force_utf8_client_env`] injects when nothing else
/// declares UTF-8.
///
/// Why: tmux only string-matches the variable, so the locale need not be
/// installed for the client flag to be set; `en_US.UTF-8` is the locale tmux
/// itself tries first at startup.
const UTF8_LOCALE: &str = "en_US.UTF-8";

/// Does the environment already declare UTF-8 the way tmux's client decides it?
///
/// Why: tmux does not test the three variables independently — it takes the
/// FIRST non-empty of `LC_ALL`, `LC_CTYPE`, `LANG` and tests only that one. An
/// any-of test would read `LC_ALL=C` beside `LANG=en_US.UTF-8` as UTF-8 while
/// tmux reads it as not, which is the exact case this helper exists to repair.
/// What: mirrors that precedence, then substring-matches `utf-8`/`utf8`
/// case-insensitively, as tmux's `strcasestr` does.
/// Test: `env_declares_utf8_follows_tmux_precedence`.
fn declares_utf8(lc_all: Option<&str>, lc_ctype: Option<&str>, lang: Option<&str>) -> bool {
    let effective = [lc_all, lc_ctype, lang]
        .into_iter()
        .flatten()
        .find(|v| !v.is_empty());
    effective.is_some_and(|v| {
        let lower = v.to_ascii_lowercase();
        lower.contains("utf-8") || lower.contains("utf8")
    })
}

/// Guarantee a spawned `tmux` client declares UTF-8, so tmux does not rewrite
/// the delimiters out of a `-F` listing (#6529).
///
/// Why: tmux sets its `CLIENT_UTF8` flag purely from `LC_ALL` / `LC_CTYPE` /
/// `LANG`. Without that flag the SERVER pushes every printed line through
/// `utf8_sanitize()`, which replaces each byte below `0x20` with `_` — the TAB
/// separating the columns of `list-panes -F` included. A launchd-started daemon
/// inherits no locale variables at all, so its pane rows arrived as one
/// underscore-joined field: `session_name` held the whole row,
/// `pane_current_command` came back empty, and the orphan-GC's idleness gate
/// could never fire. Measured on tmux 3.6b: identical argv, same server, tabs
/// with `LC_ALL=en_US.UTF-8` and underscores with `LC_ALL=C`.
/// What: sets `LC_ALL` to [`UTF8_LOCALE`] only when the inherited environment
/// does not already declare UTF-8 by tmux's own precedence ([`declares_utf8`]),
/// so an operator's own UTF-8 locale is left alone. Mutates the child's
/// environment only — never this process's. Every tmux call whose output is
/// parsed by column MUST route through here.
/// Test: `env_declares_utf8_follows_tmux_precedence`,
/// `force_utf8_client_env_is_a_no_op_under_a_utf8_locale`.
pub fn force_utf8_client_env(cmd: &mut std::process::Command) -> &mut std::process::Command {
    let read = |key: &str| std::env::var(key).ok();
    if !declares_utf8(
        read("LC_ALL").as_deref(),
        read("LC_CTYPE").as_deref(),
        read("LANG").as_deref(),
    ) {
        cmd.env("LC_ALL", UTF8_LOCALE);
    }
    cmd
}

/// Run a typed tmux command against an EXPLICITLY resolved binary path,
/// returning the raw process `Output` (#2398 architecture consolidation).
///
/// Why: this is the ONE place in the crate that actually spawns an
/// output-capturing `tmux` child process — every caller
/// ([`daemon::tmux::TmuxDriver`](crate::daemon::tmux::TmuxDriver), `bin/tm`'s
/// CLI commands, `client::http_client`'s TUI-driving methods, and
/// `control::backend::tmux::TmuxBackend`) routes through here so argv
/// construction and process-spawning can never drift or be re-implemented ad
/// hoc at a call site — the exact drift that let 5 CLI/client call sites
/// bypass the scrollback/mouse ergonomics entirely (the QA finding this
/// consolidation closes). Takes an explicit `tmux_bin` (rather than
/// re-resolving on every call) so callers that already cache a resolved path
/// — like `TmuxDriver`, which resolves once in `discover()` for its
/// fail-fast-if-absent contract — do not pay repeated resolution cost.
/// What: renders `cmd` via [`tmux_argv`] and spawns `tmux` through
/// [`crate::core::spawn_disclaim::disclaimed_output`], which on macOS disclaims
/// TCC responsibility so the forked tmux server (and every `claude`/agent
/// descendant) is its own responsible process rather than rolling attribution up
/// to the signed `trusty-mpm` binary (issue #2819). On non-macOS it is a plain
/// `Command::output`. Every session-creating call site routes through here, so
/// the disclaim covers the one spawn that forks the shared server regardless of
/// which tmux sub-command triggers the fork. This TCC-disclaim wrapping is WHY
/// process spawning stayed in trusty-mpm rather than moving to
/// `trusty_common::tmux` in #3004 — it is a trusty-mpm-signing-identity
/// concern, not a tmux-argv concern, and trusty-agents does not need it (no
/// equivalent signed-binary/TCC-prompt problem reported for it). Callers own
/// interpreting the exit status / stderr — this function does not classify
/// failures.
/// Test: `disclaimed_output_captures_stdout` (and the sibling
/// `disclaimed_output_*` tests) in `crate::core::spawn_disclaim` cover the
/// disclaim/capture behaviour; this thin adapter is exercised transitively by
/// every call site (a live `tmux` binary is required to observe real output —
/// see the `#[ignore]` integration tests).
pub fn run_tmux_with_bin(
    tmux_bin: &str,
    cmd: &TmuxCommand,
) -> std::io::Result<std::process::Output> {
    host_state_guard()?;
    // #8443: an empty session name addresses no session; never spawn for it.
    cmd.validate_targets()
        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
    crate::core::spawn_disclaim::disclaimed_output(tmux_bin, &tmux_argv(cmd))
}

/// Refuse a tmux spawn when this process is not running in the operator's
/// real environment (#5784).
///
/// Why: a tmux server is keyed to the OS user, not to `$HOME`, so a daemon or
/// CLI under a throwaway `$HOME` still reaches the operator's live sessions.
/// [`crate::daemon::tmux::TmuxDriver::discover`] gates every path that holds a
/// driver, but `tm launch`/`tm connect` CREATE and KILL sessions through
/// [`create_managed_session`] and [`run_tmux`] without ever constructing one —
/// so the check also belongs at the spawn itself, which this module already
/// owns as the crate's single tmux-spawning point.
/// What: `Ok(())` when [`crate::core::host_state_gate::host_state_access`]
/// allows; otherwise an `io::Error` of kind `PermissionDenied` carrying the
/// reason, so a caller that only prints the error still tells its operator
/// which variable lifts the gate. Logged at `debug!` rather than `warn!`
/// because one operation issues many spawns — the loud line belongs at the
/// entry point ([`create_managed_session`], `TmuxDriver::discover`,
/// `discovery::discover_all`, the daemon's startup banner).
/// Test: `scratch_home_daemon_does_not_spawn_tmux` in
/// `tests/scratch_home_tmux_gate.rs` drives both a `create_managed_session`
/// and a `run_tmux(KillSession)` through this guard and asserts each is
/// refused with `PermissionDenied` and no subprocess.
fn host_state_guard() -> std::io::Result<()> {
    match crate::core::host_state_gate::host_state_access().skip_reason() {
        None => Ok(()),
        Some(reason) => {
            tracing::debug!("#5784: tmux spawn refused — {reason}");
            Err(std::io::Error::new(
                std::io::ErrorKind::PermissionDenied,
                format!("tmux spawn refused: {reason}"),
            ))
        }
    }
}

/// [`run_tmux_with_bin`], resolving the tmux binary via
/// [`resolve_tmux_binary_or_bare`] first.
///
/// Why: the common-case convenience for callers (CLI, TUI client) that do not
/// already hold a cached, resolved tmux path.
/// What: resolves then delegates to [`run_tmux_with_bin`].
/// Test: exercised transitively; see [`run_tmux_with_bin`].
pub fn run_tmux(cmd: &TmuxCommand) -> std::io::Result<std::process::Output> {
    run_tmux_with_bin(&resolve_tmux_binary_or_bare(), cmd)
}

/// Render `tmux display-message [-t <target>] -p <format>` argv (#2414).
///
/// Why: `trusty_common::tmux::TmuxCommand` has no `display-message` variant
/// (extending that shared enum is out of scope here — see this module's scope
/// note), so the four read-only probes this issue migrates need a local,
/// pure argv builder to route through, exactly like [`tmux_argv`] does for
/// the typed commands.
/// What: `target: None` renders untargeted (`display-message -p <format>`,
/// which tmux resolves against the invoking CLIENT, not any particular
/// session — the "current client" fallback [`tmux_session_name`](crate)
/// relies on; #8694 moved `current_tmux_session_name` to a pane target); `Some(target)`
/// renders `-t <target.as_target()> -p <format>`, addressing exactly the
/// session or bare pane id [`TmuxTarget::as_target`] renders (never a
/// `"session:%pane"` compound, which tmux parses as a window spec, not a
/// pane spec).
/// Test: `display_message_argv_untargeted`, `display_message_argv_session_targeted`.
pub fn display_message_argv(target: Option<&TmuxTarget>, format: &str) -> Vec<String> {
    let mut argv = vec!["display-message".to_string()];
    if let Some(t) = target {
        argv.push("-t".to_string());
        argv.push(t.as_target());
    }
    argv.push("-p".to_string());
    argv.push(format.to_string());
    argv
}

/// Render `tmux show-environment [-t <name>] <key>` argv (#2414).
///
/// Why: the [`display_message_argv`] counterpart for
/// `bin/tm/commands/guided_inplace.rs::read_tmux_env_managed_session_id`,
/// which reads a variable's durably-published value from the SESSION
/// environment table (never a per-pane `export`) — see
/// [`TmuxCommand::SetEnvironment`]'s doc for why that table exists.
/// What: `name: None` renders untargeted (`show-environment <key>`, querying
/// the CURRENT session — the only shape this issue's call site uses, since it
/// only ever runs from inside the tmux client whose own session it wants);
/// `Some(name)` renders `-t =<name> <key>` (exact match, #8443) for a caller
/// that needs an explicit session.
/// Test: `show_environment_argv_untargeted`, `show_environment_argv_session_targeted`.
pub fn show_environment_argv(name: Option<&str>, key: &str) -> Vec<String> {
    let mut argv = vec!["show-environment".to_string()];
    if let Some(n) = name {
        argv.push("-t".to_string());
        argv.push(exact_session_target(n));
    }
    argv.push(key.to_string());
    argv
}

/// Run raw tmux argv against an EXPLICITLY resolved binary path, through the
/// SAME spawn primitive [`run_tmux_with_bin`] uses (#2414).
///
/// Why: [`display_message_argv`]/[`show_environment_argv`] cover sub-commands
/// `trusty_common::tmux::TmuxCommand` does not model, so they cannot go
/// through [`run_tmux_with_bin`]'s `&TmuxCommand` signature — but the actual
/// spawn (binary resolution already done by the caller, TCC-disclaim
/// wrapping) must still be the ONE place that happens, or these four probes
/// would just re-introduce the ad hoc `Command::new("tmux")` drift #2414
/// exists to close, one layer down. Delegating straight to
/// [`crate::core::spawn_disclaim::disclaimed_output`] — the exact primitive
/// [`run_tmux_with_bin`] itself calls — keeps that one place shared.
/// What: spawns `tmux_bin` with `args` via `disclaimed_output`. Callers own
/// interpreting the exit status / stderr, exactly like [`run_tmux_with_bin`].
/// Test: exercised transitively by every migrated call site (a live `tmux`
/// binary is required to observe real output).
pub fn run_tmux_argv_with_bin(
    tmux_bin: &str,
    args: &[String],
) -> std::io::Result<std::process::Output> {
    host_state_guard()?;
    crate::core::spawn_disclaim::disclaimed_output(tmux_bin, args)
}

/// [`run_tmux_argv_with_bin`], resolving the tmux binary via
/// [`resolve_tmux_binary_or_bare`] first (#2414).
///
/// Why: the common-case convenience mirroring [`run_tmux`] for the untyped
/// argv path.
/// What: resolves then delegates to [`run_tmux_argv_with_bin`].
/// Test: exercised transitively; see [`run_tmux_argv_with_bin`].
pub fn run_tmux_argv(args: &[String]) -> std::io::Result<std::process::Output> {
    run_tmux_argv_with_bin(&resolve_tmux_binary_or_bare(), args)
}

/// [`run_tmux_argv`] under a deadline: a tmux that outlives `budget` is
/// killed with its process group, and the call is `ErrorKind::TimedOut`.
///
/// Why: #9001 — pm-guard lists tmux panes inside a hook Claude Code kills at
/// 5 s, and a stopped tmux server held the unbounded listing for minutes.
/// What: the host-state guard, then [`crate::core::bounded_proc::run_bounded`]
/// over the resolved binary, stdin from `/dev/null`. It is not TCC-disclaimed:
/// the disclaimed spawns have no kill-on-timeout, and a tmux client that only
/// reads its socket touches no TCC-protected resource.
/// Test: `a_stopped_tmux_server_times_out_the_listing`.
pub fn run_tmux_argv_bounded(
    args: &[String],
    budget: std::time::Duration,
) -> std::io::Result<std::process::Output> {
    use crate::core::bounded_proc::{BoundedError, run_bounded};
    host_state_guard()?;
    let mut cmd = std::process::Command::new(resolve_tmux_binary_or_bare());
    cmd.args(args).stdin(std::process::Stdio::null());
    match run_bounded(cmd, budget) {
        Ok(out) => Ok(std::process::Output {
            status: out.status,
            stdout: out.stdout.into_bytes(),
            stderr: out.stderr.into_bytes(),
        }),
        Err(BoundedError::Spawn(err)) => Err(err),
        Err(BoundedError::TimedOut) => Err(std::io::Error::new(
            std::io::ErrorKind::TimedOut,
            format!("tmux did not answer within {budget:?}"),
        )),
        Err(err) => Err(std::io::Error::other(format!("tmux {err}"))),
    }
}

/// Build the exact ordered [`TmuxCommand`] sequence [`create_managed_session`]
/// issues, given already-resolved tmux options (#3004).
///
/// Why: extracted as a pure function (no process spawning, no config
/// loading) purely so a hermetic unit test can assert THIS crate's
/// session-creation call routes through `trusty_common::tmux`'s shared
/// ordering guarantee with the right, config-resolved parameters — the
/// per-consumer "integration assertion" issue #3004 asks for, without
/// re-testing the ordering guarantee itself (already covered once in
/// `trusty_common::tmux`'s own tests).
/// What: delegates straight to
/// [`trusty_common::tmux::managed_session_commands`] with no initial command.
/// `idempotent` renders `-A` (trusty-mpm's attach-if-present creation
/// semantics); `false` is the #3707 exclusive create, where a name already in
/// use is a refusal the caller retries under a fresh name.
/// Test: `managed_session_command_sequence_matches_shared_layer`,
/// `exclusive_command_sequence_omits_the_attach_flag`.
fn managed_session_command_sequence(
    name: &str,
    workdir: Option<&str>,
    history_limit: u32,
    mouse: bool,
    alternate_screen: bool,
    idempotent: bool,
) -> Vec<TmuxCommand> {
    managed_session_commands(
        name,
        workdir,
        history_limit,
        mouse,
        alternate_screen,
        idempotent,
        None,
    )
}

/// Outcome of [`create_managed_session`] (#3386 review finding).
///
/// Why: a `warn!`-and-continue on a failed scrollback-ergonomics
/// verification is exactly the silent-degrade #3386 was originally filed
/// against — only grep-able, never surfaced to whoever asked for the
/// session. Wrapping the raw `new-session` `Output` together with an
/// explicit `options_verified` flag forces every call site to at least
/// LOOK at whether the pane it just got may be capped at tmux's factory
/// 2000-line history-limit, rather than the flag being available only to a
/// caller who happens to read the log.
/// What: `output` is the `new-session` process output — callers classify
/// session-creation success/failure from it exactly as before
/// (`output.status.success()`); `options_verified` is `true` only when an
/// apply-and-verify cycle (server-up, `set-option -g` x2, `show-options`
/// probe) confirmed `history-limit` landed on the server before this
/// `new-session` call ran.
#[derive(Debug)]
pub struct ManagedSessionOutcome {
    /// Raw `tmux new-session` process output.
    pub output: std::process::Output,
    /// `false` when the scrollback/mouse ergonomics could not be CONFIRMED
    /// to have landed before the pane was created — the pane may be capped
    /// at tmux's factory `history-limit` of 2000. Callers must surface this
    /// to the operator rather than silently discarding it (#3386 review).
    pub options_verified: bool,
}

/// Log an operator-visible `error!` naming `session_name` when `outcome`'s
/// scrollback ergonomics were not verified (#3386 review).
///
/// Why: shared by every `tracing`-based call site
/// ([`daemon::tmux::TmuxDriver::create_session`](crate::daemon::tmux::TmuxDriver::create_session),
/// `client::http_client::session_connect`) so the exact message stays in
/// one place and each call site only pays ONE line, keeping
/// `daemon::tmux` (already near its 500-SLOC production cap) from growing
/// just to inline this check. A no-op when `outcome.options_verified` is
/// `true`.
/// What: `tracing::error!` (not `warn!` — a `warn!`-only log is the exact
/// silent-degrade #3386 was filed against) with a `session` field.
/// Test: exercised transitively by `daemon::tmux`'s and
/// `client::http_client::session_connect`'s own tests; the condition itself
/// is covered by `apply_and_verify_scrollback_options_returns_false_after_exhausting_retries`.
pub fn warn_if_options_unverified(outcome: &ManagedSessionOutcome, session_name: &str) {
    if !outcome.options_verified {
        tracing::error!(
            session = %session_name,
            "#3386: tmux scrollback ergonomics could not be verified before this pane was \
             created — it may be capped at tmux's factory history-limit of 2000 instead of \
             the configured value"
        );
    }
}

/// Create a tmux session with the configured scrollback + mouse ergonomics
/// applied FIRST — THE single choke point for "create a managed session"
/// used by every session-creating call site in the crate (#2398 architecture
/// consolidation).
///
/// Why: `history-limit` is captured into a pane's ring buffer AT CREATION
/// TIME, so the scrollback/mouse `set-option -g` calls MUST run before
/// `new-session` — see [`trusty_common::tmux::scrollback_option_commands`].
/// Baking both steps into one function (rather than asking every caller to
/// remember "apply options, then create") is what makes it structurally
/// impossible for a future call site to bypass the ergonomics again, which
/// is exactly how the #2398 QA finding happened the first time (5 call
/// sites independently shelled out to `tmux new-session`, none aware the
/// daemon-side ergonomics existed) — and, per #3004, exactly how
/// trusty-agents' independent tmux implementation missed the fix entirely.
/// What: resolves the tmux binary (`tmux_bin` if given, else
/// [`resolve_tmux_binary_or_bare`]), loads
/// [`crate::core::trusty_tools_config::TrustyToolsConfig`] and resolves the
/// `tmux:` section, then runs [`apply_and_verify_scrollback_options`] (#3386
/// — retries the WHOLE apply-and-verify cycle, not just server-up, since a
/// successful `start-server` does not prove the following `set-option -g`
/// landed) before issuing `new-session`. Never blocks session creation on a
/// degraded outcome — `new-session` always still runs — but the returned
/// [`ManagedSessionOutcome::options_verified`] tells the caller whether it
/// can trust the ergonomics landed.
/// Test: argv construction and the ordering guarantee are covered once in
/// `trusty_common::tmux`'s own tests; this crate's config-resolved call is
/// covered by `managed_session_command_sequence_matches_shared_layer`. The
/// live-process call itself needs a real `tmux` binary, so it is exercised
/// transitively by every migrated call site (daemon `#[ignore]` integration
/// tests; CLI/TUI paths are exercised end-to-end by the existing `tm
/// launch`/`tm connect` integration coverage); the #3386 apply-and-verify
/// behavior itself is covered by `create_managed_session_confirms_server_before_applying_options`
/// and the `apply_and_verify_scrollback_options_*` tests.
pub fn create_managed_session(
    tmux_bin: Option<&str>,
    name: &str,
    workdir: Option<&str>,
) -> std::io::Result<ManagedSessionOutcome> {
    let config = crate::core::trusty_tools_config::TrustyToolsConfig::load();
    let opts = crate::core::trusty_tools_config::resolve_tmux_options(&config);
    create_managed_session_with_options(tmux_bin, name, workdir, opts)
}

/// [`create_managed_session`] whose `new-session` carries NO `-A`, so a name
/// already in use makes tmux refuse instead of attaching (#3707).
///
/// Why: `-A` turns a lost create race into silence — the loser attaches to the
/// winner's pane and both persist a record for it, which is two sessions
/// driving one terminal with no error anywhere. The managed create path wants
/// the refusal so it can re-dedupe the name and try again; every other caller
/// (resume, reattach) still wants `-A` and keeps it.
/// What: the same #2398 option-ordering and #3386 apply-and-verify cycle as
/// [`create_managed_session`], with `idempotent: false`. The refusal arrives as
/// a non-zero exit whose stderr says `duplicate session`; classifying it is
/// [`crate::daemon::tmux::TmuxDriver::create_session_exclusive`]'s job, since
/// this layer returns the raw `Output` either way.
/// Test: `exclusive_command_sequence_omits_the_attach_flag`; the live-process
/// call is exercised by the `#[ignore]` tmux integration tests.
pub fn create_managed_session_exclusive(
    tmux_bin: Option<&str>,
    name: &str,
    workdir: Option<&str>,
) -> std::io::Result<ManagedSessionOutcome> {
    let config = crate::core::trusty_tools_config::TrustyToolsConfig::load();
    let opts = crate::core::trusty_tools_config::resolve_tmux_options(&config);
    create_managed_session_inner(tmux_bin, name, workdir, opts, false)
}

/// [`create_managed_session`] with the resolved tmux options supplied by the
/// caller instead of loaded from `~/.trusty-mpm/config.toml` (#5040).
///
/// Why: `TrustyToolsConfig::load()` reads a `$HOME`-derived path, so a test that
/// wanted to assert on the applied options had to resolve them ITSELF and then
/// trust that the second resolution inside `create_managed_session` saw the same
/// `$HOME`. It does not: a concurrently-running test that redirects `$HOME` —
/// and this crate has more than ten that do — makes the two sides of the
/// assertion answer to different environments, which is how
/// `create_managed_session_confirms_server_before_applying_options` failed with
/// no change to any file it touches. Injecting the options means ONE resolution
/// per call, so the two sides cannot diverge.
/// What: everything `create_managed_session` does apart from the config load —
/// the host-state gate, the binary resolution, the #3386 apply-and-verify cycle,
/// and `new-session`.
/// Test: `create_managed_session_confirms_server_before_applying_options`.
pub fn create_managed_session_with_options(
    tmux_bin: Option<&str>,
    name: &str,
    workdir: Option<&str>,
    opts: crate::core::trusty_tools_config::ResolvedTmuxOptions,
) -> std::io::Result<ManagedSessionOutcome> {
    create_managed_session_inner(tmux_bin, name, workdir, opts, true)
}

/// The body shared by [`create_managed_session_with_options`] (`-A`) and
/// [`create_managed_session_exclusive`] (no `-A`, #3707).
///
/// Why: the two differ by one boolean threaded into the `new-session` entry;
/// duplicating the host-state gate, binary resolution and apply-and-verify
/// cycle to vary it would put two copies of the #3386 retry loop in one file.
/// What: see [`create_managed_session_with_options`]. `idempotent` is passed
/// straight to [`managed_session_command_sequence`].
/// Test: `create_managed_session_confirms_server_before_applying_options`.
fn create_managed_session_inner(
    tmux_bin: Option<&str>,
    name: &str,
    workdir: Option<&str>,
    opts: crate::core::trusty_tools_config::ResolvedTmuxOptions,
    idempotent: bool,
) -> std::io::Result<ManagedSessionOutcome> {
    // #5784: refuse once, loudly, at the entry point. `host_state_guard` also
    // guards every spawn below, but this is the operation an operator asked
    // for by name, so it is the one that earns a `warn!` — and refusing here
    // skips the retried apply-and-verify cycle that would otherwise emit a
    // dozen refusals for one `tm launch`.
    if let Some(reason) = crate::core::host_state_gate::host_state_access().skip_reason() {
        warn!("#5784: refusing to create tmux session '{name}' — {reason}");
        return Err(std::io::Error::new(
            std::io::ErrorKind::PermissionDenied,
            format!("tmux session creation refused: {reason}"),
        ));
    }

    let owned_bin;
    let bin = match tmux_bin {
        Some(b) => b,
        None => {
            owned_bin = resolve_tmux_binary_or_bare();
            &owned_bin
        }
    };

    let commands = managed_session_command_sequence(
        name,
        workdir,
        opts.history_limit,
        opts.mouse,
        opts.alternate_screen,
        idempotent,
    );
    let split = commands.len().saturating_sub(1);
    let (options, new_session) = commands.split_at(split);

    let options_verified = apply_and_verify_scrollback_options(
        bin,
        options,
        opts.history_limit,
        opts.alternate_screen,
    );
    if !options_verified {
        warn!(
            "#3386: tmux scrollback ergonomics could not be verified after \
             {APPLY_VERIFY_MAX_ATTEMPTS} attempts for session '{name}' — the pane about to be \
             created may be capped at tmux's factory history-limit of 2000; returning \
             options_verified=false for the caller to surface"
        );
    }

    let output = run_tmux_with_bin(
        bin,
        new_session
            .first()
            .expect("managed_session_command_sequence always ends with NewSession"),
    )?;

    Ok(ManagedSessionOutcome {
        output,
        options_verified,
    })
}

/// Number of attempts [`ensure_server_up`] makes before giving up (#3386).
const START_SERVER_MAX_ATTEMPTS: u8 = 3;

/// Delay between [`ensure_server_up`] retry attempts (#3386).
const START_SERVER_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(25);

/// Number of attempts [`apply_and_verify_scrollback_options`] makes before
/// conceding the ergonomics could not be confirmed (#3386 review finding).
const APPLY_VERIFY_MAX_ATTEMPTS: u8 = 3;

/// Delay between [`apply_and_verify_scrollback_options`] retry attempts.
const APPLY_VERIFY_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(25);

/// Confirm the tmux SERVER exists before any `set-option -g`/`new-session`
/// call relies on it (#3386).
///
/// Why: see [`TmuxCommand::StartServer`]'s doc for the full root-cause
/// explanation — `set-option -g` does NOT auto-start the server the way
/// `new-session` does, so a caller must confirm the server is actually up
/// first rather than assuming the first tmux command of the sequence takes
/// care of it.
/// What: runs `tmux start-server` (idempotent against an already-running
/// server) up to [`START_SERVER_MAX_ATTEMPTS`] times, waiting
/// [`START_SERVER_RETRY_DELAY`] between attempts, returning `Ok(())` on the
/// first success. Exhausting every attempt returns an `Err` describing the
/// last failure — this is the "error loudly rather than proceed silently"
/// signal callers must not swallow into an unconditional success path.
/// Test: `ensure_server_up_retries_then_succeeds`,
/// `ensure_server_up_fails_loudly_after_exhausting_retries` (both drive a
/// scripted fake `tmux` binary — no live tmux server required).
///
/// Visibility (#3823): `pub(crate)` (not private) so
/// [`crate::daemon::tmux::TmuxDriver`] can call it directly to guarantee the
/// server exists before the FIRST tmux call of the session-creation/resume
/// flow (`list-sessions`, issued by name-collision checks well before
/// [`create_managed_session`] is ever reached) — not just before this
/// module's own `set-option -g`/`new-session` sequence.
pub(crate) fn ensure_server_up(bin: &str) -> Result<(), String> {
    let mut last_err = String::new();
    for attempt in 1..=START_SERVER_MAX_ATTEMPTS {
        match run_tmux_with_bin(bin, &TmuxCommand::StartServer) {
            Ok(output) if output.status.success() => return Ok(()),
            Ok(output) => last_err = String::from_utf8_lossy(&output.stderr).into_owned(),
            Err(e) => last_err = e.to_string(),
        }
        if attempt < START_SERVER_MAX_ATTEMPTS {
            std::thread::sleep(START_SERVER_RETRY_DELAY);
        }
    }
    Err(last_err)
}

/// Run [`ensure_server_up`], apply every entry of `options`, then confirm
/// via [`probe_history_limit`] and [`probe_alternate_screen`] — retrying the
/// WHOLE cycle up to
/// [`APPLY_VERIFY_MAX_ATTEMPTS`] times (#3386 review finding): a successful
/// `start-server` does NOT by itself prove the FOLLOWING `set-option -g`
/// landed (the server could still be torn down between the two calls, or
/// the value could be clamped/rejected by a tmux version quirk), so the
/// verification probe — not just the server-up check — must gate whether
/// another attempt is worth making.
///
/// Why: [`create_managed_session`] must never silently proceed to
/// `new-session` believing the ergonomics landed when they did not — the
/// original #3386 bug was exactly a `warn!`-and-continue on a single failed
/// attempt.
/// What: returns `true` as soon as one cycle's probes confirm BOTH
/// `history-limit == expected_history_limit` (global scope) and
/// `alternate-screen == expected_alternate_screen` (window scope, #5151);
/// returns `false` once [`APPLY_VERIFY_MAX_ATTEMPTS`] cycles are exhausted
/// without a confirmed match on both. Every failure within a cycle is logged
/// via `warn!`; never panics, never blocks the caller from proceeding to
/// `new-session` either way — the return value is the caller's signal to
/// surface the degraded outcome, not a reason to abort session creation.
///
/// Each probe reads the SAME tmux option scope its `set-option` wrote —
/// `-g` for `history-limit`, `-wg` for `alternate-screen`. A probe that reads
/// a different scope than it wrote would report success for a `set-option`
/// that silently did nothing (measured: `set-option -pg alternate-screen off`
/// exits 0 and leaves the pane still on the alternate screen), which is the
/// same fail-open shape #3386 was filed against.
/// Test: `apply_and_verify_scrollback_options_succeeds_on_second_attempt`,
/// `apply_and_verify_scrollback_options_returns_false_after_exhausting_retries`,
/// `apply_and_verify_scrollback_options_fails_when_only_alternate_screen_is_wrong`,
/// `apply_and_verify_probes_the_same_scope_it_sets` (all drive a scripted
/// fake `tmux` binary — no live tmux server required).
fn apply_and_verify_scrollback_options(
    bin: &str,
    options: &[TmuxCommand],
    expected_history_limit: u32,
    expected_alternate_screen: bool,
) -> bool {
    for attempt in 1..=APPLY_VERIFY_MAX_ATTEMPTS {
        if let Err(e) = ensure_server_up(bin) {
            warn!(
                "#3386: tmux start-server failed on apply-and-verify attempt \
                 {attempt}/{APPLY_VERIFY_MAX_ATTEMPTS}: {e}"
            );
        } else {
            for cmd in options {
                match run_tmux_with_bin(bin, cmd) {
                    Ok(output) if !output.status.success() => {
                        warn!(
                            "#3386: tmux {cmd:?} exited non-zero on apply-and-verify attempt \
                             {attempt}/{APPLY_VERIFY_MAX_ATTEMPTS}: {}",
                            String::from_utf8_lossy(&output.stderr)
                        );
                    }
                    Err(e) => {
                        warn!(
                            "#3386: failed to run tmux {cmd:?} on apply-and-verify attempt \
                             {attempt}/{APPLY_VERIFY_MAX_ATTEMPTS}: {e}"
                        );
                    }
                    Ok(_) => {}
                }
            }
            let history_ok = match probe_history_limit(bin) {
                Ok(observed) if observed == expected_history_limit => true,
                Ok(observed) => {
                    warn!(
                        "#3386: tmux history-limit verification mismatch on apply-and-verify \
                         attempt {attempt}/{APPLY_VERIFY_MAX_ATTEMPTS} — expected \
                         {expected_history_limit}, observed {observed}"
                    );
                    false
                }
                Err(e) => {
                    warn!(
                        "#3386: tmux history-limit verification probe failed on apply-and-verify \
                         attempt {attempt}/{APPLY_VERIFY_MAX_ATTEMPTS}: {e}"
                    );
                    false
                }
            };
            // #5151: read back the WINDOW scope the alternate-screen set wrote.
            let alternate_screen_ok = match probe_alternate_screen(bin) {
                Ok(observed) if observed == expected_alternate_screen => true,
                Ok(observed) => {
                    warn!(
                        "#5151: tmux alternate-screen verification mismatch on apply-and-verify \
                         attempt {attempt}/{APPLY_VERIFY_MAX_ATTEMPTS} — expected \
                         {expected_alternate_screen}, observed {observed}"
                    );
                    false
                }
                Err(e) => {
                    warn!(
                        "#5151: tmux alternate-screen verification probe failed on \
                         apply-and-verify attempt {attempt}/{APPLY_VERIFY_MAX_ATTEMPTS}: {e}"
                    );
                    false
                }
            };
            if history_ok && alternate_screen_ok {
                return true;
            }
        }
        if attempt < APPLY_VERIFY_MAX_ATTEMPTS {
            std::thread::sleep(APPLY_VERIFY_RETRY_DELAY);
        }
    }
    false
}

/// Read back the tmux server's current `history-limit` global option
/// (#3386).
///
/// Why: the verification counterpart to [`ensure_server_up`] and the
/// `set-option -g` loop in [`create_managed_session`] — confirms the value
/// actually landed on the server rather than trusting a `set-option -g`
/// exit code alone.
/// What: runs `tmux show-options -g -v history-limit` and parses the single
/// line of stdout as `u32`. Any spawn failure, non-zero exit, or unparsable
/// output is returned as `Err` describing why the probe could not confirm
/// the value — callers decide how loudly to react.
/// Test: `probe_history_limit_reads_back_configured_value`,
/// `probe_history_limit_errors_on_unparsable_output`.
pub(crate) fn probe_history_limit(bin: &str) -> Result<u32, String> {
    match run_tmux_with_bin(
        bin,
        &TmuxCommand::ShowGlobalOption {
            name: HISTORY_LIMIT_OPTION.to_string(),
        },
    ) {
        Ok(output) if output.status.success() => {
            let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
            raw.parse::<u32>()
                .map_err(|_| format!("unparsable show-options output: {raw:?}"))
        }
        Ok(output) => Err(String::from_utf8_lossy(&output.stderr).into_owned()),
        Err(e) => Err(e.to_string()),
    }
}

/// Read back the tmux server's current WINDOW-scoped `alternate-screen`
/// option (#5151).
///
/// Why: the verification counterpart to the
/// `set-option -wg alternate-screen` entry
/// [`scrollback_option_commands`](trusty_common::tmux::scrollback_option_commands)
/// emits, and deliberately scope-matched to it. `alternate-screen` is a
/// window/pane option, not a server option like `history-limit`; probing
/// `show-options -g` (or `-pg`) here would read a scope the `set` never
/// wrote, which is how a wrong-scope `set-option` verifies green while the
/// pane still enters the alternate screen.
/// What: runs `tmux show-options -wg -v alternate-screen` and maps the single
/// line of stdout — `on` → `true`, `off` → `false`. Any spawn failure,
/// non-zero exit, or unrecognised value is returned as `Err`; a value tmux
/// does not document (anything but `on`/`off`) is NOT guessed at, because a
/// wrong guess here is a silent pass.
/// Test: `probe_alternate_screen_reads_back_on`,
/// `probe_alternate_screen_reads_back_off`,
/// `probe_alternate_screen_errors_on_unrecognised_value`,
/// `probe_alternate_screen_errors_on_nonzero_exit`.
pub(crate) fn probe_alternate_screen(bin: &str) -> Result<bool, String> {
    match run_tmux_with_bin(
        bin,
        &TmuxCommand::ShowWindowGlobalOption {
            name: ALTERNATE_SCREEN_OPTION.to_string(),
        },
    ) {
        Ok(output) if output.status.success() => {
            let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
            match raw.as_str() {
                "on" => Ok(true),
                "off" => Ok(false),
                _ => Err(format!("unrecognised show-options output: {raw:?}")),
            }
        }
        Ok(output) => Err(String::from_utf8_lossy(&output.stderr).into_owned()),
        Err(e) => Err(e.to_string()),
    }
}

/// Ceiling on a SHELL COMMAND LINE typed into a pane, in bytes (#8233).
///
/// Why: a pane's tty is in CANONICAL mode until the shell's line editor takes
/// over, and a canonical-mode line discipline buffers at most `MAX_CANON` bytes
/// — 1024 on macOS (`sys/syslimits.h:89`; `fpathconf(pty, _PC_MAX_CANON)`
/// returns 1024 on a live pty). `tmux send-keys` types into that buffer, so a
/// longer command loses its tail with no error anywhere: session `dd0e2fb8-…`
/// died with a 1054-byte launch line cut off mid-path at
/// `internal-spawn-disclaimed /Use`. The bytes are dropped by the KERNEL, so
/// nothing downstream can detect it — refusing to type the line is the only
/// place the failure can be made visible.
/// What: 960, leaving 64 bytes of headroom under `MAX_CANON` for the terminating
/// newline and any line-discipline overhead. It is NOT the target size: every
/// migrated builder emits a fixed-shape line well under 512 bytes (#8233). This
/// is the backstop that makes a builder still composing an unbounded line fail
/// LOUDLY instead of silently truncating.
/// Test: `pane_command_limit_sits_below_max_canon`,
/// `oversized_pane_command_is_refused`.
pub const MAX_PANE_COMMAND_BYTES: usize = 960;

/// Refuse a pane COMMAND line that the tty's canonical buffer would truncate.
///
/// Why: see [`MAX_PANE_COMMAND_BYTES`]. This is deliberately scoped to command
/// lines — text typed at a SHELL — and is NOT applied to
/// [`send_keys_literal`](crate::daemon::tmux::TmuxDriver::send_keys_literal) or
/// to task injection. Once Claude Code's TUI owns the pane the tty is in RAW
/// mode, where `MAX_CANON` does not apply and a multi-kilobyte task prompt is
/// both legitimate and routine; refusing those would break task delivery to fix
/// a limit that is not in force.
/// What: `None` when `text` fits; otherwise a one-line operator-facing message
/// naming the actual size, the limit, and the issue.
/// Test: `oversized_pane_command_is_refused`, `pane_command_at_the_limit_is_allowed`.
pub fn refuse_oversized_pane_command(text: &str) -> Option<String> {
    if text.len() <= MAX_PANE_COMMAND_BYTES {
        return None;
    }
    Some(format!(
        "refusing to type a {}-byte command into a tmux pane: the tty's canonical-mode \
         input buffer holds at most MAX_CANON (1024) bytes, so anything over \
         {MAX_PANE_COMMAND_BYTES} would be silently truncated mid-command (#8233). The \
         builder that produced this line must carry its parameters in a launch spec \
         instead of in the typed line.",
        text.len()
    ))
}

/// [`send_line`] for a SHELL COMMAND, refusing an oversized line (#8233).
///
/// Why: the CLI/TUI launch paths type a composed `claude` invocation at the
/// pane's shell prompt, which is exactly the canonical-mode case
/// [`MAX_PANE_COMMAND_BYTES`] exists for. Routing them through this wrapper
/// rather than [`send_line`] keeps the guard off the injection paths, which
/// share the same primitive but type into a raw-mode TUI.
/// What: [`refuse_oversized_pane_command`] first — on refusal NOTHING is typed
/// and an `InvalidInput` error carries the message — else [`send_line`].
/// Test: `send_command_line_refuses_an_oversized_line`.
pub fn send_command_line(
    tmux_bin: Option<&str>,
    target: &TmuxTarget,
    text: &str,
) -> std::io::Result<std::process::Output> {
    if let Some(msg) = refuse_oversized_pane_command(text) {
        return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, msg));
    }
    send_line(tmux_bin, target, text)
}

/// Type a composed `claude` launch line at a fresh session's shell (#8308).
///
/// Why: `tm launch`, `tm connect`, the in-place `tm session start`,
/// `DaemonClient::launch_session`/`connect_session` and the Architect launch in
/// `tm fleet` each typed their line through the unguarded [`send_line`], so a
/// line past [`MAX_PANE_COMMAND_BYTES`] lost its tail in the tty with no error
/// — the #8233 class, which that fix closed only for the managed path. The six
/// now start `claude` through `runtime::cli_launch::send_spec_launch`, whose
/// short line this types, so a line that grew is refused, never truncated.
/// What: [`send_command_line`] against `TmuxTarget::session(session)`. `Err`
/// carries the refusal (`InvalidInput`, nothing typed), a spawn failure, or a
/// non-zero `send-keys` exit with tmux's stderr.
/// Test: `launch_line_over_the_limit_is_refused_before_tmux_runs`,
/// `launch_line_within_the_limit_reaches_tmux`,
/// `cli_launch_paths_type_through_the_guard`.
pub fn send_launch_line(tmux_bin: Option<&str>, session: &str, line: &str) -> std::io::Result<()> {
    let output = send_command_line(tmux_bin, &TmuxTarget::session(session), line)?;
    if output.status.success() {
        return Ok(());
    }
    Err(std::io::Error::other(format!(
        "tmux send-keys exited {}: {}",
        output.status,
        String::from_utf8_lossy(&output.stderr).trim()
    )))
}

/// Type `text` into a tmux pane, then press Enter (#2398 consolidation).
///
/// Why: every call site that starts `claude` in a freshly-created pane needs
/// this exact idiom — mirrors
/// [`daemon::tmux::TmuxDriver::send_line`](crate::daemon::tmux::TmuxDriver::send_line)'s
/// literal-then-Enter pattern (two `send-keys` invocations: `-l` literal text,
/// then the `Enter` key name), now shared so CLI/TUI callers do not
/// re-implement it as a single bare `send-keys <text> Enter` invocation
/// (functionally equivalent for a non-key-name string, but a second,
/// independently-typed-out version of the same idiom is exactly the kind of
/// drift #2398 closes).
/// What: runs the literal `send-keys -l <text>` via [`run_tmux_with_bin`]
/// (`tmux_bin` if given, else resolved via [`resolve_tmux_binary_or_bare`]);
/// if that invocation itself fails to spawn, the error is returned
/// immediately WITHOUT sending `Enter`. Otherwise sends `Enter` and returns
/// ITS `Output` — callers check `output.status.success()` exactly as they
/// did with the single-invocation form.
/// Test: argv shapes covered by `trusty_common::tmux`'s
/// `send_keys_literal_argv`/`send_keys_keyname_argv`; the live-process call
/// needs a real `tmux` binary, exercised transitively by every migrated call
/// site.
pub fn send_line(
    tmux_bin: Option<&str>,
    target: &TmuxTarget,
    text: &str,
) -> std::io::Result<std::process::Output> {
    let owned_bin;
    let bin = match tmux_bin {
        Some(b) => b,
        None => {
            owned_bin = resolve_tmux_binary_or_bare();
            &owned_bin
        }
    };
    let literal_output = run_tmux_with_bin(
        bin,
        &TmuxCommand::SendKeys {
            target: target.clone(),
            keys: text.to_string(),
            literal: true,
        },
    )?;
    if !literal_output.status.success() {
        // Don't send Enter after a failed literal send — nothing to submit.
        return Ok(literal_output);
    }
    run_tmux_with_bin(
        bin,
        &TmuxCommand::SendKeys {
            target: target.clone(),
            keys: "Enter".to_string(),
            literal: false,
        },
    )
}

#[cfg(test)]
#[path = "tmux_tests.rs"]
mod tmux_tests;