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
//! `session` command handler.
//!
//! Why: session lifecycle operations (start, stop, list, clean, info, events,
//! breakers, pause, resume, catchup, run, output, instructions) form a cohesive
//! group that benefits from a dedicated file.
//! What: the `session` dispatcher function (shared by the canonical `tm
//! sessions` command and its deprecated hidden `tm session` alias, #2116) and
//! its private helpers `emit_managed_alias_notice`, `compose_session_instructions`,
//! plus the crate-visible [`emit_top_level_alias_notice`] the alias's `main.rs`
//! match arm calls. `start` is handled by the [`start`] submodule (protected-path
//! routing, #1916). The managed verbs (`new`/`ls`/`send`/`answer`/`attach`/
//! `stop`/`resume`/`decommission`) route through the shared chat-core layer
//! (`commands::managed_route`); the id-or-name resolution for both managed and
//! project sessions goes through the one canonical `client::resolve_target`.
//! `catchup` is handled by the DOC-28 cutover bridge in the `catchup` submodule.
//! Test: `cli_parses_session_*` / `cli_parses_sessions_*`, `cli_parses_session_catchup`,
//! `compose_session_instructions_*` in `tests.rs`.

// CUTOVER BRIDGE submodule — remove post-migration (#1762)
pub(crate) mod catchup;
// #7422: the instruction pipeline and its new scope notice live in their own
// module — this file was at the 500-SLOC production cap.
pub(crate) mod instructions;
mod start;

use instructions::{compose_session_instructions, print_excluded_scope};

use serde::Deserialize;

use crate::cli::SessionAction;
use crate::commands::project::resolve_dir;
use crate::formatters::session::{event_summary, print_compression_stats, short_id};
use crate::types::{EventRow, SessionRow};

/// `session` subcommand — define and manage sessions within a project.
///
/// Why: a session is a Claude Code instance; operators start, stop, list,
/// reap, and inspect them per project from the shell.
/// What: `Start` routes through [`start::start_session`] (protected-path
/// segregation for a recognized GitHub-backed git repo, in-place
/// `prepare_session` for everything else — see that function's doc for the
/// #1916 rationale); `Stop` and `Resume` are managed-aware (#1218) — they
/// route to the managed runtime-stop/resume endpoints when the id/name
/// resolves to a managed session, falling back to the project-session path
/// otherwise; `Info` resolves a session by id or friendly name; `List` and
/// `Clean` scope to the project directory.
/// Test: `cli_parses_session_start`, `cli_parses_session_stop`,
/// `cli_parses_session_list`, `cli_parses_session_clean`,
/// `cli_parses_session_info`.
pub(crate) async fn session(
    daemon: &trusty_mpm::client::DaemonClient,
    action: SessionAction,
) -> anyhow::Result<()> {
    match action {
        SessionAction::Start { dir } => start::start_session(daemon, dir).await?,
        SessionAction::Stop { id_or_name } => {
            // #1218: `stop` is managed-aware. If the argument resolves to a
            // MANAGED session (by id or friendly name) via the canonical
            // chat-core resolver, route to the managed runtime-stop endpoint;
            // otherwise fall back to the project-session DELETE path. This keeps
            // one intuitive verb that does the right thing for both families (the
            // #842 driver skill documents `stop`).
            if let Some(managed_id) =
                crate::commands::managed_route::resolve_managed_match(daemon, &id_or_name).await
            {
                crate::commands::managed::session_stop(daemon, managed_id).await?;
            } else {
                let resp = daemon
                    .delete(format!("/sessions/{id_or_name}"))
                    .send()
                    .await?;
                if resp.status() == reqwest::StatusCode::NOT_FOUND {
                    anyhow::bail!("session '{id_or_name}' not found");
                }
                resp.error_for_status()?;
                println!("stopped {id_or_name}");
            }
        }
        SessionAction::List { dir } => {
            let path = resolve_dir(dir)?;
            #[derive(Deserialize)]
            struct Body {
                sessions: Vec<SessionRow>,
            }
            let body: Body = daemon
                .get("/sessions")
                .query(&[("project", path.to_string_lossy().as_ref())])
                .send()
                .await?
                .error_for_status()?
                .json()
                .await?;
            if body.sessions.is_empty() {
                // #3822: `tm sessions list` (this command) and `tm sessions
                // ls` are two DIFFERENT, intentionally-scoped subsystems —
                // `list` shows this project's local (pre-session-manager)
                // sessions, `ls` shows daemon-managed sessions (e.g. every
                // session created via `tm sessions new <url>`) from
                // anywhere. A user who ran `sessions new` and then checked
                // with `sessions list` sees an empty result here even
                // though their session is alive and well under `ls` — a
                // repeated support/bug-report source. Nudge toward the
                // command that actually shows managed sessions rather than
                // leaving the empty result unexplained.
                println!("no sessions for {}", path.display());
                println!(
                    "(looking for a session created via `tm sessions new`? try `tm sessions ls`)"
                );
            }
            for s in &body.sessions {
                let status = s.status.as_str().unwrap_or("unknown");
                println!("{} {} {}", short_id(&s.id), status, s.workdir);
            }
        }
        SessionAction::Tui {
            url: tui_url,
            interval_ms,
        } => {
            // #1392: the coordinator TUI is `tm session tui` (formerly the
            // top-level `tm coordinator-tui`). It polls the daemon live, so its
            // `run` is async and runs on the tokio runtime like `tui::run`.
            // `resolve_daemon_url` honours an explicit `--url`, then the lock
            // file, then the default — so we resolve from this subcommand's own
            // `--url`/`TRUSTY_MPM_URL` flag rather than the dispatcher's `url`.
            // #9556: an explicit `--url` / `TRUSTY_MPM_URL` is pinned.
            let client = trusty_mpm::tui::client_for(tui_url.as_deref());
            trusty_mpm::tui::coordinator::run_with_client(client, interval_ms).await?;
        }
        SessionAction::Clean { dir } => {
            // `dir` is accepted for symmetry; the daemon reaps globally.
            let _ = resolve_dir(dir)?;
            let body: serde_json::Value = daemon
                .delete("/sessions/dead")
                .send()
                .await?
                .error_for_status()?
                .json()
                .await?;
            let removed = body.get("removed").and_then(|v| v.as_u64()).unwrap_or(0);
            println!("reaped {removed} dead session(s)");
        }
        SessionAction::Info { id_or_name } => {
            #[derive(Deserialize)]
            struct Body {
                sessions: Vec<serde_json::Value>,
            }
            let body: Body = daemon
                .get("/sessions")
                .send()
                .await?
                .error_for_status()?
                .json()
                .await?;
            let found = body.sessions.iter().find(|s| {
                let id_match = s
                    .get("id")
                    .and_then(|v| v.get("0"))
                    .and_then(|v| v.as_str())
                    == Some(id_or_name.as_str());
                let name_match =
                    s.get("tmux_name").and_then(|v| v.as_str()) == Some(id_or_name.as_str());
                id_match || name_match
            });
            match found {
                Some(s) => println!("{}", serde_json::to_string_pretty(s)?),
                None => {
                    // Fall back: the id/name may belong to a MANAGED session
                    // (SM sessions live in the managed store, not the project-session
                    // store). Fetch the managed list and search there too before
                    // giving up. This fixes the gap where `tm session info <uuid>`
                    // printed "not found" for sessions visible in `tm session ls`.
                    let managed = info_from_managed_store(daemon, &id_or_name).await;
                    match managed {
                        Some(val) => println!("{}", serde_json::to_string_pretty(&val)?),
                        None => anyhow::bail!("session '{id_or_name}' not found"),
                    }
                }
            }
        }
        SessionAction::Instructions { dir } => {
            // Pure local computation — no daemon round-trip needed.
            let path = resolve_dir(dir)?;
            // `resolved_prompt` is the same text written to the stash and
            // passed to `claude --append-system-prompt-file` — the single
            // source of truth for what Claude received (issue #382).
            let (resolved_prompt, _output, _stash) = compose_session_instructions(&path)?;
            print!("{resolved_prompt}");
            // #9012: the section report reads the same content the prompt did.
            let content = trusty_mpm::core::content_source::framework_content_for(&path)
                .map_err(|err| anyhow::anyhow!("cannot compose the PM instructions: {err}"))?;
            // #8533: per-section package / overridden / declined, checked
            // against the prompt just printed, then the style it launches with.
            eprint!(
                "\n{}{}",
                trusty_mpm::core::instruction_overrides::section_report_for(
                    &content,
                    &path,
                    &resolved_prompt
                ),
                trusty_mpm::core::output_style::describe_effective_style(
                    &trusty_mpm::core::paths::FrameworkPaths::default().root,
                    &path
                )
            );
            // #7422: the composed prompt goes to stdout so it can be piped; what
            // this project's sessions will NOT load goes to stderr beside it,
            // the same channel the override applied/declined markers use.
            print_excluded_scope(&path);
            // #7673: the memory files ABOVE the project this prompt rides with,
            // or why they could not be checked. stderr, beside the scope notice.
            let scanned = trusty_mpm::core::ancestor_claude_md::scan(
                &path,
                dirs::home_dir().as_deref(),
                trusty_mpm::core::trusty_tools_config::managed_claude_config_dir().as_deref(),
            );
            if let Some(text) = trusty_mpm::core::ancestor_claude_md::scan_notice(&path, &scanned) {
                eprintln!("\n{text}");
            }
        }
        SessionAction::Events { id_or_name } => {
            let id = match crate::commands::managed_route::resolve_project_session_id(
                daemon,
                &id_or_name,
            )
            .await?
            {
                Some(id) => id,
                None => {
                    anyhow::bail!("session '{id_or_name}' not found");
                }
            };
            #[derive(Deserialize)]
            struct Body {
                events: Vec<EventRow>,
            }
            let body: Body = daemon
                .get(format!("/sessions/{id}/events/poll"))
                .send()
                .await?
                .error_for_status()?
                .json()
                .await?;
            if body.events.is_empty() {
                println!("no events for session {id_or_name}");
            }
            for e in &body.events {
                println!("{} {} {}", e.at, e.event, event_summary(&e.payload));
            }
        }
        SessionAction::Breakers => {
            #[derive(Deserialize)]
            struct Row {
                agent: String,
                breaker: serde_json::Value,
            }
            #[derive(Deserialize)]
            struct Body {
                breakers: Vec<Row>,
            }
            let body: Body = daemon
                .get("/breakers")
                .send()
                .await?
                .error_for_status()?
                .json()
                .await?;
            if body.breakers.is_empty() {
                println!("no circuit breakers");
            } else {
                println!("{:<24} {:<12} FAILURES", "AGENT", "STATE");
                for r in &body.breakers {
                    let state = r
                        .breaker
                        .get("state")
                        .and_then(|v| v.as_str())
                        .unwrap_or("unknown");
                    let failures = r
                        .breaker
                        .get("consecutive_failures")
                        .and_then(|v| v.as_u64())
                        .unwrap_or(0);
                    // #4068: the AGENT cell carries the identity color and is
                    // padded outside the color run, so the STATE and FAILURES
                    // columns stay aligned; state text is left uncolored.
                    println!(
                        "{} {:<12} {}",
                        crate::formatters::agent_color::agent_label_padded(&r.agent, 24),
                        state,
                        failures
                    );
                }
            }
        }
        // DOC-28 cutover bridge — dispatches to session/catchup.rs
        // CUTOVER BRIDGE — remove post-migration (#1762)
        SessionAction::Catchup {
            all_projects,
            full,
            json,
            sessions_offset,
        } => {
            // #8017: `--json` pages exactly as the MCP tool does.
            if json {
                catchup::handle_catchup_json(all_projects, full, sessions_offset).await?;
            } else {
                catchup::handle_catchup(all_projects, full).await?;
            }
        }
        SessionAction::Pause { id_or_name, note } => {
            let resp = daemon
                .post(format!("/sessions/{id_or_name}/pause"))
                .json(&serde_json::json!({ "summary": note }))
                .send()
                .await?;
            if resp.status() == reqwest::StatusCode::NOT_FOUND {
                anyhow::bail!("session '{id_or_name}' not found");
            }
            let body: serde_json::Value = resp.error_for_status()?.json().await?;
            let summary = body.get("summary").and_then(|v| v.as_str()).unwrap_or("");
            println!("paused {id_or_name}: {summary}");
        }
        SessionAction::Resume { id_or_name } => {
            // #1218: `resume` is managed-aware (mirrors `Stop`). A managed
            // session id/name routes (via the canonical chat-core resolver) to
            // the managed resume endpoint; anything else falls back to the
            // project-session pause/resume path.
            if let Some(managed_id) =
                crate::commands::managed_route::resolve_managed_match(daemon, &id_or_name).await
            {
                crate::commands::managed::session_resume(daemon, managed_id).await?;
            } else {
                let resp = daemon
                    .post(format!("/sessions/{id_or_name}/resume"))
                    .send()
                    .await?;
                match resp.status() {
                    reqwest::StatusCode::NOT_FOUND => {
                        anyhow::bail!("session '{id_or_name}' not found");
                    }
                    reqwest::StatusCode::CONFLICT => {
                        println!("session '{id_or_name}' is not paused");
                    }
                    _ => {
                        resp.error_for_status()?;
                        println!("resumed {id_or_name}");
                    }
                }
            }
        }
        SessionAction::Run {
            id_or_name,
            command,
            summarize,
        } => {
            let mut req = daemon.post(format!("/sessions/{id_or_name}/command"));
            if summarize {
                req = req.query(&[("compress", "summarise")]);
            }
            let resp = req
                .json(&serde_json::json!({ "command": command }))
                .send()
                .await?;
            match resp.status() {
                reqwest::StatusCode::NOT_FOUND => {
                    anyhow::bail!("session '{id_or_name}' not found");
                }
                reqwest::StatusCode::CONFLICT => {
                    println!("session '{id_or_name}' is stopped");
                }
                _ => {
                    let body: serde_json::Value = resp.error_for_status()?.json().await?;
                    let output = body.get("output").and_then(|v| v.as_str()).unwrap_or("");
                    print!("{output}");
                    print_compression_stats(&body);
                }
            }
        }
        SessionAction::Output {
            id_or_name,
            lines,
            summarize,
        } => {
            // #6288: `lines` typed, so the socket's params struct decodes it.
            let mut req = daemon
                .get(format!("/sessions/{id_or_name}/output"))
                .query(&[("lines", lines)]);
            if summarize {
                req = req.query(&[("compress", "summarise")]);
            }
            let resp = req.send().await?;
            if resp.status() == reqwest::StatusCode::NOT_FOUND {
                anyhow::bail!("session '{id_or_name}' not found");
            }
            let body: serde_json::Value = resp.error_for_status()?.json().await?;
            let output = body.get("output").and_then(|v| v.as_str()).unwrap_or("");
            print!("{output}");
            print_compression_stats(&body);
            // #8407: on stderr, so stdout stays the pane text byte for byte.
            eprintln!("{}", crate::formatters::session::input_box_note(&body));
        }
        // ── Managed session-manager actions ──────────────────────────────────
        // All `ls` variants are routed through the direct HTTP path so the
        // `?source_id=` filter and the extended table columns (task, created_at)
        // work regardless of the `--json` flag. `--json` keeps the raw
        // daemon-JSON passthrough byte-for-byte (scripts rely on this).
        SessionAction::Ls {
            terms,
            json,
            source_id,
            current,
            all,
            no_prune,
        } => {
            let sid: Option<String> = if current {
                derive_source_id_from_cwd()
            } else {
                source_id
            };
            let (sort, term) = crate::commands::session_picker::parse_ls_terms(&terms);
            // `tm sessions ls <term>` keeps the #3483 all-visible-columns scope;
            // only `tm f` narrows to NAME.
            let filter = term.map(crate::commands::session_picker::SessionFilter::visible);
            crate::commands::managed::session_ls(
                daemon,
                json,
                sid.as_deref(),
                all,
                // `-a` is a `tm ls` flag only; `tm sessions ls` is unchanged.
                false,
                sort,
                filter,
                // #5950: explicit opt-out from the default listing auto-prune.
                no_prune,
            )
            .await?
        }
        // `activity` stays on the raw path: its CLI output carries confidence,
        // token, cache, and latency detail that `CommandResult::ManagedActivity`
        // does not model, so routing it through chat-core would regress output.
        SessionAction::Activity { id } => {
            crate::commands::managed::session_activity(daemon, id).await?
        }
        SessionAction::PruneIdle { dry_run, json } => {
            crate::commands::prune::prune_idle(daemon, dry_run, json).await?
        }
        // #1508: bulk teardown + by-state prune go through direct HTTP (like
        // `PruneIdle`), not chat-core — they are fleet-wide store operations, not
        // single-session intents.
        SessionAction::DecommissionEphemeral { dry_run } => {
            crate::commands::managed::session_decommission_ephemeral(daemon, dry_run).await?
        }
        SessionAction::Prune {
            state,
            dry_run,
            include_active,
        } => {
            crate::commands::managed::session_prune(daemon, state, dry_run, include_active).await?
        }
        SessionAction::PruneWorktrees {
            force,
            discard_dirty,
            merged_prs,
            all_projects,
        } => {
            // #8782: scoped to this checkout unless `--all-projects`; outside a
            // repository there is no project to scope to, so it refuses.
            // `--force` means "actually delete"; absence means dry-run (#1840).
            // `--discard-dirty` is a SEPARATE opt-in that additionally permits
            // destroying uncommitted/unpushed work (#4091).
            // `--merged-prs` is a THIRD, independent opt-in (#2919) enabling the
            // merged-pull-request reclaim pass. It never implies the other two.
            // #6806: the daemon occupies no pane and cannot discover the
            // caller, so the caller names itself; absent outside a managed
            // session, which leaves every claim foreign.
            crate::commands::managed_merged_prs::prune_worktrees_from(
                daemon,
                &std::env::current_dir()?,
                force,
                discard_dirty,
                merged_prs,
                all_projects,
                std::env::var("TM_MANAGED_SESSION_ID").ok(),
            )
            .await?
        }
        // #4288: the report-only inventory. No `force`/`dry_run` argument
        // exists because the verb has no destructive form.
        SessionAction::ReconcileWorktrees { json } => {
            crate::commands::reconcile_worktrees::session_reconcile_worktrees(daemon, json).await?
        }
        // #6497: the transfer the reconcile report can only propose. Explicit
        // by design — a tree that changes hands on its own is indistinguishable
        // from one taken out from under a working agent.
        // #7313: read-only per-session disk reporting. One `disk_survey` call
        // over `POST /rpc` — the walk, the classification and the by-session
        // fold are all the daemon's, so this verb runs no survey of its own.
        SessionAction::Disk {
            id_or_name,
            json,
            budget_seconds,
        } => {
            // #6288: `disk` rides the MCP `POST /rpc` surface, which the
            // stdio-bridge slice moves; `socket_dispatch` keeps it on HTTP.
            crate::commands::session_disk::session_disk(
                daemon.http(),
                daemon.base_url(),
                id_or_name,
                json,
                budget_seconds,
            )
            .await?
        }
        SessionAction::AdoptWorktree { path, as_session } => {
            crate::commands::adopt_worktree::session_adopt_worktree(daemon, &path, &as_session)
                .await?
        }
        // #2444: re-sync a live session's (or every syncable session's)
        // deployed assets against the current catalog. Fleet-wide store
        // operation like `Prune`/`DecommissionEphemeral` above — direct HTTP,
        // not chat-core.
        SessionAction::SyncAssets { id: Some(id), .. } => {
            crate::commands::sync_assets::session_sync_assets(daemon, id).await?
        }
        SessionAction::SyncAssets { id: None, .. } => {
            crate::commands::sync_assets::session_sync_assets_all(daemon).await?
        }
        // #2012: hard-delete the record; goes through direct HTTP like the
        // other fleet-teardown verbs above (not chat-core — `delete` is a
        // distinct terminal store operation, not a `decommission` alias).
        SessionAction::Delete { id, force } => {
            crate::commands::delete::session_delete(daemon, id, force).await?
        }
        // Rename a managed session. Direct HTTP (PATCH), like `delete` — a
        // distinct store+tmux mutation, not a chat-core intent.
        SessionAction::Rename { arg1, arg2 } => {
            crate::commands::rename::session_rename(daemon, arg1, arg2).await?
        }
        // #9313: a record update only; direct HTTP like `rename`.
        SessionAction::Rebind { id, tmux, .. } => {
            crate::commands::rebind::session_rebind(daemon, id, tmux).await?
        }
        // #7660: decommission prints the daemon's verdict itself so a kept
        // workspace exits non-zero with the reason.
        SessionAction::Decommission { id, force } => {
            crate::commands::managed::session_decommission_routed(daemon, &id, force).await?
        }
        // The deprecated verbose aliases emit their deprecation notice, then
        // route through chat-core exactly like their canonical verb (#1205).
        action @ (SessionAction::ManagedStop { .. }
        | SessionAction::RuntimeStop { .. }
        | SessionAction::ManagedResume { .. }) => {
            emit_managed_alias_notice(&action);
            // `to_command` maps every one of these aliases; `run` therefore
            // always handles it.
            crate::commands::managed_route::run(daemon, &action).await?;
        }
        // Every remaining managed verb (`new`/`ls`/`send`/`answer`/`attach`/
        // `decommission`) routes through the shared chat-core layer. `run`
        // returns `false` only for non-managed variants, which the arms above
        // already handled — so an unrouted variant here is a wiring bug.
        action => {
            debug_assert!(
                crate::commands::managed_route::to_command(&action).is_some(),
                "unrouted session action reached the managed fallthrough: {action:?}"
            );
            crate::commands::managed_route::run(daemon, &action).await?;
        }
    }
    Ok(())
}

/// Emit the deprecation notice for a renamed managed lifecycle alias (#1205).
///
/// Why: `managed-stop`/`runtime-stop`/`managed-resume` were renamed to the
/// cleaner `stop`/`resume` family; the old spellings still parse but every
/// invocation must nudge the operator toward the canonical verb. Centralizing the
/// old→new mapping here keeps the routing arm a thin match.
/// What: writes `warning: '<old>' is deprecated; use '<new>'` to stderr for the
/// three deprecated aliases; a no-op for any other action.
/// Test: the message text is asserted by `deprecation_notice_format`; the alias
/// parse paths by `cli_parses_session_managed_stop`/`_runtime_stop`/`_managed_resume`.
fn emit_managed_alias_notice(action: &SessionAction) {
    let pair = match action {
        SessionAction::ManagedStop { .. } => Some(("managed-stop", "stop")),
        SessionAction::RuntimeStop { .. } => Some(("runtime-stop", "stop")),
        SessionAction::ManagedResume { .. } => Some(("managed-resume", "resume")),
        _ => None,
    };
    if let Some((old, new)) = pair {
        crate::commands::managed::deprecation_notice(old, new);
    }
}

/// Emit the deprecation notice for the top-level `session` (singular) alias (#2116).
///
/// Why: `tm session <verb>` is now a hidden deprecated alias of the canonical
/// `tm sessions <verb>` (DOC-35 §2.2/§3.2). Unlike [`emit_managed_alias_notice`],
/// this is a rename of the top-level NOUN, not a specific verb, so it must fire
/// exactly once per invocation regardless of which verb was invoked. `main.rs`
/// calls this from the single `Command::Session` match arm — one call site,
/// executed at most once per process — before dispatching to [`session`], the
/// same handler the canonical `Command::Sessions` arm calls. No functional
/// difference in behavior between the two arms beyond this notice.
/// What: writes `warning: 'session' is deprecated; use 'sessions'` to stderr.
/// Test: `tm_session_singular_prints_deprecation_notice_once` /
/// `tm_sessions_plural_prints_no_deprecation_notice` (integration tests,
/// `tests/tm_sessions_alias_notice.rs`) drive the real binary and assert the
/// notice appears exactly once for the singular form and not at all for the
/// plural; `top_level_alias_notice_message` in `tests.rs` asserts the exact text.
pub(crate) fn emit_top_level_alias_notice() {
    crate::commands::managed::deprecation_notice("session", "sessions");
}

/// Derive the `owner/repo` source_id from a git directory for `--current`.
///
/// Why: extracted from `derive_source_id_from_cwd` so the git-remote resolution
/// is unit-testable with a path argument rather than the process cwd (which would
/// require `std::env::set_current_dir`, a process-global, non-thread-safe call).
/// What: runs `git -C <dir> config --get remote.origin.url` and parses the result
/// via `parse_github_path`. Returns `Some("owner/repo")` on success; `None` when
/// not in a git repo or the remote URL is not a GitHub URL.
/// Test: `derive_source_id_from_cwd_returns_none_without_git` (unit).
fn derive_source_id_from_path(dir: &std::path::Path) -> Option<String> {
    // #4734: a `--current` filter has nothing to fall back to either way, so an
    // unreadable remote and an absent one both yield no filter here.
    let url = trusty_mpm::daemon::managed_routes::inproject::get_origin_url(dir)
        .ok()
        .flatten()?;
    let gh = trusty_common::github_path::parse_github_path(&url)?;
    Some(format!("{}/{}", gh.owner, gh.repo))
}

/// Derive the `owner/repo` source_id from the process cwd for `--current`.
///
/// Why: thin wrapper over `derive_source_id_from_path` for the CLI call site;
/// separating cwd resolution from git-remote parsing keeps the impl testable.
/// What: reads `std::env::current_dir()` and delegates to `derive_source_id_from_path`.
/// Returns `Some("owner/repo")` on success; `None` on any failure (both cases silently
/// ignored — the caller treats None as "no filter").
/// Test: `derive_source_id_from_cwd_returns_none_without_git` (unit via path variant).
pub(crate) fn derive_source_id_from_cwd() -> Option<String> {
    derive_source_id_from_path(&std::env::current_dir().ok()?)
}

/// Return true when `session` matches `id_or_name` by id or tmux name.
///
/// Why: extracted from `info_from_managed_store` so the match predicate is
/// unit-testable without requiring an HTTP call.
/// What: compares `session["id"]` and `session["name"]` against `id_or_name`;
/// returns true on either match.
/// Test: `info_managed_fallback_matches_by_id_and_name`.
fn matches_session(session: &serde_json::Value, id_or_name: &str) -> bool {
    let id_match = session.get("id").and_then(|v| v.as_str()) == Some(id_or_name);
    let name_match = session.get("name").and_then(|v| v.as_str()) == Some(id_or_name);
    id_match || name_match
}

/// Fetch the managed session list and find a session matching `id_or_name`.
///
/// Why: `tm session info` queries the project-session store first; managed
/// sessions live in a separate store and return 404 there. This fallback
/// prevents the confusing "not found" message for sessions that ARE visible in
/// `tm session ls`. Non-404 HTTP errors are logged as warnings so daemon 5xx
/// responses are not silently swallowed as "not found".
/// What: GETs `/api/v1/sessions/managed`, searches by id-exact then name-exact
/// via `matches_session`, returns the first match as a `serde_json::Value`.
/// Returns `None` when the daemon is unreachable, returns a non-success status,
/// or no session matches.
/// Test: `info_managed_fallback_matches_by_id_and_name` (unit on match predicate);
/// HTTP path covered by the integration test.
async fn info_from_managed_store(
    daemon: &trusty_mpm::client::DaemonClient,
    id_or_name: &str,
) -> Option<serde_json::Value> {
    let resp = daemon.get("/api/v1/sessions/managed").send().await.ok()?;
    let status = resp.status();
    if !status.is_success() {
        if status != reqwest::StatusCode::NOT_FOUND {
            tracing::warn!(
                "managed store lookup failed with HTTP {status} — \
                 'session not found' may mask a daemon error"
            );
        }
        return None;
    }
    let raw = resp.text().await.ok()?;
    let resp: serde_json::Value = serde_json::from_str(&raw).ok()?;
    let sessions = resp.get("sessions")?.as_array()?;
    sessions
        .iter()
        .find(|s| matches_session(s, id_or_name))
        .cloned()
}

// Unit tests live in session_tests.rs (test-file budget: 1500 SLOC).
#[cfg(test)]
#[path = "session_tests.rs"]
mod tests;