@rryando/arcs 3.11.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 (191) hide show
  1. package/dist/cli/commands/hooks.d.ts +46 -6
  2. package/dist/cli/commands/hooks.d.ts.map +1 -1
  3. package/dist/cli/commands/hooks.js +190 -52
  4. package/dist/cli/commands/hooks.js.map +1 -1
  5. package/dist/cli/commands/web.js +14 -4
  6. package/dist/cli/commands/web.js.map +1 -1
  7. package/dist/cli/config.d.ts +14 -3
  8. package/dist/cli/config.d.ts.map +1 -1
  9. package/dist/cli/config.js +39 -38
  10. package/dist/cli/config.js.map +1 -1
  11. package/dist/cli/instructions.d.ts +13 -1
  12. package/dist/cli/instructions.d.ts.map +1 -1
  13. package/dist/cli/instructions.js +38 -3
  14. package/dist/cli/instructions.js.map +1 -1
  15. package/dist/cli/setup.d.ts.map +1 -1
  16. package/dist/cli/setup.js +76 -11
  17. package/dist/cli/setup.js.map +1 -1
  18. package/dist/shared/session-vocabulary.d.ts +88 -0
  19. package/dist/shared/session-vocabulary.d.ts.map +1 -0
  20. package/dist/shared/session-vocabulary.js +114 -0
  21. package/dist/shared/session-vocabulary.js.map +1 -0
  22. package/dist/utils/claude-code-hook-install.d.ts +1 -1
  23. package/dist/utils/claude-code-hook-install.d.ts.map +1 -1
  24. package/dist/utils/claude-code-hook-install.js +68 -3
  25. package/dist/utils/claude-code-hook-install.js.map +1 -1
  26. package/dist/utils/claude-transcript.d.ts +81 -2
  27. package/dist/utils/claude-transcript.d.ts.map +1 -1
  28. package/dist/utils/claude-transcript.js +33 -2
  29. package/dist/utils/claude-transcript.js.map +1 -1
  30. package/dist/utils/git.d.ts +21 -0
  31. package/dist/utils/git.d.ts.map +1 -1
  32. package/dist/utils/git.js +65 -1
  33. package/dist/utils/git.js.map +1 -1
  34. package/dist/utils/hook-contract.d.ts +36 -0
  35. package/dist/utils/hook-contract.d.ts.map +1 -0
  36. package/dist/utils/hook-contract.js +35 -0
  37. package/dist/utils/hook-contract.js.map +1 -0
  38. package/dist/utils/hook-token-store.d.ts +39 -0
  39. package/dist/utils/hook-token-store.d.ts.map +1 -1
  40. package/dist/utils/hook-token-store.js +46 -3
  41. package/dist/utils/hook-token-store.js.map +1 -1
  42. package/dist/utils/session-store.d.ts +226 -0
  43. package/dist/utils/session-store.d.ts.map +1 -1
  44. package/dist/utils/session-store.js +266 -1
  45. package/dist/utils/session-store.js.map +1 -1
  46. package/dist/web-client/assets/{GraphCanvas-dNyZ458L.js → GraphCanvas-CTyf_XXQ.js} +1 -1
  47. package/dist/web-client/assets/{MarkdownEditor-BmU9mdkN.js → MarkdownEditor-af2vwQOX.js} +1 -1
  48. package/dist/web-client/assets/{abnfDiagram-VRR7QNED-D1BFBoeF.js → abnfDiagram-VRR7QNED-CdxcKX9t.js} +1 -1
  49. package/dist/web-client/assets/architecture-TIHT7OUA-Bt2-DmfV.js +1 -0
  50. package/dist/web-client/assets/{architectureDiagram-ZJ3FMSHR-CA8hTWUK.js → architectureDiagram-ZJ3FMSHR-DOISDv6o.js} +1 -1
  51. package/dist/web-client/assets/{blockDiagram-677ZJIJ3-CQpb_KwX.js → blockDiagram-677ZJIJ3-DKwtbttM.js} +1 -1
  52. package/dist/web-client/assets/{c4Diagram-LMCZKHZV-BhpHX84V.js → c4Diagram-LMCZKHZV-CilqK-Mm.js} +1 -1
  53. package/dist/web-client/assets/channel-C2mb9np_.js +1 -0
  54. package/dist/web-client/assets/{chunk-32BRIVSS-BUusQQa_.js → chunk-32BRIVSS-C79m1mkG.js} +1 -1
  55. package/dist/web-client/assets/{chunk-52WLFC77-nNYwlftl.js → chunk-52WLFC77-C6WelGWJ.js} +1 -1
  56. package/dist/web-client/assets/{chunk-C7G6YPKG-D0a-yqnK.js → chunk-C7G6YPKG-DLg7ryWI.js} +1 -1
  57. package/dist/web-client/assets/{chunk-EX3LRPZG-IDuRMN-4.js → chunk-EX3LRPZG-DjWgo4gL.js} +1 -1
  58. package/dist/web-client/assets/{chunk-FWX5IMBZ-D_spTcqi.js → chunk-FWX5IMBZ-BgS9p_zy.js} +2 -2
  59. package/dist/web-client/assets/{chunk-HOUHSVGY-C-bcxwZS.js → chunk-HOUHSVGY-DuVR7dZO.js} +1 -1
  60. package/dist/web-client/assets/{chunk-ICXQ74PX-CjmK2bAM.js → chunk-ICXQ74PX-Y8DlnIJM.js} +1 -1
  61. package/dist/web-client/assets/{chunk-MOJQB5TN-Bkc08KWY.js → chunk-MOJQB5TN-BEM3QgeD.js} +1 -1
  62. package/dist/web-client/assets/{chunk-OGEWGWER-BAAYYvG9.js → chunk-OGEWGWER-DlM8LxGr.js} +1 -1
  63. package/dist/web-client/assets/{chunk-PUDLZKDR-WXbPY7NM.js → chunk-PUDLZKDR-c6cqNVTx.js} +1 -1
  64. package/dist/web-client/assets/{chunk-Q4XR5HBZ-DcbnjxQE.js → chunk-Q4XR5HBZ-C5lNmcka.js} +1 -1
  65. package/dist/web-client/assets/{chunk-V7JOEXUC-C6t75PAp.js → chunk-V7JOEXUC-nSswxvSG.js} +1 -1
  66. package/dist/web-client/assets/{chunk-VAUOI2AC-DVrJ0Ic7.js → chunk-VAUOI2AC-CqJkCkT0.js} +1 -1
  67. package/dist/web-client/assets/{chunk-VR4S4FIN-MOOFvGS0.js → chunk-VR4S4FIN-D3pFchin.js} +1 -1
  68. package/dist/web-client/assets/{chunk-WYO6CB5R-sK7Y2NZD.js → chunk-WYO6CB5R-BI9c-NzI.js} +1 -1
  69. package/dist/web-client/assets/{chunk-ZGVPDNZ5-a13RQsku.js → chunk-ZGVPDNZ5-CKTF2kLR.js} +1 -1
  70. package/dist/web-client/assets/classDiagram-OUVF2IWQ-CPd_w3Zr.js +1 -0
  71. package/dist/web-client/assets/classDiagram-v2-EOCWNBFH-CPd_w3Zr.js +1 -0
  72. package/dist/web-client/assets/{cynefin-VYW2F7L2-D8xaH-wO.js → cynefin-VYW2F7L2-BrJrnfh1.js} +1 -1
  73. package/dist/web-client/assets/{cynefinDiagram-TSTJHNR4-Bt__EqJW.js → cynefinDiagram-TSTJHNR4-9jYzPWJZ.js} +1 -1
  74. package/dist/web-client/assets/{dagre-VKFMJZFB-Bwgjwflz.js → dagre-VKFMJZFB-B705Djpu.js} +1 -1
  75. package/dist/web-client/assets/{diagram-FQU43EPY-CaPDVUq2.js → diagram-FQU43EPY-BfQAlUlJ.js} +1 -1
  76. package/dist/web-client/assets/{diagram-G47NLZAW-BQLB9YYA.js → diagram-G47NLZAW-s1YDe06A.js} +1 -1
  77. package/dist/web-client/assets/{diagram-NH7WQ7WH-BWo84w8Y.js → diagram-NH7WQ7WH-Dy3z11Hc.js} +1 -1
  78. package/dist/web-client/assets/{diagram-OA4YK3LP-bKn6Pz5s.js → diagram-OA4YK3LP-Bius2xUN.js} +1 -1
  79. package/dist/web-client/assets/{diagram-WEI45ONY-BRqV5Oy6.js → diagram-WEI45ONY-D7x4VcHM.js} +1 -1
  80. package/dist/web-client/assets/{ebnfDiagram-CCIWWBDH-DhUXL1-7.js → ebnfDiagram-CCIWWBDH-CBg1xrmD.js} +1 -1
  81. package/dist/web-client/assets/{erDiagram-Q63AITRT-HXAQQ-_F.js → erDiagram-Q63AITRT-CJRvTFvd.js} +1 -1
  82. package/dist/web-client/assets/eventmodeling-45OFAUF4-CTKRC2Yj.js +1 -0
  83. package/dist/web-client/assets/flowDiagram-23GEKE2U-CaZjFyU1.js +1 -0
  84. package/dist/web-client/assets/{ganttDiagram-NO4QXBWP-D-Ddf_Ii.js → ganttDiagram-NO4QXBWP-C_LsypZ4.js} +1 -1
  85. package/dist/web-client/assets/{gitGraph-TEB2WS4Q-BmHzs0uF.js → gitGraph-TEB2WS4Q-Dx2XxdGk.js} +1 -1
  86. package/dist/web-client/assets/{gitGraphDiagram-IHSO6WYX-YnQWrlh_.js → gitGraphDiagram-IHSO6WYX-DiKWkGWQ.js} +1 -1
  87. package/dist/web-client/assets/{index-DCWxuIeQ.js → index-CYwhkPtc.js} +38 -36
  88. package/dist/web-client/assets/index-wSzUPvml.css +2 -0
  89. package/dist/web-client/assets/{info-DKCQHKI2-DCT_B7RN.js → info-DKCQHKI2-DORwHenK.js} +1 -1
  90. package/dist/web-client/assets/{infoDiagram-FWYZ7A6U-D-le1Zhq.js → infoDiagram-FWYZ7A6U-CQecXS1E.js} +1 -1
  91. package/dist/web-client/assets/{ishikawaDiagram-FXEZZL3T-Jr1x2VJB.js → ishikawaDiagram-FXEZZL3T-CWhj60Zp.js} +1 -1
  92. package/dist/web-client/assets/{journeyDiagram-5HDEW3XC-BF9ELxj-.js → journeyDiagram-5HDEW3XC-Cj3z2U8u.js} +1 -1
  93. package/dist/web-client/assets/{kanban-definition-HUTT4EX6-C4fJqAxu.js → kanban-definition-HUTT4EX6-DFmBRenP.js} +1 -1
  94. package/dist/web-client/assets/{line-7N7ikFxa.js → line-C_Hxz9xb.js} +1 -1
  95. package/dist/web-client/assets/{mermaid-parser.core-QbC1icPt.js → mermaid-parser.core-E3xRdzPn.js} +3 -3
  96. package/dist/web-client/assets/{mermaid.core-C26d_UJm.js → mermaid.core-C2_CcotH.js} +3 -3
  97. package/dist/web-client/assets/{mindmap-definition-LN4V7U3C-D6TV1JDf.js → mindmap-definition-LN4V7U3C-Bs7VxPid.js} +1 -1
  98. package/dist/web-client/assets/{packet-7NZHBO7P-CR1vrGj3.js → packet-7NZHBO7P-D2nqso7i.js} +1 -1
  99. package/dist/web-client/assets/{pegDiagram-2B236MQR-xOMBBtfV.js → pegDiagram-2B236MQR-Cepo1sIu.js} +1 -1
  100. package/dist/web-client/assets/{pie-RZYD4A2V-BbWuhjwy.js → pie-RZYD4A2V-Sx0IGbXt.js} +1 -1
  101. package/dist/web-client/assets/{pieDiagram-ENE6RG2P-MsfnsqgW.js → pieDiagram-ENE6RG2P-D-PMDNhk.js} +1 -1
  102. package/dist/web-client/assets/{quadrantDiagram-ABIIQ3AL-BoI7zKXF.js → quadrantDiagram-ABIIQ3AL-BNGC7Fms.js} +1 -1
  103. package/dist/web-client/assets/{radar-I7S5WNFK-CbYXKToJ.js → radar-I7S5WNFK-CFf_-2OC.js} +1 -1
  104. package/dist/web-client/assets/{railroad-3IZDKUUU-6LxHDkLe.js → railroad-3IZDKUUU-BvZeVRGh.js} +1 -1
  105. package/dist/web-client/assets/railroad-abnf-AHOZXSZD-DnGh6x2O.js +1 -0
  106. package/dist/web-client/assets/railroad-ebnf-EBAXGLYW-CGfgCoaE.js +1 -0
  107. package/dist/web-client/assets/railroad-peg-LSFZ7HO6-BzQxYdqp.js +1 -0
  108. package/dist/web-client/assets/{railroadDiagram-RFXS5EU6-D6RUoUki.js → railroadDiagram-RFXS5EU6-D0iK_k84.js} +1 -1
  109. package/dist/web-client/assets/{requirementDiagram-TGXJPOKE-B6k4BDpE.js → requirementDiagram-TGXJPOKE-BPdsw9tz.js} +1 -1
  110. package/dist/web-client/assets/{sankeyDiagram-HTMAVEWB-BUDF-UFr.js → sankeyDiagram-HTMAVEWB-maA6ukOJ.js} +1 -1
  111. package/dist/web-client/assets/{sequenceDiagram-DBY2YBRQ-D6GqcsUi.js → sequenceDiagram-DBY2YBRQ-DKmAjmgr.js} +1 -1
  112. package/dist/web-client/assets/{stateDiagram-2N3HPSRC-WfJCQAK5.js → stateDiagram-2N3HPSRC-esxmzk3w.js} +1 -1
  113. package/dist/web-client/assets/stateDiagram-v2-6OUMAXLB-hcBqlOLc.js +1 -0
  114. package/dist/web-client/assets/{swimlanes-5IMT3BWC-BtMo82mC.js → swimlanes-5IMT3BWC-DFd-wGBv.js} +1 -1
  115. package/dist/web-client/assets/swimlanesDiagram-G3AALYLV-DbSrPBYr.js +8 -0
  116. package/dist/web-client/assets/{timeline-definition-FHXFAJF6-CoAmv2Sn.js → timeline-definition-FHXFAJF6-C7SG8MQH.js} +1 -1
  117. package/dist/web-client/assets/{treeView-QDETBFTQ-BWsKzE1s.js → treeView-QDETBFTQ-M0rhxbs2.js} +1 -1
  118. package/dist/web-client/assets/{treemap-6X3UGDF4-i_qGtB3o.js → treemap-6X3UGDF4-BQfkQxSh.js} +1 -1
  119. package/dist/web-client/assets/{vennDiagram-L72KCM5P-DYkiLe-P.js → vennDiagram-L72KCM5P-CyIlb8ju.js} +1 -1
  120. package/dist/web-client/assets/{wardley-OPB4EBWU-Daaqr1Vp.js → wardley-OPB4EBWU-EE-7q8M1.js} +1 -1
  121. package/dist/web-client/assets/{wardleyDiagram-EHGQE667-Bofbsg3J.js → wardleyDiagram-EHGQE667-bY8XdYYS.js} +1 -1
  122. package/dist/web-client/assets/{xychartDiagram-FW5EYKEG-KNF4VTfL.js → xychartDiagram-FW5EYKEG-AhuXJ3de.js} +1 -1
  123. package/dist/web-client/index.html +2 -2
  124. package/dist/web-server/app.d.ts +4 -1
  125. package/dist/web-server/app.d.ts.map +1 -1
  126. package/dist/web-server/app.js +19 -0
  127. package/dist/web-server/app.js.map +1 -1
  128. package/dist/web-server/claude-runner.d.ts +109 -7
  129. package/dist/web-server/claude-runner.d.ts.map +1 -1
  130. package/dist/web-server/claude-runner.js +329 -50
  131. package/dist/web-server/claude-runner.js.map +1 -1
  132. package/dist/web-server/index.d.ts +2 -2
  133. package/dist/web-server/index.d.ts.map +1 -1
  134. package/dist/web-server/index.js +3 -2
  135. package/dist/web-server/index.js.map +1 -1
  136. package/dist/web-server/permission-policy.d.ts +46 -0
  137. package/dist/web-server/permission-policy.d.ts.map +1 -0
  138. package/dist/web-server/permission-policy.js +96 -0
  139. package/dist/web-server/permission-policy.js.map +1 -0
  140. package/dist/web-server/prompt-assembly.d.ts +454 -0
  141. package/dist/web-server/prompt-assembly.d.ts.map +1 -0
  142. package/dist/web-server/prompt-assembly.js +1122 -0
  143. package/dist/web-server/prompt-assembly.js.map +1 -0
  144. package/dist/web-server/routes/hook-events.d.ts +28 -3
  145. package/dist/web-server/routes/hook-events.d.ts.map +1 -1
  146. package/dist/web-server/routes/hook-events.js +209 -24
  147. package/dist/web-server/routes/hook-events.js.map +1 -1
  148. package/dist/web-server/routes/sessions.d.ts +7 -5
  149. package/dist/web-server/routes/sessions.d.ts.map +1 -1
  150. package/dist/web-server/routes/sessions.js +1030 -174
  151. package/dist/web-server/routes/sessions.js.map +1 -1
  152. package/dist/web-server/routes/workspace.d.ts +27 -0
  153. package/dist/web-server/routes/workspace.d.ts.map +1 -0
  154. package/dist/web-server/routes/workspace.js +280 -0
  155. package/dist/web-server/routes/workspace.js.map +1 -0
  156. package/dist/web-server/run-event-log.d.ts +167 -0
  157. package/dist/web-server/run-event-log.d.ts.map +1 -0
  158. package/dist/web-server/run-event-log.js +468 -0
  159. package/dist/web-server/run-event-log.js.map +1 -0
  160. package/dist/web-server/session-reconciler.d.ts +162 -0
  161. package/dist/web-server/session-reconciler.d.ts.map +1 -0
  162. package/dist/web-server/session-reconciler.js +363 -0
  163. package/dist/web-server/session-reconciler.js.map +1 -0
  164. package/dist/web-server/static.d.ts +7 -0
  165. package/dist/web-server/static.d.ts.map +1 -1
  166. package/dist/web-server/static.js +46 -3
  167. package/dist/web-server/static.js.map +1 -1
  168. package/dist/web-server/web-auth.d.ts +17 -0
  169. package/dist/web-server/web-auth.d.ts.map +1 -0
  170. package/dist/web-server/web-auth.js +33 -0
  171. package/dist/web-server/web-auth.js.map +1 -0
  172. package/dist/web-server/web-token.d.ts +39 -0
  173. package/dist/web-server/web-token.d.ts.map +1 -0
  174. package/dist/web-server/web-token.js +71 -0
  175. package/dist/web-server/web-token.js.map +1 -0
  176. package/opencode/arcs/manifest.json +8 -8
  177. package/package.json +1 -1
  178. package/scripts/claude-code-session-hook.mjs +49 -3
  179. package/scripts/deploy-claudecode-bundle.mjs +24 -3
  180. package/dist/web-client/assets/architecture-TIHT7OUA-CJqI5wNI.js +0 -1
  181. package/dist/web-client/assets/channel-C8DlmyVe.js +0 -1
  182. package/dist/web-client/assets/classDiagram-OUVF2IWQ-p32N1P_G.js +0 -1
  183. package/dist/web-client/assets/classDiagram-v2-EOCWNBFH-p32N1P_G.js +0 -1
  184. package/dist/web-client/assets/eventmodeling-45OFAUF4-Bj5P8mZJ.js +0 -1
  185. package/dist/web-client/assets/flowDiagram-23GEKE2U-37BztFri.js +0 -1
  186. package/dist/web-client/assets/index-3mNPVkix.css +0 -2
  187. package/dist/web-client/assets/railroad-abnf-AHOZXSZD-2Dg9wu0J.js +0 -1
  188. package/dist/web-client/assets/railroad-ebnf-EBAXGLYW-C_E2ot0R.js +0 -1
  189. package/dist/web-client/assets/railroad-peg-LSFZ7HO6-Bs9UQR1b.js +0 -1
  190. package/dist/web-client/assets/stateDiagram-v2-6OUMAXLB-BCMWGnsJ.js +0 -1
  191. package/dist/web-client/assets/swimlanesDiagram-G3AALYLV-DmudmLcK.js +0 -8
@@ -5,24 +5,32 @@
5
5
  * attached to a project. All mutations go through the locked session-store,
6
6
  * so the opencode discovery bridge and the web UI cannot clobber each other.
7
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.
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.
13
15
  */
14
16
  import { randomUUID } from "node:crypto";
15
- import { stat, unlink } from "node:fs/promises";
17
+ import { open, readFile, stat, unlink } from "node:fs/promises";
16
18
  import { resolve } from "node:path";
17
19
  import { Hono } from "hono";
20
+ import { streamSSE } from "hono/streaming";
18
21
  import { z } from "zod";
19
- import { appendReferenceTurn, appendSessionTurn, mirrorSessionTranscript, readSessionTurns, sessionTranscriptPath, } from "../../utils/claude-transcript.js";
22
+ import { appendReferenceTurn, appendSessionTurn, readSessionTurns, referenceTurnText, sessionTranscriptPath, } from "../../utils/claude-transcript.js";
20
23
  import { DagError } from "../../utils/errors.js";
21
24
  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";
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";
24
28
  import { createOpencodeSession, readOpencodeConfig, sendOpencodeMessage, } from "../opencode-client.js";
25
- import { parseBody, requireProjectDir, respond } from "../respond.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";
26
34
  export const sessionsRoute = new Hono();
27
35
  const createSessionSchema = z.object({
28
36
  runtimeType: z.enum(SESSION_RUNTIME_TYPES),
@@ -42,11 +50,21 @@ const updateSessionSchema = z.object({
42
50
  linkedNodeType: z.enum(SESSION_LINKED_NODE_TYPES).nullable().optional(),
43
51
  linkedNodeId: z.string().nullable().optional(),
44
52
  });
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({
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"),
50
68
  section: z.object({
51
69
  depth: z.number(),
52
70
  text: z.string(),
@@ -62,22 +80,107 @@ const sessionReferenceSchema = z.object({
62
80
  id: z.string().optional(),
63
81
  }),
64
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
+ });
65
129
  const sendMessageSchema = z.object({
66
130
  message: z.string().min(1),
67
131
  reference: sessionReferenceSchema.optional(),
68
132
  });
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"]),
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),
74
151
  message: z.string().min(1),
75
- threadId: z.string().optional(),
76
- reference: sessionReferenceSchema.optional(),
152
+ refs: z.array(sessionReferenceSchema).optional(),
153
+ threadRef: z.string().min(1).optional(),
154
+ guards: z.record(z.unknown()).optional(),
77
155
  });
78
156
  const createOpencodeSessionSchema = z.object({
79
157
  title: z.string().min(1).optional(),
80
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
+ }
81
184
  function sessionDirectory(session) {
82
185
  const directory = session.metadata?.directory;
83
186
  return typeof directory === "string" && directory ? directory : undefined;
@@ -106,6 +209,11 @@ async function primaryWorkspacePath(projectDir, slug) {
106
209
  }
107
210
  return directory;
108
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
+ }
109
217
  function parseFilters(status, runtimeType) {
110
218
  const filters = {};
111
219
  if (status && SESSION_STATUSES.includes(status)) {
@@ -116,71 +224,240 @@ function parseFilters(status, runtimeType) {
116
224
  }
117
225
  return filters;
118
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
+ }
119
242
  /**
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.
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.
133
374
  *
134
375
  * Every path finalizes metadata.run with the settled record (pid/startedAt/
135
376
  * mode plus endedAt/outcome/error/replyChars) so the panel shows the true
136
377
  * result. Best-effort by contract: the runner swallows any error thrown here,
137
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.
138
385
  */
139
386
  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.
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.
157
399
  await appendSessionTurn(projectDir, ctx.writeTarget.normalizedId, {
158
400
  type: "assistant",
159
401
  text: record.replyText,
402
+ run: ctx.runId,
160
403
  });
161
404
  }
162
- const run = {
163
- pid: record.pid,
164
- startedAt: record.startedAt,
165
- mode: ctx.mode,
166
- ...(record.endedAt !== undefined && { endedAt: record.endedAt }),
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,
167
425
  outcome: record.outcome,
168
426
  ...(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,
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 }),
178
455
  });
179
456
  }
180
457
  sessionsRoute.get("/api/p/:slug/sessions", async (c) => respond(c, async () => {
181
458
  const projectDir = requireProjectDir(c.req.param("slug"));
182
459
  const sessions = await listSessions(projectDir, parseFilters(c.req.query("status"), c.req.query("runtimeType")));
183
- return { sessions };
460
+ return { sessions: await withPhases(projectDir, sessions) };
184
461
  }));
185
462
  sessionsRoute.post("/api/p/:slug/sessions", async (c) => respond(c, async () => {
186
463
  const projectDir = requireProjectDir(c.req.param("slug"));
@@ -218,7 +495,9 @@ sessionsRoute.post("/api/p/:slug/sessions/opencode/new", async (c) => respond(c,
218
495
  }, 201));
219
496
  sessionsRoute.get("/api/p/:slug/sessions/:id", async (c) => respond(c, async () => {
220
497
  const projectDir = requireProjectDir(c.req.param("slug"));
221
- return getSession(projectDir, c.req.param("id"));
498
+ const session = await getSession(projectDir, c.req.param("id"));
499
+ const [view] = await withPhases(projectDir, [session]);
500
+ return view;
222
501
  }));
223
502
  sessionsRoute.patch("/api/p/:slug/sessions/:id", async (c) => respond(c, async () => {
224
503
  const projectDir = requireProjectDir(c.req.param("slug"));
@@ -235,6 +514,19 @@ sessionsRoute.patch("/api/p/:slug/sessions/:id", async (c) => respond(c, async (
235
514
  * Both answer with the updated session, so the caller can tell them apart by
236
515
  * `messageQueue` rather than by branching on `runtimeType` itself.
237
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
+ *
238
530
  * A `reference` body field appends an ARCS-authored reference turn to the
239
531
  * session's transcript sidecar, but only after delivery has succeeded — a
240
532
  * failed send must never leave a dangling reference behind. The append is a
@@ -246,6 +538,11 @@ sessionsRoute.post("/api/p/:slug/sessions/:id/message", async (c) => respond(c,
246
538
  const session = await getSession(projectDir, c.req.param("id"));
247
539
  let updated;
248
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
+ }
249
546
  // Queued, not sent: `lastMessageAt` stays untouched until the session
250
547
  // actually drains this at a checkpoint.
251
548
  updated = await enqueueSessionMessage(projectDir, session.normalizedId, message);
@@ -262,143 +559,340 @@ sessionsRoute.post("/api/p/:slug/sessions/:id/message", async (c) => respond(c,
262
559
  }
263
560
  // Delivery succeeded (or was accepted into the queue) — record the
264
561
  // reference turn against the session's transcript sidecar, carrying the
265
- // section/source payload verbatim so the web UI can render click-through.
562
+ // payload verbatim so the web UI can render click-through.
266
563
  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
- });
564
+ await appendReference(projectDir, session.normalizedId, reference);
273
565
  }
274
566
  return updated;
275
567
  }));
276
568
  /**
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).
569
+ * The thread record `threadRef` names, or `undefined` when the index answers
570
+ * that there is no such record.
299
571
  *
300
- * Concurrency: one live run per write-target. The runner's beginRun is the
301
- * 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).
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.
304
581
  */
305
- sessionsRoute.post("/api/p/:slug/sessions/:id/run", async (c) => respond(c, async () => {
306
- const slug = c.req.param("slug");
307
- const projectDir = requireProjectDir(slug);
308
- const { mode, message, threadId, reference } = await parseBody(c, runClaudeMessageSchema);
309
- 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`);
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`);
320
633
  }
321
- writeTarget = session;
322
- dir = sessionDirectory(session) ?? (await primaryWorkspacePath(projectDir, slug));
634
+ }
635
+ else if (session.origin === "arcs") {
636
+ threadId = session.runtimeSessionId;
637
+ existing = session;
323
638
  }
324
639
  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;
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`);
346
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);
347
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;
348
779
  // One live run per write-target — refuse before appending anything.
349
780
  if (isRunLive(writeTarget.normalizedId)) {
350
781
  throw new DagError("CLAUDE_RUN_IN_PROGRESS", `a claude run for "${writeTarget.normalizedId}" is already in progress`);
351
782
  }
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
- }
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);
365
789
  }
366
790
  // NOTE: no "--cwd" flag — claude >= 2.x rejects it ("error: unknown
367
791
  // option '--cwd'"), settling every headless run as outcome:error. The
368
792
  // 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
- }
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
+ });
382
852
  // Fire-and-forget: the run proceeds out-of-band. The runner invokes the
383
853
  // registered write-back after the child fully exits (it resolves on
384
854
  // `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.
855
+ // the runner, so a failed finalize never surfaces on the accepted 202.
856
+ // The trailing catch is defensive — the runner never rejects.
387
857
  runClaudeJob({
388
858
  argv,
389
859
  cwd: dir,
860
+ timeoutMs,
390
861
  writeTargetKey: writeTarget.normalizedId,
391
- onSettled: (record) => writeBackRun(projectDir, { mode, writeTarget, firstStableSpawn }, record),
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),
392
866
  }).catch(() => {
393
867
  // Best-effort — the write-back lives inside the runner's onSettled.
394
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
+ }
395
888
  return {
396
- session: writeTarget,
397
- run: {
398
- accepted: true,
399
- mode,
400
- ...(mode === "stable" && { threadId: runThreadId }),
401
- },
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,
402
896
  };
403
897
  }, 202));
404
898
  sessionsRoute.delete("/api/p/:slug/sessions/:id", async (c) => respond(c, async () => {
@@ -414,6 +908,9 @@ sessionsRoute.delete("/api/p/:slug/sessions/:id", async (c) => respond(c, async
414
908
  catch {
415
909
  // Sidecar may not exist — a failed unlink is a swallowed no-op.
416
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);
417
914
  return { deleted: true };
418
915
  }));
419
916
  /**
@@ -439,4 +936,363 @@ sessionsRoute.get("/api/p/:slug/sessions/:id/transcript", async (c) => respond(c
439
936
  return { turns: [], mirroredAt: null };
440
937
  return { turns: await readSessionTurns(projectDir, session.normalizedId), mirroredAt };
441
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
+ });
442
1298
  //# sourceMappingURL=sessions.js.map