@rubytech/create-maxy-code 0.1.40 → 0.1.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/package.json +2 -2
  2. package/payload/platform/lib/mcp-stderr-tee/package.json +7 -0
  3. package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +142 -52
  4. package/payload/platform/plugins/docs/references/deployment.md +6 -0
  5. package/payload/platform/plugins/docs/references/platform.md +18 -4
  6. package/payload/platform/plugins/docs/references/plugins-guide.md +2 -0
  7. package/payload/platform/scripts/check-no-esm-require.mjs +132 -0
  8. package/payload/platform/scripts/smoke-boot-services.sh +175 -0
  9. package/payload/platform/services/claude-session-manager/dist/config.d.ts +17 -0
  10. package/payload/platform/services/claude-session-manager/dist/config.d.ts.map +1 -1
  11. package/payload/platform/services/claude-session-manager/dist/config.js +31 -0
  12. package/payload/platform/services/claude-session-manager/dist/config.js.map +1 -1
  13. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts +63 -0
  14. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts.map +1 -0
  15. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js +514 -0
  16. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js.map +1 -0
  17. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +3 -1
  18. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  19. package/payload/platform/services/claude-session-manager/dist/http-server.js +364 -204
  20. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  21. package/payload/platform/services/claude-session-manager/dist/index.js +38 -11
  22. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  23. package/payload/platform/services/claude-session-manager/dist/jsonl-path.d.ts +2 -0
  24. package/payload/platform/services/claude-session-manager/dist/jsonl-path.d.ts.map +1 -1
  25. package/payload/platform/services/claude-session-manager/dist/jsonl-path.js +11 -6
  26. package/payload/platform/services/claude-session-manager/dist/jsonl-path.js.map +1 -1
  27. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +37 -44
  28. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  29. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +136 -159
  30. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  31. package/payload/platform/services/claude-session-manager/dist/reaper.d.ts +24 -0
  32. package/payload/platform/services/claude-session-manager/dist/reaper.d.ts.map +1 -0
  33. package/payload/platform/services/claude-session-manager/dist/reaper.js +114 -0
  34. package/payload/platform/services/claude-session-manager/dist/reaper.js.map +1 -0
  35. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts +16 -41
  36. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts.map +1 -1
  37. package/payload/platform/services/claude-session-manager/dist/session-store.js +29 -68
  38. package/payload/platform/services/claude-session-manager/dist/session-store.js.map +1 -1
  39. package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.d.ts +51 -0
  40. package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.d.ts.map +1 -0
  41. package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.js +196 -0
  42. package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.js.map +1 -0
  43. package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/package.json +7 -0
  44. package/payload/server/public/assets/{admin-DO9W2D9g.js → admin-CzJDs5FQ.js} +24 -24
  45. package/payload/server/public/assets/{arc-DMDAZHAN.js → arc-aUiRP9AS.js} +1 -1
  46. package/payload/server/public/assets/architecture-YZFGNWBL--v-pJPNp.js +1 -0
  47. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-BbLtk_k6.js → architectureDiagram-Q4EWVU46-DJvcnhtT.js} +1 -1
  48. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-B9IgcgeJ.js → blockDiagram-DXYQGD6D-_vt7n9ql.js} +1 -1
  49. package/payload/server/public/assets/{brand-DQrOw38P.css → brand-DNSV7-vn.css} +1 -1
  50. package/payload/server/public/assets/{brand-Bky-hMVw.js → brand-FZ59T8pI.js} +1 -1
  51. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-BrvPS09f.js → c4Diagram-AHTNJAMY-CdEo9QqN.js} +1 -1
  52. package/payload/server/public/assets/channel-DzmCBQ_9.js +1 -0
  53. package/payload/server/public/assets/{chunk-2KRD3SAO-Di4bO8ir.js → chunk-2KRD3SAO-BK3470lx.js} +1 -1
  54. package/payload/server/public/assets/chunk-336JU56O-JmuLOuir.js +2 -0
  55. package/payload/server/public/assets/chunk-426QAEUC-10f8pXpz.js +1 -0
  56. package/payload/server/public/assets/{chunk-4BX2VUAB-DyEhFk-Z.js → chunk-4BX2VUAB-BOvVdJLf.js} +1 -1
  57. package/payload/server/public/assets/{chunk-4TB4RGXK-D7_xc8xf.js → chunk-4TB4RGXK-DajMd0ug.js} +1 -1
  58. package/payload/server/public/assets/{chunk-55IACEB6-BRJOZLpU.js → chunk-55IACEB6-BwZyF7vR.js} +1 -1
  59. package/payload/server/public/assets/{chunk-5FUZZQ4R-CdCSpb8j.js → chunk-5FUZZQ4R-B4A-XUHI.js} +1 -1
  60. package/payload/server/public/assets/{chunk-5PVQY5BW-BVgnD9QR.js → chunk-5PVQY5BW-BNM3rT14.js} +1 -1
  61. package/payload/server/public/assets/{chunk-67CJDMHE-BG6-9r6c.js → chunk-67CJDMHE-D5bhMrtY.js} +1 -1
  62. package/payload/server/public/assets/{chunk-7N4EOEYR-BvMbitX7.js → chunk-7N4EOEYR-Si7Lgrwc.js} +1 -1
  63. package/payload/server/public/assets/{chunk-AA7GKIK3-CE8mGBD5.js → chunk-AA7GKIK3-DMuHtDqO.js} +1 -1
  64. package/payload/server/public/assets/{chunk-BSJP7CBP-DP7LTBll.js → chunk-BSJP7CBP-L79XKVcb.js} +1 -1
  65. package/payload/server/public/assets/{chunk-CIAEETIT-VfnIdN-h.js → chunk-CIAEETIT-C0O7Upmg.js} +1 -1
  66. package/payload/server/public/assets/{chunk-EDXVE4YY-CYrT1Tom.js → chunk-EDXVE4YY-atMwO81K.js} +1 -1
  67. package/payload/server/public/assets/{chunk-ENJZ2VHE-B70d0jN7.js → chunk-ENJZ2VHE-BUbZv2Nz.js} +1 -1
  68. package/payload/server/public/assets/{chunk-FMBD7UC4-w-yBZsN2.js → chunk-FMBD7UC4-C_E43NFJ.js} +1 -1
  69. package/payload/server/public/assets/{chunk-FOC6F5B3-f9cFywhd.js → chunk-FOC6F5B3-D9lWWHAu.js} +1 -1
  70. package/payload/server/public/assets/{chunk-ICPOFSXX-BcLouRbd.js → chunk-ICPOFSXX-BMF-eWHb.js} +2 -2
  71. package/payload/server/public/assets/{chunk-K5T4RW27-B_ZUrFUq.js → chunk-K5T4RW27-DuhsNH4c.js} +1 -1
  72. package/payload/server/public/assets/{chunk-KGLVRYIC-CcWTvRlI.js → chunk-KGLVRYIC-B4-A1Abi.js} +1 -1
  73. package/payload/server/public/assets/{chunk-LIHQZDEY-D-5-peQw.js → chunk-LIHQZDEY-BxqgHRgT.js} +1 -1
  74. package/payload/server/public/assets/{chunk-ORNJ4GCN-B4Z5L25I.js → chunk-ORNJ4GCN-DEYQ5WaJ.js} +1 -1
  75. package/payload/server/public/assets/{chunk-OYMX7WX6-C2ja2fPj.js → chunk-OYMX7WX6-BEMDmPTI.js} +1 -1
  76. package/payload/server/public/assets/chunk-QZHKN3VN-Bd-GrQM6.js +1 -0
  77. package/payload/server/public/assets/{chunk-U2HBQHQK-CCjoTpL6.js → chunk-U2HBQHQK-DOM7Y4f1.js} +1 -1
  78. package/payload/server/public/assets/{chunk-X2U36JSP-QIVkp-XR.js → chunk-X2U36JSP-C_uWyW6Y.js} +1 -1
  79. package/payload/server/public/assets/{chunk-XPW4576I-m1Y_r88I.js → chunk-XPW4576I-CBfZXZDB.js} +1 -1
  80. package/payload/server/public/assets/{chunk-YZCP3GAM-qD9rebhx.js → chunk-YZCP3GAM-DQbgl47k.js} +1 -1
  81. package/payload/server/public/assets/{chunk-ZZ45TVLE-C0J_f05H.js → chunk-ZZ45TVLE-C44DEAgq.js} +1 -1
  82. package/payload/server/public/assets/classDiagram-6PBFFD2Q-DVoOmecn.js +1 -0
  83. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-B0r3hpf9.js +1 -0
  84. package/payload/server/public/assets/clone-Dde30M8J.js +1 -0
  85. package/payload/server/public/assets/{cose-bilkent-S5V4N54A-BhtgY3T7.js → cose-bilkent-S5V4N54A-Br2gjtEO.js} +1 -1
  86. package/payload/server/public/assets/{dagre-KV5264BT-C8lVFO5U.js → dagre-KV5264BT-CE3meBlZ.js} +1 -1
  87. package/payload/server/public/assets/{dagre-B3cA7jId.js → dagre-lNEvWLDX.js} +1 -1
  88. package/payload/server/public/assets/data-DW6QAnMD.js +1 -0
  89. package/payload/server/public/assets/{device-url-actions-qu2-XiHc.js → device-url-actions-a6Bd-MRk.js} +1 -1
  90. package/payload/server/public/assets/{diagram-5BDNPKRD-BYkcTelK.js → diagram-5BDNPKRD-BqR72jWu.js} +1 -1
  91. package/payload/server/public/assets/{diagram-G4DWMVQ6-B6f_WR4j.js → diagram-G4DWMVQ6-DOjamNAP.js} +1 -1
  92. package/payload/server/public/assets/{diagram-MMDJMWI5-ALaKcN6w.js → diagram-MMDJMWI5-DAUB-aPu.js} +1 -1
  93. package/payload/server/public/assets/{diagram-TYMM5635-CkVg3crF.js → diagram-TYMM5635-DpToW00F.js} +1 -1
  94. package/payload/server/public/assets/{erDiagram-SMLLAGMA-DszlTGIQ.js → erDiagram-SMLLAGMA-C-p2ze2T.js} +1 -1
  95. package/payload/server/public/assets/{flatten-BsWEYbBB.js → flatten-Bo6YRmWl.js} +1 -1
  96. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-C9-FqSlk.js → flowDiagram-DWJPFMVM-B9SS6a8F.js} +1 -1
  97. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-faJU-5Zy.js → ganttDiagram-T4ZO3ILL-DnXZX0Gp.js} +1 -1
  98. package/payload/server/public/assets/gitGraph-7Q5UKJZL-CI0s_tqn.js +1 -0
  99. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-Cck8_tiG.js → gitGraphDiagram-UUTBAWPF-CtQ0FzPg.js} +1 -1
  100. package/payload/server/public/assets/graph-D13IJB3w.js +1 -0
  101. package/payload/server/public/assets/graph-labels-CI9WmEiE.js +1 -0
  102. package/payload/server/public/assets/{graphlib-CYRxeBXj.js → graphlib-nUaDKzkp.js} +1 -1
  103. package/payload/server/public/assets/info-OMHHGYJF-g3gYW7Qm.js +1 -0
  104. package/payload/server/public/assets/infoDiagram-42DDH7IO-c2wWbEaX.js +2 -0
  105. package/payload/server/public/assets/{isEmpty-BWl67LAZ.js → isEmpty-D6Kr-M1M.js} +1 -1
  106. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-nSIaVXdw.js → ishikawaDiagram-UXIWVN3A-BBOgnjrU.js} +1 -1
  107. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-DuKq7d3g.js → journeyDiagram-VCZTEJTY-DzIkOZor.js} +1 -1
  108. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-Bt5xqHfY.js → kanban-definition-6JOO6SKY-NoAdbB_E.js} +1 -1
  109. package/payload/server/public/assets/{line-BgV8qCP_.js → line-BRK1p00q.js} +1 -1
  110. package/payload/server/public/assets/{linear-0O14Y6uf.js → linear-DOh_6k2k.js} +1 -1
  111. package/payload/server/public/assets/{mermaid-parser.core-DQzl__pY.js → mermaid-parser.core-Bm0OvYTL.js} +2 -2
  112. package/payload/server/public/assets/{mermaid.core-BAPEOjwh.js → mermaid.core-CTznq93v.js} +3 -3
  113. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-CmMBrI79.js → mindmap-definition-QFDTVHPH-BOQ1enWi.js} +1 -1
  114. package/payload/server/public/assets/{ordinal-krseTxxN.js → ordinal-BDi6f4xk.js} +1 -1
  115. package/payload/server/public/assets/packet-4T2RLAQJ-CT0TB9HI.js +1 -0
  116. package/payload/server/public/assets/{page-BoDdtGsH.js → page-BLNJpxTT.js} +2 -2
  117. package/payload/server/public/assets/{page-D_NCNi5j.js → page-Dn3gASQM.js} +1 -1
  118. package/payload/server/public/assets/pie-ZZUOXDRM-CXLe7TFF.js +1 -0
  119. package/payload/server/public/assets/{pieDiagram-DEJITSTG-BhsR9k6j.js → pieDiagram-DEJITSTG-2S-XHkal.js} +1 -1
  120. package/payload/server/public/assets/{public-DCcGKIMW.js → public-D0z7BALL.js} +5 -5
  121. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-DSKV6d_A.js → quadrantDiagram-34T5L4WZ-CncZqXXr.js} +1 -1
  122. package/payload/server/public/assets/radar-PYXPWWZC-DnPLBl-D.js +1 -0
  123. package/payload/server/public/assets/{reduce-tk-xY6Fv.js → reduce-CGi9ik8i.js} +1 -1
  124. package/payload/server/public/assets/{requirementDiagram-MS252O5E-BccM48AI.js → requirementDiagram-MS252O5E-CPeWs9fs.js} +1 -1
  125. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-C0pxiQfn.js → sankeyDiagram-XADWPNL6-A4_669ey.js} +1 -1
  126. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-BVsUIJsW.js → sequenceDiagram-FGHM5R23-DD-qEurY.js} +1 -1
  127. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-lqYQZ-F1.js → stateDiagram-FHFEXIEX-CdO6Mw_T.js} +1 -1
  128. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-D28bJZnf.js +1 -0
  129. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-D-LxYOHq.js → timeline-definition-GMOUNBTQ-JH65fN1V.js} +1 -1
  130. package/payload/server/public/assets/treeView-SZITEDCU-C3cb7Xwe.js +1 -0
  131. package/payload/server/public/assets/treemap-W4RFUUIX-Dc7G3Bgm.js +1 -0
  132. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-BNHzvNba.js → vennDiagram-DHZGUBPP-CJLoNrBL.js} +1 -1
  133. package/payload/server/public/assets/wardley-RL74JXVD-DtgibWAt.js +1 -0
  134. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-BfXR9l5s.js → wardleyDiagram-NUSXRM2D-BgM9eEf7.js} +1 -1
  135. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-CL7mVEbT.js → xychartDiagram-5P7HB3ND-DCmesmc9.js} +1 -1
  136. package/payload/server/public/data.html +5 -5
  137. package/payload/server/public/graph.html +5 -6
  138. package/payload/server/public/index.html +7 -8
  139. package/payload/server/public/public.html +4 -5
  140. package/payload/server/server.js +94 -4
  141. package/payload/server/public/assets/Checkbox-6lLDKzMP.js +0 -1
  142. package/payload/server/public/assets/architecture-YZFGNWBL-COhEvUpo.js +0 -1
  143. package/payload/server/public/assets/channel-Bmc1qlCD.js +0 -1
  144. package/payload/server/public/assets/chunk-336JU56O-DXOr0ZnC.js +0 -2
  145. package/payload/server/public/assets/chunk-426QAEUC-BFW4zwrV.js +0 -1
  146. package/payload/server/public/assets/chunk-QZHKN3VN-CWh_0JsP.js +0 -1
  147. package/payload/server/public/assets/classDiagram-6PBFFD2Q-RVZorqSk.js +0 -1
  148. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-BEZem5bl.js +0 -1
  149. package/payload/server/public/assets/clone-B6eXIhiR.js +0 -1
  150. package/payload/server/public/assets/data-BaSmBQ7U.js +0 -1
  151. package/payload/server/public/assets/gitGraph-7Q5UKJZL-DeTNsAO0.js +0 -1
  152. package/payload/server/public/assets/graph-labels-COhLbJD0.js +0 -1
  153. package/payload/server/public/assets/graph-qg6fkRyC.js +0 -1
  154. package/payload/server/public/assets/info-OMHHGYJF-DJJ9GlS6.js +0 -1
  155. package/payload/server/public/assets/infoDiagram-42DDH7IO-CLjEjhJX.js +0 -2
  156. package/payload/server/public/assets/packet-4T2RLAQJ-CGbvGkvF.js +0 -1
  157. package/payload/server/public/assets/pie-ZZUOXDRM-BZy8rjFn.js +0 -1
  158. package/payload/server/public/assets/radar-PYXPWWZC-DcfWIVXr.js +0 -1
  159. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-D2eHThhC.js +0 -1
  160. package/payload/server/public/assets/treeView-SZITEDCU-3WugwVdj.js +0 -1
  161. package/payload/server/public/assets/treemap-W4RFUUIX-Cf5mDLlu.js +0 -1
  162. package/payload/server/public/assets/wardley-RL74JXVD-Bv4md4b3.js +0 -1
  163. /package/payload/server/public/assets/{_baseFor-BHtDrjIo.js → _baseFor-Cam2PbSt.js} +0 -0
  164. /package/payload/server/public/assets/{array-DetWRiSa.js → array-DYRGGQae.js} +0 -0
  165. /package/payload/server/public/assets/{cytoscape.esm-C9yNhe1u.js → cytoscape.esm-nWsJMTNI.js} +0 -0
  166. /package/payload/server/public/assets/{defaultLocale-_WRwicXn.js → defaultLocale-Du1XY3Dp.js} +0 -0
  167. /package/payload/server/public/assets/{dist-Bd4S37oi.js → dist-BzAsli7o.js} +0 -0
  168. /package/payload/server/public/assets/{init-sTEcj9YX.js → init-B5BXBRcm.js} +0 -0
  169. /package/payload/server/public/assets/{katex-s61Rgv6l.js → katex-HOUACuRw.js} +0 -0
  170. /package/payload/server/public/assets/{path-B0Ik7Tu9.js → path-CNO468J-.js} +0 -0
  171. /package/payload/server/public/assets/{rough.esm-DKRO8IF-.js → rough.esm-DRO6hWPh.js} +0 -0
  172. /package/payload/server/public/assets/{src-B6XdH6xq.js → src-CWiyyVfn.js} +0 -0
@@ -6,35 +6,27 @@
6
6
  // POST /spawn { senderId, role, channel, tunnelUrl? } -> Session
7
7
  // GET /list?senderId=... -> Session[]
8
8
  // POST /resume { senderId, role, channel, claudeSessionId } -> Session
9
- // DELETE /:sessionId -> 204
10
- // DELETE /:sessionId?purge=1 -> 204 (hard-removes JSONL + sidecar); 409 if PTY alive
11
- // POST /:sessionId/input { text }
12
- // GET /:sessionId/log -> { jsonlPath, sizeBytes, exists }
13
- // GET /:sessionId/log?follow=1 -> streamed file content (current + new)
14
- // GET /:sessionId/meta -> SessionMeta payload
15
- // PATCH /:sessionId { displayName } -> 204 (writes sidecar)
9
+ // POST /:id/stop -> 204 (kill PTY; idempotent on already-dead row)
10
+ // 404 if no row matches any of the 4 keys
11
+ // DELETE /:id -> 204 (rmSync JSONL + sidecar + subdir)
12
+ // 409 if PTY still alive
13
+ // 404 if no transcript on disk
14
+ // POST /:id/input { text }
15
+ // GET /:id/log -> { jsonlPath, sizeBytes, exists }
16
+ // GET /:id/log?follow=1 -> streamed file content (current + new)
17
+ // GET /:id/meta -> SessionMeta payload
18
+ // PATCH /:id { displayName } -> 204 (writes sidecar)
19
+ // POST /:id/archive { mode }
16
20
  //
17
- // `/spawn` returns 200 the instant Claude Code emits its `/remote-control`
18
- // URL the operator gets a usable session inside ~2 s even when they never
19
- // type. The wire shape is `{sessionId: <bridge-suffix>, claudeSessionId: null,
20
- // jsonlPath: null, ...LivePayload}`. The jsonl-observer fires on the first
21
- // operator turn (claude does not write the JSONL until then, verified Pi
22
- // 2.1.143 and Mac 2.1.128) and promotes the row to the intrinsic
23
- // claudeSessionId in the background — no second client call needed.
24
- //
25
- // Every GET route resolves either the bridge suffix or the intrinsic id to
26
- // the same row, so a UI that mounted on the bridge suffix at t=0 keeps
27
- // routing correctly after promotion without flipping the row's visible
28
- // position. The intrinsic id is the right field to use for routing once
29
- // known (UI reads `claudeSessionId ?? sessionId`).
30
- //
31
- // sidecar `<id>.meta.json` next to the JSONL holds operator-owned fields
32
- // (displayName). PATCH writes it; /meta reads it via the enumerator helpers.
21
+ // Any of the four ids intrinsic sessionId, bridgeSessionId, bridgeSuffix,
22
+ // or pid (numeric) resolves to the same row via the fs-watcher index.
23
+ // Unknown ids return 404; nothing returns a silent 204 against an id the
24
+ // manager does not know.
33
25
  import { Hono } from 'hono';
34
26
  import { stream } from 'hono/streaming';
35
27
  import { existsSync, statSync, createReadStream, watchFile, unwatchFile, rmSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
36
- import { spawnClaudeSession, deleteSession, PERMISSION_MODES } from './pty-spawner.js';
37
- import { listJsonlSessions, readJsonlSession, readSidecar, sidecarPathFor } from './jsonl-enumerator.js';
28
+ import { spawnClaudeSession, stopSession, PERMISSION_MODES } from './pty-spawner.js';
29
+ import { readJsonlSession, readSidecar, sidecarPathFor } from './jsonl-enumerator.js';
38
30
  import { claudeStateRoot, projectSlugForCwd } from './jsonl-path.js';
39
31
  import { basename, join } from 'node:path';
40
32
  const ROLES = ['admin', 'public'];
@@ -43,12 +35,10 @@ function parseAboutOwner(raw) {
43
35
  if (!raw || typeof raw !== 'object')
44
36
  return undefined;
45
37
  const r = raw;
46
- if (r.ok === true && typeof r.body === 'string') {
38
+ if (r.ok === true && typeof r.body === 'string')
47
39
  return { ok: true, body: r.body };
48
- }
49
- if (r.ok === false && typeof r.reason === 'string' && r.reason.length > 0) {
40
+ if (r.ok === false && typeof r.reason === 'string' && r.reason.length > 0)
50
41
  return { ok: false, reason: r.reason };
51
- }
52
42
  return undefined;
53
43
  }
54
44
  function parseDormantPlugins(raw) {
@@ -121,37 +111,40 @@ function parseSpecialistDomains(raw) {
121
111
  }
122
112
  return out.length > 0 ? out : undefined;
123
113
  }
124
- // Path-traversal guard. Bridge suffixes and intrinsic JSONL basenames both
125
- // match this character class.
114
+ // `<id>` may be: intrinsic UUID, bridgeSessionId ("session_<suffix>"),
115
+ // bridgeSuffix, or pid (numeric). All resolve to a sessionId via watcher.
126
116
  const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
127
117
  const LIST_TAIL_BYTES = 64 * 1024;
128
118
  const DISPLAY_NAME_MAX = 256;
129
119
  function timed(logger, method, path, status, ms) {
130
120
  logger(`api method=${method} path=${path} outcome=${status} ms=${ms}`);
131
121
  }
132
- /** Locate the JSONL path for any sessionId. For alive rows, returns null
133
- * when the row has not yet promoted (no JSONL on disk yet). */
134
- function resolveJsonlPath(deps, sessionId) {
135
- const live = deps.store.get(sessionId);
136
- if (live && live.claudeSessionId !== null && live.jsonlPath !== null) {
137
- return { path: live.jsonlPath, claudeSessionId: live.claudeSessionId };
138
- }
139
- if (live && live.claudeSessionId === null) {
140
- // Row is alive but pre-promotion: no JSONL exists yet.
141
- return null;
122
+ /** Resolve any of the 4 keys to the canonical (sessionId, row) pair.
123
+ * Tries: intrinsic sessionId, bridgeSessionId, bridgeSuffix, numeric pid.
124
+ * Returns undefined when nothing matches. */
125
+ function resolveRow(watcher, id) {
126
+ const direct = watcher.getBySessionId(id);
127
+ if (direct)
128
+ return direct;
129
+ const byBridge = watcher.getByBridgeSessionId(id);
130
+ if (byBridge)
131
+ return byBridge;
132
+ const bySuffix = watcher.getByBridgeSuffix(id);
133
+ if (bySuffix)
134
+ return bySuffix;
135
+ if (/^\d+$/.test(id)) {
136
+ const byPid = watcher.getByPid(Number(id));
137
+ if (byPid)
138
+ return byPid;
142
139
  }
143
- const slug = projectSlugForCwd(deps.spawnCwd);
144
- const path = join(claudeStateRoot(), 'projects', slug, `${sessionId}.jsonl`);
145
- if (existsSync(path))
146
- return { path, claudeSessionId: sessionId };
147
- return null;
140
+ return undefined;
148
141
  }
149
142
  function attachPublicAudit(deps, session) {
150
143
  if (session.role !== 'public')
151
144
  return;
152
- if (session.claudeSessionId === null || session.jsonlPath === null)
145
+ if (session.jsonlPath === null)
153
146
  return;
154
- const claudeSessionId = session.claudeSessionId;
147
+ const claudeSessionId = session.sessionId;
155
148
  deps.publicToolAudit.attach(claudeSessionId, session.jsonlPath);
156
149
  if (session.exited) {
157
150
  deps.publicToolAudit.detach(claudeSessionId);
@@ -163,24 +156,41 @@ function attachPublicAudit(deps, session) {
163
156
  }
164
157
  export function buildHttpApp(deps) {
165
158
  const app = new Hono();
159
+ // Watcher subscriber: when JSONL CREATE fires for a live session,
160
+ // patch jsonlPath onto the StoredSession and attach the audit watcher.
161
+ deps.watcher.subscribe((event) => {
162
+ if (event.source !== 'jsonl')
163
+ return;
164
+ if (event.kind !== 'create' && event.kind !== 'modify')
165
+ return;
166
+ if (!event.row || !event.row.jsonlPath)
167
+ return;
168
+ const live = deps.store.get(event.sessionId);
169
+ if (!live)
170
+ return;
171
+ if (live.jsonlPath !== null)
172
+ return;
173
+ live.jsonlPath = event.row.jsonlPath;
174
+ deps.logger(`[fs-watcher] live-row-jsonl-attached sessionId=${event.sessionId} path=${event.row.jsonlPath}`);
175
+ attachPublicAudit(deps, live);
176
+ });
166
177
  function liveDisplayName(s, sidecarName) {
167
178
  if (sidecarName && sidecarName.trim())
168
179
  return sidecarName.trim();
169
- if (s.jsonlPath && s.claudeSessionId && existsSync(s.jsonlPath)) {
170
- const enriched = readJsonlSession(deps.spawnCwd, s.claudeSessionId, LIST_TAIL_BYTES, deps.logger);
180
+ if (s.jsonlPath && existsSync(s.jsonlPath)) {
181
+ const enriched = readJsonlSession(deps.spawnCwd, s.sessionId, LIST_TAIL_BYTES, deps.logger);
171
182
  if (enriched?.aiTitle?.trim())
172
183
  return enriched.aiTitle.trim();
173
184
  }
174
- // pre-promotion 8 chars of the bridge suffix; post-promotion → 8
175
- // chars of the intrinsic id (which the operator may copy into resume).
176
- return (s.claudeSessionId ?? s.bridgeSuffix).slice(0, 8);
185
+ return s.sessionId.slice(0, 8);
177
186
  }
178
187
  function toLivePayload(s) {
179
188
  const sidecar = s.jsonlPath ? readSidecar(s.jsonlPath) : null;
180
189
  const hasJsonl = s.jsonlPath !== null && existsSync(s.jsonlPath);
181
190
  return {
182
- sessionId: s.bridgeSuffix,
183
- claudeSessionId: s.claudeSessionId,
191
+ sessionId: s.sessionId,
192
+ claudeSessionId: s.sessionId,
193
+ bridgeSessionId: s.bridgeSessionId,
184
194
  pid: s.pid,
185
195
  url: s.url,
186
196
  startedAt: s.startedAt,
@@ -198,6 +208,45 @@ export function buildHttpApp(deps) {
198
208
  specialist: s.specialist,
199
209
  };
200
210
  }
211
+ function toArchivedPayload(row, senderId) {
212
+ let displayName = row.sessionId.slice(0, 8);
213
+ let lastMessageAt = null;
214
+ let turnCount = 0;
215
+ let capped = false;
216
+ let sizeBytes = null;
217
+ let startedAt = new Date(row.updatedAt || 0).toISOString();
218
+ if (row.jsonlPath && existsSync(row.jsonlPath)) {
219
+ const enriched = readJsonlSession(deps.spawnCwd, row.sessionId, LIST_TAIL_BYTES, deps.logger);
220
+ if (enriched) {
221
+ displayName = enriched.displayName;
222
+ lastMessageAt = enriched.lastMessageAt;
223
+ turnCount = enriched.turnCount;
224
+ capped = enriched.capped;
225
+ sizeBytes = enriched.sizeBytes;
226
+ startedAt = enriched.startedAt;
227
+ }
228
+ }
229
+ return {
230
+ sessionId: row.sessionId,
231
+ claudeSessionId: row.sessionId,
232
+ bridgeSessionId: row.bridgeSessionId,
233
+ pid: null,
234
+ url: null,
235
+ startedAt,
236
+ senderId,
237
+ role: null,
238
+ channel: null,
239
+ jsonlPath: row.jsonlPath,
240
+ status: 'ended',
241
+ displayName,
242
+ lastMessageAt,
243
+ turnCount,
244
+ capped,
245
+ sizeBytes,
246
+ hidden: false,
247
+ specialist: row.agent,
248
+ };
249
+ }
201
250
  app.post('/spawn', async (c) => {
202
251
  const start = Date.now();
203
252
  let body;
@@ -216,7 +265,6 @@ export function buildHttpApp(deps) {
216
265
  const specialist = typeof body.specialist === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(body.specialist)
217
266
  ? body.specialist
218
267
  : undefined;
219
- const stdinPayload = typeof body.stdinPayload === 'string' ? body.stdinPayload : undefined;
220
268
  const aboutOwnerShape = body.aboutOwner == null ? 'absent' : (typeof body.aboutOwner === 'object' ? 'object' : typeof body.aboutOwner);
221
269
  const dormantPluginsShape = Array.isArray(body.dormantPlugins)
222
270
  ? `array(${body.dormantPlugins.length})`
@@ -250,16 +298,12 @@ export function buildHttpApp(deps) {
250
298
  const dormantPlugins = parseDormantPlugins(body.dormantPlugins);
251
299
  const activePlugins = parseActivePlugins(body.activePlugins);
252
300
  const specialistDomains = parseSpecialistDomains(body.specialistDomains);
253
- // tunnelUrl override resolved upstream by the UI process from the
254
- // brand's tunnel.state on disk (the manager has no Neo4j credentials).
255
- // string → render; null → render `tunnel-url: none`; undefined → fall
256
- // back to deps.host.tunnelUrl (whatever boot resolved).
257
301
  const tunnelUrlOverride = typeof body.tunnelUrl === 'string'
258
302
  ? body.tunnelUrl
259
303
  : body.tunnelUrl === null
260
304
  ? null
261
305
  : undefined;
262
- const result = await spawnClaudeSession({ ...deps, store: deps.store, onJsonlObserved: (s) => attachPublicAudit(deps, s), tunnelUrlOverride }, {
306
+ const result = await spawnClaudeSession({ ...deps, store: deps.store, watcher: deps.watcher, onSessionReady: (s) => attachPublicAudit(deps, s), tunnelUrlOverride }, {
263
307
  senderId,
264
308
  role: role,
265
309
  channel: channel,
@@ -272,19 +316,147 @@ export function buildHttpApp(deps) {
272
316
  specialistDomains,
273
317
  hidden,
274
318
  specialist,
275
- stdinPayload,
276
319
  });
277
320
  if (!result.ok) {
278
- // Every remaining spawn-failure surface is 500: which-claude-not-found,
279
- // pty-spawn-failed, url-capture-timeout, bridge-suffix-unparseable,
280
- // host-context-unresolved, identity-unresolved. The observe-timeout 504
281
- // is gone /spawn never blocks on observation.
321
+ if ('rejected' in result) {
322
+ timed(deps.logger, 'POST', '/spawn', 503, Date.now() - start);
323
+ if (result.rejected.kind === 'specialist-cap-reached') {
324
+ return c.json({ error: 'specialist-cap-reached', currentCount: result.rejected.currentCount, cap: result.rejected.cap }, 503);
325
+ }
326
+ return c.json({ error: 'operator-slots-reserved', total: result.rejected.total, reserve: result.rejected.reserve, totalCap: result.rejected.totalCap }, 503);
327
+ }
282
328
  timed(deps.logger, 'POST', '/spawn', 500, Date.now() - start);
283
329
  return c.json({ error: 'spawn-failed', reason: result.reason, stderrTail: result.stderrTail }, 500);
284
330
  }
285
331
  timed(deps.logger, 'POST', '/spawn', 200, Date.now() - start);
286
332
  return c.json(toLivePayload(result.session), 200);
287
333
  });
334
+ // SSE feed of row deltas. One channel per client; manager fans out from
335
+ // a single watcher subscription. On connect: replay current rows as
336
+ // `row-created`, then stream subsequent watcher events as
337
+ // `row-<created|updated|archived|removed>`.
338
+ //
339
+ // Payload shape per event (`data: <json>`):
340
+ // { sessionId, live, status, agentLabel, bridgeSessionId, cwd,
341
+ // firstPrompt, updatedAt }
342
+ //
343
+ // `live` mirrors the watcher's `state === 'live'`. `status` is the
344
+ // watcher's `idle | busy | gone`. `agentLabel` is the resolution
345
+ // `agent ?? cachedAgentSetting`; the UI maps null → "admin",
346
+ // non-null → the specialist name. `firstPrompt` is the resolved
347
+ // displayName (sidecar → ai-title → 8-char slice) so the row title is
348
+ // computed once on the server, not by every browser tab.
349
+ app.get('/events', (c) => {
350
+ const start = Date.now();
351
+ const clientIp = c.req.header('x-forwarded-for') ?? 'loopback';
352
+ const replayRows = deps.watcher.all();
353
+ deps.logger(`[admin-events] client-connected ip=${clientIp} seeded-rows=${replayRows.length}`);
354
+ function rowToPayload(row) {
355
+ const agentLabel = row.agent ?? row.cachedAgentSetting ?? null;
356
+ let firstPrompt = null;
357
+ if (row.jsonlPath && existsSync(row.jsonlPath)) {
358
+ const enriched = readJsonlSession(deps.spawnCwd, row.sessionId, LIST_TAIL_BYTES, deps.logger);
359
+ if (enriched)
360
+ firstPrompt = enriched.displayName;
361
+ }
362
+ if (!firstPrompt) {
363
+ const sidecar = row.jsonlPath ? readSidecar(row.jsonlPath) : null;
364
+ firstPrompt = sidecar?.displayName ?? row.sessionId.slice(0, 8);
365
+ }
366
+ return {
367
+ sessionId: row.sessionId,
368
+ live: row.state === 'live',
369
+ status: row.status,
370
+ agentLabel,
371
+ bridgeSessionId: row.bridgeSessionId,
372
+ cwd: row.cwd,
373
+ firstPrompt,
374
+ updatedAt: row.updatedAt,
375
+ };
376
+ }
377
+ timed(deps.logger, 'GET', '/events', 200, Date.now() - start);
378
+ return stream(c, async (out) => {
379
+ // SSE response headers — Hono's stream() helper sets text/event-stream
380
+ // automatically, but we explicitly disable proxy buffering so the
381
+ // first replay batch arrives before any keep-alive.
382
+ c.header('Content-Type', 'text/event-stream');
383
+ c.header('Cache-Control', 'no-cache, no-transform');
384
+ c.header('X-Accel-Buffering', 'no');
385
+ c.header('Connection', 'keep-alive');
386
+ const connectStart = Date.now();
387
+ let active = true;
388
+ let subscribersCount = 0;
389
+ // Replay phase. Emit a `row-created` for every row in the index so
390
+ // a freshly-connected tab catches up to the manager's view before
391
+ // any subsequent delta arrives.
392
+ for (const row of replayRows) {
393
+ if (!active)
394
+ break;
395
+ const payload = rowToPayload(row);
396
+ await out.write(`event: row-created\ndata: ${JSON.stringify(payload)}\n\n`);
397
+ }
398
+ // Delta phase. Subscribe to the watcher and map watcher events to
399
+ // the four SSE event kinds. Mapping:
400
+ // create → row-created
401
+ // modify → row-updated (state may have flipped — see archive)
402
+ // delete → row-removed when the row is fully gone, row-archived
403
+ // when the PID file went away but the JSONL survives.
404
+ const unsubscribe = deps.watcher.subscribe((event) => {
405
+ if (!active)
406
+ return;
407
+ // The watcher emits a synthetic `kind=modify source=jsonl` pair
408
+ // immediately after `kind=delete source=pid` when a row demotes
409
+ // from live → archived. Treat that pair as one `row-archived`
410
+ // event so the UI sees a single transition rather than a delete
411
+ // followed by an unrelated update.
412
+ if (event.kind === 'delete' && event.source === 'pid' && event.row) {
413
+ const payload = rowToPayload(event.row);
414
+ subscribersCount += 1;
415
+ deps.logger(`[admin-events] emit event=row-archived sessionId=${event.sessionId} subscribers=1`);
416
+ void out.write(`event: row-archived\ndata: ${JSON.stringify(payload)}\n\n`);
417
+ return;
418
+ }
419
+ if (event.kind === 'delete' && event.source === 'jsonl' && !event.row) {
420
+ subscribersCount += 1;
421
+ deps.logger(`[admin-events] emit event=row-removed sessionId=${event.sessionId} subscribers=1`);
422
+ void out.write(`event: row-removed\ndata: ${JSON.stringify({ sessionId: event.sessionId })}\n\n`);
423
+ return;
424
+ }
425
+ // Skip the synthetic `modify jsonl` half of the demote pair —
426
+ // it carries the same row state already emitted as row-archived.
427
+ if (event.kind === 'modify' && event.source === 'jsonl' && event.row && event.row.state === 'archived' && event.row.pidFilePath === null) {
428
+ return;
429
+ }
430
+ if (!event.row)
431
+ return;
432
+ const sseEvent = event.kind === 'create' ? 'row-created' : 'row-updated';
433
+ const payload = rowToPayload(event.row);
434
+ subscribersCount += 1;
435
+ deps.logger(`[admin-events] emit event=${sseEvent} sessionId=${event.sessionId} subscribers=1`);
436
+ void out.write(`event: ${sseEvent}\ndata: ${JSON.stringify(payload)}\n\n`);
437
+ });
438
+ // Keep-alive comment every 25 s. Proxies aggressively close idle
439
+ // text/event-stream connections at 30–60 s; a comment line is
440
+ // ignored by EventSource consumers but resets the proxy clock.
441
+ const keepAlive = setInterval(() => {
442
+ if (!active)
443
+ return;
444
+ void out.write(`: keep-alive ${Date.now()}\n\n`);
445
+ }, 25_000);
446
+ out.onAbort(() => {
447
+ active = false;
448
+ clearInterval(keepAlive);
449
+ unsubscribe();
450
+ const durationMs = Date.now() - connectStart;
451
+ deps.logger(`[admin-events] client-disconnected ip=${clientIp} duration-ms=${durationMs} emits=${subscribersCount}`);
452
+ });
453
+ // Park the stream open until the client aborts. Without this the
454
+ // handler returns and Hono closes the response.
455
+ while (active) {
456
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
457
+ }
458
+ });
459
+ });
288
460
  app.get('/list', (c) => {
289
461
  const start = Date.now();
290
462
  const senderId = c.req.query('senderId') ?? '';
@@ -295,36 +467,22 @@ export function buildHttpApp(deps) {
295
467
  }
296
468
  const liveStored = deps.store.listBySender(senderId, { includeHidden });
297
469
  const live = liveStored.map(toLivePayload);
298
- // Live rows dedup against dehydrated by intrinsic id when promoted;
299
- // un-promoted rows have no intrinsic id and cannot collide with disk.
300
- const liveIntrinsicIds = new Set();
301
- for (const s of liveStored) {
302
- if (s.claudeSessionId !== null)
303
- liveIntrinsicIds.add(s.claudeSessionId);
304
- }
470
+ const liveSessionIds = new Set();
471
+ for (const s of liveStored)
472
+ liveSessionIds.add(s.sessionId);
473
+ // Archived rows come from the watcher's JSONL index (rows whose
474
+ // state === 'archived'). The watcher's boot-seed already paired
475
+ // PID files with JSONLs on disk, so archived rows here are exactly
476
+ // the sessions whose PTY has exited but transcript persists.
305
477
  const dehydrated = [];
306
- for (const j of listJsonlSessions(deps.spawnCwd, LIST_TAIL_BYTES, deps.logger)) {
307
- if (liveIntrinsicIds.has(j.claudeSessionId))
478
+ for (const row of deps.watcher.all()) {
479
+ if (row.state !== 'archived')
308
480
  continue;
309
- dehydrated.push({
310
- sessionId: j.claudeSessionId,
311
- claudeSessionId: j.claudeSessionId,
312
- pid: null,
313
- url: null,
314
- startedAt: j.startedAt,
315
- senderId,
316
- role: null,
317
- channel: null,
318
- jsonlPath: j.jsonlPath,
319
- status: 'ended',
320
- displayName: j.displayName,
321
- lastMessageAt: j.lastMessageAt,
322
- turnCount: j.turnCount,
323
- capped: j.capped,
324
- sizeBytes: j.sizeBytes,
325
- hidden: false,
326
- specialist: null,
327
- });
481
+ if (liveSessionIds.has(row.sessionId))
482
+ continue;
483
+ if (!row.jsonlPath)
484
+ continue;
485
+ dehydrated.push(toArchivedPayload(row, senderId));
328
486
  }
329
487
  deps.logger(`list-source live=${live.length} jsonl=${dehydrated.length}`);
330
488
  const merged = [...live, ...dehydrated];
@@ -362,7 +520,7 @@ export function buildHttpApp(deps) {
362
520
  : body.tunnelUrl === null
363
521
  ? null
364
522
  : undefined;
365
- const result = await spawnClaudeSession({ ...deps, store: deps.store, onJsonlObserved: (s) => attachPublicAudit(deps, s), tunnelUrlOverride }, {
523
+ const result = await spawnClaudeSession({ ...deps, store: deps.store, watcher: deps.watcher, onSessionReady: (s) => attachPublicAudit(deps, s), tunnelUrlOverride }, {
366
524
  senderId,
367
525
  role: role,
368
526
  channel: channel,
@@ -374,22 +532,34 @@ export function buildHttpApp(deps) {
374
532
  specialistDomains,
375
533
  });
376
534
  if (!result.ok) {
535
+ if ('rejected' in result) {
536
+ timed(deps.logger, 'POST', '/resume', 503, Date.now() - start);
537
+ if (result.rejected.kind === 'specialist-cap-reached') {
538
+ return c.json({ error: 'specialist-cap-reached', currentCount: result.rejected.currentCount, cap: result.rejected.cap }, 503);
539
+ }
540
+ return c.json({ error: 'operator-slots-reserved', total: result.rejected.total, reserve: result.rejected.reserve, totalCap: result.rejected.totalCap }, 503);
541
+ }
377
542
  deps.logger(`resume-failed reason=${result.reason} resumeFrom=${claudeSessionId}`);
378
543
  timed(deps.logger, 'POST', '/resume', 500, Date.now() - start);
379
544
  return c.json({ error: 'resume-failed', reason: result.reason }, 500);
380
545
  }
381
- deps.logger(`resume pid=${result.session.pid} sessionUrl=${result.session.url} resumeFrom=${claudeSessionId} latency-ms=${Date.now() - start}`);
546
+ deps.logger(`resume pid=${result.session.pid} sessionId=${result.session.sessionId} resumeFrom=${claudeSessionId} latency-ms=${Date.now() - start}`);
382
547
  timed(deps.logger, 'POST', '/resume', 200, Date.now() - start);
383
548
  return c.json(toLivePayload(result.session), 200);
384
549
  });
385
550
  app.get('/:sessionId/meta', (c) => {
386
551
  const start = Date.now();
387
- const sessionId = c.req.param('sessionId');
388
- if (!SESSION_ID_PATTERN.test(sessionId)) {
389
- timed(deps.logger, 'GET', `/${sessionId}/meta`, 400, Date.now() - start);
552
+ const id = c.req.param('sessionId');
553
+ if (!SESSION_ID_PATTERN.test(id)) {
554
+ timed(deps.logger, 'GET', `/${id}/meta`, 400, Date.now() - start);
390
555
  return c.json({ error: 'invalid-session-id' }, 400);
391
556
  }
392
- const live = deps.store.get(sessionId);
557
+ const row = resolveRow(deps.watcher, id);
558
+ if (!row) {
559
+ timed(deps.logger, 'GET', `/${id}/meta`, 404, Date.now() - start);
560
+ return c.json({ error: 'session-not-found' }, 404);
561
+ }
562
+ const live = deps.store.get(row.sessionId);
393
563
  let payload;
394
564
  if (live) {
395
565
  const base = toLivePayload(live);
@@ -397,8 +567,8 @@ export function buildHttpApp(deps) {
397
567
  let turnCount = 0;
398
568
  let capped = false;
399
569
  let sizeBytes = base.sizeBytes;
400
- if (live.claudeSessionId && live.jsonlPath && existsSync(live.jsonlPath)) {
401
- const enriched = readJsonlSession(deps.spawnCwd, live.claudeSessionId, deps.jsonlReadMaxBytes, deps.logger);
570
+ if (live.jsonlPath && existsSync(live.jsonlPath)) {
571
+ const enriched = readJsonlSession(deps.spawnCwd, live.sessionId, deps.jsonlReadMaxBytes, deps.logger);
402
572
  if (enriched) {
403
573
  lastMessageAt = enriched.lastMessageAt;
404
574
  turnCount = enriched.turnCount;
@@ -406,50 +576,20 @@ export function buildHttpApp(deps) {
406
576
  sizeBytes = enriched.sizeBytes;
407
577
  }
408
578
  }
409
- payload = {
410
- ...base,
411
- lastMessageAt,
412
- turnCount,
413
- capped,
414
- sizeBytes,
415
- };
579
+ payload = { ...base, lastMessageAt, turnCount, capped, sizeBytes };
416
580
  }
417
581
  else {
418
- const enriched = readJsonlSession(deps.spawnCwd, sessionId, deps.jsonlReadMaxBytes, deps.logger);
419
- if (!enriched) {
420
- timed(deps.logger, 'GET', `/${sessionId}/meta`, 404, Date.now() - start);
421
- return c.json({ error: 'session-not-found' }, 404);
422
- }
423
- payload = {
424
- sessionId: enriched.claudeSessionId,
425
- claudeSessionId: enriched.claudeSessionId,
426
- pid: null,
427
- url: null,
428
- startedAt: enriched.startedAt,
429
- senderId: '',
430
- role: null,
431
- channel: null,
432
- jsonlPath: enriched.jsonlPath,
433
- status: 'ended',
434
- displayName: enriched.displayName,
435
- lastMessageAt: enriched.lastMessageAt,
436
- turnCount: enriched.turnCount,
437
- capped: enriched.capped,
438
- sizeBytes: enriched.sizeBytes,
439
- hidden: false,
440
- specialist: null,
441
- };
442
- deps.logger(`meta-jsonl-tail-bytes sessionId=${sessionId.slice(0, 8)} read=${Math.min(enriched.sizeBytes, deps.jsonlReadMaxBytes)} capped=${enriched.capped}`);
582
+ payload = toArchivedPayload(row, '');
443
583
  }
444
- deps.logger(`meta sessionId=${sessionId.slice(0, 8)} fields=${Object.keys(payload).length} ms=${Date.now() - start}`);
445
- timed(deps.logger, 'GET', `/${sessionId}/meta`, 200, Date.now() - start);
584
+ deps.logger(`meta sessionId=${row.sessionId.slice(0, 8)} fields=${Object.keys(payload).length} ms=${Date.now() - start}`);
585
+ timed(deps.logger, 'GET', `/${id}/meta`, 200, Date.now() - start);
446
586
  return c.json(payload, 200);
447
587
  });
448
588
  app.patch('/:sessionId', async (c) => {
449
589
  const start = Date.now();
450
- const sessionId = c.req.param('sessionId');
451
- if (!SESSION_ID_PATTERN.test(sessionId)) {
452
- timed(deps.logger, 'PATCH', `/${sessionId}`, 400, Date.now() - start);
590
+ const id = c.req.param('sessionId');
591
+ if (!SESSION_ID_PATTERN.test(id)) {
592
+ timed(deps.logger, 'PATCH', `/${id}`, 400, Date.now() - start);
453
593
  return c.json({ error: 'invalid-session-id' }, 400);
454
594
  }
455
595
  let body;
@@ -457,38 +597,38 @@ export function buildHttpApp(deps) {
457
597
  body = await c.req.json();
458
598
  }
459
599
  catch {
460
- timed(deps.logger, 'PATCH', `/${sessionId}`, 400, Date.now() - start);
600
+ timed(deps.logger, 'PATCH', `/${id}`, 400, Date.now() - start);
461
601
  return c.json({ error: 'body-not-json' }, 400);
462
602
  }
463
603
  if (typeof body.displayName !== 'string' || body.displayName.length === 0 || body.displayName.length > DISPLAY_NAME_MAX) {
464
- timed(deps.logger, 'PATCH', `/${sessionId}`, 400, Date.now() - start);
604
+ timed(deps.logger, 'PATCH', `/${id}`, 400, Date.now() - start);
465
605
  return c.json({ error: 'invalid-displayName', detail: { maxLen: DISPLAY_NAME_MAX } }, 400);
466
606
  }
467
- const resolved = resolveJsonlPath(deps, sessionId);
468
- if (!resolved) {
469
- timed(deps.logger, 'PATCH', `/${sessionId}`, 404, Date.now() - start);
607
+ const row = resolveRow(deps.watcher, id);
608
+ if (!row || !row.jsonlPath) {
609
+ timed(deps.logger, 'PATCH', `/${id}`, 404, Date.now() - start);
470
610
  return c.json({ error: 'session-not-found' }, 404);
471
611
  }
472
- const sidecarPath = sidecarPathFor(resolved.path);
473
- const prior = readSidecar(resolved.path)?.displayName ?? null;
612
+ const sidecarPath = sidecarPathFor(row.jsonlPath);
613
+ const prior = readSidecar(row.jsonlPath)?.displayName ?? null;
474
614
  try {
475
615
  writeFileSync(sidecarPath, JSON.stringify({ displayName: body.displayName }, null, 2) + '\n', 'utf8');
476
616
  }
477
617
  catch (err) {
478
618
  const msg = err instanceof Error ? err.message : String(err);
479
- deps.logger(`rename-failed sessionId=${sessionId.slice(0, 8)} message=${msg}`);
480
- timed(deps.logger, 'PATCH', `/${sessionId}`, 500, Date.now() - start);
619
+ deps.logger(`rename-failed sessionId=${row.sessionId.slice(0, 8)} message=${msg}`);
620
+ timed(deps.logger, 'PATCH', `/${id}`, 500, Date.now() - start);
481
621
  return c.json({ error: 'sidecar-write-failed', detail: msg }, 500);
482
622
  }
483
- deps.logger(`rename sessionId=${sessionId.slice(0, 8)} from=${prior ?? ''} to=${body.displayName}`);
484
- timed(deps.logger, 'PATCH', `/${sessionId}`, 200, Date.now() - start);
623
+ deps.logger(`rename sessionId=${row.sessionId.slice(0, 8)} from=${prior ?? ''} to=${body.displayName}`);
624
+ timed(deps.logger, 'PATCH', `/${id}`, 200, Date.now() - start);
485
625
  return c.json({ ok: true, displayName: body.displayName }, 200);
486
626
  });
487
627
  app.post('/:sessionId/archive', async (c) => {
488
628
  const start = Date.now();
489
- const sessionId = c.req.param('sessionId');
490
- if (!SESSION_ID_PATTERN.test(sessionId)) {
491
- timed(deps.logger, 'POST', `/${sessionId}/archive`, 400, Date.now() - start);
629
+ const id = c.req.param('sessionId');
630
+ if (!SESSION_ID_PATTERN.test(id)) {
631
+ timed(deps.logger, 'POST', `/${id}/archive`, 400, Date.now() - start);
492
632
  return c.json({ error: 'invalid-session-id' }, 400);
493
633
  }
494
634
  let body;
@@ -496,30 +636,35 @@ export function buildHttpApp(deps) {
496
636
  body = await c.req.json();
497
637
  }
498
638
  catch {
499
- timed(deps.logger, 'POST', `/${sessionId}/archive`, 400, Date.now() - start);
639
+ timed(deps.logger, 'POST', `/${id}/archive`, 400, Date.now() - start);
500
640
  return c.json({ error: 'body-not-json' }, 400);
501
641
  }
502
642
  const mode = body.mode;
503
643
  if (mode !== 'archive' && mode !== 'unarchive') {
504
- timed(deps.logger, 'POST', `/${sessionId}/archive`, 400, Date.now() - start);
644
+ timed(deps.logger, 'POST', `/${id}/archive`, 400, Date.now() - start);
505
645
  return c.json({ error: 'invalid-mode', detail: { expected: ['archive', 'unarchive'] } }, 400);
506
646
  }
507
- if (deps.store.get(sessionId)) {
508
- deps.logger(`archive-rejected sessionId=${sessionId.slice(0, 8)} reason=pty-still-alive`);
509
- timed(deps.logger, 'POST', `/${sessionId}/archive`, 409, Date.now() - start);
647
+ const row = resolveRow(deps.watcher, id);
648
+ if (!row) {
649
+ timed(deps.logger, 'POST', `/${id}/archive`, 404, Date.now() - start);
650
+ return c.json({ error: 'session-not-found' }, 404);
651
+ }
652
+ if (deps.store.get(row.sessionId)) {
653
+ deps.logger(`archive-rejected sessionId=${row.sessionId.slice(0, 8)} reason=pty-still-alive`);
654
+ timed(deps.logger, 'POST', `/${id}/archive`, 409, Date.now() - start);
510
655
  return c.json({ error: 'pty-still-alive', detail: 'end the session before archiving' }, 409);
511
656
  }
512
657
  const slug = projectSlugForCwd(deps.spawnCwd);
513
658
  const slugDir = join(claudeStateRoot(), 'projects', slug);
514
659
  const archiveDir = join(slugDir, 'archive');
515
- const fileName = `${sessionId}.jsonl`;
516
- const sidecarName = `${sessionId}.meta.json`;
660
+ const fileName = `${row.sessionId}.jsonl`;
661
+ const sidecarName = `${row.sessionId}.meta.json`;
517
662
  const fromJsonl = mode === 'archive' ? join(slugDir, fileName) : join(archiveDir, fileName);
518
663
  const toJsonl = mode === 'archive' ? join(archiveDir, fileName) : join(slugDir, fileName);
519
664
  const fromSidecar = mode === 'archive' ? join(slugDir, sidecarName) : join(archiveDir, sidecarName);
520
665
  const toSidecar = mode === 'archive' ? join(archiveDir, sidecarName) : join(slugDir, sidecarName);
521
666
  if (!existsSync(fromJsonl)) {
522
- timed(deps.logger, 'POST', `/${sessionId}/archive`, 404, Date.now() - start);
667
+ timed(deps.logger, 'POST', `/${id}/archive`, 404, Date.now() - start);
523
668
  return c.json({ error: 'session-not-found' }, 404);
524
669
  }
525
670
  try {
@@ -531,49 +676,63 @@ export function buildHttpApp(deps) {
531
676
  }
532
677
  catch (err) {
533
678
  const msg = err instanceof Error ? err.message : String(err);
534
- deps.logger(`archive-failed sessionId=${sessionId.slice(0, 8)} mode=${mode} message=${msg}`);
535
- timed(deps.logger, 'POST', `/${sessionId}/archive`, 500, Date.now() - start);
679
+ deps.logger(`archive-failed sessionId=${row.sessionId.slice(0, 8)} mode=${mode} message=${msg}`);
680
+ timed(deps.logger, 'POST', `/${id}/archive`, 500, Date.now() - start);
536
681
  return c.json({ error: 'archive-failed', detail: msg }, 500);
537
682
  }
538
- deps.logger(`archive sessionId=${sessionId.slice(0, 8)} from=${basename(fromJsonl)} to=${join(mode === 'archive' ? 'archive' : '.', basename(toJsonl))}`);
539
- timed(deps.logger, 'POST', `/${sessionId}/archive`, 200, Date.now() - start);
683
+ deps.logger(`archive sessionId=${row.sessionId.slice(0, 8)} from=${basename(fromJsonl)} to=${join(mode === 'archive' ? 'archive' : '.', basename(toJsonl))}`);
684
+ timed(deps.logger, 'POST', `/${id}/archive`, 200, Date.now() - start);
540
685
  return c.json({ ok: true, mode }, 200);
541
686
  });
542
- app.delete('/:sessionId', async (c) => {
687
+ // SIGTERM-only stop. Idempotent on already-dead PTYs. JSONL untouched.
688
+ app.post('/:sessionId/stop', async (c) => {
543
689
  const start = Date.now();
544
- const sessionId = c.req.param('sessionId');
545
- const purge = c.req.query('purge') === '1';
546
- if (!SESSION_ID_PATTERN.test(sessionId)) {
547
- timed(deps.logger, 'DELETE', `/${sessionId}`, 400, Date.now() - start);
690
+ const id = c.req.param('sessionId');
691
+ if (!SESSION_ID_PATTERN.test(id) && !/^\d+$/.test(id)) {
692
+ timed(deps.logger, 'POST', `/${id}/stop`, 400, Date.now() - start);
548
693
  return c.json({ error: 'invalid-session-id' }, 400);
549
694
  }
550
- if (!purge) {
551
- await deleteSession({ store: deps.store, killGraceMs: deps.killGraceMs, logger: deps.logger }, sessionId);
552
- timed(deps.logger, 'DELETE', `/${sessionId}`, 204, Date.now() - start);
553
- return c.body(null, 204);
695
+ const row = resolveRow(deps.watcher, id);
696
+ if (!row && !deps.store.get(id)) {
697
+ timed(deps.logger, 'POST', `/${id}/stop`, 404, Date.now() - start);
698
+ return c.json({ error: 'session-not-found' }, 404);
699
+ }
700
+ const sessionId = row?.sessionId ?? id;
701
+ await stopSession({ store: deps.store, killGraceMs: deps.killGraceMs, logger: deps.logger }, sessionId);
702
+ timed(deps.logger, 'POST', `/${sessionId}/stop`, 204, Date.now() - start);
703
+ return c.body(null, 204);
704
+ });
705
+ // Hard-remove transcript. 409 if PTY is still alive (stop first).
706
+ app.delete('/:sessionId', async (c) => {
707
+ const start = Date.now();
708
+ const id = c.req.param('sessionId');
709
+ if (!SESSION_ID_PATTERN.test(id) && !/^\d+$/.test(id)) {
710
+ timed(deps.logger, 'DELETE', `/${id}`, 400, Date.now() - start);
711
+ return c.json({ error: 'invalid-session-id' }, 400);
554
712
  }
713
+ const row = resolveRow(deps.watcher, id);
714
+ const sessionId = row?.sessionId ?? id;
555
715
  if (deps.store.get(sessionId)) {
556
- deps.logger(`purge-rejected sessionId=${sessionId.slice(0, 8)} reason=pty-still-alive`);
557
- timed(deps.logger, 'DELETE', `/${sessionId}?purge=1`, 409, Date.now() - start);
558
- return c.json({ error: 'pty-still-alive', detail: 'end the session before purging' }, 409);
716
+ deps.logger(`delete-rejected sessionId=${sessionId.slice(0, 8)} reason=pty-still-alive`);
717
+ timed(deps.logger, 'DELETE', `/${id}`, 409, Date.now() - start);
718
+ return c.json({ error: 'pty-still-alive', detail: 'stop the session before deleting' }, 409);
559
719
  }
560
- const resolved = resolveJsonlPath(deps, sessionId);
561
- if (!resolved) {
562
- timed(deps.logger, 'DELETE', `/${sessionId}?purge=1`, 404, Date.now() - start);
720
+ if (!row || !row.jsonlPath) {
721
+ timed(deps.logger, 'DELETE', `/${id}`, 404, Date.now() - start);
563
722
  return c.json({ error: 'session-not-found' }, 404);
564
723
  }
565
- const sidecarPath = sidecarPathFor(resolved.path);
724
+ const sidecarPath = sidecarPathFor(row.jsonlPath);
566
725
  const subdir = join(claudeStateRoot(), 'projects', projectSlugForCwd(deps.spawnCwd), sessionId);
567
726
  let bytes = 0;
568
727
  try {
569
- bytes = existsSync(resolved.path) ? statSync(resolved.path).size : 0;
728
+ bytes = existsSync(row.jsonlPath) ? statSync(row.jsonlPath).size : 0;
570
729
  }
571
730
  catch {
572
- // ignore — size is informational
731
+ // size is informational
573
732
  }
574
733
  try {
575
- if (existsSync(resolved.path))
576
- rmSync(resolved.path);
734
+ if (existsSync(row.jsonlPath))
735
+ rmSync(row.jsonlPath);
577
736
  if (existsSync(sidecarPath))
578
737
  rmSync(sidecarPath);
579
738
  if (existsSync(subdir))
@@ -581,24 +740,26 @@ export function buildHttpApp(deps) {
581
740
  }
582
741
  catch (err) {
583
742
  const msg = err instanceof Error ? err.message : String(err);
584
- deps.logger(`purge-failed sessionId=${sessionId.slice(0, 8)} message=${msg}`);
585
- timed(deps.logger, 'DELETE', `/${sessionId}?purge=1`, 500, Date.now() - start);
586
- return c.json({ error: 'purge-failed', detail: msg }, 500);
743
+ deps.logger(`delete-failed sessionId=${sessionId.slice(0, 8)} message=${msg}`);
744
+ timed(deps.logger, 'DELETE', `/${id}`, 500, Date.now() - start);
745
+ return c.json({ error: 'delete-failed', detail: msg }, 500);
587
746
  }
588
747
  deps.logger(`purge sessionId=${sessionId.slice(0, 8)} bytes=${bytes}`);
589
- timed(deps.logger, 'DELETE', `/${sessionId}?purge=1`, 204, Date.now() - start);
748
+ timed(deps.logger, 'DELETE', `/${id}`, 204, Date.now() - start);
590
749
  return c.body(null, 204);
591
750
  });
592
751
  app.post('/:sessionId/input', async (c) => {
593
752
  const start = Date.now();
594
- const sessionId = c.req.param('sessionId');
753
+ const id = c.req.param('sessionId');
754
+ const row = resolveRow(deps.watcher, id);
755
+ const sessionId = row?.sessionId ?? id;
595
756
  const s = deps.store.get(sessionId);
596
757
  if (!s) {
597
- timed(deps.logger, 'POST', `/${sessionId}/input`, 404, Date.now() - start);
758
+ timed(deps.logger, 'POST', `/${id}/input`, 404, Date.now() - start);
598
759
  return c.json({ error: 'session-not-found' }, 404);
599
760
  }
600
761
  if (s.exited) {
601
- timed(deps.logger, 'POST', `/${sessionId}/input`, 410, Date.now() - start);
762
+ timed(deps.logger, 'POST', `/${id}/input`, 410, Date.now() - start);
602
763
  return c.json({ error: 'session-exited' }, 410);
603
764
  }
604
765
  let body;
@@ -606,11 +767,11 @@ export function buildHttpApp(deps) {
606
767
  body = await c.req.json();
607
768
  }
608
769
  catch {
609
- timed(deps.logger, 'POST', `/${sessionId}/input`, 400, Date.now() - start);
770
+ timed(deps.logger, 'POST', `/${id}/input`, 400, Date.now() - start);
610
771
  return c.json({ error: 'body-not-json' }, 400);
611
772
  }
612
773
  if (typeof body.text !== 'string') {
613
- timed(deps.logger, 'POST', `/${sessionId}/input`, 400, Date.now() - start);
774
+ timed(deps.logger, 'POST', `/${id}/input`, 400, Date.now() - start);
614
775
  return c.json({ error: 'text-required' }, 400);
615
776
  }
616
777
  const bytes = Buffer.byteLength(body.text, 'utf8');
@@ -620,26 +781,25 @@ export function buildHttpApp(deps) {
620
781
  catch (err) {
621
782
  const msg = err instanceof Error ? err.message : String(err);
622
783
  deps.logger(`input-failed pid=${s.pid} sessionId=${sessionId} message=${msg}`);
623
- timed(deps.logger, 'POST', `/${sessionId}/input`, 500, Date.now() - start);
784
+ timed(deps.logger, 'POST', `/${id}/input`, 500, Date.now() - start);
624
785
  return c.json({ error: 'pty-write-failed', detail: msg }, 500);
625
786
  }
626
787
  deps.logger(`input pid=${s.pid} sessionId=${sessionId} bytes=${bytes} channel=${s.channel}`);
627
- timed(deps.logger, 'POST', `/${sessionId}/input`, 200, Date.now() - start);
788
+ timed(deps.logger, 'POST', `/${id}/input`, 200, Date.now() - start);
628
789
  return c.json({ ok: true, bytes }, 200);
629
790
  });
630
791
  app.get('/:sessionId/log', (c) => {
631
792
  const start = Date.now();
632
- const sessionId = c.req.param('sessionId');
793
+ const id = c.req.param('sessionId');
794
+ const row = resolveRow(deps.watcher, id);
795
+ const sessionId = row?.sessionId ?? id;
633
796
  const s = deps.store.get(sessionId);
634
797
  if (!s) {
635
- timed(deps.logger, 'GET', `/${sessionId}/log`, 404, Date.now() - start);
798
+ timed(deps.logger, 'GET', `/${id}/log`, 404, Date.now() - start);
636
799
  return c.json({ error: 'session-not-found' }, 404);
637
800
  }
638
801
  if (s.jsonlPath === null) {
639
- // alive but pre-promotion: the first operator turn has not landed,
640
- // so claude has not written a JSONL yet. Loud-fail with 202 so the
641
- // UI can re-poll once `claudeSessionId` flips on a subsequent /list.
642
- timed(deps.logger, 'GET', `/${sessionId}/log`, 202, Date.now() - start);
802
+ timed(deps.logger, 'GET', `/${id}/log`, 202, Date.now() - start);
643
803
  return c.json({ jsonlPath: null, exists: false, sizeBytes: 0, pending: true }, 202);
644
804
  }
645
805
  const jsonlPath = s.jsonlPath;
@@ -647,10 +807,10 @@ export function buildHttpApp(deps) {
647
807
  if (!follow) {
648
808
  const exists = existsSync(jsonlPath);
649
809
  const sizeBytes = exists ? statSync(jsonlPath).size : 0;
650
- timed(deps.logger, 'GET', `/${sessionId}/log`, 200, Date.now() - start);
810
+ timed(deps.logger, 'GET', `/${id}/log`, 200, Date.now() - start);
651
811
  return c.json({ jsonlPath, exists, sizeBytes }, 200);
652
812
  }
653
- timed(deps.logger, 'GET', `/${sessionId}/log?follow=1`, 200, Date.now() - start);
813
+ timed(deps.logger, 'GET', `/${id}/log?follow=1`, 200, Date.now() - start);
654
814
  return stream(c, async (out) => {
655
815
  let position = 0;
656
816
  if (existsSync(jsonlPath)) {