spexcode 0.6.2 → 0.6.6

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 (495) hide show
  1. package/README.md +3 -1
  2. package/bin/spex.mjs +4 -0
  3. package/node_modules/@spexcode/session-core/dist/delivery-queue.d.ts +23 -0
  4. package/node_modules/@spexcode/session-core/dist/delivery-queue.js +195 -0
  5. package/node_modules/@spexcode/session-core/dist/index.d.ts +5 -0
  6. package/node_modules/@spexcode/session-core/dist/index.js +5 -0
  7. package/node_modules/@spexcode/session-core/dist/internal.d.ts +3 -0
  8. package/node_modules/@spexcode/session-core/dist/internal.js +3 -0
  9. package/node_modules/@spexcode/session-core/dist/message.d.ts +22 -0
  10. package/node_modules/@spexcode/session-core/dist/message.js +52 -0
  11. package/node_modules/@spexcode/session-core/dist/record-lock.d.ts +7 -0
  12. package/node_modules/@spexcode/session-core/dist/record-lock.js +152 -0
  13. package/node_modules/@spexcode/session-core/dist/runtime-session.d.ts +50 -0
  14. package/node_modules/@spexcode/session-core/dist/runtime-session.js +286 -0
  15. package/node_modules/@spexcode/session-core/dist/session-cursors.d.ts +14 -0
  16. package/node_modules/@spexcode/session-core/dist/session-cursors.js +82 -0
  17. package/node_modules/@spexcode/session-core/dist/session-timeline.d.ts +46 -0
  18. package/node_modules/@spexcode/session-core/dist/session-timeline.js +216 -0
  19. package/node_modules/@spexcode/session-core/package.json +33 -0
  20. package/node_modules/@spexcode/spec-cli/bin/spex.mjs +77 -0
  21. package/node_modules/@spexcode/spec-cli/dist/attach.d.ts +2 -0
  22. package/node_modules/@spexcode/spec-cli/dist/attach.js +48 -0
  23. package/node_modules/@spexcode/spec-cli/dist/claude-headless.d.ts +36 -0
  24. package/node_modules/@spexcode/spec-cli/dist/claude-headless.js +316 -0
  25. package/node_modules/@spexcode/spec-cli/dist/cli.d.ts +1 -0
  26. package/node_modules/@spexcode/spec-cli/dist/cli.js +1908 -0
  27. package/node_modules/@spexcode/spec-cli/dist/client.d.ts +112 -0
  28. package/node_modules/@spexcode/spec-cli/dist/client.js +481 -0
  29. package/node_modules/@spexcode/spec-cli/dist/cockpit.d.ts +7 -0
  30. package/node_modules/@spexcode/spec-cli/dist/cockpit.js +26 -0
  31. package/node_modules/@spexcode/spec-cli/dist/codex-headless.d.ts +1 -0
  32. package/{spec-cli/src/codex-headless.ts → node_modules/@spexcode/spec-cli/dist/codex-headless.js} +3 -9
  33. package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.d.ts +58 -0
  34. package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.js +798 -0
  35. package/node_modules/@spexcode/spec-cli/dist/commit-surgery.d.ts +1 -0
  36. package/node_modules/@spexcode/spec-cli/dist/commit-surgery.js +90 -0
  37. package/node_modules/@spexcode/spec-cli/dist/contract-filter.d.ts +14 -0
  38. package/node_modules/@spexcode/spec-cli/dist/contract-filter.js +219 -0
  39. package/node_modules/@spexcode/spec-cli/dist/dashboard-assets.d.ts +6 -0
  40. package/node_modules/@spexcode/spec-cli/dist/dashboard-assets.js +50 -0
  41. package/node_modules/@spexcode/spec-cli/dist/doctor.d.ts +9 -0
  42. package/node_modules/@spexcode/spec-cli/dist/doctor.js +553 -0
  43. package/node_modules/@spexcode/spec-cli/dist/eval-host.d.ts +1 -0
  44. package/node_modules/@spexcode/spec-cli/dist/eval-host.js +19 -0
  45. package/node_modules/@spexcode/spec-cli/dist/execution-trace.d.ts +26 -0
  46. package/node_modules/@spexcode/spec-cli/dist/execution-trace.js +475 -0
  47. package/node_modules/@spexcode/spec-cli/dist/file-write.d.ts +2 -0
  48. package/node_modules/@spexcode/spec-cli/dist/file-write.js +26 -0
  49. package/node_modules/@spexcode/spec-cli/dist/flat.d.ts +89 -0
  50. package/node_modules/@spexcode/spec-cli/dist/flat.js +1076 -0
  51. package/node_modules/@spexcode/spec-cli/dist/gateway-auth.d.ts +56 -0
  52. package/node_modules/@spexcode/spec-cli/dist/gateway-auth.js +168 -0
  53. package/node_modules/@spexcode/spec-cli/dist/gateway-hub.d.ts +28 -0
  54. package/node_modules/@spexcode/spec-cli/dist/gateway-hub.js +363 -0
  55. package/node_modules/@spexcode/spec-cli/dist/gateway.d.ts +38 -0
  56. package/node_modules/@spexcode/spec-cli/dist/gateway.js +527 -0
  57. package/node_modules/@spexcode/spec-cli/dist/graphCache.d.ts +27 -0
  58. package/node_modules/@spexcode/spec-cli/dist/graphCache.js +717 -0
  59. package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.d.ts +14 -0
  60. package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.js +36 -0
  61. package/node_modules/@spexcode/spec-cli/dist/graphStream.d.ts +85 -0
  62. package/node_modules/@spexcode/spec-cli/dist/graphStream.js +1195 -0
  63. package/node_modules/@spexcode/spec-cli/dist/guidance-catalog.d.ts +64 -0
  64. package/node_modules/@spexcode/spec-cli/dist/guidance-catalog.js +166 -0
  65. package/node_modules/@spexcode/spec-cli/dist/guide.d.ts +8 -0
  66. package/{spec-cli/src/guide.ts → node_modules/@spexcode/spec-cli/dist/guide.js} +36 -32
  67. package/node_modules/@spexcode/spec-cli/dist/harness-select.d.ts +18 -0
  68. package/node_modules/@spexcode/spec-cli/dist/harness-select.js +62 -0
  69. package/node_modules/@spexcode/spec-cli/dist/harness.d.ts +338 -0
  70. package/node_modules/@spexcode/spec-cli/dist/harness.js +3146 -0
  71. package/node_modules/@spexcode/spec-cli/dist/headless-controller.d.ts +9 -0
  72. package/node_modules/@spexcode/spec-cli/dist/headless-controller.js +47 -0
  73. package/node_modules/@spexcode/spec-cli/dist/help.d.ts +15 -0
  74. package/node_modules/@spexcode/spec-cli/dist/help.js +594 -0
  75. package/node_modules/@spexcode/spec-cli/dist/hook-prompts.d.ts +18 -0
  76. package/node_modules/@spexcode/spec-cli/dist/hook-prompts.js +122 -0
  77. package/node_modules/@spexcode/spec-cli/dist/hooks.d.ts +1 -0
  78. package/{spec-cli/src/hooks.ts → node_modules/@spexcode/spec-cli/dist/hooks.js} +15 -15
  79. package/node_modules/@spexcode/spec-cli/dist/host-resources.d.ts +97 -0
  80. package/node_modules/@spexcode/spec-cli/dist/host-resources.js +795 -0
  81. package/node_modules/@spexcode/spec-cli/dist/host.d.ts +88 -0
  82. package/node_modules/@spexcode/spec-cli/dist/host.js +663 -0
  83. package/node_modules/@spexcode/spec-cli/dist/index.d.ts +1 -0
  84. package/node_modules/@spexcode/spec-cli/dist/index.js +899 -0
  85. package/node_modules/@spexcode/spec-cli/dist/init.d.ts +1 -0
  86. package/node_modules/@spexcode/spec-cli/dist/init.js +284 -0
  87. package/node_modules/@spexcode/spec-cli/dist/issues-cli.d.ts +6 -0
  88. package/node_modules/@spexcode/spec-cli/dist/issues-cli.js +334 -0
  89. package/node_modules/@spexcode/spec-cli/dist/issues.d.ts +69 -0
  90. package/node_modules/@spexcode/spec-cli/dist/issues.js +189 -0
  91. package/node_modules/@spexcode/spec-cli/dist/lint.d.ts +34 -0
  92. package/node_modules/@spexcode/spec-cli/dist/lint.js +476 -0
  93. package/node_modules/@spexcode/spec-cli/dist/listen.d.ts +8 -0
  94. package/node_modules/@spexcode/spec-cli/dist/listen.js +24 -0
  95. package/node_modules/@spexcode/spec-cli/dist/localIssues.d.ts +65 -0
  96. package/node_modules/@spexcode/spec-cli/dist/localIssues.js +582 -0
  97. package/node_modules/@spexcode/spec-cli/dist/login-page.d.ts +5 -0
  98. package/{spec-cli/src/login-page.ts → node_modules/@spexcode/spec-cli/dist/login-page.js} +7 -7
  99. package/node_modules/@spexcode/spec-cli/dist/loop-in.d.ts +23 -0
  100. package/{spec-cli/src/loop-in.ts → node_modules/@spexcode/spec-cli/dist/loop-in.js} +35 -45
  101. package/node_modules/@spexcode/spec-cli/dist/machine-peer.d.ts +69 -0
  102. package/node_modules/@spexcode/spec-cli/dist/machine-peer.js +603 -0
  103. package/node_modules/@spexcode/spec-cli/dist/materialize.d.ts +18 -0
  104. package/node_modules/@spexcode/spec-cli/dist/materialize.js +468 -0
  105. package/node_modules/@spexcode/spec-cli/dist/mentions.d.ts +50 -0
  106. package/node_modules/@spexcode/spec-cli/dist/mentions.js +117 -0
  107. package/node_modules/@spexcode/spec-cli/dist/opencode-headless.d.ts +5 -0
  108. package/node_modules/@spexcode/spec-cli/dist/opencode-headless.js +215 -0
  109. package/node_modules/@spexcode/spec-cli/dist/opencode.d.ts +3 -0
  110. package/{spec-cli/src/opencode.ts → node_modules/@spexcode/spec-cli/dist/opencode.js} +9 -13
  111. package/node_modules/@spexcode/spec-cli/dist/pi-harness.d.ts +3 -0
  112. package/{spec-cli/src/pi-harness.ts → node_modules/@spexcode/spec-cli/dist/pi-harness.js} +45 -40
  113. package/node_modules/@spexcode/spec-cli/dist/pi-headless.d.ts +25 -0
  114. package/node_modules/@spexcode/spec-cli/dist/pi-headless.js +196 -0
  115. package/node_modules/@spexcode/spec-cli/dist/plugin-harness.d.ts +21 -0
  116. package/node_modules/@spexcode/spec-cli/dist/plugin-harness.js +146 -0
  117. package/node_modules/@spexcode/spec-cli/dist/pty-bridge.d.ts +10 -0
  118. package/node_modules/@spexcode/spec-cli/dist/pty-bridge.js +404 -0
  119. package/node_modules/@spexcode/spec-cli/dist/pty-helper.d.mts +1 -0
  120. package/node_modules/@spexcode/spec-cli/dist/pty-helper.mjs +101 -0
  121. package/node_modules/@spexcode/spec-cli/dist/pty-native-helper.d.mts +2 -0
  122. package/node_modules/@spexcode/spec-cli/dist/pty-native-helper.mjs +23 -0
  123. package/node_modules/@spexcode/spec-cli/dist/public-graph.d.ts +46 -0
  124. package/node_modules/@spexcode/spec-cli/dist/public-graph.js +56 -0
  125. package/node_modules/@spexcode/spec-cli/dist/ranker.d.ts +15 -0
  126. package/node_modules/@spexcode/spec-cli/dist/ranker.js +188 -0
  127. package/node_modules/@spexcode/spec-cli/dist/reaper.d.ts +6 -0
  128. package/node_modules/@spexcode/spec-cli/dist/reaper.js +81 -0
  129. package/node_modules/@spexcode/spec-cli/dist/reviews.d.ts +189 -0
  130. package/node_modules/@spexcode/spec-cli/dist/reviews.js +350 -0
  131. package/node_modules/@spexcode/spec-cli/dist/runtime-guard.d.ts +6 -0
  132. package/node_modules/@spexcode/spec-cli/dist/runtime-guard.js +37 -0
  133. package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.d.ts +21 -0
  134. package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.js +84 -0
  135. package/node_modules/@spexcode/spec-cli/dist/runtime-rotate.d.ts +1 -0
  136. package/node_modules/@spexcode/spec-cli/dist/runtime-rotate.js +58 -0
  137. package/node_modules/@spexcode/spec-cli/dist/search.bench.d.mts +1 -0
  138. package/node_modules/@spexcode/spec-cli/dist/search.bench.mjs +75 -0
  139. package/node_modules/@spexcode/spec-cli/dist/search.d.ts +20 -0
  140. package/node_modules/@spexcode/spec-cli/dist/search.js +61 -0
  141. package/node_modules/@spexcode/spec-cli/dist/session-declarations.d.ts +13 -0
  142. package/node_modules/@spexcode/spec-cli/dist/session-declarations.js +126 -0
  143. package/node_modules/@spexcode/spec-cli/dist/session-execution.d.ts +10 -0
  144. package/node_modules/@spexcode/spec-cli/dist/session-execution.js +70 -0
  145. package/node_modules/@spexcode/spec-cli/dist/session-files.d.ts +28 -0
  146. package/node_modules/@spexcode/spec-cli/dist/session-files.js +130 -0
  147. package/node_modules/@spexcode/spec-cli/dist/session-follow.d.ts +31 -0
  148. package/node_modules/@spexcode/spec-cli/dist/session-follow.js +179 -0
  149. package/node_modules/@spexcode/spec-cli/dist/session-reparent.d.ts +10 -0
  150. package/node_modules/@spexcode/spec-cli/dist/session-reparent.js +33 -0
  151. package/node_modules/@spexcode/spec-cli/dist/session-timeline.d.ts +11 -0
  152. package/node_modules/@spexcode/spec-cli/dist/session-timeline.js +20 -0
  153. package/node_modules/@spexcode/spec-cli/dist/session-web.d.ts +24 -0
  154. package/node_modules/@spexcode/spec-cli/dist/session-web.js +141 -0
  155. package/node_modules/@spexcode/spec-cli/dist/sessions.d.ts +433 -0
  156. package/node_modules/@spexcode/spec-cli/dist/sessions.js +4667 -0
  157. package/node_modules/@spexcode/spec-cli/dist/sh.d.ts +1 -0
  158. package/node_modules/@spexcode/spec-cli/dist/sh.js +3 -0
  159. package/node_modules/@spexcode/spec-cli/dist/shim-runtime.d.ts +1 -0
  160. package/{spec-cli/src/shim-runtime.ts → node_modules/@spexcode/spec-cli/dist/shim-runtime.js} +3 -4
  161. package/node_modules/@spexcode/spec-cli/dist/slash-commands.d.ts +9 -0
  162. package/node_modules/@spexcode/spec-cli/dist/slash-commands.js +307 -0
  163. package/node_modules/@spexcode/spec-cli/dist/source-files.d.ts +9 -0
  164. package/node_modules/@spexcode/spec-cli/dist/source-files.js +113 -0
  165. package/node_modules/@spexcode/spec-cli/dist/supervise.d.ts +1 -0
  166. package/node_modules/@spexcode/spec-cli/dist/supervise.js +308 -0
  167. package/node_modules/@spexcode/spec-cli/dist/tree.d.ts +25 -0
  168. package/node_modules/@spexcode/spec-cli/dist/tree.js +98 -0
  169. package/node_modules/@spexcode/spec-cli/dist/tsx-bin.d.ts +3 -0
  170. package/node_modules/@spexcode/spec-cli/dist/tsx-bin.js +41 -0
  171. package/node_modules/@spexcode/spec-cli/dist/uninstall.d.ts +3 -0
  172. package/node_modules/@spexcode/spec-cli/dist/uninstall.js +177 -0
  173. package/node_modules/@spexcode/spec-cli/dist/uploads.d.ts +36 -0
  174. package/node_modules/@spexcode/spec-cli/dist/uploads.js +243 -0
  175. package/node_modules/@spexcode/spec-cli/dist/worktree-sources.d.ts +1 -0
  176. package/node_modules/@spexcode/spec-cli/dist/worktree-sources.js +47 -0
  177. package/{spec-cli → node_modules/@spexcode/spec-cli}/hooks/dispatch.sh +5 -5
  178. package/{spec-cli → node_modules/@spexcode/spec-cli}/hooks/harness.sh +1 -1
  179. package/node_modules/@spexcode/spec-cli/package.json +49 -0
  180. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
  181. package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/spec-first/spec-first.sh +1 -1
  182. package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/spec-of-file/spec-of-file.sh +1 -0
  183. package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/stop-gate/spec.md +2 -2
  184. package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +31 -29
  185. package/node_modules/@spexcode/spec-core/dist/anchors.d.ts +94 -0
  186. package/node_modules/@spexcode/spec-core/dist/anchors.js +730 -0
  187. package/node_modules/@spexcode/spec-core/dist/git.d.ts +170 -0
  188. package/node_modules/@spexcode/spec-core/dist/git.js +2768 -0
  189. package/node_modules/@spexcode/spec-core/dist/graph-delta.d.ts +44 -0
  190. package/node_modules/@spexcode/spec-core/dist/graph-delta.js +72 -0
  191. package/node_modules/@spexcode/spec-core/dist/graph.d.ts +34 -0
  192. package/node_modules/@spexcode/spec-core/dist/graph.js +237 -0
  193. package/node_modules/@spexcode/spec-core/dist/graphDelta.d.ts +3 -0
  194. package/node_modules/@spexcode/spec-core/dist/graphDelta.js +13 -0
  195. package/node_modules/@spexcode/spec-core/dist/harness-identity.d.ts +31 -0
  196. package/node_modules/@spexcode/spec-core/dist/harness-identity.js +20 -0
  197. package/node_modules/@spexcode/spec-core/dist/identity-presets.d.ts +152 -0
  198. package/node_modules/@spexcode/spec-core/dist/identity-presets.js +132 -0
  199. package/node_modules/@spexcode/spec-core/dist/index.d.ts +45 -0
  200. package/node_modules/@spexcode/spec-core/dist/index.js +19 -0
  201. package/node_modules/@spexcode/spec-core/dist/layout.d.ts +187 -0
  202. package/node_modules/@spexcode/spec-core/dist/layout.js +548 -0
  203. package/node_modules/@spexcode/spec-core/dist/process-identity.d.ts +37 -0
  204. package/node_modules/@spexcode/spec-core/dist/process-identity.js +214 -0
  205. package/node_modules/@spexcode/spec-core/dist/project-identity.d.ts +12 -0
  206. package/node_modules/@spexcode/spec-core/dist/project-identity.js +71 -0
  207. package/node_modules/@spexcode/spec-core/dist/project-store.d.ts +3 -0
  208. package/node_modules/@spexcode/spec-core/dist/project-store.js +14 -0
  209. package/node_modules/@spexcode/spec-core/dist/resilience.d.ts +2 -0
  210. package/node_modules/@spexcode/spec-core/dist/resilience.js +40 -0
  211. package/node_modules/@spexcode/spec-core/dist/review/index.d.ts +3 -0
  212. package/node_modules/@spexcode/spec-core/dist/review/index.js +5 -0
  213. package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.d.ts +77 -0
  214. package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.js +308 -0
  215. package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.d.ts +66 -0
  216. package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.js +180 -0
  217. package/node_modules/@spexcode/spec-core/dist/review/session.d.ts +4 -0
  218. package/node_modules/@spexcode/spec-core/dist/review/session.js +8 -0
  219. package/node_modules/@spexcode/spec-core/dist/reviewSnapshot.d.ts +15 -0
  220. package/node_modules/@spexcode/spec-core/dist/reviewSnapshot.js +12 -0
  221. package/node_modules/@spexcode/spec-core/dist/root-lru.d.ts +4 -0
  222. package/{spec-cli/src/root-lru.ts → node_modules/@spexcode/spec-core/dist/root-lru.js} +26 -30
  223. package/node_modules/@spexcode/spec-core/dist/specs.d.ts +117 -0
  224. package/node_modules/@spexcode/spec-core/dist/specs.js +489 -0
  225. package/node_modules/@spexcode/spec-core/package.json +28 -0
  226. package/node_modules/@spexcode/spec-eval/dist/cache.d.ts +10 -0
  227. package/node_modules/@spexcode/spec-eval/dist/cache.js +50 -0
  228. package/node_modules/@spexcode/spec-eval/dist/cli.d.ts +22 -0
  229. package/node_modules/@spexcode/spec-eval/dist/cli.js +981 -0
  230. package/node_modules/@spexcode/spec-eval/dist/evaltab.d.ts +98 -0
  231. package/node_modules/@spexcode/spec-eval/dist/evaltab.js +176 -0
  232. package/node_modules/@spexcode/spec-eval/dist/filing.d.ts +16 -0
  233. package/node_modules/@spexcode/spec-eval/dist/filing.js +36 -0
  234. package/node_modules/@spexcode/spec-eval/dist/freshness.d.ts +48 -0
  235. package/node_modules/@spexcode/spec-eval/dist/freshness.js +799 -0
  236. package/node_modules/@spexcode/spec-eval/dist/host.d.ts +46 -0
  237. package/node_modules/@spexcode/spec-eval/dist/host.js +53 -0
  238. package/node_modules/@spexcode/spec-eval/dist/humanok.d.ts +11 -0
  239. package/node_modules/@spexcode/spec-eval/dist/humanok.js +27 -0
  240. package/node_modules/@spexcode/spec-eval/dist/index.d.ts +4 -0
  241. package/node_modules/@spexcode/spec-eval/dist/index.js +4 -0
  242. package/node_modules/@spexcode/spec-eval/dist/remarks.d.ts +31 -0
  243. package/node_modules/@spexcode/spec-eval/dist/remarks.js +1 -0
  244. package/node_modules/@spexcode/spec-eval/dist/scenariofresh.d.ts +13 -0
  245. package/node_modules/@spexcode/spec-eval/dist/scenariofresh.js +303 -0
  246. package/node_modules/@spexcode/spec-eval/dist/scenarios.d.ts +99 -0
  247. package/node_modules/@spexcode/spec-eval/dist/scenarios.js +650 -0
  248. package/node_modules/@spexcode/spec-eval/dist/sessioneval.d.ts +307 -0
  249. package/node_modules/@spexcode/spec-eval/dist/sessioneval.js +1883 -0
  250. package/node_modules/@spexcode/spec-eval/dist/sidecar.d.ts +55 -0
  251. package/node_modules/@spexcode/spec-eval/dist/sidecar.js +82 -0
  252. package/node_modules/@spexcode/spec-eval/dist/timeline.d.ts +25 -0
  253. package/node_modules/@spexcode/spec-eval/dist/timeline.js +65 -0
  254. package/node_modules/@spexcode/spec-eval/dist/ui-path.d.ts +1 -0
  255. package/node_modules/@spexcode/spec-eval/dist/ui-path.js +2 -0
  256. package/node_modules/@spexcode/spec-eval/package.json +36 -0
  257. package/node_modules/@spexcode/spec-forge/dist/cache.d.ts +29 -0
  258. package/node_modules/@spexcode/spec-forge/dist/cache.js +59 -0
  259. package/node_modules/@spexcode/spec-forge/dist/cli.d.ts +1 -0
  260. package/node_modules/@spexcode/spec-forge/dist/cli.js +100 -0
  261. package/node_modules/@spexcode/spec-forge/dist/drivers/github.d.ts +2 -0
  262. package/node_modules/@spexcode/spec-forge/dist/drivers/github.js +116 -0
  263. package/node_modules/@spexcode/spec-forge/dist/drivers/gitlab.d.ts +7 -0
  264. package/node_modules/@spexcode/spec-forge/dist/drivers/gitlab.js +144 -0
  265. package/node_modules/@spexcode/spec-forge/dist/drivers.d.ts +10 -0
  266. package/node_modules/@spexcode/spec-forge/dist/drivers.js +83 -0
  267. package/node_modules/@spexcode/spec-forge/dist/index.d.ts +4 -0
  268. package/node_modules/@spexcode/spec-forge/dist/index.js +4 -0
  269. package/node_modules/@spexcode/spec-forge/dist/links.d.ts +15 -0
  270. package/node_modules/@spexcode/spec-forge/dist/links.js +65 -0
  271. package/node_modules/@spexcode/spec-forge/dist/needs-eval.d.ts +10 -0
  272. package/node_modules/@spexcode/spec-forge/dist/needs-eval.js +20 -0
  273. package/node_modules/@spexcode/spec-forge/dist/port.d.ts +53 -0
  274. package/node_modules/@spexcode/spec-forge/dist/port.js +1 -0
  275. package/node_modules/@spexcode/spec-forge/dist/resident.d.ts +7 -0
  276. package/node_modules/@spexcode/spec-forge/dist/resident.js +43 -0
  277. package/node_modules/@spexcode/spec-forge/package.json +32 -0
  278. package/package.json +33 -19
  279. package/spec-cli/bin/spex.mjs +0 -69
  280. package/spec-cli/src/anchors.ts +0 -728
  281. package/spec-cli/src/attach.ts +0 -43
  282. package/spec-cli/src/claude-headless.ts +0 -321
  283. package/spec-cli/src/cli.ts +0 -1258
  284. package/spec-cli/src/client.ts +0 -414
  285. package/spec-cli/src/cockpit.ts +0 -43
  286. package/spec-cli/src/codex-runtime-generations.ts +0 -646
  287. package/spec-cli/src/commit-surgery.ts +0 -68
  288. package/spec-cli/src/contract-filter.ts +0 -202
  289. package/spec-cli/src/delivery-queue.ts +0 -165
  290. package/spec-cli/src/doctor.ts +0 -516
  291. package/spec-cli/src/execution-trace.ts +0 -444
  292. package/spec-cli/src/file-write.ts +0 -22
  293. package/spec-cli/src/gateway-auth.ts +0 -181
  294. package/spec-cli/src/gateway-hub.ts +0 -358
  295. package/spec-cli/src/gateway.ts +0 -461
  296. package/spec-cli/src/git.ts +0 -2556
  297. package/spec-cli/src/graph.ts +0 -271
  298. package/spec-cli/src/graphCache.ts +0 -710
  299. package/spec-cli/src/graphDelta.ts +0 -77
  300. package/spec-cli/src/graphStream.ts +0 -1019
  301. package/spec-cli/src/harness-select.ts +0 -77
  302. package/spec-cli/src/harness.ts +0 -3025
  303. package/spec-cli/src/headless-controller.ts +0 -63
  304. package/spec-cli/src/help.ts +0 -492
  305. package/spec-cli/src/host-resources.ts +0 -762
  306. package/spec-cli/src/host.ts +0 -611
  307. package/spec-cli/src/identity-presets.d.ts +0 -13
  308. package/spec-cli/src/identity-presets.js +0 -138
  309. package/spec-cli/src/index.ts +0 -818
  310. package/spec-cli/src/init.ts +0 -258
  311. package/spec-cli/src/issues-cli.ts +0 -276
  312. package/spec-cli/src/issues.ts +0 -237
  313. package/spec-cli/src/layout.ts +0 -640
  314. package/spec-cli/src/lint.ts +0 -488
  315. package/spec-cli/src/listen.ts +0 -28
  316. package/spec-cli/src/localIssues.ts +0 -538
  317. package/spec-cli/src/materialize.ts +0 -417
  318. package/spec-cli/src/mentions.ts +0 -81
  319. package/spec-cli/src/opencode-headless.ts +0 -205
  320. package/spec-cli/src/pi-headless.ts +0 -153
  321. package/spec-cli/src/plugin-harness.ts +0 -157
  322. package/spec-cli/src/process-identity.ts +0 -207
  323. package/spec-cli/src/project-identity.ts +0 -73
  324. package/spec-cli/src/project-store.ts +0 -17
  325. package/spec-cli/src/pty-bridge.ts +0 -396
  326. package/spec-cli/src/pty-helper.mjs +0 -96
  327. package/spec-cli/src/pty-native-helper.mjs +0 -22
  328. package/spec-cli/src/ranker.ts +0 -191
  329. package/spec-cli/src/reaper.ts +0 -125
  330. package/spec-cli/src/resilience.ts +0 -41
  331. package/spec-cli/src/reviewSnapshot.ts +0 -28
  332. package/spec-cli/src/reviews.ts +0 -434
  333. package/spec-cli/src/runtime-guard.ts +0 -35
  334. package/spec-cli/src/runtime-ownership.ts +0 -88
  335. package/spec-cli/src/search.bench.mjs +0 -80
  336. package/spec-cli/src/search.ts +0 -63
  337. package/spec-cli/src/session-cursors.ts +0 -91
  338. package/spec-cli/src/session-execution.ts +0 -68
  339. package/spec-cli/src/session-files.ts +0 -121
  340. package/spec-cli/src/session-follow.ts +0 -182
  341. package/spec-cli/src/session-reparent.ts +0 -33
  342. package/spec-cli/src/session-timeline.ts +0 -247
  343. package/spec-cli/src/session-web.ts +0 -135
  344. package/spec-cli/src/sessions.ts +0 -3883
  345. package/spec-cli/src/sh.ts +0 -3
  346. package/spec-cli/src/slash-commands.ts +0 -318
  347. package/spec-cli/src/source-files.ts +0 -104
  348. package/spec-cli/src/specs.ts +0 -498
  349. package/spec-cli/src/supervise.ts +0 -245
  350. package/spec-cli/src/tree.ts +0 -118
  351. package/spec-cli/src/tsx-bin.ts +0 -18
  352. package/spec-cli/src/uninstall.ts +0 -171
  353. package/spec-cli/src/uploads.ts +0 -253
  354. package/spec-cli/src/worktree-sources.ts +0 -42
  355. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +0 -8
  356. package/spec-dashboard/dist/assets/App-B6IbFbGu.js +0 -2
  357. package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +0 -1
  358. package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +0 -27
  359. package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +0 -2
  360. package/spec-dashboard/dist/assets/IssuesPage-BG9rNOgV.js +0 -1
  361. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  362. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  363. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  364. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  365. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  366. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  367. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  368. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  369. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  370. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  371. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  372. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  373. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  374. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  375. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  376. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  377. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  378. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  379. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  380. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  381. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  382. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  383. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  384. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  385. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  386. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  387. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  388. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  389. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  390. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  391. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  392. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  393. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  394. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  395. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  396. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  397. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  398. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  399. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  400. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  401. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  402. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  403. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  404. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  405. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  406. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  407. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  408. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  409. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  410. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  411. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  412. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  413. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  414. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  415. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  416. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  417. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  418. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  419. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  420. package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +0 -2
  421. package/spec-dashboard/dist/assets/Modal-B3EcgeA3.js +0 -1
  422. package/spec-dashboard/dist/assets/PageScroll-CNZOugWV.js +0 -1
  423. package/spec-dashboard/dist/assets/ProjectsPage-C2CRHmvs.js +0 -1
  424. package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +0 -32
  425. package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +0 -40
  426. package/spec-dashboard/dist/assets/SessionWindow-1nYgZ4CL.js +0 -1
  427. package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +0 -1
  428. package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +0 -13
  429. package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +0 -1
  430. package/spec-dashboard/dist/assets/data-SlvxNA2r.js +0 -1
  431. package/spec-dashboard/dist/assets/index-DLVeOHL-.js +0 -41
  432. package/spec-dashboard/dist/assets/index-DYLGfPjx.css +0 -1
  433. package/spec-dashboard/dist/assets/launch-Cp579UoJ.css +0 -1
  434. package/spec-dashboard/dist/assets/launch-yxCYr64x.js +0 -276
  435. package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +0 -1
  436. package/spec-dashboard/dist/index.html +0 -29
  437. package/spec-dashboard/src/reviewFilters.js +0 -322
  438. package/spec-dashboard/src/reviewQuery.js +0 -174
  439. package/spec-dashboard/src/session.js +0 -199
  440. package/spec-eval/src/cache.ts +0 -57
  441. package/spec-eval/src/cli.ts +0 -894
  442. package/spec-eval/src/evaltab.ts +0 -264
  443. package/spec-eval/src/filing.ts +0 -39
  444. package/spec-eval/src/freshness.ts +0 -836
  445. package/spec-eval/src/humanok.ts +0 -28
  446. package/spec-eval/src/scenariofresh.ts +0 -244
  447. package/spec-eval/src/scenarios.ts +0 -672
  448. package/spec-eval/src/sessioneval.ts +0 -2220
  449. package/spec-eval/src/sidecar.ts +0 -93
  450. package/spec-eval/src/timeline.ts +0 -54
  451. package/spec-forge/src/__fixtures__/github-forge.json +0 -63
  452. package/spec-forge/src/cache.ts +0 -66
  453. package/spec-forge/src/cli.ts +0 -99
  454. package/spec-forge/src/drivers/github.ts +0 -132
  455. package/spec-forge/src/drivers/gitlab.ts +0 -153
  456. package/spec-forge/src/drivers.ts +0 -73
  457. package/spec-forge/src/links.ts +0 -72
  458. package/spec-forge/src/needs-eval.ts +0 -29
  459. package/spec-forge/src/port.ts +0 -36
  460. package/spec-forge/src/resident.ts +0 -46
  461. /package/{spec-cli → node_modules/@spexcode/spec-cli}/README.md +0 -0
  462. /package/{spec-cli → node_modules/@spexcode/spec-cli}/hooks/compat/mark-active-0.5.2-eef1.fixture +0 -0
  463. /package/{spec-cli → node_modules/@spexcode/spec-cli}/hooks/compat/mark-active-sed-v0.fixture +0 -0
  464. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/commit-msg +0 -0
  465. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/post-checkout +0 -0
  466. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/post-merge +0 -0
  467. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/pre-commit +0 -0
  468. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/prepare-commit-msg +0 -0
  469. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/reference-transaction +0 -0
  470. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/extract/spec.md +0 -0
  471. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/regroup/spec.md +0 -0
  472. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/rename/spec.md +0 -0
  473. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/spec.md +0 -0
  474. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/tidy/spec.md +0 -0
  475. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/comment-altitude/spec.md +0 -0
  476. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/idle/idle.sh +0 -0
  477. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/idle/spec.md +0 -0
  478. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/mark-active/mark-active.sh +0 -0
  479. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/mark-active/spec.md +0 -0
  480. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/session-fail/fail.sh +0 -0
  481. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/session-fail/spec.md +0 -0
  482. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/spec-first/spec.md +0 -0
  483. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/spec-of-file/spec.md +0 -0
  484. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/spec.md +0 -0
  485. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +0 -0
  486. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/prompts/forge-link/spec.md +0 -0
  487. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +0 -0
  488. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +0 -0
  489. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/prompts/spec.md +0 -0
  490. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/skills/distill/digest.mjs +0 -0
  491. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/skills/distill/spec.md +0 -0
  492. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/skills/spec.md +0 -0
  493. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/spec.md +0 -0
  494. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/spec.md +0 -0
  495. /package/{spec-cli → node_modules/@spexcode/spec-core}/templates/spexcode.json +0 -0
@@ -0,0 +1,4667 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { readFileSync, writeFileSync, appendFileSync, existsSync, renameSync, linkSync, mkdirSync, rmSync, readdirSync, realpathSync, statSync, unlinkSync } from 'node:fs';
5
+ import { join, dirname, isAbsolute, resolve, sep } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { seedWorktreeHostState } from './worktree-sources.js';
8
+ import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, withGitAbortSignal, isGitObjectId } from '@spexcode/spec-core';
9
+ import { loadConfig, loadSpecs, loadSpecsLite } from '@spexcode/spec-core';
10
+ import { adapterLoadedReferenceState, assertRvSockPath, defaultHarness, HARNESSES, sessionIdentityEnvVars, defaultLauncher, harnessById, procSnapshot, resolveLauncher, rendezvousListening, stampRvSock } from './harness.js';
11
+ import { materialize } from './materialize.js';
12
+ import { mainBranch, mainRoot, gitCommonDir, readConfig, runtimeRoot, treeSlotDir, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, rawLaunchReadinessOriginal, readRecordEntry, readAliasedRecordEntry, readPublicRecordEntry, envSessionId, isSessionLifecycle, isSessionProposal } from '@spexcode/spec-core';
13
+ import { readSessionFiles } from './session-files.js';
14
+ import { readSessionWebs } from './session-web.js';
15
+ import { acceptMessage, drain, recordStatus, lastHumanSendVia, owesDelivery, pendingMessages } from '@spexcode/session-core';
16
+ import { pendingSnapshot, replacePendingWhileLocked, revokePendingFromWhileLocked, revokeSenderDelivery, withDeliveryLocks, trySessionRecordLockSync, withSessionRecordLock, withSessionRecordLockSync as coreWithSessionRecordLockSync } from '@spexcode/session-core/internal';
17
+ import { stripRefSigil } from './mentions.js';
18
+ import { shQuote } from './sh.js';
19
+ import { assertSessionOwnerSafe, assertSessionStopSafe, ResourceConflict } from './host-resources.js';
20
+ import { processStartToken } from '@spexcode/spec-core';
21
+ import { bindCodexGeneration, codexGenerationBindingForSession, commitCodexGenerationRegistration, prepareCodexGenerationClose, prepareCodexGenerationRegistration, readCodexGenerationLedger } from './codex-runtime-generations.js';
22
+ import { cliEntrypointArgs } from './tsx-bin.js';
23
+ const pexec = promisify(execFile);
24
+ export const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode';
25
+ const DEFER_FOOTPRINT_REFRESH = { SPEXCODE_DEFER_FOOTPRINT_REFRESH: 'session-create' };
26
+ const HARNESS = defaultHarness;
27
+ const COLS = 120, ROWS = 32;
28
+ const DEFAULT_MAX_ACTIVE = 8;
29
+ function maxActive() {
30
+ let v;
31
+ try {
32
+ const fromJson = readConfig(mainRoot()).sessions?.maxActive;
33
+ if (typeof fromJson === 'number' && Number.isFinite(fromJson))
34
+ v = fromJson;
35
+ }
36
+ catch { /* config unreadable — fall through to env/default */ }
37
+ if (v === undefined) {
38
+ const e = Number(process.env.SPEXCODE_MAX_ACTIVE);
39
+ if (Number.isFinite(e) && e > 0)
40
+ v = e;
41
+ }
42
+ return Math.max(1, Math.floor(v ?? DEFAULT_MAX_ACTIVE));
43
+ }
44
+ // The adapter owns any transport bootstrap env (rendezvous daemon + socket); product launch only composes it
45
+ // with the governed session id and home vars. This env prefix also ensures hooks + materialize write to the
46
+ // SAME store the backend uses. SPEXCODE_HOME/CODEX_HOME are
47
+ // propagated when set, because the session inherits the tmux SERVER's env (not the backend's), so without this
48
+ // an overridden home would silently leak the session's hook-state + codex-trust to the default ~/.spexcode /
49
+ // ~/.codex. Deterministic: the session's store = the backend's store, never the ambient env's.
50
+ const rvEnv = (id, harness = HARNESS) => {
51
+ // SPEXCODE_SESSION_ID is the governed record id, and it is the SESSION'S OWN — so the launch STRIPS every
52
+ // session-identity variable it may have inherited (the pane inherits the tmux SERVER's env, which may carry
53
+ // a foreign session's ids from whoever started it) before setting this one. Identity is established HERE,
54
+ // once, at the boundary; nothing downstream re-verifies it, because after this a session-identity variable
55
+ // exists in a process only if that process belongs to that session — either set right here, or stamped by
56
+ // the harness itself for its own acting conversation ([[harness-adapter]]). The same strip runs on the one
57
+ // other process we own that is NOT a session's own — codex's shared app-server, whose leaked inherited id
58
+ // was github#76.
59
+ const scrub = sessionIdentityEnvVars().map((v) => `-u ${v}`);
60
+ const homeVars = ['SPEXCODE_HOME', 'CODEX_HOME'].flatMap((v) => {
61
+ const value = process.env[v];
62
+ return value ? [`${v}=${value}`] : [];
63
+ });
64
+ return [...scrub,
65
+ `SPEXCODE_SESSION_ID=${id}`,
66
+ `SPEXCODE_SESSION_IDENTITY_VARS=${shQuote(sessionIdentityEnvVars().join(','))}`,
67
+ `SPEXCODE_PROJECT_ROOT=${shQuote(mainRoot())}`,
68
+ ...harness.launchEnv(id), ...homeVars].join(' ');
69
+ };
70
+ const PROPOSAL_STATUS = { merge: 'review', nothing: 'done', close: 'close-pending' };
71
+ function normalizeCloseSource(raw) {
72
+ if (raw == null)
73
+ return { kind: 'user' };
74
+ if (!raw || typeof raw !== 'object')
75
+ throw new ResourceConflict('refusing session close: source must be user or an unverified session claim');
76
+ const source = raw;
77
+ if (source.kind === 'user')
78
+ return { kind: 'user' };
79
+ if (source.kind === 'unverified-session-claim' && typeof source.id === 'string' && source.id.trim())
80
+ return { kind: 'unverified-session-claim', id: source.id.trim() };
81
+ throw new ResourceConflict('refusing session close: source must be user or an unverified session claim');
82
+ }
83
+ function appendCloseLedger(id, rec, source) {
84
+ const path = join(runtimeRoot(), 'session-close-ledger.ndjson');
85
+ const event = {
86
+ version: 1,
87
+ action: 'close-authorized',
88
+ at: new Date().toISOString(),
89
+ source,
90
+ target: {
91
+ id,
92
+ harness: rec.harness,
93
+ thread: rec.harnessSessionId,
94
+ worktree: rec.worktreePath,
95
+ branch: rec.branch,
96
+ },
97
+ };
98
+ appendFileSync(path, `${JSON.stringify(event)}\n`);
99
+ }
100
+ export function findSessionClosure(selector) {
101
+ const query = stripRefSigil(selector).trim();
102
+ if (!query)
103
+ return null;
104
+ const path = join(runtimeRoot(), 'session-close-ledger.ndjson');
105
+ let text;
106
+ try {
107
+ text = readFileSync(path, 'utf8');
108
+ }
109
+ catch (error) {
110
+ if (error.code === 'ENOENT')
111
+ return null;
112
+ throw error;
113
+ }
114
+ const matches = new Map();
115
+ for (const line of text.split('\n')) {
116
+ if (!line.trim())
117
+ continue;
118
+ let event;
119
+ try {
120
+ event = JSON.parse(line);
121
+ }
122
+ catch {
123
+ throw new ResourceConflict('session close history is unreadable');
124
+ }
125
+ if (!event || typeof event !== 'object' || event.action !== 'close-authorized')
126
+ continue;
127
+ const target = event.target;
128
+ const id = target && typeof target === 'object' ? target.id : undefined;
129
+ const closedAt = event.at;
130
+ if (typeof id !== 'string' || !id || typeof closedAt !== 'string' || !closedAt)
131
+ throw new ResourceConflict('session close history is malformed');
132
+ if (id === query || id.startsWith(query))
133
+ matches.set(id, { id, closedAt });
134
+ }
135
+ if (!matches.size)
136
+ return null;
137
+ if (matches.size > 1)
138
+ throw new ResourceConflict(`close history for ${query} is ambiguous: ${[...matches.keys()].map((id) => id.slice(0, 8)).join(', ')}`);
139
+ return [...matches.values()][0];
140
+ }
141
+ function storeDir(id) { const d = sessionStoreDir(id); mkdirSync(d, { recursive: true }); return d; }
142
+ function writePromptFile(id, prompt) {
143
+ try {
144
+ writeFileSync(join(storeDir(id), 'prompt'), prompt);
145
+ }
146
+ catch { /* best-effort; must never block the launch */ }
147
+ }
148
+ function readPromptFile(id) {
149
+ try {
150
+ const p = sessionArtifactPath(id, 'prompt');
151
+ if (!existsSync(p))
152
+ return null;
153
+ const s = readFileSync(p, 'utf8');
154
+ return s.trim() ? s : null;
155
+ }
156
+ catch {
157
+ return null;
158
+ }
159
+ }
160
+ // The resolved first-turn payload is authoritative across queue drain and recovery. Adapters that mint native
161
+ // identity keep it until they prove identity + first-turn durability; other adapters consume on submission.
162
+ function writeLaunchFile(id, prompt) {
163
+ writeFileSync(join(storeDir(id), 'launch'), prompt);
164
+ }
165
+ function readLaunchFile(id) {
166
+ try {
167
+ const p = sessionArtifactPath(id, 'launch');
168
+ return existsSync(p) ? readFileSync(p, 'utf8') : null;
169
+ }
170
+ catch {
171
+ return null;
172
+ }
173
+ }
174
+ function removeLaunchFile(id) {
175
+ try {
176
+ rmSync(sessionArtifactPath(id, 'launch'), { force: true });
177
+ }
178
+ catch { /* best-effort */ }
179
+ }
180
+ // One line, bounded — the launch prompt's shape when it enters a compact headline.
181
+ export const HEADLINE_PREVIEW_COLUMNS = 60;
182
+ function isBareUrl(text) {
183
+ return /^(?:https?|git|ssh):\/\/\S+$/i.test(text);
184
+ }
185
+ function oneLinePreview(text, n = HEADLINE_PREVIEW_COLUMNS) {
186
+ const lines = text.split('\n').map((l) => l.trim()).filter(Boolean);
187
+ const first = lines.find((line) => !isBareUrl(line)) || lines[0] || '';
188
+ return first.length > n ? first.slice(0, n - 1) + '…' : first;
189
+ }
190
+ export const deriveLabel = (r) => r.name || r.node || r.title || r.branch || r.id;
191
+ export const deriveTitle = (r) => r.name || r.activity || (r.note ? oneLinePreview(r.note) : '') || (r.promptPreview ? oneLinePreview(r.promptPreview) : '') || r.node || r.title || r.branch || r.id;
192
+ // Compatibility for package consumers that still import the old name.
193
+ export const deriveHeadline = deriveTitle;
194
+ export const sessionLabel = (s) => s.label;
195
+ export const sessionTitle = (s) => s.title;
196
+ // Compatibility for older callers; all visible surfaces now resolve through `title`.
197
+ export const sessionHeadline = sessionTitle;
198
+ // @@@ tmux probe timeout - under load (the incident: load ~30 + swap thrash) a bare `tmux list-sessions` can
199
+ // HANG, and with no bound the whole board assembly hung behind it — the dashboard froze / dropped rows, which
200
+ // the human read as "sessions disappeared". So the liveness/title probes pass a bounded timeout; on expiry
201
+ // execFile SIGKILLs the child and rejects with `killed:true`, which liveSnapshot tells apart from a clean
202
+ // "no server" exit (see probeTimedOut) so a timeout renders `unknown`, not a false `offline`.
203
+ const TMUX_PROBE_TIMEOUT_MS = 4000;
204
+ // A destructive close already names one target, so it can afford the longer bounded probe without making
205
+ // every dashboard refresh wait behind an overloaded tmux server.
206
+ const TARGET_PROBE_TIMEOUT_MS = 15000;
207
+ const TARGET_TMUX_CLOSE_SETTLE_MS = 3000;
208
+ async function tmux(args, timeoutMs) {
209
+ const { stdout } = await pexec('tmux', ['-L', TMUX_SOCK, ...args], { encoding: 'utf8', ...(timeoutMs ? { timeout: timeoutMs, killSignal: 'SIGKILL' } : {}) });
210
+ return stdout;
211
+ }
212
+ // a rejected pexec whose child we KILLED (timeout) vs one that exited cleanly non-zero (e.g. tmux "no server
213
+ // running" when there are genuinely no sessions). Only the former is a PROBE FAILURE (→ unknown); a clean
214
+ // non-zero exit is authoritative (→ everything offline). node sets `killed`/`signal` when it SIGKILLs on timeout.
215
+ function probeTimedOut(e) {
216
+ const err = e;
217
+ return err?.killed === true || err?.signal === 'SIGKILL' || err?.code === 'ETIMEDOUT';
218
+ }
219
+ async function tmuxOk(args) { try {
220
+ await tmux(args);
221
+ return true;
222
+ }
223
+ catch {
224
+ return false;
225
+ } }
226
+ export async function alive(id) { return tmuxOk(['has-session', '-t', id]); }
227
+ function pkgRoot() {
228
+ return fileURLToPath(new URL('..', import.meta.url));
229
+ }
230
+ export const OWNED_QUEUE_RAW_STATUS = 'launch-queued';
231
+ export function backendLaunchAuthority(env = process.env) {
232
+ const raw = env.SPEXCODE_API_URL?.trim() || `http://127.0.0.1:${env.PORT?.trim() || '8787'}`;
233
+ const url = new URL(raw);
234
+ url.username = '';
235
+ url.password = '';
236
+ url.search = '';
237
+ url.hash = '';
238
+ url.pathname = url.pathname.replace(/\/+$/, '') || '/';
239
+ return url.toString().replace(/\/$/, '');
240
+ }
241
+ export function rawLifecycleStatus(rec) {
242
+ return rec.status === 'queued' && rec.launchOwner ? OWNED_QUEUE_RAW_STATUS : rec.status;
243
+ }
244
+ export function canDrainQueued(rec, authority = backendLaunchAuthority()) {
245
+ return rec.status === 'queued' && !rec.stopped && (rec.launchOwner === null || rec.launchOwner === authority);
246
+ }
247
+ // typed read of a session's record from the global store (null if it has none — a self-launched session that
248
+ // only ever wrote spec-discipline sentinels has a store dir but no session.json). Goes through layout's
249
+ // readAliasedRawRecord (the seam that owns the path + the codex-thread-id alias), then validates the loose
250
+ // on-disk fields into the typed shape — so a codex hook resolving by its thread id reaches the real record.
251
+ function readRecord(id) {
252
+ const entry = readAliasedRecordEntry(id);
253
+ if (entry.kind === 'absent')
254
+ return null;
255
+ if (entry.kind === 'corrupt')
256
+ throw new SessionRecordUnusable('corrupt', id, corruptReason(entry));
257
+ try {
258
+ return fromRaw(entry.raw);
259
+ }
260
+ catch (error) {
261
+ throw new SessionRecordUnusable('corrupt', id, `session record is unreadable: ${sessionRecordPath(id)} — ${error instanceof Error ? error.message : String(error)}. The file is kept as-is; nothing will rewrite it.`);
262
+ }
263
+ }
264
+ export class SessionRecordUnusable extends Error {
265
+ code;
266
+ session;
267
+ constructor(code, session, message) {
268
+ super(message);
269
+ this.code = code;
270
+ this.session = session;
271
+ this.name = 'SessionRecordUnusable';
272
+ }
273
+ }
274
+ const corruptReason = (e) => `session record is unreadable: ${e.path} — ${e.error}. The file is kept as-is; nothing will rewrite it. A close attempt quarantines the bytes and reports the preserved runtime/worktree/branch residue, but cannot signal or delete without an exact owner.`;
275
+ function retirementReason(rec) {
276
+ if (!rec.worktreePath || existsSync(rec.worktreePath))
277
+ return null;
278
+ return `session ${rec.session.slice(0, 8)} is retired: its worktree ${rec.worktreePath} no longer exists, so it cannot work, be marked active/idle, or be relaunched. Close it (\`spex session close <id>\`) to drop the record.`;
279
+ }
280
+ function readLiveRecord(id) {
281
+ const rec = readRecord(id);
282
+ if (!rec)
283
+ return null;
284
+ const retired = retirementReason(rec);
285
+ if (retired)
286
+ throw new SessionRecordUnusable('retired', rec.session, retired);
287
+ return rec;
288
+ }
289
+ const withRecordLock = withSessionRecordLock;
290
+ export function withSessionRecordLockSync(id, body) {
291
+ return coreWithSessionRecordLockSync(id, body);
292
+ }
293
+ const withRecordLockSync = withSessionRecordLockSync;
294
+ // Synchronous terminal input is another product turn-entry path. The PTY bridge uses this narrow seam to
295
+ // enqueue input while holding the same durable record lock as archive, so an archive preflight cannot pass idle
296
+ // and then race a just-queued TUI turn.
297
+ export function withSessionInputLock(id, body) {
298
+ // PTY input is synchronous. A single non-blocking open is the only safe barrier: EEXIST rejects this input
299
+ // regardless of owner PID, so a same-process async archive can never be frozen behind Atomics.wait.
300
+ const release = trySessionRecordLockSync(id);
301
+ if (!release)
302
+ return null;
303
+ try {
304
+ return body();
305
+ }
306
+ finally {
307
+ release();
308
+ }
309
+ }
310
+ const COLD_PROOF_VERSION = 'cold-v1';
311
+ function coldProofFor(rec) {
312
+ const adapter = harnessById(rec.harness || defaultHarness.id).id;
313
+ const exact = rec.harnessSessionId ? `thread:${rec.harnessSessionId}` : 'no-resident-ref';
314
+ return `${COLD_PROOF_VERSION}|${adapter}|${rec.session}|${exact}`;
315
+ }
316
+ function hasValidColdProof(rec) {
317
+ return !!rec.coldProof && rec.coldProof === coldProofFor(rec);
318
+ }
319
+ // the loose on-disk fields validated into the typed shape. Exported so the old-record defaults (harness →
320
+ // claude, absent pin → null) are unit-auditable without a store on disk.
321
+ export function fromRaw(raw) {
322
+ const ownedQueue = raw.status === OWNED_QUEUE_RAW_STATUS;
323
+ const status = ownedQueue ? 'queued' : isSessionLifecycle(raw.status) ? raw.status : 'active';
324
+ const launchOwner = ownedQueue ? raw.launch_owner?.trim() : null;
325
+ if (ownedQueue && !launchOwner)
326
+ throw new Error(`owned queue record '${raw.session_id}' has no launch_owner`);
327
+ const proposal = isSessionProposal(raw.proposal) ? raw.proposal : null;
328
+ const sk = raw.sortkey;
329
+ const sortKey = typeof sk === 'number' && Number.isFinite(sk) ? sk : null;
330
+ const pendingRaw = rawLaunchReadinessOriginal(raw);
331
+ const pendingStatus = pendingRaw && isSessionLifecycle(pendingRaw.status) ? pendingRaw.status : null;
332
+ if (pendingRaw && !pendingStatus)
333
+ throw new Error(`session '${raw.session_id}' launch readiness original has invalid lifecycle '${pendingRaw.status}'`);
334
+ const pendingProposal = pendingRaw && isSessionProposal(pendingRaw.proposal) ? pendingRaw.proposal : null;
335
+ if (pendingRaw?.proposal && !pendingProposal)
336
+ throw new Error(`session '${raw.session_id}' launch readiness original has invalid proposal '${pendingRaw.proposal}'`);
337
+ const zcodeChildSessionIds = raw.zcode_child_session_ids ?? [];
338
+ if (!Array.isArray(zcodeChildSessionIds)
339
+ || zcodeChildSessionIds.some((id) => typeof id !== 'string' || !id || id.trim() !== id)
340
+ || new Set(zcodeChildSessionIds).size !== zcodeChildSessionIds.length)
341
+ throw new Error(`session '${raw.session_id}' has invalid zcode_child_session_ids`);
342
+ return {
343
+ session: raw.session_id, governed: !!raw.governed, worktreePath: raw.worktree_path || '', branch: raw.branch || null,
344
+ node: raw.node || null, title: raw.title || null, name: raw.name || null, parent: raw.parent || null,
345
+ status, proposal, merges: Number(raw.merges) || 0,
346
+ note: raw.note || null, sortKey, createdAt: Number(raw.createdAt) || 0,
347
+ harness: raw.harness || 'claude', // records written before the harness field default to claude
348
+ harnessSessionId: raw.harness_session_id || null,
349
+ stopped: !!raw.stopped, // records written before explicit stop tracking were not stopped
350
+ archived: !!raw.archived, // records written before archive → absent → not shelved
351
+ coldProof: raw.cold_proof || null, // legacy archived rows have no proof and remain visible until re-archived
352
+ adapterRecovery: raw.adapter_recovery || null,
353
+ launcher: raw.launcher || null, // records written before launchers → null → old-record fallback
354
+ launchCmd: raw.launch_cmd || null, // records written before the pin → null → fall back to launcher name / ambient
355
+ launchOwner: launchOwner || null,
356
+ createRequestId: raw.create_request_id || null,
357
+ createPayloadHash: raw.create_payload_hash || null,
358
+ zcodeChildSessionIds: [...zcodeChildSessionIds],
359
+ base: raw.base || null, // records written before pinned bases → null → the source-of-truth branch
360
+ launchReadinessPending: pendingRaw ? {
361
+ version: 1,
362
+ startedAt: raw.launch_readiness_pending.startedAt,
363
+ original: {
364
+ status: pendingStatus, proposal: pendingProposal, note: pendingRaw.note || null,
365
+ stopped: pendingRaw.stopped, archived: pendingRaw.archived,
366
+ coldProof: pendingRaw.cold_proof || null, adapterRecovery: pendingRaw.adapter_recovery || null,
367
+ },
368
+ } : null,
369
+ };
370
+ }
371
+ function publicRecord(rec) {
372
+ const original = rec.launchReadinessPending?.original;
373
+ return original ? { ...rec, ...original } : rec;
374
+ }
375
+ function launchReadinessPending(original) {
376
+ return {
377
+ version: 1,
378
+ startedAt: Date.now(),
379
+ original: {
380
+ status: original.status,
381
+ proposal: original.proposal,
382
+ note: original.note,
383
+ stopped: original.stopped,
384
+ archived: original.archived,
385
+ coldProof: original.coldProof ?? null,
386
+ adapterRecovery: original.adapterRecovery ?? null,
387
+ },
388
+ };
389
+ }
390
+ function restoreLaunchReadinessOriginal(rec) {
391
+ const original = rec.launchReadinessPending?.original;
392
+ return original ? { ...rec, ...original, launchReadinessPending: null } : rec;
393
+ }
394
+ // Rebuild the full disk projection so retired keys disappear on the next write.
395
+ function writeRecord(rec) {
396
+ let previous = null;
397
+ try {
398
+ previous = readRecord(rec.session);
399
+ }
400
+ catch { /* a new or damaged record has no prior transition */ }
401
+ const obj = {
402
+ session_id: rec.session,
403
+ governed: rec.governed,
404
+ worktree_path: rec.worktreePath,
405
+ branch: rec.branch ?? '',
406
+ node: rec.node ?? '',
407
+ title: rec.title ?? '',
408
+ name: rec.name ?? '',
409
+ parent: rec.parent ?? '',
410
+ status: rawLifecycleStatus(rec),
411
+ proposal: rec.proposal ?? '',
412
+ merges: rec.merges,
413
+ note: rec.note ?? '',
414
+ sortkey: rec.sortKey ?? '',
415
+ createdAt: rec.createdAt,
416
+ harness: rec.harness || 'claude',
417
+ harness_session_id: rec.harnessSessionId ?? '',
418
+ stopped: rec.stopped,
419
+ archived: rec.archived,
420
+ cold_proof: rec.coldProof ?? '',
421
+ adapter_recovery: rec.adapterRecovery ?? '',
422
+ launcher: rec.launcher ?? '',
423
+ launch_cmd: rec.launchCmd ?? '',
424
+ launch_owner: rec.status === 'queued' ? rec.launchOwner ?? '' : '',
425
+ create_request_id: rec.createRequestId ?? '',
426
+ create_payload_hash: rec.createPayloadHash ?? '',
427
+ ...(rec.zcodeChildSessionIds?.length ? { zcode_child_session_ids: rec.zcodeChildSessionIds } : {}),
428
+ // Written only when the creator pinned one: an unpinned record keeps its exact legacy bytes, so a
429
+ // restore-the-frozen-record path stays byte-identical instead of silently gaining a key.
430
+ ...(rec.base ? { base: rec.base } : {}),
431
+ launch_readiness_pending: rec.launchReadinessPending ? {
432
+ version: 1,
433
+ startedAt: rec.launchReadinessPending.startedAt,
434
+ original: {
435
+ status: rec.launchReadinessPending.original.status,
436
+ proposal: rec.launchReadinessPending.original.proposal ?? '',
437
+ note: rec.launchReadinessPending.original.note ?? '',
438
+ stopped: rec.launchReadinessPending.original.stopped,
439
+ archived: rec.launchReadinessPending.original.archived,
440
+ cold_proof: rec.launchReadinessPending.original.coldProof ?? '',
441
+ adapter_recovery: rec.launchReadinessPending.original.adapterRecovery ?? '',
442
+ },
443
+ } : '',
444
+ };
445
+ const dir = sessionStoreDir(rec.session);
446
+ mkdirSync(dir, { recursive: true });
447
+ const path = sessionRecordPath(rec.session);
448
+ const tmp = join(dir, `.session.json.${process.pid}.tmp`);
449
+ writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n');
450
+ renameSync(tmp, path); // atomic within the dir: a concurrent reader sees the old record or the new one
451
+ const previousPublic = previous ? publicRecord(previous) : null;
452
+ const nextPublic = publicRecord(rec);
453
+ if (rec.governed && previousPublic && (previousPublic.status !== nextPublic.status
454
+ || previousPublic.proposal !== nextPublic.proposal || previousPublic.note !== nextPublic.note)) {
455
+ recordStatus(rec.session, nextPublic.status, nextPublic.proposal, nextPublic.note);
456
+ scheduleWatchNotifications(rec);
457
+ }
458
+ }
459
+ const watchPath = (target) => sessionArtifactPath(target, 'watchers.json');
460
+ function readWatchEntries(target) {
461
+ try {
462
+ const raw = JSON.parse(readFileSync(watchPath(target), 'utf8'));
463
+ if (!Array.isArray(raw))
464
+ return [];
465
+ const parent = readRecord(target)?.parent ?? '';
466
+ const seen = new Set();
467
+ return raw.flatMap((entry) => {
468
+ if (!entry || typeof entry !== 'object')
469
+ return [];
470
+ const watcher = entry.watcher;
471
+ const createdAt = entry.createdAt;
472
+ if (!watcher || typeof watcher !== 'string' || typeof createdAt !== 'string' || seen.has(watcher))
473
+ return [];
474
+ seen.add(watcher);
475
+ const rawSources = entry.sources;
476
+ const sources = Array.isArray(rawSources)
477
+ ? [...new Set(rawSources.filter((source) => source === 'manual' || source === 'parent'))]
478
+ // The former one-source format cannot name an origin. Its child pointer is the only durable witness
479
+ // that this watcher was installed for parent supervision; every other legacy row is a manual watch.
480
+ : [watcher === parent ? 'parent' : 'manual'];
481
+ const snapshotPending = entry.snapshotPending;
482
+ return sources.length ? [{ watcher, createdAt, sources,
483
+ ...(sources.includes('parent') && typeof snapshotPending === 'string' && snapshotPending ? { snapshotPending } : {}),
484
+ }] : [];
485
+ });
486
+ }
487
+ catch {
488
+ return [];
489
+ }
490
+ }
491
+ function writeWatchEntries(target, entries) {
492
+ const path = watchPath(target);
493
+ if (!entries.length) {
494
+ try {
495
+ unlinkSync(path);
496
+ }
497
+ catch { /* already absent */ }
498
+ ;
499
+ return;
500
+ }
501
+ const dir = sessionStoreDir(target);
502
+ mkdirSync(dir, { recursive: true });
503
+ const tmp = join(dir, `.watchers.json.${process.pid}.tmp`);
504
+ writeFileSync(tmp, JSON.stringify(entries, null, 2) + '\n');
505
+ renameSync(tmp, path);
506
+ }
507
+ function addWatchSource(entries, watcher, source, deferInitialSnapshot = false) {
508
+ const existing = entries.find((entry) => entry.watcher === watcher);
509
+ const snapshotPending = deferInitialSnapshot && !existing?.sources.includes('manual') ? randomUUID() : undefined;
510
+ if (!existing)
511
+ return { entries: [...entries, {
512
+ watcher, createdAt: new Date().toISOString(), sources: [source], ...(snapshotPending ? { snapshotPending } : {}),
513
+ }], added: true };
514
+ if (existing.sources.includes(source))
515
+ return { entries, added: false };
516
+ return {
517
+ entries: entries.map((entry) => entry === existing ? {
518
+ ...entry, sources: [...entry.sources, source], ...(snapshotPending ? { snapshotPending } : {}),
519
+ } : entry),
520
+ added: true,
521
+ };
522
+ }
523
+ function removeWatchSource(entries, watcher, source) {
524
+ let removed = false;
525
+ const next = entries.flatMap((entry) => {
526
+ if (entry.watcher !== watcher || !entry.sources.includes(source))
527
+ return [entry];
528
+ removed = true;
529
+ const sources = entry.sources.filter((candidate) => candidate !== source);
530
+ if (!sources.length)
531
+ return [];
532
+ if (source !== 'parent')
533
+ return [{ ...entry, sources }];
534
+ const { snapshotPending: _pending, ...withoutParentDebt } = entry;
535
+ return [{ ...withoutParentDebt, sources }];
536
+ });
537
+ return { entries: next, removed };
538
+ }
539
+ function managedWatchRecord(id) {
540
+ const rec = readRecord(id);
541
+ if (!rec?.governed)
542
+ throw new ResourceConflict(`session ${id} is not a governed session and cannot participate in a durable watch`);
543
+ return rec;
544
+ }
545
+ function watchMessage(target) {
546
+ const status = target.status === 'awaiting'
547
+ ? PROPOSAL_STATUS[target.proposal ?? 'nothing']
548
+ : target.status === 'active' ? 'working' : target.status;
549
+ const note = target.note ? ` — ${target.note}` : '';
550
+ return `[spex watch] ${target.session} is ${status}${note}`;
551
+ }
552
+ function shouldDeliverWatchTransition(target, sources) {
553
+ // @@@watch-delivery-policy - Relationship setup sends current state; manual opts into working changes.
554
+ return target.status !== 'active' || sources.includes('manual');
555
+ }
556
+ function scheduleWatchNotifications(target) {
557
+ const watchers = readWatchEntries(target.session)
558
+ .filter((entry) => !entry.snapshotPending && shouldDeliverWatchTransition(target, entry.sources))
559
+ .map((entry) => entry.watcher);
560
+ if (!watchers.length)
561
+ return;
562
+ queueMicrotask(() => {
563
+ for (const watcher of watchers) {
564
+ void sendText(watcher, watchMessage(target), target.session).then((result) => {
565
+ if (!result.ok)
566
+ console.error(`spex session watch: could not deliver ${target.session} state to ${watcher}: ${result.error}`);
567
+ });
568
+ }
569
+ });
570
+ }
571
+ const watchSnapshotState = (target) => JSON.stringify([target.status, target.proposal, target.note]);
572
+ async function deliverPendingWatchSnapshots(targetId, forceCurrent = true) {
573
+ const pending = readWatchEntries(targetId).filter((entry) => entry.snapshotPending);
574
+ for (const original of pending) {
575
+ const token = original.snapshotPending;
576
+ let force = forceCurrent;
577
+ for (;;) {
578
+ const target = readRecord(targetId);
579
+ const entry = readWatchEntries(targetId)
580
+ .find((candidate) => candidate.watcher === original.watcher && candidate.snapshotPending === token);
581
+ if (!target || !entry)
582
+ break;
583
+ const state = watchSnapshotState(target);
584
+ const shouldDeliver = force || shouldDeliverWatchTransition(target, entry.sources);
585
+ if (!shouldDeliver) {
586
+ let settled = false;
587
+ await withRecordLock(targetId, async () => {
588
+ const current = readRecord(targetId);
589
+ const entries = readWatchEntries(targetId);
590
+ const pendingEntry = entries.find((candidate) => candidate.watcher === original.watcher && candidate.snapshotPending === token);
591
+ if (!current || !pendingEntry || watchSnapshotState(current) !== state)
592
+ return;
593
+ const next = entries.map((candidate) => {
594
+ if (candidate !== pendingEntry)
595
+ return candidate;
596
+ const { snapshotPending: _pending, ...cleared } = candidate;
597
+ return cleared;
598
+ });
599
+ writeWatchEntries(targetId, next);
600
+ settled = true;
601
+ });
602
+ if (settled)
603
+ break;
604
+ force = false;
605
+ continue;
606
+ }
607
+ const identity = `${targetId}\0${entry.watcher}\0${token}\0${state}`;
608
+ const delivered = await sendText(entry.watcher, watchMessage(target), targetId, {
609
+ idempotency: {
610
+ operation: 'watch-initial-snapshot',
611
+ requestDigest: digest(identity),
612
+ payloadHash: digest(`watch-initial-snapshot\0${identity}\0${watchMessage(target)}`),
613
+ },
614
+ acceptGuard: async () => {
615
+ const current = readRecord(targetId);
616
+ const stillPending = readWatchEntries(targetId)
617
+ .some((candidate) => candidate.watcher === entry.watcher && candidate.snapshotPending === token);
618
+ if (!current || !stillPending || watchSnapshotState(current) !== state)
619
+ throw new ResourceConflict('watch initial snapshot changed before acceptance');
620
+ },
621
+ });
622
+ if (!delivered.ok) {
623
+ if (delivered.error?.includes('watch initial snapshot changed before acceptance'))
624
+ continue;
625
+ console.error(`spex session watch: could not deliver initial ${targetId} state to ${entry.watcher}: ${delivered.error}`);
626
+ break;
627
+ }
628
+ force = false;
629
+ let settled = false;
630
+ await withRecordLock(targetId, async () => {
631
+ const current = readRecord(targetId);
632
+ const entries = readWatchEntries(targetId);
633
+ const pendingEntry = entries.find((candidate) => candidate.watcher === entry.watcher && candidate.snapshotPending === token);
634
+ if (!current || !pendingEntry) {
635
+ settled = true;
636
+ return;
637
+ }
638
+ const currentState = watchSnapshotState(current);
639
+ if (currentState !== state && shouldDeliverWatchTransition(current, pendingEntry.sources))
640
+ return;
641
+ const next = entries.map((candidate) => {
642
+ if (candidate !== pendingEntry)
643
+ return candidate;
644
+ const { snapshotPending: _pending, ...cleared } = candidate;
645
+ return cleared;
646
+ });
647
+ writeWatchEntries(targetId, next);
648
+ settled = true;
649
+ });
650
+ if (settled)
651
+ break;
652
+ }
653
+ }
654
+ }
655
+ async function clearPendingWatchSnapshots(targetId) {
656
+ await withRecordLock(targetId, async () => {
657
+ const entries = readWatchEntries(targetId);
658
+ const next = entries.map((entry) => {
659
+ if (!entry.snapshotPending)
660
+ return entry;
661
+ const { snapshotPending: _pending, ...settled } = entry;
662
+ return settled;
663
+ });
664
+ if (next.some((entry, index) => entry !== entries[index]))
665
+ writeWatchEntries(targetId, next);
666
+ });
667
+ }
668
+ export async function subscribeSessionWatch(watcher, targets, source = 'manual') {
669
+ managedWatchRecord(watcher);
670
+ const watched = [];
671
+ for (const target of [...new Set(targets)]) {
672
+ if (target === watcher)
673
+ throw new ResourceConflict('a session cannot watch itself');
674
+ let targetRecord = null;
675
+ let added = false;
676
+ let pending = false;
677
+ withRecordLockSync(target, () => {
678
+ targetRecord = managedWatchRecord(target);
679
+ const entries = readWatchEntries(target);
680
+ const next = addWatchSource(entries, watcher, source, source === 'parent' && targetRecord.status === 'queued');
681
+ if (next.added)
682
+ writeWatchEntries(target, next.entries);
683
+ added = next.added;
684
+ pending = next.entries.some((entry) => entry.watcher === watcher && !!entry.snapshotPending);
685
+ });
686
+ if (pending) {
687
+ if (source === 'manual')
688
+ await deliverPendingWatchSnapshots(target);
689
+ }
690
+ else if (source === 'manual' || added) {
691
+ const delivered = await sendText(watcher, watchMessage(targetRecord), target);
692
+ if (!delivered.ok)
693
+ throw new ResourceConflict(`watch established but could not queue ${target}'s current state for ${watcher}: ${delivered.error}`);
694
+ }
695
+ watched.push(target);
696
+ }
697
+ return { watched };
698
+ }
699
+ export function listSessionWatches(watcher) {
700
+ managedWatchRecord(watcher);
701
+ const watches = [];
702
+ for (const target of listSessionIds()) {
703
+ const entries = readWatchEntries(target);
704
+ const active = entries.filter((entry) => {
705
+ try {
706
+ return !!readRecord(entry.watcher)?.governed;
707
+ }
708
+ catch {
709
+ return false;
710
+ }
711
+ });
712
+ if (active.length !== entries.length)
713
+ writeWatchEntries(target, active);
714
+ for (const entry of active)
715
+ if (entry.watcher === watcher)
716
+ watches.push({ target, createdAt: entry.createdAt });
717
+ }
718
+ return watches.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.target.localeCompare(b.target));
719
+ }
720
+ export function cancelSessionWatch(watcher, targets) {
721
+ managedWatchRecord(watcher);
722
+ let cancelled = 0;
723
+ for (const target of [...new Set(targets)]) {
724
+ withRecordLockSync(target, () => {
725
+ const entries = readWatchEntries(target);
726
+ const next = removeWatchSource(entries, watcher, 'manual');
727
+ if (next.removed) {
728
+ writeWatchEntries(target, next.entries);
729
+ cancelled++;
730
+ }
731
+ });
732
+ }
733
+ return cancelled;
734
+ }
735
+ async function withRecordLocks(ids, body, index = 0) {
736
+ if (index >= ids.length)
737
+ return body();
738
+ return withRecordLock(ids[index], () => withRecordLocks(ids, body, index + 1));
739
+ }
740
+ function assertReparentable(children, parent, records) {
741
+ if (!children.length)
742
+ throw new ResourceConflict('reparent needs at least one child session');
743
+ if (parent)
744
+ managedWatchRecord(parent);
745
+ for (const id of children) {
746
+ const child = records.get(id);
747
+ if (!child?.governed)
748
+ throw new ResourceConflict(`session ${id} is not a governed child session`);
749
+ if (id === parent)
750
+ throw new ResourceConflict('a session cannot be its own parent');
751
+ }
752
+ const childIds = new Set(children);
753
+ const seen = new Set();
754
+ for (let current = parent; current;) {
755
+ if (childIds.has(current))
756
+ throw new ResourceConflict(`reparent would create a parent cycle through ${current}`);
757
+ if (seen.has(current))
758
+ throw new ResourceConflict(`cannot reparent through malformed parent cycle at ${current}`);
759
+ seen.add(current);
760
+ current = readRecord(current)?.parent ?? null;
761
+ }
762
+ }
763
+ export async function reparentSessionRecords(rawChildren, parent) {
764
+ const children = [...new Set(rawChildren)].sort();
765
+ const notify = [];
766
+ await withRecordLock('session-reparent-transaction', async () => {
767
+ // Read former supervisors only after the transaction fence: a concurrent reparent may change exactly
768
+ // this relation, and its real sender lock is part of the next transaction's outgoing-message boundary.
769
+ const before = new Map(children.map((id) => [id, managedWatchRecord(id)]));
770
+ assertReparentable(children, parent, before);
771
+ const formerParents = [...new Set([...before.values()].flatMap((record) => record.parent ? [record.parent] : []))];
772
+ await withRecordLocks([...children, ...formerParents].sort(), () => withDeliveryLocks(children, async () => {
773
+ const current = new Map(children.map((id) => [id, managedWatchRecord(id)]));
774
+ assertReparentable(children, parent, current);
775
+ const snapshots = children.map((id) => ({ id, record: current.get(id), watchers: readWatchEntries(id), pending: pendingSnapshot(id) }));
776
+ try {
777
+ for (const snapshot of snapshots) {
778
+ const { record, watchers } = snapshot;
779
+ const hadNewParent = !!parent && watchers.some((entry) => entry.watcher === parent && entry.sources.includes('parent'));
780
+ const withoutFormerParent = record.parent
781
+ ? removeWatchSource(watchers, record.parent, 'parent').entries
782
+ : watchers;
783
+ const nextWatchers = parent
784
+ ? addWatchSource(withoutFormerParent, parent, 'parent').entries
785
+ : withoutFormerParent;
786
+ if (nextWatchers !== watchers)
787
+ writeWatchEntries(snapshot.id, nextWatchers);
788
+ if (record.parent !== parent)
789
+ writeRecord({ ...record, parent });
790
+ if (parent && (record.parent !== parent || !hadNewParent))
791
+ notify.push({ ...record, parent });
792
+ }
793
+ for (const snapshot of snapshots) {
794
+ if (snapshot.record.parent && snapshot.record.parent !== parent)
795
+ revokePendingFromWhileLocked(snapshot.id, snapshot.record.parent);
796
+ }
797
+ }
798
+ catch (error) {
799
+ let rollbackFailure = null;
800
+ for (const snapshot of [...snapshots].reverse()) {
801
+ try {
802
+ replacePendingWhileLocked(snapshot.id, snapshot.pending);
803
+ writeWatchEntries(snapshot.id, snapshot.watchers);
804
+ writeRecord(snapshot.record);
805
+ }
806
+ catch (rollback) {
807
+ rollbackFailure ??= rollback;
808
+ }
809
+ }
810
+ const detail = error instanceof Error ? error.message : String(error);
811
+ const rollbackDetail = rollbackFailure instanceof Error ? `; rollback also failed: ${rollbackFailure.message}` : '';
812
+ throw new ResourceConflict(`reparent did not commit: ${detail}${rollbackDetail}`);
813
+ }
814
+ }));
815
+ });
816
+ const notified = [];
817
+ if (parent)
818
+ for (const child of notify) {
819
+ const delivered = await sendText(parent, watchMessage(child), child.session);
820
+ if (!delivered.ok)
821
+ throw new ResourceConflict(`reparent committed but could not queue ${child.session}'s current state for ${parent}: ${delivered.error}`);
822
+ notified.push(child.session);
823
+ }
824
+ return { children, parent, notified };
825
+ }
826
+ // tmux rewrites CONTROL characters in a format string before printing them — 3.6a turns both a tab and a raw
827
+ // 0x1f into `_`, while 3.4 turns a raw 0x1f into the printable escape `\037`. So the field separator is ASKED
828
+ // FOR as that printable text, which every supported version passes through untouched, and the format is built
829
+ // from the same constant the parser splits on: the two can no longer disagree about what tmux actually emits.
830
+ const TMUX_PANE_SEPARATOR = '\\037';
831
+ export const TMUX_PANE_FORMAT = `#{session_name}${TMUX_PANE_SEPARATOR}#{pane_pid}${TMUX_PANE_SEPARATOR}#{pane_title}`;
832
+ // First pane per session wins; split only twice so titles may contain the field separator.
833
+ export function parseLivePanes(out) {
834
+ const m = new Map();
835
+ for (const line of out.split('\n')) {
836
+ if (!line)
837
+ continue;
838
+ // Accept the former tab shape for callers replaying old snapshots; tmux itself emits TMUX_PANE_SEPARATOR.
839
+ const separator = line.includes(TMUX_PANE_SEPARATOR) ? TMUX_PANE_SEPARATOR : '\t';
840
+ const t1 = line.indexOf(separator);
841
+ const name = (t1 < 0 ? line : line.slice(0, t1)).trim();
842
+ if (!name || m.has(name))
843
+ continue; // first pane per session wins
844
+ if (t1 < 0) {
845
+ m.set(name, {});
846
+ continue;
847
+ }
848
+ const rest = line.slice(t1 + separator.length);
849
+ const t2 = rest.indexOf(separator);
850
+ const pid = Number((t2 < 0 ? rest : rest.slice(0, t2)).trim());
851
+ const title = t2 < 0 ? '' : rest.slice(t2 + separator.length);
852
+ m.set(name, { panePid: Number.isFinite(pid) && pid > 0 ? pid : undefined, title: title || undefined });
853
+ }
854
+ return m;
855
+ }
856
+ const pidRegistry = new Map();
857
+ function readAgentPid(p) { try {
858
+ return Number(readFileSync(p, 'utf8').trim());
859
+ }
860
+ catch {
861
+ return NaN;
862
+ } }
863
+ function agentAlive(id) {
864
+ const pidPath = sessionArtifactPath(id, 'agent.pid');
865
+ let mtimeMs;
866
+ try {
867
+ mtimeMs = statSync(pidPath).mtimeMs;
868
+ }
869
+ catch {
870
+ pidRegistry.delete(id);
871
+ return undefined;
872
+ } // no pid file → pre-registration
873
+ let e = pidRegistry.get(id);
874
+ if (!e || e.mtimeMs !== mtimeMs) {
875
+ e = { mtimeMs, pid: readAgentPid(pidPath), deadLatched: false };
876
+ pidRegistry.set(id, e);
877
+ }
878
+ if (e.deadLatched)
879
+ return false; // latched dead stays dead until a new write (fresh mtime)
880
+ if (!Number.isFinite(e.pid) || e.pid <= 0)
881
+ return false;
882
+ try {
883
+ process.kill(e.pid, 0);
884
+ return true;
885
+ }
886
+ catch (err) {
887
+ if (err.code === 'EPERM')
888
+ return true; // alive but not ours to signal
889
+ e.deadLatched = true; // ESRCH → proven dead, latch it permanently
890
+ return false;
891
+ }
892
+ }
893
+ // Only pre-agent.pid Codex sessions need the legacy whole-process scan.
894
+ export function needsCodexProcScan(windowed) {
895
+ return windowed.some((w) => (w.harness || 'claude') === 'codex' && !w.hasPid);
896
+ }
897
+ async function liveSnapshot(targetId) {
898
+ const windows = new Map();
899
+ const titles = new Map();
900
+ let out;
901
+ try {
902
+ // ONE merged spawn replaces the old two (list-sessions + list-panes): window presence + pane pid + title.
903
+ // A target-scoped close probe avoids unrelated panes turning a safe close into a global timeout.
904
+ const args = targetId
905
+ ? ['list-panes', '-t', targetId, '-F', TMUX_PANE_FORMAT]
906
+ : ['list-panes', '-a', '-F', TMUX_PANE_FORMAT];
907
+ out = await tmux(args, targetId ? TARGET_PROBE_TIMEOUT_MS : TMUX_PROBE_TIMEOUT_MS);
908
+ }
909
+ catch (e) {
910
+ // a TIMEOUT/kill is a probe FAILURE (we can't tell who's alive → unknown, never a false graveyard). A clean
911
+ // non-zero exit ("no server running" — genuinely zero sessions) is authoritative → the empty map = offline.
912
+ return { probeFailed: probeTimedOut(e), windows, titles, sockets: new Set(), unproven: new Set() };
913
+ }
914
+ // the hot-tier pid verdict per windowed session (latch-consistent with hotSignature) + the legacy-scan gate.
915
+ const legacy = [];
916
+ for (const [id, p] of parseLivePanes(out)) {
917
+ windows.set(id, { panePid: p.panePid, pidAlive: agentAlive(id) });
918
+ if (p.title)
919
+ titles.set(id, p.title);
920
+ if (windows.get(id).pidAlive === undefined) {
921
+ // A corrupt row has no trustworthy harness to scan and renders liveness=unknown on its own. Letting this
922
+ // optional legacy enrichment throw would turn one diagnosable row into a 409 for the entire board.
923
+ try {
924
+ const rec = readRecord(id);
925
+ if (rec)
926
+ legacy.push({ harness: rec.harness, hasPid: false });
927
+ }
928
+ catch (e) {
929
+ if (!(e instanceof SessionRecordUnusable))
930
+ throw e;
931
+ }
932
+ }
933
+ }
934
+ // the whole-box ps table is gathered ONCE, and ONLY for the legacy pid-less-codex fallback (paneTreeRunsCodex).
935
+ if (needsCodexProcScan(legacy)) {
936
+ const procs = await procSnapshot().catch(() => undefined); // codex-only, auxiliary; its failure isn't a liveness failure
937
+ if (procs)
938
+ for (const probe of windows.values())
939
+ probe.procs = procs;
940
+ }
941
+ // LISTENER probe for every windowed session, once, in parallel (a live listener, not a lingering socket
942
+ // file). A codex session has no rvSock → instant ENOENT → proven dead for the socket axis (codex ignores it).
943
+ // The tri-state matters: 'unproven' (timeout/EAGAIN — a wedged or thrashed but possibly-alive listener) lands
944
+ // in `unproven`, never silently not-live, so liveness() renders `unknown` not a false `offline` (issue #40).
945
+ const ids = [...windows.keys()];
946
+ // A burst of simultaneous Unix-socket connects can fill a Claude listener's accept backlog on macOS and
947
+ // turn every healthy socket into `unproven`. Keep the probe bounded while preserving the tri-state result.
948
+ const listening = [];
949
+ for (let start = 0; start < ids.length; start += 2) {
950
+ listening.push(...await Promise.all(ids.slice(start, start + 2).map((id) => rendezvousListening(id))));
951
+ }
952
+ const sockets = new Set();
953
+ const unproven = new Set();
954
+ ids.forEach((id, i) => {
955
+ if (listening[i] === 'live')
956
+ sockets.add(id);
957
+ else if (listening[i] === 'unproven')
958
+ unproven.add(id);
959
+ });
960
+ return { probeFailed: false, windows, titles, sockets, unproven };
961
+ }
962
+ async function assertTargetTmuxAbsent(id, phase) {
963
+ const deadline = Date.now() + TARGET_TMUX_CLOSE_SETTLE_MS;
964
+ let probeFailed = false;
965
+ do {
966
+ const snap = await liveSnapshot(id);
967
+ if (!snap.probeFailed && !snap.windows.has(id))
968
+ return;
969
+ probeFailed ||= snap.probeFailed;
970
+ if (Date.now() >= deadline)
971
+ break;
972
+ await new Promise((resolve) => setTimeout(resolve, 50));
973
+ } while (true);
974
+ throw new ResourceConflict(probeFailed
975
+ ? `refusing to stop ${id}: target tmux absence is unproven ${phase}`
976
+ : `refusing to stop ${id}: target tmux session remains ${phase}`);
977
+ }
978
+ // Avoid process spawns on the hot path; old sessions without agent.pid remain warm-tier only.
979
+ let hotIds = [];
980
+ let hotIdsAt = 0;
981
+ export async function hotSignature() {
982
+ const now = Date.now();
983
+ if (now - hotIdsAt >= 1000) {
984
+ hotIds = listSessionIds();
985
+ hotIdsAt = now;
986
+ }
987
+ const pairs = [];
988
+ const present = [];
989
+ for (const id of hotIds) {
990
+ const alive = agentAlive(id);
991
+ if (alive === undefined)
992
+ continue; // no agent.pid → the warm tier's concern, not the hot death detector
993
+ present.push(id);
994
+ pairs.push(`${id}:${alive ? 1 : 0}`);
995
+ }
996
+ // prune latch entries for ids no longer registered (closed sessions), keeping the registry bounded.
997
+ const live = new Set(hotIds);
998
+ for (const k of [...pidRegistry.keys()])
999
+ if (!live.has(k))
1000
+ pidRegistry.delete(k);
1001
+ return pairs.sort().join(',') + '|' + present.sort().join(',');
1002
+ }
1003
+ // Include listener and title changes so watchers refresh without another store read.
1004
+ export async function warmSignature() {
1005
+ const snap = await liveSnapshot();
1006
+ return (snap.probeFailed ? 'PROBEFAIL|' : '') + [...snap.windows.keys()].sort().join(',') + '#' +
1007
+ [...snap.sockets].sort().join(',') + '~' + [...snap.unproven].sort().join(',') + '|' +
1008
+ [...snap.titles].sort().map(([k, v]) => `${k}=${v}`).join(',');
1009
+ }
1010
+ // @@@ paneActivity - the harness-aware live self-summary: the SINGLE place a raw pane title becomes (or does
1011
+ // NOT become) a session's headline activity. The board headline derives from the pane title ONLY for a
1012
+ // harness whose pane title is its own task self-summary (`paneTitleIsSelfSummary`, an adapter capability —
1013
+ // [[harness-adapter]]). claude qualifies (it writes its task summary into the OSC title), so we parse it with
1014
+ // selfSummary (glyph-gated). codex does NOT — its pane title is a spinner glyph + the cwd FOLDER name, so
1015
+ // returning it would headline the worktree folder, not the task; we refuse it (→ null) and sessionHeadline
1016
+ // falls through to promptPreview (the launch prompt). The ONLY harness branch is the capability read here —
1017
+ // no `if (codex)`, no glyph special-case; selfSummary stays the pure claude-title parser.
1018
+ export function paneActivity(harness, paneTitle) {
1019
+ if (paneTitle == null || !harness.paneTitleIsSelfSummary)
1020
+ return null;
1021
+ return selfSummary(paneTitle);
1022
+ }
1023
+ // @@@ selfSummary - the agent's OWN live one-line description, parsed from its tmux pane title — the SINGLE
1024
+ // place the "is this the agent speaking?" rule lives, exported so it is unit-auditable. Claude Code sets that
1025
+ // title via an OSC escape and ALWAYS leads it with a status glyph: ✳ (and its ✶✻✽✢ blink frames) when idle, a
1026
+ // braille spinner frame (U+2800–U+28FF) while working. That leading glyph is the only reliable proof the
1027
+ // title is the agent and not tmux's default — which, from pane birth until the first turn, is the HOST NAME
1028
+ // (e.g. `ser581555022561`) or a bare `Claude Code` splash. So the glyph is REQUIRED: no leading glyph → null,
1029
+ // and the caller keeps showing the launch-prompt placeholder instead of flickering through the host name and
1030
+ // splash. The leading glyph run (with the spaces/`·` between and after) is stripped — the dashboard draws its
1031
+ // own status dot, a frozen spinner frame is just noise — leaving only the summary text (null if it is empty).
1032
+ // ONE regex is the single source of the glyph rule: it gates (requires ≥1 glyph) and strips in one match.
1033
+ // The glyph gate alone is not enough: Claude Code emits a glyph-led SPLASH of its own app name (`✳ Claude
1034
+ // Code`) between pane birth and its first real task summary — it CLEARS the glyph gate yet is the app naming
1035
+ // itself, not the task. GENERIC_SUMMARY rejects that stripped splash too, so the row keeps its launch-prompt
1036
+ // placeholder instead of flashing "Claude Code" for a tick (the glyph-LESS `Claude Code` splash was already
1037
+ // rejected by the gate; this catches its glyph-led twin).
1038
+ const GENERIC_SUMMARY = /^claude code$/i;
1039
+ export function selfSummary(paneTitle) {
1040
+ const m = /^[\s·]*(?:[✳✶✻✽✢⠀-⣿][\s·]*)+(.*)$/u.exec(paneTitle);
1041
+ if (!m)
1042
+ return null;
1043
+ const text = m[1].trim();
1044
+ return text && !GENERIC_SUMMARY.test(text) ? text : null;
1045
+ }
1046
+ // @@@ launchedAt - when we last started a tmux window for an id (set in launch()). claude needs ~15-20s
1047
+ // after the window appears to recreate its rendezvous socket; in that window the socket is absent but the
1048
+ // session is booting, NOT dead. reconcile consults this to report 'starting' (a distinct transient state)
1049
+ // instead of 'offline' for BOOT_GRACE_MS after launch — so 'offline' only ever means genuinely dead. In-
1050
+ // memory in the single server process (lost on restart, which is fine: a restart has nothing in flight).
1051
+ const launchedAt = new Map();
1052
+ export const BOOT_GRACE_MS = 45000; // > SOCKET_READY_TIMEOUT_MS, and spans launchScript's bounded fast-fail retry
1053
+ // window (~3 attempts) so a relaunching session reads 'starting', not 'offline'
1054
+ const LAUNCH_FAST_FAIL_S = 12; // launchScript retries the agent command when it exits faster than this: fast
1055
+ // exit before readiness is retryable, but it is not proof of one specific cause
1056
+ export function liveness(rec, snap) {
1057
+ if (!rec.session || rec.stopped || rec.archived)
1058
+ return 'offline';
1059
+ // Ask the resolved ADAPTER ([[harness-adapter]]): claude/pi/opencode prove their rendezvous listener;
1060
+ // codex proves its launch-registered pid (with the legacy descendant-tree fallback). The 'starting' grace
1061
+ // stays here: a just-launched agent whose online signal has not appeared yet reads 'starting', only past it
1062
+ // 'offline'.
1063
+ const h = harnessById(rec.harness || defaultHarness.id);
1064
+ if (h.liveness(rec, snap.windows.has(rec.session), runtimeRoot(), snap.windows.get(rec.session), snap.sockets.has(rec.session)) === 'online')
1065
+ return 'online';
1066
+ if (snap.probeFailed)
1067
+ return 'unknown'; // the probe failed — we can't tell, and MUST NOT guess offline
1068
+ // not provably online — but if this session's LISTENER probe couldn't conclude (timeout under load / EAGAIN
1069
+ // off a full-but-alive backlog), death is UNPROVEN: `unknown`, never a false `offline` a supervisor would
1070
+ // act on (issue #40 — a wedged-but-alive worker must not read as an actionable corpse).
1071
+ if (snap.unproven.has(rec.session))
1072
+ return 'unknown';
1073
+ const at = launchedAt.get(rec.session);
1074
+ if (at && Date.now() - at < BOOT_GRACE_MS)
1075
+ return 'starting';
1076
+ // A dead TRANSPORT is not a dead AGENT. The socket path is keyed by session id alone, so a foreign teardown
1077
+ // (or a stray rm) can unlink it out from under its own live listener: the agent keeps working, unreachable,
1078
+ // and every path-connect ENOENTs — which the adapter axis above reports as proven death. The registered
1079
+ // agent.pid is a SECOND, independent witness, and while it still answers, death is UNPROVEN: `unknown`, not
1080
+ // the `offline` that disarms the relaunch guard and invites a human to kill a working agent. Same rule as
1081
+ // the probe-failure branch (issue #40), one layer down: only a corpse both witnesses agree on is actionable.
1082
+ if (agentAlive(rec.session) === true)
1083
+ return 'unknown';
1084
+ return 'offline';
1085
+ }
1086
+ function reconcile(rec, snap) {
1087
+ // record integrity outranks both axes: a session whose worktree is gone has no work to be in any state
1088
+ // about. It reads `retired` — a terminal, human-closable row, never a lifecycle a hook can write back over.
1089
+ if (retirementReason(rec))
1090
+ return 'retired';
1091
+ if (rec.archived)
1092
+ return 'offline';
1093
+ if (rec.status === 'awaiting')
1094
+ return PROPOSAL_STATUS[rec.proposal || 'nothing'];
1095
+ if (rec.status !== 'active' && rec.status !== 'idle')
1096
+ return rec.status; // parked | error | asking | queued (no tmux yet)
1097
+ const lv = liveness(rec, snap);
1098
+ if (lv !== 'online')
1099
+ return lv; // 'offline' | 'starting' | 'unknown'
1100
+ return rec.status === 'idle' ? 'idle' : 'working';
1101
+ }
1102
+ // resolve a session id to its record + worktree. Now a DIRECT store read (the record carries worktree_path),
1103
+ // not a scan of every worktree reading its `.session` — O(1) and exact. null when the id has no governed-or-not
1104
+ // record. Shape kept ({path, branch, rec}) so the many callers (rename/propose/resume/merge/close/…) are unchanged.
1105
+ async function findWorktree(id) {
1106
+ const rec = readRecord(id);
1107
+ if (!rec)
1108
+ return null;
1109
+ return { path: rec.worktreePath, branch: rec.branch, rec };
1110
+ }
1111
+ export function reviewIdentity(id) {
1112
+ const rec = readRecord(id);
1113
+ if (!rec)
1114
+ return null;
1115
+ return {
1116
+ id,
1117
+ node: rec.node,
1118
+ branch: rec.branch,
1119
+ label: deriveLabel({ id, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch }),
1120
+ };
1121
+ }
1122
+ function corruptSession(id, entry) {
1123
+ const label = `${id.slice(0, 8)} (unreadable record)`;
1124
+ return {
1125
+ id, node: null, branch: null, path: '', label, title: label, raw: { name: null, title: null },
1126
+ parent: null, harness: defaultHarness.id, capabilities: { headless: false }, launcher: null,
1127
+ lifecycle: 'active', proposal: null, merges: 0, status: 'corrupt', liveness: 'unknown',
1128
+ note: corruptReason(entry), archived: false, prompt: null, promptPreview: null, created: 0,
1129
+ activity: null, sortKey: null, archiveHazard: null, files: [], web: [],
1130
+ };
1131
+ }
1132
+ export function toSession(rec, status, lv, activity = null) {
1133
+ const prompt = readPromptFile(rec.session); // the originating ask, captured at launch (store artifact; null for old sessions)
1134
+ // activity is the LIVE pane title; it only means anything while the worker is genuinely up — a
1135
+ // dead/booting session would show a stale or absent title, so it's suppressed unless liveness is online.
1136
+ const showActivity = lv === 'online';
1137
+ const act = showActivity ? activity : null;
1138
+ const pp = prompt ? oneLinePreview(prompt) : null;
1139
+ const parts = { id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch, activity: act, note: rec.note, promptPreview: pp };
1140
+ const harness = harnessById(rec.harness || defaultHarness.id);
1141
+ return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts), title: deriveTitle(parts), raw: { name: rec.name, title: rec.title }, path: rec.worktreePath, parent: rec.parent, harness: harness.id, capabilities: { headless: harness.headless }, launcher: rec.launcher, lifecycle: rec.status, proposal: rec.proposal, merges: rec.merges, note: rec.note, status, liveness: lv, archived: rec.archived, archiveHazard: null, prompt, promptPreview: pp, created: rec.createdAt, activity: act, sortKey: rec.sortKey, files: readSessionFiles(rec.session), web: readSessionWebs(rec.session), ...(rec.zcodeChildSessionIds?.length ? { zcodeChildSessionIds: [...rec.zcodeChildSessionIds] } : {}) };
1142
+ }
1143
+ // @@@zcode child identity - ZCode owns the child id and SpexCode owns the session record. The writer accepts
1144
+ // only their exact declared pair; names, worktrees, branches, and timestamps are deliberately not candidates.
1145
+ // One hash-derived lock serializes every claim for one opaque child id across all records, while the target's
1146
+ // ordinary record lock preserves its lifecycle write discipline. Closing the target removes this field with
1147
+ // its record, so the association lasts exactly as long as the SpexCode session/eval projection it names.
1148
+ export async function linkZCodeChildSession(sessionId, childSessionId) {
1149
+ if (typeof childSessionId !== 'string' || !childSessionId || childSessionId.trim() !== childSessionId)
1150
+ throw new ResourceConflict('z-code child session link needs a non-empty, whitespace-free childSessionId');
1151
+ const child = childSessionId;
1152
+ const childLock = `zcode-child-link-${createHash('sha256').update(child).digest('hex')}`;
1153
+ return withRecordLock(childLock, () => withRecordLock(sessionId, async () => {
1154
+ const target = readRecord(sessionId);
1155
+ if (!target || !target.governed)
1156
+ return null;
1157
+ for (const id of listSessionIds()) {
1158
+ const candidate = readRecord(id);
1159
+ if (!candidate?.zcodeChildSessionIds?.includes(child))
1160
+ continue;
1161
+ if (candidate.session !== target.session)
1162
+ throw new ResourceConflict(`z-code child session ${child} is already linked to SpexCode session ${candidate.session}`);
1163
+ return { sessionId: target.session, childSessionId: child, alreadyLinked: true };
1164
+ }
1165
+ writeRecord({ ...target, zcodeChildSessionIds: [...(target.zcodeChildSessionIds ?? []), child] });
1166
+ return { sessionId: target.session, childSessionId: child, alreadyLinked: false };
1167
+ }));
1168
+ }
1169
+ export async function renameSession(id, name) {
1170
+ return withRecordLock(id, async () => {
1171
+ const wt = await findWorktree(id);
1172
+ if (!wt)
1173
+ return false;
1174
+ writeRecord({ ...wt.rec, name: name.trim() || null });
1175
+ return true;
1176
+ });
1177
+ }
1178
+ export async function setSessionSort(id, key) {
1179
+ return withRecordLock(id, async () => {
1180
+ const wt = await findWorktree(id);
1181
+ if (!wt)
1182
+ return false;
1183
+ writeRecord({ ...wt.rec, sortKey: key != null && Number.isFinite(key) ? key : null });
1184
+ return true;
1185
+ });
1186
+ }
1187
+ // the session's full ORIGINATING prompt (what it was asked to do), or null if none was recorded. A record we
1188
+ // cannot read simply has no prompt to report — a READ accessor must not turn an unreadable record into an
1189
+ // error for the surface asking about it; the row itself already carries the diagnosis.
1190
+ export async function sessionPrompt(id) {
1191
+ try {
1192
+ return readRecord(id) ? readPromptFile(id) : null;
1193
+ }
1194
+ catch (e) {
1195
+ if (e instanceof SessionRecordUnusable)
1196
+ return null;
1197
+ throw e;
1198
+ }
1199
+ }
1200
+ // Preserve rows through a transient record-read failure; prune after the store entry disappears.
1201
+ const lastKnownSession = new Map();
1202
+ // A BOARD row carries the launch ask only as its one-line preview. The full text is served by the
1203
+ // id-addressed record detail, which reads the stored prompt itself and overrides this field — so the list
1204
+ // never had a reader for it. Shipping it made the body grow with total ask LENGTH instead of session count
1205
+ // (measured on the adopter-a board: 2192 KB of a 2218 KB default body, 27 KB of which was actual board data)
1206
+ // and pinned the same bytes in `lastKnownSession` for the life of the process. The CREATE response keeps the
1207
+ // full text: it is a receipt for one ask the caller just made, not a row in a list of many.
1208
+ const boardRow = (s) => { s.prompt = null; return s; };
1209
+ export async function listSessions(includeArchived = false) {
1210
+ // ONE store enumeration + ONE tmux snapshot (windows + pane pids + titles, merged) for the whole list, then
1211
+ // every session reconciles by a pure set lookup + one existsSync — no per-session tmux spawn.
1212
+ const [ids, snap] = await Promise.all([
1213
+ Promise.resolve(listSessionIds()), liveSnapshot(),
1214
+ ]);
1215
+ // Freeze one record snapshot for both the census join and row projection. A second full read after an awaited
1216
+ // probe could pair record A with thread identity B and accidentally treat a missing census entry as clean.
1217
+ const snapshots = new Map();
1218
+ for (const id of ids) {
1219
+ try {
1220
+ const entry = readPublicRecordEntry(id);
1221
+ snapshots.set(id, { entry, rec: entry.kind === 'ok' ? fromRaw(entry.raw) : null });
1222
+ }
1223
+ catch { /* guardSession below preserves the last-known row for a transient read failure */ }
1224
+ }
1225
+ // Only archived adapter records need the resident-ID join. If there are none, this read path performs zero
1226
+ // control-plane probes; resources still owns the full turn/read probe for its detailed report.
1227
+ const censusRecords = [...snapshots.values()].flatMap(({ entry, rec }) => entry.kind === 'ok' && entry.liveness === null && rec && rec.governed && rec.archived && rec.harnessSessionId
1228
+ ? [{ ...rec, harness: rec.harness || defaultHarness.id }]
1229
+ : []);
1230
+ const residentCensus = censusRecords.length ? await adapterLoadedReferenceState(censusRecords) : new Map();
1231
+ // A record can change while the one census is in flight. Mark such rows conservatively; never let the stale
1232
+ // proof hide them. This is a bounded storage read, not another adapter RPC.
1233
+ const changedDuringCensus = new Set();
1234
+ for (const rec of censusRecords) {
1235
+ try {
1236
+ const current = readPublicRecordEntry(rec.session);
1237
+ const before = snapshots.get(rec.session)?.entry;
1238
+ if (current.kind !== 'ok' || before?.kind !== 'ok' || JSON.stringify(current.raw) !== JSON.stringify(before.raw))
1239
+ changedDuringCensus.add(rec.session);
1240
+ }
1241
+ catch {
1242
+ changedDuringCensus.add(rec.session);
1243
+ }
1244
+ }
1245
+ const rows = ids.map((id) => guardSession(id, () => {
1246
+ // a record we cannot READ still has a row: it is a session that exists and whose state is unknowable, which
1247
+ // is a thing to act on, not a thing to hide. It carries its own status and names the file, so the human can
1248
+ // see the file and close it — the alternative (dropping it) is what made a live session read as gone.
1249
+ const snapshot = snapshots.get(id);
1250
+ if (!snapshot)
1251
+ throw new Error(`session ${id} record snapshot unavailable`);
1252
+ const { entry } = snapshot;
1253
+ // The corrupt row becomes the LAST-KNOWN row, never a deletion. Dropping it would mean the next poll that
1254
+ // hits a transient read failure has nothing to fall back on and the row vanishes — re-opening the exact
1255
+ // hole this branch closes, one poll later. `corrupt` is a true reading, so it is worth remembering.
1256
+ if (entry.kind === 'corrupt') {
1257
+ const c = corruptSession(id, entry);
1258
+ lastKnownSession.set(id, c);
1259
+ return c;
1260
+ }
1261
+ const rec = snapshot.rec;
1262
+ if (!rec || !rec.governed) {
1263
+ lastKnownSession.delete(id);
1264
+ return null;
1265
+ } // no record, or a self-launched (non-board) one
1266
+ // A forced public liveness comes only from the shared record projection. Do not let live process/thread
1267
+ // evidence punch through it (including archive hazard repair).
1268
+ if (entry.kind === 'ok' && entry.liveness === 'offline') {
1269
+ const pending = boardRow(toSession(rec, 'offline', 'offline'));
1270
+ lastKnownSession.set(id, pending);
1271
+ return pending;
1272
+ }
1273
+ // the pane title → headline activity, gated by THIS session's harness ([[harness-adapter]]): claude's title
1274
+ // is its task self-summary (used); codex's is the cwd folder name (refused → headline falls to the prompt).
1275
+ const activity = paneActivity(harnessById(rec.harness || defaultHarness.id), snap.titles.get(id));
1276
+ const sessionHarness = harnessById(rec.harness || defaultHarness.id);
1277
+ const resident = rec.harnessSessionId
1278
+ ? residentCensus.get(`${rec.harness || defaultHarness.id}:${rec.harnessSessionId}`)
1279
+ : undefined;
1280
+ const residentRequired = sessionHarness.runtimeOwnership === 'adapter' && !!rec.harnessSessionId && !!sessionHarness.sharedRuntimes?.(runtimeRoot()).length;
1281
+ const physical = rec.archived
1282
+ ? (sessionHarness.runtimeOwnership === 'adapter'
1283
+ ? (resident && !resident.healthy ? 'unknown' : resident?.loaded ? 'online' : snap.windows.has(id) ? 'online' : 'offline')
1284
+ : liveness({ ...rec, archived: false, stopped: false }, snap))
1285
+ : null;
1286
+ // Only a physically-offline record projects as archived. A legacy archived+live/unknown record is exposed
1287
+ // as ordinary working-set state with its real liveness/status and one backend-owned hazard marker. A
1288
+ // missing durable cold proof is also legacy: leaf liveness alone cannot prove a Codex loaded thread was
1289
+ // unloaded, so it remains visible until an explicit archive repair.
1290
+ const cleanCold = rec.archived && !changedDuringCensus.has(id) && hasValidColdProof(rec) && physical === 'offline' && (!residentRequired || resident?.healthy === true);
1291
+ const projected = rec.archived && !cleanCold ? { ...rec, archived: false, stopped: false } : rec;
1292
+ const projectedLv = projected === rec ? liveness(rec, snap) : physical;
1293
+ const s = boardRow(toSession(projected, reconcile(projected, snap), projectedLv, activity));
1294
+ if (projected !== rec)
1295
+ s.archiveHazard = changedDuringCensus.has(id)
1296
+ ? 'archived runtime hazard: record changed while adapter residency was being reconciled; retry exact archive'
1297
+ : hasValidColdProof(rec)
1298
+ ? residentRequired && !resident
1299
+ ? 'archived runtime hazard: adapter resident-reference census missing; exact unload is unproven'
1300
+ : resident && !resident.healthy
1301
+ ? `archived runtime hazard: adapter resident-reference census is unknown (${resident.error || 'probe failed'})`
1302
+ : resident?.loaded
1303
+ ? 'archived runtime hazard: target adapter thread is still loaded'
1304
+ : `archived runtime hazard: record says archived but physical liveness is ${physical}`
1305
+ : 'archived runtime hazard: record has no durable cold witness; exact adapter unload is unproven';
1306
+ if (rec.adapterRecovery)
1307
+ s.archiveHazard = `archive adapter recovery required: ${rec.adapterRecovery}`;
1308
+ lastKnownSession.set(id, s);
1309
+ return s;
1310
+ }, () => {
1311
+ // DEGRADED: the record dir still exists but reading session.json failed transiently. NEVER drop a live
1312
+ // session — serve its last-known row. (No last-known means a first sighting raced a failure; nothing to
1313
+ // show yet, and it reappears on the next build.)
1314
+ return lastKnownSession.get(id) ?? null;
1315
+ }));
1316
+ // prune last-known entries for ids that no longer appear at all (genuinely removed), keeping it bounded.
1317
+ const liveIds = new Set(ids);
1318
+ for (const k of [...lastKnownSession.keys()])
1319
+ if (!liveIds.has(k))
1320
+ lastKnownSession.delete(k);
1321
+ return rows.filter((s) => s != null && (includeArchived || !s.archived))
1322
+ .sort((a, b) => (a.sortKey ?? a.created) - (b.sortKey ?? b.created) || a.id.localeCompare(b.id));
1323
+ }
1324
+ // a per-session read guard mirroring resilience.guardWorktree but keyed on the store record (not a worktree
1325
+ // path): run `primary`; if it throws AND the record dir still exists, the failure is transient → serve the
1326
+ // `degraded` fallback; if the dir is gone (a genuine close), return null (omit). No async git, so it's sync.
1327
+ function guardSession(id, primary, degraded) {
1328
+ try {
1329
+ return primary();
1330
+ }
1331
+ catch {
1332
+ return existsSync(sessionStoreDir(id)) ? degraded() : null;
1333
+ }
1334
+ }
1335
+ const usageError = (msg) => { const e = new Error(msg); e.name = 'UsageError'; return e; };
1336
+ export function optionArgv(argv = process.argv) {
1337
+ const delimiter = argv.indexOf('--');
1338
+ return delimiter < 0 ? argv : argv.slice(0, delimiter);
1339
+ }
1340
+ // the explicit routing flag, read from THIS process's argv (never the environment — that's the point).
1341
+ // `--port` doubles as a BIND port for serve/dashboard, so the sugar is skipped for those verbs.
1342
+ function explicitApiFlag() {
1343
+ const argv = optionArgv();
1344
+ const ai = argv.indexOf('--api');
1345
+ if (ai >= 0) {
1346
+ const v = argv[ai + 1];
1347
+ if (!v || v.startsWith('--'))
1348
+ throw usageError('--api expects a URL (e.g. --api http://127.0.0.1:8901)');
1349
+ const withScheme = v.includes('://') ? v : `http://${v}`;
1350
+ try {
1351
+ new URL(withScheme);
1352
+ }
1353
+ catch {
1354
+ throw usageError(`--api: not a URL: ${v}`);
1355
+ }
1356
+ return withScheme.replace(/\/+$/, '');
1357
+ }
1358
+ if (argv[2] === 'serve' || argv[2] === 'dashboard')
1359
+ return null; // their --port is a bind port, not routing
1360
+ const pi = argv.indexOf('--port');
1361
+ if (pi >= 0) {
1362
+ const v = argv[pi + 1];
1363
+ if (!v || !Number.isInteger(Number(v)))
1364
+ throw usageError('--port expects an integer (localhost sugar for --api http://127.0.0.1:<n>)');
1365
+ return `http://127.0.0.1:${v}`;
1366
+ }
1367
+ return null;
1368
+ }
1369
+ // the cwd project's recorded backend ({url,pid}, written by `spex serve` at bind time into the per-project
1370
+ // runtime tier), trusted only after a live /health probe — a stale record must never swallow a command.
1371
+ async function liveRecordUrl() {
1372
+ let file;
1373
+ try {
1374
+ file = join(runtimeRoot(), 'backend.json');
1375
+ }
1376
+ catch {
1377
+ return null;
1378
+ } // cwd not in a git repo → nothing to discover
1379
+ let url = '';
1380
+ try {
1381
+ const rec = JSON.parse(readFileSync(file, 'utf8'));
1382
+ if (typeof rec?.url === 'string')
1383
+ url = rec.url.trim();
1384
+ }
1385
+ catch {
1386
+ return null;
1387
+ }
1388
+ if (!url)
1389
+ return null;
1390
+ const ctrl = new AbortController();
1391
+ const t = setTimeout(() => ctrl.abort(), 600);
1392
+ try {
1393
+ return (await fetch(`${url}/health`, { signal: ctrl.signal })).ok ? url : null;
1394
+ }
1395
+ catch {
1396
+ return null;
1397
+ }
1398
+ finally {
1399
+ clearTimeout(t);
1400
+ }
1401
+ }
1402
+ async function resolveApiBase() {
1403
+ const flag = explicitApiFlag();
1404
+ if (flag)
1405
+ return { url: flag, source: 'flag' };
1406
+ const env = process.env.SPEXCODE_API_URL?.trim() || null;
1407
+ if (process.env.SPEXCODE_SESSION_ID?.trim()) {
1408
+ if (env)
1409
+ return { url: env, source: 'worker-env' };
1410
+ const rec = await liveRecordUrl();
1411
+ if (rec)
1412
+ return { url: rec, source: 'record' };
1413
+ }
1414
+ else {
1415
+ const rec = await liveRecordUrl();
1416
+ if (rec)
1417
+ return { url: rec, source: 'record' };
1418
+ if (env)
1419
+ return { url: env, source: 'env-fallback' };
1420
+ }
1421
+ return { url: `http://127.0.0.1:${process.env.PORT || 8787}`, source: 'default' };
1422
+ }
1423
+ let apiBaseMemo = null;
1424
+ export const apiBaseInfo = () => (apiBaseMemo ??= resolveApiBase());
1425
+ export const apiBase = async () => (await apiBaseInfo()).url;
1426
+ export const ownSessionId = envSessionId;
1427
+ export function withSenderHint(text, sender) {
1428
+ if (!sender)
1429
+ return text;
1430
+ const who = sender.label && sender.label !== sender.id ? `session "${sender.label}" (${sender.id})` : `session ${sender.id}`;
1431
+ return `${text}\n\n— from ${who}. To reply: spex session send ${sender.id} "<your reply>"`;
1432
+ }
1433
+ export function withPeerSenderHint(text, sender, sshAddress, machineId) {
1434
+ if (!sender)
1435
+ return text;
1436
+ const who = sender.label && sender.label !== sender.id ? `session "${sender.label}" (${sender.id})` : `session ${sender.id}`;
1437
+ return `${text}\n\n— from ${who} on machine ${machineId}. To reply: spex session send --ssh ${sshAddress} ${sender.id} "<your reply>"`;
1438
+ }
1439
+ export const withNoteReplyHint = (text) => `${text}\n\n— REQUIRED REPLY TRANSPORT (PER-MESSAGE): this terminal-free sender CANNOT see normal assistant/final output. Do not stop after only printing the answer. As your FINAL action, put your COMPLETE reply to this message in the truthful declaration's --note. For an answered exploratory question or simple answer awaiting the sender's follow-up, run \`spex session ask --note "<complete reply>"\`; if the true state is done or parked, put the same complete reply in that declaration's --note instead. This declaration command is reply transport, not part of the requested work, and remains REQUIRED even when the message says to use no tools, make no tool calls, or only print/reply. A later message arriving WITHOUT this notice means the sender is back at a terminal and reads your normal output again.`;
1440
+ export const withTerminalReplyHint = (text) => `${text}\n\n— sent from a terminal-attached client: the sender now reads your terminal output directly. Reply in your normal conversation output from here on — stop putting replies in declaration --notes (the earlier terminal-free notices no longer apply; a --note can go back to being a short status line).`;
1441
+ export const slugify = (s) => (s || 'session').normalize('NFC').replace(/[^\p{L}\p{N}_-]+/gu, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '') || 'session';
1442
+ const MENTION = /\[\[(\.?[\p{L}\p{N}_-]+)\]\]/u;
1443
+ export const nodeFromPrompt = (prompt) => prompt.match(MENTION)?.[1] ?? null;
1444
+ export function composeCommandPrompt(raw, presets, specs) {
1445
+ const match = raw.match(/^\/(\S+)\s*([\s\S]*)$/);
1446
+ if (!match)
1447
+ return raw;
1448
+ const preset = presets.find((p) => p.name === match[1]);
1449
+ if (!preset)
1450
+ return raw;
1451
+ const ids = [];
1452
+ const allMentions = new RegExp(MENTION.source, 'gu');
1453
+ const free = match[2].replace(allMentions, (_, id) => { ids.push(id); return ''; }).trim();
1454
+ const targets = ids.length
1455
+ ? ids.map((id) => {
1456
+ const spec = specs.find((s) => s.id === id);
1457
+ const path = spec?.path.replace(/^\.spec\//, '').replace(/\/spec\.md$/, '');
1458
+ return path ? `- [[${id}]] — ${path}` : `- [[${id}]]`;
1459
+ }).join('\n')
1460
+ : '(No target was mentioned. If the prompt names the scope, use it; otherwise ask the human to define the scope before proceeding — unless this task needs no scope, in which case proceed.)';
1461
+ const body = preset.body.includes('{{targets}}')
1462
+ ? preset.body.replace('{{targets}}', targets)
1463
+ : ids.length ? `${preset.body}\n\n${targets}` : preset.body;
1464
+ return free ? `${body}\n\n${free}` : body;
1465
+ }
1466
+ // Load only the one live preset named by the raw invocation. Both session creation and sendText call this seam, so
1467
+ // launch and an existing session's inbox resolve identical plugin data with identical target semantics.
1468
+ export async function resolveCommandPrompt(raw, loadedSpecs) {
1469
+ const commandName = raw.match(/^\/(\S+)/)?.[1];
1470
+ const preset = commandName ? loadConfig().find((p) => p.name === commandName) : undefined;
1471
+ if (!preset)
1472
+ return raw;
1473
+ const specs = loadedSpecs ?? (nodeFromPrompt(raw) ? await loadSpecs() : []);
1474
+ return composeCommandPrompt(raw, [preset], specs);
1475
+ }
1476
+ // @@@ composeSessionPrompt - the ONE prompt-delivery seam: raw caller text + target session become the
1477
+ // exact text handed to an adapter. Launch, ordinary input, CLI send, issue dispatch, watch greetings, and
1478
+ // merge all enter here (directly or through sendText). `replyVia` is target readability: an explicit note
1479
+ // request wins; otherwise a headless adapter defaults to note. This function alone decides and appends the
1480
+ // note/terminal inserts, so clients never own the policy or duplicate the phrase.
1481
+ export async function composeSessionPrompt(raw, target, opts = {}) {
1482
+ const resolved = await resolveCommandPrompt(raw, opts.loadedSpecs);
1483
+ const prompt = opts.suffix ? `${resolved}${opts.suffix}` : resolved;
1484
+ const h = harnessById(target.harness || defaultHarness.id);
1485
+ const replyVia = opts.replyVia ?? (h.headless ? 'note' : undefined);
1486
+ const text = replyVia === 'note' ? withNoteReplyHint(prompt)
1487
+ : !opts.from && lastHumanSendVia(target.session) === 'note' ? withTerminalReplyHint(prompt) : prompt;
1488
+ return { text: optionSafe(text), ...(replyVia ? { replyVia } : {}) };
1489
+ }
1490
+ const optionSafe = (text) => text.startsWith('-') ? ` ${text}` : text;
1491
+ const UUID_TOKEN = /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/g;
1492
+ const stripIdentityTokens = (s) => s.replace(/(^|\s)@[\p{L}\p{N}_-]+/gu, '$1').replace(UUID_TOKEN, ' ');
1493
+ export function titleFromPrompt(prompt) {
1494
+ const first = stripIdentityTokens(prompt || '').split('\n').map((l) => l.trim()).find(Boolean) || '';
1495
+ const words = first.split(/\s+/).filter(Boolean).slice(0, 7).join(' ');
1496
+ if (!words)
1497
+ return null;
1498
+ return words.length > 50 ? words.slice(0, 49).trimEnd() + '…' : words;
1499
+ }
1500
+ // @@@ launchScript - the WHOLE launch invocation (rendezvous env prefix + harness command + the human prompt)
1501
+ // is written to an ephemeral `launch.sh` in the session's GLOBAL store and
1502
+ // run via `bash <file>`, NOT typed inline. Inline send-keys TRUNCATES past ~2KB (the launch-prompt-limit trap),
1503
+ // and a long human prompt + spec pointer can exceed it; a file has no length limit
1504
+ // and the only thing send-keys types is the short `bash <file>` line. It's the SAME command the inline path
1505
+ // ran (env prefix exports the rendezvous vars to the claude child), just relocated to a file. Liveness no
1506
+ // longer cares what the pane's foreground command is: claude runs as a child of bash (and, via the
1507
+ // `reclaude` wrapper, a grandchild), so the pane command is the wrapper/shell — reconcile reads claude's
1508
+ // rendezvous socket instead (present while claude is alive, gone once it exits). The file lives OUTSIDE the
1509
+ // worktree (in the store, keyed by session_id), so it never pollutes the spec/code work.
1510
+ // the launch command for THIS session ([[launcher-select]] resume-launcher-pin): the RESOLVED base command
1511
+ // PINNED on the record at creation wins — so a (re)launch replays the EXACT launcher that made the conversation
1512
+ // (and its config-dir env), never re-resolving against a since-changed default that would send `--resume` to the
1513
+ // wrong config dir and lose the transcript. Fall back to the named-launcher resolution (an old record with a
1514
+ // launcher name but no pinned cmd; fail-loud on a since-removed launcher), then undefined (truly old record →
1515
+ // the harness adapter's ambient resolution, best-effort).
1516
+ export function launcherCmd(rec) {
1517
+ if (rec.launchCmd)
1518
+ return rec.launchCmd;
1519
+ return rec.launcher ? resolveLauncher(rec.launcher).cmd : undefined;
1520
+ }
1521
+ // does this ref resolve to a commit? Through git.ts's git() so a hook's exported GIT_DIR can't misdirect
1522
+ // discovery. `--verify --quiet` answers a MISSING ref with a bare non-zero exit and no stderr, while a broken
1523
+ // repo/timeout writes stderr — so only the silent failure is read as absence. A probe that could not answer
1524
+ // reads as "exists": the preflight refuses on a PROVEN absence, never on a failed probe ([[state]]'s board
1525
+ // honesty rule applied to launch).
1526
+ function refExists(cwd, ref) {
1527
+ try {
1528
+ return !!git(['-C', cwd, 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`]).trim();
1529
+ }
1530
+ catch (e) {
1531
+ return String(e?.stderr ?? '').trim() !== '';
1532
+ }
1533
+ }
1534
+ export function launchPreflight(rec) {
1535
+ if (!rec.worktreePath || !existsSync(rec.worktreePath))
1536
+ return { code: 'no-worktree', message: `session ${rec.session.slice(0, 8)}: its worktree ${rec.worktreePath || '(unrecorded)'} does not exist — there is nothing to launch in. If the work merged and the worktree was removed, close the session; otherwise restore the worktree first.` };
1537
+ if (rec.branch && !refExists(rec.worktreePath, rec.branch))
1538
+ return { code: 'no-branch', message: `session ${rec.session.slice(0, 8)}: its branch ${rec.branch} no longer exists — a relaunch would put the agent on a detached or wrong ref. Restore the branch, or close the session.` };
1539
+ let cmd;
1540
+ try {
1541
+ cmd = launcherCmd(rec);
1542
+ }
1543
+ catch (e) {
1544
+ return { code: 'no-launcher', message: `session ${rec.session.slice(0, 8)}: its launcher cannot be resolved — ${e instanceof Error ? e.message : e}` };
1545
+ }
1546
+ // only an ABSOLUTE command is checkable here; a bare name is the shell's PATH lookup at launch time, and
1547
+ // guessing at it would refuse launches that work. Certainty is the whole point of a preflight.
1548
+ const bin = (cmd ?? '').trim().split(/\s+/)[0];
1549
+ if (bin && isAbsolute(bin) && !existsSync(bin))
1550
+ return { code: 'no-launcher', message: `session ${rec.session.slice(0, 8)}: its pinned launcher command ${bin} is not on this machine — every launch of it will fail until the path is restored or the session is re-dispatched under a launcher that exists.` };
1551
+ return null;
1552
+ }
1553
+ // @@@ launch quoting - single-quote a string for a POSIX shell, `'` → `'\''`. Used to nest the whole agent
1554
+ // invocation inside the birth-registration `sh -c '…'` wrapper without any segment double-expanding.
1555
+ export function launchScript(id, tail, harness = HARNESS, cmd) {
1556
+ const file = join(storeDir(id), 'launch.sh');
1557
+ // NO --append-system-prompt / --settings: the contract + hooks are materialized into the worktree at
1558
+ // createSession ([[harness-delivery]]) and the agent auto-discovers them — the SAME path as a self-launched
1559
+ // agent. The launch line is just the rendezvous env + the harness command + the session-id/spec-pointer/prompt tail.
1560
+ // `cmd` is the session's persisted launcher command ([[launcher-select]]); when set it OVERRIDES the harness's
1561
+ // ambient default so resume reuses the same auth. Undefined is only for old records before launch_cmd existed.
1562
+ const invocation = `${rvEnv(id, harness)} ${harness.launchCmd(id, runtimeRoot(), cmd)} ${tail}`;
1563
+ // @@@ birth registration - record the AGENT's real pid BEFORE exec, the anchor of the 100ms hot death tier
1564
+ // ([[state]]). Each attempt runs `sh -c '<pid-write>; exec env <invocation>'`: the sh writes its own `$$` to
1565
+ // agent.pid, then `exec env` REPLACES that sh in place — so the pid persists down the whole command chain
1566
+ // (claude: env→(reclaude→)claude; codex: env→bash -lc <script> whose last line is `exec codex … resume`), and
1567
+ // `$$` therefore IS the launched agent's pid. `env` carries the leading `VAR=val` assignments (an env prefix
1568
+ // can't lead an `exec`), and the whole payload is single-quoted for the outer shell (shQuote) so the
1569
+ // invocation's own single-quoted segments — the codex `$@`/`$tid` script, the prompt — reach sh verbatim,
1570
+ // parsed exactly ONCE, never double-expanded. Each retry attempt rewrites agent.pid with a fresh `$$`.
1571
+ const pidPath = join(storeDir(id), 'agent.pid');
1572
+ const receiptPath = join(storeDir(id), 'agent.identity.json');
1573
+ const born = `sh -c ${shQuote(`rm -f ${shQuote(receiptPath)}; printf %s "$$" > ${shQuote(pidPath)}; exec env ${invocation}`)}`;
1574
+ // Bounded relaunch on a FAST exit: the agent launcher can exit within seconds before the rendezvous socket
1575
+ // ever appears. That is enough evidence to retry, but not enough evidence to name the cause. Once the agent
1576
+ // has run past LAUNCH_FAST_FAIL_S it has genuinely started; its eventual (much later) exit is a normal
1577
+ // session end and is NEVER retried — the loop exits. BOOT_GRACE_MS and SOCKET_READY_TIMEOUT_MS both span this
1578
+ // retry window, so liveness stays 'starting' and waitForReady keeps holding the slot across retries. This
1579
+ // only closes startup unready failures — it adds no fallback and never masks a genuinely dead agent (3
1580
+ // attempts, then give up).
1581
+ // A one-shot adapter (currently codex-headless) deliberately exits after its first turn while the shared
1582
+ // app-server stays alive. Retrying that successful fast exit would mint a duplicate thread/prompt, so the
1583
+ // retry loop is a runtime capability rather than a harness-id branch.
1584
+ // @@@ retry only what retrying can fix - a fast exit says the launcher stopped before readiness, which is
1585
+ // reason enough to try again but never a diagnosis. So after a fast exit the script reads what the harness
1586
+ // actually SAID and matches it against the ADAPTER's own settled-failure patterns ([[harness-adapter]]
1587
+ // fatalLaunchOutput). A match means this command cannot succeed however many times we run it: stop at one
1588
+ // attempt and let the harness's own line be the last thing on the pane, instead of spending a certain failure
1589
+ // three times and burying the reason. No match keeps the plain bounded retry.
1590
+ //
1591
+ // It reads the PANE, not the agent's streams. Capturing stderr through a pipe missed the answer entirely —
1592
+ // measured against real reclaude, "No conversation found with session ID" arrives on STDOUT, so a
1593
+ // stderr-only capture classified nothing and retried a certain failure three times (the unit test passed
1594
+ // only because its stub printed to the stream the implementation happened to watch). Redirecting stdout too
1595
+ // would be worse: a TUI that finds stdout is not a terminal stops being a TUI. The pane already holds both
1596
+ // streams exactly as the human sees them, and the script runs inside that pane — so it just asks tmux.
1597
+ const fatal = (harness.fatalLaunchOutput ?? []).join('|');
1598
+ const launchBody = harness.launchOneShot ? [born, ''] : [
1599
+ `for __spex_try in 1 2 3; do`,
1600
+ ` __spex_t0=$SECONDS`,
1601
+ // @@@ classify THIS attempt only - the pane is a scrollback, so it also holds every earlier attempt and
1602
+ // every earlier launch that ever ran in this window. Matching the whole capture would let a stale
1603
+ // settled-failure line from minutes ago condemn an unrelated fast exit and cut a launch that retrying
1604
+ // WOULD have recovered — the exact mirror of the miss this classifier exists to fix. So each attempt
1605
+ // stamps a line unique to (this run, this attempt) and the match starts after it. The run's pid is what
1606
+ // makes it unique across relaunches, which reuse the session id.
1607
+ ` __spex_mark="attempt $__spex_try start $$"`,
1608
+ ` printf '[spex launch] %s\\n' "$__spex_mark"`,
1609
+ ` ${born}`,
1610
+ ` __spex_rc=$?`,
1611
+ ` [ $(( SECONDS - __spex_t0 )) -ge ${LAUNCH_FAST_FAIL_S} ] && exit $__spex_rc`,
1612
+ ...(fatal ? [
1613
+ // -t "$TMUX_PANE" names THIS pane explicitly (tmux still resolves the server from $TMUX), so the capture
1614
+ // can never land on a neighbouring pane; run outside tmux the call fails, nothing matches, and the plain
1615
+ // bounded retry stands.
1616
+ ` if tmux capture-pane -p -S -400 -t "\${TMUX_PANE:-}" 2>/dev/null | sed -n "/$__spex_mark/,\\$p" | grep -Eq ${shQuote(fatal)}; then`,
1617
+ ` printf '[spex launch] attempt %s exited in %ss (rc=%s) - the launcher reported a failure retrying cannot fix (see above); not retrying\\n' "$__spex_try" "$(( SECONDS - __spex_t0 ))" "$__spex_rc" >&2`,
1618
+ ` exit $__spex_rc`,
1619
+ ` fi`,
1620
+ ] : []),
1621
+ ` printf '[spex launch] attempt %s exited in %ss (rc=%s) - fast launcher exit before readiness; retrying\\n' "$__spex_try" "$(( SECONDS - __spex_t0 ))" "$__spex_rc" >&2`,
1622
+ ` sleep 2`,
1623
+ `done`,
1624
+ `exit $__spex_rc`,
1625
+ ``,
1626
+ ];
1627
+ writeFileSync(file, launchBody.join('\n'));
1628
+ return file;
1629
+ }
1630
+ async function launch(id, path, tail, harness = HARNESS, cmd) {
1631
+ // record the transport path THIS runtime hands the agent, before anything reads it (launchScript bakes it
1632
+ // into the launch env). Same kind of launch-time fact as agent.pid, and the reason a session's socket is
1633
+ // reachable only from the world it belongs to ([[harness-adapter]] rendezvous socket).
1634
+ if (harness.ownsRendezvous)
1635
+ stampRvSock(id);
1636
+ await tmux(['new-session', '-d', '-s', id, '-x', String(COLS), '-y', String(ROWS), '-c', path]);
1637
+ await tmux(['send-keys', '-t', id, '-l', '--', `bash ${launchScript(id, tail, harness, cmd)}`]);
1638
+ await tmux(['send-keys', '-t', id, 'Enter']);
1639
+ launchedAt.set(id, Date.now()); // stamp the boot window so reconcile reads 'starting', not 'offline', until the socket is up
1640
+ }
1641
+ const OCCUPIES_SLOT = new Set(['working', 'parked', 'starting']); // starting's boot window is also held via `launching`
1642
+ function isOccupying(s, snap) {
1643
+ if (!OCCUPIES_SLOT.has(s.status))
1644
+ return false; // waiting-on-human / proposed / queued / dead → free
1645
+ const rec = readRecord(s.id);
1646
+ if (!rec)
1647
+ return false;
1648
+ return harnessById(rec.harness || defaultHarness.id).liveness(rec, snap.windows.has(rec.session), runtimeRoot(), snap.windows.get(rec.session), snap.sockets.has(rec.session)) === 'online';
1649
+ }
1650
+ // sessions we've JUST launched whose agent hasn't come online yet. During that boot window reconcile reads them
1651
+ // `offline` (the adapter's online-signal not up yet) and isOccupying would miss them, so the drainer would
1652
+ // over-launch and blow past the cap. We hold the slot here from launch until the agent is online (waitForReady)
1653
+ // or it times out.
1654
+ // In-memory in the single server process (the only drainer) — lost on restart, which is fine: a restart drains
1655
+ // the durable `queued` worktrees fresh with nothing in flight.
1656
+ const launching = new Set();
1657
+ // A queued launch and a cold archive must not cross between their final read and record write. This is a
1658
+ // narrow per-session intent latch, not a second cleanup primitive; the launch path simply leaves an archiving id
1659
+ // alone and the archive path re-probes before filing.
1660
+ const archiving = new Set();
1661
+ const transitionTails = new Map();
1662
+ async function withSessionTransition(id, body) {
1663
+ const previous = transitionTails.get(id) ?? Promise.resolve();
1664
+ let release;
1665
+ const current = new Promise((resolve) => { release = resolve; });
1666
+ transitionTails.set(id, current);
1667
+ await previous;
1668
+ try {
1669
+ return await body();
1670
+ }
1671
+ finally {
1672
+ release();
1673
+ if (transitionTails.get(id) === current)
1674
+ transitionTails.delete(id);
1675
+ }
1676
+ }
1677
+ let draining = false; // re-entrancy guard: only one drain pass runs at a time (no double-launch)
1678
+ function noteQueuedLaunchFailureUnlocked(id, error) {
1679
+ const reason = error instanceof Error ? error.message : String(error);
1680
+ const note = `queued launch readiness failed: ${reason}`;
1681
+ console.error(`spex: session ${id}: ${note}`);
1682
+ const rec = readRecord(id);
1683
+ if (rec && rec.note !== note)
1684
+ writeRecord({ ...rec, note });
1685
+ }
1686
+ function observeQueuedLaunchReadiness(id, harness) {
1687
+ void waitForReady(id, harness)
1688
+ .then(async (readiness) => {
1689
+ if (!readiness) {
1690
+ const committed = !!readRecord(id)?.harnessSessionId;
1691
+ throw new ResourceConflict(harness.launchPayloadProof
1692
+ ? committed
1693
+ ? 'post-proof adapter liveness did not become ready before launch readiness timed out'
1694
+ : 'native identity and first-turn rollout proof did not arrive before launch readiness timed out'
1695
+ : 'adapter liveness did not become ready before launch readiness timed out');
1696
+ }
1697
+ let readyToPublish = false;
1698
+ await withRecordLock(id, async () => {
1699
+ const candidate = readRecord(id);
1700
+ if (!candidate)
1701
+ return;
1702
+ const stillReady = await readiness.validate(() => {
1703
+ const current = readRecord(id);
1704
+ return current ? { ...current, runtimeDir: runtimeRoot() } : null;
1705
+ });
1706
+ if (!stillReady)
1707
+ throw new ResourceConflict('launch readiness changed before queued publication');
1708
+ const current = readRecord(id);
1709
+ if (!current)
1710
+ return;
1711
+ if (current.status === 'queued')
1712
+ writeRecord({ ...current, status: 'active', proposal: null, note: null, launchOwner: null });
1713
+ readyToPublish = true;
1714
+ });
1715
+ if (!readyToPublish)
1716
+ return;
1717
+ await deliverPendingWatchSnapshots(id);
1718
+ await drainSession(id);
1719
+ })
1720
+ .catch(async (error) => {
1721
+ try {
1722
+ await withRecordLock(id, async () => noteQueuedLaunchFailureUnlocked(id, error));
1723
+ }
1724
+ catch (recordError) {
1725
+ console.error(`spex: session ${id}: queued launch failure could not be recorded: ${recordError instanceof Error ? recordError.message : String(recordError)}; original failure: ${error instanceof Error ? error.message : String(error)}`);
1726
+ }
1727
+ await clearPendingWatchSnapshots(id);
1728
+ })
1729
+ .finally(() => launching.delete(id));
1730
+ }
1731
+ // Launch a prepared `queued` worktree. Deterministic blockers retire its creation snapshot debt; a transport
1732
+ // attempt that may succeed on the next drain keeps that durable debt for the eventual real outcome.
1733
+ async function startQueuedUnlocked(id) {
1734
+ if (archiving.has(id))
1735
+ return 'retryable';
1736
+ const wt = await findWorktree(id);
1737
+ if (!wt)
1738
+ return 'blocked';
1739
+ if (archiving.has(id))
1740
+ return 'retryable';
1741
+ if (wt.rec.archived)
1742
+ return 'blocked';
1743
+ if (!canDrainQueued(wt.rec))
1744
+ return 'retryable';
1745
+ const h = harnessById(wt.rec.harness || defaultHarness.id);
1746
+ if (h.launchPayloadProof && existsSync(sessionArtifactPath(id, 'launch.proof'))) {
1747
+ launching.add(id);
1748
+ let readinessOwnsSlot = false;
1749
+ try {
1750
+ try {
1751
+ consumeHarnessLaunchProofUnlocked(id);
1752
+ }
1753
+ catch (error) {
1754
+ noteQueuedLaunchFailureUnlocked(id, error);
1755
+ throw error;
1756
+ }
1757
+ const recovered = readRecord(id) || wt.rec;
1758
+ writeRecord({ ...recovered, status: 'active', proposal: null, note: null, launchOwner: null });
1759
+ observeQueuedLaunchReadiness(id, h);
1760
+ readinessOwnsSlot = true;
1761
+ return 'started';
1762
+ }
1763
+ finally {
1764
+ if (!readinessOwnsSlot)
1765
+ launching.delete(id);
1766
+ }
1767
+ }
1768
+ const launchPrompt = readLaunchFile(id);
1769
+ if (launchPrompt == null) {
1770
+ const message = `authoritative resolved launch payload is missing for queued session ${id}; refusing to create an empty thread`;
1771
+ if (wt.rec.note !== message) {
1772
+ console.error(`spex: ${message}`);
1773
+ writeRecord({ ...wt.rec, note: message });
1774
+ }
1775
+ return 'blocked';
1776
+ }
1777
+ // a queued worktree can go missing while it waits (a human cleaned up, a disk moved). Draining it would open
1778
+ // a window that fast-exits and burn the retry budget every tick, so refuse ONCE, loudly, and stamp the reason
1779
+ // on the record — the drainer then leaves it alone instead of spinning on a launch that cannot work.
1780
+ const blocked = launchPreflight(wt.rec);
1781
+ if (blocked) {
1782
+ if (wt.rec.note !== blocked.message) {
1783
+ console.error(`spex: not launching queued session ${id}: ${blocked.message}`);
1784
+ writeRecord({ ...wt.rec, note: blocked.message });
1785
+ }
1786
+ return 'blocked';
1787
+ }
1788
+ launching.add(id); // hold the slot across the boot window BEFORE we launch, so a concurrent count can't race us
1789
+ let readinessOwnsSlot = false;
1790
+ try {
1791
+ try {
1792
+ const sq = shQuote(launchPrompt);
1793
+ await launch(id, wt.path, `${h.sessionIdArg(id)} ${sq}`.trim(), h, launcherCmd(wt.rec));
1794
+ }
1795
+ catch {
1796
+ return 'retryable'; // launch failed → stays `queued`, with its initial debt, for the next drain tick
1797
+ }
1798
+ // the note this record may carry is the QUEUED state's word (a launch-blocker message stamped above); the
1799
+ // launch just succeeded, so it is spent. Clearing it with the transition is what keeps "a stored note
1800
+ // belongs to the state currently declared" true for every writer — the invariant [[session-label]]'s
1801
+ // headline precedence stands on.
1802
+ const launched = readRecord(id) || wt.rec;
1803
+ writeRecord({ ...launched, status: 'active', proposal: null, note: null, launchOwner: null });
1804
+ if (!h.launchPayloadProof)
1805
+ removeLaunchFile(id);
1806
+ // release the boot-window hold once the socket is up (then isOccupying takes over) or after the bounded
1807
+ // wait — so a launch that never booted reads offline and the drainer reclaims the slot instead of pinning it.
1808
+ observeQueuedLaunchReadiness(id, h);
1809
+ readinessOwnsSlot = true;
1810
+ return 'started';
1811
+ }
1812
+ finally {
1813
+ if (!readinessOwnsSlot)
1814
+ launching.delete(id);
1815
+ }
1816
+ }
1817
+ const startQueued = (id) => withSessionTransition(id, () => withRecordLock(id, () => startQueuedUnlocked(id)));
1818
+ async function drainQueueUnlocked() {
1819
+ if (draining)
1820
+ return;
1821
+ draining = true;
1822
+ try {
1823
+ const cap = maxActive(); // read once per drain pass (spexcode.json → env → default); won't shift mid-burst
1824
+ for (;;) {
1825
+ const [sessions, snap] = await Promise.all([listSessions(), liveSnapshot()]);
1826
+ for (const session of sessions) {
1827
+ const rec = readRecord(session.id);
1828
+ if (!rec || launching.has(session.id) || !readWatchEntries(session.id).some((entry) => entry.snapshotPending))
1829
+ continue;
1830
+ if (rec.status === 'queued')
1831
+ continue;
1832
+ if (rec.status === 'active' && !rec.stopped && !rec.archived) {
1833
+ launching.add(session.id);
1834
+ observeQueuedLaunchReadiness(session.id, harnessById(rec.harness || defaultHarness.id));
1835
+ continue;
1836
+ }
1837
+ await deliverPendingWatchSnapshots(session.id, false);
1838
+ }
1839
+ // if the liveness probe FAILED (tmux timing out — the overload condition), occupancy is UNKNOWABLE: every
1840
+ // session would read window-less and isOccupying would undercount, so the drainer would OVER-launch and pile
1841
+ // MORE compute onto an already-thrashing box. Under load, do the safe thing — launch nothing this pass and
1842
+ // let the next tick re-drain once the probe recovers ([[state]] board honesty applied to the cap).
1843
+ if (snap.probeFailed)
1844
+ break;
1845
+ const occupied = sessions.reduce((n, s) => n + (launching.has(s.id) || isOccupying(s, snap) ? 1 : 0), 0);
1846
+ if (occupied >= cap) {
1847
+ const authority = backendLaunchAuthority();
1848
+ await Promise.all(sessions.filter((session) => {
1849
+ if (session.status !== 'queued')
1850
+ return false;
1851
+ const rec = readRecord(session.id);
1852
+ return !!rec && canDrainQueued(rec, authority);
1853
+ }).map((session) => deliverPendingWatchSnapshots(session.id)));
1854
+ break;
1855
+ }
1856
+ const authority = backendLaunchAuthority();
1857
+ const next = sessions.find((s) => {
1858
+ if (s.status !== 'queued' || launching.has(s.id))
1859
+ return false;
1860
+ const rec = readRecord(s.id);
1861
+ return !!rec && canDrainQueued(rec, authority);
1862
+ });
1863
+ if (!next)
1864
+ break;
1865
+ const started = await startQueued(next.id);
1866
+ if (started !== 'started') {
1867
+ if (started === 'blocked')
1868
+ await clearPendingWatchSnapshots(next.id);
1869
+ break; // launch failed → stop this pass; a later tick retries
1870
+ }
1871
+ }
1872
+ }
1873
+ finally {
1874
+ draining = false;
1875
+ }
1876
+ }
1877
+ export const drainQueue = () => drainQueueUnlocked();
1878
+ const requestQueueDrain = () => {
1879
+ void drainQueue().catch((error) => {
1880
+ console.error(`spex: queue drain failed: ${error instanceof Error ? error.message : String(error)}`);
1881
+ });
1882
+ };
1883
+ let supervisingQueue = false;
1884
+ export function superviseQueue(intervalMs = 3000) {
1885
+ if (supervisingQueue)
1886
+ return;
1887
+ supervisingQueue = true;
1888
+ const tick = async () => {
1889
+ try {
1890
+ await drainQueue();
1891
+ }
1892
+ catch { /* transient git/tmux hiccup; next tick retries */ }
1893
+ setTimeout(tick, intervalMs);
1894
+ };
1895
+ void tick();
1896
+ }
1897
+ let supervisingDelivery = false;
1898
+ // @@@ superviseDelivery - the RETRY half of [[delivery-queue]]. `sendText` hands over in its own process, which
1899
+ // covers the live case; this covers everything that could not be handed over then — a harness mid-restart, a
1900
+ // pane in the one state that swallows prompts, a session that was offline when the message arrived. Owned by
1901
+ // the serve that serves this project root, so a message owed to a worker is delivered when the worker can take
1902
+ // it rather than when it happens to run a tool. A tick with nothing owed is one existsSync per session, and
1903
+ // concurrent serves are harmless: the queue's lock, not the process, is what makes a handover exactly-once.
1904
+ export function superviseDelivery(intervalMs = 2000) {
1905
+ if (supervisingDelivery)
1906
+ return;
1907
+ supervisingDelivery = true;
1908
+ const tick = async () => {
1909
+ try {
1910
+ for (const id of listSessionIds()) {
1911
+ if (!owesDelivery(id))
1912
+ continue;
1913
+ try {
1914
+ await drainSession(id);
1915
+ }
1916
+ catch { /* an adapter that refused stays owed; next tick retries */ }
1917
+ }
1918
+ }
1919
+ catch { /* transient store read; next tick retries */ }
1920
+ setTimeout(tick, intervalMs).unref();
1921
+ };
1922
+ void tick();
1923
+ }
1924
+ const turnFailureObservers = new Map();
1925
+ let supervisingTurnFailures = false;
1926
+ const TURN_FAILURE_OBSERVER_STABLE_MS = 5000;
1927
+ export function turnFailureNote(harness, failure) {
1928
+ const message = failure.message.replace(/\s+/g, ' ').trim().slice(0, 500) || 'turn failed';
1929
+ const at = failure.completedAt == null ? '' : ` at ${new Date(failure.completedAt * 1000).toISOString()}`;
1930
+ return `${harness} turn failed${at}: ${message}`;
1931
+ }
1932
+ export function turnFailureRetryDelay(failures) {
1933
+ return Math.min(30_000, 1000 * 2 ** Math.max(0, Math.min(failures - 1, 5)));
1934
+ }
1935
+ function deferTurnFailureObserver(id, harness, state, reason) {
1936
+ state.subscription = null;
1937
+ state.failures++;
1938
+ const delay = turnFailureRetryDelay(state.failures);
1939
+ state.retryAt = Date.now() + delay;
1940
+ if (state.lastReason !== reason)
1941
+ console.warn(`[spex ${harness}] turn failure observer for ${id} disconnected (${reason}); retrying in ${delay}ms`);
1942
+ state.lastReason = reason;
1943
+ }
1944
+ // Reconcile one adapter-owned native failure subscription per live governed session. Product code knows only
1945
+ // the optional interface capability; Codex owns WebSocket/thread semantics and Claude keeps using StopFailure.
1946
+ export function reconcileTurnFailureObservers() {
1947
+ const wanted = new Map();
1948
+ for (const id of listSessionIds()) {
1949
+ let rec = null;
1950
+ try {
1951
+ rec = readRecord(id);
1952
+ }
1953
+ catch {
1954
+ continue;
1955
+ }
1956
+ if (!rec?.governed || rec.stopped || rec.archived || !rec.harnessSessionId)
1957
+ continue;
1958
+ const harness = harnessById(rec.harness || defaultHarness.id);
1959
+ if (!harness.observeTurnFailures)
1960
+ continue;
1961
+ wanted.set(id, { rec, harness, fingerprint: `${harness.id}:${rec.harnessSessionId}:${runtimeRoot()}` });
1962
+ }
1963
+ for (const [id, state] of turnFailureObservers) {
1964
+ if (wanted.get(id)?.fingerprint === state.fingerprint)
1965
+ continue;
1966
+ turnFailureObservers.delete(id);
1967
+ state.subscription?.close();
1968
+ }
1969
+ for (const [id, target] of wanted) {
1970
+ const now = Date.now();
1971
+ let state = turnFailureObservers.get(id);
1972
+ if (state?.subscription) {
1973
+ if (state.failures > 0 && now - state.startedAt >= TURN_FAILURE_OBSERVER_STABLE_MS) {
1974
+ state.failures = 0;
1975
+ state.retryAt = 0;
1976
+ state.lastReason = null;
1977
+ }
1978
+ continue;
1979
+ }
1980
+ if (state && now < state.retryAt)
1981
+ continue;
1982
+ state ??= { fingerprint: target.fingerprint, subscription: null, startedAt: 0, failures: 0, retryAt: 0, lastReason: null };
1983
+ state.startedAt = now;
1984
+ turnFailureObservers.set(id, state);
1985
+ try {
1986
+ const subscription = target.harness.observeTurnFailures({
1987
+ session: id,
1988
+ worktreePath: target.rec.worktreePath,
1989
+ harnessSessionId: target.rec.harnessSessionId,
1990
+ runtimeDir: runtimeRoot(),
1991
+ launchCmd: target.rec.launchCmd,
1992
+ }, (failure) => {
1993
+ if (turnFailureObservers.get(id)?.fingerprint !== target.fingerprint)
1994
+ return;
1995
+ try {
1996
+ markTurnFailure(id, turnFailureNote(target.harness.id, failure));
1997
+ }
1998
+ catch (error) {
1999
+ console.error(`[spex ${target.harness.id}] could not record native turn failure for ${id}: ${error instanceof Error ? error.message : String(error)}`);
2000
+ }
2001
+ });
2002
+ state.subscription = subscription;
2003
+ void subscription.closed.then((reason) => {
2004
+ if (turnFailureObservers.get(id) !== state)
2005
+ return;
2006
+ if (reason)
2007
+ deferTurnFailureObserver(id, target.harness.id, state, reason);
2008
+ else
2009
+ turnFailureObservers.delete(id);
2010
+ });
2011
+ }
2012
+ catch (error) {
2013
+ deferTurnFailureObserver(id, target.harness.id, state, error instanceof Error ? error.message : String(error));
2014
+ }
2015
+ }
2016
+ }
2017
+ export function superviseTurnFailures(intervalMs = 1000) {
2018
+ if (supervisingTurnFailures)
2019
+ return;
2020
+ supervisingTurnFailures = true;
2021
+ const tick = () => {
2022
+ try {
2023
+ reconcileTurnFailureObservers();
2024
+ }
2025
+ catch (error) {
2026
+ console.error(`spex: turn failure reconciliation failed: ${error instanceof Error ? error.message : String(error)}`);
2027
+ }
2028
+ const timer = setTimeout(tick, intervalMs);
2029
+ timer.unref?.();
2030
+ };
2031
+ tick();
2032
+ }
2033
+ function assertProjectRootMatch(verb, target, servedRoot) {
2034
+ const { url, source } = target;
2035
+ if (source === 'flag')
2036
+ return; // explicitly routed — the caller named the target
2037
+ let localMain;
2038
+ try {
2039
+ localMain = realpathSync(mainRoot());
2040
+ }
2041
+ catch {
2042
+ return;
2043
+ } // caller not in a repo → can't prove a mismatch
2044
+ if (!servedRoot || !isAbsolute(servedRoot))
2045
+ return; // unknown / config-aliased root → don't risk a false refusal
2046
+ let backendMain;
2047
+ try {
2048
+ backendMain = realpathSync(servedRoot);
2049
+ }
2050
+ catch {
2051
+ return;
2052
+ } // backend root not a local path → a remote backend, allow
2053
+ if (backendMain !== localMain) {
2054
+ const e = new Error(`${verb}: refusing WRITE — cwd is in ${localMain} but the backend at ${url} serves ${backendMain}.\n` +
2055
+ `Name the target explicitly (--api <url> / --port <n>) to write cross-project on purpose,\n` +
2056
+ `or run this project's own backend: cd ${localMain} && spex serve. (Reads stay unguarded.)`);
2057
+ e.name = 'GuardError';
2058
+ throw e;
2059
+ }
2060
+ }
2061
+ function assertProjectSettingsMatch(verb, target, settings) {
2062
+ assertProjectRootMatch(verb, target, settings?.layout?.main ?? null);
2063
+ }
2064
+ function assertProjectInstanceMatch(verb, target, instance) {
2065
+ const root = instance?.root;
2066
+ if (typeof root !== 'string' || !isAbsolute(root))
2067
+ return;
2068
+ let servedMain;
2069
+ try {
2070
+ servedMain = mainRoot(root);
2071
+ }
2072
+ catch {
2073
+ return;
2074
+ }
2075
+ assertProjectRootMatch(verb, target, servedMain);
2076
+ }
2077
+ export async function assertProjectMatch(verb) {
2078
+ const target = await apiBaseInfo();
2079
+ if (target.source === 'flag')
2080
+ return;
2081
+ let settings = null;
2082
+ try {
2083
+ const r = await fetch(`${target.url}/api/settings`);
2084
+ if (r.ok)
2085
+ settings = await r.json();
2086
+ }
2087
+ catch {
2088
+ return;
2089
+ } // backend unreachable → the write itself surfaces it (fail-loud there)
2090
+ assertProjectSettingsMatch(verb, target, settings);
2091
+ }
2092
+ export class SessionCreateError extends Error {
2093
+ code;
2094
+ phase;
2095
+ status;
2096
+ constructor(code, phase, message, status) {
2097
+ super(message);
2098
+ this.code = code;
2099
+ this.phase = phase;
2100
+ this.status = status;
2101
+ this.name = 'SessionCreateError';
2102
+ }
2103
+ }
2104
+ const DEFAULT_CREATE_TIMEOUT_MS = 30_000;
2105
+ export function sessionCreateTimeoutMs(env = process.env) {
2106
+ const configured = Number(env.SPEXCODE_SESSION_CREATE_TIMEOUT_MS);
2107
+ return Number.isFinite(configured) ? Math.max(250, Math.min(120_000, Math.floor(configured))) : DEFAULT_CREATE_TIMEOUT_MS;
2108
+ }
2109
+ function normalizeCreateKey(raw) {
2110
+ const key = raw?.trim() || randomUUID();
2111
+ if (key.length > 128 || !/^[\x21-\x7e]+$/.test(key)) {
2112
+ throw new SessionCreateError('session_create_failed', 'request', 'Idempotency-Key must be 1-128 visible ASCII characters', 400);
2113
+ }
2114
+ return key;
2115
+ }
2116
+ const digest = (value) => createHash('sha256').update(value).digest('hex');
2117
+ export function sessionIdForCreateKey(key) {
2118
+ const hex = digest(`spexcode-session-create\0${key}`);
2119
+ const uuid = `${hex.slice(0, 12)}4${hex.slice(13, 16)}${((parseInt(hex[16], 16) & 3) | 8).toString(16)}${hex.slice(17)}`;
2120
+ return `${uuid.slice(0, 8)}-${uuid.slice(8, 12)}-${uuid.slice(12, 16)}-${uuid.slice(16, 20)}-${uuid.slice(20, 32)}`;
2121
+ }
2122
+ function traceSessionCreate(id, requestDigest, phase, event, detail) {
2123
+ console.error(`spex session-create ${JSON.stringify({ ts: new Date().toISOString(), request: requestDigest.slice(0, 12), session: id, phase, event, ...(detail ? { detail } : {}) })}`);
2124
+ }
2125
+ function createAbortError(signal, phase) {
2126
+ const timedOut = signal.reason instanceof SessionCreateError && signal.reason.code === 'session_create_timeout';
2127
+ return new SessionCreateError(timedOut ? 'session_create_timeout' : 'session_create_cancelled', phase, timedOut ? `session creation timed out during ${phase}` : `session creation was cancelled during ${phase}`, timedOut ? 504 : 408);
2128
+ }
2129
+ function throwIfCreateAborted(signal, phase) {
2130
+ if (signal.aborted)
2131
+ throw createAbortError(signal, phase);
2132
+ }
2133
+ // The API create boundary accepts one small, closed object shape. Unknown fields fail through this generic
2134
+ // contract before any worktree is made; removed or misspelled inputs never disappear into defaults.
2135
+ export async function sessionCreateRequest(body, options = {}) {
2136
+ if (!body || typeof body !== 'object' || Array.isArray(body))
2137
+ return { status: 400, error: 'body must be a JSON object' };
2138
+ const input = body;
2139
+ const unknown = Object.keys(input).filter((key) => !['prompt', 'parent', 'launcher', 'name', 'base'].includes(key)).sort();
2140
+ if (unknown.length)
2141
+ return { status: 400, error: `unknown session-create field${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}` };
2142
+ const prompt = typeof input.prompt === 'string' ? input.prompt : '';
2143
+ if (!prompt.trim())
2144
+ return { status: 400, error: 'empty prompt' };
2145
+ const launcher = typeof input.launcher === 'string' && input.launcher.trim() ? input.launcher.trim() : undefined;
2146
+ const parent = typeof input.parent === 'string' && input.parent.trim() ? input.parent.trim() : null;
2147
+ if (input.name !== undefined && typeof input.name !== 'string')
2148
+ return { status: 400, error: 'session-create name must be a string' };
2149
+ const name = typeof input.name === 'string' && input.name.trim() ? input.name.trim() : null;
2150
+ if (input.base !== undefined && typeof input.base !== 'string')
2151
+ return { status: 400, error: 'session-create base must be a string' };
2152
+ const base = typeof input.base === 'string' && input.base.trim() ? input.base.trim() : null;
2153
+ let key;
2154
+ try {
2155
+ key = normalizeCreateKey(options.requestKey);
2156
+ }
2157
+ catch (error) {
2158
+ const failure = error;
2159
+ return { status: failure.status, error: failure.message, code: failure.code, phase: failure.phase };
2160
+ }
2161
+ const requestDigest = digest(key);
2162
+ const id = sessionIdForCreateKey(key);
2163
+ // Keep no-name retries byte-compatible with pre-name receipts; an explicit non-empty name is one more
2164
+ // immutable creation input because it publishes the record's existing display override. `base` joins them
2165
+ // for the same reason and with the same shape: absent, it must not perturb an existing receipt's bytes.
2166
+ const payloadHash = digest(JSON.stringify({ prompt, parent, launcher: launcher ?? null, ...(name ? { name } : {}), ...(base ? { base } : {}) }));
2167
+ const controller = new AbortController();
2168
+ const cancel = () => controller.abort(new SessionCreateError('session_create_cancelled', 'request', 'session creation caller disconnected', 408));
2169
+ if (options.signal?.aborted)
2170
+ cancel();
2171
+ else
2172
+ options.signal?.addEventListener('abort', cancel, { once: true });
2173
+ const timer = setTimeout(() => controller.abort(new SessionCreateError('session_create_timeout', 'request', 'session creation exceeded its deadline', 504)), options.timeoutMs ?? sessionCreateTimeoutMs());
2174
+ timer.unref?.();
2175
+ traceSessionCreate(id, requestDigest, 'request', 'start');
2176
+ try {
2177
+ try {
2178
+ const session = await prepareSession(prompt, parent, launcher, name, { id, requestDigest, payloadHash, base, signal: controller.signal });
2179
+ traceSessionCreate(id, requestDigest, 'request', 'finish');
2180
+ return { status: 201, session };
2181
+ }
2182
+ catch (error) {
2183
+ const failure = error instanceof SessionCreateError
2184
+ ? error
2185
+ : controller.signal.aborted
2186
+ ? createAbortError(controller.signal, 'request')
2187
+ : new SessionCreateError('session_create_failed', 'request', String(error.message || error), 400);
2188
+ return { status: failure.status, error: failure.message, code: failure.code, phase: failure.phase };
2189
+ }
2190
+ }
2191
+ finally {
2192
+ clearTimeout(timer);
2193
+ options.signal?.removeEventListener('abort', cancel);
2194
+ }
2195
+ }
2196
+ function isExplicitConnectionRefused(error) {
2197
+ if (!error || typeof error !== 'object')
2198
+ return false;
2199
+ if (error.code === 'ECONNREFUSED')
2200
+ return true;
2201
+ const errors = error.errors;
2202
+ if (Array.isArray(errors))
2203
+ return errors.length > 0 && errors.every(isExplicitConnectionRefused);
2204
+ return isExplicitConnectionRefused(error.cause);
2205
+ }
2206
+ async function probeSessionCreateAuthority(target) {
2207
+ const controller = new AbortController();
2208
+ const timer = setTimeout(() => controller.abort(), 1500);
2209
+ timer.unref?.();
2210
+ let response;
2211
+ try {
2212
+ response = await fetch(`${target.url}/api/instance`, { signal: controller.signal });
2213
+ }
2214
+ catch (error) {
2215
+ clearTimeout(timer);
2216
+ if (isExplicitConnectionRefused(error))
2217
+ return true;
2218
+ const failed = new Error(`backend availability is indeterminate at ${target.url}; refusing in-process session creation (${error instanceof Error ? error.message : error})`);
2219
+ failed.name = 'BackendError';
2220
+ Object.assign(failed, { code: 'backend_availability_indeterminate', cause: error });
2221
+ throw failed;
2222
+ }
2223
+ try {
2224
+ let instance = null;
2225
+ if (response.ok) {
2226
+ try {
2227
+ instance = await response.json();
2228
+ }
2229
+ catch { /* an HTTP response already established backend authority */ }
2230
+ }
2231
+ assertProjectInstanceMatch('spex session new', target, instance);
2232
+ return false;
2233
+ }
2234
+ finally {
2235
+ clearTimeout(timer);
2236
+ }
2237
+ }
2238
+ export async function createSession(prompt, launcher, name, base) {
2239
+ const parent = ownSessionId();
2240
+ const requestKey = randomUUID();
2241
+ const body = { prompt, parent, launcher, ...(name !== undefined ? { name } : {}), ...(base !== undefined ? { base } : {}) };
2242
+ const target = await apiBaseInfo();
2243
+ const apiUrl = target.url;
2244
+ const refused = await probeSessionCreateAuthority(target);
2245
+ if (refused) {
2246
+ console.error('spex: no backend reachable — launching in-process (caller env owns auth, no concurrency cap)');
2247
+ const fallback = await sessionCreateRequest(body, { requestKey });
2248
+ if (fallback.status === 201)
2249
+ return fallback.session;
2250
+ const error = new Error(`${fallback.code || 'session_create_failed'}: ${fallback.error}`);
2251
+ error.name = 'BackendError';
2252
+ throw error;
2253
+ }
2254
+ const controller = new AbortController();
2255
+ const timer = setTimeout(() => controller.abort(new Error('backend session-create request timed out')), sessionCreateTimeoutMs() + 5_000);
2256
+ timer.unref?.();
2257
+ let res;
2258
+ try {
2259
+ res = await fetch(`${apiUrl}/api/sessions`, {
2260
+ method: 'POST',
2261
+ headers: { 'content-type': 'application/json', 'Idempotency-Key': requestKey },
2262
+ body: JSON.stringify(body),
2263
+ signal: controller.signal,
2264
+ });
2265
+ }
2266
+ catch (error) {
2267
+ const failed = new Error(`backend session create failed without fallback after admission began: ${error instanceof Error ? error.message : error}`);
2268
+ failed.name = 'BackendError';
2269
+ throw failed;
2270
+ }
2271
+ finally {
2272
+ clearTimeout(timer);
2273
+ }
2274
+ if (!res.ok) {
2275
+ const text = await res.text().catch(() => '');
2276
+ let msg = text;
2277
+ try {
2278
+ msg = JSON.parse(text).error || text;
2279
+ }
2280
+ catch { }
2281
+ const err = new Error(`backend rejected session (${res.status}): ${msg}`);
2282
+ err.name = 'BackendError';
2283
+ throw err;
2284
+ }
2285
+ return await res.json();
2286
+ }
2287
+ export function spawnerClause(p) {
2288
+ if (!p?.worktreePath)
2289
+ return '';
2290
+ const who = p.name || p.title;
2291
+ return `\n\nYou were created by session \`${p.session.slice(0, 8)}\`${who ? ` (${who})` : ''}, whose worktree is ${p.worktreePath}` +
2292
+ `${p.branch ? ` on branch \`${p.branch}\`` : ''}. Your own worktree is branched from \`${mainBranch()}\`, so it does NOT contain that ` +
2293
+ `session's uncommitted or unmerged work — a spec node it just created, an edit it hasn't landed. If your task needs anything of theirs, ` +
2294
+ `read it there directly. Read only: never write into another session's worktree.`;
2295
+ }
2296
+ function sessionCreateFailureRecord(rec, error) {
2297
+ const msg = error instanceof Error ? error.message : String(error);
2298
+ console.error(`spex: materialize failed for worktree ${rec.worktreePath} — hooks/contract not materialized, worker launches UNGOVERNED: ${msg}`);
2299
+ return { ...rec, note: `materialize failed at creation — worker ungoverned (no hooks/contract): ${msg}` };
2300
+ }
2301
+ // A materialize failure can leave a tracked contract or .gitignore half-written. Until publication this is
2302
+ // still creation-owned preparation: no worker can have authored work here, so restore HEAD and remove its
2303
+ // untracked artifacts rather than publish a queued record that close must preserve as possibly-user-dirty.
2304
+ // Disable checkout hooks: recovery is not another anchor that may recreate the failed materialization.
2305
+ async function resetFailedMaterializeCandidate(rec, signal) {
2306
+ throwIfCreateAborted(signal, 'materialize');
2307
+ const reset = await gitTry(['-C', rec.worktreePath, '-c', 'core.hooksPath=/dev/null', 'reset', '--hard', '--quiet', 'HEAD']);
2308
+ if (!reset.ok) {
2309
+ const detail = (reset.stderr || reset.stdout || 'git reset failed without diagnostic').trim();
2310
+ throw new SessionCreateError('session_create_failed', 'materialize', `materialize failed and its prepared worktree could not be restored: ${detail}`, 500);
2311
+ }
2312
+ const clean = await gitTry(['-C', rec.worktreePath, '-c', 'core.hooksPath=/dev/null', 'clean', '-fd', '-e', 'spexcode.local.json']);
2313
+ if (clean.ok)
2314
+ return;
2315
+ const detail = (clean.stderr || clean.stdout || 'git clean failed without diagnostic').trim();
2316
+ throw new SessionCreateError('session_create_failed', 'materialize', `materialize failed and its prepared worktree could not be restored: ${detail}`, 500);
2317
+ }
2318
+ async function materializeSessionCandidate(rec, signal) {
2319
+ throwIfCreateAborted(signal, 'materialize');
2320
+ try {
2321
+ await new Promise((resolvePromise, reject) => {
2322
+ const child = spawn(process.execPath, [...cliEntrypointArgs(pkgRoot(), dirname(fileURLToPath(import.meta.url))), 'materialize'], {
2323
+ cwd: rec.worktreePath,
2324
+ env: process.env,
2325
+ detached: true,
2326
+ stdio: ['ignore', 'ignore', 'pipe'],
2327
+ });
2328
+ let stderr = '', settled = false;
2329
+ const killTree = () => {
2330
+ if (!child.pid)
2331
+ return;
2332
+ try {
2333
+ process.kill(-child.pid, 'SIGKILL');
2334
+ }
2335
+ catch { /* process group already gone */ }
2336
+ try {
2337
+ child.kill('SIGKILL');
2338
+ }
2339
+ catch { /* child already gone */ }
2340
+ };
2341
+ const abort = () => killTree();
2342
+ signal.addEventListener('abort', abort, { once: true });
2343
+ child.stderr.setEncoding('utf8').on('data', (chunk) => { if (stderr.length < 64 * 1024)
2344
+ stderr += chunk; });
2345
+ child.once('error', (error) => {
2346
+ if (settled)
2347
+ return;
2348
+ settled = true;
2349
+ signal.removeEventListener('abort', abort);
2350
+ reject(error);
2351
+ });
2352
+ child.once('close', (code, childSignal) => {
2353
+ if (settled)
2354
+ return;
2355
+ settled = true;
2356
+ signal.removeEventListener('abort', abort);
2357
+ if (signal.aborted) {
2358
+ reject(createAbortError(signal, 'materialize'));
2359
+ return;
2360
+ }
2361
+ if (code === 0) {
2362
+ resolvePromise();
2363
+ return;
2364
+ }
2365
+ reject(new Error(`materialize exited ${childSignal || code}${stderr.trim() ? `: ${stderr.trim()}` : ''}`));
2366
+ });
2367
+ if (signal.aborted)
2368
+ abort();
2369
+ });
2370
+ return rec;
2371
+ }
2372
+ catch (error) {
2373
+ if (signal.aborted || error instanceof SessionCreateError)
2374
+ throw createAbortError(signal, 'materialize');
2375
+ const failed = sessionCreateFailureRecord(rec, error);
2376
+ await resetFailedMaterializeCandidate(rec, signal);
2377
+ return failed;
2378
+ }
2379
+ }
2380
+ const sessionCandidateReceiptDir = () => join(runtimeRoot(), '.session-create-candidates');
2381
+ const sessionCandidateReceiptPath = (id) => join(sessionCandidateReceiptDir(), `${id}.json`);
2382
+ const sessionCandidateLockId = (path, branch) => `create-resource-${digest(`${path}\0${branch}`)}`;
2383
+ // The graph watcher uses this private fence to avoid rebuilding the full board while Git is still
2384
+ // registering a session candidate. The receipt is written before `git worktree add` and retired only
2385
+ // after publication or bounded cleanup, so the path names exactly the transaction-owned worktree.
2386
+ export function pendingSessionCreateWorktreePaths() {
2387
+ const paths = new Set();
2388
+ let entries;
2389
+ try {
2390
+ entries = readdirSync(sessionCandidateReceiptDir(), { withFileTypes: true });
2391
+ }
2392
+ catch {
2393
+ return paths;
2394
+ }
2395
+ for (const entry of entries) {
2396
+ if (!entry.isFile() || !entry.name.endsWith('.json'))
2397
+ continue;
2398
+ try {
2399
+ const value = JSON.parse(readFileSync(join(sessionCandidateReceiptDir(), entry.name), 'utf8'));
2400
+ if (typeof value.path === 'string' && value.path && typeof value.stage === 'string')
2401
+ paths.add(resolve(value.path));
2402
+ }
2403
+ catch { /* an in-flight atomic replace is not a candidate path */ }
2404
+ }
2405
+ return paths;
2406
+ }
2407
+ function readSessionCandidateReceipt(id) {
2408
+ const path = sessionCandidateReceiptPath(id);
2409
+ if (!existsSync(path))
2410
+ return { kind: 'absent' };
2411
+ try {
2412
+ const value = JSON.parse(readFileSync(path, 'utf8'));
2413
+ const prestate = value.prestate;
2414
+ if (value.version !== 1 || typeof value.requestDigest !== 'string' || typeof value.payloadHash !== 'string'
2415
+ || typeof value.root !== 'string' || typeof value.path !== 'string' || typeof value.branch !== 'string'
2416
+ || !prestate || prestate.store !== false || prestate.path !== false || prestate.worktree !== false || prestate.branch !== false
2417
+ || !['prepared', 'git-created', 'store-created'].includes(value.stage)) {
2418
+ return { kind: 'invalid', error: `invalid private candidate receipt at ${path}` };
2419
+ }
2420
+ return { kind: 'valid', receipt: value };
2421
+ }
2422
+ catch (error) {
2423
+ return { kind: 'invalid', error: `invalid private candidate receipt at ${path}: ${error instanceof Error ? error.message : error}` };
2424
+ }
2425
+ }
2426
+ function writeSessionCandidateReceipt(id, receipt) {
2427
+ const dir = sessionCandidateReceiptDir();
2428
+ mkdirSync(dir, { recursive: true });
2429
+ const path = sessionCandidateReceiptPath(id);
2430
+ const tmp = join(dir, `.${id}.${process.pid}.${randomUUID()}.tmp`);
2431
+ try {
2432
+ writeFileSync(tmp, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 });
2433
+ renameSync(tmp, path);
2434
+ }
2435
+ finally {
2436
+ rmSync(tmp, { force: true });
2437
+ }
2438
+ }
2439
+ function retireSessionCandidateReceipt(id) {
2440
+ try {
2441
+ rmSync(sessionCandidateReceiptPath(id), { force: true });
2442
+ }
2443
+ catch {
2444
+ return false;
2445
+ }
2446
+ return !existsSync(sessionCandidateReceiptPath(id));
2447
+ }
2448
+ function sessionCandidateReceiptMatches(receipt, context, root, path, branch) {
2449
+ return receipt.requestDigest === context.requestDigest && receipt.payloadHash === context.payloadHash
2450
+ && receipt.root === root && receipt.path === path && receipt.branch === branch;
2451
+ }
2452
+ function publishedSessionCandidateReceiptRetirementFailure(rec, root) {
2453
+ const durable = readSessionCandidateReceipt(rec.session);
2454
+ if (durable.kind === 'absent')
2455
+ return null;
2456
+ if (durable.kind === 'invalid')
2457
+ return durable.error;
2458
+ if (!rec.createRequestId || !rec.createPayloadHash || !rec.branch || !rec.worktreePath
2459
+ || durable.receipt.requestDigest !== rec.createRequestId || durable.receipt.payloadHash !== rec.createPayloadHash
2460
+ || durable.receipt.root !== root || durable.receipt.path !== rec.worktreePath || durable.receipt.branch !== rec.branch) {
2461
+ return `private candidate receipt at ${sessionCandidateReceiptPath(rec.session)} does not match the published record`;
2462
+ }
2463
+ if (!retireSessionCandidateReceipt(rec.session))
2464
+ return `private candidate receipt remains at ${sessionCandidateReceiptPath(rec.session)}`;
2465
+ return readSessionCandidateReceipt(rec.session).kind === 'absent'
2466
+ ? null
2467
+ : `private candidate receipt retirement is unproven at ${sessionCandidateReceiptPath(rec.session)}`;
2468
+ }
2469
+ async function retirePublishedSessionCandidateReceipt(rec, context) {
2470
+ if (!rec.branch || !rec.worktreePath)
2471
+ return;
2472
+ if (readSessionCandidateReceipt(rec.session).kind === 'absent')
2473
+ return;
2474
+ const root = mainRoot(), path = rec.worktreePath, branch = rec.branch;
2475
+ await withRecordLock(sessionCandidateLockId(path, branch), async () => {
2476
+ const failure = publishedSessionCandidateReceiptRetirementFailure(rec, root);
2477
+ if (failure)
2478
+ console.error(`spex: published session ${rec.session.slice(0, 8)} remains the fence for its candidate receipt: ${failure}`);
2479
+ }, context.signal);
2480
+ }
2481
+ async function sessionCandidateState(root, path, branch, signal) {
2482
+ const [listed, ref] = await withGitAbortSignal(signal, () => Promise.all([
2483
+ gitTry(['-C', root, 'worktree', 'list', '--porcelain', '-z']),
2484
+ gitTry(['-C', root, 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`]),
2485
+ ]));
2486
+ if (!listed.ok)
2487
+ throw new SessionCreateError('session_create_failed', 'git-worktree', `cannot read worktree registry: ${listed.stderr.trim() || listed.failure}`, 500);
2488
+ if (!ref.ok && ref.failure !== 'exit')
2489
+ throw new SessionCreateError('session_create_failed', 'git-worktree', `cannot read candidate branch: ${ref.stderr.trim() || ref.failure}`, 500);
2490
+ return { path: existsSync(path), worktree: listed.stdout.split('\0').includes(`worktree ${path}`), branch: ref.ok };
2491
+ }
2492
+ async function cleanupSessionCandidate(root, id, path, branch, owned) {
2493
+ const residues = [];
2494
+ if (owned.store) {
2495
+ try {
2496
+ rmSync(sessionStoreDir(id), { recursive: true, force: true });
2497
+ }
2498
+ catch { /* verified below */ }
2499
+ }
2500
+ const controller = new AbortController();
2501
+ const timer = setTimeout(() => controller.abort(), 10_000);
2502
+ timer.unref?.();
2503
+ try {
2504
+ await withGitAbortSignal(controller.signal, async () => {
2505
+ if (owned.worktree) {
2506
+ const removed = await gitTry(['-C', root, 'worktree', 'remove', '--force', path]);
2507
+ if (!removed.ok)
2508
+ residues.push(`worktree remove failed: ${removed.stderr.trim() || removed.failure}`);
2509
+ }
2510
+ const afterRemove = await sessionCandidateState(root, path, branch, controller.signal);
2511
+ if (owned.path && afterRemove.path && !afterRemove.worktree) {
2512
+ try {
2513
+ rmSync(path, { recursive: true, force: true });
2514
+ }
2515
+ catch { /* verified below */ }
2516
+ }
2517
+ const ref = `refs/heads/${branch}`;
2518
+ if (owned.branch) {
2519
+ const deleted = await gitTry(['-C', root, 'branch', '-D', branch]);
2520
+ if (!deleted.ok)
2521
+ residues.push(`branch delete failed: ${deleted.stderr.trim() || deleted.failure}`);
2522
+ }
2523
+ const state = await sessionCandidateState(root, path, branch, controller.signal);
2524
+ if ((owned.path && state.path) || (owned.worktree && state.worktree))
2525
+ residues.push(`owned worktree remains at ${path}`);
2526
+ if (owned.branch && state.branch)
2527
+ residues.push(`owned branch remains at ${ref}`);
2528
+ if ((!owned.path && state.path) || (!owned.worktree && state.worktree))
2529
+ residues.push(`unowned candidate worktree preserved at ${path}`);
2530
+ if (!owned.branch && state.branch)
2531
+ residues.push(`unowned candidate branch preserved at ${ref}`);
2532
+ });
2533
+ }
2534
+ catch (error) {
2535
+ residues.push(`Git cleanup did not settle: ${error instanceof Error ? error.message : error}`);
2536
+ }
2537
+ finally {
2538
+ clearTimeout(timer);
2539
+ }
2540
+ if (owned.store && existsSync(sessionStoreDir(id)))
2541
+ residues.push(`owned session store remains at ${sessionStoreDir(id)}`);
2542
+ return residues;
2543
+ }
2544
+ function existingCreateReceipt(rec) {
2545
+ const h = harnessById(rec.harness || defaultHarness.id);
2546
+ if (rec.status === 'queued')
2547
+ return toSession(rec, 'queued', 'offline');
2548
+ const status = rec.status === 'active' ? 'working' : rec.status === 'awaiting' ? PROPOSAL_STATUS[rec.proposal ?? 'nothing'] : rec.status;
2549
+ return toSession(rec, status, rec.stopped ? 'offline' : h.headless ? 'online' : 'starting');
2550
+ }
2551
+ async function proveSessionCandidate(path, branch, signal) {
2552
+ const [top, checkedOut, ref] = await withGitAbortSignal(signal, () => Promise.all([
2553
+ gitTry(['-C', path, 'rev-parse', '--show-toplevel']),
2554
+ gitTry(['-C', path, 'symbolic-ref', '--quiet', '--short', 'HEAD']),
2555
+ gitTry(['-C', mainRoot(), 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`]),
2556
+ ]));
2557
+ if (!top.ok || !checkedOut.ok || !ref.ok)
2558
+ return [top.stderr, checkedOut.stderr, ref.stderr].map((value) => value.trim()).filter(Boolean).join('; ') || 'Git identity validation failed';
2559
+ let actualTop = top.stdout.trim();
2560
+ try {
2561
+ actualTop = realpathSync(actualTop);
2562
+ }
2563
+ catch { /* missing path is reported by the comparison */ }
2564
+ let expectedTop = path;
2565
+ try {
2566
+ expectedTop = realpathSync(path);
2567
+ }
2568
+ catch { /* missing path is reported by the comparison */ }
2569
+ if (actualTop !== expectedTop)
2570
+ return `worktree top-level is ${actualTop}, expected ${expectedTop}`;
2571
+ if (checkedOut.stdout.trim() !== branch)
2572
+ return `worktree checked out ${checkedOut.stdout.trim() || '(detached)'}, expected ${branch}`;
2573
+ return null;
2574
+ }
2575
+ async function prepareSession(prompt, parent, launcher, name, context) {
2576
+ const { id, requestDigest, payloadHash, base, signal } = context;
2577
+ let phase = 'creation-lock';
2578
+ let shouldDrain = false;
2579
+ traceSessionCreate(id, requestDigest, phase, 'start');
2580
+ try {
2581
+ const receipt = await withRecordLock(id, async () => {
2582
+ const existing = readRecord(id);
2583
+ if (existing) {
2584
+ if (existing.createRequestId !== requestDigest || existing.createPayloadHash !== payloadHash) {
2585
+ throw new SessionCreateError('session_create_key_reused', 'creation-lock', 'Idempotency-Key is already bound to another session-create payload', 409);
2586
+ }
2587
+ await retirePublishedSessionCandidateReceipt(existing, context);
2588
+ return existingCreateReceipt(existing);
2589
+ }
2590
+ phase = 'launcher-resolution';
2591
+ traceSessionCreate(id, requestDigest, phase, 'start');
2592
+ let chosen;
2593
+ let h;
2594
+ let pinned;
2595
+ try {
2596
+ const lname = launcher ?? defaultLauncher(mainRoot());
2597
+ chosen = resolveLauncher(lname);
2598
+ h = harnessById(chosen.harness);
2599
+ pinned = h.baseCmd(chosen.cmd);
2600
+ if (h.ownsRendezvous)
2601
+ assertRvSockPath(id);
2602
+ }
2603
+ catch (error) {
2604
+ throw new SessionCreateError('session_create_failed', phase, error instanceof Error ? error.message : String(error), 400);
2605
+ }
2606
+ traceSessionCreate(id, requestDigest, phase, 'finish');
2607
+ phase = 'target-resolution';
2608
+ traceSessionCreate(id, requestDigest, phase, 'start');
2609
+ throwIfCreateAborted(signal, phase);
2610
+ const rawPrompt = prompt;
2611
+ const ref = nodeFromPrompt(rawPrompt);
2612
+ const launchSpecs = ref ? loadSpecsLite() : null;
2613
+ const title = ref ? null : titleFromPrompt(rawPrompt);
2614
+ const slug = `${slugify(ref || title)}-${id.slice(0, 4)}`;
2615
+ const root = mainRoot();
2616
+ // An explicit base pins the fork point so a run is reproducible against a frozen commit instead of
2617
+ // whatever the source-of-truth branch has drifted to. Resolve it here, before any git mutation: an
2618
+ // unknown ref must fail the create request outright, never leave a half-made worktree behind.
2619
+ const startPoint = base ?? mainBranch();
2620
+ if (base) {
2621
+ const resolved = await withGitAbortSignal(signal, () => gitTry(['-C', root, 'rev-parse', '--verify', '--quiet', `${base}^{commit}`]));
2622
+ if (!resolved.ok || !resolved.stdout.trim()) {
2623
+ throw new SessionCreateError('session_create_failed', phase, `session-create base does not name a commit: ${base}`, 400);
2624
+ }
2625
+ }
2626
+ const branch = `${readConfig(dirname(gitCommonDir())).branchPrefix ?? 'node/'}${slug}`;
2627
+ const path = join(root, '.worktrees', slug);
2628
+ const spec = ref ? launchSpecs?.find((node) => node.id === ref) : undefined;
2629
+ const suffix = (spec ? `\n\nThe spec node \`${ref}\` is your ground truth — read its spec at ${join(path, spec.path)}.` : '')
2630
+ + spawnerClause(parent ? readRecord(parent) : null);
2631
+ let launchPrompt;
2632
+ try {
2633
+ launchPrompt = (await composeSessionPrompt(rawPrompt, { session: id, harness: h.id }, {
2634
+ loadedSpecs: launchSpecs ?? undefined,
2635
+ suffix: suffix || undefined,
2636
+ })).text;
2637
+ }
2638
+ catch (error) {
2639
+ throw new SessionCreateError('session_create_failed', phase, error instanceof Error ? error.message : String(error), 400);
2640
+ }
2641
+ traceSessionCreate(id, requestDigest, phase, 'finish');
2642
+ phase = 'git-worktree';
2643
+ traceSessionCreate(id, requestDigest, phase, 'start');
2644
+ const resourceLock = sessionCandidateLockId(path, branch);
2645
+ return await withRecordLock(resourceLock, async () => {
2646
+ throwIfCreateAborted(signal, phase);
2647
+ traceSessionCreate(id, requestDigest, phase, 'start', 'candidate-state');
2648
+ let before = await sessionCandidateState(root, path, branch, signal);
2649
+ traceSessionCreate(id, requestDigest, phase, 'finish', 'candidate-state');
2650
+ let storePresent = existsSync(sessionStoreDir(id));
2651
+ const durable = readSessionCandidateReceipt(id);
2652
+ if (durable.kind === 'invalid')
2653
+ throw new SessionCreateError('session_create_failed', phase, durable.error, 409);
2654
+ if (durable.kind === 'valid') {
2655
+ if (!sessionCandidateReceiptMatches(durable.receipt, context, root, path, branch)) {
2656
+ throw new SessionCreateError('session_create_failed', phase, 'private candidate receipt does not match this create request; preserving candidate resources', 409);
2657
+ }
2658
+ phase = 'cleanup';
2659
+ traceSessionCreate(id, requestDigest, phase, 'start', `recover-${durable.receipt.stage}`);
2660
+ const recovered = await cleanupSessionCandidate(root, id, path, branch, {
2661
+ store: storePresent, path: before.path, worktree: before.worktree, branch: before.branch,
2662
+ });
2663
+ traceSessionCreate(id, requestDigest, phase, recovered.length ? 'abort' : 'finish', recovered.join('; ') || undefined);
2664
+ if (recovered.length)
2665
+ throw new SessionCreateError('session_create_cleanup_failed', phase, `matching candidate recovery left residue: ${recovered.join('; ')}`, 500);
2666
+ before = { path: false, worktree: false, branch: false };
2667
+ storePresent = false;
2668
+ phase = 'git-worktree';
2669
+ }
2670
+ else if (storePresent || before.path || before.worktree || before.branch) {
2671
+ const occupied = [storePresent ? `session store ${sessionStoreDir(id)}` : '', before.path ? `path ${path}` : '', before.worktree ? `registered worktree ${path}` : '', before.branch ? `branch ${branch}` : ''].filter(Boolean).join(', ');
2672
+ throw new SessionCreateError('session_create_failed', phase, `session target is already occupied: ${occupied}`, 409);
2673
+ }
2674
+ let candidateReceipt = {
2675
+ version: 1, requestDigest, payloadHash, root, path, branch,
2676
+ prestate: { store: false, path: false, worktree: false, branch: false }, stage: 'prepared',
2677
+ };
2678
+ writeSessionCandidateReceipt(id, candidateReceipt);
2679
+ const owned = { store: false, path: false, worktree: false, branch: false };
2680
+ let gitMutationStarted = false;
2681
+ let published = false;
2682
+ try {
2683
+ gitMutationStarted = true;
2684
+ traceSessionCreate(id, requestDigest, phase, 'start', 'worktree-add');
2685
+ const added = await withGitAbortSignal(signal, () => gitTry(['-C', root, 'worktree', 'add', '-b', branch, path, startPoint], { extraEnv: DEFER_FOOTPRINT_REFRESH }));
2686
+ traceSessionCreate(id, requestDigest, phase, 'finish', 'worktree-add');
2687
+ if (added.ok)
2688
+ Object.assign(owned, { path: true, worktree: true, branch: true });
2689
+ if (!added.ok || !existsSync(path)) {
2690
+ throw new SessionCreateError('session_create_failed', phase, `git worktree add failed: ${added.stderr.trim() || added.failure || 'worktree missing after success'}`, 500);
2691
+ }
2692
+ candidateReceipt = { ...candidateReceipt, stage: 'git-created' };
2693
+ writeSessionCandidateReceipt(id, candidateReceipt);
2694
+ traceSessionCreate(id, requestDigest, phase, 'finish');
2695
+ traceSessionCreate(id, requestDigest, phase, 'start', 'seed-worktree-host-state');
2696
+ seedWorktreeHostState(root, path);
2697
+ traceSessionCreate(id, requestDigest, phase, 'finish', 'seed-worktree-host-state');
2698
+ let rec = {
2699
+ session: id, governed: true, worktreePath: path, branch,
2700
+ node: ref || null, title, name, parent: parent && parent !== id ? parent : null,
2701
+ status: 'queued', proposal: null, merges: 0, note: null, sortKey: null, createdAt: Date.now(),
2702
+ harness: h.id, harnessSessionId: null, stopped: false, archived: false, coldProof: null, adapterRecovery: null, launcher: chosen.name,
2703
+ launchCmd: pinned, launchOwner: backendLaunchAuthority(), createRequestId: requestDigest, createPayloadHash: payloadHash,
2704
+ ...(base ? { base } : {}),
2705
+ };
2706
+ owned.store = true;
2707
+ const dir = storeDir(id);
2708
+ writeFileSync(join(dir, 'prompt'), rawPrompt);
2709
+ writeFileSync(join(dir, 'launch'), launchPrompt);
2710
+ candidateReceipt = { ...candidateReceipt, stage: 'store-created' };
2711
+ writeSessionCandidateReceipt(id, candidateReceipt);
2712
+ phase = 'materialize';
2713
+ traceSessionCreate(id, requestDigest, phase, 'start');
2714
+ rec = await materializeSessionCandidate(rec, signal);
2715
+ traceSessionCreate(id, requestDigest, phase, 'finish');
2716
+ phase = 'record-write';
2717
+ traceSessionCreate(id, requestDigest, phase, 'start');
2718
+ throwIfCreateAborted(signal, phase);
2719
+ const gitMismatch = await proveSessionCandidate(path, branch, signal);
2720
+ if (gitMismatch)
2721
+ throw new SessionCreateError('session_create_failed', phase, `refusing session publication: ${gitMismatch}`, 500);
2722
+ throwIfCreateAborted(signal, phase);
2723
+ writeRecord(rec);
2724
+ if (rec.parent && readRecord(rec.parent)?.governed) {
2725
+ const watchers = readWatchEntries(id);
2726
+ const next = addWatchSource(watchers, rec.parent, 'parent', true);
2727
+ if (next.added)
2728
+ writeWatchEntries(id, next.entries);
2729
+ }
2730
+ published = true;
2731
+ const receiptFailure = publishedSessionCandidateReceiptRetirementFailure(rec, root);
2732
+ if (receiptFailure)
2733
+ console.error(`spex: published session ${id.slice(0, 8)} remains the fence for its candidate receipt: ${receiptFailure}`);
2734
+ shouldDrain = true;
2735
+ traceSessionCreate(id, requestDigest, phase, 'publish');
2736
+ return toSession(rec, 'queued', 'offline');
2737
+ }
2738
+ catch (error) {
2739
+ if (published)
2740
+ throw error;
2741
+ const failurePhase = error instanceof SessionCreateError ? error.phase : phase;
2742
+ let ownershipFailure = null;
2743
+ if (gitMutationStarted && !(owned.path && owned.worktree && owned.branch)) {
2744
+ const inspection = new AbortController();
2745
+ const timer = setTimeout(() => inspection.abort(), 10_000);
2746
+ timer.unref?.();
2747
+ try {
2748
+ const after = await sessionCandidateState(root, path, branch, inspection.signal);
2749
+ owned.path ||= !before.path && after.path;
2750
+ owned.worktree ||= !before.worktree && after.worktree;
2751
+ owned.branch ||= !before.branch && after.branch;
2752
+ }
2753
+ catch (inspectionError) {
2754
+ ownershipFailure = `candidate ownership inspection failed: ${inspectionError instanceof Error ? inspectionError.message : inspectionError}`;
2755
+ }
2756
+ finally {
2757
+ clearTimeout(timer);
2758
+ }
2759
+ }
2760
+ phase = 'cleanup';
2761
+ traceSessionCreate(id, requestDigest, phase, 'start');
2762
+ const residues = await cleanupSessionCandidate(root, id, path, branch, owned);
2763
+ if (ownershipFailure)
2764
+ residues.unshift(ownershipFailure);
2765
+ if (!residues.length && !retireSessionCandidateReceipt(id))
2766
+ residues.push(`private candidate receipt remains at ${sessionCandidateReceiptPath(id)}`);
2767
+ traceSessionCreate(id, requestDigest, phase, residues.length ? 'abort' : 'finish', residues.join('; ') || undefined);
2768
+ if (residues.length)
2769
+ throw new SessionCreateError('session_create_cleanup_failed', phase, `session creation failed and cleanup left residue: ${residues.join('; ')}`, 500);
2770
+ if (signal.aborted) {
2771
+ phase = failurePhase;
2772
+ throw createAbortError(signal, failurePhase);
2773
+ }
2774
+ throw error instanceof SessionCreateError
2775
+ ? error
2776
+ : new SessionCreateError('session_create_failed', failurePhase, error instanceof Error ? error.message : String(error), 500);
2777
+ }
2778
+ }, signal);
2779
+ }, signal);
2780
+ traceSessionCreate(id, requestDigest, 'creation-lock', 'finish');
2781
+ if (shouldDrain) {
2782
+ phase = 'launcher-queue';
2783
+ traceSessionCreate(id, requestDigest, phase, 'start');
2784
+ requestQueueDrain();
2785
+ traceSessionCreate(id, requestDigest, phase, 'finish');
2786
+ }
2787
+ return receipt;
2788
+ }
2789
+ catch (error) {
2790
+ const failure = signal.aborted
2791
+ ? createAbortError(signal, phase)
2792
+ : error instanceof SessionCreateError
2793
+ ? error
2794
+ : new SessionCreateError('session_create_failed', phase, error instanceof Error ? error.message : String(error), 500);
2795
+ traceSessionCreate(id, requestDigest, failure.phase, 'abort', failure.code);
2796
+ throw failure;
2797
+ }
2798
+ }
2799
+ export function bootstrapMaterialize(rec, doMaterialize = materialize) {
2800
+ try {
2801
+ doMaterialize(rec.worktreePath);
2802
+ }
2803
+ catch (e) {
2804
+ writeRecord(sessionCreateFailureRecord(rec, e));
2805
+ }
2806
+ }
2807
+ const SOCKET_READY_TIMEOUT_MS = 30000; // spans launchScript's bounded fast-fail relaunch window, so
2808
+ // waitForReady (slot-hold + resume) waits through a daemon-race retry
2809
+ // instead of returning before a recovering socket
2810
+ const SOCKET_POLL_MS = 200;
2811
+ async function waitForReady(id, harness, pending, timeoutMs = SOCKET_READY_TIMEOUT_MS, recordLockHeld = false) {
2812
+ const current = () => {
2813
+ const stored = readRecord(id);
2814
+ const rec = stored && pending
2815
+ ? { ...pending, ...stored, stopped: pending.stopped, archived: pending.archived }
2816
+ : stored || pending;
2817
+ return rec ? { ...rec, runtimeDir: runtimeRoot() } : null;
2818
+ };
2819
+ const deadline = Date.now() + timeoutMs;
2820
+ if (harness.launchPayloadProof && !current()?.harnessSessionId) {
2821
+ for (;;) {
2822
+ if (existsSync(sessionArtifactPath(id, 'launch.proof'))) {
2823
+ if (recordLockHeld)
2824
+ consumeHarnessLaunchProofUnlocked(id);
2825
+ else
2826
+ await withRecordLock(id, async () => consumeHarnessLaunchProofUnlocked(id));
2827
+ break;
2828
+ }
2829
+ if (Date.now() >= deadline)
2830
+ return null;
2831
+ await new Promise((r) => setTimeout(r, SOCKET_POLL_MS));
2832
+ }
2833
+ }
2834
+ if (harness.launchReady)
2835
+ return harness.launchReady(current, deadline);
2836
+ const genericFence = () => ({
2837
+ proof: Object.freeze({ kind: 'adapter-liveness', harnessId: harness.id, sessionId: id }),
2838
+ validate: async (latest) => {
2839
+ const rec = latest();
2840
+ const snap = await liveSnapshot();
2841
+ return !!rec && harness.liveness(rec, snap.windows.has(id), runtimeRoot(), snap.windows.get(id), snap.sockets.has(id)) === 'online';
2842
+ },
2843
+ });
2844
+ for (;;) {
2845
+ const rec = current();
2846
+ const snap = await liveSnapshot(); // window + pane probe + live-listener set in one snapshot — all the adapter needs
2847
+ if (rec && harness.liveness(rec, snap.windows.has(id), runtimeRoot(), snap.windows.get(id), snap.sockets.has(id)) === 'online')
2848
+ return genericFence();
2849
+ if (Date.now() >= deadline)
2850
+ return null;
2851
+ await new Promise((r) => setTimeout(r, SOCKET_POLL_MS));
2852
+ }
2853
+ }
2854
+ const restingLifecycle = (status) => status === 'active' || status === 'queued' ? 'idle' : status;
2855
+ async function resumeSessionUnlocked(id, opts = {}) {
2856
+ const { force = false, guard = true } = opts;
2857
+ let wt;
2858
+ try {
2859
+ wt = await findWorktree(id);
2860
+ }
2861
+ catch (e) {
2862
+ if (e instanceof SessionRecordUnusable)
2863
+ return { ok: false, refused: true, error: e.message };
2864
+ throw e;
2865
+ }
2866
+ if (!wt)
2867
+ return { ok: false, error: `no such session ${id}` };
2868
+ // A process that died while validating left an internal candidate behind. This record lock proves no live
2869
+ // resume still owns it. Restore the frozen public original before doing any transport work and require an
2870
+ // explicit retry; stale runtime evidence is never adopted into a fresh launch attempt.
2871
+ if (wt.rec.launchReadinessPending) {
2872
+ writeRecord(restoreLaunchReadinessOriginal(wt.rec));
2873
+ return {
2874
+ ok: false,
2875
+ refused: true,
2876
+ error: `session ${id}: stale launch readiness pending was recovered fail-closed; the exact stopped/offline record was retained. Retry resume.`,
2877
+ };
2878
+ }
2879
+ const preResume = wt.rec;
2880
+ // a retired session (its worktree gone) is terminal, not offline: say so in its own words rather than in the
2881
+ // preflight's, since `close` — not a repair — is what it needs.
2882
+ const retired = retirementReason(wt.rec);
2883
+ if (retired)
2884
+ return { ok: false, refused: true, error: retired };
2885
+ // everything else the transport can settle before opening a window: no branch, no launcher. A launch that
2886
+ // cannot succeed must not be attempted, retried, or given a regenerated launch script.
2887
+ const blocked = launchPreflight(wt.rec);
2888
+ if (blocked)
2889
+ return { ok: false, refused: true, error: blocked.message };
2890
+ const h = harnessById(wt.rec.harness || defaultHarness.id);
2891
+ // A prior adapter process may have proven identity + first-turn durability just before its session owner
2892
+ // died. Consume that receipt before choosing a recovery tail, so retry resumes the proven thread instead of
2893
+ // creating another one with the same first prompt.
2894
+ if (h.launchPayloadProof && existsSync(sessionArtifactPath(id, 'launch.proof'))) {
2895
+ consumeHarnessLaunchProofUnlocked(id);
2896
+ wt = await findWorktree(id);
2897
+ if (!wt)
2898
+ return { ok: false, error: `session ${id} disappeared while recovering native launch proof` };
2899
+ }
2900
+ // An archived record is expected to be stopped, but the guard must still inspect physical liveness in case
2901
+ // it is a legacy/invariant-violating row. Ignore filing and stale stop metadata for this one safety probe so
2902
+ // resume can never kill a live leaf merely because the record was hidden.
2903
+ const probeRec = wt.rec.archived ? { ...wt.rec, archived: false, stopped: false } : wt.rec;
2904
+ const resumeSnap = await liveSnapshot();
2905
+ const lv = h.runtimeOwnership === 'adapter'
2906
+ ? (resumeSnap.windows.has(id) ? 'online' : 'offline')
2907
+ : liveness(probeRec, resumeSnap); // FRESH, honest liveness (listener-verified)
2908
+ if (guard && !force && lv === 'online')
2909
+ return { ok: false, refused: true, error: `session ${id} is ALIVE — refusing to relaunch, which would kill a live worker mid-work. To steer it, send it a message; use force only for a genuinely wedged (but alive) process.` };
2910
+ if (guard && !force && lv === 'unknown')
2911
+ return { ok: false, refused: true, error: `session ${id}: the liveness probe failed (the box is likely overloaded) — refusing to relaunch since a live worker can't be ruled out. Retry in a moment, or use force to override.` };
2912
+ const wasArchived = wt.rec.archived;
2913
+ if (!wasArchived && wt.rec.adapterRecovery) {
2914
+ const recovery = await h.restoreRuntime?.(wt.rec);
2915
+ if (recovery && !recovery.ok)
2916
+ return { ok: false, refused: true, error: `session ${id}: recovery required before resume — ${recovery.reason}` };
2917
+ writeRecord({ ...(readRecord(id) || wt.rec), adapterRecovery: null, coldProof: null, archived: false, stopped: true });
2918
+ wt = await findWorktree(id);
2919
+ if (!wt)
2920
+ return { ok: false, error: `session ${id} disappeared during adapter recovery` };
2921
+ }
2922
+ if (wasArchived && (force || lv === 'offline')) {
2923
+ // Make the durable row visible/offline before any adapter unarchive or launch RPC. Any later failure leaves
2924
+ // a retryable unarchived record rather than archived:true with a newly loaded target thread.
2925
+ const pendingRecovery = wt.rec.adapterRecovery || 'restore-runtime-pending';
2926
+ writeRecord({ ...wt.rec, archived: false, stopped: true, coldProof: wt.rec.coldProof, adapterRecovery: pendingRecovery });
2927
+ const visible = readRecord(id) || { ...wt.rec, archived: false, stopped: true, coldProof: wt.rec.coldProof, adapterRecovery: pendingRecovery };
2928
+ const restored = await h.restoreRuntime?.(visible);
2929
+ if (restored && !restored.ok)
2930
+ return { ok: false, refused: true, error: `session ${id}: ${restored.reason}` };
2931
+ writeRecord({ ...(readRecord(id) || visible), adapterRecovery: null, coldProof: null });
2932
+ }
2933
+ // proceeding: settle the RESTING lifecycle (a resumed working agent is now idle), then relaunch iff the agent
2934
+ // is CONFIRMED offline (or force — the wedged-but-alive escape). Clear the explicit-stop marker only after
2935
+ // launch has accepted the relaunch; a thrown launch leaves the record truthfully stopped. `starting`/`unknown`
2936
+ // fall through to a metadata-only no-op.
2937
+ // Archived sessions have no runtime by invariant. Resume first leaves cold storage, then the normal
2938
+ // starting -> online launch path recreates the same conversation.
2939
+ const current = wasArchived ? (readRecord(id) || { ...wt.rec, archived: false, stopped: true, coldProof: null }) : wt.rec;
2940
+ const resumed = { ...current, archived: false, coldProof: null, status: restingLifecycle(current.status), stopped: false };
2941
+ if (force || lv === 'offline') {
2942
+ let resumeTail;
2943
+ try {
2944
+ resumeTail = h.resumeArg(wt.rec, readLaunchFile(id)).trim();
2945
+ }
2946
+ catch (error) {
2947
+ return { ok: false, refused: true, error: error instanceof Error ? error.message : String(error) };
2948
+ }
2949
+ await tmuxOk(['kill-session', '-t', id]); // drop a dead/offline pane (or a force-killed live one)
2950
+ await launch(id, wt.path, resumeTail, h, launcherCmd(wt.rec));
2951
+ let readiness = null;
2952
+ let readinessError = '';
2953
+ try {
2954
+ readiness = await waitForReady(id, h, resumed, SOCKET_READY_TIMEOUT_MS, true);
2955
+ }
2956
+ catch (error) {
2957
+ readinessError = error instanceof Error ? error.message : String(error);
2958
+ }
2959
+ if (!readiness) {
2960
+ const failed = readRecord(id) || current;
2961
+ writeRecord({ ...failed, ...preResume, harnessSessionId: failed.harnessSessionId, launchReadinessPending: null });
2962
+ return {
2963
+ ok: false,
2964
+ refused: true,
2965
+ error: `session ${id}: launch did not become ready${readinessError ? ` - ${readinessError}` : ''}; the session remains stopped and can be retried`,
2966
+ };
2967
+ }
2968
+ const latest = readRecord(id) || resumed;
2969
+ const candidate = {
2970
+ ...latest,
2971
+ archived: false,
2972
+ coldProof: null,
2973
+ status: restingLifecycle(latest.status),
2974
+ stopped: false,
2975
+ launchReadinessPending: launchReadinessPending(preResume),
2976
+ };
2977
+ writeRecord(candidate);
2978
+ let stillReady = false;
2979
+ try {
2980
+ stillReady = await readiness.validate(() => {
2981
+ const stored = readRecord(id);
2982
+ return stored ? { ...stored, runtimeDir: runtimeRoot() } : null;
2983
+ });
2984
+ }
2985
+ catch (error) {
2986
+ readinessError = error instanceof Error ? error.message : String(error);
2987
+ }
2988
+ if (!stillReady) {
2989
+ const failed = readRecord(id) || candidate;
2990
+ writeRecord(restoreLaunchReadinessOriginal(failed));
2991
+ return {
2992
+ ok: false,
2993
+ refused: true,
2994
+ error: `session ${id}: launch readiness changed across the pending publication${readinessError ? ` - ${readinessError}` : ''}; the session remains stopped and can be retried`,
2995
+ };
2996
+ }
2997
+ const published = readRecord(id) || candidate;
2998
+ writeRecord({ ...published, launchReadinessPending: null });
2999
+ }
3000
+ else
3001
+ writeRecord(resumed);
3002
+ return { ok: true };
3003
+ }
3004
+ export const resumeSession = (id, opts = {}) => withSessionTransition(id, async () => {
3005
+ const result = await withRecordLock(id, () => resumeSessionUnlocked(id, opts));
3006
+ if (result.ok)
3007
+ await drainSession(id);
3008
+ return result;
3009
+ });
3010
+ export function markState(status, opts = {}) {
3011
+ const id = opts.sessionId || ownSessionId();
3012
+ if (!id)
3013
+ return false;
3014
+ return withRecordLockSync(id, () => {
3015
+ const rec = readLiveRecord(id);
3016
+ if (!rec)
3017
+ return false;
3018
+ const proposal = status === 'awaiting' ? (opts.proposal ?? 'nothing') : null;
3019
+ writeRecord({
3020
+ ...rec, status,
3021
+ proposal,
3022
+ note: opts.note ?? null,
3023
+ });
3024
+ return true;
3025
+ });
3026
+ }
3027
+ export const markDone = (proposal = 'nothing', sessionId, note) => markState('awaiting', { proposal, note, sessionId });
3028
+ export const markError = (sessionId) => markState('error', { sessionId });
3029
+ export function markTurnFailure(sessionId, note) {
3030
+ if (!sessionId)
3031
+ return false;
3032
+ return withRecordLockSync(sessionId, () => {
3033
+ const rec = readLiveRecord(sessionId);
3034
+ if (!rec || rec.status !== 'active' || rec.stopped || rec.archived)
3035
+ return false;
3036
+ writeRecord({ ...rec, status: 'error', proposal: null, note });
3037
+ return true;
3038
+ });
3039
+ }
3040
+ export function markHeadlessTurnFailure(sessionId, harness, exitCode) {
3041
+ if (exitCode === '0')
3042
+ return false;
3043
+ const outcome = /^\d+$/.test(exitCode) ? `exit code ${exitCode}` : `signal ${exitCode}`;
3044
+ return markTurnFailure(sessionId, `${harness} turn exited with ${outcome}`);
3045
+ }
3046
+ function bindHarnessSessionIdUnlocked(rec, harnessSessionId, generationId = process.env.SPEXCODE_CODEX_GENERATION?.trim()) {
3047
+ const id = rec.session;
3048
+ if (rec.harnessSessionId && rec.harnessSessionId !== harnessSessionId)
3049
+ throw new ResourceConflict(`refusing to replace exact harness thread identity for ${id}; create a new governed session instead`);
3050
+ const codex = rec.harness === 'codex' || rec.harness === 'codex-headless';
3051
+ const root = runtimeRoot();
3052
+ let priorBinding = null;
3053
+ let registrationPrepared = false;
3054
+ if (codex) {
3055
+ const ledger = readCodexGenerationLedger(root);
3056
+ if (ledger.revision > 0 && !generationId)
3057
+ throw new ResourceConflict(`refusing to bind Codex thread ${harnessSessionId}: launch did not provide an exact generation id`);
3058
+ priorBinding = codexGenerationBindingForSession(root, id);
3059
+ if (priorBinding && (!generationId || priorBinding.generationId !== generationId || priorBinding.threadId !== harnessSessionId))
3060
+ throw new ResourceConflict(`refusing to replace exact Codex generation binding for ${id}`);
3061
+ if (generationId && !priorBinding) {
3062
+ prepareCodexGenerationRegistration(root, id, harnessSessionId, generationId);
3063
+ registrationPrepared = true;
3064
+ }
3065
+ }
3066
+ try {
3067
+ writeRecord({ ...rec, harnessSessionId, coldProof: null, adapterRecovery: null });
3068
+ }
3069
+ catch (error) {
3070
+ if (codex && generationId && registrationPrepared) {
3071
+ try {
3072
+ bindCodexGeneration(root, id, harnessSessionId, null);
3073
+ }
3074
+ catch (rollback) {
3075
+ throw new ResourceConflict(`Codex generation binding persisted but session ${id} record write failed and rollback failed: ${rollback instanceof Error ? rollback.message : String(rollback)}`);
3076
+ }
3077
+ }
3078
+ throw error;
3079
+ }
3080
+ if (codex && generationId)
3081
+ commitCodexGenerationRegistration(root, id, harnessSessionId, generationId);
3082
+ }
3083
+ function readHarnessLaunchProof(id) {
3084
+ try {
3085
+ const proof = JSON.parse(readFileSync(sessionArtifactPath(id, 'launch.proof'), 'utf8'));
3086
+ if (!proof || proof.version !== 1 || typeof proof.sessionId !== 'string'
3087
+ || typeof proof.harnessId !== 'string' || typeof proof.harnessSessionId !== 'string' || !proof.harnessSessionId
3088
+ || typeof proof.launchPayloadHash !== 'string'
3089
+ || (proof.generationId !== null && typeof proof.generationId !== 'string'))
3090
+ throw new Error('invalid receipt shape');
3091
+ return proof;
3092
+ }
3093
+ catch (error) {
3094
+ throw new ResourceConflict(`native launch proof for ${id} is unreadable: ${error instanceof Error ? error.message : String(error)}`);
3095
+ }
3096
+ }
3097
+ function sameHarnessLaunchProof(left, right) {
3098
+ return left.version === right.version && left.sessionId === right.sessionId && left.harnessId === right.harnessId
3099
+ && left.harnessSessionId === right.harnessSessionId && left.launchPayloadHash === right.launchPayloadHash
3100
+ && left.generationId === right.generationId;
3101
+ }
3102
+ export function stageHarnessLaunchProof(sessionId, harnessSessionId, launchPayload) {
3103
+ const id = sessionId || ownSessionId();
3104
+ if (!id || !harnessSessionId)
3105
+ return false;
3106
+ const rec = readLiveRecord(id);
3107
+ if (!rec)
3108
+ return false;
3109
+ const harness = harnessById(rec.harness || defaultHarness.id);
3110
+ if (!harness.launchPayloadProof)
3111
+ throw new ResourceConflict(`harness ${harness.id} does not use native launch-payload proof`);
3112
+ const pending = readLaunchFile(id);
3113
+ if (pending == null)
3114
+ throw new ResourceConflict(`refusing native launch proof for ${id}: authoritative resolved launch payload is missing`);
3115
+ if (pending !== launchPayload)
3116
+ throw new ResourceConflict(`refusing native launch proof for ${id}: first-turn payload differs from the authoritative resolved launch payload`);
3117
+ const generationId = process.env.SPEXCODE_CODEX_GENERATION?.trim() || null;
3118
+ if (rec.harness === 'codex' || rec.harness === 'codex-headless') {
3119
+ const ledger = readCodexGenerationLedger(runtimeRoot());
3120
+ if (ledger.revision > 0 && !generationId)
3121
+ throw new ResourceConflict(`refusing native launch proof for ${id}: launch did not provide an exact Codex generation id`);
3122
+ if (generationId && (!ledger.generations[generationId] || ledger.generations[generationId].state === 'reclaimed'))
3123
+ throw new ResourceConflict(`refusing to bind Codex thread ${harnessSessionId}: generation ${generationId} is absent or reclaimed`);
3124
+ }
3125
+ const proof = {
3126
+ version: 1,
3127
+ sessionId: id,
3128
+ harnessId: harness.id,
3129
+ harnessSessionId,
3130
+ launchPayloadHash: createHash('sha256').update(launchPayload).digest('hex'),
3131
+ generationId,
3132
+ };
3133
+ const path = sessionArtifactPath(id, 'launch.proof');
3134
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
3135
+ writeFileSync(temp, `${JSON.stringify(proof, null, 2)}\n`, { mode: 0o600 });
3136
+ try {
3137
+ linkSync(temp, path);
3138
+ return true;
3139
+ }
3140
+ catch (error) {
3141
+ if (error.code !== 'EEXIST')
3142
+ throw error;
3143
+ const staged = readHarnessLaunchProof(id);
3144
+ if (sameHarnessLaunchProof(staged, proof))
3145
+ return true;
3146
+ throw new ResourceConflict(`refusing to replace native launch proof for ${id}: the staged session, thread, payload, or generation differs`);
3147
+ }
3148
+ finally {
3149
+ rmSync(temp, { force: true });
3150
+ }
3151
+ }
3152
+ function consumeHarnessLaunchProofUnlocked(id) {
3153
+ const rec = readLiveRecord(id);
3154
+ if (!rec)
3155
+ return false;
3156
+ const harness = harnessById(rec.harness || defaultHarness.id);
3157
+ const proof = readHarnessLaunchProof(id);
3158
+ if (proof.sessionId !== id || proof.harnessId !== harness.id)
3159
+ throw new ResourceConflict(`native launch proof for ${id} does not match the governed adapter identity`);
3160
+ const pending = readLaunchFile(id);
3161
+ if (pending == null && rec.harnessSessionId !== proof.harnessSessionId)
3162
+ throw new ResourceConflict(`refusing native launch proof for ${id}: authoritative resolved launch payload is missing`);
3163
+ if (pending != null && proof.launchPayloadHash !== createHash('sha256').update(pending).digest('hex'))
3164
+ throw new ResourceConflict(`native launch proof for ${id} does not match the authoritative resolved launch payload`);
3165
+ bindHarnessSessionIdUnlocked(rec, proof.harnessSessionId, proof.generationId || undefined);
3166
+ if (pending != null) {
3167
+ try {
3168
+ rmSync(sessionArtifactPath(id, 'launch'));
3169
+ }
3170
+ catch (error) {
3171
+ if (error.code !== 'ENOENT') {
3172
+ console.error(`spex: native launch proof committed for ${id}, but launch could not be consumed: ${error instanceof Error ? error.message : String(error)}`);
3173
+ return true;
3174
+ }
3175
+ }
3176
+ }
3177
+ try {
3178
+ rmSync(sessionArtifactPath(id, 'launch.proof'));
3179
+ }
3180
+ catch (error) {
3181
+ if (error.code !== 'ENOENT')
3182
+ console.error(`spex: native launch proof committed for ${id}, but launch.proof could not be consumed: ${error instanceof Error ? error.message : String(error)}`);
3183
+ }
3184
+ return true;
3185
+ }
3186
+ export function markHarnessSessionId(sessionId, harnessSessionId) {
3187
+ const id = sessionId || ownSessionId();
3188
+ if (!id || !harnessSessionId)
3189
+ return false;
3190
+ return withRecordLockSync(id, () => {
3191
+ const rec = readLiveRecord(id);
3192
+ if (!rec)
3193
+ return false;
3194
+ const harness = harnessById(rec.harness || defaultHarness.id);
3195
+ if (harness.launchPayloadProof)
3196
+ throw new ResourceConflict(`harness ${harness.id} must stage native identity together with authoritative first-turn payload proof`);
3197
+ bindHarnessSessionIdUnlocked(rec, harnessSessionId);
3198
+ return true;
3199
+ });
3200
+ }
3201
+ export function markIdle(sessionId) {
3202
+ const id = sessionId || ownSessionId();
3203
+ if (!id)
3204
+ return false;
3205
+ return withRecordLockSync(id, () => {
3206
+ const rec = readLiveRecord(id);
3207
+ if (!rec || rec.status !== 'active')
3208
+ return false; // active-only: never clobber a declaration
3209
+ writeRecord({ ...rec, status: 'idle' });
3210
+ return true;
3211
+ });
3212
+ }
3213
+ export function mergeReadiness(proposal = 'merge') {
3214
+ let dirty = [];
3215
+ try {
3216
+ dirty = git(['status', '--porcelain', '--untracked-files=all']).split('\n').filter(Boolean).map(porcelainPath);
3217
+ }
3218
+ catch { /* git status failed — fall through to the ahead check, still a real guard */ }
3219
+ if (dirty.length) {
3220
+ const shown = dirty.slice(0, 8).join(', ') + (dirty.length > 8 ? ', …' : '');
3221
+ return { ready: false, reason: `uncommitted changes on your node branch (${shown}) — commit your spec+code first` };
3222
+ }
3223
+ // a `nothing` proposal makes no claim about having something to land, so the clean tree is the whole gate.
3224
+ if (proposal === 'nothing')
3225
+ return { ready: true };
3226
+ let ahead = 0;
3227
+ const base = mainBranch();
3228
+ try {
3229
+ ahead = Number(git(['rev-list', '--count', `${base}..HEAD`]).trim()) || 0;
3230
+ }
3231
+ catch {
3232
+ ahead = 0;
3233
+ }
3234
+ if (ahead === 0)
3235
+ return { ready: false, reason: `your node branch is 0 commits ahead of ${base} — nothing is committed to merge (declaring \`done --propose nothing\` needs no commits ahead; use it to pause for the human)` };
3236
+ return { ready: true };
3237
+ }
3238
+ // the path a `git status --porcelain` line refers to: strip the `XY ` status, and for a rename keep the
3239
+ // NEW path (after ` -> `). Shared by the dirty-file counters (mergeReadiness above, reviewPayload below).
3240
+ function porcelainPath(line) {
3241
+ let p = line.slice(3);
3242
+ const arrow = p.indexOf(' -> ');
3243
+ if (arrow >= 0)
3244
+ p = p.slice(arrow + 4);
3245
+ return p;
3246
+ }
3247
+ async function reviewHeadPair(root, branch, base) {
3248
+ const branchRef = `refs/heads/${branch}`, baseRef = `refs/heads/${base}`;
3249
+ const output = await gitA(['-C', root, 'for-each-ref', '--sort=refname', '--format=%(refname)%00%(objectname)', branchRef, baseRef]);
3250
+ const refs = new Map();
3251
+ for (const line of output.split('\n')) {
3252
+ const at = line.indexOf('\0');
3253
+ if (at > 0)
3254
+ refs.set(line.slice(0, at), line.slice(at + 1).trim());
3255
+ }
3256
+ const branchHead = refs.get(branchRef), baseHead = refs.get(baseRef);
3257
+ if (!branchHead || !baseHead || !isGitObjectId(root, branchHead) || !isGitObjectId(root, baseHead)) {
3258
+ throw new ResourceConflict(`review head pair is unproven: ${branchRef} or ${baseRef} is missing or not a native Git object id`);
3259
+ }
3260
+ return { branchHead, baseHead };
3261
+ }
3262
+ // @@@ lintGate - the spec↔code graph lint is a LOCATION gate: a function of the backend checkout's tree ALONE
3263
+ // (its .spec graph + governed files), not of which session is reviewed, and it costs a few seconds. Re-running
3264
+ // it on every reviewPayload — i.e. on every [[session-eval]] Proof-tab open, and once per session — is
3265
+ // wasteful, so memoize it on a whole-repo fingerprint: `rev-parse HEAD` + `status --porcelain` + the mtimes of
3266
+ // the changed paths (covers committed state, the dirty SET, and dirty-file CONTENT). An identical fingerprint
3267
+ // reuses the last (in-flight) result — a re-open or a second session's proof is instant — while any commit or
3268
+ // working-tree edit moves the fingerprint and recomputes. A rejected run is not cached.
3269
+ let gateCache = null;
3270
+ async function lintGate() {
3271
+ const root = repoRoot();
3272
+ const [head, status] = await Promise.all([
3273
+ gitA(['-C', root, 'rev-parse', 'HEAD']),
3274
+ gitA(['-C', root, 'status', '--porcelain', '--untracked-files=all']),
3275
+ ]);
3276
+ // `status --porcelain` gives the SET of changed paths + status letters but is CONTENT-BLIND: re-editing an
3277
+ // already-listed (dirty or untracked) file leaves the string byte-identical, so HEAD+status alone would
3278
+ // freeze the gate after a file first goes dirty. `--untracked-files=all` stops an untracked dir from
3279
+ // collapsing to one line (which hides a newly-added file); then fold each listed path's mtime in, so a
3280
+ // content edit to a dirty file also moves the fingerprint. HEAD covers committed state, this covers the
3281
+ // working tree. (Residual, accepted: the fingerprint is snapshot just before the compute, so a change
3282
+ // landing mid-compute is labelled with the pre-change fp — rare, and the gate is advisory, re-verified at merge.)
3283
+ const mtimes = status.split('\n').filter(Boolean).map(porcelainPath)
3284
+ .map((p) => { try {
3285
+ return statSync(join(root, p)).mtimeMs;
3286
+ }
3287
+ catch {
3288
+ return 0;
3289
+ } }).join(',');
3290
+ const fp = head.trim() + '\n' + status + '\n' + mtimes;
3291
+ if (gateCache?.fp === fp)
3292
+ return gateCache.p;
3293
+ const p = (async () => {
3294
+ const { specLint } = await import('./lint.js');
3295
+ const findings = await specLint();
3296
+ return {
3297
+ errorCount: findings.filter((f) => f.level === 'error').length,
3298
+ warningCount: findings.filter((f) => f.level === 'warn').length,
3299
+ };
3300
+ })();
3301
+ p.catch(() => { if (gateCache?.p === p)
3302
+ gateCache = null; }); // don't pin a failed run
3303
+ gateCache = { fp, p };
3304
+ return p;
3305
+ }
3306
+ // @@@ reviewPayload - assemble the cockpit review for one session. The four session-specific reads
3307
+ // (ahead / dirty / diff / conflict gate) plus the one location gate (lint) are all independent, so they run
3308
+ // in parallel. The lint gate goes through lintGate(), which memoizes it on the checkout's tree fingerprint —
3309
+ // so an unchanged tree doesn't re-run the lint on each review / Proof-tab open, while any commit or edit
3310
+ // invalidates and recomputes.
3311
+ export async function reviewPayload(id) {
3312
+ const wt = await findWorktree(id);
3313
+ if (!wt)
3314
+ return null;
3315
+ if (!wt.rec.governed || !wt.branch)
3316
+ throw new ResourceConflict(`session ${id} has no governed branch to review`);
3317
+ const base = mainBranch();
3318
+ const { branchHead, baseHead } = await reviewHeadPair(wt.path, wt.branch, base);
3319
+ const [aheadOut, statusOut, diff, conflictsWithMain, lint] = await Promise.all([
3320
+ gitA(['-C', wt.path, 'rev-list', '--count', `${baseHead}..${branchHead}`]),
3321
+ gitA(['-C', wt.path, 'status', '--porcelain', '--untracked-files=all']),
3322
+ mergeBaseDiff(wt.path, baseHead, branchHead),
3323
+ mergeConflicts(wt.path, baseHead, branchHead),
3324
+ lintGate(), // lint — memoized on the checkout fingerprint, not re-run per session/open
3325
+ ]);
3326
+ const settledPair = await reviewHeadPair(wt.path, wt.branch, base);
3327
+ if (settledPair.branchHead !== branchHead || settledPair.baseHead !== baseHead) {
3328
+ throw new ResourceConflict(`review head pair changed while assembling: started branch ${branchHead} / base ${baseHead}, ended branch ${settledPair.branchHead} / base ${settledPair.baseHead}`, 'session_review_head_changed');
3329
+ }
3330
+ // the worktree carries no SpexCode runtime files any more (the store lives in ~/.spexcode), so every dirty
3331
+ // path is genuine work — this is just the total uncommitted count.
3332
+ const dirtyNonRuntime = statusOut.split('\n').filter(Boolean).map(porcelainPath).length;
3333
+ return {
3334
+ id, node: wt.rec.node, branch: wt.branch,
3335
+ label: deriveLabel({ id, name: wt.rec.name, node: wt.rec.node, title: wt.rec.title, branch: wt.branch }),
3336
+ ahead: Number(aheadOut.trim()) || 0,
3337
+ dirtyNonRuntime, diff,
3338
+ gates: { conflictsWithMain, lint },
3339
+ proposal: { kind: wt.rec.proposal, note: wt.rec.note },
3340
+ };
3341
+ }
3342
+ const MERGE_PROMPT = `Merge your branch into main, then settle the session honestly.
3343
+
3344
+ 1. In your own worktree, merge the latest main into your branch. Resolve any conflicts there and re-run the tests.
3345
+ 2. Atomic landing: main only receives the completed branch as one no-ff merge. Never resolve conflicts in the shared main checkout.
3346
+ 3. Verify main advanced cleanly with no merge left in progress. If this task is settled, run \`spex session done --propose close\` as your FINAL action; otherwise declare the state that is true.`;
3347
+ export async function mergeSession(id) {
3348
+ const wt = await findWorktree(id);
3349
+ if (!wt?.branch)
3350
+ return { dispatched: false, reason: 'no such mergeable session' };
3351
+ const r = await sendText(id, MERGE_PROMPT, undefined, {
3352
+ deferDrain: true,
3353
+ acceptGuard: async (rec) => {
3354
+ if (!rec.governed || rec.status !== 'awaiting' || rec.proposal !== 'merge') {
3355
+ const error = new ResourceConflict(`session ${id} is not a governed awaiting merge proposal`);
3356
+ Object.assign(error, { code: 'session_merge_not_proposed' });
3357
+ throw error;
3358
+ }
3359
+ },
3360
+ });
3361
+ if (r.code === 'session_merge_not_proposed')
3362
+ return { dispatched: false, reason: r.error || 'merge dispatch refused', code: r.code, status: 409 };
3363
+ if (!r.ok)
3364
+ return { dispatched: false, reason: r.error || 'could not dispatch merge prompt' };
3365
+ await resumeSession(id, { guard: false });
3366
+ await drainSession(id);
3367
+ return { dispatched: true };
3368
+ }
3369
+ // @@@ killAgentProcess - the pane is the agent's HOME, not its LEASH. `kill-session` SIGHUPs the pane's
3370
+ // process group, and an idle agent goes with it (measured: ~0.8s) — but one mid-turn can outlive the whole
3371
+ // tmux server and keep running, orphaned, still holding its rendezvous socket (measured: pane gone, server
3372
+ // gone, agent still answering). Close promises ZERO residue including the process tree, so the teardown
3373
+ // escalates on the pid launch registered for exactly this purpose: give the SIGHUP its moment, then SIGTERM,
3374
+ // then SIGKILL, each bounded. This is also what lets the socket sweep run at all — a still-answering listener
3375
+ // is never ours to unlink, so an un-killed agent would otherwise strand its own socket forever.
3376
+ // The escalation is IDENTITY-GUARDED: a recorded pid can have been recycled by an unrelated process, so we
3377
+ // signal only the immutable pid/start instance whose ownership was witnessed in the exact tmux pane closure.
3378
+ // Unidentifiable → we signal nothing and let the adapter's proof-of-death rule leave the transport alone.
3379
+ const AGENT_EXIT_GRACE_MS = 3000;
3380
+ const SESSION_LEAF_RECEIPT_VERSION = 1;
3381
+ const SESSION_LEAF_RECEIPT_KIND = 'session-leaf';
3382
+ export function parseSessionLeafReceipt(raw, sessionId) {
3383
+ let value;
3384
+ try {
3385
+ value = JSON.parse(raw);
3386
+ }
3387
+ catch {
3388
+ return null;
3389
+ }
3390
+ if (!value || typeof value !== 'object' || Array.isArray(value))
3391
+ return null;
3392
+ const row = value;
3393
+ const keys = Object.keys(row).sort();
3394
+ if (keys.join(',') !== 'kind,pid,sessionId,startToken,version')
3395
+ return null;
3396
+ if (row.version !== SESSION_LEAF_RECEIPT_VERSION || row.kind !== SESSION_LEAF_RECEIPT_KIND || row.sessionId !== sessionId)
3397
+ return null;
3398
+ if (!Number.isSafeInteger(row.pid) || row.pid <= 0 || typeof row.startToken !== 'string' || !row.startToken)
3399
+ return null;
3400
+ return row;
3401
+ }
3402
+ function pidInPaneClosure(pid, panePid, procs) {
3403
+ const seen = new Set();
3404
+ for (let current = pid; current > 0 && !seen.has(current);) {
3405
+ if (current === panePid)
3406
+ return true;
3407
+ seen.add(current);
3408
+ const row = procs.get(current);
3409
+ if (!row || row.ppid === current)
3410
+ return false;
3411
+ current = row.ppid;
3412
+ }
3413
+ return false;
3414
+ }
3415
+ export function sessionLeafReceiptCandidate(sessionId, pid, panePid, procs, startBefore, startAfter) {
3416
+ if (!panePid)
3417
+ return { ok: false, reason: 'exact target pane PID is unavailable' };
3418
+ if (!procs)
3419
+ return { ok: false, reason: 'process snapshot is unavailable' };
3420
+ if (!startBefore || !startAfter)
3421
+ return { ok: false, reason: 'leaf process-start identity is unreadable' };
3422
+ if (startBefore !== startAfter)
3423
+ return { ok: false, reason: 'leaf process-start identity changed during ancestry observation' };
3424
+ if (!pidInPaneClosure(pid, panePid, procs))
3425
+ return { ok: false, reason: `registered leaf PID ${pid} is not in exact target pane ${panePid} descendant closure` };
3426
+ return {
3427
+ ok: true,
3428
+ receipt: { version: SESSION_LEAF_RECEIPT_VERSION, kind: SESSION_LEAF_RECEIPT_KIND, sessionId, pid, startToken: startAfter },
3429
+ };
3430
+ }
3431
+ export function sessionLeafReceiptIdentityState(receipt, registeredPid, currentStartToken, liveness) {
3432
+ if (registeredPid == null)
3433
+ return 'registration-missing';
3434
+ if (registeredPid !== receipt.pid)
3435
+ return 'registration-changed';
3436
+ if (liveness === 'unknown')
3437
+ return 'unknown';
3438
+ if (liveness === 'dead')
3439
+ return currentStartToken ? 'unknown' : 'gone';
3440
+ if (!currentStartToken)
3441
+ return 'unknown';
3442
+ return currentStartToken === receipt.startToken ? 'same-live' : 'pid-reused';
3443
+ }
3444
+ const sessionLeafReceiptPath = (id) => sessionArtifactPath(id, 'agent.identity.json');
3445
+ function readSessionLeafReceipt(id) {
3446
+ const path = sessionLeafReceiptPath(id);
3447
+ let raw;
3448
+ try {
3449
+ raw = readFileSync(path, 'utf8');
3450
+ }
3451
+ catch (error) {
3452
+ if (error.code === 'ENOENT')
3453
+ return { state: 'missing' };
3454
+ return { state: 'invalid', reason: `leaf birth receipt is unreadable (${error instanceof Error ? error.message : String(error)})` };
3455
+ }
3456
+ const receipt = parseSessionLeafReceipt(raw, id);
3457
+ return receipt ? { state: 'valid', receipt } : { state: 'invalid', reason: 'leaf birth receipt is malformed or names a different session' };
3458
+ }
3459
+ function writeSessionLeafReceipt(id, receipt) {
3460
+ const path = sessionLeafReceiptPath(id);
3461
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
3462
+ try {
3463
+ writeFileSync(temp, `${JSON.stringify(receipt)}\n`, { mode: 0o600 });
3464
+ renameSync(temp, path);
3465
+ }
3466
+ finally {
3467
+ rmSync(temp, { force: true });
3468
+ }
3469
+ }
3470
+ function clearSessionLeafArtifacts(id) {
3471
+ rmSync(sessionArtifactPath(id, 'agent.pid'), { force: true });
3472
+ pidRegistry.delete(id);
3473
+ rmSync(sessionLeafReceiptPath(id), { force: true });
3474
+ }
3475
+ const sameSessionLeafReceipt = (left, right) => left.version === right.version && left.kind === right.kind && left.sessionId === right.sessionId
3476
+ && left.pid === right.pid && left.startToken === right.startToken;
3477
+ async function killAgentProcess(id, beforeSignal, leaf) {
3478
+ const pid = leaf.pid;
3479
+ const startToken = leaf.startToken;
3480
+ const alive = () => leafAlive(pid);
3481
+ const identityState = () => {
3482
+ const stored = readSessionLeafReceipt(id);
3483
+ if (stored.state !== 'valid' || !sameSessionLeafReceipt(stored.receipt, leaf.receipt))
3484
+ return 'changed';
3485
+ const registered = readAgentPid(sessionArtifactPath(id, 'agent.pid'));
3486
+ const liveness = leafProcessLiveness(pid);
3487
+ const currentStartToken = sessionLeafStartToken(pid);
3488
+ const state = sessionLeafReceiptIdentityState(stored.receipt, Number.isSafeInteger(registered) ? registered : null, currentStartToken, liveness);
3489
+ return state === 'same-live' ? 'same' : state === 'gone' ? 'gone' : 'changed';
3490
+ };
3491
+ const initialState = identityState();
3492
+ if (initialState === 'gone')
3493
+ return;
3494
+ if (initialState === 'changed')
3495
+ throw new ResourceConflict(`refusing to stop ${id}: session leaf identity changed before signal`);
3496
+ const gone = async (ms) => {
3497
+ for (const end = Date.now() + ms; Date.now() < end;) {
3498
+ if (!alive())
3499
+ return true;
3500
+ await new Promise((r) => setTimeout(r, 100));
3501
+ }
3502
+ return !alive();
3503
+ };
3504
+ if (await gone(AGENT_EXIT_GRACE_MS))
3505
+ return; // the pane's SIGHUP took it — the normal path
3506
+ for (const sig of ['SIGTERM', 'SIGKILL']) {
3507
+ await beforeSignal();
3508
+ const state = identityState();
3509
+ if (state === 'gone')
3510
+ return;
3511
+ if (state === 'changed')
3512
+ throw new ResourceConflict(`refusing to stop ${id}: session leaf identity changed during escalation`);
3513
+ try {
3514
+ process.kill(pid, sig);
3515
+ }
3516
+ catch {
3517
+ return;
3518
+ } // vanished between checks
3519
+ if (await gone(sig === 'SIGTERM' ? AGENT_EXIT_GRACE_MS : 1000))
3520
+ return;
3521
+ }
3522
+ throw new ResourceConflict(`refusing to stop ${id}: exact leaf PID ${pid}@${startToken} remains live after escalation`);
3523
+ }
3524
+ const hostLeafProcessLiveness = (pid) => {
3525
+ try {
3526
+ process.kill(pid, 0);
3527
+ return 'alive';
3528
+ }
3529
+ catch (error) {
3530
+ const code = error?.code;
3531
+ if (code === 'ESRCH')
3532
+ return 'dead';
3533
+ if (code === 'EPERM')
3534
+ return 'alive';
3535
+ return 'unknown';
3536
+ }
3537
+ };
3538
+ let sessionLeafProcessProbe = {
3539
+ startToken: processStartToken,
3540
+ liveness: hostLeafProcessLiveness,
3541
+ };
3542
+ export function installSessionLeafProcessProbeForTest(probe) {
3543
+ const previous = sessionLeafProcessProbe;
3544
+ sessionLeafProcessProbe = probe;
3545
+ return () => { sessionLeafProcessProbe = previous; };
3546
+ }
3547
+ const sessionLeafStartToken = (pid) => sessionLeafProcessProbe.startToken(pid);
3548
+ const leafProcessLiveness = (pid) => sessionLeafProcessProbe.liveness(pid);
3549
+ const leafAlive = (pid) => leafProcessLiveness(pid) !== 'dead';
3550
+ // A retained leaf can mint durable ownership only while its registered PID is in the exact target pane's process
3551
+ // closure. The receipt then binds that one immutable process instance across tmux reparenting and crash retry.
3552
+ async function inspectSessionLeafIdentity(id, rec) {
3553
+ if (harnessById(rec.harness || defaultHarness.id).runtimeOwnership === 'adapter')
3554
+ return { state: 'missing' };
3555
+ const path = sessionArtifactPath(id, 'agent.pid');
3556
+ let raw;
3557
+ try {
3558
+ raw = readFileSync(path, 'utf8');
3559
+ }
3560
+ catch (error) {
3561
+ if (error?.code === 'ENOENT') {
3562
+ const stored = readSessionLeafReceipt(id);
3563
+ if (stored.state === 'invalid')
3564
+ return { state: 'unknown', reason: stored.reason };
3565
+ if (stored.state === 'missing')
3566
+ return { state: 'missing' };
3567
+ const liveness = leafProcessLiveness(stored.receipt.pid);
3568
+ const current = sessionLeafStartToken(stored.receipt.pid);
3569
+ const state = sessionLeafReceiptIdentityState(stored.receipt, stored.receipt.pid, current, liveness);
3570
+ if (state === 'same-live')
3571
+ return { state: 'unknown', pid: stored.receipt.pid, reason: 'leaf birth receipt remains live but agent.pid registration is missing' };
3572
+ if (state === 'unknown')
3573
+ return { state: 'unknown', pid: stored.receipt.pid, reason: 'leaf birth receipt PID is live but its process-start identity is unreadable' };
3574
+ if (state !== 'gone' && state !== 'pid-reused')
3575
+ return { state: 'unknown', pid: stored.receipt.pid, reason: `leaf birth receipt cannot reconcile a missing registration (${state})` };
3576
+ rmSync(sessionLeafReceiptPath(id), { force: true });
3577
+ return { state: 'dead', pid: stored.receipt.pid };
3578
+ }
3579
+ return { state: 'unknown', reason: `leaf PID artifact is unreadable (${error instanceof Error ? error.message : String(error)})` };
3580
+ }
3581
+ const pid = Number(raw.trim());
3582
+ if (!Number.isSafeInteger(pid) || pid <= 0)
3583
+ return { state: 'unknown', reason: 'leaf PID artifact is malformed' };
3584
+ const stored = readSessionLeafReceipt(id);
3585
+ if (stored.state === 'invalid')
3586
+ return { state: 'unknown', pid, reason: stored.reason };
3587
+ if (stored.state === 'valid') {
3588
+ const liveness = leafProcessLiveness(pid);
3589
+ const current = sessionLeafStartToken(pid);
3590
+ const state = sessionLeafReceiptIdentityState(stored.receipt, pid, current, liveness);
3591
+ if (state === 'same-live')
3592
+ return { state: 'owned', identity: { pid, startToken: current, receipt: stored.receipt } };
3593
+ if (state === 'gone') {
3594
+ clearSessionLeafArtifacts(id);
3595
+ return { state: 'dead', pid };
3596
+ }
3597
+ if (state === 'pid-reused') {
3598
+ const snap = await liveSnapshot(id);
3599
+ if (snap.probeFailed)
3600
+ return { state: 'unknown', pid, reason: 'target pane state is unreadable while reconciling a retired leaf receipt' };
3601
+ if (snap.windows.has(id))
3602
+ return { state: 'unknown', pid, reason: `leaf birth receipt no longer matches PID ${pid} while the exact target pane remains live` };
3603
+ clearSessionLeafArtifacts(id);
3604
+ return { state: 'dead', pid };
3605
+ }
3606
+ if (state === 'unknown')
3607
+ return { state: 'unknown', pid, reason: `leaf PID ${pid} is live but its process-start identity is unreadable` };
3608
+ return { state: 'unknown', pid, reason: 'leaf birth receipt and agent.pid registration disagree' };
3609
+ }
3610
+ const snap = await liveSnapshot(id);
3611
+ if (snap.probeFailed)
3612
+ return { state: 'unknown', pid, reason: 'exact target pane is unreadable; leaf birth receipt cannot be minted' };
3613
+ const panePid = snap.windows.get(id)?.panePid ?? null;
3614
+ const startBefore = sessionLeafStartToken(pid);
3615
+ if (!startBefore) {
3616
+ const live = leafProcessLiveness(pid);
3617
+ if (live !== 'dead')
3618
+ return { state: 'unknown', pid, reason: `leaf PID ${pid} process-start identity is unreadable while liveness is ${live}` };
3619
+ clearSessionLeafArtifacts(id);
3620
+ return { state: 'dead', pid };
3621
+ }
3622
+ let procs = null;
3623
+ try {
3624
+ procs = await procSnapshot();
3625
+ }
3626
+ catch { /* fail closed below */ }
3627
+ const startAfter = sessionLeafStartToken(pid);
3628
+ const candidate = sessionLeafReceiptCandidate(id, pid, panePid, procs, startBefore, startAfter);
3629
+ if (!candidate.ok || !candidate.receipt)
3630
+ return { state: 'unknown', pid, reason: candidate.reason || 'leaf birth receipt proof failed' };
3631
+ if (readAgentPid(path) !== pid || sessionLeafStartToken(pid) !== candidate.receipt.startToken)
3632
+ return { state: 'unknown', pid, reason: `leaf PID ${pid} identity changed before receipt commit` };
3633
+ writeSessionLeafReceipt(id, candidate.receipt);
3634
+ const committed = readSessionLeafReceipt(id);
3635
+ if (committed.state !== 'valid' || !sameSessionLeafReceipt(committed.receipt, candidate.receipt)
3636
+ || readAgentPid(path) !== pid || sessionLeafStartToken(pid) !== candidate.receipt.startToken)
3637
+ return { state: 'unknown', pid, reason: `leaf PID ${pid} identity changed while receipt committed` };
3638
+ return { state: 'owned', identity: { pid, startToken: candidate.receipt.startToken, receipt: candidate.receipt } };
3639
+ }
3640
+ async function assertSessionLeafOwned(id, rec) {
3641
+ if (harnessById(rec.harness || defaultHarness.id).runtimeOwnership === 'adapter')
3642
+ return null;
3643
+ const observed = await inspectSessionLeafIdentity(id, rec);
3644
+ if (observed.state === 'missing') {
3645
+ if (rec.stopped || rec.status === 'queued')
3646
+ return null;
3647
+ throw new ResourceConflict(`refusing to stop ${id}: no readable session-owned leaf PID`);
3648
+ }
3649
+ if (observed.state === 'dead')
3650
+ return null;
3651
+ if (observed.state === 'owned')
3652
+ return observed.identity;
3653
+ throw new ResourceConflict(`refusing to stop ${id}: ${observed.reason}`);
3654
+ }
3655
+ async function stopAgentProcess(id, rec, requireCold = false, coldReceipt) {
3656
+ // The caller resolves one readable owner before entering this seam. An absent/corrupt record never reaches
3657
+ // tmux, signals, or adapter cleanup: a bare session id is an address, not ownership authority.
3658
+ const assertOwned = () => assertSessionStopSafe(id, rec ? { ...rec, harness: rec.harness } : null, { ...(requireCold && coldReceipt !== undefined ? { coldReceipt } : {}) });
3659
+ await assertOwned();
3660
+ if (!rec)
3661
+ throw new ResourceConflict(`refusing to stop ${id}: no readable session owner`);
3662
+ const harness = harnessById(rec.harness || defaultHarness.id);
3663
+ const leaf = await assertSessionLeafOwned(id, rec);
3664
+ // Adapter-owned headless sessions may have no live leaf PID, but launch still created an exact tmux session
3665
+ // wrapper. Kill that session-id unconditionally; runtimeOwnership only changes the leaf receipt proof, never the
3666
+ // exact tmux teardown.
3667
+ await tmuxOk(['kill-session', '-t', id]);
3668
+ await assertTargetTmuxAbsent(id, 'after kill');
3669
+ if (leaf) {
3670
+ await killAgentProcess(id, assertOwned, leaf);
3671
+ const registered = readAgentPid(sessionArtifactPath(id, 'agent.pid'));
3672
+ const liveness = leafProcessLiveness(leaf.pid);
3673
+ const currentStartToken = sessionLeafStartToken(leaf.pid);
3674
+ const finalState = sessionLeafReceiptIdentityState(leaf.receipt, Number.isSafeInteger(registered) ? registered : null, currentStartToken, liveness);
3675
+ if (finalState !== 'gone' && finalState !== 'pid-reused')
3676
+ throw new ResourceConflict(`refusing to stop ${id}: exact leaf teardown remains ${finalState}`);
3677
+ clearSessionLeafArtifacts(id);
3678
+ }
3679
+ launchedAt.delete(id);
3680
+ await harness.cleanupRuntime(rec);
3681
+ if (requireCold) {
3682
+ const cold = await harness.coldRuntime?.(rec, coldReceipt);
3683
+ if (cold && !cold.ok)
3684
+ throw new ResourceConflict(`refusing to archive ${id}: ${cold.reason}`);
3685
+ }
3686
+ }
3687
+ async function stopSessionUnlocked(id) {
3688
+ let wt;
3689
+ try {
3690
+ wt = await findWorktree(id);
3691
+ }
3692
+ catch (e) {
3693
+ if (!(e instanceof SessionRecordUnusable) || e.code !== 'corrupt')
3694
+ throw e;
3695
+ await stopAgentProcess(id, null);
3696
+ throw e;
3697
+ }
3698
+ if (!wt)
3699
+ return false;
3700
+ await stopAgentProcess(id, wt.rec);
3701
+ const rec = readRecord(id);
3702
+ if (rec)
3703
+ writeRecord({ ...rec, stopped: true });
3704
+ if (wt.rec.status !== 'queued')
3705
+ requestQueueDrain(); // a live stop frees a slot; a prepared queue never held one
3706
+ return !!wt;
3707
+ }
3708
+ export const stopSession = (id) => withSessionTransition(id, () => withRecordLock(id, () => stopSessionUnlocked(id)));
3709
+ async function archiveSessionUnlocked(id, on = true) {
3710
+ let wt;
3711
+ try {
3712
+ wt = await findWorktree(id);
3713
+ }
3714
+ catch (e) {
3715
+ if (e instanceof SessionRecordUnusable)
3716
+ throw new ResourceConflict(`refusing to archive ${id}: ${e.message}`);
3717
+ throw e;
3718
+ }
3719
+ if (!wt)
3720
+ return false;
3721
+ if (!on)
3722
+ throw new ResourceConflict('unarchive is not a record-only transition; use resume to restore the runtime');
3723
+ if (wt.rec.status === 'queued')
3724
+ throw new ResourceConflict(`refusing to archive ${id}: queued sessions have only a prepared launch prompt; resume/startQueued is their lifecycle`);
3725
+ const retired = retirementReason(wt.rec);
3726
+ if (retired)
3727
+ throw new ResourceConflict(`refusing to archive ${id}: ${retired}`);
3728
+ archiving.add(id);
3729
+ try {
3730
+ const h = harnessById(wt.rec.harness || defaultHarness.id);
3731
+ const settleArchiveRecovery = () => {
3732
+ const current = readRecord(id);
3733
+ if (current?.adapterRecovery)
3734
+ writeRecord({ ...current, adapterRecovery: null });
3735
+ };
3736
+ // A proven cold record is already archived; never clear it and issue a second thread/archive RPC. Verify the
3737
+ // adapter's exact resident reference first so an externally respawned thread is repaired rather than hidden.
3738
+ if (wt.rec.archived && hasValidColdProof(wt.rec)) {
3739
+ const proofSnap = await liveSnapshot(id);
3740
+ if (proofSnap.probeFailed)
3741
+ throw new ResourceConflict(`refusing to re-archive ${id}: liveness probe failed; the exact leaf may have respawned`);
3742
+ const proofLv = h.runtimeOwnership === 'adapter'
3743
+ ? (proofSnap.windows.has(id) ? 'online' : 'offline')
3744
+ : liveness({ ...wt.rec, archived: false, stopped: false }, proofSnap);
3745
+ if (proofLv === 'unknown' || proofLv === 'starting')
3746
+ throw new ResourceConflict(`refusing to re-archive ${id}: session liveness is ${proofLv}; exact cold state is unproven`);
3747
+ if (proofLv === 'offline') {
3748
+ // A deliberately stopped shared control plane is a valid empty resident census. A durable proof plus
3749
+ // an adapter-owned root-absent fact is the only idempotent short-circuit; a live root still has to prove
3750
+ // the thread's archived/non-archived disk collection before we can claim it is already cold.
3751
+ const rootAbsent = await Promise.all((h.sharedRuntimes?.(runtimeRoot()) ?? []).map(async (descriptor) => {
3752
+ if (!descriptor.residency)
3753
+ return false;
3754
+ const state = await descriptor.residency();
3755
+ return state.healthy && state.rootAbsent === true && state.referenceIds.length === 0;
3756
+ })).then((states) => states.some(Boolean));
3757
+ if (rootAbsent) {
3758
+ settleArchiveRecovery();
3759
+ return true;
3760
+ }
3761
+ const pre = await h.coldPreflight?.({ ...wt.rec, archived: false, stopped: true });
3762
+ if (!pre || pre.ok) {
3763
+ const cold = await h.coldRuntime?.({ ...wt.rec, archived: false, stopped: true }, pre?.ok ? pre.receipt : undefined);
3764
+ if (!cold || cold.ok) {
3765
+ settleArchiveRecovery();
3766
+ return true;
3767
+ }
3768
+ }
3769
+ }
3770
+ }
3771
+ // Legacy/respawned archives are made visible before repair. Any refusal below therefore leaves an unarchived
3772
+ // row instead of relying on a hidden bit while a runtime proof is missing.
3773
+ if (wt.rec.archived) {
3774
+ writeRecord({ ...wt.rec, archived: false, coldProof: null });
3775
+ wt = await findWorktree(id);
3776
+ if (!wt)
3777
+ return false;
3778
+ }
3779
+ const snap = await liveSnapshot(id);
3780
+ if (snap.probeFailed)
3781
+ throw new ResourceConflict(`refusing to archive ${id}: liveness probe failed; the leaf may still be live`);
3782
+ const lv = h.runtimeOwnership === 'adapter'
3783
+ ? 'offline'
3784
+ : liveness({ ...wt.rec, archived: false, stopped: false }, snap);
3785
+ if (lv === 'unknown' || lv === 'starting')
3786
+ throw new ResourceConflict(`refusing to archive ${id}: session liveness is ${lv}; exact leaf ownership is unproven`);
3787
+ // The adapter guard runs BEFORE any tmux/process signal. Active/unknown native turns and ambiguous descendant
3788
+ // ownership refuse here; a verified adapter receipt carries an exact subtree through to coldRuntime's commit.
3789
+ assertSessionOwnerSafe(id, h.id);
3790
+ const preflight = await h.coldPreflight?.({ ...wt.rec, archived: false, stopped: lv === 'offline' });
3791
+ if (preflight && !preflight.ok)
3792
+ throw new ResourceConflict(`refusing to archive ${id}: ${preflight.reason}`);
3793
+ // Even a proven-offline leaf can leave a stale rendezvous/socket or adapter artifact. Reuse the same exact
3794
+ // teardown seam with the explicit stopped marker so cleanupRuntime gets its ownership check and no second
3795
+ // cleanup primitive is invented.
3796
+ let coldCommitted = false;
3797
+ let coldAttempted = false;
3798
+ try {
3799
+ coldAttempted = true;
3800
+ await stopAgentProcess(id, { ...wt.rec, archived: false, stopped: lv === 'offline' }, true, preflight?.ok ? preflight.receipt : undefined);
3801
+ coldCommitted = true;
3802
+ const latest = readRecord(id);
3803
+ if (!latest)
3804
+ throw new ResourceConflict(`refusing to archive ${id}: session record disappeared before filing`);
3805
+ // The leaf identity kill and adapter cold proof established process/transport absence. Record-backed
3806
+ // adapters intentionally project online until the archive write, so display liveness is not physical
3807
+ // evidence here. A target pane appearing after the stop proof is the remaining shared runtime witness.
3808
+ await assertTargetTmuxAbsent(id, 'before archive filing');
3809
+ writeRecord({ ...latest, archived: true, stopped: true, coldProof: coldProofFor(latest), adapterRecovery: null });
3810
+ }
3811
+ catch (error) {
3812
+ if (coldCommitted) {
3813
+ const restored = await h.restoreRuntime?.(wt.rec, preflight?.ok ? preflight.receipt : undefined);
3814
+ if (restored && !restored.ok) {
3815
+ const current = readRecord(id);
3816
+ if (current)
3817
+ writeRecord({ ...current, archived: false, stopped: true, coldProof: null, adapterRecovery: `restore-runtime:${restored.reason}` });
3818
+ console.error(`spex: archive compensation for ${id} failed: ${restored.reason}`);
3819
+ }
3820
+ }
3821
+ else if (coldAttempted && error instanceof Error && /compensation failed|state is unknown|reconciliation failed/i.test(error.message)) {
3822
+ const current = readRecord(id);
3823
+ if (current)
3824
+ writeRecord({ ...current, archived: false, stopped: true, coldProof: coldProofFor(current), adapterRecovery: `restore-runtime:${error.message}` });
3825
+ }
3826
+ throw error;
3827
+ }
3828
+ requestQueueDrain();
3829
+ return true;
3830
+ }
3831
+ finally {
3832
+ archiving.delete(id);
3833
+ }
3834
+ }
3835
+ export const archiveSession = (id, on = true) => {
3836
+ if (!on)
3837
+ return archiveSessionUnarchive(id);
3838
+ return withSessionTransition(id, () => withRecordLock(id, () => archiveSessionUnlocked(id, on)));
3839
+ };
3840
+ async function archiveSessionUnarchive(id) {
3841
+ const wt = await findWorktree(id);
3842
+ if (!wt)
3843
+ return false;
3844
+ if (!wt.rec.archived)
3845
+ return true;
3846
+ const resumed = await resumeSession(id);
3847
+ if (!resumed.ok)
3848
+ throw new ResourceConflict(resumed.error || `refusing to resume ${id}`);
3849
+ return true;
3850
+ }
3851
+ // @@@ cold retirement - archive already returned the target's runtime, so closing a proven-cold row must not
3852
+ // re-enter the live stop guard and make unrelated shared-root references prove ownership again. Verify only
3853
+ // that the target-bound cold proof is still current and that no target PID/window/socket/thread has reappeared.
3854
+ // This is read-only: no signal, adapter mutation, or shared-root cleanup belongs on the cold path.
3855
+ async function assertColdRetirementSafe(id, rec) {
3856
+ if (!rec.archived || !rec.stopped || !hasValidColdProof(rec))
3857
+ throw new ResourceConflict(`refusing to close archived session ${id}: target-bound cold witness is missing or stale`);
3858
+ if (rec.adapterRecovery)
3859
+ throw new ResourceConflict(`refusing to close archived session ${id}: adapter recovery is pending (${rec.adapterRecovery})`);
3860
+ const [snap, socket] = await Promise.all([liveSnapshot(id), rendezvousListening(id)]);
3861
+ if (snap.probeFailed)
3862
+ throw new ResourceConflict(`refusing to close archived session ${id}: liveness probe failed; target runtime absence is unproven`);
3863
+ if (snap.windows.has(id))
3864
+ throw new ResourceConflict(`refusing to close archived session ${id}: target tmux window has reappeared`);
3865
+ if (socket === 'live')
3866
+ throw new ResourceConflict(`refusing to close archived session ${id}: target rendezvous transport has reappeared`);
3867
+ if (socket === 'unproven')
3868
+ throw new ResourceConflict(`refusing to close archived session ${id}: target rendezvous state is ambiguous`);
3869
+ const leaf = await inspectSessionLeafIdentity(id, rec);
3870
+ if (leaf.state === 'owned')
3871
+ throw new ResourceConflict(`refusing to close archived session ${id}: target leaf PID ${leaf.identity.pid} is live or recycled; ownership is ambiguous`);
3872
+ if (leaf.state === 'unknown')
3873
+ throw new ResourceConflict(`refusing to close archived session ${id}: ${leaf.reason}; ownership is ambiguous`);
3874
+ const harness = harnessById(rec.harness || defaultHarness.id);
3875
+ if (harness.coldRetirementPreflight) {
3876
+ const proof = await harness.coldRetirementPreflight(rec);
3877
+ if (!proof.ok)
3878
+ throw new ResourceConflict(`refusing to close archived session ${id}: ${proof.reason}`);
3879
+ return;
3880
+ }
3881
+ const descriptors = harness.sharedRuntimes?.(runtimeRoot()) ?? [];
3882
+ let everySharedRootAbsent = descriptors.length > 0;
3883
+ for (const descriptor of descriptors) {
3884
+ const resident = descriptor.residency
3885
+ ? await descriptor.residency()
3886
+ : await descriptor.probe().then((probe) => ({ healthy: probe.healthy, referenceIds: probe.references.map((reference) => reference.referenceId), error: probe.error }));
3887
+ if (!resident.healthy)
3888
+ throw new ResourceConflict(`refusing to close archived session ${id}: ${resident.error || `${descriptor.label} resident census is unhealthy`}`);
3889
+ if (rec.harnessSessionId && resident.referenceIds.includes(rec.harnessSessionId))
3890
+ throw new ResourceConflict(`refusing to close archived session ${id}: target adapter thread ${rec.harnessSessionId} is loaded`);
3891
+ everySharedRootAbsent = everySharedRootAbsent && resident.rootAbsent === true;
3892
+ }
3893
+ if (harness.coldPreflight && !everySharedRootAbsent) {
3894
+ const proof = await harness.coldPreflight(rec);
3895
+ if (!proof.ok)
3896
+ throw new ResourceConflict(`refusing to close archived session ${id}: ${proof.reason}`);
3897
+ if (!proof.alreadyCold)
3898
+ throw new ResourceConflict(`refusing to close archived session ${id}: target adapter collection is not proven cold`);
3899
+ }
3900
+ }
3901
+ async function assertDiscardableWorktree(id, path, branch, kind) {
3902
+ if (existsSync(path)) {
3903
+ const status = await gitTry(['-C', path, 'status', '--porcelain', '--untracked-files=all']);
3904
+ if (!status.ok)
3905
+ throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} worktree status is unreadable`);
3906
+ if (status.stdout.trim())
3907
+ throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} worktree has dirty work`);
3908
+ }
3909
+ if (branch) {
3910
+ const resolved = await gitTry(['-C', mainRoot(), 'rev-parse', '--verify', `${branch}^{commit}`]);
3911
+ if (resolved.ok) {
3912
+ const count = await gitTry(['-C', mainRoot(), 'rev-list', '--count', `${mainBranch()}..${branch}`]);
3913
+ if (!count.ok)
3914
+ throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} branch ancestry is unreadable`);
3915
+ const ahead = Number(count.stdout.trim());
3916
+ if (!Number.isFinite(ahead) || ahead !== 0)
3917
+ throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} branch is ${Number.isFinite(ahead) ? ahead : 'an unknown number of'} commit(s) ahead`);
3918
+ }
3919
+ else if (resolved.failure !== 'exit') {
3920
+ throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} branch identity is unreadable`);
3921
+ }
3922
+ else if (existsSync(path)) {
3923
+ throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} worktree exists but branch ${branch} is missing`);
3924
+ }
3925
+ }
3926
+ }
3927
+ // A never-launched queue owns only prepared disk state. The transition/record locks around close serialize
3928
+ // this check with startQueued: whichever wins decides whether the record is still a queue or has become live.
3929
+ // No shared-runtime probe belongs here because a valid prepared row has no adapter thread to look up.
3930
+ async function assertQueuedRetirementSafe(id, rec, path, branch) {
3931
+ if (rec.status !== 'queued' || rec.harnessSessionId)
3932
+ throw new ResourceConflict(`refusing to close queued session ${id}: the record has a target thread or is no longer queued`);
3933
+ if (rec.adapterRecovery || launching.has(id))
3934
+ throw new ResourceConflict(`refusing to close queued session ${id}: target launch/recovery is already in progress`);
3935
+ const [snap, socket] = await Promise.all([liveSnapshot(id), rendezvousListening(id)]);
3936
+ if (snap.probeFailed)
3937
+ throw new ResourceConflict(`refusing to close queued session ${id}: liveness probe failed; target runtime absence is unproven`);
3938
+ if (snap.windows.has(id))
3939
+ throw new ResourceConflict(`refusing to close queued session ${id}: target tmux window already exists`);
3940
+ if (socket === 'live')
3941
+ throw new ResourceConflict(`refusing to close queued session ${id}: target rendezvous transport already exists`);
3942
+ if (socket === 'unproven')
3943
+ throw new ResourceConflict(`refusing to close queued session ${id}: target rendezvous state is ambiguous`);
3944
+ const pidPath = sessionArtifactPath(id, 'agent.pid');
3945
+ if (existsSync(pidPath)) {
3946
+ const pid = readAgentPid(pidPath);
3947
+ throw new ResourceConflict(`refusing to close queued session ${id}: target leaf PID artifact ${Number.isFinite(pid) && pid > 0 ? pid : 'is unreadable'}; never-launched ownership is ambiguous`);
3948
+ }
3949
+ await assertDiscardableWorktree(id, path, branch, 'prepared');
3950
+ }
3951
+ // A launch may leave its row active before Codex publishes the native thread binding. This close path owns
3952
+ // only the record's dead local launch residue; an unbound app-server peer stays unowned and untouched.
3953
+ async function assertUnboundRetirementSafe(id, rec, path, branch) {
3954
+ if (harnessById(rec.harness || defaultHarness.id).exactNativeTargetId(rec) || rec.status === 'queued' || rec.archived)
3955
+ throw new ResourceConflict(`refusing to close unbound session ${id}: it is not an unbound live-record residue`);
3956
+ if (rec.adapterRecovery || rec.launchReadinessPending || launching.has(id) || existsSync(sessionArtifactPath(id, 'launch')))
3957
+ throw new ResourceConflict(`refusing to close unbound session ${id}: launch or recovery is still in progress`);
3958
+ const [snap, socket] = await Promise.all([liveSnapshot(id), rendezvousListening(id)]);
3959
+ if (snap.probeFailed)
3960
+ throw new ResourceConflict(`refusing to close unbound session ${id}: liveness probe failed; local worker absence is unproven`);
3961
+ const harness = harnessById(rec.harness || defaultHarness.id);
3962
+ if (harness.liveness(rec, snap.windows.has(id), runtimeRoot(), snap.windows.get(id), snap.sockets.has(id)) === 'online')
3963
+ throw new ResourceConflict(`refusing to close unbound session ${id}: its adapter still reports a live worker`);
3964
+ if (socket === 'live')
3965
+ throw new ResourceConflict(`refusing to close unbound session ${id}: target rendezvous transport already exists`);
3966
+ if (socket === 'unproven')
3967
+ throw new ResourceConflict(`refusing to close unbound session ${id}: target rendezvous state is ambiguous`);
3968
+ const leaf = await inspectSessionLeafIdentity(id, rec);
3969
+ if (leaf.state !== 'missing' && leaf.state !== 'dead')
3970
+ throw new ResourceConflict(`refusing to close unbound session ${id}: ${leaf.state === 'unknown' ? leaf.reason : 'target leaf identity is live or ambiguous'}`);
3971
+ await assertDiscardableWorktree(id, path, branch, 'unbound');
3972
+ }
3973
+ async function closeOwnedSessionUnlocked(id, wt, source, unboundRetired = false) {
3974
+ const root = mainRoot();
3975
+ const receiptFailure = publishedSessionCandidateReceiptRetirementFailure(wt.rec, root);
3976
+ if (receiptFailure)
3977
+ throw new ResourceConflict(`refusing destructive close for ${id}: ${receiptFailure}; public record and resources remain the authority fence`);
3978
+ const closesCodexBinding = (wt.rec.harness === 'codex' || wt.rec.harness === 'codex-headless') && !!wt.rec.harnessSessionId;
3979
+ const retired = !wt.rec.archived && !!retirementReason(wt.rec);
3980
+ // A retired row has already lost its worktree; close is its explicit record-only terminal cleanup.
3981
+ if (!retired) {
3982
+ if (wt.rec.archived)
3983
+ await assertColdRetirementSafe(id, wt.rec);
3984
+ else if (wt.rec.status === 'queued')
3985
+ await assertQueuedRetirementSafe(id, wt.rec, wt.path, wt.branch);
3986
+ else if (!unboundRetired)
3987
+ throw new ResourceConflict(`refusing to close ${id}: target runtime was not cold-retired first`);
3988
+ }
3989
+ // The marker protects only the destructive half. A failed cold proof must leave a normal, resumable binding.
3990
+ if (closesCodexBinding)
3991
+ prepareCodexGenerationClose(runtimeRoot(), id, wt.rec.harnessSessionId);
3992
+ appendCloseLedger(id, wt.rec, source);
3993
+ let slot = null;
3994
+ try {
3995
+ slot = treeSlotDir(wt.path);
3996
+ }
3997
+ catch { /* tree already unresolvable — nothing to key the slot by */ }
3998
+ // a retired session's worktree/branch are already gone; removing them is a no-op to skip, not a failure.
3999
+ if (existsSync(wt.path)) {
4000
+ const removed = await gitTry(['-C', root, 'worktree', 'remove', '--force', wt.path]);
4001
+ if (!removed.ok)
4002
+ throw new ResourceConflict(`refusing to finish close for ${id}: worktree removal failed`);
4003
+ if (existsSync(wt.path))
4004
+ throw new ResourceConflict(`refusing to finish close for ${id}: worktree remains after removal`);
4005
+ }
4006
+ if (wt.branch) {
4007
+ const branchRef = `refs/heads/${wt.branch}`;
4008
+ const present = await gitTry(['-C', root, 'rev-parse', '--verify', '--quiet', branchRef]);
4009
+ if (present.ok) {
4010
+ const removed = await gitTry(['-C', root, 'branch', '-D', wt.branch]);
4011
+ if (!removed.ok)
4012
+ throw new ResourceConflict(`refusing to finish close for ${id}: branch removal failed`);
4013
+ const remaining = await gitTry(['-C', root, 'rev-parse', '--verify', '--quiet', branchRef]);
4014
+ if (remaining.ok || remaining.failure !== 'exit')
4015
+ throw new ResourceConflict(`refusing to finish close for ${id}: branch remains or its removal is unproven`);
4016
+ }
4017
+ else if (present.failure !== 'exit') {
4018
+ throw new ResourceConflict(`refusing to finish close for ${id}: branch presence is unreadable`);
4019
+ }
4020
+ }
4021
+ if (slot) {
4022
+ try {
4023
+ rmSync(slot, { recursive: true, force: true });
4024
+ }
4025
+ catch { /* best-effort GC */ }
4026
+ }
4027
+ try {
4028
+ rmSync(sessionStoreDir(id), { recursive: true, force: true });
4029
+ }
4030
+ catch (error) {
4031
+ throw new ResourceConflict(`refusing to finish close for ${id}: session record/prompt removal failed (${error instanceof Error ? error.message : String(error)})`);
4032
+ }
4033
+ if (existsSync(sessionStoreDir(id)))
4034
+ throw new ResourceConflict(`refusing to finish close for ${id}: session record removal failed`);
4035
+ // The close still owns this sender's record lock. Marking after its store is gone lets any send already
4036
+ // admitted finish before close returns, while every later send and every retry sweep sees terminal output.
4037
+ revokeSenderDelivery(id);
4038
+ if (closesCodexBinding && wt.rec.harnessSessionId) {
4039
+ bindCodexGeneration(runtimeRoot(), id, wt.rec.harnessSessionId, null);
4040
+ }
4041
+ requestQueueDrain(); // a close frees a slot — start the next queued session if any
4042
+ return true;
4043
+ }
4044
+ async function closeSessionUnlocked(id, source) {
4045
+ let wt = null;
4046
+ try {
4047
+ wt = await findWorktree(id);
4048
+ }
4049
+ catch (e) {
4050
+ if (!(e instanceof SessionRecordUnusable) || e.code !== 'corrupt')
4051
+ throw e;
4052
+ const quarantined = quarantineRecord(id);
4053
+ const runtime = sessionStoreDir(id);
4054
+ const evidence = quarantined
4055
+ ? `Original bytes were copied to ${quarantined}`
4056
+ : `Original bytes remain at ${join(runtime, 'session.json')}; no quarantine copy could be made`;
4057
+ let guard = 'no readable session record proves the adapter or leaf owner';
4058
+ try {
4059
+ await stopAgentProcess(id, null);
4060
+ }
4061
+ catch (error) {
4062
+ guard = error instanceof Error ? error.message : String(error);
4063
+ }
4064
+ throw new SessionRecordUnusable('corrupt', id, `refusing destructive close for ${id}: the unreadable record proves no adapter, leaf, worktree, or branch owner (${guard}). ${evidence}. Runtime remains at ${runtime}; worktree and branch ownership is unknown and was not touched; no process signal or deletion was attempted.`);
4065
+ }
4066
+ if (!wt)
4067
+ return false;
4068
+ let unboundRetired = false;
4069
+ if (!retirementReason(wt.rec) && !wt.rec.archived && wt.rec.status !== 'queued') {
4070
+ const harness = harnessById(wt.rec.harness || defaultHarness.id);
4071
+ if (!harness.exactNativeTargetId(wt.rec)) {
4072
+ await assertUnboundRetirementSafe(id, wt.rec, wt.path, wt.branch);
4073
+ await tmuxOk(['kill-session', '-t', id]);
4074
+ await assertTargetTmuxAbsent(id, 'after unbound residue retirement');
4075
+ await harness.cleanupRuntime(wt.rec);
4076
+ unboundRetired = true;
4077
+ }
4078
+ else {
4079
+ // A confirmed terminal close may end an exact native turn before cold proof. Ordinary archive deliberately
4080
+ // remains non-destructive while a turn is active; close already means discard this session's work.
4081
+ assertSessionOwnerSafe(id, harness.id);
4082
+ const interrupt = harness.interrupt;
4083
+ if (interrupt) {
4084
+ const result = await interrupt({ ...wt.rec, runtimeDir: runtimeRoot() });
4085
+ if (!result.ok)
4086
+ throw new ResourceConflict(`refusing to close ${id}: native interrupt failed (${result.error || 'unknown error'})`);
4087
+ }
4088
+ const archived = await archiveSessionUnlocked(id);
4089
+ if (!archived)
4090
+ return false;
4091
+ wt = await findWorktree(id);
4092
+ if (!wt)
4093
+ return false;
4094
+ }
4095
+ }
4096
+ const target = wt;
4097
+ return target.branch
4098
+ ? withRecordLock(sessionCandidateLockId(target.path, target.branch), () => closeOwnedSessionUnlocked(id, target, source, unboundRetired))
4099
+ : closeOwnedSessionUnlocked(id, target, source, unboundRetired);
4100
+ }
4101
+ export const closeSession = (id, rawSource) => {
4102
+ const source = normalizeCloseSource(rawSource);
4103
+ return withSessionTransition(id, () => withRecordLock(id, () => closeSessionUnlocked(id, source)));
4104
+ };
4105
+ const quarantineRoot = (id) => join(runtimeRoot(), 'corrupt', id);
4106
+ const recordSha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
4107
+ function normalizeQuarantineWitness(id, raw) {
4108
+ if (!raw || typeof raw !== 'object')
4109
+ throw new ResourceConflict(`refusing to quarantine ${id}: an exact adapter/thread/tmux/worktree/branch witness is required`);
4110
+ const value = raw;
4111
+ const text = (key) => typeof value[key] === 'string' ? value[key].trim() : '';
4112
+ const adapter = text('adapter');
4113
+ const tmux = text('tmux');
4114
+ const worktree = text('worktree');
4115
+ const branch = text('branch');
4116
+ if (!Object.prototype.hasOwnProperty.call(value, 'thread'))
4117
+ throw new ResourceConflict(`refusing to quarantine ${id}: thread witness must be explicit (a string or null)`);
4118
+ const threadValue = value.thread;
4119
+ const thread = typeof threadValue === 'string' && threadValue.trim() ? threadValue.trim() : threadValue == null || threadValue === '' ? null : null;
4120
+ if (!adapter || !HARNESSES.some((h) => h.id === adapter))
4121
+ throw new ResourceConflict(`refusing to quarantine ${id}: adapter must name one registered harness`);
4122
+ if (tmux !== id)
4123
+ throw new ResourceConflict(`refusing to quarantine ${id}: tmux witness must be the exact session id ${id}`);
4124
+ if (!worktree || !isAbsolute(worktree))
4125
+ throw new ResourceConflict(`refusing to quarantine ${id}: worktree witness must be an absolute path`);
4126
+ if (!branch || branch.startsWith('-') || branch.startsWith('refs/'))
4127
+ throw new ResourceConflict(`refusing to quarantine ${id}: branch witness must be one local branch name`);
4128
+ if (threadValue !== undefined && threadValue !== null && typeof threadValue !== 'string')
4129
+ throw new ResourceConflict(`refusing to quarantine ${id}: thread witness must be a string or null`);
4130
+ return { adapter, thread, tmux, worktree: resolve(worktree), branch };
4131
+ }
4132
+ async function proveQuarantineTmuxAbsent(id) {
4133
+ try {
4134
+ await tmux(['has-session', '-t', id], TMUX_PROBE_TIMEOUT_MS);
4135
+ }
4136
+ catch (error) {
4137
+ if (probeTimedOut(error))
4138
+ throw new ResourceConflict(`refusing to quarantine ${id}: tmux absence is unknown (probe timed out)`);
4139
+ if (typeof error.code === 'number')
4140
+ return { state: 'absent' };
4141
+ throw new ResourceConflict(`refusing to quarantine ${id}: tmux absence is unknown (${error instanceof Error ? error.message : String(error)})`);
4142
+ }
4143
+ throw new ResourceConflict(`refusing to quarantine ${id}: exact tmux session ${id} is live`);
4144
+ }
4145
+ async function proveQuarantineGitAbsent(id, witness) {
4146
+ if (existsSync(witness.worktree))
4147
+ throw new ResourceConflict(`refusing to quarantine ${id}: witnessed worktree ${witness.worktree} is live`);
4148
+ const root = mainRoot();
4149
+ const listed = await gitTry(['-C', root, 'worktree', 'list', '--porcelain']);
4150
+ if (!listed.ok)
4151
+ throw new ResourceConflict(`refusing to quarantine ${id}: worktree registry is unknown`);
4152
+ const registered = listed.stdout.split('\n').filter((line) => line.startsWith('worktree ')).map((line) => resolve(line.slice('worktree '.length)));
4153
+ if (registered.includes(witness.worktree))
4154
+ throw new ResourceConflict(`refusing to quarantine ${id}: witnessed worktree ${witness.worktree} remains registered`);
4155
+ const branch = await gitTry(['-C', root, 'show-ref', '--verify', '--quiet', `refs/heads/${witness.branch}`]);
4156
+ if (branch.ok)
4157
+ throw new ResourceConflict(`refusing to quarantine ${id}: witnessed branch ${witness.branch} is live`);
4158
+ if (branch.failure !== 'exit')
4159
+ throw new ResourceConflict(`refusing to quarantine ${id}: branch absence is unknown`);
4160
+ return { worktree: { state: 'absent' }, branch: { state: 'absent' } };
4161
+ }
4162
+ function proveQuarantineLeafAbsent(id) {
4163
+ const path = sessionArtifactPath(id, 'agent.pid');
4164
+ let text;
4165
+ try {
4166
+ text = readFileSync(path, 'utf8').trim();
4167
+ }
4168
+ catch (error) {
4169
+ if (error.code === 'ENOENT')
4170
+ return { state: 'absent', artifact: 'missing' };
4171
+ throw new ResourceConflict(`refusing to quarantine ${id}: leaf PID artifact is unreadable`);
4172
+ }
4173
+ const pid = Number(text);
4174
+ if (!Number.isSafeInteger(pid) || pid <= 0)
4175
+ throw new ResourceConflict(`refusing to quarantine ${id}: leaf PID artifact is malformed`);
4176
+ const start = processStartToken(pid);
4177
+ if (start)
4178
+ throw new ResourceConflict(`refusing to quarantine ${id}: registered agent process ${pid}@${start} is live or recycled`);
4179
+ return { state: 'absent', artifact: `stale:${pid}` };
4180
+ }
4181
+ async function proveQuarantineAdapter(id, witness) {
4182
+ const harness = harnessById(witness.adapter);
4183
+ if (harness.ownsRendezvous) {
4184
+ const socket = await rendezvousListening(id);
4185
+ if (socket === 'live')
4186
+ throw new ResourceConflict(`refusing to quarantine ${id}: ${harness.id} rendezvous transport is live`);
4187
+ if (socket === 'unproven')
4188
+ throw new ResourceConflict(`refusing to quarantine ${id}: ${harness.id} rendezvous transport absence is unknown`);
4189
+ }
4190
+ if (witness.thread) {
4191
+ if (!harness.quarantineOrphanThread)
4192
+ throw new ResourceConflict(`refusing to quarantine ${id}: ${harness.id} has no archivable native thread; omit --thread (a SpexCode session id is not an adapter thread)`);
4193
+ const native = await harness.quarantineOrphanThread(witness.thread, { excludingSessionId: id });
4194
+ if (!native.ok)
4195
+ throw new ResourceConflict(`refusing to quarantine ${id}: ${native.reason}`);
4196
+ return { adapter: native.audit.adapter, thread: native.audit.threadId, action: native.audit.action, compensate: native.compensate };
4197
+ }
4198
+ const descriptors = harness.sharedRuntimes?.(runtimeRoot()) ?? [];
4199
+ for (const descriptor of descriptors) {
4200
+ if (!descriptor.residency)
4201
+ throw new ResourceConflict(`refusing to quarantine ${id}: ${descriptor.label} has no exact absence census`);
4202
+ let residency;
4203
+ try {
4204
+ residency = await descriptor.residency();
4205
+ }
4206
+ catch (error) {
4207
+ throw new ResourceConflict(`refusing to quarantine ${id}: ${descriptor.label} absence is unknown (${error instanceof Error ? error.message : String(error)})`);
4208
+ }
4209
+ if (!residency.healthy)
4210
+ throw new ResourceConflict(`refusing to quarantine ${id}: ${descriptor.label} absence is unknown (${residency.error || 'unhealthy census'})`);
4211
+ if (!residency.rootAbsent || residency.referenceIds.length)
4212
+ throw new ResourceConflict(`refusing to quarantine ${id}: ${descriptor.label} is live; supply its exact native thread instead of claiming absence`);
4213
+ }
4214
+ return { adapter: harness.id, thread: null, action: 'absent', compensate: async () => ({ ok: true }) };
4215
+ }
4216
+ export async function quarantineCorruptRecord(id, rawWitness) {
4217
+ return withSessionTransition(id, () => withRecordLock(id, async () => {
4218
+ const entry = readRecordEntry(id);
4219
+ if (entry.kind === 'absent')
4220
+ throw new ResourceConflict(`refusing to quarantine ${id}: no active session record exists`);
4221
+ if (entry.kind === 'ok')
4222
+ throw new ResourceConflict(`refusing to quarantine ${id}: record is readable; use its ordinary lifecycle control`);
4223
+ const witness = normalizeQuarantineWitness(id, rawWitness);
4224
+ const original = readFileSync(entry.path);
4225
+ const sha256 = recordSha256(original);
4226
+ const observedAt = new Date().toISOString();
4227
+ const leaf = proveQuarantineLeafAbsent(id);
4228
+ const tmux = await proveQuarantineTmuxAbsent(id);
4229
+ const git = await proveQuarantineGitAbsent(id, witness);
4230
+ const adapter = await proveQuarantineAdapter(id, witness);
4231
+ const bundle = join(quarantineRoot(id), `${observedAt.replace(/[:.]/g, '-')}-${randomUUID()}`);
4232
+ const stored = join(bundle, 'session.json');
4233
+ const provenance = join(bundle, 'provenance.json');
4234
+ const audit = {
4235
+ version: 1,
4236
+ sessionId: id,
4237
+ observedAt,
4238
+ record: { activePath: entry.path, sha256, bytes: original.length },
4239
+ witness,
4240
+ observed: { leaf, tmux, ...git, adapter: { adapter: adapter.adapter, thread: adapter.thread, action: adapter.action } },
4241
+ };
4242
+ try {
4243
+ mkdirSync(bundle, { recursive: true, mode: 0o700 });
4244
+ const temp = `${provenance}.${process.pid}.${randomUUID()}.tmp`;
4245
+ writeFileSync(temp, `${JSON.stringify(audit, null, 2)}\n`, { mode: 0o600 });
4246
+ renameSync(temp, provenance);
4247
+ const current = readRecordEntry(id);
4248
+ if (current.kind !== 'corrupt' || current.path !== entry.path)
4249
+ throw new ResourceConflict(`refusing to quarantine ${id}: active record changed during absence verification`);
4250
+ const currentBytes = readFileSync(current.path);
4251
+ if (recordSha256(currentBytes) !== sha256)
4252
+ throw new ResourceConflict(`refusing to quarantine ${id}: opaque record changed during absence verification`);
4253
+ renameSync(current.path, stored);
4254
+ if (recordSha256(readFileSync(stored)) !== sha256) {
4255
+ renameSync(stored, current.path);
4256
+ throw new ResourceConflict(`refusing to quarantine ${id}: moved record failed byte-exact verification`);
4257
+ }
4258
+ }
4259
+ catch (error) {
4260
+ const restored = await adapter.compensate();
4261
+ const suffix = restored.ok ? '' : `; native orphan compensation failed: ${restored.reason}`;
4262
+ if (error instanceof ResourceConflict)
4263
+ throw new ResourceConflict(`${error.message}${suffix}`);
4264
+ throw new ResourceConflict(`refusing to quarantine ${id}: ${error instanceof Error ? error.message : String(error)}${suffix}`);
4265
+ }
4266
+ return { id, bundle, sha256, observedAt };
4267
+ }));
4268
+ }
4269
+ export async function restoreQuarantinedRecord(id) {
4270
+ return withSessionTransition(id, () => withRecordLock(id, async () => {
4271
+ if (readRecordEntry(id).kind !== 'absent')
4272
+ throw new ResourceConflict(`refusing to restore ${id}: an active session record already exists`);
4273
+ let bundles;
4274
+ try {
4275
+ bundles = readdirSync(quarantineRoot(id), { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().reverse();
4276
+ }
4277
+ catch (error) {
4278
+ if (error.code === 'ENOENT')
4279
+ throw new ResourceConflict(`refusing to restore ${id}: no quarantine bundle exists`);
4280
+ throw new ResourceConflict(`refusing to restore ${id}: quarantine bundle inventory is unreadable`);
4281
+ }
4282
+ const bundle = bundles.map((name) => join(quarantineRoot(id), name)).find((path) => existsSync(join(path, 'session.json')) && existsSync(join(path, 'provenance.json')));
4283
+ if (!bundle)
4284
+ throw new ResourceConflict(`refusing to restore ${id}: no complete quarantine bundle exists`);
4285
+ const stored = join(bundle, 'session.json');
4286
+ let provenance;
4287
+ try {
4288
+ provenance = JSON.parse(readFileSync(join(bundle, 'provenance.json'), 'utf8'));
4289
+ }
4290
+ catch {
4291
+ throw new ResourceConflict(`refusing to restore ${id}: quarantine provenance is unreadable`);
4292
+ }
4293
+ const bytes = readFileSync(stored);
4294
+ if (provenance.sessionId !== id || typeof provenance.record?.sha256 !== 'string' || provenance.record.sha256 !== recordSha256(bytes))
4295
+ throw new ResourceConflict(`refusing to restore ${id}: quarantine payload/provenance binding is invalid`);
4296
+ const active = sessionRecordPath(id);
4297
+ mkdirSync(dirname(active), { recursive: true });
4298
+ try {
4299
+ renameSync(stored, active);
4300
+ }
4301
+ catch (error) {
4302
+ throw new ResourceConflict(`refusing to restore ${id}: opaque record move failed (${error instanceof Error ? error.message : String(error)})`);
4303
+ }
4304
+ if (recordSha256(readFileSync(active)) !== provenance.record.sha256) {
4305
+ renameSync(active, stored);
4306
+ throw new ResourceConflict(`refusing to restore ${id}: restored record failed byte-exact verification`);
4307
+ }
4308
+ return { id, bundle, sha256: provenance.record.sha256, observedAt: new Date().toISOString() };
4309
+ }));
4310
+ }
4311
+ function quarantineRecord(id) {
4312
+ let entry;
4313
+ try {
4314
+ entry = readRecordEntry(id);
4315
+ }
4316
+ catch {
4317
+ return null;
4318
+ } // unreadable for another reason (permissions) — leave it
4319
+ if (entry.kind !== 'corrupt')
4320
+ return null;
4321
+ try {
4322
+ const shelf = join(runtimeRoot(), 'corrupt');
4323
+ mkdirSync(shelf, { recursive: true });
4324
+ const dest = join(shelf, `${id}-${new Date().toISOString().replace(/[:.]/g, '-')}.json`);
4325
+ writeFileSync(dest, readFileSync(entry.path));
4326
+ console.error(`spex: session ${id.slice(0, 8)} had an unreadable record; its original bytes are preserved at ${dest}`);
4327
+ return dest;
4328
+ }
4329
+ catch (e) {
4330
+ console.error(`spex: could not quarantine the unreadable record for ${id}: ${e instanceof Error ? e.message : e}`);
4331
+ return null;
4332
+ }
4333
+ }
4334
+ export async function captureSessionResult(id) {
4335
+ if (!(await alive(id))) {
4336
+ const known = (await listSessions(true)).some((s) => s.id === id);
4337
+ return { ok: false, reason: known ? 'offline' : 'unknown' };
4338
+ }
4339
+ try {
4340
+ return { ok: true, pane: await tmux(['capture-pane', '-e', '-p', '-t', id]) };
4341
+ }
4342
+ catch {
4343
+ return { ok: false, reason: 'capture-failed' };
4344
+ }
4345
+ }
4346
+ // @@@ watch - the event source for Claude Code's Monitor tool (first-class managing-agent support).
4347
+ // Polls the session list and emits the COMPLETE session lifecycle so it's a true "subscribe to all
4348
+ // session changes" feed: a LAUNCH (first sighting of an id, even though it enters at 'working', which is
4349
+ // not actionable — emitted ONCE per id so a manager learns a new session started), each ACTIONABLE state
4350
+ // transition — review / done / close-pending (agent proposals), offline (process died), error — and the
4351
+ // removal. Per Monitor's "silence is not success" rule a vanished session pings too. Net feed:
4352
+ // launched → [actionable transitions] → closed. Each line names the suggested next action(s). Drop into Monitor:
4353
+ // Monitor({ command: 'spex session watch', persistent: true, description: 'session state changes' })
4354
+ // @@@ presentation + selection - shared by `spex session ls` (pretty), `spex session watch` (events) and the API.
4355
+ export const STATUS_GLYPH = {
4356
+ working: '\u25cf', idle: '\u25cb', offline: '\u23fb', starting: '\u25d4', review: '\u25c6', done: '\u2713',
4357
+ 'close-pending': '\u2715', parked: '\u29d6', error: '\u2717', asking: '\u2370', queued: '\u25cc', unknown: '\u2047',
4358
+ corrupt: '\u26a0', retired: '\u2691',
4359
+ };
4360
+ const ANSI = {
4361
+ working: '33', idle: '90', offline: '90', starting: '36', review: '35', done: '34', 'close-pending': '31', parked: '36', error: '31', asking: '93', queued: '90', unknown: '93',
4362
+ corrupt: '31', retired: '90',
4363
+ };
4364
+ // @@@ session selectors - the ONE matcher every session command shares (see [[session-selectors]]). A
4365
+ // selector matches a session iff it is the session's full id, an id-PREFIX, its node, its branch, or `.` for
4366
+ // the caller's own launched session. This is
4367
+ // the single predicate; selectSessions (MANY) and resolveSession (ONE) both call it, so id-prefix/node/branch
4368
+ // resolution can never drift between "which sessions ls/watch/wait/graph show" and "which session
4369
+ // review/merge/send/close act on".
4370
+ export function matchesSelector(s, q, own = ownSessionId(), cwd = process.cwd()) {
4371
+ // a selector may be a comma-separated list (the same convention as `--status a,b`): it matches iff ANY part
4372
+ // names the session, so `watch a,b` and `watch a b` are equivalent. A single name is the one-part case. This
4373
+ // is what stops a comma-joined selector from silently matching nothing — an id/node/branch never holds a
4374
+ // comma, so without the split `a,b` would be one literal selector that matches no session and streams in
4375
+ // silence forever. Each part sheds an optional reference sigil (stripRefSigil): `@<sel>` / `[[<sel>]]` name
4376
+ // the same session as the bare token, so the dashboard's mention grammar is tolerated in every CLI selector.
4377
+ const sessionPath = s.path ? resolve(s.path) : null;
4378
+ const callerPath = resolve(cwd);
4379
+ const self = Boolean(own) && s.id === own
4380
+ || Boolean(sessionPath) && (callerPath === sessionPath || callerPath.startsWith(`${sessionPath}${sep}`));
4381
+ return q.split(',').map((p) => stripRefSigil(p.trim())).filter(Boolean)
4382
+ .some((p) => p === '.' ? self : s.id === p || s.id.startsWith(p) || s.node === p || s.branch === p);
4383
+ }
4384
+ // no selectors (or '@all') = everything. Optional status filter on top. This IS the ls/watch subscription.
4385
+ export function selectSessions(all, selectors, statuses, own = ownSessionId(), cwd = process.cwd()) {
4386
+ let out = all;
4387
+ const sel = selectors.filter((x) => x && x !== '@all');
4388
+ if (sel.length)
4389
+ out = out.filter((s) => sel.some((q) => matchesSelector(s, q, own, cwd)));
4390
+ if (statuses && statuses.length)
4391
+ out = out.filter((s) => statuses.includes(s.status));
4392
+ return out;
4393
+ }
4394
+ // Parentage is a durable direct pointer, even when that parent was terminally closed and no longer has a row.
4395
+ // The CLI's child scope must keep that fact visible rather than requiring callers to reverse-engineer prompts.
4396
+ export function selectChildren(all, parent) {
4397
+ return all.filter((session) => session.parent === parent);
4398
+ }
4399
+ export function resolveSession(selector, sessions, own = ownSessionId(), cwd = process.cwd()) {
4400
+ // the exact-id check sheds the optional sigil too, so `@<full-id>` keeps the exact-wins-over-prefix rule
4401
+ const exact = sessions.find((s) => s.id === stripRefSigil(selector));
4402
+ if (exact)
4403
+ return { ok: exact };
4404
+ const hits = sessions.filter((s) => matchesSelector(s, selector, own, cwd));
4405
+ if (hits.length === 1)
4406
+ return { ok: hits[0] };
4407
+ return hits.length ? { ambiguous: hits } : { none: true };
4408
+ }
4409
+ // @@@ display width - the table aligns by TERMINAL CELLS, not code units. CJK/fullwidth glyphs render
4410
+ // two cells wide, so `slice`/`padEnd` (which count code units) shear a wide glyph mid-cut and under-pad
4411
+ // the column, misaligning everything after it. A small wcwidth-style range check covers the wide blocks
4412
+ // that actually reach session labels/prompts \u2014 no dependency needed.
4413
+ const isWideCp = (cp) => (cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
4414
+ (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) || // CJK radicals \u2026 kana \u2026 CJK ideographs \u2026 Yi
4415
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
4416
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK compatibility ideographs
4417
+ (cp >= 0xfe30 && cp <= 0xfe4f) || // CJK compatibility forms
4418
+ (cp >= 0xff00 && cp <= 0xff60) || // fullwidth forms
4419
+ (cp >= 0xffe0 && cp <= 0xffe6) || // fullwidth signs
4420
+ (cp >= 0x1f300 && cp <= 0x1faff) || // emoji
4421
+ (cp >= 0x20000 && cp <= 0x3fffd); // CJK extensions B+
4422
+ export function displayWidth(s) {
4423
+ let w = 0;
4424
+ for (const ch of s)
4425
+ w += isWideCp(ch.codePointAt(0)) ? 2 : 1;
4426
+ return w;
4427
+ }
4428
+ // truncate to a display width (the ellipsis occupies its own cell); never cuts a wide glyph in half.
4429
+ export function truncWidth(s, max) {
4430
+ if (displayWidth(s) <= max)
4431
+ return s;
4432
+ let w = 0;
4433
+ let out = '';
4434
+ for (const ch of s) {
4435
+ const cw = isWideCp(ch.codePointAt(0)) ? 2 : 1;
4436
+ if (w + cw > max - 1)
4437
+ break;
4438
+ out += ch;
4439
+ w += cw;
4440
+ }
4441
+ return out + '\u2026';
4442
+ }
4443
+ // pad to a display width \u2014 `padEnd` would count a double-cell glyph as one and under-pad the column.
4444
+ export const padWidth = (s, w) => s + ' '.repeat(Math.max(0, w - displayWidth(s)));
4445
+ const trunc = truncWidth;
4446
+ // the board table's NOTE display cap \u2014 exported so the declaration echo (cli.ts) can tell an author
4447
+ // exactly where their note gets cut, instead of the cap living as an anonymous magic number here.
4448
+ export const NOTE_BOARD_LIMIT = 50;
4449
+ // short display label per status (only close-pending differs from the status name) \u2014 used by the legend.
4450
+ const SHORT = { 'close-pending': 'close' };
4451
+ // @@@ statusLegend - one-line glyph\u2192meaning key, BUILT from STATUS_GLYPH so it can never drift from
4452
+ // the glyphs the table actually prints. Shown under `spex session ls` so the symbols are self-explanatory.
4453
+ export function statusLegend(color = true) {
4454
+ const c = (code, t) => (color ? `\x1b[${code}m${t}\x1b[0m` : t);
4455
+ const parts = Object.keys(STATUS_GLYPH).map((k) => `${c(ANSI[k], STATUS_GLYPH[k])} ${SHORT[k] || k}`);
4456
+ return c('90', ' key: ') + parts.join(' ');
4457
+ }
4458
+ function statusSummary(sessions) {
4459
+ const counts = new Map();
4460
+ for (const session of sessions)
4461
+ counts.set(session.status, (counts.get(session.status) || 0) + 1);
4462
+ return Object.keys(STATUS_GLYPH)
4463
+ .flatMap((status) => {
4464
+ const count = counts.get(status);
4465
+ return count ? [`${count} ${SHORT[status] || status}`] : [];
4466
+ })
4467
+ .join(' · ');
4468
+ }
4469
+ export function formatTable(sessions, color = true, scope = { kind: 'sessions' }) {
4470
+ const c = (code, t) => (color ? `\x1b[${code}m${t}\x1b[0m` : t);
4471
+ const label = scope.kind === 'children' ? `children of ${scope.parent.slice(0, 8)}` : 'sessions';
4472
+ const heading = c('1', `SpexCode ${label} (${sessions.length}${sessions.length ? `; ${statusSummary(sessions)}` : ''})`);
4473
+ if (!sessions.length)
4474
+ return [heading, c('90', ` no ${scope.kind === 'children' ? 'children' : 'living sessions'}`)].join('\n');
4475
+ const header = c('90', ` ${'STATUS'.padEnd(13)} ${'TITLE'.padEnd(22)} ${'ID'.padEnd(8)} ${'PARENT'.padEnd(8)} ${'\u00d7'.padEnd(4)}${'PROMPT'.padEnd(42)}NOTE`);
4476
+ const rows = sessions.map((s) => {
4477
+ const g = STATUS_GLYPH[s.status] ?? '\u00b7';
4478
+ const code = ANSI[s.status] ?? '0';
4479
+ const title = padWidth(truncWidth(sessionTitle(s), 22), 22);
4480
+ const st = s.status.padEnd(13);
4481
+ const parent = c('90', (s.parent || '-').slice(0, 8).padEnd(8));
4482
+ const merges = (s.merges ? `\u00d7${s.merges}` : '').padEnd(4);
4483
+ const prompt = c('90', padWidth(s.promptPreview ? trunc(s.promptPreview, 40) : '', 42)); // what it was asked to do
4484
+ const note = s.note ? c('90', trunc(s.note, NOTE_BOARD_LIMIT)) : '';
4485
+ return ` ${c(code, g)} ${c(code, st)} ${title} ${c('90', s.id.slice(0, 8))} ${parent} ${merges}${prompt}${note}`;
4486
+ });
4487
+ return [heading, header, ...rows, statusLegend(color)].join('\n');
4488
+ }
4489
+ class StrandedDeliveryError extends Error {
4490
+ }
4491
+ async function strandedDeliveryError(rec) {
4492
+ const h = harnessById(rec.harness || defaultHarness.id);
4493
+ if (!h.deliveryTransport)
4494
+ return null;
4495
+ const transport = await h.deliveryTransport({ ...rec, runtimeDir: runtimeRoot() });
4496
+ if (transport.kind !== 'unreachable' || agentAlive(rec.session) !== true)
4497
+ return null;
4498
+ const queued = pendingMessages(rec.session).length;
4499
+ const noun = queued === 1 ? 'message is' : 'messages are';
4500
+ return new StrandedDeliveryError(`session ${rec.session} is stranded: ${transport.reason} while its registered agent process is still alive; ${queued} queued ${noun} waiting with no transport to claim them. Use \`spex session send ${rec.session} --keys "<keys>"\` to steer the live tmux pane, then repair the control transport before sending text.`);
4501
+ }
4502
+ export async function sendText(id, text, from, opts = {}) {
4503
+ if (!text)
4504
+ return { ok: false, error: 'empty prompt — nothing to dispatch' };
4505
+ let replayed = false;
4506
+ try {
4507
+ // Taking a declared sender's record lock makes close a real outgoing fence even across backend processes:
4508
+ // a send either appends before close obtains the fence (and close's revocation voids its debt), or sees
4509
+ // the terminal marker before it records anything. Arbitrary legacy `from` values keep working; they just
4510
+ // name an otherwise-unused lock until a matching session is closed.
4511
+ let rec = null;
4512
+ const accepted = await acceptMessage({
4513
+ target: id,
4514
+ text,
4515
+ from,
4516
+ idempotency: opts.idempotency,
4517
+ validate: async () => {
4518
+ rec = readRecord(id);
4519
+ if (!rec)
4520
+ throw new ResourceConflict(`no session record for ${id} — prompt NOT delivered`);
4521
+ },
4522
+ prepare: async () => {
4523
+ await opts.acceptGuard?.(rec);
4524
+ const stranded = await strandedDeliveryError(rec);
4525
+ if (stranded)
4526
+ throw stranded;
4527
+ // Composed at ACCEPT time, once: the log keeps the raw conversational text plus the effective reply channel,
4528
+ // the queue keeps the transport form. Composing again at handover would let a later send change the hints on
4529
+ // a message that was already accepted.
4530
+ const prompt = await composeSessionPrompt(text, rec, { from, replyVia: opts.replyVia });
4531
+ return { text: prompt.text, ...(prompt.replyVia ? { replyVia: prompt.replyVia } : {}) };
4532
+ },
4533
+ });
4534
+ replayed = accepted.replayed;
4535
+ }
4536
+ catch (error) {
4537
+ const code = error?.code;
4538
+ const detail = error instanceof StrandedDeliveryError
4539
+ ? error.message
4540
+ : `could not append the message to session ${id}'s log: ${error instanceof Error ? error.message : String(error)} — prompt NOT delivered`;
4541
+ return {
4542
+ ok: false,
4543
+ error: detail,
4544
+ ...(code ? { code } : {}),
4545
+ };
4546
+ }
4547
+ // Awaited, not fire-and-forget: an unawaited insert can lose its race with a short-lived caller's exit,
4548
+ // costing that send its same-turn arrival. Draining HERE rather than leaving it to the sweep is what puts
4549
+ // the text in a live agent's current turn instead of up to one tick later.
4550
+ if (!opts.deferDrain)
4551
+ await drainSession(id);
4552
+ return { ok: true, ...(opts.idempotency ? { replayed } : {}) };
4553
+ }
4554
+ // @@@ drainSession - hand over what this session is owed, as ordinary prompts. Safe to call from anywhere and
4555
+ // at any time: the queue's own lock serializes concurrent passes, and an empty queue costs one existsSync.
4556
+ // The retry sweep in `serve` calls this for the sessions whose queues an earlier pass could not empty.
4557
+ export async function drainSession(id) {
4558
+ if (!owesDelivery(id))
4559
+ return;
4560
+ const rec = readRecord(id);
4561
+ if (!rec)
4562
+ return;
4563
+ const h = harnessById(rec.harness || defaultHarness.id);
4564
+ if (h.launchPayloadProof && !rec.harnessSessionId)
4565
+ return;
4566
+ await drain(id, async (msg) => {
4567
+ // the pane guard ([[harness-adapter]] deliveryBlockedBy): the ONE pane state where the harness swallows a
4568
+ // prompt its channel confirms (claude's sessions panel), checkable only from the pane. Treated as a REFUSAL
4569
+ // rather than a skip — the message stays owed and the sweep hands it over once the pane leaves that state.
4570
+ if (h.deliveryBlockedBy) {
4571
+ try {
4572
+ if (h.deliveryBlockedBy(await tmux(['capture-pane', '-p', '-t', rec.session], TMUX_PROBE_TIMEOUT_MS)))
4573
+ return false;
4574
+ }
4575
+ catch { /* no pane to consult — let the insert itself decide */ }
4576
+ }
4577
+ const delivered = await h.deliver({ ...rec, runtimeDir: runtimeRoot(), mid: msg.mid }, msg.text);
4578
+ return delivered.ok;
4579
+ });
4580
+ }
4581
+ // Hard interrupt is adapter-native control, distinct from stop's process teardown. A harness without a
4582
+ // confirmed native primitive refuses loudly; there is no signal/PTY fallback that could target the wrong turn.
4583
+ export async function interruptSession(id) {
4584
+ return withRecordLock(id, async () => {
4585
+ const rec = readRecord(id);
4586
+ if (!rec)
4587
+ return { ok: false, error: `no session record for ${id} - nothing to interrupt` };
4588
+ const h = harnessById(rec.harness || defaultHarness.id);
4589
+ if (!h.interrupt)
4590
+ return { ok: false, error: `harness ${h.id} has no native hard-interrupt control` };
4591
+ return h.interrupt({ ...rec, runtimeDir: runtimeRoot() });
4592
+ });
4593
+ }
4594
+ // @@@ rawKey - the RAW-KEYSTROKE nav path, kept DELIBERATELY on `tmux send-keys` and NEVER the rendezvous
4595
+ // socket. Two channels, two jobs: the socket INJECTS a whole prompt (text + submit), which can drive the
4596
+ // agent's normal prompt but CANNOT navigate an interactive TUI select menu (e.g. `/model`'s list — ↑/↓ to
4597
+ // move, ←/→ to adjust, Enter to set, `s` for this-session, Esc to cancel). When the agent is in that
4598
+ // keystroke-navigation state its input box is replaced by the menu, so the CLI raw-key fallback forwards
4599
+ // each key here in real time. send-keys is exactly right for single raw keys: named keys map to tmux's own
4600
+ // key names; a single printable char is sent literally (`-l`) so tmux doesn't reinterpret it. The dashboard
4601
+ // also drives the agent with MODIFIER COMBOS — a terminal's three modifiers carried as a `C-`/`M-`/`S-`
4602
+ // prefix on the token (e.g. `C-r`, `M-b`, `S-Tab`, `C-M-x`); those are passed to tmux UNescaped so it parses
4603
+ // the combo. One key per call, no socket and no Enter-synthesis — this IS the send-keys channel. False if
4604
+ // the tmux session is gone, or if the token isn't a known base after its prefixes (defends the send-keys arg).
4605
+ const TMUX_KEY = {
4606
+ Up: 'Up', Down: 'Down', Left: 'Left', Right: 'Right',
4607
+ Enter: 'Enter', Escape: 'Escape', Tab: 'Tab', Space: 'Space', Backspace: 'BSpace',
4608
+ Home: 'Home', End: 'End', Delete: 'DC',
4609
+ };
4610
+ // tmux honours an `S-` (shift) modifier ONLY on these named keys; on Enter/Space/BSpace it would send the
4611
+ // literal text "S-Enter" etc. (and shift is a no-op there anyway), so a stray S- is dropped. Shift+Tab is
4612
+ // the named exception: tmux spells it `BTab` (back-tab → ESC[Z, what Claude Code's mode-cycle reads).
4613
+ const SHIFTABLE = new Set(['Up', 'Down', 'Left', 'Right', 'Home', 'End', 'DC']);
4614
+ // resolve ONE frontend token to the `tmux send-keys` args for it, or null if it isn't a known base after its
4615
+ // prefixes (defends the send-keys arg). Pure — the batch loop below sequences the actual sends.
4616
+ function rawKeyArgs(id, key) {
4617
+ // peel the optional C-/M-/S- modifier prefixes (each at most once, in any order) off the front; the
4618
+ // remainder is the BASE key. The frontend only ever sends {C-,M-,S-} prefixes + a named key or one char.
4619
+ let rest = key, prefix = '';
4620
+ const seen = new Set();
4621
+ while (rest.length >= 2 && (rest[0] === 'C' || rest[0] === 'M' || rest[0] === 'S') && rest[1] === '-' && !seen.has(rest[0])) {
4622
+ seen.add(rest[0]);
4623
+ prefix += rest.slice(0, 2);
4624
+ rest = rest.slice(2);
4625
+ }
4626
+ const named = TMUX_KEY[rest];
4627
+ if (named) {
4628
+ const noShift = prefix.replace('S-', ''); // C-/M- without the shift bit
4629
+ let token;
4630
+ if (prefix.includes('S-') && named === 'Tab')
4631
+ token = noShift + 'BTab'; // Shift+Tab → back-tab
4632
+ else if (prefix.includes('S-') && !SHIFTABLE.has(named))
4633
+ token = noShift + named; // tmux can't carry S- here
4634
+ else
4635
+ token = prefix + named;
4636
+ return ['send-keys', '-t', id, token];
4637
+ }
4638
+ if ([...rest].length === 1) {
4639
+ // a single printable char: bare → literal (`-l`, so tmux never reinterprets it as a key name);
4640
+ // modified → hand tmux the `C-`/`M-`/`S-` combo to parse (e.g. `C-a`), which `-l` would defeat.
4641
+ if (prefix)
4642
+ return ['send-keys', '-t', id, prefix + rest];
4643
+ return ['send-keys', '-t', id, '-l', '--', rest];
4644
+ }
4645
+ return null;
4646
+ }
4647
+ // One call carries a BATCH of tokens (or one) — the client coalesces fast typing into an ordered array. Order
4648
+ // is the whole point ([[nav-mode-key-ordering]]): the keys are sent by ONE awaited `send-keys` each, IN ARRAY
4649
+ // ORDER, so they reach the pane in exactly the order they were struck. Concurrent per-key POSTs used to race
4650
+ // (browser + server + send-keys all parallel) and scramble the sequence; a single serialised batch cannot.
4651
+ // An unknown token is skipped without dropping the rest; false only if the tmux session is gone or nothing sent.
4652
+ export async function rawKey(id, key) {
4653
+ return withRecordLock(id, async () => {
4654
+ const list = (Array.isArray(key) ? key : [key]).filter((k) => typeof k === 'string' && k.length > 0);
4655
+ if (list.length === 0 || !(await alive(id)))
4656
+ return false;
4657
+ let sent = false;
4658
+ for (const k of list) {
4659
+ const args = rawKeyArgs(id, k);
4660
+ if (!args)
4661
+ continue;
4662
+ await tmux(args);
4663
+ sent = true;
4664
+ }
4665
+ return sent;
4666
+ });
4667
+ }