@rryando/arcs 3.10.0 → 4.0.0

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