spexcode 0.6.2 → 0.6.5

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