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