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