@rryando/arcs 5.1.0 → 5.2.1

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 (219) hide show
  1. package/README.md +30 -17
  2. package/dist/cli/arcs-orchestrate.d.ts +1 -1
  3. package/dist/cli/arcs-orchestrate.d.ts.map +1 -1
  4. package/dist/cli/commands/bundle.js +39 -0
  5. package/dist/cli/commands/bundle.js.map +1 -1
  6. package/dist/cli/commands/proposal-doc.js +76 -58
  7. package/dist/cli/commands/proposal-doc.js.map +1 -1
  8. package/dist/cli/commands/web.js +2 -2
  9. package/dist/cli/commands/web.js.map +1 -1
  10. package/dist/cli/config.d.ts +37 -9
  11. package/dist/cli/config.d.ts.map +1 -1
  12. package/dist/cli/config.js +64 -5
  13. package/dist/cli/config.js.map +1 -1
  14. package/dist/cli/instructions.d.ts +3 -3
  15. package/dist/cli/instructions.d.ts.map +1 -1
  16. package/dist/cli/instructions.js +10 -2
  17. package/dist/cli/instructions.js.map +1 -1
  18. package/dist/cli/orchestrator-shared-blocks.d.ts +1 -1
  19. package/dist/cli/orchestrator-shared-blocks.d.ts.map +1 -1
  20. package/dist/cli/orchestrator-shared-blocks.js +1 -1
  21. package/dist/cli/setup.d.ts.map +1 -1
  22. package/dist/cli/setup.js +281 -47
  23. package/dist/cli/setup.js.map +1 -1
  24. package/dist/utils/json-schemas.d.ts +32 -9
  25. package/dist/utils/json-schemas.d.ts.map +1 -1
  26. package/dist/utils/json-schemas.js +8 -1
  27. package/dist/utils/json-schemas.js.map +1 -1
  28. package/dist/utils/project-resolver.d.ts.map +1 -1
  29. package/dist/utils/project-resolver.js +9 -2
  30. package/dist/utils/project-resolver.js.map +1 -1
  31. package/dist/utils/run-transcript.d.ts +138 -0
  32. package/dist/utils/run-transcript.d.ts.map +1 -0
  33. package/dist/utils/run-transcript.js +146 -0
  34. package/dist/utils/run-transcript.js.map +1 -0
  35. package/dist/utils/storage-utils.d.ts +1 -1
  36. package/dist/utils/storage-utils.d.ts.map +1 -1
  37. package/dist/utils/storage-utils.js +1 -1
  38. package/dist/utils/storage-utils.js.map +1 -1
  39. package/dist/web-client/assets/{GraphCanvas-BYZE4sO9.js → GraphCanvas-BgWF1rB_.js} +1 -1
  40. package/dist/web-client/assets/{MarkdownEditor-BKx6M1cw.js → MarkdownEditor-5buYPQ1d.js} +1 -1
  41. package/dist/web-client/assets/{abnfDiagram-VRR7QNED-D4kt0l2y.js → abnfDiagram-VRR7QNED-AjQ08-zY.js} +1 -1
  42. package/dist/web-client/assets/architecture-TIHT7OUA-a3lTAuFS.js +1 -0
  43. package/dist/web-client/assets/{architectureDiagram-ZJ3FMSHR-bu29SJFS.js → architectureDiagram-ZJ3FMSHR-CtW2pYEp.js} +1 -1
  44. package/dist/web-client/assets/{blockDiagram-677ZJIJ3-hsu7mXKw.js → blockDiagram-677ZJIJ3-DE6HRcpA.js} +1 -1
  45. package/dist/web-client/assets/{c4Diagram-LMCZKHZV-DMllRlT_.js → c4Diagram-LMCZKHZV-Rn6iQdUl.js} +1 -1
  46. package/dist/web-client/assets/channel-Rxk7gzWl.js +1 -0
  47. package/dist/web-client/assets/{chunk-32BRIVSS-BSzwj5eM.js → chunk-32BRIVSS-C4-ay_Qd.js} +1 -1
  48. package/dist/web-client/assets/{chunk-52WLFC77-RDdZn6yY.js → chunk-52WLFC77-Bib0DU0H.js} +1 -1
  49. package/dist/web-client/assets/{chunk-C7G6YPKG-DQr--txT.js → chunk-C7G6YPKG-BLphnESE.js} +1 -1
  50. package/dist/web-client/assets/{chunk-EX3LRPZG-Bb8nc4z4.js → chunk-EX3LRPZG-CzvkrPNM.js} +1 -1
  51. package/dist/web-client/assets/{chunk-FWX5IMBZ-QvBpOcOg.js → chunk-FWX5IMBZ-BFTR_OLz.js} +2 -2
  52. package/dist/web-client/assets/{chunk-HOUHSVGY-B0mX_yjc.js → chunk-HOUHSVGY-CZS3ExUR.js} +1 -1
  53. package/dist/web-client/assets/{chunk-ICXQ74PX-BmJSQqOH.js → chunk-ICXQ74PX-iWFpNZgv.js} +1 -1
  54. package/dist/web-client/assets/{chunk-MOJQB5TN-BrEI4GGn.js → chunk-MOJQB5TN-opEQCwVq.js} +1 -1
  55. package/dist/web-client/assets/{chunk-OGEWGWER-CrqDPOWP.js → chunk-OGEWGWER-NCfmjyhi.js} +1 -1
  56. package/dist/web-client/assets/{chunk-PUDLZKDR-DfPKldpG.js → chunk-PUDLZKDR-D3_A--Z2.js} +1 -1
  57. package/dist/web-client/assets/{chunk-Q4XR5HBZ-ekATI9aV.js → chunk-Q4XR5HBZ-D6s-qZLi.js} +1 -1
  58. package/dist/web-client/assets/{chunk-V7JOEXUC-C5APsP1t.js → chunk-V7JOEXUC-DTqLR-bZ.js} +1 -1
  59. package/dist/web-client/assets/{chunk-VAUOI2AC-BhfSWJZI.js → chunk-VAUOI2AC-Gsd0Itap.js} +1 -1
  60. package/dist/web-client/assets/{chunk-VR4S4FIN-BGa-44J7.js → chunk-VR4S4FIN-Cdo_MeDQ.js} +1 -1
  61. package/dist/web-client/assets/{chunk-WYO6CB5R-By1K0guW.js → chunk-WYO6CB5R-n7FhYrli.js} +1 -1
  62. package/dist/web-client/assets/{chunk-ZGVPDNZ5-Vnzpc76F.js → chunk-ZGVPDNZ5-DU1Jcue0.js} +1 -1
  63. package/dist/web-client/assets/classDiagram-OUVF2IWQ-BG5uYDvC.js +1 -0
  64. package/dist/web-client/assets/classDiagram-v2-EOCWNBFH-BG5uYDvC.js +1 -0
  65. package/dist/web-client/assets/{cynefin-VYW2F7L2-C6MOMOOz.js → cynefin-VYW2F7L2-D-gD9LCO.js} +1 -1
  66. package/dist/web-client/assets/{cynefinDiagram-TSTJHNR4-Cseyu79b.js → cynefinDiagram-TSTJHNR4-BFGkcrNl.js} +1 -1
  67. package/dist/web-client/assets/{dagre-VKFMJZFB-CQJlIuNh.js → dagre-VKFMJZFB-BUaC6fks.js} +1 -1
  68. package/dist/web-client/assets/{diagram-FQU43EPY-DpqDxhq4.js → diagram-FQU43EPY-Ckgu02XC.js} +1 -1
  69. package/dist/web-client/assets/{diagram-G47NLZAW-C11fYYaF.js → diagram-G47NLZAW-BickHhmE.js} +1 -1
  70. package/dist/web-client/assets/{diagram-NH7WQ7WH-BiG-uRAF.js → diagram-NH7WQ7WH-CoqgJpcU.js} +1 -1
  71. package/dist/web-client/assets/{diagram-OA4YK3LP-CIXWWjq-.js → diagram-OA4YK3LP-CTSZWqCe.js} +1 -1
  72. package/dist/web-client/assets/{diagram-WEI45ONY-C3OgIWu9.js → diagram-WEI45ONY-D7xd1naU.js} +1 -1
  73. package/dist/web-client/assets/{ebnfDiagram-CCIWWBDH-BHF_NA3_.js → ebnfDiagram-CCIWWBDH-FESKYpKQ.js} +1 -1
  74. package/dist/web-client/assets/{erDiagram-Q63AITRT-CUOJCrLy.js → erDiagram-Q63AITRT-C-879ftU.js} +1 -1
  75. package/dist/web-client/assets/eventmodeling-45OFAUF4-C3kRZCei.js +1 -0
  76. package/dist/web-client/assets/flowDiagram-23GEKE2U-QKWFycdH.js +1 -0
  77. package/dist/web-client/assets/{ganttDiagram-NO4QXBWP-D_4BMJ-g.js → ganttDiagram-NO4QXBWP-mIX5nPpv.js} +1 -1
  78. package/dist/web-client/assets/{gitGraph-TEB2WS4Q-CBuaZBId.js → gitGraph-TEB2WS4Q-DlEDJ1IK.js} +1 -1
  79. package/dist/web-client/assets/{gitGraphDiagram-IHSO6WYX-BbztsGuO.js → gitGraphDiagram-IHSO6WYX-D2fXD4AO.js} +1 -1
  80. package/dist/web-client/assets/index-BsYR-C8u.js +61 -0
  81. package/dist/web-client/assets/index-Cc5DCTOr.css +2 -0
  82. package/dist/web-client/assets/{info-DKCQHKI2-Bzi0Xjro.js → info-DKCQHKI2-jinbCROt.js} +1 -1
  83. package/dist/web-client/assets/{infoDiagram-FWYZ7A6U-BkeEKcr0.js → infoDiagram-FWYZ7A6U-CM-A4TL8.js} +1 -1
  84. package/dist/web-client/assets/{ishikawaDiagram-FXEZZL3T-CVusWu0p.js → ishikawaDiagram-FXEZZL3T-BdFpHwp4.js} +1 -1
  85. package/dist/web-client/assets/{journeyDiagram-5HDEW3XC-BUxu71zw.js → journeyDiagram-5HDEW3XC-BwDWz25z.js} +1 -1
  86. package/dist/web-client/assets/{kanban-definition-HUTT4EX6-BE8Hv4Kd.js → kanban-definition-HUTT4EX6-_2QXjSvx.js} +1 -1
  87. package/dist/web-client/assets/{line-CiAoINJS.js → line-HCziev87.js} +1 -1
  88. package/dist/web-client/assets/{mermaid-parser.core-BW47khiS.js → mermaid-parser.core-C4139mkz.js} +3 -3
  89. package/dist/web-client/assets/{mermaid.core-DP--Jl9R.js → mermaid.core-ocHwkZiF.js} +3 -3
  90. package/dist/web-client/assets/{mindmap-definition-LN4V7U3C-CvWJUqdq.js → mindmap-definition-LN4V7U3C-DHyCmflp.js} +1 -1
  91. package/dist/web-client/assets/{packet-7NZHBO7P-XtzX9SaQ.js → packet-7NZHBO7P-Bk0w_irl.js} +1 -1
  92. package/dist/web-client/assets/{pegDiagram-2B236MQR-XNo0K1ct.js → pegDiagram-2B236MQR-D_GrmL93.js} +1 -1
  93. package/dist/web-client/assets/{pie-RZYD4A2V-B1eUd9yt.js → pie-RZYD4A2V-B6VIAbyj.js} +1 -1
  94. package/dist/web-client/assets/{pieDiagram-ENE6RG2P-CRSb5z-4.js → pieDiagram-ENE6RG2P-CiR2Y8y2.js} +1 -1
  95. package/dist/web-client/assets/{quadrantDiagram-ABIIQ3AL-DziMaQxE.js → quadrantDiagram-ABIIQ3AL-CLlicKmV.js} +1 -1
  96. package/dist/web-client/assets/{radar-I7S5WNFK-BpRqZH2g.js → radar-I7S5WNFK-B53XVRSZ.js} +1 -1
  97. package/dist/web-client/assets/{railroad-3IZDKUUU-FDIHW04k.js → railroad-3IZDKUUU-Dn8TN2M-.js} +1 -1
  98. package/dist/web-client/assets/railroad-abnf-AHOZXSZD-UrOKy6yx.js +1 -0
  99. package/dist/web-client/assets/railroad-ebnf-EBAXGLYW-Bx73sfMZ.js +1 -0
  100. package/dist/web-client/assets/railroad-peg-LSFZ7HO6-C4QEje-y.js +1 -0
  101. package/dist/web-client/assets/{railroadDiagram-RFXS5EU6-Cs7EVBaz.js → railroadDiagram-RFXS5EU6-C6kZwOio.js} +1 -1
  102. package/dist/web-client/assets/{requirementDiagram-TGXJPOKE-B9_Lj1ga.js → requirementDiagram-TGXJPOKE-D2b1MeP8.js} +1 -1
  103. package/dist/web-client/assets/{sankeyDiagram-HTMAVEWB-DuTJTiy0.js → sankeyDiagram-HTMAVEWB-pS_Ufq1E.js} +1 -1
  104. package/dist/web-client/assets/{sequenceDiagram-DBY2YBRQ-vOCU5UoE.js → sequenceDiagram-DBY2YBRQ-BrsKXNzu.js} +1 -1
  105. package/dist/web-client/assets/{stateDiagram-2N3HPSRC-rxAnfzWn.js → stateDiagram-2N3HPSRC-BuQKu3i0.js} +1 -1
  106. package/dist/web-client/assets/stateDiagram-v2-6OUMAXLB-BhqgaxDu.js +1 -0
  107. package/dist/web-client/assets/{swimlanes-5IMT3BWC-gdVZUnPe.js → swimlanes-5IMT3BWC-CzoImfTA.js} +1 -1
  108. package/dist/web-client/assets/swimlanesDiagram-G3AALYLV-CHOEI9hI.js +8 -0
  109. package/dist/web-client/assets/{timeline-definition-FHXFAJF6-Do68JyGy.js → timeline-definition-FHXFAJF6-DvezW7Fr.js} +1 -1
  110. package/dist/web-client/assets/{treeView-QDETBFTQ-CdZkmg80.js → treeView-QDETBFTQ-xu58R4kS.js} +1 -1
  111. package/dist/web-client/assets/{treemap-6X3UGDF4-ub54fmpW.js → treemap-6X3UGDF4-DMYBAOW1.js} +1 -1
  112. package/dist/web-client/assets/{vennDiagram-L72KCM5P-D-r8NBEp.js → vennDiagram-L72KCM5P-l1CbC7Ig.js} +1 -1
  113. package/dist/web-client/assets/{wardley-OPB4EBWU-CTleH1-J.js → wardley-OPB4EBWU-G3zmnnM0.js} +1 -1
  114. package/dist/web-client/assets/{wardleyDiagram-EHGQE667-BU8Kw39F.js → wardleyDiagram-EHGQE667-Dl2I79Vx.js} +1 -1
  115. package/dist/web-client/assets/{xychartDiagram-FW5EYKEG-CfNnag6i.js → xychartDiagram-FW5EYKEG-BWfge21N.js} +1 -1
  116. package/dist/web-client/index.html +2 -2
  117. package/dist/web-server/app.d.ts.map +1 -1
  118. package/dist/web-server/app.js +13 -7
  119. package/dist/web-server/app.js.map +1 -1
  120. package/dist/web-server/ask-prompt.d.ts +70 -0
  121. package/dist/web-server/ask-prompt.d.ts.map +1 -0
  122. package/dist/web-server/ask-prompt.js +238 -0
  123. package/dist/web-server/ask-prompt.js.map +1 -0
  124. package/dist/web-server/index.d.ts.map +1 -1
  125. package/dist/web-server/index.js +56 -3
  126. package/dist/web-server/index.js.map +1 -1
  127. package/dist/web-server/respond.d.ts.map +1 -1
  128. package/dist/web-server/respond.js +6 -1
  129. package/dist/web-server/respond.js.map +1 -1
  130. package/dist/web-server/routes/ask.d.ts +29 -0
  131. package/dist/web-server/routes/ask.d.ts.map +1 -0
  132. package/dist/web-server/routes/ask.js +698 -0
  133. package/dist/web-server/routes/ask.js.map +1 -0
  134. package/dist/web-server/routes/changes.d.ts +35 -0
  135. package/dist/web-server/routes/changes.d.ts.map +1 -0
  136. package/dist/web-server/routes/changes.js +93 -0
  137. package/dist/web-server/routes/changes.js.map +1 -0
  138. package/dist/web-server/routes/projects.d.ts.map +1 -1
  139. package/dist/web-server/routes/projects.js +10 -2
  140. package/dist/web-server/routes/projects.js.map +1 -1
  141. package/dist/web-server/routes/proposal-docs.d.ts +18 -0
  142. package/dist/web-server/routes/proposal-docs.d.ts.map +1 -0
  143. package/dist/web-server/routes/proposal-docs.js +208 -0
  144. package/dist/web-server/routes/proposal-docs.js.map +1 -0
  145. package/dist/web-server/routes/runners.d.ts +15 -0
  146. package/dist/web-server/routes/runners.d.ts.map +1 -0
  147. package/dist/web-server/routes/runners.js +66 -0
  148. package/dist/web-server/routes/runners.js.map +1 -0
  149. package/dist/web-server/routes/workspace.d.ts +2 -2
  150. package/dist/web-server/routes/workspace.js +4 -4
  151. package/dist/web-server/run-diff.d.ts +188 -0
  152. package/dist/web-server/run-diff.d.ts.map +1 -0
  153. package/dist/web-server/run-diff.js +739 -0
  154. package/dist/web-server/run-diff.js.map +1 -0
  155. package/dist/web-server/run-driver.d.ts +102 -2
  156. package/dist/web-server/run-driver.d.ts.map +1 -1
  157. package/dist/web-server/run-driver.js +495 -1
  158. package/dist/web-server/run-driver.js.map +1 -1
  159. package/dist/web-server/run-event-log.d.ts +13 -6
  160. package/dist/web-server/run-event-log.d.ts.map +1 -1
  161. package/dist/web-server/run-event-log.js +70 -25
  162. package/dist/web-server/run-event-log.js.map +1 -1
  163. package/dist/web-server/run-store.d.ts +201 -0
  164. package/dist/web-server/run-store.d.ts.map +1 -0
  165. package/dist/web-server/run-store.js +304 -0
  166. package/dist/web-server/run-store.js.map +1 -0
  167. package/dist/web-server/watcher.d.ts.map +1 -1
  168. package/dist/web-server/watcher.js +4 -0
  169. package/dist/web-server/watcher.js.map +1 -1
  170. package/opencode/arcs/manifest.json +8 -8
  171. package/opencode/arcs/prompts/arcs-orchestrate-caveman.txt +1 -1
  172. package/opencode/arcs/prompts/arcs-orchestrate.txt +1 -1
  173. package/opencode/arcs/skills/writing-proposals/SKILL.md +6 -3
  174. package/package.json +1 -1
  175. package/scripts/deploy-claudecode-bundle.mjs +2 -2
  176. package/scripts/deploy-opencode-bundle.mjs +1 -1
  177. package/scripts/deploy-pi-bundle.mjs +468 -0
  178. package/scripts/lint-bundle.mjs +1 -1
  179. package/dist/shared/session-vocabulary.d.ts +0 -88
  180. package/dist/shared/session-vocabulary.d.ts.map +0 -1
  181. package/dist/shared/session-vocabulary.js +0 -114
  182. package/dist/shared/session-vocabulary.js.map +0 -1
  183. package/dist/utils/claude-transcript.d.ts +0 -206
  184. package/dist/utils/claude-transcript.d.ts.map +0 -1
  185. package/dist/utils/claude-transcript.js +0 -384
  186. package/dist/utils/claude-transcript.js.map +0 -1
  187. package/dist/utils/session-store.d.ts +0 -313
  188. package/dist/utils/session-store.d.ts.map +0 -1
  189. package/dist/utils/session-store.js +0 -513
  190. package/dist/utils/session-store.js.map +0 -1
  191. package/dist/web-client/assets/architecture-TIHT7OUA-CIdGE6VO.js +0 -1
  192. package/dist/web-client/assets/channel-CBg-s-ZD.js +0 -1
  193. package/dist/web-client/assets/classDiagram-OUVF2IWQ-ciwDjhUR.js +0 -1
  194. package/dist/web-client/assets/classDiagram-v2-EOCWNBFH-ciwDjhUR.js +0 -1
  195. package/dist/web-client/assets/eventmodeling-45OFAUF4-DxfboL1J.js +0 -1
  196. package/dist/web-client/assets/flowDiagram-23GEKE2U-C3NzpTtI.js +0 -1
  197. package/dist/web-client/assets/index-B1KVIr80.css +0 -2
  198. package/dist/web-client/assets/index-BWd2fBNL.js +0 -60
  199. package/dist/web-client/assets/railroad-abnf-AHOZXSZD-BKj6JAhH.js +0 -1
  200. package/dist/web-client/assets/railroad-ebnf-EBAXGLYW-ChDM1OBv.js +0 -1
  201. package/dist/web-client/assets/railroad-peg-LSFZ7HO6-DvwA2e0i.js +0 -1
  202. package/dist/web-client/assets/stateDiagram-v2-6OUMAXLB-B3EvQPEe.js +0 -1
  203. package/dist/web-client/assets/swimlanesDiagram-G3AALYLV-WcOXuCPg.js +0 -8
  204. package/dist/web-server/permission-policy.d.ts +0 -46
  205. package/dist/web-server/permission-policy.d.ts.map +0 -1
  206. package/dist/web-server/permission-policy.js +0 -96
  207. package/dist/web-server/permission-policy.js.map +0 -1
  208. package/dist/web-server/prompt-assembly.d.ts +0 -454
  209. package/dist/web-server/prompt-assembly.d.ts.map +0 -1
  210. package/dist/web-server/prompt-assembly.js +0 -1121
  211. package/dist/web-server/prompt-assembly.js.map +0 -1
  212. package/dist/web-server/routes/sessions.d.ts +0 -19
  213. package/dist/web-server/routes/sessions.d.ts.map +0 -1
  214. package/dist/web-server/routes/sessions.js +0 -1296
  215. package/dist/web-server/routes/sessions.js.map +0 -1
  216. package/dist/web-server/session-reconciler.d.ts +0 -162
  217. package/dist/web-server/session-reconciler.d.ts.map +0 -1
  218. package/dist/web-server/session-reconciler.js +0 -363
  219. package/dist/web-server/session-reconciler.js.map +0 -1
@@ -1,1296 +0,0 @@
1
- /**
2
- * Session routes — full CRUD over the per-project session index.
3
- *
4
- * Sessions are runtime records for agent threads attached to a project. Every
5
- * record this module mints is ARCS-origin ("arcs"): a thread ARCS drives itself
6
- * through one-shot runs, `opencode run` for the default opencode runtime and
7
- * headless `claude -p` for legacy claude-code threads. All mutations go through
8
- * the locked session-store, so concurrent writers cannot clobber each other.
9
- *
10
- * One route reaches outside the store: `POST /sessions/:id/turns` RUNS a turn.
11
- * The RUNTIME policy — argv shapes and wire format — lives in run-driver.ts
12
- * adapters; the generic lifecycle (spawn, concurrency slot, durable event log,
13
- * timeout) stays in claude-runner.ts and run-event-log.ts. Every route here is
14
- * browser-facing and therefore already behind the global loopback-only
15
- * `secureLocalRequest` middleware — no per-route auth.
16
- */
17
- import { randomUUID } from "node:crypto";
18
- import { open, readFile, stat, unlink } from "node:fs/promises";
19
- import { resolve } from "node:path";
20
- import { Hono } from "hono";
21
- import { streamSSE } from "hono/streaming";
22
- import { z } from "zod";
23
- import { appendReferenceTurn, appendSessionTurn, readSessionTurns, referenceTurnText, sessionTranscriptPath, } from "../../utils/claude-transcript.js";
24
- import { DagError } from "../../utils/errors.js";
25
- import { readJsonSafe } from "../../utils/json.js";
26
- import { beginSessionRun, createSession, deleteSession, deriveSessionPhase, getSession, listSessions, SESSION_LINKED_NODE_TYPES, SESSION_RUNTIME_TYPES, SESSION_STATUSES, sessionRunClaim, settleSessionRun, updateSession, upsertSession, } from "../../utils/session-store.js";
27
- import { normalizeIdentifier } from "../../utils/slug.js";
28
- import { isRunLive, liveRunPid, resolveTimeoutMs, runClaudeJob, } from "../claude-runner.js";
29
- import { buildPermissionArgv, RUN_INTENTS } from "../permission-policy.js";
30
- import { buildStagedEnvironment, planStageRefresh, renderReferences } from "../prompt-assembly.js";
31
- import { fail, parseBody, requireProjectDir, respond } from "../respond.js";
32
- import { getRunDriver } from "../run-driver.js";
33
- import { foldRunEventLog, pruneRunEventLogs, RUN_EVENT_LOG_MAX_BYTES, runEventLogPath, } from "../run-event-log.js";
34
- import { isProcessAlive, reconcileSessionPhases } from "../session-reconciler.js";
35
- export const sessionsRoute = new Hono();
36
- /**
37
- * Payload for POST /sessions — one ARCS-owned thread record.
38
- *
39
- * `runtimeType` defaults to "opencode": the runtime the run-driver seam exists
40
- * for. `runtimeSessionId` is optional and only ever NAMES the record when
41
- * given; the runtime-native id of an opencode thread is unknowable until its
42
- * first settled run harvests one, so a minted thread starts without it.
43
- * Creation spawns nothing — the first POST /turns does.
44
- */
45
- const createSessionSchema = z.object({
46
- runtimeType: z.enum(SESSION_RUNTIME_TYPES).default("opencode"),
47
- runtimeSessionId: z.string().min(1).optional(),
48
- status: z.enum(SESSION_STATUSES).optional(),
49
- startedAt: z.string().optional(),
50
- lastMessageAt: z.string().optional(),
51
- userEmail: z.string().optional(),
52
- metadata: z.record(z.unknown()).optional(),
53
- });
54
- const updateSessionSchema = z.object({
55
- status: z.enum(SESSION_STATUSES).optional(),
56
- lastMessageAt: z.string().nullable().optional(),
57
- userEmail: z.string().nullable().optional(),
58
- metadata: z.record(z.unknown()).nullable().optional(),
59
- /** `null` on either linkage field unlinks the session entirely. */
60
- linkedNodeType: z.enum(SESSION_LINKED_NODE_TYPES).nullable().optional(),
61
- linkedNodeId: z.string().nullable().optional(),
62
- });
63
- /**
64
- * The `doc` variant — a markdown document section.
65
- *
66
- * FROZEN, field for field: this is the only reference shape that existed before
67
- * the union, so every reference turn already on disk carries exactly these keys
68
- * and nothing else. Widening or tightening `section`/`source` here would strand
69
- * those sidecars, so the union adds a tag and touches nothing else. The tag is
70
- * REQUIRED here, exactly as on the pointer variants: a legacy body carrying no
71
- * tag never reaches this schema untagged, because `sessionReferenceSchema`'s
72
- * preprocess fills it in before the union runs. That preprocess is the whole
73
- * legacy mechanism — a default here would be a dead second one implying the tag
74
- * is optional at this boundary when it cannot be.
75
- */
76
- const docReferenceSchema = z.object({
77
- type: z.literal("doc"),
78
- section: z.object({
79
- depth: z.number(),
80
- text: z.string(),
81
- id: z.string(),
82
- startOffset: z.number(),
83
- endOffset: z.number(),
84
- }),
85
- text: z.string(),
86
- source: z.object({
87
- kind: z.enum(["overview", "knowledge", "plan"]),
88
- label: z.string(),
89
- doc: z.string().optional(),
90
- id: z.string().optional(),
91
- }),
92
- });
93
- /** The `file` variant — a line range in a workspace file. `headRev` rides along
94
- * so a later diff can tell whether the file moved under the agent. */
95
- const fileReferenceSchema = z.object({
96
- type: z.literal("file"),
97
- path: z.string().min(1),
98
- startLine: z.number().int().min(1),
99
- endLine: z.number().int().min(1),
100
- excerpt: z.string().optional(),
101
- headRev: z.string().optional(),
102
- });
103
- /** The `node` variant — a DAG entity, with no text slice of its own. */
104
- const nodeReferenceSchema = z.object({
105
- type: z.literal("node"),
106
- kind: z.enum(["task", "plan", "knowledge"]),
107
- id: z.string().min(1),
108
- });
109
- /**
110
- * Something the caller is pointing the session at. Each entry is followed by an
111
- * ARCS-authored reference turn in the session's transcript sidecar (see
112
- * `appendReference`) once the turn is accepted.
113
- *
114
- * A discriminated union on `type`, so an unknown variant is REJECTED (400
115
- * INVALID_BODY naming the three tags) rather than coerced into the nearest
116
- * shape. The one accommodation is the preprocess below: a body with no `type`
117
- * at all can only be a pre-union doc reference — every caller and every stored
118
- * turn predating the union is exactly that — so the tag is filled in before the
119
- * union sees it. Nothing else is inferred: an explicitly tagged body is matched
120
- * on its own tag and fails on its own merits.
121
- */
122
- const sessionReferenceSchema = z
123
- .preprocess((value) => typeof value === "object" && value !== null && !Array.isArray(value) && !("type" in value)
124
- ? { ...value, type: "doc" }
125
- : value, z.discriminatedUnion("type", [docReferenceSchema, fileReferenceSchema, nodeReferenceSchema]))
126
- .superRefine((reference, ctx) => {
127
- // A backwards slice would render a nonsense pointer into the prompt. Checked
128
- // here rather than on the variant because a discriminated-union option must
129
- // stay a plain object schema.
130
- if (reference.type === "file" && reference.endLine < reference.startLine) {
131
- ctx.addIssue({
132
- code: z.ZodIssueCode.custom,
133
- path: ["endLine"],
134
- message: `endLine (${reference.endLine}) must be >= startLine (${reference.startLine})`,
135
- });
136
- }
137
- });
138
- /**
139
- * Payload for POST /sessions/:id/turns — one turn of a headless conversation.
140
- *
141
- * `intent` is a PERMISSION POLICY, not a delivery mode: it selects the tool set
142
- * and permission mode `buildPermissionArgv` emits (`ask` → read-only + plan,
143
- * `change` → the edit surface + acceptEdits) and decides nothing else.
144
- *
145
- * `threadRef` names an ARCS thread RECORD to continue — never a runtime-native
146
- * session id, and never a record ARCS does not own (that is refused, not
147
- * claimed). `refs` are the turn's references: they render into the user-facing
148
- * prompt AND land on the sidecar. `guards` is validated and then deliberately
149
- * ignored here; the change-intent preflight that reads it is a separate task,
150
- * and accepting the key now keeps that task from being a breaking payload
151
- * change.
152
- */
153
- const turnSchema = z.object({
154
- intent: z.enum(RUN_INTENTS),
155
- message: z.string().min(1),
156
- refs: z.array(sessionReferenceSchema).optional(),
157
- threadRef: z.string().min(1).optional(),
158
- guards: z.record(z.unknown()).optional(),
159
- });
160
- /**
161
- * Records a delivered reference on the session's transcript sidecar, one turn
162
- * per `refs` entry on POST /turns.
163
- *
164
- * A `doc` reference writes exactly the fields this route has always written —
165
- * `text`, `ts`, `section`, `source`, in that order — so the serialized line is
166
- * byte-identical to one written before the union existed: no tag is added to the
167
- * DOC record.
168
- *
169
- * That is NOT "the tag never reaches disk". The pointer kinds have no historical
170
- * fields, so they ride `ref` whole — discriminator included — and a stored
171
- * `ref: {type: "file"|"node", ...}` is exactly what makes a pointer turn
172
- * re-readable as its own variant. The tag is on disk there, correctly and
173
- * necessarily; it is only the frozen doc record that stays untagged.
174
- */
175
- async function appendReference(projectDir, sessionId, reference) {
176
- await appendReferenceTurn(projectDir, sessionId, {
177
- text: referenceTurnText(reference),
178
- ts: new Date().toISOString(),
179
- ...(reference.type === "doc"
180
- ? { section: reference.section, source: reference.source }
181
- : { ref: reference }),
182
- });
183
- }
184
- function sessionDirectory(session) {
185
- const directory = session.metadata?.directory;
186
- return typeof directory === "string" && directory ? directory : undefined;
187
- }
188
- /**
189
- * The worktree a newly minted thread should run in.
190
- *
191
- * Guessing is not an option: a turn run in the wrong directory would silently
192
- * point the agent at the wrong repository. An unregistered project is an error.
193
- */
194
- async function primaryWorkspacePath(projectDir, slug) {
195
- const meta = await readJsonSafe(resolve(projectDir, "meta.json"));
196
- const directory = meta?.workspacePaths?.[0];
197
- if (!directory) {
198
- throw new DagError("PROJECT_WORKSPACE_UNSET", `Project "${slug}" has no registered workspace path, so there is no directory to ` +
199
- `create a session in — run \`arcs project update-paths ${slug} --add <path>\` first.`);
200
- }
201
- return directory;
202
- }
203
- /** A metadata slot read back as a real string, or `undefined`. A hand-edited
204
- * index (and a cleared key, written as `""`) can carry anything here. */
205
- function metadataString(value) {
206
- return typeof value === "string" && value !== "" ? value : undefined;
207
- }
208
- function parseFilters(status, runtimeType) {
209
- const filters = {};
210
- if (status && SESSION_STATUSES.includes(status)) {
211
- filters.status = status;
212
- }
213
- if (runtimeType && SESSION_RUNTIME_TYPES.includes(runtimeType)) {
214
- filters.runtimeType = runtimeType;
215
- }
216
- return filters;
217
- }
218
- // The state this module decides from — `sessionState()` and the predicates over
219
- // it — is NOT defined here. It lives in `src/shared/session-vocabulary.ts`, the
220
- // zero-import leaf `web/src/components/SessionStatusBadge.tsx` imports too, so
221
- // an affordance the client offers and the answer this server gives are computed
222
- // by the same function rather than by two copies of it. The reachable
223
- // (status, phase) pairs are enumerated there.
224
- /**
225
- * Epoch-ms deadline the claimed run will be killed at, when the spawn site
226
- * persisted one. Validated rather than trusted — a hand-edited index can carry
227
- * anything under this key.
228
- */
229
- function runDeadlineAt(session) {
230
- const value = session.metadata?.runDeadlineAt;
231
- return typeof value === "number" && Number.isFinite(value) ? value : undefined;
232
- }
233
- /**
234
- * The claimed run's own deadline, standing in for the store's fixed heartbeat
235
- * TTL.
236
- *
237
- * `RUN_HEARTBEAT_TTL_MS` is sized to the runner's 10-minute DEFAULT_TIMEOUT_MS,
238
- * but `resolveTimeoutMs` honours an explicit `timeoutMs` and
239
- * `ARCS_CLAUDE_RUN_TIMEOUT_MS`, and nothing refreshes `heartbeatAt` mid-run —
240
- * so past minute 10 a perfectly healthy 30-minute run derives `idle`, and the
241
- * reconciler cannot rescue it because it early-returns on any non-`running`
242
- * derivation and never probes the pid. The spawn site is the only place that
243
- * knows the timeout, so it persists the resulting deadline on the claim
244
- * (`metadata.runDeadlineAt`) and it is read back here as that run's TTL:
245
- *
246
- * - inside the deadline the pid decides, exactly as the reconciler would;
247
- * - past it the claim is not evidence of anything — the runner has already
248
- * SIGTERMed then SIGKILLed the child — so it demotes to `idle`.
249
- *
250
- * Only ever consulted for a record that still holds a claim AND carries a
251
- * deadline; anything else (including every claim written before this field
252
- * existed) keeps the reconciler's own answer untouched.
253
- */
254
- function runDeadlinePhase(session, phase, now) {
255
- // A terminal status outranks every liveness signal — a session that is over
256
- // is never reopened here.
257
- if (phase === "failed" || phase === "ended")
258
- return phase;
259
- const deadlineAt = runDeadlineAt(session);
260
- if (sessionRunClaim(session) === undefined || deadlineAt === undefined)
261
- return phase;
262
- if (now > deadlineAt)
263
- return "idle";
264
- const pid = session.currentRunPid;
265
- // No pid to probe (the spawn produced none) — the deadline stands alone.
266
- if (typeof pid !== "number")
267
- return "running";
268
- return isProcessAlive(pid) ? "running" : "idle";
269
- }
270
- /**
271
- * Attaches the reconciled phase to each session of ONE response.
272
- *
273
- * `reconcileSessionPhases` takes the project's whole index and runs AT MOST one
274
- * `claude agents --json` probe for it, so this is never one probe per session —
275
- * and the detail route pays exactly what the list does. At most, because the
276
- * probe is lazy: a request whose records all answer from their own evidence
277
- * (terminal, idle, or holding a run claim) spawns no subprocess at all. A record
278
- * that appeared between the two reads is not in the reconciler's answer and
279
- * falls back to its own store-derived phase.
280
- */
281
- async function withPhases(projectDir, sessions) {
282
- const now = Date.now();
283
- const reconciled = new Map((await reconcileSessionPhases(projectDir, { now })).map((view) => [view.sessionId, view.phase]));
284
- return sessions.map((session) => ({
285
- ...session,
286
- phase: runDeadlinePhase(session, reconciled.get(session.normalizedId) ?? deriveSessionPhase(session, { now }), now),
287
- }));
288
- }
289
- /** Existing `metadata.run` as a mergeable object — anything else reads empty. */
290
- function runMetadata(session) {
291
- const run = session.metadata?.run;
292
- if (typeof run !== "object" || run === null || Array.isArray(run))
293
- return {};
294
- return run;
295
- }
296
- /**
297
- * Claude's own words for the two ways a thread's seed decision can be wrong,
298
- * observed on claude 2.1.223's flag validation (exit 1, before any network
299
- * call): `--session-id <id>` on an id it already knows, and `--resume <id>` on
300
- * one it does not.
301
- *
302
- * FRAGILE BY CONSTRUCTION, and stated as such rather than hidden: these are a
303
- * CLI's human-facing stderr strings, not a stable contract, and a claude patch
304
- * release can reword either without notice. Each branch is therefore pinned by
305
- * a test driving the literal message, and each is a REPAIR rather than a
306
- * behaviour: a message that stops matching costs the self-heal, never the run.
307
- */
308
- const THREAD_SEED_CONFLICT_PATTERN = /already in use/i;
309
- const THREAD_UNKNOWN_PATTERN = /No conversation found with session ID/i;
310
- /**
311
- * Reads the child's error text as EVIDENCE about the thread's seed decision and
312
- * repairs the record from it.
313
- *
314
- * `metadata.threadInitialized` is persisted at SPAWN — its honest meaning is
315
- * "ARCS has already handed this uuid to `--session-id`", which the route knows
316
- * with certainty the moment it builds argv and which survives a server crash
317
- * where the settle never runs. That alone closes the wedge (a run that times
318
- * out after claude registered the uuid no longer re-seeds forever). These two
319
- * branches close the remainder, where the flag and claude disagree:
320
- *
321
- * - "already in use" — claude HAS the id ARCS thought it had not handed over.
322
- * The flag was a false negative; set it and the next turn resumes.
323
- * - "No conversation found with session ID" — claude does NOT have the id ARCS
324
- * resumed. Clearing the flag alone would re-seed the SAME uuid, so a fresh
325
- * one is minted with it: keeping the old id would re-issue the identical
326
- * doomed `--resume` on every later turn — the exact wedge this repair exists
327
- * to prevent.
328
- */
329
- function repairThreadSeed(record) {
330
- const error = typeof record.error === "string" ? record.error : "";
331
- if (error === "")
332
- return { metadata: {} };
333
- if (THREAD_SEED_CONFLICT_PATTERN.test(error)) {
334
- return { errorCode: "THREAD_SEED_CONFLICT", metadata: { threadInitialized: true } };
335
- }
336
- if (THREAD_UNKNOWN_PATTERN.test(error)) {
337
- return {
338
- errorCode: "THREAD_UNKNOWN_TO_CLAUDE",
339
- metadata: {
340
- threadInitialized: false,
341
- claudeSessionId: randomUUID(),
342
- },
343
- };
344
- }
345
- return { metadata: {} };
346
- }
347
- /**
348
- * The write-back the route registers on runClaudeJob, invoked by the runner
349
- * after the headless child fully exits — on every outcome (success / error /
350
- * timeout / killed).
351
- *
352
- * Every write target is an ARCS-owned thread, so there is exactly one sidecar
353
- * discipline: `appendSessionTurn`-owned, never mirrored. The run's own event log
354
- * folds down first (assistant text plus one turn per tool call, every turn
355
- * tagged with the run id so a second fold is a no-op) — through the write
356
- * target's own driver normalizer when it has one, so an opencode log's
357
- * `{type, sessionID, part}` lines fold instead of being read as claude events.
358
- * Only for claude-code, and only when that fold produced no assistant text — no
359
- * log, an empty log, a child that spoke only through the terminal `result`
360
- * envelope — does the captured reply get appended as an assistant turn on a
361
- * success outcome; error/timeout outcomes append nothing. A driver-driven run
362
- * never appends the captured reply: the runner's reader does not speak that
363
- * wire format, so its fallback "reply" is raw NDJSON, while the durable log the
364
- * fold just read IS the reply when there was one.
365
- *
366
- * When the fold harvested a runtime-native session id onto a thread that has
367
- * none (an opencode first turn), it is persisted BEFORE the settle releases the
368
- * claim — the next turn must continue this runtime session, not fork a fresh
369
- * one. The thread's `lastMessageAt` moves to the settle too, and a non-terminal
370
- * status is re-stamped active: the thread was just driven.
371
- *
372
- * Every path finalizes metadata.run with the settled record (pid/startedAt/
373
- * mode plus endedAt/outcome/error/replyChars) so the panel shows the true
374
- * result. Best-effort by contract: the runner swallows any error thrown here,
375
- * so a failed write-back never surfaces on the accepted 202.
376
- *
377
- * The run CLAIM is released here too, by `settleSessionRun` rather than by a
378
- * hand-assembled `metadata.run` write: releasing the claim and stamping the
379
- * outcome is one read-modify-write under the store lock, guarded by the run id
380
- * so a run that has already been superseded never settles a newer one out from
381
- * under it.
382
- */
383
- async function writeBackRun(projectDir, ctx, record) {
384
- // Never settle a claim that is still being written (see ctx.claimed).
385
- await ctx.claimed;
386
- // Fold the run's durable event log down into the sidecar first. Idempotent by
387
- // its own output — every folded turn carries the run id, and a run already
388
- // represented there folds to nothing.
389
- const fold = await foldRunEventLog(projectDir, ctx.writeTarget.normalizedId, ctx.runId, {
390
- runtimeType: ctx.writeTarget.runtimeType,
391
- });
392
- // The harvested runtime session id lands before the settle: from the moment
393
- // the claim is released the next turn is accepted, and it has to see the id
394
- // or it mints a fresh runtime thread instead of continuing this one.
395
- if (fold.runtimeSessionId !== undefined &&
396
- ctx.writeTarget.runtimeSessionId.trim() === "" &&
397
- ctx.writeTarget.normalizedId !== fold.runtimeSessionId) {
398
- await updateSession(projectDir, {
399
- id: ctx.writeTarget.normalizedId,
400
- runtimeSessionId: fold.runtimeSessionId,
401
- });
402
- }
403
- if (!fold.assistantTextFolded &&
404
- record.outcome === "success" &&
405
- record.replyText !== undefined &&
406
- ctx.writeTarget.runtimeType === "claude-code") {
407
- // Nothing in the log spoke for this run (no log at all, or only tool
408
- // turns): the captured reply lands in the sidecar as an assistant turn,
409
- // minted in the shared negative id space after the user turn and any
410
- // reference. Tagged with the run id too, so it is covered by the same
411
- // no-second-fold guard. Error/timeout outcomes append nothing.
412
- await appendSessionTurn(projectDir, ctx.writeTarget.normalizedId, {
413
- type: "assistant",
414
- text: record.replyText,
415
- run: ctx.runId,
416
- });
417
- }
418
- // Bounded retention: the log that just settled is the newest, so it always
419
- // survives and the sessions dir stays capped at RUN_EVENT_LOG_RETENTION logs
420
- // per session however many runs it accumulates.
421
- await pruneRunEventLogs(projectDir, ctx.writeTarget.normalizedId);
422
- const repair = repairThreadSeed(record);
423
- // ONE write: the outcome, everything the runner measured, the seed-decision
424
- // repair, and the claim release. `endedAt` rides the record so the run is
425
- // stamped with the moment the CHILD exited, not the moment this write ran.
426
- //
427
- // Leaving any of it to a follow-up `updateSession` is what made the repair
428
- // unsound, because the RUNNER frees its concurrency slot (endRun) BEFORE it
429
- // fires this write-back: from the moment the claim is released the next turn
430
- // is accepted, so a repair one write later is both readable in the gap — the
431
- // record reads settled-and-failed while still carrying the seed state that
432
- // failed it, and the next turn re-issues the very `--resume` claude just
433
- // refused — and able to land AFTER that turn claimed the record, clobbering
434
- // its live metadata.run and re-minting its uuid mid-flight. The `runId` guard
435
- // is only honest inside the settle's own lock.
436
- await settleSessionRun(projectDir, ctx.writeTarget.normalizedId, {
437
- runId: ctx.runId,
438
- outcome: record.outcome,
439
- ...(record.error !== undefined && { error: record.error }),
440
- ...(record.endedAt !== undefined && { endedAt: record.endedAt }),
441
- // Everything the RUNNER measured, which no claim could have known at spawn:
442
- // the pid/startedAt the child actually reported, the run's intent, and the
443
- // stream observations — time-to-first-token and wire-format drift are only
444
- // readable after the fact if they reach disk.
445
- run: {
446
- pid: record.pid,
447
- startedAt: record.startedAt,
448
- mode: ctx.intent,
449
- // Typed, so the panel can act on the failure rather than render opaque
450
- // CLI text at the user.
451
- ...(repair.errorCode !== undefined && { errorCode: repair.errorCode }),
452
- // Reply size and reader drift are CLAUDE READER observations — the
453
- // built-in reader does not speak a driver runtime's wire format, so its
454
- // fallback reply length and skip count would only lie about one.
455
- ...(ctx.writeTarget.runtimeType === "claude-code" &&
456
- record.replyChars !== undefined && { replyChars: record.replyChars }),
457
- ...(record.firstTokenAt !== undefined && { firstTokenAt: record.firstTokenAt }),
458
- ...(ctx.writeTarget.runtimeType === "claude-code" &&
459
- record.skippedLines !== undefined && { skippedLines: record.skippedLines }),
460
- ...(record.eventLogLines !== undefined && { eventLogLines: record.eventLogLines }),
461
- // Whether the log is the WHOLE stream. `eventLogLines` alone cannot say:
462
- // a log capped on its first chunk reports zero lines, the same number a
463
- // child that never spoke reports. Anything that later tails this file by
464
- // offset reads this before it treats the file as complete.
465
- ...(record.eventLogTruncated !== undefined && {
466
- eventLogTruncated: record.eventLogTruncated,
467
- }),
468
- // A log that could not be written is REPORTED here, never thrown: the run
469
- // itself already succeeded or failed on its own merits.
470
- ...(record.eventLogError !== undefined && { eventLogError: record.eventLogError }),
471
- },
472
- ...(Object.keys(repair.metadata).length > 0 && { metadata: repair.metadata }),
473
- });
474
- // The thread was just driven: its last message is this run's end, and a
475
- // non-terminal status is re-stamped active. Terminal statuses are never
476
- // reopened by a run — the same rule the phase derivation enforces.
477
- const terminal = ctx.writeTarget.status === "completed" ||
478
- ctx.writeTarget.status === "failed" ||
479
- ctx.writeTarget.status === "disconnected";
480
- await updateSession(projectDir, {
481
- id: ctx.writeTarget.normalizedId,
482
- lastMessageAt: new Date(record.endedAt ?? Date.now()).toISOString(),
483
- ...(terminal ? {} : { status: "active" }),
484
- });
485
- }
486
- sessionsRoute.get("/api/p/:slug/sessions", async (c) => respond(c, async () => {
487
- const projectDir = requireProjectDir(c.req.param("slug"));
488
- const sessions = await listSessions(projectDir, parseFilters(c.req.query("status"), c.req.query("runtimeType")));
489
- return { sessions: await withPhases(projectDir, sessions) };
490
- }));
491
- sessionsRoute.post("/api/p/:slug/sessions", async (c) => respond(c, async () => {
492
- const slug = c.req.param("slug");
493
- const projectDir = requireProjectDir(slug);
494
- const input = await parseBody(c, createSessionSchema);
495
- // ARCS-origin only, and the name is minted unless the caller supplies
496
- // one: provenance is never client-settable, and a thread without a
497
- // runtime-native id still needs a stable record key from birth. The
498
- // supplied name keys the RECORD only — it never seeds
499
- // `runtimeSessionId`, which stays blank until the runtime itself
500
- // produces one (a harvested opencode session id) or the claude path
501
- // mints its uuid into metadata at first spawn.
502
- const threadName = input.runtimeSessionId ?? `arcs-thread-${slug}-${randomUUID()}`;
503
- // The workspace is resolved NOW so every later turn spawns in the same
504
- // directory even if the project's registered paths change in between.
505
- const directory = metadataString(input.metadata?.directory) ?? (await primaryWorkspacePath(projectDir, slug));
506
- return createSession(projectDir, {
507
- runtimeType: input.runtimeType,
508
- recordName: threadName,
509
- origin: "arcs",
510
- status: input.status,
511
- startedAt: input.startedAt,
512
- lastMessageAt: input.lastMessageAt,
513
- userEmail: input.userEmail,
514
- metadata: { control: "arcs-owned", directory, ...input.metadata },
515
- });
516
- }, 201));
517
- sessionsRoute.get("/api/p/:slug/sessions/:id", async (c) => respond(c, async () => {
518
- const projectDir = requireProjectDir(c.req.param("slug"));
519
- const session = await getSession(projectDir, c.req.param("id"));
520
- const [view] = await withPhases(projectDir, [session]);
521
- return view;
522
- }));
523
- sessionsRoute.patch("/api/p/:slug/sessions/:id", async (c) => respond(c, async () => {
524
- const projectDir = requireProjectDir(c.req.param("slug"));
525
- const input = await parseBody(c, updateSessionSchema);
526
- return updateSession(projectDir, { id: c.req.param("id"), ...input });
527
- }));
528
- /**
529
- * The thread record `threadRef` names, or `undefined` when the index answers
530
- * that there is no such record.
531
- *
532
- * `getSession`'s `ITEM_NOT_FOUND` is NOT evidence of absence: `readSessionIndex`
533
- * folds an unreadable index into an empty one (`readJsonSafe` swallows every
534
- * error class), so an EACCES or an EISDIR on a live index arrives wearing the
535
- * deleted record's code. Minting on that answer would upsert a fresh ARCS
536
- * thread over a name that already belongs to something else. So a not-found is
537
- * believed only when a DIRECT read of the index says this record is not listed
538
- * (or that there is no index at all); anything else is reported as unavailable
539
- * and the caller retries.
540
- */
541
- async function readThreadRecord(projectDir, threadRef) {
542
- try {
543
- return await getSession(projectDir, threadRef);
544
- }
545
- catch (err) {
546
- if (!(err instanceof DagError) || err.code !== "ITEM_NOT_FOUND")
547
- throw err;
548
- }
549
- if (await sessionIndexAnswered(projectDir, normalizeIdentifier(threadRef)))
550
- return undefined;
551
- throw new DagError("SESSION_INDEX_UNAVAILABLE", `cannot tell whether thread "${threadRef}" exists — the session index did not answer for ` +
552
- `it, and minting a second record over that name would collide with whatever holds the ` +
553
- `name today. Retry once the index reads.`);
554
- }
555
- /**
556
- * Mints a fresh ARCS thread for a `threadRef` that names nothing yet. The
557
- * runtime defaults to "opencode" — the same default POST /sessions applies — so
558
- * every minted-by-turn thread is drivable by the run-driver seam from birth.
559
- */
560
- async function mintThread(projectDir, slug, threadName) {
561
- const dir = await primaryWorkspacePath(projectDir, slug);
562
- return upsertSession(projectDir, {
563
- runtimeType: "opencode",
564
- recordName: threadName,
565
- origin: "arcs",
566
- metadata: { control: "arcs-owned", directory: dir },
567
- });
568
- }
569
- /**
570
- * The implicit Ask-AI thread: one ARCS-owned thread per project that the Ask-AI
571
- * panel chats through, addressed by the virtual id `ask` — no thread picker,
572
- * no create ceremony. The record's name is `ask-ai`, so its `normalizedId` (and
573
- * therefore its transcript sidecar and run logs) is stable across processes.
574
- */
575
- const ASK_THREAD_ALIAS = "ask";
576
- const ASK_THREAD_NAME = "ask-ai";
577
- /**
578
- * The project's implicit Ask-AI thread, minted on first use.
579
- *
580
- * A record already holding the `ask-ai` name but NOT ARCS-owned is refused
581
- * rather than claimed or silently shadowed: the name would otherwise key two
582
- * different transcripts depending on which resolution won.
583
- */
584
- async function resolveAskThread(projectDir, slug) {
585
- const existing = await readThreadRecord(projectDir, ASK_THREAD_NAME);
586
- if (existing !== undefined) {
587
- if (existing.origin !== "arcs") {
588
- throw new DagError("TURN_THREAD_NOT_OWNED", `the name "${ASK_THREAD_NAME}" is held by a non-ARCS record — free or rename it to use Ask AI`);
589
- }
590
- return existing;
591
- }
592
- return mintThread(projectDir, slug, ASK_THREAD_NAME);
593
- }
594
- /** The turn's target session: the `ask` alias names the implicit thread. */
595
- async function resolveTurnSession(projectDir, slug, rawId) {
596
- return rawId === ASK_THREAD_ALIAS
597
- ? resolveAskThread(projectDir, slug)
598
- : getSession(projectDir, rawId);
599
- }
600
- /**
601
- * Resolves the record this turn writes to.
602
- *
603
- * Two branches, and only these two:
604
- *
605
- * 1. `threadRef` — the caller names an ARCS thread RECORD to continue. It may
606
- * name one that does not exist yet (a fresh thread is minted for it), but a
607
- * record that exists and is not ARCS-owned is refused rather than claimed.
608
- * 2. no `threadRef` — the addressed session itself must be an ARCS-owned
609
- * thread; it is continued in place.
610
- *
611
- * There is no third branch. Turns used to ADOPT an observed session by forking
612
- * it into a new thread (`--resume <observed> --session-id <fresh>
613
- * --fork-session`); with the hook bridge gone there are no observed sessions
614
- * left to adopt, so anything that is not an ARCS thread is refused outright.
615
- */
616
- async function resolveTurnTarget(projectDir, slug, session, threadRef) {
617
- let writeTarget;
618
- if (threadRef !== undefined) {
619
- const existing = await readThreadRecord(projectDir, threadRef);
620
- if (existing !== undefined && existing.origin !== "arcs") {
621
- throw new DagError("TURN_THREAD_NOT_OWNED", `cannot continue thread "${existing.normalizedId}": it is not an ARCS-owned thread`);
622
- }
623
- writeTarget = existing ?? (await mintThread(projectDir, slug, threadRef));
624
- }
625
- else {
626
- if (session.origin !== "arcs") {
627
- throw new DagError("TURN_THREAD_NOT_OWNED", `cannot run a turn on "${session.normalizedId}": it is not an ARCS-owned thread — ` +
628
- `create one with POST /api/p/${slug}/sessions and address turns to it`);
629
- }
630
- writeTarget = session;
631
- }
632
- const persistedDir = sessionDirectory(writeTarget);
633
- const dir = persistedDir ?? (await primaryWorkspacePath(projectDir, slug));
634
- if (persistedDir === undefined) {
635
- // Pin the workspace on first contact so later turns spawn in the same
636
- // directory even if the project's registered paths change in between.
637
- await updateSession(projectDir, { id: writeTarget.normalizedId, metadata: { directory: dir } });
638
- writeTarget = { ...writeTarget, metadata: { ...writeTarget.metadata, directory: dir } };
639
- }
640
- return { writeTarget, dir };
641
- }
642
- /**
643
- * The turn's user-facing prompt: the message, then its rendered reference block.
644
- *
645
- * References ride the PROMPT, never a system tier. The system tier is the
646
- * STABLE one — byte-identical across turns is what makes the prompt cache pay —
647
- * while a reference belongs to the turn that sent it and to no other. Staging
648
- * them would break the cache on every send and leave the pointer in the
649
- * conversation long after the turn it was meant for.
650
- */
651
- function turnPrompt(message, refs) {
652
- const block = renderReferences(refs ?? []);
653
- return block === "" ? message : `${message}\n\n${block}`;
654
- }
655
- /**
656
- * Targeting tokens for one legacy claude-code spawn — everything before the
657
- * permission segment. Exactly two shapes:
658
- * - fresh thread seed: -p <prompt> --session-id <new> --output-format json
659
- * - thread resume: -p <prompt> --resume <own> --output-format json
660
- *
661
- * (`--resume <observed> --fork-session` — the adoption fork — is gone with the
662
- * observed sessions it forked.)
663
- */
664
- function turnTargetingArgv(prompt, seeding, claudeSessionId) {
665
- const argv = ["-p", prompt];
666
- argv.push(seeding ? "--session-id" : "--resume", claudeSessionId);
667
- argv.push("--output-format", "json");
668
- return argv;
669
- }
670
- /**
671
- * One turn of a headless conversation. Answers 202 with the run's id, the
672
- * stream to tail it on, and the record it writes to — the acceptance, not the
673
- * result: the run proceeds out-of-band in the runner, whose exit-time write-back
674
- * settles it.
675
- *
676
- * WHAT THE CALLER CHOOSES is an INTENT (`ask` | `change`), never a targeting
677
- * mode. Where the turn lands is derived from the record it is addressed to (see
678
- * `resolveTurnTarget`): an ARCS thread continues in place.
679
- *
680
- * RUNTIME SELECTION is the write target's own `runtimeType`, read through the
681
- * run-driver registry: a thread whose runtime has a registered adapter (the
682
- * default, opencode) is driven one-shot through that adapter — its argv, its
683
- * binary, its wire format; a thread without one (legacy claude-code) keeps the
684
- * claude path below.
685
- *
686
- * ARGV OWNERSHIP, which is the safety property on the claude path: every tool
687
- * and permission token comes from `buildPermissionArgv` and this route builds
688
- * none. It keeps only the targeting tokens above, and the permission segment is
689
- * appended LAST — `--tools` is variadic (it eats following tokens until the next
690
- * dash-leading one) and `--append-system-prompt` consumes exactly one following
691
- * token, so a segment placed before `-p` would swallow the prompt or the staged
692
- * text. The staged environment reaches the child through that segment's
693
- * `stagedSystemPrompt` slot and nowhere else; a second direct push would emit
694
- * the flag twice. A driver-driven run carries NO permission segment — those
695
- * flags are claude's vocabulary, and the adapter's argv is complete on its own.
696
- *
697
- * The user turn (and one reference turn per `refs` entry) is appended to the
698
- * write target's sidecar immediately, so the panel shows the prompt before the
699
- * run ends, with delivery-first ordering.
700
- *
701
- * Staged environment (W2): claude-code only. A spawn that STARTS a conversation
702
- * always carries it; a spawn that CONTINUES one carries it only on a restage.
703
- *
704
- * Concurrency: one live run per write-target. The runner's beginRun is the
705
- * atomic claim; the read-only isRunLive probe here answers the common
706
- * overlapping case with a proper 409 before anything is appended or spawned.
707
- */
708
- sessionsRoute.post("/api/p/:slug/sessions/:id/turns", async (c) => respond(c, async () => {
709
- const slug = c.req.param("slug");
710
- const projectDir = requireProjectDir(slug);
711
- // `guards` is validated by the schema and deliberately not read here —
712
- // the change-intent preflight that consumes it is a separate task.
713
- const { intent, message, refs, threadRef } = await parseBody(c, turnSchema);
714
- const session = await resolveTurnSession(projectDir, slug, c.req.param("id"));
715
- const target = await resolveTurnTarget(projectDir, slug, session, threadRef);
716
- const { writeTarget, dir } = target;
717
- // One live run per write-target — refuse before appending anything. The
718
- // CODE is historical (claude was the only drivable runtime when it was
719
- // minted); both runtimes share it so clients keep one overlap signal.
720
- if (isRunLive(writeTarget.normalizedId)) {
721
- throw new DagError("CLAUDE_RUN_IN_PROGRESS", `a run for "${writeTarget.normalizedId}" is already in progress`);
722
- }
723
- await appendSessionTurn(projectDir, writeTarget.normalizedId, {
724
- type: "user",
725
- text: message,
726
- });
727
- for (const reference of refs ?? []) {
728
- await appendReference(projectDir, writeTarget.normalizedId, reference);
729
- }
730
- // The run's own ceiling, resolved HERE so the deadline persisted with the
731
- // claim is the same number the runner arms its kill timer with (it
732
- // prefers this over its own env/default lookup).
733
- const runId = randomUUID();
734
- const timeoutMs = resolveTimeoutMs(undefined, process.env);
735
- // --- Per-runtime child shape -------------------------------------------
736
- //
737
- // A registered driver adapter owns everything runtime-specific about the
738
- // spawn: argv shape, binary, wire format. What stays here is the generic
739
- // lifecycle — deadline, claim, durable log, write-back — identical for
740
- // every runtime.
741
- const driver = getRunDriver(writeTarget.runtimeType);
742
- let argv;
743
- let runnerOptions;
744
- /** Sibling metadata persisted with the deadline below, per runtime. */
745
- let spawnMetadata = {};
746
- if (driver !== undefined) {
747
- // One-shot driver runtime (opencode): FRESH when no runtime session id
748
- // has been harvested yet, `-s` continuation once one has. No permission
749
- // segment and no staged tier — the adapter's argv is complete policy,
750
- // and the runner must not rewrite it onto its stream-json contract.
751
- const runtimeSessionId = writeTarget.runtimeSessionId.trim();
752
- argv = driver.buildArgv({
753
- message: turnPrompt(message, refs),
754
- title: metadataString(writeTarget.metadata?.title),
755
- ...(runtimeSessionId !== "" && { runtimeSessionId }),
756
- });
757
- runnerOptions = { binary: driver.binary };
758
- }
759
- else {
760
- // Legacy claude-code thread — seed-or-resume decision, targeting tokens,
761
- // staged environment, then the permission segment LAST.
762
- const meta = writeTarget.metadata;
763
- const persistedUuid = metadataString(meta?.claudeSessionId);
764
- // The SEED DECISION. `threadInitialized` means "ARCS has already handed
765
- // this uuid to --session-id" and is persisted at spawn, so a thread
766
- // whose first run timed out (or whose server died) resumes on the next
767
- // turn instead of re-seeding an id claude has already registered.
768
- const seeding = meta?.threadInitialized !== true || persistedUuid === undefined;
769
- const claudeSessionId = persistedUuid ?? randomUUID();
770
- // NOTE: no "--cwd" flag — claude >= 2.x rejects it ("error: unknown
771
- // option '--cwd'"), settling every headless run as outcome:error. The
772
- // spawn applies the working directory via options.cwd below instead.
773
- argv = turnTargetingArgv(turnPrompt(message, refs), seeding, claudeSessionId);
774
- // Keyed on the WRITE TARGET, never on anything else: the write target
775
- // is the record the run lands on and the record `metadata.stage` is
776
- // persisted to, so the fingerprint compared next turn describes the
777
- // same node the text was built from.
778
- const stageOpts = { workspaceRoot: dir };
779
- const refresh = await planStageRefresh(projectDir, slug, writeTarget, stageOpts);
780
- const staged = refresh.staged ??
781
- (seeding
782
- ? await buildStagedEnvironment(projectDir, slug, writeTarget, {
783
- ...stageOpts,
784
- // The same watermark planStageRefresh stamps with, so this record
785
- // stays mtime-comparable even though this path never persists it.
786
- now: refresh.probedAt,
787
- })
788
- : undefined);
789
- // LAST, and the only source of tool/permission tokens. The staged text
790
- // is handed over as this segment's value slot rather than pushed
791
- // directly — one flag, one emitter.
792
- argv.push(...buildPermissionArgv({
793
- intent,
794
- ...(staged !== undefined && { stagedSystemPrompt: staged.text }),
795
- }));
796
- spawnMetadata = {
797
- // Minted once here and persisted AT SPAWN, so a crash before the
798
- // settle still leaves the thread resuming the uuid it was seeded
799
- // with rather than re-seeding a second one.
800
- claudeSessionId,
801
- ...(seeding && { threadInitialized: true }),
802
- // Written EXACTLY when the refresh asks for it. On the cheap exit
803
- // nothing was rebuilt, so re-stamping the record would move the very
804
- // watermark the next turn's freshness decision is made against.
805
- ...(refresh.persist && refresh.stage ? { stage: refresh.stage } : {}),
806
- };
807
- }
808
- // Persisted next to the claim rather than inside metadata.run, which
809
- // `beginSessionRun` replaces wholesale: as sibling keys these cannot be
810
- // clobbered by the claim, nor the claim by them.
811
- await updateSession(projectDir, {
812
- id: writeTarget.normalizedId,
813
- metadata: {
814
- runDeadlineAt: Date.now() + timeoutMs,
815
- ...spawnMetadata,
816
- },
817
- });
818
- // Claim the record BEFORE the child exists: from here on, a server that
819
- // dies mid-run leaves a claim behind rather than an invisible orphan, and
820
- // the startup sweep (settleOrphanedRunsOnStartup) is what settles it.
821
- await beginSessionRun(projectDir, writeTarget.normalizedId, { runId });
822
- // Gate for the write-back: it must not settle (and release) the claim
823
- // while the pid write below is still in flight.
824
- let claimComplete = () => { };
825
- const claimed = new Promise((resolveClaim) => {
826
- claimComplete = resolveClaim;
827
- });
828
- // Fire-and-forget: the run proceeds out-of-band. The runner invokes the
829
- // registered write-back after the child fully exits (it resolves on
830
- // `close`) on every outcome path; write-back failures are swallowed by
831
- // the runner, so a failed finalize never surfaces on the accepted 202.
832
- // The trailing catch is defensive — the runner never rejects.
833
- runClaudeJob({
834
- argv,
835
- cwd: dir,
836
- timeoutMs,
837
- writeTargetKey: writeTarget.normalizedId,
838
- // A driver runtime owns its own wire format: its argv reaches the
839
- // child verbatim, never rewritten onto the claude output contract.
840
- ...(driver !== undefined && { streamJsonArgv: false }),
841
- // The SAME runId the claim above persisted as currentRunId — the log's
842
- // filename and the session record can never name different runs.
843
- eventLog: { projectDir, sessionId: writeTarget.normalizedId, runId },
844
- onSettled: (record) => writeBackRun(projectDir, { intent, writeTarget, runId, claimed }, record),
845
- }, runnerOptions).catch(() => {
846
- // Best-effort — the write-back lives inside the runner's onSettled.
847
- });
848
- // runClaudeJob spawns synchronously (nothing is awaited before its
849
- // beginRun), so the child's pid is readable right here — and the claim it
850
- // lands on is the one written above, never a later run's. `undefined`
851
- // means the spawn produced no live run at all and `null` means it
852
- // produced no pid; neither is something to persist, and the claim then
853
- // stands on its heartbeat/deadline alone.
854
- try {
855
- const pid = liveRunPid(writeTarget.normalizedId);
856
- if (typeof pid === "number") {
857
- await beginSessionRun(projectDir, writeTarget.normalizedId, { runId, pid });
858
- }
859
- }
860
- catch {
861
- // A claim ARCS could not complete is not a reason to fail an accepted
862
- // run — the record simply carries no pid for it.
863
- }
864
- finally {
865
- claimComplete();
866
- }
867
- return {
868
- runId,
869
- // Keyed on the WRITE TARGET's id, never on the path `:id`: when
870
- // `threadRef` mints a thread they can differ, and a stream URL built
871
- // from the path id answers 200 and then emits nothing —
872
- // indistinguishable from a child that never spoke.
873
- streamUrl: `/api/p/${slug}/sessions/${writeTarget.normalizedId}/runs/${runId}/stream`,
874
- writeTargetId: writeTarget.normalizedId,
875
- };
876
- }, 202));
877
- sessionsRoute.delete("/api/p/:slug/sessions/:id", async (c) => respond(c, async () => {
878
- const projectDir = requireProjectDir(c.req.param("slug"));
879
- // Resolve through the index first: the sidecar filename keys on the
880
- // session's canonical normalizedId, so a non-slugified route id must not
881
- // re-derive the filename (deleteSession alone would orphan the sidecar).
882
- const session = await getSession(projectDir, c.req.param("id"));
883
- await deleteSession(projectDir, session.normalizedId);
884
- try {
885
- await unlink(sessionTranscriptPath(projectDir, session.normalizedId));
886
- }
887
- catch {
888
- // Sidecar may not exist — a failed unlink is a swallowed no-op.
889
- }
890
- // Retention only ever prunes at a settle, and a deleted session never
891
- // settles again — its logs would otherwise sit in the sessions dir forever.
892
- await pruneRunEventLogs(projectDir, session.normalizedId, 0);
893
- return { deleted: true };
894
- }));
895
- /**
896
- * Reads the session's transcript sidecar (mirrored Claude Code lines plus
897
- * ARCS-authored reference turns) into the read-model the web UI renders.
898
- * An absent sidecar answers an empty transcript with `mirroredAt: null`; once
899
- * the sidecar exists, `mirroredAt` is the file mtime so the UI can show how
900
- * fresh the mirror is.
901
- */
902
- sessionsRoute.get("/api/p/:slug/sessions/:id/transcript", async (c) => respond(c, async () => {
903
- const projectDir = requireProjectDir(c.req.param("slug"));
904
- // The ask alias reads WITHOUT minting: an untouched Ask-AI thread is an
905
- // empty transcript, not a record created by a GET.
906
- let session;
907
- if (c.req.param("id") === ASK_THREAD_ALIAS) {
908
- session = await readThreadRecord(projectDir, ASK_THREAD_NAME);
909
- if (session === undefined)
910
- return { turns: [], mirroredAt: null };
911
- }
912
- else {
913
- session = await getSession(projectDir, c.req.param("id"));
914
- }
915
- let mirroredAt = null;
916
- try {
917
- const info = await stat(sessionTranscriptPath(projectDir, session.normalizedId));
918
- if (info.isFile())
919
- mirroredAt = info.mtime.toISOString();
920
- }
921
- catch {
922
- // No sidecar yet — empty transcript, nothing mirrored.
923
- }
924
- if (mirroredAt === null)
925
- return { turns: [], mirroredAt: null };
926
- return { turns: await readSessionTurns(projectDir, session.normalizedId), mirroredAt };
927
- }));
928
- // ---------------------------------------------------------------------------
929
- // Run event stream — a stateless tail of one run's event log
930
- // ---------------------------------------------------------------------------
931
- /**
932
- * How often an attached tail re-reads the log.
933
- *
934
- * Sized against the DAG stream's 250ms debounce (`watcher.ts`, feeding
935
- * `routes/events.ts`), which is exactly what makes that channel unusable for
936
- * tokens and why this is a second channel at all: a quarter second of
937
- * coalescing is invisible on a graph repaint and jarring on text arriving word
938
- * by word. This is a different channel with a different budget, so it polls
939
- * rather than debounces, and it polls an order faster.
940
- *
941
- * Polling rather than `fs.watch`: watch semantics vary by platform and
942
- * filesystem (and still need a poll fallback to be total), and a watcher is
943
- * per-connection state — the one thing this route may not hold.
944
- */
945
- const RUN_TAIL_POLL_MS = 100;
946
- /** The framing byte. A line is only a record once THIS terminates it. */
947
- const RUN_LOG_NEWLINE = 0x0a;
948
- /**
949
- * Whether the index, read DIRECTLY, answers that THIS SESSION is not in it —
950
- * the only thing that turns `getSession`'s not-found into "the session is gone".
951
- *
952
- * `ITEM_NOT_FOUND` is not by itself evidence of deletion: `readSessionIndex`
953
- * folds an unreadable index into an EMPTY one (`readJsonSafe` swallows every
954
- * error class), so an `EACCES`, an `EISDIR` or a malformed index on a live
955
- * session arrives wearing the deleted session's code.
956
- *
957
- * The question asked here is about the SESSION, never about the file. "The
958
- * index parses" is NOT the same answer: this is a second, later read, so a
959
- * failure that CLEARS between the two makes the file parse while the session is
960
- * still listed in it — settling a live run on nothing but a flicker. Only a
961
- * parse that completes AND does not list `sessionId` answers, plus `ENOENT`,
962
- * where the record the session would have to be in is not there at all.
963
- *
964
- * Deliberate trade: an index that is readable but MALFORMED (`sessions` present
965
- * and not an array), or that only the store's JSONC-tolerant reader accepts and
966
- * this plain parse does not, never answers at all — so a tail whose session
967
- * really was deleted keeps polling for the life of the connection rather than
968
- * ending. That is the intended direction — absent evidence must not look like
969
- * evidence of silence — and it is unbounded on purpose. The repair for a broken
970
- * index is to repair the index; do not "fix" this back into a settle.
971
- *
972
- * Re-deriving the index path here (rather than asking the store) is the whole
973
- * point: the store's own reader is the thing that cannot distinguish these.
974
- *
975
- * One agreement with that reader IS mirrored: a listed record whose
976
- * `runtimeType` is not a member of `SESSION_RUNTIME_TYPES` answers ABSENT.
977
- * `readSessionIndex` drops such records before any read sees them, so to the
978
- * store the session genuinely does not exist — counting it as "listed" here
979
- * would make every `getSession` not-found for it look like an index that
980
- * cannot answer, wedging the caller in retry until attrition happens to
981
- * compact the record away.
982
- */
983
- async function sessionIndexAnswered(projectDir, sessionId) {
984
- try {
985
- const raw = await readFile(resolve(projectDir, "sessions", "index.json"), "utf-8");
986
- const parsed = JSON.parse(raw);
987
- if (!Array.isArray(parsed.sessions))
988
- return false;
989
- return !parsed.sessions.some((s) => s?.normalizedId === sessionId &&
990
- SESSION_RUNTIME_TYPES.includes(s.runtimeType ?? ""));
991
- }
992
- catch (err) {
993
- return err.code === "ENOENT";
994
- }
995
- }
996
- /**
997
- * Whether the run is still live, read from the SESSION RECORD rather than from
998
- * the runner's in-memory `liveRuns` map.
999
- *
1000
- * The claim is the only liveness signal that survives a restart, and it is what
1001
- * keeps this route stateless: `isRunLive` would answer "no" for every run
1002
- * inherited from a dead server process, closing a stream whose child is still
1003
- * writing. The claim also settles exactly once, under the store lock, in the
1004
- * same write that stamps the outcome — so "claim gone" and "outcome readable"
1005
- * can never disagree.
1006
- */
1007
- async function readRunTailState(projectDir, sessionId, runId) {
1008
- let session;
1009
- try {
1010
- session = await getSession(projectDir, sessionId);
1011
- }
1012
- catch (err) {
1013
- // Two reads, and only their AGREEMENT settles: `getSession` raised
1014
- // not-found AND a direct read of the index answers that this session is not
1015
- // in it (or that there is no index at all). Everything else — another error
1016
- // class, or an index that cannot answer for this session — keeps the tail
1017
- // polling, because absent evidence must not look like evidence of silence:
1018
- // the `end` frame a transient read failure would emit here is byte-identical
1019
- // to the legitimate superseded-run one, the client contract below is to
1020
- // `close()` on `end`, and so a live run's remaining lines would never reach
1021
- // that consumer at all. Staying open costs one more poll and nothing else.
1022
- if (!(err instanceof DagError) || err.code !== "ITEM_NOT_FOUND")
1023
- return { settled: false };
1024
- return { settled: await sessionIndexAnswered(projectDir, sessionId) };
1025
- }
1026
- if (sessionRunClaim(session) === runId)
1027
- return { settled: false };
1028
- const run = runMetadata(session);
1029
- // A newer run owns the record: this one is over (its claim is gone), but its
1030
- // outcome and completeness are no longer on disk to report.
1031
- if (run.runId !== runId)
1032
- return { settled: true };
1033
- return {
1034
- settled: true,
1035
- ...(typeof run.outcome === "string" && { outcome: run.outcome }),
1036
- // Only ever written as `true` (claude-runner omits it otherwise), so its
1037
- // absence on THIS run's own record means the log is whole.
1038
- truncated: run.eventLogTruncated === true,
1039
- };
1040
- }
1041
- const EMPTY_TAIL_READ = { lines: [], bytes: 0 };
1042
- /**
1043
- * Every COMPLETE line the log holds at or after `byteOffset`.
1044
- *
1045
- * The trailing-partial rule is the whole point of this function. While a run is
1046
- * live the file's last bytes may be a record the child is still writing, and the
1047
- * log also leaves an orphaned fragment behind wherever it lost bytes and refused
1048
- * to extend the open line. Both look identical from here — unterminated bytes at
1049
- * EOF — so neither is ever emitted: consumption stops AT the last newline and
1050
- * `bytes` reports only that much, leaving the fragment to be re-read by the next
1051
- * poll once (and if) it completes. Emitting it would fabricate a record the child
1052
- * never wrote, and a fabricated record is undetectable downstream.
1053
- *
1054
- * Decoding only the consumed region is also what keeps multi-byte UTF-8 intact:
1055
- * `0x0a` cannot appear inside a multi-byte sequence, so a cut at a newline is
1056
- * always a character boundary however the child chunked its writes.
1057
- *
1058
- * Total, like everything else that touches this log: a file that is not there
1059
- * yet (the claim is written BEFORE the child spawns), was pruned, or cannot be
1060
- * read reads as nothing new.
1061
- */
1062
- async function readRunLogLines(path, byteOffset) {
1063
- let handle;
1064
- try {
1065
- handle = await open(path, "r");
1066
- }
1067
- catch {
1068
- return EMPTY_TAIL_READ;
1069
- }
1070
- try {
1071
- const { size } = await handle.stat();
1072
- // Bounded by the same ceiling `foldRunEventLog` reads against — the same
1073
- // ceiling, NOT the same handling: the fold refuses an oversized file whole
1074
- // where this clamps to the ceiling and tails what fits. The writer cannot
1075
- // produce such a file (`push` refuses the crossing chunk and reserves the
1076
- // byte `terminate` may add), so the two never disagree on a log this server
1077
- // wrote. The clamp stands for what that leaves: bytes past a ceiling the log
1078
- // never wrote are not tailed as if the log had written them, and this
1079
- // allocation can never exceed the log's own maximum size.
1080
- const end = Math.min(size, RUN_EVENT_LOG_MAX_BYTES);
1081
- // Nothing new. `<` rather than `===` covers the file shrinking under us,
1082
- // which an append-only log cannot do — but reading a negative length could.
1083
- if (end <= byteOffset)
1084
- return EMPTY_TAIL_READ;
1085
- const buffer = Buffer.allocUnsafe(end - byteOffset);
1086
- const { bytesRead } = await handle.read(buffer, 0, buffer.length, byteOffset);
1087
- const chunk = buffer.subarray(0, bytesRead);
1088
- const lines = [];
1089
- let consumed = 0;
1090
- for (;;) {
1091
- const at = chunk.indexOf(RUN_LOG_NEWLINE, consumed);
1092
- if (at === -1)
1093
- break;
1094
- // Verbatim, terminator excluded: the log is the source of truth and this
1095
- // is a view of it, so nothing here trims, parses or repairs a line.
1096
- lines.push(chunk.toString("utf-8", consumed, at));
1097
- consumed = at + 1;
1098
- }
1099
- return { lines, bytes: consumed };
1100
- }
1101
- catch {
1102
- return EMPTY_TAIL_READ;
1103
- }
1104
- finally {
1105
- await handle.close().catch(() => { });
1106
- }
1107
- }
1108
- /**
1109
- * Digits and nothing else — no sign, no exponent, no whitespace, no separators.
1110
- *
1111
- * Leading zeros ARE accepted (`?from=007` is offset 7), because they are the one
1112
- * decoration that cannot change the value in base 10: every shape refused above
1113
- * either names a different number than it reads as, or names none at all.
1114
- */
1115
- const RUN_TAIL_OFFSET_PATTERN = /^\d+$/;
1116
- /**
1117
- * Where the tail starts, as an ABSOLUTE line offset into the log.
1118
- *
1119
- * The same contract `mirrorOffset` implements for transcript mirroring
1120
- * (`src/utils/claude-transcript.ts`): the value is the index of the next line
1121
- * the client has NOT seen — last seen offset + 1 — so a reconnect at it can
1122
- * neither duplicate nor skip. The log is append-only, so a line's index is fixed
1123
- * forever and the offset means the same thing to every connection.
1124
- *
1125
- * Two sources, and the LARGER wins. `from` is what an explicit reconnect passes;
1126
- * `Last-Event-ID` is what a browser `EventSource` replays automatically on its
1127
- * own reconnect, where the URL (and therefore `from`) is frozen at whatever the
1128
- * first connect used. Both are lower bounds on "lines I already hold", so their
1129
- * max is the only value that satisfies both — honouring `from` alone would make
1130
- * every automatic reconnect re-deliver the whole run.
1131
- *
1132
- * The consequence, which is what a client author actually needs: a request that
1133
- * carries `Last-Event-ID` CANNOT REWIND below it, whatever `?from=` says. That
1134
- * is the right trade rather than a limitation to work around — a browser only
1135
- * replays the header on an automatic reconnect of the same `EventSource`, so a
1136
- * deliberate rewind is a fresh `EventSource` (or a plain GET), neither of which
1137
- * sends the header at all.
1138
- *
1139
- * Garbage is REFUSED rather than clamped: the only clamp available is 0, which
1140
- * silently replays the entire log — precisely the duplicate storm the offset
1141
- * exists to prevent. Refusal is DIGITS ONLY plus a `MAX_SAFE_INTEGER` bound,
1142
- * because `Number()` + `Number.isInteger` is not refusal: it admits `1e3`,
1143
- * `0x2`, whitespace-padded values and results past `MAX_SAFE_INTEGER`. Every
1144
- * one of those is fail-safe in direction — they only move the tail forward —
1145
- * but none of them is the "non-negative integer" this documents, and `1e21`
1146
- * buys an end-frame-only stream indistinguishable from a run that said nothing.
1147
- * A doc stricter than its code is a defect on its own.
1148
- */
1149
- function parseRunTailOffset(from, lastEventId) {
1150
- const parse = (raw, label) => {
1151
- if (raw === undefined || raw === "")
1152
- return 0;
1153
- if (!RUN_TAIL_OFFSET_PATTERN.test(raw) || Number(raw) > Number.MAX_SAFE_INTEGER) {
1154
- throw new DagError("INVALID_RUN_STREAM_OFFSET", `${label} must be a non-negative integer line offset no greater than ` +
1155
- `${Number.MAX_SAFE_INTEGER}, got "${raw}"`);
1156
- }
1157
- return Number(raw);
1158
- };
1159
- return Math.max(parse(from, "from"), parse(lastEventId, "Last-Event-ID"));
1160
- }
1161
- /**
1162
- * Answers a pre-stream resolution failure as JSON rather than as a stream.
1163
- *
1164
- * `respond` cannot be reused: it wraps the SUCCESS path in the envelope too, and
1165
- * this route's success is an event stream with no envelope at all. The refusals
1166
- * still speak the shared envelope, because a client that asked for a pruned run
1167
- * needs a 404 it can read off `res.status` — not a 200 stream that says nothing
1168
- * and closes, which is what a run whose child was silent looks like.
1169
- */
1170
- function runStreamFailure(c, err) {
1171
- if (err instanceof DagError) {
1172
- return c.json(fail(err.code, err.message), err.code.includes("NOT_FOUND") ? 404 : 400);
1173
- }
1174
- console.error("[arcs-web] run stream preflight failed", err);
1175
- return c.json(fail("internal_error", "Unexpected server error"), 500);
1176
- }
1177
- /**
1178
- * Tails one run's durable event log as SSE, live or after the fact.
1179
- *
1180
- * The log is the source of truth and this is a VIEW of it — a stateless tail,
1181
- * not a subscription. Every frame is derived from `?from=` plus the file, the
1182
- * only state is two numbers on this request's own stack, and nothing keyed on a
1183
- * run or a connection exists anywhere in this module. That is what makes a
1184
- * server restart cost exactly one client reconnect: the new process can answer
1185
- * the same GET with the same bytes, because it never knew anything the file did
1186
- * not already say.
1187
- *
1188
- * Frames, all of them carrying an absolute line offset:
1189
- * - `line` `{ offset, line }` — the log's line at `offset`, verbatim.
1190
- * - `end` `{ offset, outcome?, truncated? }` — the run has settled and the
1191
- * log is drained; `offset` is the log's total complete-line count,
1192
- * i.e. the `from` that would now return nothing.
1193
- *
1194
- * The SSE `id` field is the RESUME cursor rather than the frame's own index
1195
- * (`offset + 1` on a line, `offset` on the end frame), which is what makes an
1196
- * `EventSource` auto-reconnect land exactly where it left off with no client
1197
- * arithmetic. Note that an `EventSource` reconnects on ANY stream end, `end`
1198
- * frame included — the client is expected to `close()` on `end`; the reconnect
1199
- * is harmless (it replays nothing and closes again) but it is the client's job
1200
- * to stop it. The header it replays merges as `max(from, Last-Event-ID)`, so a
1201
- * request carrying it CANNOT REWIND below it — a deliberate rewind is a fresh
1202
- * `EventSource` (or a plain GET), which sends no header at all.
1203
- *
1204
- * Ordering that carries the whole live/settled distinction: the settle is
1205
- * observed BEFORE the read, never after. A run settled at that instant appends
1206
- * nothing later, so the read that follows is guaranteed to see the log whole —
1207
- * the other order loses every line written between the read and the check. A GET
1208
- * issued after settle therefore takes exactly one pass: replay from `from`, one
1209
- * `end` frame, close.
1210
- *
1211
- * `truncated` on the `end` frame is how a consumer tells "I reached the end of
1212
- * the stream" from "I reached a hole the log refused to fill" — a capped log
1213
- * ends on a line boundary and is indistinguishable from a complete one by
1214
- * reading it. It is only readable at settle: while the run is live the flag
1215
- * lives in the writer's memory and reaches disk (as
1216
- * `metadata.run.eventLogTruncated`) only when the write-back stamps the outcome,
1217
- * so a live tail cannot report it and does not pretend to.
1218
- *
1219
- * A read route by construction — it opens nothing, spawns nothing and writes
1220
- * nothing — so it sits behind the loopback check alone, exactly like every other
1221
- * GET here, and the `X-ARCS-Token` mutation gate passes it through on method.
1222
- */
1223
- sessionsRoute.get("/api/p/:slug/sessions/:id/runs/:runId/stream", async (c) => {
1224
- const runId = c.req.param("runId");
1225
- let projectDir;
1226
- let sessionId;
1227
- let logPath;
1228
- let fromOffset;
1229
- try {
1230
- projectDir = requireProjectDir(c.req.param("slug"));
1231
- const session = await getSession(projectDir, c.req.param("id"));
1232
- sessionId = session.normalizedId;
1233
- fromOffset = parseRunTailOffset(c.req.query("from"), c.req.header("last-event-id"));
1234
- // Keyed on the canonical id, exactly as the writer keys it; the run id
1235
- // reaches a filename through `runEventLogSegment`, which sanitizes it, so a
1236
- // traversal-shaped runId cannot address anything outside the sessions dir.
1237
- logPath = runEventLogPath(projectDir, sessionId, runId);
1238
- let logged = false;
1239
- try {
1240
- logged = (await stat(logPath)).isFile();
1241
- }
1242
- catch {
1243
- // Not written yet — the claim lands BEFORE the child spawns, so a tail
1244
- // that connects on the 202 legitimately arrives ahead of the file.
1245
- }
1246
- // Neither a log nor a claim: the run never existed under this id, or
1247
- // retention has already pruned it. Refused rather than answered with an
1248
- // empty stream, for the same reason `eventLogTruncated` exists — absent
1249
- // evidence must never look like evidence of silence.
1250
- if (!logged && sessionRunClaim(session) !== runId) {
1251
- throw new DagError("RUN_EVENT_LOG_NOT_FOUND", `no event log for run "${runId}" on session "${sessionId}" — it is not the ` +
1252
- `session's live run and its log is not on disk (pruned, or never written)`);
1253
- }
1254
- }
1255
- catch (err) {
1256
- return runStreamFailure(c, err);
1257
- }
1258
- return streamSSE(c, async (stream) => {
1259
- /** Absolute index of the next line at `byteOffset`. */
1260
- let lineOffset = 0;
1261
- /** Bytes of the log already framed into lines — never inside a record. */
1262
- let byteOffset = 0;
1263
- while (!stream.aborted) {
1264
- const state = await readRunTailState(projectDir, sessionId, runId);
1265
- const { lines, bytes } = await readRunLogLines(logPath, byteOffset);
1266
- byteOffset += bytes;
1267
- for (const line of lines) {
1268
- const offset = lineOffset;
1269
- lineOffset += 1;
1270
- // Counted but not sent: the client already holds it. Counting is what
1271
- // keeps offsets ABSOLUTE — a skipped line still occupies its index.
1272
- if (offset < fromOffset)
1273
- continue;
1274
- await stream.writeSSE({
1275
- event: "line",
1276
- id: String(offset + 1),
1277
- data: JSON.stringify({ offset, line }),
1278
- });
1279
- }
1280
- if (state.settled) {
1281
- await stream.writeSSE({
1282
- event: "end",
1283
- id: String(lineOffset),
1284
- data: JSON.stringify({
1285
- offset: lineOffset,
1286
- ...(state.outcome !== undefined && { outcome: state.outcome }),
1287
- ...(state.truncated !== undefined && { truncated: state.truncated }),
1288
- }),
1289
- });
1290
- return;
1291
- }
1292
- await stream.sleep(RUN_TAIL_POLL_MS);
1293
- }
1294
- });
1295
- });
1296
- //# sourceMappingURL=sessions.js.map