trusty-mpm 1.0.2

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
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
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
//! Daemon HTTP API.
//!
//! Why: the CLI, TUI, and Telegram bot are separate processes; they need a
//! transport to the daemon. HTTP/JSON over a loopback port is simple, debuggable
//! with `curl`, and lets the universal hook relay receive events from a tiny
//! forwarder shim with no client library.
//! What: builds the axum [`Router`] — health, session listing, the hook-event
//! relay endpoint, the live event feed, and the per-agent breaker view. State
//! is injected as `Arc<DaemonState>` via axum's `State` extractor.
//! Test: `cargo test -p trusty-mpm-daemon` drives the handlers directly with an
//! in-memory state (no socket bind needed).

use std::{convert::Infallible, path::PathBuf, sync::Arc, time::Duration};

use axum::{
    Json, Router,
    extract::{Path, Query, State},
    response::sse::{Event, KeepAlive, Sse},
    routing::{get, post},
};
use futures::Stream;
use tokio_stream::StreamExt;
use tokio_stream::wrappers::BroadcastStream;
#[cfg(feature = "swagger-ui")]
use utoipa::OpenApi;
#[cfg(feature = "swagger-ui")]
use utoipa_swagger_ui::SwaggerUi;

use crate::core::compress::CompressionLevel;
use crate::core::hook::HookEvent;
use crate::core::project::ProjectInfo;
use crate::core::session::{ControlModel, Session, SessionId, SessionStatus};

use super::error::DaemonError;
use super::services::{HookDecision, HookService, PairingService, SessionService, TmuxService};
use super::state::DaemonState;

/// The SESSCTL control-plane routes (`/api/v1/control/sessions/*`, WI-2 #1593).
///
/// Why: the SESSCTL Phase 2 handlers are a cohesive cluster; keeping them in a
/// sibling module mirrors `coordinator_routes` and keeps `api.rs` focused on
/// routing only.
/// What: five handlers wired to `DaemonState::session_registry` (run, connect,
/// stop, auth, list). Re-exported so `router` refers to them unqualified.
/// Test: `control_routes::tests`.
pub mod control_routes;
pub use control_routes::{
    ctl_auth_session, ctl_connect_session, ctl_list_sessions, ctl_run_session, ctl_stop_session,
};

/// The Claude Code configuration analyzer routes (`/claude-config/*`).
///
/// Why: that endpoint cluster is cohesive and large; keeping it in its own
/// module keeps `api.rs` focused on the core session / hook / tmux surface.
/// The handlers are re-exported below so `router` and `openapi.rs` can keep
/// referring to them as `super::api::<handler>`.
pub mod claude_config_routes;
pub use claude_config_routes::*;

/// The cross-session coordinator routes (`/api/v1/sessions/context` + `/chat`).
///
/// Why: the coordinator's context and chat endpoints form their own cohesive
/// cluster; keeping them in a sibling module mirrors `claude_config_routes` and
/// keeps `api.rs` focused on the core session / hook / tmux surface. As of #1392
/// these endpoints live under the plural `/api/v1/sessions/*` namespace (the
/// former `/api/v1/coordinator/*` paths are removed); the internal module and
/// handler names keep the `coordinator_` prefix for continuity.
/// The handlers are re-exported so `router` can refer to them as
/// `super::api::<handler>`.
pub mod coordinator_routes;
pub use coordinator_routes::*;

/// The Web control-surface route (`GET /web`, DOC-20 §6 Phase 2 / ONB-3).
///
/// Why: ONB-3 names a Web control surface as a peer of the TUI/Telegram
/// adapters. It is a *thin* presentation layer — a single static browser chat
/// page that drives the EXISTING action-capable `POST /api/v1/sessions/chat`
/// endpoint — so it embeds no session logic. Keeping it in a sibling module
/// mirrors `coordinator_routes` and keeps `api.rs` focused.
/// What: the `web_chat_page` handler (serves the embedded page); re-exported so
/// `router` refers to it as `super::api::web_chat_page`.
/// Test: `web_routes::web_chat_tests` drives the page and the `/web` route.
pub mod web_routes;
pub use web_routes::*;

/// The CSRF/origin guard for the browser-facing, action-capable chat endpoint.
///
/// Why: the Web surface (`GET /web`) drives the state-mutating
/// `POST /api/v1/sessions/chat` with `actions: true` from a browser; without an
/// origin check a malicious cross-origin page could trigger session actions if
/// the daemon were reachable off loopback. Keeping the policy in a sibling module
/// makes it unit-testable in isolation and keeps `coordinator_routes` thin.
/// What: re-exports [`origin_guard::origin_allowed`] so `coordinator_chat` can
/// gate on it.
/// Test: `origin_guard::origin_guard_tests`.
pub mod origin_guard;
pub use origin_guard::origin_allowed;

/// The loopback-only JSON-RPC dispatch endpoint (`POST /rpc`, #1221).
///
/// Why: the `serve --stdio` bridge forwards MCP JSON-RPC envelopes to the durable
/// daemon over this internal channel. It is gated to loopback peers only (the
/// #1221 hard requirement) so a future `0.0.0.0` rebind cannot expose the tool
/// surface. Keeping it in a sibling module mirrors `coordinator_routes` and keeps
/// `api.rs` focused.
pub mod rpc;
pub use rpc::rpc_handler;

/// The Deliverable/Milestone CRUD routes (`/api/v1/projects/{name}/deliverables`
/// and `.../milestones`, DOC-35 §10.2/§10.5; #2378 + #2380).
///
/// Why: the Deliverable/Milestone ledger is exposed over the same HTTP surface
/// as every other subsystem so any consumer (CLI #2381, TUI #2383, `tm manager`
/// #2109) reads and mutates it deterministically. Keeping the handlers in a
/// sibling module mirrors `coordinator_routes` and keeps `api.rs` focused on
/// routing. The §10.3 status state machine is enforced inside these handlers
/// (#2380). The handlers are re-exported so `router` refers to them unqualified.
/// Test: `deliverable_routes::tests`.
pub mod deliverable_routes;
pub use deliverable_routes::{
    create_deliverable, create_milestone, get_deliverable, get_milestone, list_deliverables,
    list_milestones, patch_deliverable, patch_milestone,
};

/// The managed session-manager routes (`/api/v1/sessions/managed/*`).
///
/// Why: the managed-session API is a cohesive cluster (spawn, list, get,
/// send, answer, attach-cmd, stop/runtime-stop/resume/decommission, activity).
/// The handlers live in [`crate::daemon::managed_routes`]; importing them here
/// lets `router` refer to them unqualified, mirroring the `coordinator_routes`
/// wiring.
use super::managed_routes::{
    adopt_existing_session, answer_session_decision, decommission_ephemeral_route,
    decommission_managed_session, delete_managed_session, fleet_by_project_route, get_attach_cmd,
    get_managed_session, get_project_registry_route, get_session_activity, list_managed_sessions,
    list_projects_registry_route, patch_project_registry_route, project_status_route, proxy_router,
    prune_managed_route, prune_worktrees_route, reactivate_managed_session,
    register_project_registry_route, rename_managed_session, resume_managed_session,
    send_to_session, spawn_session, stop_managed_session, stop_managed_session_runtime,
};
// Layer-3 portfolio manager surface (`/api/v1/manager/*`, epic #2109, DOC-36).
use super::manager::manager_router;

/// Typed HTTP response bodies for every endpoint.
///
/// Why: keeping the response structs in their own module keeps `api.rs`
/// focused on routing and handler logic. Re-exported so handlers and tests
/// refer to them as `super::api::<Type>`.
pub mod types;
pub use types::*;

/// Build the daemon's HTTP router with shared state injected.
///
/// Why: one place wires every route so `main` stays a thin bootstrap.
/// What: returns an axum `Router` already carrying `Arc<DaemonState>`.
/// Test: `health_endpoint_responds` and the hook-relay tests call handlers via
/// this router's logic.
pub fn router(state: Arc<DaemonState>) -> Router {
    let router = Router::new()
        .route("/health", get(health))
        .route("/sessions", get(list_sessions).post(register_session))
        .route("/api/v1/sessions/connect", post(connect_session))
        .route("/sessions/dead", axum::routing::delete(reap_sessions))
        .route("/sessions/discover", post(discover_sessions))
        .route("/sessions/{id}", get(get_session).delete(remove_session))
        .route("/sessions/{id}/events", get(stream_session_events))
        .route("/sessions/{id}/events/poll", get(session_events))
        .route("/sessions/{id}/pause", post(pause_session))
        .route("/sessions/{id}/resume", post(resume_session))
        .route("/sessions/{id}/command", post(send_command))
        .route("/sessions/{id}/output", get(get_output))
        .route("/sessions/{id}/pane", get(get_output))
        .route("/sessions/{id}/pid", axum::routing::patch(set_session_pid))
        .route("/projects", get(list_projects).post(register_project))
        .route("/projects/current", get(current_project))
        .route("/projects/discover", get(discover_projects))
        // #2114 (DOC-35 §4): registry-B project HTTP surface — the plain-HTTP
        // list/register/get/patch the deterministic `tm projects` CLI (#2115)
        // and `tm projects config` field editor (#2120) call, mirroring the
        // `project_list`/`project_register`/`project_get` MCP tools. The bare
        // `/api/v1/projects` collection route is registered here; the `{name}`
        // point-lookup/PATCH sits below the more specific `/status` and
        // `/deliverables` param routes (matchit disambiguates by segment count).
        .route(
            "/api/v1/projects",
            get(list_projects_registry_route).post(register_project_registry_route),
        )
        .route(
            "/api/v1/projects/{name}",
            get(get_project_registry_route).patch(patch_project_registry_route),
        )
        // #2117 (DOC-35 §4.1): deterministic project status-aggregation rollup —
        // session-state histogram + most-recent activity + config-completeness
        // flags for ONE named project. Pure composition over ProjectRegistry::get
        // + SessionManager::list; no LLM, no cross-project reasoning (§11).
        .route("/api/v1/projects/{name}/status", get(project_status_route))
        .route("/events", get(stream_events))
        .route("/events/poll", get(recent_events))
        .route("/hooks", post(ingest_hook))
        .route("/breakers", get(breakers))
        .route("/optimizer", get(get_optimizer))
        .route("/overseer", get(get_overseer))
        .route("/llm/chat", post(llm_chat))
        // #1392: the cross-session coordinator surface lives under the plural
        // `/api/v1/sessions/*` namespace (unified with `/sessions/managed/*`);
        // the former `/api/v1/coordinator/*` paths are removed. The descriptive
        // leaves (`context`, `chat`) avoid colliding with the managed-session
        // routes below.
        .route("/api/v1/sessions/context", get(coordinator_context))
        // Use the shared `COORDINATOR_CHAT_PATH` constant so the registered route
        // and the browser Web adapter (`web_chat.html`) can never drift (#1503).
        .route(COORDINATOR_CHAT_PATH, post(coordinator_chat))
        // DOC-14 D0.1: `/session-manager/*` aliases resolve to the SAME handlers
        // as the session context/chat endpoints; these are the canonical SM names.
        .route("/api/v1/session-manager/context", get(coordinator_context))
        .route("/api/v1/session-manager/chat", post(coordinator_chat))
        // DOC-20 §6 Phase 2 / ONB-3: the Web control surface — a static browser
        // chat page that drives the action-capable `/api/v1/sessions/chat`
        // endpoint above. Thin adapter; no new session logic.
        .route("/web", get(web_chat_page))
        .route("/tmux/sessions", get(list_tmux_sessions))
        .route("/tmux/sessions/{name}/snapshot", get(tmux_snapshot))
        .route("/tmux/adopt", post(adopt_tmux_session))
        .route("/claude-config", get(get_claude_config))
        .route("/claude-config/apply", post(apply_claude_config))
        .route("/claude-config/restart", post(restart_claude_code))
        .route(
            "/claude-config/checkpoints",
            get(list_checkpoints).post(create_checkpoint),
        )
        .route(
            "/claude-config/checkpoints/{id}",
            axum::routing::delete(delete_checkpoint),
        )
        .route("/claude-config/restore", post(restore_checkpoint))
        .route("/claude-config/profiles", get(list_profiles))
        .route("/claude-config/deploy", post(deploy_profile))
        .route("/pair/request", post(pair_request))
        .route("/pair/confirm", post(pair_confirm))
        .route("/pair/status", get(pair_status))
        .route("/pair/reset", post(pair_reset))
        .route("/api/v1/doctor", get(doctor))
        .route("/api/v1/errors", get(list_errors))
        .route("/api/v1/report-bug", post(report_bug_http))
        // WI-2 #1593: SESSCTL control-plane routes. All literal `/run` and
        // `/{id}/*` leaves are registered before any `managed/{id}` param route
        // so they are never captured as managed-session IDs.
        .route("/api/v1/control/sessions", get(ctl_list_sessions))
        .route("/api/v1/control/sessions/run", post(ctl_run_session))
        .route(
            "/api/v1/control/sessions/{id}/connect",
            post(ctl_connect_session),
        )
        .route("/api/v1/control/sessions/{id}/stop", post(ctl_stop_session))
        .route("/api/v1/control/sessions/{id}/auth", get(ctl_auth_session))
        .route(
            "/api/v1/sessions/managed",
            post(spawn_session).get(list_managed_sessions),
        )
        // #1433: adopt an EXISTING tmux session into the managed store. The literal
        // `/adopt` segment is registered BEFORE the `/{id}` param route so it is
        // never captured as an id (axum prefers literal matches, but ordering here
        // makes the intent explicit). Distinct from the stateless `/tmux/adopt`.
        .route(
            "/api/v1/sessions/managed/adopt",
            post(adopt_existing_session),
        )
        // #1508: bulk teardown + by-state prune. Both literal segments are
        // registered BEFORE the `/{id}` param route so they are never captured as
        // an id (mirroring the `/adopt` ordering above).
        .route(
            "/api/v1/sessions/managed/decommission-ephemeral",
            post(decommission_ephemeral_route),
        )
        .route("/api/v1/sessions/managed/prune", post(prune_managed_route))
        // #1840: orphaned worktree prune. Literal segment registered BEFORE the
        // `/{id}` param route.
        .route(
            "/api/v1/sessions/managed/prune-worktrees",
            post(prune_worktrees_route),
        )
        // #1586: fleet-by-project view. Literal `/fleet` registered BEFORE the
        // `/{id}` param route so it is never captured as an id (axum prefers
        // literal matches, but ordering makes the intent explicit).
        .route(
            "/api/v1/sessions/managed/fleet",
            get(fleet_by_project_route),
        )
        .route(
            "/api/v1/sessions/managed/{id}",
            get(get_managed_session)
                .delete(stop_managed_session)
                .patch(rename_managed_session),
        )
        .route("/api/v1/sessions/managed/{id}/send", post(send_to_session))
        // #2605: async-spawn progress poll route, merged as a sub-router.
        .merge(super::managed_routes::provision_status::router())
        // #2444: per-session + fleet-wide asset re-sync
        // (`tm sessions sync-assets <id>|--all`), merged as a sub-router —
        // mirrors the `provision_status::router()` merge immediately above.
        .merge(super::managed_routes::sync_assets::router())
        .route(
            "/api/v1/sessions/managed/{id}/answer",
            post(answer_session_decision),
        )
        .route(
            "/api/v1/sessions/managed/{id}/attach-cmd",
            get(get_attach_cmd),
        )
        .route(
            "/api/v1/sessions/managed/{id}/activity",
            get(get_session_activity),
        )
        .route(
            "/api/v1/sessions/managed/{id}/runtime-stop",
            post(stop_managed_session_runtime),
        )
        .route(
            "/api/v1/sessions/managed/{id}/resume",
            post(resume_managed_session),
        )
        // #2023 C: bare-`tm` in-pane relaunch flips Stopped -> Active in place,
        // with NO tmux mutation — distinct from `/resume` above, which always
        // kills any surviving tmux session and creates a fresh one.
        .route(
            "/api/v1/sessions/managed/{id}/reactivate",
            post(reactivate_managed_session),
        )
        .route(
            "/api/v1/sessions/managed/{id}/decommission",
            post(decommission_managed_session),
        )
        // #2012: hard-delete the session RECORD (distinct from decommission,
        // which stops/reaps). Fail-closed unless `?force=true`.
        .route(
            "/api/v1/sessions/managed/{id}/delete",
            post(delete_managed_session),
        )
        // TELUI-6 (#1440): the local session-manager PROXY surface — the SAME
        // focus/inject/summarize state machine every channel (Telegram, Slack)
        // binds to, exposed here as plain HTTP so it is `curl`-testable before
        // any channel connects. Registrations live with their handlers in
        // `managed_routes::proxy::proxy_router` and are merged here.
        .merge(proxy_router())
        // Layer-3 portfolio manager surface (epic #2109, DOC-36 §3.2), merged as a
        // self-contained sub-router (like the L2 `proxy_router`). Phase 1: the
        // read-only capabilities stub, deterministic rollup, LLM digest, and chat
        // loop. Phase 2: `route-task` (advisory task routing + disambiguation,
        // #2585) and `act` (session launch/inject/summarize propose→confirm, which
        // mutates ONLY on an explicit `confirm: true`, #2586). All curl-testable
        // with no channel/bot token (§4).
        .merge(manager_router())
        // DOC-35 §10.2/§10.5 (#2378 + #2380): Deliverable/Milestone CRUD, nested
        // under the projects namespace. The literal `/deliverables` and
        // `/milestones` segments come before their `/{id}` param routes so a
        // collection request is never captured as an id (mirroring the managed-
        // session `/adopt` ordering above). PATCH on `/{id}` is the `set-status`
        // seam that enforces the §10.3 state machine.
        .route(
            "/api/v1/projects/{name}/deliverables",
            get(list_deliverables).post(create_deliverable),
        )
        .route(
            "/api/v1/projects/{name}/deliverables/{id}",
            get(get_deliverable).patch(patch_deliverable),
        )
        .route(
            "/api/v1/projects/{name}/milestones",
            get(list_milestones).post(create_milestone),
        )
        .route(
            "/api/v1/projects/{name}/milestones/{id}",
            get(get_milestone).patch(patch_milestone),
        )
        // #1221: loopback-only JSON-RPC dispatch for the `serve --stdio` bridge.
        // The handler independently enforces the loopback gate via ConnectInfo.
        .route("/rpc", post(rpc_handler));

    // Gate the in-process Swagger UI explorer behind the `swagger-ui` feature
    // so offline / air-gapped builds (which cannot fetch the swagger-ui static
    // assets) compile cleanly with `--features daemon` alone (closes #1694).
    #[cfg(feature = "swagger-ui")]
    let router = router.merge(
        SwaggerUi::new("/api-docs")
            .url("/api-docs/openapi.json", super::openapi::ApiDoc::openapi()),
    );

    router.with_state(state)
}

/// Liveness probe plus the HR-3 catalog-staleness signal.
///
/// Why: liveness alone is not enough for an autonomous runner — DOC-17 §HR-3
/// requires `/health` to SURFACE whether the deployed harness content has drifted
/// from the synced catalog so the TUI can show an indicator and the operator can
/// rebuild. The check is a cheap, offline SHA compare (no network pull on the
/// hot path), and an unreachable/never-synced catalog degrades to `unknown`
/// (never `stale`, never an error) per the spec's "never block" rule. Also
/// surfaces the #2486 `supervised` restart-race signal — a 200 response here
/// does NOT by itself prove launchd owns this process; check `supervised` too.
/// Also surfaces the #2332 `version` field so `tm doctor` can detect a
/// stale, unrestarted daemon running older code than the installed binary.
/// What: returns `status: "ok"` with `catalog_stale`/`catalog_unknown` flags, a
/// small `catalog_changes` summary computed via
/// [`crate::core::update_check::detect_for_framework`] anchored at the daemon's
/// framework root, `supervised` read from `state` (set once at daemon startup
/// by `daemon_run::run_daemon`), and `version` (this process's
/// `CARGO_PKG_VERSION`, a compile-time constant).
/// Test: `health_reports_ok_status`, `health_reports_catalog_unknown_without_catalog`,
/// `health_response_serializes_supervised_field`,
/// `health_response_serializes_version_field`.
#[utoipa::path(
    get,
    path = "/health",
    tag = "config",
    responses((status = 200, description = "Daemon is alive", body = HealthResponse))
)]
pub async fn health(State(state): State<Arc<DaemonState>>) -> Json<HealthResponse> {
    let fw = crate::core::paths::FrameworkPaths::from_root(state.framework_root());
    // The daemon-wide baseline uses the framework root as the "project" so only
    // the user/catalog/default manifest layers apply (no per-project override).
    let report = crate::core::update_check::detect_for_framework(&fw, state.framework_root());
    Json(HealthResponse {
        status: "ok".to_owned(),
        catalog_stale: report.stale,
        catalog_unknown: report.unknown,
        catalog_changes: report.summary_lines(),
        supervised: state.supervised(),
        version: env!("CARGO_PKG_VERSION").to_owned(),
    })
}

/// Query parameters for `GET /sessions`.
///
/// Why: `trusty-mpm session list` scopes the listing to one project; an
/// optional `?project=<path>` filter keeps the endpoint usable both ways.
/// What: an optional project path; when absent, all sessions are returned.
/// Test: `list_sessions_filters_by_project`.
#[derive(serde::Deserialize, Default)]
pub struct SessionQuery {
    /// Optional project path to filter sessions by.
    pub project: Option<PathBuf>,
}

/// `GET /sessions` — snapshot of managed sessions, optionally project-scoped.
#[utoipa::path(
    get,
    path = "/sessions",
    tag = "sessions",
    params(("project" = Option<String>, Query, description = "Filter by project path")),
    responses((status = 200, description = "Array of managed sessions", body = [Session]))
)]
pub async fn list_sessions(
    State(state): State<Arc<DaemonState>>,
    Query(query): Query<SessionQuery>,
) -> Json<SessionsResponse> {
    let sessions = match query.project {
        Some(path) => state.list_sessions_for_project(&path),
        None => state.list_sessions(),
    };
    Json(SessionsResponse { sessions })
}

/// `GET /events/poll` — JSON snapshot of recent hook events (legacy / fallback).
///
/// Why: SSE-incapable clients (curl in a one-shot script, legacy CLI tooling)
/// still need a way to read the ring buffer. The push-based feed lives at
/// `GET /events`; this endpoint preserves the original synchronous-snapshot
/// contract under a `/poll` suffix for backward compatibility.
/// What: returns the bounded ring buffer of recent [`HookEventRecord`]s as
/// `{ "events": [...] }`.
/// Test: covered transitively by `hook_relay_ingests_known_event`.
#[utoipa::path(
    get,
    path = "/events/poll",
    tag = "events",
    responses((status = 200, description = "Recent hook events across all sessions"))
)]
pub async fn recent_events(State(state): State<Arc<DaemonState>>) -> Json<EventsResponse> {
    Json(EventsResponse {
        events: state.recent_hook_events(),
    })
}

/// `GET /events` — live SSE stream of every hook event.
///
/// Why: the GUI and other real-time consumers need push notifications when
/// hook events arrive rather than polling `GET /events/poll`. SSE is the
/// lowest-friction streaming protocol for browser and `reqwest` clients —
/// plain HTTP, no upgrade dance, automatic reconnection in the browser.
/// What: subscribes to the daemon's broadcast channel and streams each event
/// as one SSE `data:` line (JSON-encoded). A `KeepAlive` comment ping every
/// 15 seconds prevents idle proxies from closing the connection.
/// Test: `events_sse_streams_one_frame` posts a hook and reads one SSE frame
/// from this handler.
pub async fn stream_events(
    State(state): State<Arc<DaemonState>>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
    let rx = state.event_subscribe();
    let stream = BroadcastStream::new(rx).filter_map(|result| match result {
        Ok(val) => Some(Ok(Event::default().data(val.to_string()))),
        // Lagged / dropped frames are skipped — the channel intentionally
        // sheds load when a subscriber falls behind.
        Err(_) => None,
    });
    Sse::new(stream).keep_alive(
        KeepAlive::new()
            .interval(Duration::from_secs(15))
            .text("ping"),
    )
}

/// JSON body for registering a session via `POST /sessions`.
///
/// Why: a session created by an external launcher (or the CLI) must announce
/// itself so the dashboard and MCP tools can see it. The GUI's "New Session"
/// button additionally needs the daemon to *spawn* a fresh session — create
/// the tmux host and start `claude` — without going through the CLI; that
/// path is opted into by supplying `workdir`.
/// What: the project directory the session runs in, plus an optional project
/// association, an optional caller-supplied tmux session name, and an
/// optional `workdir` that switches the request from registration-only to
/// spawn mode.
/// Test: `register_and_remove_session` (registration-only),
/// `spawn_session_without_claude_returns_422` (spawn mode failure path),
/// `spawn_session_without_tmux_returns_422` (spawn mode no-tmux path).
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct RegisterSession {
    /// Project directory the session was launched in (the session's working
    /// directory). Named `project` to match the CLI `project` argument.
    pub project: String,
    /// Optional project this session belongs to. When present, the session is
    /// associated with that registered project so `session list` can scope to
    /// it.
    #[serde(default)]
    #[schema(value_type = Option<String>)]
    pub project_path: Option<PathBuf>,
    /// Optional caller-supplied tmux session name.
    ///
    /// Why: the CLI computes a `tm-<folder>` name from the project directory
    /// and creates the tmux session under that name; passing it here keeps the
    /// daemon registry's `tmux_name` consistent with the live tmux session.
    /// What: when present and non-empty it is used as the session's
    /// `tmux_name`; when absent the daemon derives one itself.
    #[serde(default)]
    pub name: Option<String>,
    /// Optional working directory in which to spawn a brand-new session.
    ///
    /// Why: the GUI's "New Session" button needs the daemon to create the
    /// tmux host and start `claude` itself — going through the CLI is not an
    /// option from a browser. Presence of `workdir` is the explicit opt-in
    /// for spawn semantics; absence preserves the registration-only behaviour
    /// every other caller (the CLI, `session start`, the hook auto-register
    /// path) depends on.
    /// What: when present, the daemon creates a tmux session in this
    /// directory and launches `claude` inside it; when absent, only
    /// bookkeeping happens.
    /// Test: `spawn_session_without_claude_returns_422`,
    /// `spawn_session_without_tmux_returns_422`.
    #[serde(default)]
    #[schema(value_type = Option<String>)]
    pub workdir: Option<PathBuf>,
}

/// `POST /sessions` — register a new managed session, returning its id.
///
/// Why: registering a session is pure bookkeeping — it records that a session
/// exists so the dashboard and MCP tools can see it. In addition, the GUI's
/// "New Session" button needs the daemon to actually *spawn* a fresh session
/// (create the tmux host and start `claude`) without going through the CLI;
/// supplying `workdir` switches the request into that spawn mode. Sessions
/// without `workdir` are still recorded as pure bookkeeping (no tmux window
/// is created — that behaviour is unchanged so the existing CLI and hook
/// auto-registration paths keep working).
/// What: builds the `Session` record and registers it in state. When `workdir`
/// is present, additionally creates the tmux session via [`TmuxService::spawn_claude`]
/// and starts `claude` in it; failures map to HTTP 422 (`claude` missing or
/// tmux missing) or 500 (tmux command failed). On success the new session
/// appears immediately in `GET /sessions`.
/// Test: `register_and_remove_session` covers the bookkeeping path;
/// `spawn_session_without_claude_returns_422` and
/// `spawn_session_without_tmux_returns_422` cover the spawn-mode error paths;
/// `spawn_session_registers_session_when_possible` covers the happy/observable
/// state path.
#[utoipa::path(
    post,
    path = "/sessions",
    tag = "sessions",
    request_body = RegisterSession,
    responses(
        (status = 201, description = "Session registered; returns its id and name"),
        (status = 422, description = "Spawn requested but `claude` binary or tmux is unavailable"),
        (status = 500, description = "tmux command failed while creating the session"),
    )
)]
pub async fn register_session(
    State(state): State<Arc<DaemonState>>,
    Json(body): Json<RegisterSession>,
) -> Result<Json<RegisterSessionResponse>, DaemonError> {
    // Derive the tmux name from the project directory (`tm-<folder>`) so the
    // registry name matches the folder-based session the CLI creates. A
    // caller-supplied `name` always wins; otherwise fall back to the UUID name.
    let project_dir = body.project_path.as_deref();
    let mut session = Session::new(
        SessionId::new(),
        body.project.clone(),
        ControlModel::Tmux,
        project_dir,
    );
    session.project_path = body.project_path.clone();
    if let Some(name) = body.name.as_deref().filter(|n| !n.is_empty()) {
        session.tmux_name = name.to_string();
    }
    if let Some(workdir) = body.workdir.as_deref() {
        // Mirror the workdir onto the session record so the dashboard and the
        // reaper see the spawn directory, not the project label. The
        // `Session::workdir` field is the per-session working directory; the
        // `project` field is a label / association.
        session.workdir = workdir.to_string_lossy().into_owned();
    }

    // Spawn mode: create the tmux host and start `claude` *before* the session
    // is registered, so a 422 or 500 leaves the registry untouched. This
    // matches the standard HTTP contract — a failed POST should not leave a
    // half-created resource visible to subsequent GETs.
    if let Some(workdir) = body.workdir.as_deref() {
        TmuxService::spawn_claude(&session.tmux_name, workdir)?;
        // The session is now actively running `claude`; mark it Active so the
        // dashboard reflects reality rather than the default `Starting` state.
        session.status = SessionStatus::Active;
    }

    let id = session.id;
    let tmux_name = session.tmux_name.clone();
    state.register_session(session);

    // Discover the `claude` PID inside the registered tmux pane in the
    // background so the reaper can monitor process liveness. This is the
    // daemon-side counterpart of the CLI's post-launch PID capture; it does not
    // block the response, and a failure is logged, never fatal.
    super::services::session_service::spawn_pid_capture(Arc::clone(&state), id, tmux_name.clone());

    Ok(Json(RegisterSessionResponse {
        id,
        name: tmux_name,
    }))
}

/// `POST /api/v1/sessions/connect` — register a session for a *connect* (no
/// deployment) launch.
///
/// Why: `tm connect` deliberately skips the framework-deployment sequence that
/// `tm launch` runs (instructions, agents, skills) — it only wants the daemon
/// to start or attach to the tmux-hosted session. The deployment work lives in
/// the client/CLI, not the daemon, so the daemon-side bookkeeping for a
/// `connect` is identical to `register_session`: it records that the session
/// exists. A distinct endpoint keeps the two intents observable on the wire and
/// gives `connect` its own seam should the daemon need to diverge later.
/// What: delegates to [`register_session`] — the daemon does no deployment in
/// either path, so the registration body and response are the same.
/// Test: `connect_session_registers_without_deploy` in `api_tests.rs`.
#[utoipa::path(
    post,
    path = "/api/v1/sessions/connect",
    tag = "sessions",
    request_body = RegisterSession,
    responses((status = 201, description = "Session registered for connect; returns its id and name"))
)]
pub async fn connect_session(
    state: State<Arc<DaemonState>>,
    body: Json<RegisterSession>,
) -> Result<Json<RegisterSessionResponse>, DaemonError> {
    register_session(state, body).await
}

/// `GET /sessions/:id` — fetch a single session's detail.
///
/// Why: clients need to fetch one session without paging through the full
/// `GET /sessions` list — avoids over-fetching and simplifies client state
/// management when the caller already knows the session id.
/// What: parses `id` as a UUID, looks the session up in [`DaemonState`], and
/// returns it as JSON. A malformed id is a `400`; an unknown id is a `404`.
/// Test: `get_session_returns_session`, `get_session_unknown_is_404`.
#[utoipa::path(
    get,
    path = "/sessions/{id}",
    tag = "sessions",
    params(("id" = String, Path, description = "Session UUID")),
    responses(
        (status = 200, description = "Session detail", body = Session),
        (status = 400, description = "Malformed session id"),
        (status = 404, description = "No session with that id"),
    )
)]
pub async fn get_session(
    State(state): State<Arc<DaemonState>>,
    Path(id): Path<String>,
) -> Result<Json<Session>, DaemonError> {
    let session_id = parse_id(&id)?;
    state
        .session(session_id)
        .map(Json)
        .ok_or(DaemonError::SessionNotFound { id })
}

/// `DELETE /sessions/:id` — deregister a session AND kill its tmux host.
///
/// Why: a DELETE must actually tear the session down — dropping only the
/// registry entry leaks a live managed (`tm-*`/`tmpm-*`) tmux session on every call (the
/// `full_user_cycle` leak this closes, epic #1452, #1454). Every session is
/// owned by exactly one Session object; deleting it kills the tmux host too.
/// What: parses the UUID, removes the legacy registry entry (404 if absent),
/// then best-effort kills the tmux session by the entry's `tmux_name` and
/// reconciles the SessionManager store by decommissioning any managed record
/// that shares that exact `tmux_name`. Both teardown steps are best-effort:
/// the session may already be gone, so a kill/decommission failure is logged
/// (to stderr via `tracing` — never stdout, which carries MCP framing) and does
/// NOT fail the HTTP response. The registry removal having succeeded is the
/// contract the caller relies on.
/// Test: `full_user_cycle` asserts the tmux session is gone after DELETE when
/// tmux is available; `remove_session_kills_tmux_host` covers the kill call.
#[utoipa::path(
    delete,
    path = "/sessions/{id}",
    tag = "sessions",
    params(("id" = String, Path, description = "Session UUID")),
    responses(
        (status = 200, description = "Session removed and tmux host killed"),
        (status = 404, description = "No session with that id"),
    )
)]
pub async fn remove_session(
    State(state): State<Arc<DaemonState>>,
    Path(id): Path<String>,
) -> Result<Json<RemoveSessionResponse>, DaemonError> {
    let session = parse_id(&id)?;
    let removed = state
        .remove_session(session)
        .ok_or_else(|| DaemonError::SessionNotFound { id: id.clone() })?;

    // Best-effort teardown: kill the owning tmux host so the session does not
    // leak, then reconcile the SessionManager store for any record that shares
    // the same tmux name. Neither step fails the response — the registry entry
    // is already gone, which is the contract the DELETE promises.
    let tmux_name = removed.tmux_name.clone();
    TmuxService::kill_best_effort(&tmux_name);
    reconcile_managed_store_on_delete(&state, &tmux_name).await;

    Ok(Json(RemoveSessionResponse { removed: id }))
}

/// Decommission any SessionManager record that shares `tmux_name`, best-effort.
///
/// Why: the legacy `DaemonState` registry and the `SessionManager` store are two
/// registries that can both track a session under the same tmux name. A DELETE
/// against the legacy registry must not leave the managed store pointing at a
/// now-dead tmux host, or the orphan-GC and `ls` would show a phantom (#1454).
/// What: lists the managed store, finds records whose `tmux_name` matches and
/// that are not already terminal (Stopped/Decommissioned), and decommissions
/// each. Every step is best-effort: a store-read or decommission failure is
/// logged to stderr via `tracing` and swallowed so the HTTP DELETE still
/// succeeds. Idempotent — already-terminal records are skipped.
/// Test: exercised by `full_user_cycle`; the managed-store decommission path
/// itself is unit-tested in `session_manager::tests`.
async fn reconcile_managed_store_on_delete(state: &Arc<DaemonState>, tmux_name: &str) {
    let mgr = state.session_manager().await;
    for record in mgr.list().await {
        if record.tmux_name != tmux_name {
            continue;
        }
        if matches!(
            record.state,
            crate::session_manager::ManagedSessionState::Stopped
                | crate::session_manager::ManagedSessionState::Decommissioned
        ) {
            continue;
        }
        if let Err(e) = mgr.decommission(&record.id, None).await {
            tracing::warn!(
                name = %tmux_name,
                "DELETE reconcile: decommission of managed record failed (may already be gone): {e}"
            );
        }
    }
}

/// `DELETE /sessions/dead` — reap registry entries with no live tmux session.
///
/// Why: dead sessions accumulate forever otherwise; an operator (or a periodic
/// task) needs a way to prune the registry down to what tmux actually hosts.
/// What: discovers tmux, calls [`DaemonState::reap_dead_sessions`], and returns
/// `{ "removed": <count> }`. If tmux is unavailable nothing is reaped (returns
/// `0`) — reaping against an empty list would wrongly delete every session.
/// Test: `reap_dead_sessions` in `state.rs` covers the core logic.
#[utoipa::path(
    delete,
    path = "/sessions/dead",
    tag = "sessions",
    responses((status = 200, description = "Dead sessions reaped; returns the removed count"))
)]
pub async fn reap_sessions(State(state): State<Arc<DaemonState>>) -> Json<ReapResponse> {
    let result = SessionService::new(&state).reap();
    Json(ReapResponse {
        removed: result.reaped,
        stopped: result.stopped,
    })
}

/// JSON body for `PATCH /sessions/{id}/pid`.
///
/// Why: after launching `claude` inside a tmux pane the CLI discovers the real
/// process PID and reports it back so the daemon can monitor process liveness.
/// What: the OS-level `claude` process id.
/// Test: `set_session_pid_records_pid`.
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct SetPidRequest {
    /// OS-level `claude` process id discovered inside the session's tmux pane.
    pub pid: u32,
}

/// `PATCH /sessions/{id}/pid` — record the OS-level `claude` process PID.
///
/// Why: a tmux session can outlive the `claude` process inside it; tracking the
/// real PID lets the reaper detect a stopped session. The CLI (and the daemon's
/// own launch path) discover the PID a few seconds after `send-keys` and report
/// it here.
/// What: resolves the session by UUID, sets `session.pid`, and echoes the id
/// and PID. An unknown id is `404`.
/// Test: `set_session_pid_records_pid`, `set_session_pid_unknown_is_404`.
#[utoipa::path(
    patch,
    path = "/sessions/{id}/pid",
    tag = "sessions",
    params(("id" = String, Path, description = "Session UUID")),
    request_body = SetPidRequest,
    responses(
        (status = 200, description = "PID recorded for the session"),
        (status = 404, description = "No session with that id"),
    )
)]
pub async fn set_session_pid(
    State(state): State<Arc<DaemonState>>,
    Path(id): Path<String>,
    Json(body): Json<SetPidRequest>,
) -> Result<Json<SetPidResponse>, DaemonError> {
    let session = parse_id(&id)?;
    if state.set_session_pid(session, body.pid) {
        Ok(Json(SetPidResponse {
            session_id: id,
            pid: body.pid,
        }))
    } else {
        Err(DaemonError::SessionNotFound { id })
    }
}

/// `POST /sessions/discover` — auto-discover Claude Code sessions.
///
/// Why: `GET /sessions` only reports daemon-managed sessions; operators run
/// `claude` / `claude-code` / `claude-mpm` / `tm` in tmux panes — and, more
/// commonly, in native Terminal.app windows — that the daemon never created.
/// This endpoint scans both and registers the ones running Claude Code so they
/// appear in the dashboard and the Telegram bot.
/// What: runs [`super::discovery::discover_all`] (tmux panes plus native `ps`
/// processes) and returns `{ "discovered": <count>, "sessions": [name, ...] }`.
/// A missing tmux or `ps` yields a zero count rather than an error.
/// Test: `discover_sessions_returns_count` in `api_tests.rs`.
#[utoipa::path(
    post,
    path = "/sessions/discover",
    tag = "sessions",
    responses((status = 200, description = "tmux sessions running Claude Code, newly registered"))
)]
pub async fn discover_sessions(State(state): State<Arc<DaemonState>>) -> Json<DiscoverResponse> {
    let result = super::discovery::discover_all(&state).await;
    Json(DiscoverResponse {
        discovered: result.adopted,
        sessions: result.sessions,
    })
}

/// `GET /sessions/:id/events/poll` — JSON snapshot of one session's hook events.
///
/// Why: the push-based per-session feed lives at `GET /sessions/{id}/events`;
/// this endpoint preserves the original synchronous-snapshot contract under
/// a `/poll` suffix for clients that cannot or do not need to stream.
/// What: parses the session id and returns the bounded ring buffer filtered
/// to that session.
/// Test: covered transitively by the `state::tests::hook_history_is_bounded`
/// test, which exercises the underlying `hook_events_for` query.
#[utoipa::path(
    get,
    path = "/sessions/{id}/events/poll",
    tag = "events",
    params(("id" = String, Path, description = "Session UUID")),
    responses(
        (status = 200, description = "Recent hook events for the session"),
        (status = 404, description = "No session with that id"),
    )
)]
pub async fn session_events(
    State(state): State<Arc<DaemonState>>,
    Path(id): Path<String>,
) -> Result<Json<EventsResponse>, DaemonError> {
    let session = parse_id(&id)?;
    Ok(Json(EventsResponse {
        events: state.hook_events_for(session),
    }))
}

/// `GET /sessions/{id}/events` — live SSE stream of one session's hook events.
///
/// Why: per-session event filtering lets the GUI subscribe to one session's
/// activity without receiving noise from every other concurrent session.
/// What: subscribes to the broadcast channel and forwards only events whose
/// serialized JSON contains the session id (matching the
/// `"session": "<uuid>"` field every [`HookEventRecord`] carries). A 15-second
/// `KeepAlive` ping keeps idle proxies from closing the connection.
/// Test: `session_events_sse_filters_by_session` posts a hook for one
/// session and confirms only that session's stream sees it.
pub async fn stream_session_events(
    Path(id): Path<String>,
    State(state): State<Arc<DaemonState>>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
    let rx = state.event_subscribe();
    let stream = BroadcastStream::new(rx).filter_map(move |result| match result {
        Ok(val) => {
            // Include the event only if it mentions this session id. The
            // record serializes its `SessionId` as the UUID string, so a
            // simple substring match is sufficient and avoids a typed parse
            // per frame.
            if val.to_string().contains(&id) {
                Some(Ok(Event::default().data(val.to_string())))
            } else {
                None
            }
        }
        Err(_) => None,
    });
    Sse::new(stream).keep_alive(
        KeepAlive::new()
            .interval(Duration::from_secs(15))
            .text("ping"),
    )
}

/// Result of applying an optional compression level to captured output.
///
/// Why: the command and output endpoints share the same compress-then-return
/// shape; bundling the text and stats lets one helper produce both.
/// What: the (possibly compressed) text, the byte stats, and the level as a
/// lowercase wire string (`None` when no compression was applied).
/// Test: `apply_compression_off_is_passthrough`, `apply_compression_summarise`.
struct CompressedOutput {
    /// The output text after compression (or unchanged when off).
    text: String,
    /// Byte counts before and after compression.
    stats: crate::core::compress::CompressionStats,
    /// Lowercase wire name of the level applied, or `None` when uncompressed.
    level_label: Option<String>,
}

/// Apply an optional compression level to captured pane output.
///
/// Why: `POST .../command` and `GET .../output` both accept an optional
/// `?compress=` query param; doing the compress-or-passthrough decision once
/// keeps the two handlers identical.
/// What: when `level` is `Some`, runs [`compress_output`] and records the
/// level's lowercase label; when `None`, returns the raw text with empty stats
/// and no label.
/// Test: `apply_compression_off_is_passthrough`, `apply_compression_summarise`.
fn apply_compression(level: Option<CompressionLevel>, raw: &str) -> CompressedOutput {
    match level {
        Some(level) => {
            let (text, stats) = crate::core::compress::compress_output(raw, level);
            CompressedOutput {
                text,
                stats,
                level_label: Some(compression_level_label(level)),
            }
        }
        None => CompressedOutput {
            text: raw.to_string(),
            stats: crate::core::compress::CompressionStats::default(),
            level_label: None,
        },
    }
}

/// Lowercase wire name for a [`CompressionLevel`].
///
/// Why: API responses report the applied level as a stable lowercase string,
/// matching the `snake_case` serde representation of the enum.
/// What: maps each variant to its `serde` wire name.
/// Test: `compress_level_label_matches_serde`.
fn compression_level_label(level: CompressionLevel) -> String {
    match level {
        CompressionLevel::Off => "off",
        CompressionLevel::Trim => "trim",
        CompressionLevel::Summarise => "summarise",
        CompressionLevel::Caveman => "caveman",
    }
    .to_string()
}

/// JSON body for `POST /sessions/{id}/pause`.
///
/// Why: a pause may carry an optional operator note describing where the
/// session was left off; when absent the daemon derives one from pane output.
/// What: an optional free-form summary string.
/// Test: `pause_then_resume_round_trips`.
#[derive(serde::Deserialize, utoipa::ToSchema, Default)]
pub struct PauseRequest {
    /// Optional note about where the session was left off.
    #[serde(default)]
    pub summary: Option<String>,
}

/// `POST /sessions/{id}/pause` — pause a session, saving its state for resume.
///
/// Why: an operator stepping away needs the session frozen with a "where I left
/// off" note that survives a daemon restart.
/// What: resolves the session by UUID or friendly name, captures the last 50
/// pane lines, sets `status = Paused` / `paused_at = now` / `pause_summary`
/// (the request note, or the first 500 chars of the `Summarise`-compressed
/// captured output), and mirrors the pause record to disk via
/// `session_store::save_pause`.
/// Test: `pause_then_resume_round_trips`, `pause_unknown_session_is_404`.
#[utoipa::path(
    post,
    path = "/sessions/{id}/pause",
    tag = "sessions",
    params(("id" = String, Path, description = "Session UUID or friendly name")),
    request_body = PauseRequest,
    responses(
        (status = 200, description = "Session paused; returns the pause summary"),
        (status = 404, description = "No session with that id or name"),
    )
)]
pub async fn pause_session(
    State(state): State<Arc<DaemonState>>,
    Path(id): Path<String>,
    Json(body): Json<PauseRequest>,
) -> Result<Json<PauseResponse>, DaemonError> {
    let result = SessionService::new(&state).pause(&id, body.summary)?;
    Ok(Json(PauseResponse {
        paused: true,
        session_id: result.session_id,
        summary: result.summary,
    }))
}

/// `POST /sessions/{id}/resume` — resume a previously-paused session.
///
/// Why: the counterpart to pause; clears the frozen state and the on-disk
/// pause record so the session is active again.
/// What: resolves the session, requires `status == Paused` (else `409`), sets
/// `status = Active` / `paused_at = None` / `pause_summary = None`, and removes
/// the pause file via `session_store::clear_pause`.
/// Test: `pause_then_resume_round_trips`, `resume_unpaused_session_is_409`.
#[utoipa::path(
    post,
    path = "/sessions/{id}/resume",
    tag = "sessions",
    params(("id" = String, Path, description = "Session UUID or friendly name")),
    responses(
        (status = 200, description = "Session resumed"),
        (status = 404, description = "No session with that id or name"),
        (status = 409, description = "Session is not paused"),
    )
)]
pub async fn resume_session(
    State(state): State<Arc<DaemonState>>,
    Path(id): Path<String>,
) -> Result<Json<ResumeResponse>, DaemonError> {
    SessionService::new(&state).resume(&id)?;
    Ok(Json(ResumeResponse { resumed: true }))
}

/// JSON body for `POST /sessions/{id}/command`.
///
/// Why: feeding a command into a session's tmux pane is how the operator (and
/// the Telegram bot) drives Claude Code remotely.
/// What: the command line to type into the pane.
/// Test: `send_command_returns_output_shape`.
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct CommandRequest {
    /// The command line to send to the session's tmux pane.
    pub command: String,
}

/// Query parameters for `POST /sessions/{id}/command`.
///
/// Why: the caller may want the captured output summarised before it returns,
/// completing the "summarize output" step of the full user cycle.
/// What: an optional compression level (`off`, `trim`, `summarise`,
/// `caveman`); when absent the raw pane capture is returned unchanged.
/// Test: `send_command_compress_query_defaults_off`.
#[derive(serde::Deserialize, Default)]
pub struct CommandQuery {
    /// Compression level to apply to the captured output before returning.
    /// Values: off, trim, summarise, caveman. Defaults to none (raw output).
    #[serde(default)]
    pub compress: Option<CompressionLevel>,
}

/// `POST /sessions/{id}/command` — send a command to a session's tmux pane.
///
/// Why: remote control of a running session — type a line, let it run, read
/// back what happened.
/// What: resolves the session (`404` if missing, `409` if `Stopped`), sends the
/// command via `TmuxDriver::send_line`, waits 500ms for output to settle, then
/// captures the last 100 pane lines. When `?compress=` is supplied the capture
/// is compressed at that level before returning. tmux errors are logged, not
/// fatal — the endpoint still returns `200` with whatever output was captured.
/// Test: `send_command_returns_output_shape`, `command_to_stopped_session_is_409`.
#[utoipa::path(
    post,
    path = "/sessions/{id}/command",
    tag = "sessions",
    params(
        ("id" = String, Path, description = "Session UUID or friendly name"),
        ("compress" = Option<String>, Query, description = "Compression level: off, trim, summarise, caveman"),
    ),
    request_body = CommandRequest,
    responses(
        (status = 200, description = "Command sent; returns captured pane output"),
        (status = 404, description = "No session with that id or name"),
        (status = 409, description = "Session is stopped"),
    )
)]
pub async fn send_command(
    State(state): State<Arc<DaemonState>>,
    Path(id): Path<String>,
    Query(query): Query<CommandQuery>,
    Json(body): Json<CommandRequest>,
) -> Result<Json<CommandResponse>, DaemonError> {
    let session = SessionService::new(&state).command_target(&id)?;
    TmuxService::send_command(&session, &body.command);

    // Give the pane a moment to render the command's output before capturing.
    tokio::time::sleep(std::time::Duration::from_millis(500)).await;
    let raw = TmuxService::capture(&session, 100);
    let compressed = apply_compression(query.compress, &raw);

    Ok(Json(CommandResponse {
        sent: true,
        output: compressed.text,
        original_bytes: compressed.stats.original_bytes,
        compressed_bytes: compressed.stats.compressed_bytes,
        compress_level: compressed.level_label,
    }))
}

/// Query parameters for `GET /sessions/{id}/output`.
///
/// Why: the caller chooses how much scrollback to capture and whether to
/// summarise it; defaults keep the endpoint usable with no query string.
/// What: an optional line count (defaulting to 50 when absent) and an optional
/// compression level applied to the capture before returning.
/// Test: `get_output_returns_output_shape`, `output_query_defaults`.
#[derive(serde::Deserialize, Default)]
pub struct OutputQuery {
    /// Number of trailing pane lines to capture (default 50 when absent).
    #[serde(default)]
    pub lines: Option<u32>,
    /// Compression level to apply to the captured output before returning.
    /// Values: off, trim, summarise, caveman. Defaults to none (raw output).
    #[serde(default)]
    pub compress: Option<CompressionLevel>,
}

/// Default trailing-line count for `GET /sessions/{id}/output`.
fn default_output_lines() -> u32 {
    50
}

/// `GET /sessions/{id}/output` — capture the current tmux pane output.
///
/// Why: the dashboard and the Telegram bot show a session's recent output
/// without sending it a command.
/// What: resolves the session (`404` if missing), captures the last `?lines=N`
/// pane lines (default 50), optionally compresses it at `?compress=`, and
/// returns `{ output, lines, original_bytes, compressed_bytes, compress_level }`.
/// tmux being unavailable yields an empty `output` rather than an error.
/// Test: `get_output_returns_output_shape`, `output_unknown_session_is_404`.
#[utoipa::path(
    get,
    path = "/sessions/{id}/output",
    tag = "sessions",
    params(
        ("id" = String, Path, description = "Session UUID or friendly name"),
        ("lines" = Option<u32>, Query, description = "Trailing lines to capture (default 50)"),
        ("compress" = Option<String>, Query, description = "Compression level: off, trim, summarise, caveman"),
    ),
    responses(
        (status = 200, description = "Captured pane output"),
        (status = 404, description = "No session with that id or name"),
    )
)]
pub async fn get_output(
    State(state): State<Arc<DaemonState>>,
    Path(id): Path<String>,
    Query(query): Query<OutputQuery>,
) -> Result<Json<OutputResponse>, DaemonError> {
    let session = SessionService::new(&state).resolve(&id)?;
    let lines = query.lines.unwrap_or_else(default_output_lines);
    let raw = TmuxService::capture(&session, lines);
    let compressed = apply_compression(query.compress, &raw);
    Ok(Json(OutputResponse {
        output: compressed.text,
        lines,
        original_bytes: compressed.stats.original_bytes,
        compressed_bytes: compressed.stats.compressed_bytes,
        compress_level: compressed.level_label,
    }))
}

/// `GET /breakers` — every agent's circuit-breaker state.
#[utoipa::path(
    get,
    path = "/breakers",
    tag = "config",
    responses((status = 200, description = "Array of per-agent circuit-breaker states"))
)]
pub async fn breakers(State(state): State<Arc<DaemonState>>) -> Json<BreakersResponse> {
    let breakers = state
        .all_breakers()
        .into_iter()
        .map(|(agent, breaker)| BreakerEntry { agent, breaker })
        .collect();
    Json(BreakersResponse { breakers })
}

/// JSON body for the universal hook relay endpoint.
///
/// Why: the forwarder shim posts raw Claude Code hook events here; a typed
/// body documents the contract.
/// What: session id, the Claude Code event name, and the opaque payload.
/// Test: `hook_relay_ingests_known_event`.
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct HookPost {
    /// Session the event came from (UUID string).
    pub session_id: String,
    /// Claude Code event, e.g. `PreToolUse`. Deserialization rejects any name
    /// that is not a known [`HookEvent`] variant, so an unknown event is a
    /// `400` before the handler runs.
    #[schema(value_type = String)]
    pub event: HookEvent,
    /// Raw event payload (shape varies per event).
    #[serde(default)]
    #[schema(value_type = Object)]
    pub payload: serde_json::Value,
}

/// `POST /hooks` — universal hook relay; ingests one Claude Code hook event.
///
/// Why: this is how the daemon achieves full observability — a forwarder shim
/// configured for *all* 32 hook events posts each one here. It is also the
/// enforcement point for the optional session overseer.
/// What: parses the session id and event name. On a `SessionStart` event for
/// an unknown session it auto-registers that session (this is how a claude
/// session announces itself to the daemon — connection-driven registration,
/// not `POST /sessions`). It then runs the overseer on tool-use events
/// (auditing every decision; a `Block` returns `403` early), compresses
/// `PostToolUse` output, then appends a `HookEventRecord` to the ring buffer.
/// Rejects malformed ids with `400`.
/// Test: `hook_relay_ingests_known_event`, `hook_relay_rejects_unknown_event`,
/// `overseer_blocks_pre_tool_use`, `session_start_auto_registers_session`.
#[utoipa::path(
    post,
    path = "/hooks",
    tag = "internal",
    request_body = HookPost,
    responses(
        (status = 200, description = "Hook event accepted"),
        (status = 400, description = "Unknown event name or malformed session id"),
        (status = 403, description = "Overseer blocked the event"),
    )
)]
pub async fn ingest_hook(
    State(state): State<Arc<DaemonState>>,
    Json(post): Json<HookPost>,
) -> Result<Json<HookAcceptedResponse>, DaemonError> {
    let session = parse_id(&post.session_id)?;

    // Auto-register on SessionStart if not already known. This is how a claude
    // session connects itself to the daemon: its first hook event registers it
    // using the incoming UUID, so discovery and `POST /sessions` are not the
    // only ways a session enters state. The workdir is left empty here and
    // enriched later by a snapshot or subsequent events.
    if post.event == HookEvent::SessionStart && state.session(session).is_none() {
        let mut new_session = Session::new(session, String::new(), ControlModel::Tmux, None);
        new_session.status = SessionStatus::Active;
        state.register_session(new_session);
        tracing::info!("auto-registered session on SessionStart: {session:?}");
    }

    // #1744: correlate Claude session id → managed session on SessionStart;
    // immediately mark the managed session Stopped on SessionEnd.
    if post.event == HookEvent::SessionStart {
        correlate_session_start(&state, &post.session_id, &post.payload).await;
    }
    if post.event == HookEvent::SessionEnd {
        handle_session_end(&state, &post.session_id).await;
    }

    // #2621 (code-critic MEDIUM): the idle-park surface+auto-nudge dispatch
    // lives inside `HookService::process` (daemon/services/hook_service.rs),
    // not here — this grandfathered handler is already at its line-cap budget,
    // and `HookService` already has the same event/session/payload inputs.
    match HookService::new(Arc::clone(&state)).process(session, post.event, post.payload) {
        HookDecision::Block { reason } => Err(DaemonError::OverseerBlocked { reason }),
        _ => Ok(Json(HookAcceptedResponse {
            accepted: post.event,
        })),
    }
}

/// Link a `SessionStart` Claude session UUID to the right managed session record.
///
/// Why (#1744): when `resume` calls `spawn_resume` with `--resume <id>`, it needs
/// the Claude Code internal session UUID persisted on the `SessionRecord`. The only
/// reliable source is the `SessionStart` hook, which fires as Claude Code starts
/// and delivers `CLAUDE_SESSION_ID` via the environment. The hook handler
/// (`tm hook`) now also embeds the caller's `cwd` in the payload (issue #1744);
/// this function uses that `cwd` to find the Active managed session running in that
/// directory and persists the UUID.
/// What: extracts `cwd` from `payload["cwd"]`; canonicalizes it and each record's
/// `workspace_path`/`cwd` (falling back to raw path on error so macOS
/// `/private/tmp` ↔ `/tmp` symlinks resolve); finds Active managed sessions whose
/// path matches. If more than one Active session matches the cwd (ambiguous) the
/// correlation is skipped with a warning to avoid mis-attribution. Single match →
/// `SessionManager::set_claude_session_id`. Best-effort — failures are logged and
/// silently swallowed so a missing correlation never blocks the hook response.
/// Test: `session_start_hook_correlates_claude_id` in `api_tests.rs`.
async fn correlate_session_start(
    state: &Arc<DaemonState>,
    claude_session_id: &str,
    payload: &serde_json::Value,
) {
    let cwd_str = match payload["cwd"].as_str() {
        Some(s) if !s.is_empty() => s,
        _ => return,
    };
    // Canonicalize hook cwd (resolves /tmp ↔ /private/tmp on macOS).
    let hook_cwd =
        std::fs::canonicalize(cwd_str).unwrap_or_else(|_| std::path::PathBuf::from(cwd_str));

    let mgr = state.session_manager().await;
    let records = mgr.list().await;

    // Collect ALL matching Active sessions to detect ambiguous cwd.
    let matched: Vec<_> = records
        .iter()
        .filter(|r| {
            if !matches!(r.state, crate::session_manager::ManagedSessionState::Active) {
                return false;
            }
            let ws_canon = r
                .workspace_path
                .as_ref()
                .map(|p| std::fs::canonicalize(p).unwrap_or_else(|_| p.clone()));
            let cwd_canon = std::fs::canonicalize(&r.cwd).unwrap_or_else(|_| r.cwd.clone());
            ws_canon.as_deref() == Some(hook_cwd.as_path()) || cwd_canon == hook_cwd
        })
        .collect();

    match matched.len() {
        0 => {} // no Active managed session at this cwd — silent, not an error
        1 => {
            let id = matched[0].id;
            match mgr.set_claude_session_id(&id, claude_session_id).await {
                Ok(()) => tracing::info!(
                    managed_id = %id,
                    claude_session_id = %claude_session_id,
                    cwd = %cwd_str,
                    "SessionStart: linked Claude session to managed session (#1744)"
                ),
                Err(e) => tracing::warn!(
                    managed_id = %id,
                    "SessionStart: failed to persist claude_session_id: {e}"
                ),
            }
        }
        n => {
            tracing::warn!(
                cwd = %cwd_str,
                n,
                "SessionStart: {n} Active managed sessions share the same cwd — \
                 skipping claude_session_id attribution to avoid mis-assignment (#1744)"
            );
        }
    }
}

/// Pure decision: should `handle_session_end` DEFER its `Stopped` transition
/// because the record's own tmux pane still shows a live runtime? (#2454)
///
/// Why: `SessionEnd` firing is Claude Code self-reporting that its session is
/// "ending / being torn down" ([`crate::core::hook::HookEvent::SessionEnd`])
/// — NOT proof the OS process (and therefore the tmux pane) has already fully
/// exited; the hook POST can race the pane's actual fall-back to a bare idle
/// shell. `SessionManager::resume` decides pane reuse purely on
/// `tmux.session_exists()`, so flipping the record to `Stopped` while the
/// pane is still occupied could let a `resume` (manual or the auto-resume
/// supervisor) type into a pane the outgoing runtime hasn't relinquished yet.
/// This reuses the EXACT SAME classification the 60-second runtime-exit
/// reaper already trusts for this question —
/// [`crate::daemon::runtime_reap::session_has_live_pane`] (any-pane-live
/// aggregation over the idle-shell allowlist and the [`ChildLivenessProbe`]
/// fail-closed gate) — instead of inventing a second one, so the two call
/// sites can never disagree about what "exited" means.
/// What: delegates directly to
/// [`session_has_live_pane`](crate::daemon::runtime_reap::session_has_live_pane),
/// which returns `true` (defer) when ANY pane whose `session_name` matches
/// `tmux_name` is NOT yet exited. Fixed by #2463: this previously used
/// `panes.iter().find(...)` — the FIRST matching pane only — which
/// misclassified a manually-split, multi-pane managed session as idle
/// whenever `tmux list-panes -a` happened to return an idle pane ahead of a
/// live one. When no matching pane is found (already gone, or tmux/pane
/// enumeration was unavailable and `panes` is empty) this returns `false`
/// (proceed) — there is nothing to protect from a destructive teardown here,
/// since `mark_runtime_exited_stopped` never touches the pane either way; a
/// genuinely vanished session is left to the #1744 reaper as before. Pure —
/// no I/O — so it is unit-testable without a live tmux.
/// Test: `session_end_pane_still_live_true_for_running_agent`,
/// `session_end_pane_still_live_false_for_idle_shell`,
/// `session_end_pane_still_live_false_when_pane_missing`,
/// `session_end_pane_still_live_true_when_any_of_multiple_panes_live`,
/// `session_end_pane_still_live_false_when_all_of_multiple_panes_idle` in
/// `api_tests.rs`.
fn session_end_pane_still_live(
    tmux_name: &str,
    panes: &[crate::daemon::orphan_gc::PaneInfo],
    probe: &dyn crate::daemon::orphan_gc::ChildLivenessProbe,
) -> bool {
    crate::daemon::runtime_reap::session_has_live_pane(tmux_name, panes, probe)
}

/// Immediately mark a managed session Stopped on `SessionEnd`, WITHOUT killing
/// its tmux pane (#2454).
///
/// Why (#1744, revised #2454): without this, a managed session that exits
/// ungracefully (tmux pane killed, terminal closed) stays `Active` in the store
/// until the 60-second reap loop fires. On `SessionEnd`, Claude Code's internal
/// session has already ended; marking the managed session `Stopped` immediately
/// keeps the daemon's view consistent and lets the operator see the correct
/// state right away. This originally called [`SessionManager::stop`], which is
/// the EXPLICIT-stop contract (`tm session stop`) and therefore also
/// `graceful_terminate_runtime`s / `kill_session`s the tmux pane — but a
/// `SessionEnd` correlation is a self-healing state reconcile, not a
/// human/client teardown request, exactly like the 60-second runtime-exit
/// reaper (see [`crate::daemon::runtime_reap`] #2023 A). Routing it through
/// `stop` could destroy a pane the operator was still attached to, purely
/// because the daemon correlated the exit a few hundred milliseconds before
/// they noticed. This now mirrors the reaper and calls
/// [`SessionManager::mark_runtime_exited_stopped`] instead, leaving the pane
/// alive. It also gates that transition behind
/// [`session_end_pane_still_live`] (#2454 follow-up): the hook can race the
/// pane's actual teardown, so if the pane is found and still shows a live
/// runtime the transition is DEFERRED for this hook receipt — the 60-second
/// reaper will pick the session up once its pane is genuinely idle. This gate
/// is best-effort: it needs a real `tmux` binary to enumerate panes, so on a
/// host with no `tmux` (or a `list_managed_panes` failure) it fails OPEN and
/// proceeds with the transition unchecked, same as pre-gate behavior.
/// What: searches Active managed sessions for one whose `claude_session_id`
/// matches the hook's `session_id`. If found, best-effort discovers the live
/// tmux panes ([`crate::daemon::tmux::TmuxDriver::discover`] +
/// `list_managed_panes`) and calls [`session_end_pane_still_live`]; when that
/// reports the pane is still live, logs and returns WITHOUT transitioning.
/// Otherwise calls `SessionManager::mark_runtime_exited_stopped`, which marks
/// the record `Stopped` and persists WITHOUT calling
/// `graceful_terminate_runtime` / `kill_session`. Best-effort — failures are
/// logged and swallowed so the hook response is unaffected.
/// Test: `session_end_hook_marks_managed_stopped` and
/// `session_end_hook_does_not_kill_pane` in `api_tests.rs` cover the
/// transition itself (tmux is unavailable/pane-not-found in that harness, so
/// the gate fails open); the gate's own decision logic is covered by the
/// `session_end_pane_still_live_*` unit tests above.
async fn handle_session_end(state: &Arc<DaemonState>, claude_session_id: &str) {
    let mgr = state.session_manager().await;
    let records = mgr.list().await;
    let matched = records.iter().find(|r| {
        matches!(r.state, crate::session_manager::ManagedSessionState::Active)
            && r.claude_session_id.as_deref() == Some(claude_session_id)
    });
    if let Some(r) = matched {
        let id = r.id;
        let tmux_name = r.tmux_name.clone();

        // #2454: best-effort gate — defer if the pane still shows a live
        // runtime. Fails open (proceeds) when tmux is unavailable or pane
        // enumeration fails, matching the transition's own non-destructive
        // nature (nothing here ever touches the pane regardless).
        if let Ok(driver) = crate::daemon::tmux::TmuxDriver::discover()
            && let Ok(panes) = driver.list_managed_panes()
            && session_end_pane_still_live(
                &tmux_name,
                &panes,
                &crate::daemon::orphan_gc::ProcessTreeProbe,
            )
        {
            tracing::info!(
                managed_id = %id,
                claude_session_id = %claude_session_id,
                name = %tmux_name,
                "SessionEnd: deferring Stopped transition — pane still shows a live runtime (#2454); the 60s reaper will retry once idle"
            );
            return;
        }

        match mgr.mark_runtime_exited_stopped(&id).await {
            Ok(_) => tracing::info!(
                managed_id = %id,
                claude_session_id = %claude_session_id,
                "SessionEnd: marked managed session Stopped immediately (#1744, pane left alive #2454)"
            ),
            Err(e) => tracing::warn!(
                managed_id = %id,
                "SessionEnd: failed to mark managed session Stopped: {e}"
            ),
        }
    }
}

/// `GET /overseer` — current session-overseer configuration and status.
///
/// Why: the CLI and dashboard surface whether oversight is active and which
/// strategy is in force.
/// What: returns `{ "overseer": { "enabled": <bool>, "handler": <str> } }`,
/// where `handler` is the active strategy name reported by the overseer.
/// Test: `get_overseer_returns_status`.
#[utoipa::path(
    get,
    path = "/overseer",
    tag = "config",
    responses((status = 200, description = "Overseer enabled flag and handler type"))
)]
pub async fn get_overseer(State(state): State<Arc<DaemonState>>) -> Json<OverseerResponse> {
    Json(OverseerResponse {
        overseer: OverseerStatus {
            enabled: state.overseer().is_enabled(),
            handler: state.overseer_handler().to_string(),
        },
    })
}

/// `POST /llm/chat` — send a message to the LLM chat assistant.
///
/// Why: the Telegram bot routes free-text (non-command) messages here, and the
/// TUI's `/chat` command does the same; both want a conversational endpoint
/// that reuses the overseer's already-resolved OpenRouter credentials.
/// What: requires a configured LLM overseer (else `503`), runs
/// [`LlmOverseer::chat`] over the client-supplied history, and returns the
/// assistant reply plus the updated history. The daemon stays stateless about
/// chat sessions — the caller owns the history.
/// Test: `llm_chat_without_overseer_is_503`.
#[utoipa::path(
    post,
    path = "/llm/chat",
    tag = "config",
    request_body = LlmChatRequest,
    responses(
        (status = 200, description = "Assistant reply and updated history"),
        (status = 503, description = "LLM chat is not configured on this daemon"),
    )
)]
pub async fn llm_chat(
    State(state): State<Arc<DaemonState>>,
    Json(body): Json<LlmChatRequest>,
) -> Result<Json<LlmChatResponse>, DaemonError> {
    let overseer = state.llm_overseer().ok_or_else(|| {
        DaemonError::ServiceUnavailable(
            "LLM chat is not configured (no OpenRouter API key)".to_string(),
        )
    })?;
    let mut history = body.history;
    let reply = overseer
        .chat(&mut history, &body.message)
        .await
        .map_err(|e| DaemonError::Internal(e.to_string()))?;
    Ok(Json(LlmChatResponse { reply, history }))
}

/// `GET /optimizer` — current token-use optimizer configuration.
///
/// Why: the CLI and dashboard surface the active compression tuning. The
/// config is now framework-managed on disk (`optimizer.toml`); this endpoint
/// is read-only introspection of the daemon's in-memory copy of it.
/// What: returns `{ "optimizer": <OptimizerConfig>, "scope": <note> }`. The
/// `scope` note states that the level only affects observability history and
/// proxied calls, never native Claude Code sessions (issue #1944).
/// Test: e2e `optimizer_default_config`.
#[utoipa::path(
    get,
    path = "/optimizer",
    tag = "config",
    responses((status = 200, description = "Current token-use optimizer configuration"))
)]
pub async fn get_optimizer(State(state): State<Arc<DaemonState>>) -> Json<OptimizerResponse> {
    Json(OptimizerResponse {
        optimizer: state.optimizer_config(),
        scope: crate::daemon::optimizer::OPTIMIZER_SCOPE_NOTE.to_string(),
    })
}

/// JSON body for registering a project via `POST /projects`.
///
/// Why: `trusty-mpm project init` announces a working directory to the daemon
/// so sessions started there can be associated with it.
/// What: the absolute path of the project's working directory.
/// Test: `register_and_list_projects`.
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct RegisterProject {
    /// Absolute path to the project's working directory.
    #[schema(value_type = String)]
    pub path: PathBuf,
}

/// `POST /projects` — register a project, returning its `ProjectInfo`.
///
/// Why: the daemon owns the project registry; `project init` posts the
/// resolved directory here.
/// What: delegates to [`DaemonState::register_project`] and returns the
/// stored info as JSON.
/// Test: `register_and_list_projects`.
#[utoipa::path(
    post,
    path = "/projects",
    tag = "projects",
    request_body = RegisterProject,
    responses((status = 201, description = "Project registered", body = ProjectInfo))
)]
pub async fn register_project(
    State(state): State<Arc<DaemonState>>,
    Json(body): Json<RegisterProject>,
) -> Json<ProjectInfo> {
    Json(state.register_project(body.path))
}

/// `GET /projects` — snapshot of every registered project.
#[utoipa::path(
    get,
    path = "/projects",
    tag = "projects",
    responses((status = 200, description = "Array of registered projects", body = [ProjectInfo]))
)]
pub async fn list_projects(State(state): State<Arc<DaemonState>>) -> Json<ProjectsResponse> {
    Json(ProjectsResponse {
        projects: state.list_projects(),
    })
}

/// Query parameters for `GET /projects/current`.
///
/// Why: the daemon cannot see the caller's cwd; the CLI passes the resolved
/// path so the daemon can look the project up.
/// What: the path to resolve a project for.
/// Test: `current_project_found_and_missing`.
#[derive(serde::Deserialize)]
pub struct CurrentProjectQuery {
    /// Path whose registered project should be returned.
    pub path: PathBuf,
}

/// `GET /projects/current?path=<dir>` — the project registered for `path`.
///
/// Why: `trusty-mpm project info` shows the current directory's project; the
/// daemon resolves the path against its registry.
/// What: returns the matching `ProjectInfo`, or `404` when `path` is not a
/// registered project.
/// Test: `current_project_found_and_missing`.
#[utoipa::path(
    get,
    path = "/projects/current",
    tag = "projects",
    params(("path" = String, Query, description = "Directory whose project to resolve")),
    responses(
        (status = 200, description = "The project registered for the path", body = ProjectInfo),
        (status = 404, description = "Path is not a registered project"),
    )
)]
pub async fn current_project(
    State(state): State<Arc<DaemonState>>,
    Query(query): Query<CurrentProjectQuery>,
) -> Result<Json<ProjectInfo>, DaemonError> {
    match state.project(&query.path) {
        Some(info) => Ok(Json(info)),
        None => Err(DaemonError::SessionNotFound {
            id: query.path.display().to_string(),
        }),
    }
}

/// `GET /projects/discover` — projects mined from `~/.claude/projects/`.
///
/// Why: rather than register every repo by hand, the operator wants trusty-mpm
/// to enumerate the projects Claude Code already knows about and offer them for
/// one-tap registration (the Telegram `/projects` command consumes this).
/// What: runs [`ProjectDiscovery::discover`], maps each row to a
/// [`DiscoveredProjectInfo`] (path as a string, last-session time as an
/// ISO-8601 string), and returns them newest-session-first. The discovery
/// itself never fails — an absent directory yields an empty list.
/// Test: `discover_projects_returns_array`.
#[utoipa::path(
    get,
    path = "/projects/discover",
    tag = "projects",
    responses((status = 200, description = "Projects discovered from Claude Code config"))
)]
pub async fn discover_projects(
    State(_state): State<Arc<DaemonState>>,
) -> Json<DiscoverProjectsResponse> {
    let projects = crate::core::project_discovery::ProjectDiscovery::discover()
        .into_iter()
        .map(|p| DiscoveredProjectInfo {
            path: p.path.display().to_string(),
            session_count: p.session_count,
            last_session: p.last_session.map(system_time_to_iso8601),
        })
        .collect();
    Json(DiscoverProjectsResponse { projects })
}

/// Render a `SystemTime` as an ISO-8601 / RFC3339 UTC string.
///
/// Why: the discovery endpoint reports session times as human- and
/// machine-readable strings; `SystemTime` has no wire-stable serde form.
/// What: converts via `chrono::DateTime<Utc>`, falling back to the Unix epoch
/// for the (unreachable in practice) pre-1970 case.
/// Test: covered by `discover_projects_returns_array`.
fn system_time_to_iso8601(time: std::time::SystemTime) -> String {
    let datetime: chrono::DateTime<chrono::Utc> = time.into();
    datetime.to_rfc3339()
}

// ---- universal tmux session management ---------------------------------

/// `GET /tmux/sessions` — every tmux session on the host, origin-tagged.
///
/// Why: trusty-mpm manages *all* tmux sessions, not just the ones it created;
/// the dashboard needs the full list with an origin label so it can offer to
/// adopt external sessions.
/// What: runs `TmuxDriver::list_all_sessions` and returns
/// `{ "sessions": [ExternalSession, ...] }`. tmux being unavailable yields an
/// empty array rather than an error.
/// Test: `list_tmux_sessions_returns_array`.
#[utoipa::path(
    get,
    path = "/tmux/sessions",
    tag = "tmux",
    responses((status = 200, description = "All tmux sessions with origin labels"))
)]
pub async fn list_tmux_sessions(
    State(_state): State<Arc<DaemonState>>,
) -> Json<TmuxSessionsResponse> {
    Json(TmuxSessionsResponse {
        sessions: TmuxService::list_all(),
    })
}

/// `GET /tmux/sessions/{name}/snapshot` — capture any session's current state.
///
/// Why: the dashboard inspects any session (internal or external) without
/// attaching to it.
/// What: runs `TmuxDriver::monitor_session` for the last 100 pane lines and
/// returns the [`SessionSnapshot`]. A missing session or absent tmux is `404`.
/// Test: `tmux_snapshot_unknown_session_is_404` (covers the no-tmux path).
#[utoipa::path(
    get,
    path = "/tmux/sessions/{name}/snapshot",
    tag = "tmux",
    params(("name" = String, Path, description = "tmux session name")),
    responses(
        (status = 200, description = "Session snapshot"),
        (status = 404, description = "Session not found or tmux unavailable"),
    )
)]
pub async fn tmux_snapshot(
    State(_state): State<Arc<DaemonState>>,
    Path(name): Path<String>,
) -> Result<Json<TmuxSnapshotResponse>, DaemonError> {
    let snapshot = TmuxService::snapshot(&name, 100)?;
    Ok(Json(TmuxSnapshotResponse { snapshot }))
}

/// JSON body for `POST /tmux/adopt`.
///
/// Why: adopting an external session needs only its name.
/// What: the tmux session name to bring under oversight.
/// Test: `adopt_tmux_session_handles_missing`.
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct AdoptRequest {
    /// tmux session name to adopt.
    pub session: String,
}

/// `POST /tmux/adopt` — register an external tmux session for oversight.
///
/// Why: trusty-mpm should watch sessions it did not create; adoption is the
/// explicit, non-destructive opt-in for that.
/// What: runs `TmuxDriver::adopt_session` (which captures the session's shape
/// without modifying it) and returns the [`AdoptedSession`]. A missing session
/// or absent tmux is `404`.
/// Test: `adopt_tmux_session_handles_missing`.
#[utoipa::path(
    post,
    path = "/tmux/adopt",
    tag = "tmux",
    request_body = AdoptRequest,
    responses(
        (status = 200, description = "Session adopted; returns its captured state"),
        (status = 404, description = "Session not found or tmux unavailable"),
    )
)]
pub async fn adopt_tmux_session(
    State(_state): State<Arc<DaemonState>>,
    Json(body): Json<AdoptRequest>,
) -> Result<Json<AdoptResponse>, DaemonError> {
    let adopted = TmuxService::adopt(&body.session)?;
    Ok(Json(AdoptResponse { adopted }))
}

// ---- bot pairing --------------------------------------------------------

/// `POST /pair/request` — generate a one-time Telegram-bot pairing code.
///
/// Why: pairing the Telegram bot to this daemon needs an out-of-band shared
/// secret; `tm pair` calls this on the local daemon to obtain a short code the
/// operator then types into the bot.
/// What: generates a six-character code (stored with a five-minute TTL) and
/// returns `{ "code", "expires_in_seconds" }`.
/// Test: `pair_request_returns_code`.
#[utoipa::path(
    post,
    path = "/pair/request",
    tag = "config",
    responses((status = 200, description = "A one-time pairing code and its TTL"))
)]
pub async fn pair_request(
    State(state): State<Arc<DaemonState>>,
) -> Json<super::services::PairCode> {
    Json(PairingService::new(&state).request_code())
}

/// JSON body for `POST /pair/confirm`.
///
/// Why: confirming a pairing needs the operator's code and the Telegram chat id
/// to bind.
/// What: the six-character code and the chat id.
/// Test: `pair_confirm_validates_code`.
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct PairConfirmRequest {
    /// The one-time pairing code issued by `POST /pair/request`.
    pub code: String,
    /// The Telegram chat id to pair with this daemon.
    pub chat_id: i64,
}

/// `POST /pair/confirm` — confirm a pairing code and register the chat.
///
/// Why: the Telegram bot's `/pair <code>` flow validates the operator's code so
/// push alerts have an authenticated destination.
/// What: validates `code` against the outstanding code within its TTL; on
/// success stores `chat_id` and returns `{ "success": true, "chat_id" }`,
/// otherwise `{ "success": false, "error": "invalid or expired code" }`.
/// Test: `pair_confirm_validates_code`, `pair_confirm_rejects_bad_code`.
#[utoipa::path(
    post,
    path = "/pair/confirm",
    tag = "config",
    request_body = PairConfirmRequest,
    responses((status = 200, description = "Pairing result (success flag and chat id or error)"))
)]
pub async fn pair_confirm(
    State(state): State<Arc<DaemonState>>,
    Json(body): Json<PairConfirmRequest>,
) -> Json<PairConfirmResponse> {
    match PairingService::new(&state).confirm(&body.code, body.chat_id) {
        Ok(()) => Json(PairConfirmResponse {
            success: true,
            chat_id: Some(body.chat_id),
            error: None,
        }),
        Err(_) => Json(PairConfirmResponse {
            success: false,
            chat_id: None,
            error: Some("invalid or expired code".to_string()),
        }),
    }
}

/// `GET /pair/status` — report whether a Telegram chat is paired.
///
/// Why: the bot's `/start` command branches on whether the daemon is already
/// paired so it shows either a welcome-and-pair prompt or a ready message.
/// What: returns `{ "paired": <bool>, "chat_id": <i64 or null> }`.
/// Test: `pair_status_reports_unpaired`.
#[utoipa::path(
    get,
    path = "/pair/status",
    tag = "config",
    responses((status = 200, description = "Pairing status (paired flag and chat id)"))
)]
pub async fn pair_status(
    State(state): State<Arc<DaemonState>>,
) -> Json<super::services::PairStatus> {
    Json(PairingService::new(&state).status())
}

/// `POST /pair/reset` — clear the Telegram pairing.
///
/// Why: an operator unpairing the bot must drop the binding both in memory and
/// on disk so a daemon restart does not restore it from `pairing.json`.
/// What: delegates to [`PairingService::reset`] and returns `{ "reset": true }`.
/// Test: `pair_reset_clears_pairing` in `api_tests.rs`.
#[utoipa::path(
    post,
    path = "/pair/reset",
    tag = "config",
    responses((status = 200, description = "Pairing cleared"))
)]
pub async fn pair_reset(State(state): State<Arc<DaemonState>>) -> Json<PairResetResponse> {
    PairingService::new(&state).reset();
    Json(PairResetResponse { reset: true })
}

// ---- diagnostics --------------------------------------------------------

/// Query parameters for `GET /api/v1/doctor`.
///
/// Why: the instruction-pipeline probe must be scoped to a specific project
/// (`<project>/.trusty-mpm/last-instructions.md`); the daemon cannot see the
/// caller's cwd, so the CLI passes the resolved path here.
/// What: an optional `project` path; when absent the instruction probe reports
/// a warning rather than a definitive result.
/// Test: `doctor_endpoint_returns_report`.
#[derive(serde::Deserialize, Default)]
pub struct DoctorQuery {
    /// Optional project directory to scope the instruction-pipeline probe to.
    pub project: Option<PathBuf>,
}

/// `GET /api/v1/doctor` — run the full trusty-mpm stack diagnostic.
///
/// Why: a misconfigured stack fails in confusing ways; one endpoint that runs
/// every "is this wired correctly?" probe gives operators (and the `tm doctor`
/// CLI / Telegram `/doctor` command) a single actionable verdict.
/// What: delegates to [`super::doctor::run_doctor`], which probes the
/// instruction pipeline, agent and skill deployment, the trusty-memory /
/// trusty-search sidecars, AND orphaned git worktrees (#1840). The endpoint
/// always returns `200` — individual failures live in the report's per-check
/// statuses, not the HTTP status.
/// Test: `doctor_endpoint_returns_report`.
#[utoipa::path(
    get,
    path = "/api/v1/doctor",
    tag = "config",
    params(("project" = Option<String>, Query, description = "Project directory to scope the instruction probe to")),
    responses((status = 200, description = "Full diagnostic report"))
)]
pub async fn doctor(
    State(state): State<Arc<DaemonState>>,
    Query(query): Query<DoctorQuery>,
) -> Json<crate::core::doctor::DoctorReport> {
    // Collect active workspace paths for the worktree orphan check (#1840).
    let mgr = state.session_manager().await;
    let records = mgr.list().await;
    let active_workspace_paths: Vec<PathBuf> = records
        .iter()
        .filter_map(|r| r.workspace_path.clone())
        .collect();
    // Derive the managed workspace root from config (same precedence as decommission).
    let config = crate::core::trusty_tools_config::TrustyToolsConfig::load();
    let repos_root = crate::core::trusty_tools_config::workspace_root(&config);
    Json(
        super::doctor::run_doctor(
            query.project.as_deref(),
            Some(&repos_root),
            &active_workspace_paths,
        )
        .await,
    )
}

// ── Bug-reporting HTTP endpoints (Phase 2 surface + Phase 3 filing) ──────────

/// Query parameters for `GET /api/v1/errors`.
///
/// Why: the caller controls how many errors to return; the daemon caps it at
///      100 to prevent oversized responses.
/// What: optional `limit` field; defaults to 20 when absent.
/// Test: `list_errors_returns_array` in `api_tests.rs`.
#[derive(serde::Deserialize, Default)]
pub struct ErrorsQuery {
    /// Maximum number of errors to return (default 20, max 100).
    #[serde(default)]
    pub limit: Option<u64>,
}

/// `GET /api/v1/errors` — list recently captured errors from all daemon stores.
///
/// Why: sub-agents that cannot use MCP tools directly can still browse captured
///      errors over plain HTTP before deciding to file a bug report.
/// What: aggregates and deduplicates errors from all known daemon JSONL stores,
///       returns them sorted by most-recent occurrence. Always returns `200`.
/// Test: `list_errors_returns_array` in `api_tests.rs`.
#[utoipa::path(
    get,
    path = "/api/v1/errors",
    tag = "bug-reporting",
    params(("limit" = Option<u64>, Query, description = "Max errors to return (default 20, max 100)")),
    responses((status = 200, description = "Deduplicated error list"))
)]
pub async fn list_errors(
    State(_state): State<Arc<DaemonState>>,
    Query(query): Query<ErrorsQuery>,
) -> Json<ErrorsResponse> {
    let limit = query.limit.unwrap_or(20).min(100) as usize;
    let errors = super::bug_report::aggregate_errors(limit);
    let summaries: Vec<ErrorSummary> = errors
        .iter()
        .map(|e| ErrorSummary {
            fingerprint: e.record.fingerprint.clone(),
            crate_target: e.record.crate_target.clone(),
            crate_version: e.record.crate_version.clone(),
            summary: e.record.summary(),
            occurrences: e.occurrences,
            timestamp_secs: e.record.timestamp_secs,
        })
        .collect();
    let total = summaries.len();
    Json(ErrorsResponse {
        errors: summaries,
        total,
        limit,
    })
}

/// JSON body for `POST /api/v1/report-bug`.
///
/// Why: a local request struct with `utoipa::ToSchema` lets the handler slot
///      into the utoipa-axum dispatch without special traits on the shared type.
/// What: the fingerprint and confirm flag; mirrors `bug_report::types::ReportBugRequest`.
/// Test: `report_bug_no_confirm_returns_preview` in `api_tests.rs`.
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct ReportBugApiRequest {
    /// SHA-256 hex fingerprint (64 chars) from `GET /api/v1/errors`.
    pub fingerprint: String,
    /// Must be `true` to actually file; `false` or absent → preview only.
    #[serde(default)]
    pub confirm: bool,
}

/// Build a [`BugReportPreview`] from a scrubbed [`IssuePreview`].
///
/// Why: both the `confirm:false` path and the rate-limited path must return the
///      same preview shape so HTTP clients can inspect before (or after a blocked)
///      filing.
/// What: maps `IssuePreview` fields to [`BugReportPreview`] (the wire type).
/// Test: exercised transitively by `report_bug_no_confirm_includes_preview`.
fn to_wire_preview(p: &super::bug_report::IssuePreview) -> BugReportPreview {
    BugReportPreview {
        title: p.title.clone(),
        body: p.body.clone(),
        labels: p.labels.clone(),
        scrub_changes: p
            .scrub_changes
            .iter()
            .map(|c| ScrubChangeSummary {
                pattern: c.pattern.to_string(),
                hint: c.hint.to_string(),
            })
            .collect(),
    }
}

/// `POST /api/v1/report-bug` — file or preview a bug report via HTTP.
///
/// Why: sub-agents spawned by Claude Code's Agent tool do not inherit MCP
///      connections; they need an HTTP fallback to file bug reports. This
///      endpoint mirrors the `report_bug` MCP tool's consent gate.
///
/// Fixes implemented here:
///   - Fix 1 (#498, P0): token resolved via the full chain (`ResolvedProvider`
///     — PAT env → token file → GitHub App → NoToken) instead of the narrower
///     `EnvFileTokenProvider`, so the GitHub App path is now reachable.
///   - Fix 2 (P1): `confirm:false` now includes the scrubbed preview
///     (title/body/labels/scrub_changes) in the response so HTTP clients can
///     inspect before consenting.
///   - Fix 3 (P2): the `RateLimitGuard` is checked before filing; a blocked
///     call returns `{ filed:false, rate_limited:true, note:… }` without
///     hitting the GitHub API.  After a successful filing `record_filed` is
///     called.  Rate-limit state-file failures are non-fatal (logged only).
///
/// What: when `confirm:false` (or absent), returns a preview-only response
///       (`filed:false`, `preview:{…}`). When `confirm:true`, resolves the
///       token via the full provider chain, checks the rate-limit guard, then
///       calls the GitHub filing client. A missing token returns
///       `filed:false` with an actionable note; a blocked rate-limit returns
///       `filed:false, rate_limited:true`; a missing fingerprint returns
///       `filed:false` with a "not found" note.
/// Test: `report_bug_no_confirm_includes_preview`,
///       `report_bug_rate_limited_returns_not_filed` in `api_tests.rs`.
#[utoipa::path(
    post,
    path = "/api/v1/report-bug",
    tag = "bug-reporting",
    request_body = ReportBugApiRequest,
    responses(
        (status = 200, description = "Filing result or graceful failure; always 200"),
    )
)]
pub async fn report_bug_http(
    State(_state): State<Arc<DaemonState>>,
    Json(body): Json<ReportBugApiRequest>,
) -> Json<ReportBugHttpResponse> {
    // Load errors and find the requested fingerprint.
    let errors = super::bug_report::aggregate_errors(500);
    let found = errors
        .into_iter()
        .find(|e| e.record.fingerprint == body.fingerprint);

    let Some(agg) = found else {
        return Json(ReportBugHttpResponse {
            filed: false,
            deduped: None,
            issue_url: None,
            issue_number: None,
            note: Some(format!(
                "fingerprint `{}` not found in local error stores; \
                 run GET /api/v1/errors to see available fingerprints",
                body.fingerprint
            )),
            preview: None,
            rate_limited: None,
        });
    };

    let preview = super::bug_report::build_preview(&agg);

    // Fix 2 (P1): include scrubbed preview in confirm:false response.
    if !body.confirm {
        return Json(ReportBugHttpResponse {
            filed: false,
            deduped: None,
            issue_url: None,
            issue_number: None,
            note: Some("confirm:false — preview only. POST with confirm:true to file.".to_string()),
            preview: Some(to_wire_preview(&preview)),
            rate_limited: None,
        });
    }

    // Fix 3 (P2): check the rate-limit guard before calling GitHub.
    let guard = super::bug_report::RateLimitGuard::production();
    let now_secs = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0);
    let rl_decision = guard.check(&body.fingerprint, now_secs);
    if !rl_decision.is_allowed() {
        return Json(ReportBugHttpResponse {
            filed: false,
            deduped: None,
            issue_url: None,
            issue_number: None,
            note: Some(rl_decision.block_reason()),
            preview: None,
            rate_limited: Some(true),
        });
    }

    // Fix 1 (P0): use the full resolution chain — PAT → file → GitHub App → NoToken.
    let fingerprint = body.fingerprint.clone();
    let provider = super::bug_report::ResolvedProvider;
    let result =
        tokio::task::spawn_blocking(move || super::bug_report::file_issue(&preview, &provider))
            .await;

    match result {
        Ok(Ok(filing)) => {
            // Fix 3 (P2): record the successful filing in the rate-limit store.
            // State-file failures are non-fatal — log and allow.
            guard.record_filed(&fingerprint, now_secs);
            Json(ReportBugHttpResponse {
                filed: true,
                deduped: Some(filing.deduped),
                issue_url: Some(filing.issue_url),
                issue_number: Some(filing.issue_number),
                note: None,
                preview: None,
                rate_limited: None,
            })
        }
        Ok(Err(super::bug_report::GithubFilingError::NoToken)) => Json(ReportBugHttpResponse {
            filed: false,
            deduped: None,
            issue_url: None,
            issue_number: None,
            note: Some(super::bug_report::GithubFilingError::NoToken.to_string()),
            preview: None,
            rate_limited: None,
        }),
        Ok(Err(e)) => Json(ReportBugHttpResponse {
            filed: false,
            deduped: None,
            issue_url: None,
            issue_number: None,
            note: Some(format!("GitHub filing failed: {e}")),
            preview: None,
            rate_limited: None,
        }),
        Err(e) => Json(ReportBugHttpResponse {
            filed: false,
            deduped: None,
            issue_url: None,
            issue_number: None,
            note: Some(format!("internal error: {e}")),
            preview: None,
            rate_limited: None,
        }),
    }
}

/// Parse a UUID string into a `SessionId`, mapping failure to a `400`-mapped
/// [`DaemonError::InvalidRequest`].
fn parse_id(raw: &str) -> Result<SessionId, DaemonError> {
    uuid::Uuid::parse_str(raw)
        .map(SessionId)
        .map_err(|_| DaemonError::InvalidRequest(format!("malformed session id: {raw}")))
}

/// Handler unit tests.
///
/// Why: the suite is large enough that keeping it in `api.rs` pushed the file
/// past a maintainable size; a `#[path]`-linked sibling keeps the handler
/// surface readable while the tests still see the private helpers via
/// `super::*`.
/// Test: this *is* the test module.
#[cfg(test)]
#[path = "api_tests.rs"]
mod tests;

/// Session-Manager endpoint tests (DOC-14 SM-7).
///
/// Why: the SM-path `coordinator/chat` rewire + the `/session-manager/*` aliases
/// have their own focused suite; keeping it in a sibling file keeps `api_tests.rs`
/// under its SLOC cap while these tests still see the private handler surface via
/// `super::*`.
/// Test: this *is* the SM endpoint test module.
#[cfg(test)]
#[path = "coordinator_sm_tests.rs"]
mod coordinator_sm_tests;