trusty-mpm 0.19.26

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
Documentation
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
//! Shared managed-session picker + fetch helpers.
//!
//! Why: two entry points now surface the interactive session picker — bare `tm`
//! (the project-scoped guided default in `guided.rs`) and the top-level `tm ls`
//! connector (fleet-wide, optionally scoped). Extracting the numbered-menu loop,
//! the pure choice-parser, and the fetch-and-filter step into one module keeps
//! both call sites on a single implementation (no divergence, no duplication) and
//! keeps `guided.rs` under the 500-SLOC production cap.
//!
//! What: [`parse_picker_choice`] is the pure input→decision seam; [`run_tty_picker`]
//! is the I/O driver that renders the menu, reads stdin, and dispatches to
//! resume/launch; [`fetch_live_sessions`] is the single GET+filter path shared by
//! the static `tm session ls` renderer and the picker; [`run_ls_connector`] is the
//! `tm ls` orchestrator that decides between the interactive picker and static
//! output based on the TTY/`--json`/`--all`/session-count gate
//! ([`should_show_picker`]).
//!
//! Test: `parse_picker_choice` unit tests live in `tests_behavior_c_tests.rs`
//! (re-exported through `guided`); the TTY gate is unit-tested by
//! `ls_connector_should_show_picker_*` in `tests_behavior_d_tests.rs`; the I/O
//! path is exercised by the e2e suite and manual smoke tests.

use anyhow::Context as _;
use std::io::IsTerminal as _;

use trusty_mpm::client::{ManagedListResponse, ManagedSessionSummary};

use super::managed::filter_live_sessions;

/// Decision returned by [`parse_picker_choice`].
///
/// Why: extracting the parse-and-decide logic from the I/O driver makes it
/// unit-testable without stdin/tmux. The driver calls parse, checks the variant,
/// and shells out only for Resume and LaunchNew.
/// What: six variants cover every valid and invalid input the picker can
/// receive. [`Self::ConfirmRestart`] is the #2148 safety default: a bare Enter
/// that WOULD have silently restarted (destructively recreated the tmux pane
/// of) a stopped/errored session instead asks for an explicit numeric choice.
/// [`Self::Unresumable`] (#2595) is the analogous safety gate for a DEAD
/// session — no confirmation would ever help, since the resume/restart is
/// guaranteed to fail, so the driver never round-trips to the daemon for it.
/// Test: `guided_picker_bare_enter_no_sessions_launches_new`,
/// `guided_picker_bare_enter_live_session_resumes_first`,
/// `guided_picker_bare_enter_stopped_session_requires_confirm`,
/// `guided_picker_q_returns_quit`, `guided_picker_numeric_valid_resumes`,
/// `guided_picker_numeric_launch_new`, `guided_picker_out_of_range_unrecognised`,
/// `guided_picker_non_numeric_unrecognised`,
/// `guided_picker_bare_enter_unresumable_session_blocked`,
/// `guided_picker_numeric_unresumable_session_blocked`.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum PickerDecision {
    /// Resume the session at 0-based index into the sessions slice.
    Resume(usize),
    /// Launch a brand-new session.
    LaunchNew,
    /// User chose to quit without action.
    Quit,
    /// Input was not recognised; the caller quits cleanly.
    Unrecognised,
    /// Bare Enter was pressed but the 0-based-indexed session at this slot is
    /// stopped/errored — resuming it would KILL and recreate its tmux pane
    /// (or, if the pane already happened to be dead, spawn a fresh one). #2148:
    /// this is no longer an implicit default; the operator must type the
    /// number explicitly to confirm the restart.
    ConfirmRestart(usize),
    /// Delete the session at this 0-based index (#2304). Entered as `d<N>`
    /// (e.g. `d2`) or `d <N>`. The driver runs a confirm prompt — and, for a
    /// running session, a force-confirm — before touching the store, so this
    /// variant only signals intent, never an unconditional destructive action.
    Delete(usize),
    /// Selection (bare Enter OR an explicit numeric choice) targeted the
    /// 0-based-indexed session whose workspace is gone for good — the
    /// server-computed `unresumable` flag was `true` (#2595). Resuming it
    /// would only ever fail (see #2577/#2594): the driver never attempts the
    /// daemon round-trip; it prints the dead-session notice and points the
    /// operator at `d<N>` (delete) instead.
    Unresumable(usize),
}

/// Scope describing which sessions the picker operates over and how to launch new.
///
/// Why: the picker serves both a single-project context (bare `tm`, where a
/// `repo_url` is known so "launch new" can spawn into that project) and a
/// fleet-wide/filtered context (`tm ls`, where sessions may span projects and no
/// single launch target exists). Threading the scope through one struct keeps
/// [`run_tty_picker`] agnostic to its caller.
/// What: `source_id` filters the daemon session list (`None` = every managed
/// session); `repo_url` is the launch-new target (`None` disables launch-new with
/// an actionable hint instead of attaching to the wrong project).
/// Test: constructed by `guided::try_show_picker` and [`run_ls_connector`];
/// behavior is covered by the picker's e2e path.
pub(crate) struct PickerScope {
    /// `owner/repo` slug to filter by, or `None` for every managed session.
    pub(crate) source_id: Option<String>,
    /// Git-root path used as the launch-new target, or `None` to disable it.
    pub(crate) repo_url: Option<String>,
}

impl PickerScope {
    /// Build a single-project scope (bare `tm` guided default).
    ///
    /// Why: the guided default always knows both the project slug and the git
    /// root, so both fields are always populated.
    /// What: returns a scope filtered to `source_id` with `repo_url` as the
    /// launch target.
    /// Test: exercised by `guided::try_show_picker`.
    pub(crate) fn project(source_id: &str, repo_url: &str) -> Self {
        Self {
            source_id: Some(source_id.to_string()),
            repo_url: Some(repo_url.to_string()),
        }
    }
}

/// GET the raw managed-session list body, optionally scoped by `source_id`.
///
/// Why: the `--json` passthrough needs the byte-for-byte daemon response while
/// the table/picker paths need the deserialized form — both must issue exactly
/// one GET. Returning the raw text lets each caller decide.
/// What: GETs `/api/v1/sessions/managed`, appending `?source_id=` when a filter
/// is active, and returns the response body as a `String` after status checks.
/// Test: HTTP round-trip covered by `tests/session_manager_mvp.rs`.
pub(crate) async fn fetch_managed_raw(
    client: &reqwest::Client,
    url: &str,
    source_id: Option<&str>,
) -> anyhow::Result<String> {
    let endpoint = format!("{url}/api/v1/sessions/managed");
    let mut req = client.get(&endpoint);
    if let Some(sid) = source_id {
        req = req.query(&[("source_id", sid)]);
    }
    Ok(req.send().await?.error_for_status()?.text().await?)
}

/// Parse a raw managed-session list body into a scoped `Vec` for display.
///
/// Why: the static table and the picker share one filtering/sorting policy so
/// the two views never diverge (#1809/#1841).
/// What: deserializes `ManagedListResponse`; when `all` is false, drops
/// decommissioned tombstones via [`filter_live_sessions`]; when `all` is true,
/// keeps every session but stable-sorts tombstones to the end.
/// Test: `picker_filter_excludes_decommissioned_keeps_active`,
/// `ls_source_id_filter_selects_correct_slug` in `tests_behavior_c_tests.rs`.
pub(crate) fn parse_scoped_sessions(
    raw: &str,
    all: bool,
) -> anyhow::Result<Vec<ManagedSessionSummary>> {
    let fetched = serde_json::from_str::<ManagedListResponse>(raw)?.sessions;
    let sessions = if all {
        let mut s = fetched;
        s.sort_by_key(|sess| u8::from(sess.state == "decommissioned"));
        s
    } else {
        filter_live_sessions(fetched)
    };
    Ok(sessions)
}

/// Fetch and filter managed sessions in one call — the shared picker fetch path.
///
/// Why: the interactive picker (both call sites) needs the deserialized, filtered
/// session list; combining the GET and the parse keeps re-fetch-after-detach a
/// single call and guarantees identical scoping to the static list.
/// What: [`fetch_managed_raw`] then [`parse_scoped_sessions`]. The picker always
/// requests live-only (`all = false`) — a decommissioned tombstone is never a
/// resume target.
/// Test: HTTP path in `tests/session_manager_mvp.rs`; the parse/filter seam is
/// unit-tested via `parse_scoped_sessions`.
pub(crate) async fn fetch_live_sessions(
    client: &reqwest::Client,
    url: &str,
    source_id: Option<&str>,
    all: bool,
) -> anyhow::Result<Vec<ManagedSessionSummary>> {
    let raw = fetch_managed_raw(client, url, source_id).await?;
    parse_scoped_sessions(&raw, all)
}

/// Parse one line of picker input into a [`PickerDecision`].
///
/// Why: separating parse-and-decide from the I/O driver makes the dispatch
/// logic unit-testable without needing a real stdin, tmux, or daemon. Folding
/// `first_needs_restart` in here (rather than deciding safety in the I/O
/// driver) keeps the destructive-default guard (#2148) exhaustively
/// unit-testable alongside every other picker-input case. `unresumable` (#2595)
/// is folded in the same way: a session flagged dead by the daemon must never
/// reach `Resume`/`ConfirmRestart` from EITHER a bare Enter or an explicit
/// numeric choice — checking it here, ahead of both branches, is the single
/// place that guarantee can never regress.
/// What: `session_count` is the number of existing sessions in the menu (the
/// menu slot `session_count + 1` is always "launch new"); `first_needs_restart`
/// is true when the session at index 0 is `stopped`/`errored` — i.e. resuming
/// it goes through the daemon's restart path, which can recreate its tmux pane
/// (see [`super::guided_resume::needs_restart`]); `unresumable[i]` is the
/// server-computed dead-workspace flag for the session at 0-based index `i`
/// (`ManagedSessionSummary::unresumable`, #2595) — a slice, not a single bool,
/// because an EXPLICIT numeric choice can target ANY index, not just 0.
///   • `"q"` / `"Q"` → `Quit`
///   • empty / whitespace, `session_count == 0` → `LaunchNew`
///   • empty / whitespace, `session_count > 0`, session 0 is dead →
///     `Unresumable(0)` (#2595 — checked BEFORE the restart-confirm gate)
///   • empty / whitespace, `session_count > 0`, session 0 is live → `Resume(0)`
///   • empty / whitespace, `session_count > 0`, session 0 needs a restart →
///     `ConfirmRestart(0)` (#2148: no longer an implicit destructive default)
///   • `N` (1..=session_count), session `N-1` is dead → `Unresumable(N-1)`
///     (#2595 — an explicit numeric choice no longer bypasses this gate)
///   • `N` (1..=session_count) → `Resume(N-1)` (0-based index) — an EXPLICIT
///     numeric choice always restarts/resumes directly, confirm or not
///   • `session_count + 1` → `LaunchNew`
///   • `d<N>` / `d <N>` (1..=session_count) → `Delete(N-1)` (#2304); the driver
///     still runs a confirm/force-confirm prompt before deleting — this is the
///     REMEDY the driver points a dead-session pick at
///   • anything else → `Unrecognised`
/// Test: `guided_picker_bare_enter_no_sessions_launches_new`,
/// `guided_picker_bare_enter_live_session_resumes_first`,
/// `guided_picker_bare_enter_stopped_session_requires_confirm`,
/// `guided_picker_q_returns_quit`, `guided_picker_q_uppercase_returns_quit`,
/// `guided_picker_numeric_valid_resumes`, `guided_picker_numeric_launch_new`,
/// `guided_picker_out_of_range_unrecognised`,
/// `guided_picker_non_numeric_unrecognised`,
/// `guided_picker_bare_enter_unresumable_session_blocked`,
/// `guided_picker_numeric_unresumable_session_blocked`.
pub(crate) fn parse_picker_choice(
    line: &str,
    session_count: usize,
    first_needs_restart: bool,
    unresumable: &[bool],
) -> PickerDecision {
    let choice = line.trim();
    if choice.eq_ignore_ascii_case("q") {
        return PickerDecision::Quit;
    }
    // #2304: `d<N>` / `d <N>` deletes the session at menu slot N. Parsed before
    // the numeric-resume branch so the `d` prefix is unambiguous. Deleting a
    // dead session is exactly the intended remedy, so this branch is NOT
    // gated by `unresumable`.
    if let Some(rest) = choice.strip_prefix(['d', 'D']) {
        if let Ok(n) = rest.trim().parse::<usize>()
            && n >= 1
            && n <= session_count
        {
            return PickerDecision::Delete(n - 1);
        }
        return PickerDecision::Unrecognised;
    }
    if choice.is_empty() {
        if session_count == 0 {
            return PickerDecision::LaunchNew;
        }
        if unresumable.first().copied().unwrap_or(false) {
            return PickerDecision::Unresumable(0);
        }
        return if first_needs_restart {
            PickerDecision::ConfirmRestart(0)
        } else {
            PickerDecision::Resume(0)
        };
    }
    if let Ok(n) = choice.parse::<usize>() {
        if n >= 1 && n <= session_count {
            let idx = n - 1;
            if unresumable.get(idx).copied().unwrap_or(false) {
                return PickerDecision::Unresumable(idx);
            }
            return PickerDecision::Resume(idx);
        }
        if n == session_count + 1 {
            return PickerDecision::LaunchNew;
        }
    }
    PickerDecision::Unrecognised
}

/// Interactive numbered picker (TTY mode only).
///
/// Why: a simple numbered menu is the lowest-friction way to resume or launch
/// without requiring the operator to remember session names or UUIDs. After a
/// detach, the picker is redisplayed rather than exiting to the shell — the
/// common "pick → Ctrl-b d → pick again" flow stays in one command.
/// What: loops: print menu, read one line, dispatch, then re-fetch the session
/// list (via [`fetch_live_sessions`], honoring the scope) so the next iteration
/// shows current state. Exits cleanly on `Quit`, EOF (Ctrl-D), or unrecognised
/// input; propagates attach/launch errors.
///   • `Resume(i)` → [`super::guided_resume::resume_guided_session`] which
///     handles daemon restart when needed and then attaches internally;
///   • `LaunchNew` → [`super::guided_launch::launch_new_session_and_attach`] when
///     the scope carries a `repo_url`; otherwise prints an actionable hint and
///     redisplays the menu (fleet-wide `tm ls` has no single launch target);
///   • `ConfirmRestart(i)` (#2148) → print a one-line "type the number to
///     confirm" notice and redisplay the SAME menu — no daemon round-trip;
///   • `Unresumable(i)` (#2595) → print the dead-session notice pointing at
///     `d<N>` and redisplay the SAME menu — no daemon round-trip (a resume
///     would only ever fail: #2577/#2594);
///   • `Delete(i)` (#2304) → [`super::picker_delete::confirm_and_delete`] runs a
///     confirm (force-confirm for a running session) then the managed→local
///     routed delete; redisplay the re-fetched menu afterwards;
///   • `Quit` / EOF / `Unrecognised` → print notice and return `Ok`.
///
/// #2678: both `Resume` and `LaunchNew` can end in a tmux hand-off — either a
/// blocking `attach-session` (outside tmux; control returns here once the
/// operator detaches, so the loop legitimately redisplays the menu) or a
/// non-blocking `switch-client` (inside tmux; control does NOT return to a
/// visible terminal — this process's own pane is now hidden). The
/// `AttachOutcome` each helper returns is checked via
/// [`super::tmux_attach::AttachOutcome::ends_interactive_loop`]; when it is `true`
/// (a `switch-client` handoff, or a fail-closed skip that could not resolve a
/// safe target) the loop `break`s immediately instead of falling through to
/// re-fetch sessions and block on `stdin` again — the exact hang/orphan
/// #2678 reported.
/// Test: `parse_picker_choice` is the testable seam; the `AttachOutcome`
/// decision itself is unit-tested by `attach_outcome_ends_interactive_loop_matrix`
/// in `tmux_attach.rs`; the full I/O path is exercised by manual smoke tests
/// and the e2e suite.
pub(crate) async fn run_tty_picker(
    client: &reqwest::Client,
    url: &str,
    scope: &PickerScope,
    mut sessions: Vec<ManagedSessionSummary>,
) -> anyhow::Result<()> {
    loop {
        eprintln!();
        let new_idx = sessions.len() + 1;
        // #2148: bare Enter must not silently restart (kill+recreate the tmux
        // pane of) a stopped/errored session — only used to pick the menu's
        // default hint and to gate `parse_picker_choice`'s bare-Enter branch.
        let first_needs_restart = sessions
            .first()
            .map(|s| super::guided_resume::needs_restart(&s.state))
            .unwrap_or(false);
        // #2595: server-computed dead-workspace flag, one per session, in menu
        // order — the single source `parse_picker_choice` and the menu render
        // both read so they can never disagree about which slot is dead.
        let unresumable: Vec<bool> = sessions.iter().map(|s| s.unresumable).collect();
        if sessions.is_empty() {
            eprintln!("tm:   [Enter] launch new session");
            eprintln!("tm:   [q]     quit");
        } else {
            for (i, s) in sessions.iter().enumerate() {
                if s.unresumable {
                    // #2595: workspace is gone for good — never offer resume/restart;
                    // point straight at the delete remedy instead.
                    eprintln!(
                        "tm:   [{}] {} ({}) — DEAD: workspace removed; use [d{}] to remove the record",
                        i + 1,
                        s.name,
                        s.state,
                        i + 1
                    );
                    continue;
                }
                // Show "restart" for sessions that are stopped/errored — they have no
                // live tmux session and will be restarted via the daemon (#1742).
                // The state is reconciled against live tmux by the daemon list
                // handler, so a running/attached session reads "active"/"attached"
                // here and is offered "resume" (connect), never a stale "restart".
                let stopped = matches!(s.state.as_str(), "stopped" | "errored");
                let verb = if stopped { "restart" } else { "resume" };
                let shown_state = if s.attached {
                    "attached"
                } else {
                    s.state.as_str()
                };
                eprintln!("tm:   [{}] {} {} ({})", i + 1, verb, s.name, shown_state);
            }
            eprintln!("tm:   [{new_idx}] launch new session");
            eprintln!("tm:   [d<N>] delete session N (e.g. d1)");
            eprintln!("tm:   [q] quit");
            if unresumable.first().copied().unwrap_or(false) {
                eprintln!(
                    "tm: [Enter] is DEAD (workspace removed) — type [d1] to remove it, or choose another"
                );
            } else if first_needs_restart {
                // #2148: no implicit destructive default — an explicit [1] is required.
                eprintln!("tm: [Enter] does NOT restart — type [1] to confirm the restart");
            } else {
                eprintln!("tm: default: [1] resume most recent");
            }
        }
        eprint!("tm: > ");

        let mut line = String::new();
        let n = std::io::stdin()
            .read_line(&mut line)
            .context("failed to read choice from stdin")?;
        if n == 0 {
            break;
        } // EOF (Ctrl-D): exit cleanly.

        match parse_picker_choice(&line, sessions.len(), first_needs_restart, &unresumable) {
            PickerDecision::Quit => {
                eprintln!("tm: quit.");
                break;
            }
            // #1742: route through daemon resume when the session is stopped or its
            // tmux session is absent — never raw-attach a non-live session.
            // #2678: a `switch-client` hand-off (or a fail-closed skip) means this
            // pane is no longer visible to the operator — stop before looping back
            // to `stdin`.
            PickerDecision::Resume(i) => {
                let outcome =
                    super::guided_resume::resume_guided_session(client, url, &sessions[i]).await?;
                if outcome.ends_interactive_loop() {
                    break;
                }
            }
            PickerDecision::LaunchNew => match scope.repo_url.as_deref() {
                Some(repo) => {
                    let outcome =
                        super::guided_launch::launch_new_session_and_attach(client, url, repo)
                            .await?;
                    if outcome.ends_interactive_loop() {
                        break;
                    }
                }
                None => {
                    // Fleet-wide `tm ls` has no single launch target — steer the
                    // operator to the project directory or an explicit spawn.
                    eprintln!(
                        "tm: launch-new is unavailable from this list view — run `tm` from a \
                         project directory, or `tm session new <repo-url>`."
                    );
                    continue;
                }
            },
            // #2148: bare Enter hit a session that needs a restart — ask for an
            // explicit choice instead of silently destroying its pane. No daemon
            // round-trip; redisplay the SAME menu.
            PickerDecision::ConfirmRestart(i) => {
                eprintln!(
                    "tm: '{}' requires a restart (its pane is gone or will be recreated) — \
                     bare Enter no longer does this automatically.",
                    sessions[i].name
                );
                eprintln!(
                    "tm: type [{}] to confirm the restart, or [q] to quit.",
                    i + 1
                );
                continue;
            }
            // #2595: selection targeted a dead session — its workspace no longer
            // exists anywhere on disk, so a resume/restart is guaranteed to fail
            // (#2577/#2594). No daemon round-trip; point at the delete remedy and
            // redisplay the SAME menu.
            PickerDecision::Unresumable(i) => {
                eprintln!(
                    "tm: '{}' is dead — its workspace no longer exists anywhere on disk; \
                     resuming it would only fail.",
                    sessions[i].name
                );
                eprintln!(
                    "tm: run [d{}] to remove the record, or choose another session.",
                    i + 1
                );
                continue;
            }
            // #2304: delete the selected session. The confirm/force-confirm
            // prompt and the managed→local routing live in `picker_delete`; this
            // arm just runs it, then falls through to the re-fetch so the menu
            // reflects the removal. A cancel/refusal is a no-op re-fetch.
            PickerDecision::Delete(i) => {
                super::picker_delete::confirm_and_delete(client, url, &sessions[i]).await?;
            }
            PickerDecision::Unrecognised => {
                eprintln!("tm: unrecognised choice '{}'; quitting.", line.trim());
                break;
            }
        }

        // Detached or session ended — re-fetch the list before redisplaying.
        // #1809: the shared fetch path applies the same live-only tombstone filter.
        sessions = fetch_live_sessions(client, url, scope.source_id.as_deref(), false).await?;
    }
    Ok(())
}

/// Decide whether `tm ls` should open the interactive picker or print statically.
///
/// Why: a testable seam that folds every gate into one pure decision so the
/// non-TTY / `--json` / `--all` / empty-list branches are unit-testable without a
/// live terminal or daemon. Requiring BOTH stdin and stdout to be TTYs is the
/// anti-hang guarantee: a piped input (would EOF) or a piped output (must stay a
/// clean pipeable table) both fall through to static output.
/// What: returns `true` only when stdin AND stdout are TTYs, neither `--json` nor
/// `--all` was requested, and at least one session exists. `--all` forces static
/// output because its purpose is the forensic full list (including
/// decommissioned tombstones), not connecting.
/// Test: `ls_connector_should_show_picker_*` in `tests_behavior_d_tests.rs`.
pub(crate) fn should_show_picker(
    stdin_tty: bool,
    stdout_tty: bool,
    json: bool,
    all: bool,
    session_count: usize,
) -> bool {
    stdin_tty && stdout_tty && !json && !all && session_count > 0
}

/// `tm ls` — the interactive managed-session connector (top-level).
///
/// Why: bare `tm ls` should do the most useful thing for connecting to the
/// managed fleet: on a real terminal it opens the session picker; piped or
/// scripted it degrades to the same static, pipeable list as `tm session ls`.
/// What: resolves the scope (`--current` derives `owner/repo` from the cwd git
/// remote, mirroring `tm session ls`); routes `--json`, `--all`, or any non-TTY
/// invocation straight to the static [`super::managed::session_ls`] renderer
/// (preserving its raw `--json` passthrough byte-for-byte); otherwise fetches the
/// live sessions once and either renders the static table (0 sessions) or opens
/// [`run_tty_picker`] (≥1 session). Launch-new inside the picker targets the cwd
/// project only when it is a GitHub-backed git checkout.
/// Test: parse tests `cli_parses_ls_*` and the gate tests
/// `ls_connector_should_show_picker_*` in `tests_behavior_d_tests.rs`.
pub(crate) async fn run_ls_connector(
    client: &reqwest::Client,
    url: &str,
    json: bool,
    source_id: Option<String>,
    current: bool,
    all: bool,
) -> anyhow::Result<()> {
    // `--current` derives the source_id from the cwd git remote, exactly like
    // `tm session ls --current`. `--source-id` and `--current` are mutually
    // exclusive at the clap layer, so at most one branch supplies a filter.
    let sid: Option<String> = if current {
        super::session::derive_source_id_from_cwd()
    } else {
        source_id
    };

    let stdin_tty = std::io::stdin().is_terminal();
    let stdout_tty = std::io::stdout().is_terminal();

    // Cheap pre-gate: `--json`, `--all`, or any non-interactive stream never
    // fetches for the picker — delegate straight to the static renderer, which
    // owns the raw `--json` passthrough and the `--all` tombstone sort.
    if json || all || !stdin_tty || !stdout_tty {
        return super::managed::session_ls(client, url, json, sid.as_deref(), all).await;
    }

    // Interactive stream: fetch the live sessions once. On any fetch error
    // (daemon unreachable, HTTP failure) fall back to the static renderer so the
    // operator sees the same actionable error rather than a bare picker crash.
    let sessions = match fetch_live_sessions(client, url, sid.as_deref(), false).await {
        Ok(s) => s,
        Err(_) => {
            return super::managed::session_ls(client, url, false, sid.as_deref(), false).await;
        }
    };

    if !should_show_picker(stdin_tty, stdout_tty, json, all, sessions.len()) {
        // 0 sessions on a TTY: print the static "no managed sessions" line rather
        // than an empty picker.
        super::managed::render_session_table(&sessions, sid.as_deref());
        return Ok(());
    }

    // ≥1 session on a real terminal → the interactive picker. Launch-new targets
    // the cwd project only when it is a GitHub-backed git checkout.
    let repo_url = std::env::current_dir()
        .ok()
        .and_then(|cwd| super::guided::derive_project(&cwd))
        .map(|(_sid, _workspace, git_root)| git_root.to_string_lossy().to_string());
    let scope = PickerScope {
        source_id: sid,
        repo_url,
    };
    run_tty_picker(client, url, &scope, sessions).await
}