spexcode 0.6.2 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (495) hide show
  1. package/README.md +3 -1
  2. package/bin/spex.mjs +4 -0
  3. package/node_modules/@spexcode/session-core/dist/delivery-queue.d.ts +23 -0
  4. package/node_modules/@spexcode/session-core/dist/delivery-queue.js +195 -0
  5. package/node_modules/@spexcode/session-core/dist/index.d.ts +5 -0
  6. package/node_modules/@spexcode/session-core/dist/index.js +5 -0
  7. package/node_modules/@spexcode/session-core/dist/internal.d.ts +3 -0
  8. package/node_modules/@spexcode/session-core/dist/internal.js +3 -0
  9. package/node_modules/@spexcode/session-core/dist/message.d.ts +22 -0
  10. package/node_modules/@spexcode/session-core/dist/message.js +52 -0
  11. package/node_modules/@spexcode/session-core/dist/record-lock.d.ts +7 -0
  12. package/node_modules/@spexcode/session-core/dist/record-lock.js +152 -0
  13. package/node_modules/@spexcode/session-core/dist/runtime-session.d.ts +50 -0
  14. package/node_modules/@spexcode/session-core/dist/runtime-session.js +286 -0
  15. package/node_modules/@spexcode/session-core/dist/session-cursors.d.ts +14 -0
  16. package/node_modules/@spexcode/session-core/dist/session-cursors.js +82 -0
  17. package/node_modules/@spexcode/session-core/dist/session-timeline.d.ts +46 -0
  18. package/node_modules/@spexcode/session-core/dist/session-timeline.js +216 -0
  19. package/node_modules/@spexcode/session-core/package.json +33 -0
  20. package/node_modules/@spexcode/spec-cli/bin/spex.mjs +77 -0
  21. package/node_modules/@spexcode/spec-cli/dist/attach.d.ts +2 -0
  22. package/node_modules/@spexcode/spec-cli/dist/attach.js +48 -0
  23. package/node_modules/@spexcode/spec-cli/dist/claude-headless.d.ts +36 -0
  24. package/node_modules/@spexcode/spec-cli/dist/claude-headless.js +316 -0
  25. package/node_modules/@spexcode/spec-cli/dist/cli.d.ts +1 -0
  26. package/node_modules/@spexcode/spec-cli/dist/cli.js +1908 -0
  27. package/node_modules/@spexcode/spec-cli/dist/client.d.ts +112 -0
  28. package/node_modules/@spexcode/spec-cli/dist/client.js +481 -0
  29. package/node_modules/@spexcode/spec-cli/dist/cockpit.d.ts +7 -0
  30. package/node_modules/@spexcode/spec-cli/dist/cockpit.js +26 -0
  31. package/node_modules/@spexcode/spec-cli/dist/codex-headless.d.ts +1 -0
  32. package/{spec-cli/src/codex-headless.ts → node_modules/@spexcode/spec-cli/dist/codex-headless.js} +3 -9
  33. package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.d.ts +58 -0
  34. package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.js +798 -0
  35. package/node_modules/@spexcode/spec-cli/dist/commit-surgery.d.ts +1 -0
  36. package/node_modules/@spexcode/spec-cli/dist/commit-surgery.js +90 -0
  37. package/node_modules/@spexcode/spec-cli/dist/contract-filter.d.ts +14 -0
  38. package/node_modules/@spexcode/spec-cli/dist/contract-filter.js +219 -0
  39. package/node_modules/@spexcode/spec-cli/dist/dashboard-assets.d.ts +6 -0
  40. package/node_modules/@spexcode/spec-cli/dist/dashboard-assets.js +50 -0
  41. package/node_modules/@spexcode/spec-cli/dist/doctor.d.ts +9 -0
  42. package/node_modules/@spexcode/spec-cli/dist/doctor.js +553 -0
  43. package/node_modules/@spexcode/spec-cli/dist/eval-host.d.ts +1 -0
  44. package/node_modules/@spexcode/spec-cli/dist/eval-host.js +19 -0
  45. package/node_modules/@spexcode/spec-cli/dist/execution-trace.d.ts +26 -0
  46. package/node_modules/@spexcode/spec-cli/dist/execution-trace.js +475 -0
  47. package/node_modules/@spexcode/spec-cli/dist/file-write.d.ts +2 -0
  48. package/node_modules/@spexcode/spec-cli/dist/file-write.js +26 -0
  49. package/node_modules/@spexcode/spec-cli/dist/flat.d.ts +89 -0
  50. package/node_modules/@spexcode/spec-cli/dist/flat.js +1076 -0
  51. package/node_modules/@spexcode/spec-cli/dist/gateway-auth.d.ts +56 -0
  52. package/node_modules/@spexcode/spec-cli/dist/gateway-auth.js +168 -0
  53. package/node_modules/@spexcode/spec-cli/dist/gateway-hub.d.ts +28 -0
  54. package/node_modules/@spexcode/spec-cli/dist/gateway-hub.js +363 -0
  55. package/node_modules/@spexcode/spec-cli/dist/gateway.d.ts +38 -0
  56. package/node_modules/@spexcode/spec-cli/dist/gateway.js +527 -0
  57. package/node_modules/@spexcode/spec-cli/dist/graphCache.d.ts +27 -0
  58. package/node_modules/@spexcode/spec-cli/dist/graphCache.js +717 -0
  59. package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.d.ts +14 -0
  60. package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.js +36 -0
  61. package/node_modules/@spexcode/spec-cli/dist/graphStream.d.ts +85 -0
  62. package/node_modules/@spexcode/spec-cli/dist/graphStream.js +1195 -0
  63. package/node_modules/@spexcode/spec-cli/dist/guidance-catalog.d.ts +64 -0
  64. package/node_modules/@spexcode/spec-cli/dist/guidance-catalog.js +166 -0
  65. package/node_modules/@spexcode/spec-cli/dist/guide.d.ts +8 -0
  66. package/{spec-cli/src/guide.ts → node_modules/@spexcode/spec-cli/dist/guide.js} +36 -32
  67. package/node_modules/@spexcode/spec-cli/dist/harness-select.d.ts +18 -0
  68. package/node_modules/@spexcode/spec-cli/dist/harness-select.js +62 -0
  69. package/node_modules/@spexcode/spec-cli/dist/harness.d.ts +338 -0
  70. package/node_modules/@spexcode/spec-cli/dist/harness.js +3146 -0
  71. package/node_modules/@spexcode/spec-cli/dist/headless-controller.d.ts +9 -0
  72. package/node_modules/@spexcode/spec-cli/dist/headless-controller.js +47 -0
  73. package/node_modules/@spexcode/spec-cli/dist/help.d.ts +15 -0
  74. package/node_modules/@spexcode/spec-cli/dist/help.js +594 -0
  75. package/node_modules/@spexcode/spec-cli/dist/hook-prompts.d.ts +18 -0
  76. package/node_modules/@spexcode/spec-cli/dist/hook-prompts.js +122 -0
  77. package/node_modules/@spexcode/spec-cli/dist/hooks.d.ts +1 -0
  78. package/{spec-cli/src/hooks.ts → node_modules/@spexcode/spec-cli/dist/hooks.js} +15 -15
  79. package/node_modules/@spexcode/spec-cli/dist/host-resources.d.ts +97 -0
  80. package/node_modules/@spexcode/spec-cli/dist/host-resources.js +795 -0
  81. package/node_modules/@spexcode/spec-cli/dist/host.d.ts +88 -0
  82. package/node_modules/@spexcode/spec-cli/dist/host.js +663 -0
  83. package/node_modules/@spexcode/spec-cli/dist/index.d.ts +1 -0
  84. package/node_modules/@spexcode/spec-cli/dist/index.js +899 -0
  85. package/node_modules/@spexcode/spec-cli/dist/init.d.ts +1 -0
  86. package/node_modules/@spexcode/spec-cli/dist/init.js +284 -0
  87. package/node_modules/@spexcode/spec-cli/dist/issues-cli.d.ts +6 -0
  88. package/node_modules/@spexcode/spec-cli/dist/issues-cli.js +334 -0
  89. package/node_modules/@spexcode/spec-cli/dist/issues.d.ts +69 -0
  90. package/node_modules/@spexcode/spec-cli/dist/issues.js +189 -0
  91. package/node_modules/@spexcode/spec-cli/dist/lint.d.ts +34 -0
  92. package/node_modules/@spexcode/spec-cli/dist/lint.js +476 -0
  93. package/node_modules/@spexcode/spec-cli/dist/listen.d.ts +8 -0
  94. package/node_modules/@spexcode/spec-cli/dist/listen.js +24 -0
  95. package/node_modules/@spexcode/spec-cli/dist/localIssues.d.ts +65 -0
  96. package/node_modules/@spexcode/spec-cli/dist/localIssues.js +582 -0
  97. package/node_modules/@spexcode/spec-cli/dist/login-page.d.ts +5 -0
  98. package/{spec-cli/src/login-page.ts → node_modules/@spexcode/spec-cli/dist/login-page.js} +7 -7
  99. package/node_modules/@spexcode/spec-cli/dist/loop-in.d.ts +23 -0
  100. package/{spec-cli/src/loop-in.ts → node_modules/@spexcode/spec-cli/dist/loop-in.js} +35 -45
  101. package/node_modules/@spexcode/spec-cli/dist/machine-peer.d.ts +69 -0
  102. package/node_modules/@spexcode/spec-cli/dist/machine-peer.js +603 -0
  103. package/node_modules/@spexcode/spec-cli/dist/materialize.d.ts +18 -0
  104. package/node_modules/@spexcode/spec-cli/dist/materialize.js +468 -0
  105. package/node_modules/@spexcode/spec-cli/dist/mentions.d.ts +50 -0
  106. package/node_modules/@spexcode/spec-cli/dist/mentions.js +117 -0
  107. package/node_modules/@spexcode/spec-cli/dist/opencode-headless.d.ts +5 -0
  108. package/node_modules/@spexcode/spec-cli/dist/opencode-headless.js +215 -0
  109. package/node_modules/@spexcode/spec-cli/dist/opencode.d.ts +3 -0
  110. package/{spec-cli/src/opencode.ts → node_modules/@spexcode/spec-cli/dist/opencode.js} +9 -13
  111. package/node_modules/@spexcode/spec-cli/dist/pi-harness.d.ts +3 -0
  112. package/{spec-cli/src/pi-harness.ts → node_modules/@spexcode/spec-cli/dist/pi-harness.js} +45 -40
  113. package/node_modules/@spexcode/spec-cli/dist/pi-headless.d.ts +25 -0
  114. package/node_modules/@spexcode/spec-cli/dist/pi-headless.js +196 -0
  115. package/node_modules/@spexcode/spec-cli/dist/plugin-harness.d.ts +21 -0
  116. package/node_modules/@spexcode/spec-cli/dist/plugin-harness.js +146 -0
  117. package/node_modules/@spexcode/spec-cli/dist/pty-bridge.d.ts +10 -0
  118. package/node_modules/@spexcode/spec-cli/dist/pty-bridge.js +404 -0
  119. package/node_modules/@spexcode/spec-cli/dist/pty-helper.d.mts +1 -0
  120. package/node_modules/@spexcode/spec-cli/dist/pty-helper.mjs +101 -0
  121. package/node_modules/@spexcode/spec-cli/dist/pty-native-helper.d.mts +2 -0
  122. package/node_modules/@spexcode/spec-cli/dist/pty-native-helper.mjs +23 -0
  123. package/node_modules/@spexcode/spec-cli/dist/public-graph.d.ts +46 -0
  124. package/node_modules/@spexcode/spec-cli/dist/public-graph.js +56 -0
  125. package/node_modules/@spexcode/spec-cli/dist/ranker.d.ts +15 -0
  126. package/node_modules/@spexcode/spec-cli/dist/ranker.js +188 -0
  127. package/node_modules/@spexcode/spec-cli/dist/reaper.d.ts +6 -0
  128. package/node_modules/@spexcode/spec-cli/dist/reaper.js +81 -0
  129. package/node_modules/@spexcode/spec-cli/dist/reviews.d.ts +189 -0
  130. package/node_modules/@spexcode/spec-cli/dist/reviews.js +350 -0
  131. package/node_modules/@spexcode/spec-cli/dist/runtime-guard.d.ts +6 -0
  132. package/node_modules/@spexcode/spec-cli/dist/runtime-guard.js +37 -0
  133. package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.d.ts +21 -0
  134. package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.js +84 -0
  135. package/node_modules/@spexcode/spec-cli/dist/runtime-rotate.d.ts +1 -0
  136. package/node_modules/@spexcode/spec-cli/dist/runtime-rotate.js +58 -0
  137. package/node_modules/@spexcode/spec-cli/dist/search.bench.d.mts +1 -0
  138. package/node_modules/@spexcode/spec-cli/dist/search.bench.mjs +75 -0
  139. package/node_modules/@spexcode/spec-cli/dist/search.d.ts +20 -0
  140. package/node_modules/@spexcode/spec-cli/dist/search.js +61 -0
  141. package/node_modules/@spexcode/spec-cli/dist/session-declarations.d.ts +13 -0
  142. package/node_modules/@spexcode/spec-cli/dist/session-declarations.js +126 -0
  143. package/node_modules/@spexcode/spec-cli/dist/session-execution.d.ts +10 -0
  144. package/node_modules/@spexcode/spec-cli/dist/session-execution.js +70 -0
  145. package/node_modules/@spexcode/spec-cli/dist/session-files.d.ts +28 -0
  146. package/node_modules/@spexcode/spec-cli/dist/session-files.js +130 -0
  147. package/node_modules/@spexcode/spec-cli/dist/session-follow.d.ts +31 -0
  148. package/node_modules/@spexcode/spec-cli/dist/session-follow.js +179 -0
  149. package/node_modules/@spexcode/spec-cli/dist/session-reparent.d.ts +10 -0
  150. package/node_modules/@spexcode/spec-cli/dist/session-reparent.js +33 -0
  151. package/node_modules/@spexcode/spec-cli/dist/session-timeline.d.ts +11 -0
  152. package/node_modules/@spexcode/spec-cli/dist/session-timeline.js +20 -0
  153. package/node_modules/@spexcode/spec-cli/dist/session-web.d.ts +24 -0
  154. package/node_modules/@spexcode/spec-cli/dist/session-web.js +141 -0
  155. package/node_modules/@spexcode/spec-cli/dist/sessions.d.ts +433 -0
  156. package/node_modules/@spexcode/spec-cli/dist/sessions.js +4667 -0
  157. package/node_modules/@spexcode/spec-cli/dist/sh.d.ts +1 -0
  158. package/node_modules/@spexcode/spec-cli/dist/sh.js +3 -0
  159. package/node_modules/@spexcode/spec-cli/dist/shim-runtime.d.ts +1 -0
  160. package/{spec-cli/src/shim-runtime.ts → node_modules/@spexcode/spec-cli/dist/shim-runtime.js} +3 -4
  161. package/node_modules/@spexcode/spec-cli/dist/slash-commands.d.ts +9 -0
  162. package/node_modules/@spexcode/spec-cli/dist/slash-commands.js +307 -0
  163. package/node_modules/@spexcode/spec-cli/dist/source-files.d.ts +9 -0
  164. package/node_modules/@spexcode/spec-cli/dist/source-files.js +113 -0
  165. package/node_modules/@spexcode/spec-cli/dist/supervise.d.ts +1 -0
  166. package/node_modules/@spexcode/spec-cli/dist/supervise.js +308 -0
  167. package/node_modules/@spexcode/spec-cli/dist/tree.d.ts +25 -0
  168. package/node_modules/@spexcode/spec-cli/dist/tree.js +98 -0
  169. package/node_modules/@spexcode/spec-cli/dist/tsx-bin.d.ts +3 -0
  170. package/node_modules/@spexcode/spec-cli/dist/tsx-bin.js +41 -0
  171. package/node_modules/@spexcode/spec-cli/dist/uninstall.d.ts +3 -0
  172. package/node_modules/@spexcode/spec-cli/dist/uninstall.js +177 -0
  173. package/node_modules/@spexcode/spec-cli/dist/uploads.d.ts +36 -0
  174. package/node_modules/@spexcode/spec-cli/dist/uploads.js +243 -0
  175. package/node_modules/@spexcode/spec-cli/dist/worktree-sources.d.ts +1 -0
  176. package/node_modules/@spexcode/spec-cli/dist/worktree-sources.js +47 -0
  177. package/{spec-cli → node_modules/@spexcode/spec-cli}/hooks/dispatch.sh +5 -5
  178. package/{spec-cli → node_modules/@spexcode/spec-cli}/hooks/harness.sh +1 -1
  179. package/node_modules/@spexcode/spec-cli/package.json +49 -0
  180. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
  181. package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/spec-first/spec-first.sh +1 -1
  182. package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/spec-of-file/spec-of-file.sh +1 -0
  183. package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/stop-gate/spec.md +2 -2
  184. package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +31 -29
  185. package/node_modules/@spexcode/spec-core/dist/anchors.d.ts +94 -0
  186. package/node_modules/@spexcode/spec-core/dist/anchors.js +730 -0
  187. package/node_modules/@spexcode/spec-core/dist/git.d.ts +170 -0
  188. package/node_modules/@spexcode/spec-core/dist/git.js +2768 -0
  189. package/node_modules/@spexcode/spec-core/dist/graph-delta.d.ts +44 -0
  190. package/node_modules/@spexcode/spec-core/dist/graph-delta.js +72 -0
  191. package/node_modules/@spexcode/spec-core/dist/graph.d.ts +34 -0
  192. package/node_modules/@spexcode/spec-core/dist/graph.js +237 -0
  193. package/node_modules/@spexcode/spec-core/dist/graphDelta.d.ts +3 -0
  194. package/node_modules/@spexcode/spec-core/dist/graphDelta.js +13 -0
  195. package/node_modules/@spexcode/spec-core/dist/harness-identity.d.ts +31 -0
  196. package/node_modules/@spexcode/spec-core/dist/harness-identity.js +20 -0
  197. package/node_modules/@spexcode/spec-core/dist/identity-presets.d.ts +152 -0
  198. package/node_modules/@spexcode/spec-core/dist/identity-presets.js +132 -0
  199. package/node_modules/@spexcode/spec-core/dist/index.d.ts +45 -0
  200. package/node_modules/@spexcode/spec-core/dist/index.js +19 -0
  201. package/node_modules/@spexcode/spec-core/dist/layout.d.ts +187 -0
  202. package/node_modules/@spexcode/spec-core/dist/layout.js +548 -0
  203. package/node_modules/@spexcode/spec-core/dist/process-identity.d.ts +37 -0
  204. package/node_modules/@spexcode/spec-core/dist/process-identity.js +214 -0
  205. package/node_modules/@spexcode/spec-core/dist/project-identity.d.ts +12 -0
  206. package/node_modules/@spexcode/spec-core/dist/project-identity.js +71 -0
  207. package/node_modules/@spexcode/spec-core/dist/project-store.d.ts +3 -0
  208. package/node_modules/@spexcode/spec-core/dist/project-store.js +14 -0
  209. package/node_modules/@spexcode/spec-core/dist/resilience.d.ts +2 -0
  210. package/node_modules/@spexcode/spec-core/dist/resilience.js +40 -0
  211. package/node_modules/@spexcode/spec-core/dist/review/index.d.ts +3 -0
  212. package/node_modules/@spexcode/spec-core/dist/review/index.js +5 -0
  213. package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.d.ts +77 -0
  214. package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.js +308 -0
  215. package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.d.ts +66 -0
  216. package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.js +180 -0
  217. package/node_modules/@spexcode/spec-core/dist/review/session.d.ts +4 -0
  218. package/node_modules/@spexcode/spec-core/dist/review/session.js +8 -0
  219. package/node_modules/@spexcode/spec-core/dist/reviewSnapshot.d.ts +15 -0
  220. package/node_modules/@spexcode/spec-core/dist/reviewSnapshot.js +12 -0
  221. package/node_modules/@spexcode/spec-core/dist/root-lru.d.ts +4 -0
  222. package/{spec-cli/src/root-lru.ts → node_modules/@spexcode/spec-core/dist/root-lru.js} +26 -30
  223. package/node_modules/@spexcode/spec-core/dist/specs.d.ts +117 -0
  224. package/node_modules/@spexcode/spec-core/dist/specs.js +489 -0
  225. package/node_modules/@spexcode/spec-core/package.json +28 -0
  226. package/node_modules/@spexcode/spec-eval/dist/cache.d.ts +10 -0
  227. package/node_modules/@spexcode/spec-eval/dist/cache.js +50 -0
  228. package/node_modules/@spexcode/spec-eval/dist/cli.d.ts +22 -0
  229. package/node_modules/@spexcode/spec-eval/dist/cli.js +981 -0
  230. package/node_modules/@spexcode/spec-eval/dist/evaltab.d.ts +98 -0
  231. package/node_modules/@spexcode/spec-eval/dist/evaltab.js +176 -0
  232. package/node_modules/@spexcode/spec-eval/dist/filing.d.ts +16 -0
  233. package/node_modules/@spexcode/spec-eval/dist/filing.js +36 -0
  234. package/node_modules/@spexcode/spec-eval/dist/freshness.d.ts +48 -0
  235. package/node_modules/@spexcode/spec-eval/dist/freshness.js +799 -0
  236. package/node_modules/@spexcode/spec-eval/dist/host.d.ts +46 -0
  237. package/node_modules/@spexcode/spec-eval/dist/host.js +53 -0
  238. package/node_modules/@spexcode/spec-eval/dist/humanok.d.ts +11 -0
  239. package/node_modules/@spexcode/spec-eval/dist/humanok.js +27 -0
  240. package/node_modules/@spexcode/spec-eval/dist/index.d.ts +4 -0
  241. package/node_modules/@spexcode/spec-eval/dist/index.js +4 -0
  242. package/node_modules/@spexcode/spec-eval/dist/remarks.d.ts +31 -0
  243. package/node_modules/@spexcode/spec-eval/dist/remarks.js +1 -0
  244. package/node_modules/@spexcode/spec-eval/dist/scenariofresh.d.ts +13 -0
  245. package/node_modules/@spexcode/spec-eval/dist/scenariofresh.js +303 -0
  246. package/node_modules/@spexcode/spec-eval/dist/scenarios.d.ts +99 -0
  247. package/node_modules/@spexcode/spec-eval/dist/scenarios.js +650 -0
  248. package/node_modules/@spexcode/spec-eval/dist/sessioneval.d.ts +307 -0
  249. package/node_modules/@spexcode/spec-eval/dist/sessioneval.js +1883 -0
  250. package/node_modules/@spexcode/spec-eval/dist/sidecar.d.ts +55 -0
  251. package/node_modules/@spexcode/spec-eval/dist/sidecar.js +82 -0
  252. package/node_modules/@spexcode/spec-eval/dist/timeline.d.ts +25 -0
  253. package/node_modules/@spexcode/spec-eval/dist/timeline.js +65 -0
  254. package/node_modules/@spexcode/spec-eval/dist/ui-path.d.ts +1 -0
  255. package/node_modules/@spexcode/spec-eval/dist/ui-path.js +2 -0
  256. package/node_modules/@spexcode/spec-eval/package.json +36 -0
  257. package/node_modules/@spexcode/spec-forge/dist/cache.d.ts +29 -0
  258. package/node_modules/@spexcode/spec-forge/dist/cache.js +59 -0
  259. package/node_modules/@spexcode/spec-forge/dist/cli.d.ts +1 -0
  260. package/node_modules/@spexcode/spec-forge/dist/cli.js +100 -0
  261. package/node_modules/@spexcode/spec-forge/dist/drivers/github.d.ts +2 -0
  262. package/node_modules/@spexcode/spec-forge/dist/drivers/github.js +116 -0
  263. package/node_modules/@spexcode/spec-forge/dist/drivers/gitlab.d.ts +7 -0
  264. package/node_modules/@spexcode/spec-forge/dist/drivers/gitlab.js +144 -0
  265. package/node_modules/@spexcode/spec-forge/dist/drivers.d.ts +10 -0
  266. package/node_modules/@spexcode/spec-forge/dist/drivers.js +83 -0
  267. package/node_modules/@spexcode/spec-forge/dist/index.d.ts +4 -0
  268. package/node_modules/@spexcode/spec-forge/dist/index.js +4 -0
  269. package/node_modules/@spexcode/spec-forge/dist/links.d.ts +15 -0
  270. package/node_modules/@spexcode/spec-forge/dist/links.js +65 -0
  271. package/node_modules/@spexcode/spec-forge/dist/needs-eval.d.ts +10 -0
  272. package/node_modules/@spexcode/spec-forge/dist/needs-eval.js +20 -0
  273. package/node_modules/@spexcode/spec-forge/dist/port.d.ts +53 -0
  274. package/node_modules/@spexcode/spec-forge/dist/port.js +1 -0
  275. package/node_modules/@spexcode/spec-forge/dist/resident.d.ts +7 -0
  276. package/node_modules/@spexcode/spec-forge/dist/resident.js +43 -0
  277. package/node_modules/@spexcode/spec-forge/package.json +32 -0
  278. package/package.json +33 -19
  279. package/spec-cli/bin/spex.mjs +0 -69
  280. package/spec-cli/src/anchors.ts +0 -728
  281. package/spec-cli/src/attach.ts +0 -43
  282. package/spec-cli/src/claude-headless.ts +0 -321
  283. package/spec-cli/src/cli.ts +0 -1258
  284. package/spec-cli/src/client.ts +0 -414
  285. package/spec-cli/src/cockpit.ts +0 -43
  286. package/spec-cli/src/codex-runtime-generations.ts +0 -646
  287. package/spec-cli/src/commit-surgery.ts +0 -68
  288. package/spec-cli/src/contract-filter.ts +0 -202
  289. package/spec-cli/src/delivery-queue.ts +0 -165
  290. package/spec-cli/src/doctor.ts +0 -516
  291. package/spec-cli/src/execution-trace.ts +0 -444
  292. package/spec-cli/src/file-write.ts +0 -22
  293. package/spec-cli/src/gateway-auth.ts +0 -181
  294. package/spec-cli/src/gateway-hub.ts +0 -358
  295. package/spec-cli/src/gateway.ts +0 -461
  296. package/spec-cli/src/git.ts +0 -2556
  297. package/spec-cli/src/graph.ts +0 -271
  298. package/spec-cli/src/graphCache.ts +0 -710
  299. package/spec-cli/src/graphDelta.ts +0 -77
  300. package/spec-cli/src/graphStream.ts +0 -1019
  301. package/spec-cli/src/harness-select.ts +0 -77
  302. package/spec-cli/src/harness.ts +0 -3025
  303. package/spec-cli/src/headless-controller.ts +0 -63
  304. package/spec-cli/src/help.ts +0 -492
  305. package/spec-cli/src/host-resources.ts +0 -762
  306. package/spec-cli/src/host.ts +0 -611
  307. package/spec-cli/src/identity-presets.d.ts +0 -13
  308. package/spec-cli/src/identity-presets.js +0 -138
  309. package/spec-cli/src/index.ts +0 -818
  310. package/spec-cli/src/init.ts +0 -258
  311. package/spec-cli/src/issues-cli.ts +0 -276
  312. package/spec-cli/src/issues.ts +0 -237
  313. package/spec-cli/src/layout.ts +0 -640
  314. package/spec-cli/src/lint.ts +0 -488
  315. package/spec-cli/src/listen.ts +0 -28
  316. package/spec-cli/src/localIssues.ts +0 -538
  317. package/spec-cli/src/materialize.ts +0 -417
  318. package/spec-cli/src/mentions.ts +0 -81
  319. package/spec-cli/src/opencode-headless.ts +0 -205
  320. package/spec-cli/src/pi-headless.ts +0 -153
  321. package/spec-cli/src/plugin-harness.ts +0 -157
  322. package/spec-cli/src/process-identity.ts +0 -207
  323. package/spec-cli/src/project-identity.ts +0 -73
  324. package/spec-cli/src/project-store.ts +0 -17
  325. package/spec-cli/src/pty-bridge.ts +0 -396
  326. package/spec-cli/src/pty-helper.mjs +0 -96
  327. package/spec-cli/src/pty-native-helper.mjs +0 -22
  328. package/spec-cli/src/ranker.ts +0 -191
  329. package/spec-cli/src/reaper.ts +0 -125
  330. package/spec-cli/src/resilience.ts +0 -41
  331. package/spec-cli/src/reviewSnapshot.ts +0 -28
  332. package/spec-cli/src/reviews.ts +0 -434
  333. package/spec-cli/src/runtime-guard.ts +0 -35
  334. package/spec-cli/src/runtime-ownership.ts +0 -88
  335. package/spec-cli/src/search.bench.mjs +0 -80
  336. package/spec-cli/src/search.ts +0 -63
  337. package/spec-cli/src/session-cursors.ts +0 -91
  338. package/spec-cli/src/session-execution.ts +0 -68
  339. package/spec-cli/src/session-files.ts +0 -121
  340. package/spec-cli/src/session-follow.ts +0 -182
  341. package/spec-cli/src/session-reparent.ts +0 -33
  342. package/spec-cli/src/session-timeline.ts +0 -247
  343. package/spec-cli/src/session-web.ts +0 -135
  344. package/spec-cli/src/sessions.ts +0 -3883
  345. package/spec-cli/src/sh.ts +0 -3
  346. package/spec-cli/src/slash-commands.ts +0 -318
  347. package/spec-cli/src/source-files.ts +0 -104
  348. package/spec-cli/src/specs.ts +0 -498
  349. package/spec-cli/src/supervise.ts +0 -245
  350. package/spec-cli/src/tree.ts +0 -118
  351. package/spec-cli/src/tsx-bin.ts +0 -18
  352. package/spec-cli/src/uninstall.ts +0 -171
  353. package/spec-cli/src/uploads.ts +0 -253
  354. package/spec-cli/src/worktree-sources.ts +0 -42
  355. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +0 -8
  356. package/spec-dashboard/dist/assets/App-B6IbFbGu.js +0 -2
  357. package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +0 -1
  358. package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +0 -27
  359. package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +0 -2
  360. package/spec-dashboard/dist/assets/IssuesPage-BG9rNOgV.js +0 -1
  361. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  362. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  363. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  364. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  365. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  366. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  367. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  368. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  369. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  370. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  371. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  372. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  373. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  374. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  375. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  376. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  377. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  378. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  379. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  380. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  381. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  382. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  383. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  384. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  385. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  386. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  387. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  388. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  389. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  390. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  391. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  392. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  393. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  394. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  395. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  396. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  397. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  398. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  399. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  400. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  401. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  402. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  403. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  404. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  405. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  406. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  407. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  408. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  409. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  410. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  411. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  412. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  413. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  414. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  415. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  416. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  417. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  418. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  419. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  420. package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +0 -2
  421. package/spec-dashboard/dist/assets/Modal-B3EcgeA3.js +0 -1
  422. package/spec-dashboard/dist/assets/PageScroll-CNZOugWV.js +0 -1
  423. package/spec-dashboard/dist/assets/ProjectsPage-C2CRHmvs.js +0 -1
  424. package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +0 -32
  425. package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +0 -40
  426. package/spec-dashboard/dist/assets/SessionWindow-1nYgZ4CL.js +0 -1
  427. package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +0 -1
  428. package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +0 -13
  429. package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +0 -1
  430. package/spec-dashboard/dist/assets/data-SlvxNA2r.js +0 -1
  431. package/spec-dashboard/dist/assets/index-DLVeOHL-.js +0 -41
  432. package/spec-dashboard/dist/assets/index-DYLGfPjx.css +0 -1
  433. package/spec-dashboard/dist/assets/launch-Cp579UoJ.css +0 -1
  434. package/spec-dashboard/dist/assets/launch-yxCYr64x.js +0 -276
  435. package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +0 -1
  436. package/spec-dashboard/dist/index.html +0 -29
  437. package/spec-dashboard/src/reviewFilters.js +0 -322
  438. package/spec-dashboard/src/reviewQuery.js +0 -174
  439. package/spec-dashboard/src/session.js +0 -199
  440. package/spec-eval/src/cache.ts +0 -57
  441. package/spec-eval/src/cli.ts +0 -894
  442. package/spec-eval/src/evaltab.ts +0 -264
  443. package/spec-eval/src/filing.ts +0 -39
  444. package/spec-eval/src/freshness.ts +0 -836
  445. package/spec-eval/src/humanok.ts +0 -28
  446. package/spec-eval/src/scenariofresh.ts +0 -244
  447. package/spec-eval/src/scenarios.ts +0 -672
  448. package/spec-eval/src/sessioneval.ts +0 -2220
  449. package/spec-eval/src/sidecar.ts +0 -93
  450. package/spec-eval/src/timeline.ts +0 -54
  451. package/spec-forge/src/__fixtures__/github-forge.json +0 -63
  452. package/spec-forge/src/cache.ts +0 -66
  453. package/spec-forge/src/cli.ts +0 -99
  454. package/spec-forge/src/drivers/github.ts +0 -132
  455. package/spec-forge/src/drivers/gitlab.ts +0 -153
  456. package/spec-forge/src/drivers.ts +0 -73
  457. package/spec-forge/src/links.ts +0 -72
  458. package/spec-forge/src/needs-eval.ts +0 -29
  459. package/spec-forge/src/port.ts +0 -36
  460. package/spec-forge/src/resident.ts +0 -46
  461. /package/{spec-cli → node_modules/@spexcode/spec-cli}/README.md +0 -0
  462. /package/{spec-cli → node_modules/@spexcode/spec-cli}/hooks/compat/mark-active-0.5.2-eef1.fixture +0 -0
  463. /package/{spec-cli → node_modules/@spexcode/spec-cli}/hooks/compat/mark-active-sed-v0.fixture +0 -0
  464. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/commit-msg +0 -0
  465. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/post-checkout +0 -0
  466. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/post-merge +0 -0
  467. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/pre-commit +0 -0
  468. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/prepare-commit-msg +0 -0
  469. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/reference-transaction +0 -0
  470. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/extract/spec.md +0 -0
  471. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/regroup/spec.md +0 -0
  472. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/rename/spec.md +0 -0
  473. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/spec.md +0 -0
  474. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/tidy/spec.md +0 -0
  475. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/comment-altitude/spec.md +0 -0
  476. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/idle/idle.sh +0 -0
  477. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/idle/spec.md +0 -0
  478. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/mark-active/mark-active.sh +0 -0
  479. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/mark-active/spec.md +0 -0
  480. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/session-fail/fail.sh +0 -0
  481. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/session-fail/spec.md +0 -0
  482. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/spec-first/spec.md +0 -0
  483. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/spec-of-file/spec.md +0 -0
  484. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/spec.md +0 -0
  485. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +0 -0
  486. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/prompts/forge-link/spec.md +0 -0
  487. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +0 -0
  488. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +0 -0
  489. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/prompts/spec.md +0 -0
  490. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/skills/distill/digest.mjs +0 -0
  491. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/skills/distill/spec.md +0 -0
  492. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/skills/spec.md +0 -0
  493. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/spec.md +0 -0
  494. /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/spec.md +0 -0
  495. /package/{spec-cli → node_modules/@spexcode/spec-core}/templates/spexcode.json +0 -0
@@ -1,3025 +0,0 @@
1
- import { writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, readdirSync, statSync } from 'node:fs'
2
- import { join, dirname, basename } from 'node:path'
3
- import { homedir, tmpdir } from 'node:os'
4
- import { createHash, randomBytes } from 'node:crypto'
5
- import { createConnection, type Socket } from 'node:net'
6
- import { execFile, execFileSync } from 'node:child_process'
7
- import { promisify } from 'node:util'
8
- import { fileURLToPath } from 'node:url'
9
- import { claudeSlashCommands, codexSlashCommands, opencodeSlashCommands, piSlashCommands, type SlashCommand } from './slash-commands.js'
10
- import { OPENCODE_EVENTS, opencodePluginSource } from './opencode.js'
11
- import { piExtensionSource, writePiTrust, removePiTrust } from './pi-harness.js'
12
- import { claudeHeadlessLaunchCommand, claudeHeadlessSock, deliverViaClaudeHeadless, interruptClaudeHeadless } from './claude-headless.js'
13
- import { codexHeadlessLaunchCommand } from './codex-headless.js'
14
- import { opencodeHeadlessLaunchCommand, spawnOpenCodeHeadlessTurn } from './opencode-headless.js'
15
- import { piHeadlessLaunchCommand, piHeadlessSock, deliverViaPiHeadless } from './pi-headless.js'
16
- import { runtimeRoot, mainCheckout, readConfig, sessionArtifactPath } from './layout.js'
17
- import { git } from './git.js'
18
- import { shQuote } from './sh.js'
19
- import { detachedRuntimeGenerationToken, migrateLegacyDetachedRuntimeReceipt, processStartToken, verifyDetachedRuntime, type VerifiedDetachedRuntime } from './process-identity.js'
20
- import { codexGenerationEndpoints, codexGenerationSocketPath, currentCodexGeneration, legacyCodexGenerationEndpoint, readCodexGenerationLedger, resolveCodexGenerationForSession, type CodexGenerationEndpoint } from './codex-runtime-generations.js'
21
- import { writeFileIfChanged } from './file-write.js'
22
- import { codexRolloutPath, noExecutionTrace, readCodexExecutionTrace, readLocalStoreExecutionTrace, readProjectJsonlExecutionTrace, readSessionJsonlExecutionTrace, type ExecutionTrace, type ExecutionTurn } from './execution-trace.js'
23
-
24
- // @@@ harness-adapter - the ONE seam between SpexCode and the coding-agent harness (Claude Code, Codex, …).
25
- // Every harness-specific fact lives behind THIS interface with one implementation per harness; product code
26
- // (materialize, sessions, slash, the hook scripts) never branches on which harness it is — it resolves an
27
- // adapter ONCE and calls it. The only `if (codex)` / `if (claude)` in the whole product is the detector that
28
- // picks the adapter (here), plus its shell mirror in hooks/harness.sh (shell cannot import this module).
29
- //
30
- // DETECTION. There is no payload-sniffing: each adapter OWNS its shim, and the shim bakes the harness id as
31
- // dispatch.sh's first argument (`bash <dispatch> <id> <Event>`). dispatch.sh exports SPEXCODE_HARNESS, so a
32
- // hook subprocess learns its harness deterministically from the shim that wired it — never from guessing the
33
- // payload shape. On the TS side the harness is derived from the selected launcher or ALL adapters at once
34
- // (materialize writes every harness's artifacts).
35
-
36
- export type HarnessId = 'claude' | 'codex' | 'opencode' | 'pi' | 'zcode' | 'claude-headless' | 'codex-headless' | 'opencode-headless' | 'pi-headless'
37
- export type HarnessLivenessRecord = { session: string; harnessSessionId?: string | null; stopped?: boolean; archived?: boolean }
38
- export type HarnessLaunchReadyRecord = HarnessLivenessRecord & { governed?: boolean; runtimeDir: string }
39
- export type HarnessLaunchReadinessFence = {
40
- readonly proof: Readonly<Record<string, unknown>>
41
- validate(current: () => HarnessLaunchReadyRecord | null): Promise<boolean>
42
- }
43
- export type TurnFailure = { message: string; completedAt: number | null }
44
- export type FailureSubscription = { close(): void; readonly closed: Promise<string | null> }
45
- // the per-pane runtime probe the caller snapshots ONCE for the whole session list and hands liveness():
46
- // the pane's root pid (tmux `#{pane_pid}`), the hot-tier `pidAlive` verdict, and — ONLY on the legacy path —
47
- // one whole-box pid→(ppid, comm) table (a single `ps` spawn).
48
- // `pidAlive` = the hot registry's verdict for THIS session's launch-registered `agent.pid`: true = the pid
49
- // answers kill-0 (alive), false = proven dead (ESRCH, permanently latched per pid-reuse guard), undefined =
50
- // NO agent.pid file (a pre-registration/old session). codex reads this as its liveness truth when present
51
- // and falls back to `procs` (the whole-box tree walk) only when it is undefined; claude ignores it (its
52
- // truth is the rendezvous socket).
53
- // `procs` is gathered (the single `ps` spawn) ONLY when a pid-less codex session still needs the legacy
54
- // tree-walk, so a box with no codex — or all pid-registered launches — never pays for it.
55
- export type ProcTable = Map<number, { ppid: number; comm: string }>
56
- export type PaneProbe = { panePid?: number; procs?: ProcTable; pidAlive?: boolean }
57
- export type SharedRuntimeDescriptor = {
58
- key: string
59
- label: string
60
- pidFile: string
61
- receiptFile: string
62
- // When a resident adapter has several immutable generations, this resolves the one exact descriptor a
63
- // governed target owns. Product code consumes the selection without learning adapter-specific identities.
64
- targetDescriptorKey?: (rec: HarnessLivenessRecord & { harnessSessionId?: string | null }) => string | null
65
- // Lightweight project-wide resident census used by read projections. It must return exact loaded IDs without
66
- // per-thread reads; the full probe remains the resource/lifecycle surface that also reads turn state.
67
- residency?: () => Promise<{ healthy: boolean; referenceIds: string[]; error?: string; rootAbsent?: boolean }>
68
- // Lifecycle mutation guard is deliberately narrower than the full resource projection: census every loaded
69
- // ID, but read only the exact governed target when it is loaded, plus both target descendant collections.
70
- mutationGuard?: (targetReferenceId: string, opts?: { coldReceipt?: unknown }) => Promise<SharedRuntimeMutationGuard>
71
- probe(): Promise<SharedRuntimeProbe>
72
- }
73
- export type SharedRuntimeMutationGuard = {
74
- healthy: boolean
75
- referenceIds: string[]
76
- targetTurnPresence: 'none' | 'idle' | 'active' | 'unknown'
77
- descendantIds: string[]
78
- coldTeardownAuthorized?: boolean
79
- error?: string
80
- }
81
- export type SharedRuntimeProbe = {
82
- healthy: boolean
83
- references: Array<{
84
- referenceId: string
85
- turnPresence: 'idle' | 'active' | 'unknown'
86
- turnId?: string
87
- }>
88
- error?: string
89
- }
90
-
91
- export type HarnessColdPreflight =
92
- | { ok: true; alreadyCold?: boolean; receipt?: unknown }
93
- | { ok: false; reason: string }
94
-
95
- // The corrupt-record quarantine path has no typed session record to pass into cold storage. The adapter therefore
96
- // owns this separate proof: it can archive one exact native orphan, return only public audit facts, and retain an
97
- // in-memory compensation closure for the caller's atomic record move. Product code never sees a native receipt.
98
- export type HarnessOrphanThreadQuarantine =
99
- | { ok: true; audit: { adapter: string; threadId: string; action: 'archived' | 'already-unloaded' }; compensate(): Promise<{ ok: true } | { ok: false; reason: string }> }
100
- | { ok: false; reason: string }
101
-
102
- export type AdapterLoadedReferenceState = {
103
- healthy: boolean
104
- loaded: boolean
105
- error?: string
106
- }
107
-
108
- // One project-wide resident-reference census for read projections. A shared app-server descriptor is probed
109
- // once per call, then its result is joined to every record that names that adapter/thread. Product readers must
110
- // not turn this into one RPC per row: a loaded thread can be externally reloaded after its cold proof was filed.
111
- export async function adapterLoadedReferenceState(
112
- records: readonly (HarnessLivenessRecord & { harness?: string })[],
113
- runtimeDir = runtimeRoot(),
114
- ): Promise<Map<string, AdapterLoadedReferenceState>> {
115
- const descriptors = new Map<string, SharedRuntimeDescriptor>()
116
- const recordKeys = new Map<string, string[]>()
117
- for (const rec of records) {
118
- if (!rec.harnessSessionId) continue
119
- const harness = harnessById(rec.harness || defaultHarness.id)
120
- const exactKey = harness.targetDescriptorKey?.(rec) ?? null
121
- const keys = (harness.sharedRuntimes?.(runtimeDir) ?? []).filter((descriptor) => !exactKey || descriptor.key === exactKey).map((descriptor) => {
122
- descriptors.set(descriptor.key, descriptor)
123
- return descriptor.key
124
- })
125
- recordKeys.set(`${rec.harness || defaultHarness.id}:${rec.harnessSessionId}`, keys)
126
- }
127
- const probes = await Promise.all([...descriptors.entries()].map(async ([key, descriptor]) => {
128
- try {
129
- const result = descriptor.residency
130
- ? await descriptor.residency()
131
- : await descriptor.probe().then((probe) => ({ healthy: probe.healthy, referenceIds: probe.references.map((reference) => reference.referenceId), error: probe.error }))
132
- return [key, result] as const
133
- }
134
- catch (error) { return [key, { healthy: false, referenceIds: [] as string[], error: (error as Error).message }] as const }
135
- }))
136
- const byKey = new Map(probes)
137
- const result = new Map<string, AdapterLoadedReferenceState>()
138
- for (const [recordKey, keys] of recordKeys) {
139
- const refs = keys.map((key) => byKey.get(key)!).filter(Boolean)
140
- if (!refs.length) continue
141
- const unhealthy = refs.find((probe) => !probe.healthy)
142
- const threadId = recordKey.slice(recordKey.indexOf(':') + 1)
143
- result.set(recordKey, unhealthy
144
- ? { healthy: false, loaded: false, error: unhealthy.error || 'adapter resident-reference census is unhealthy' }
145
- : { healthy: true, loaded: refs.some((probe) => probe.referenceIds.includes(threadId)) })
146
- }
147
- return result
148
- }
149
-
150
- export interface Harness {
151
- readonly id: HarnessId
152
- // the id baked into the materialized shim. Headless variants reuse their native family's shim.
153
- readonly dispatchId: 'claude' | 'codex' | 'opencode' | 'pi' | 'zcode'
154
- // whether this harness runs without an interactive TUI. The dashboard launcher picker hides headless
155
- // adapters by default ([[launcher-visibility]]); CLI launcher resolution never consumes that policy.
156
- readonly headless: boolean
157
- // whether the launch command intentionally exits after its first turn instead of owning a resident process.
158
- // One-shot adapters must not be mistaken for a failed fast boot and retried with a duplicate prompt.
159
- readonly launchOneShot?: boolean
160
- // Adapter-owned runtime shape: headless controllers/shared threads have no interactive TUI leaf to signal.
161
- readonly runtimeOwnership?: 'leaf' | 'adapter'
162
- // This launch command may create its project-shared control plane through internal shared-runtime-spawn.
163
- readonly sharedRuntimeSpawn?: boolean
164
- // @@@ fatalLaunchOutput - extended regexes matching THIS harness's own report of a launch failure that
165
- // RUNNING IT AGAIN CANNOT FIX: a conversation that does not exist, a rejected credential, a broken config.
166
- // A launcher that exits within the boot window tells us only that it exited fast, which is why the transport
167
- // retries — but when the harness itself named a settled cause, retrying spends a certain failure two more
168
- // times and buries the one line that explains it. So the transport asks the ADAPTER, and the adapter is the
169
- // only place a harness's wording is ever matched: product code consumes the verdict (retry / fatal), never
170
- // the text. A harness that declares none keeps the plain bounded retry.
171
- readonly fatalLaunchOutput?: readonly string[]
172
- // the lifecycle events this harness fires (drives the shim + the trust hashes). Claude binds the full set;
173
- // Codex's canonical hook event set (its `HookEventName` enum, codex 0.142.3) has no failed-stop and no
174
- // idle/attention event, so Codex has NO equivalent of StopFailure / Notification — a real harness difference,
175
- // not a TODO. It binds only the five it actually fires (see CODEX_EVENTS).
176
- readonly events: readonly string[]
177
- // whether the harness's agent opens a reclaude rendezvous control socket. Claude does; Codex has no such
178
- // daemon and uses its app-server JSON-RPC control plane instead.
179
- readonly ownsRendezvous: boolean
180
- // whether this harness's tmux pane_title is the agent's OWN live task self-summary (so the board headline
181
- // may derive from it — see [[session-activity]]). Claude continuously writes a one-line task summary into
182
- // its OSC title → true. Codex sets the pane title to a spinner glyph + the cwd basename (the worktree FOLDER
183
- // name), which is NOT a self-summary → false, so its headline falls through to the launch-prompt preview
184
- // instead of showing the folder name. This is the ONLY harness branch in the headline path: the capability
185
- // is data on the adapter, not an `if (codex)` in sessions.ts.
186
- readonly paneTitleIsSelfSummary: boolean
187
- // The adapter-only native transcript reader. Its compact result has no raw envelope, argument, output, or
188
- // reasoning data; product surfaces receive only the latest working note and typed tool steps.
189
- executionTrace(threadId: string, turn: ExecutionTurn | null): ExecutionTrace | null
190
- // --- launch / sessionId ---
191
- // the base agent command. Claude: `claude …`; Codex starts a project-scoped app-server and launches the
192
- // visible TUI with `--remote` pointed at it. `cmd` is the SESSION's persisted launcher command
193
- // ([[launcher-select]]) — the resolved `cmd` of the named launcher it was created under. A session always
194
- // carries one (pinned at creation), so resume keeps that exact command (and auth), never reverting to a
195
- // global default. Omitted is only for tests and old records before launch_cmd was pinned (→ the bare default).
196
- launchCmd(id: string, runtimeDir?: string, cmd?: string): string
197
- // the RESOLVED base launcher command alone — the wrapper/binary that carries the agent's config-dir env
198
- // (claude `CLAUDE_CONFIG_DIR`, codex `CODEX_HOME`), WITHOUT the per-launch script built around it. `cmd`,
199
- // when given (the named launcher's `cmd`), IS the answer; else the harness's bare built-in default — there is
200
- // no env/config-field resolution (claude/codex are ordinary named launchers). The launch owner PINS this on the record
201
- // at creation so a resume replays the EXACT launcher that created the conversation — never re-resolving
202
- // against a since-changed default, which would point `--resume` at the wrong config dir and lose the
203
- // transcript ([[launcher-select]], the resume-launcher-pin). launchCmd builds its invocation ON TOP of this.
204
- baseCmd(cmd?: string): string
205
- // the flag that pins the session id at launch. Claude lets the caller choose (`--session-id <id>`); Codex
206
- // assigns its own, so there is nothing to pass (the id is captured/resumed afterwards).
207
- sessionIdArg(id: string): string
208
- // the env var the agent's OWN process carries so its `spex …` calls know their session id.
209
- readonly sessionEnvVar: string
210
- // transport bootstrap variables scoped to this launch. Rendezvous adapters own their daemon mode + socket;
211
- // product launch code only composes these with generic session/home env.
212
- launchEnv(id: string): string[]
213
-
214
- // --- materialize: shim + contract + trust ([[harness-delivery]]) ---
215
- // the auto-discovered hook shim file for this harness (.claude/settings.json vs .codex/hooks.json).
216
- shimFile(proj: string): string
217
- // whether that shim belongs to one checkout or the whole project. This is adapter placement data: Codex
218
- // reads one root-checkout hook file for every linked tree; the other harnesses discover their tree-local file.
219
- shimScope: 'tree' | 'project'
220
- // a LINKED WORKTREE's extra shim copy — the worktree-side `.codex` hook file that ANCHORS codex's project
221
- // config layer, or null when the harness needs none. codex-rs only builds a project config layer (and thus
222
- // only DISCOVERS a worktree thread's hooks) for a dir in [cwd..project_root] that contains a `.codex/`
223
- // directory; it then REWRITES that layer's hooks-config folder to the ROOT checkout (root_checkout_hooks_-
224
- // folder_for_dir), so the shim CONTENT is still read from `shimFile` at the main checkout. But with the codex
225
- // shim living ONLY at the main checkout, a linked worktree has NO `.codex/` at all → codex anchors no layer →
226
- // the rewritten root hooks are never visited → ZERO hooks fire (bypass_hook_trust can't help: it only rescues
227
- // an untrusted HANDLER inside an already-discovered layer, it never creates one). So codex ALSO writes its
228
- // shim into the worktree's own `.codex/hooks.json` purely to anchor the layer (the rewrite ignores its
229
- // content, reading the root's — and a codex that DIDN'T rewrite would read this identical shim, so it is
230
- // correct either way). Claude: null — its shim already lives IN the worktree (`.claude/settings.json`) and
231
- // self-anchors; it has no root-checkout rewrite. Non-worktree (proj == main checkout): null — `shimFile`
232
- // already wrote `.codex/hooks.json` there.
233
- worktreeHookAnchor(proj: string): string | null
234
- // the contract file(s) the `surface: system` block is folded into. Claude: ./CLAUDE.md; Codex: ONLY ./AGENTS.md.
235
- contractFiles(proj: string): string[]
236
- // the dir this harness auto-discovers skills from, or null if it has no skill primitive — the ONLY place skill-surface divergence lives.
237
- skillDir(proj: string): string | null
238
- // the dir this harness auto-discovers sub-agent definitions from, or null if it has no agent primitive — the
239
- // ONLY place agent-surface divergence lives (the skillDir analog). Claude reads .claude/agents/<name>.md;
240
- // Codex has no file-discovered agent-definition primitive, so it returns null and materialize skips it.
241
- agentDir(proj: string): string | null
242
- // the shim payload: `content` is whatever artifact THIS harness auto-discovers to wire every event to the
243
- // dispatcher (harness id baked in) — a settings/hooks JSON for claude/codex, a generated event-bus PLUGIN
244
- // for opencode, a generated TypeScript EXTENSION for pi — plus the per-event command string (shared with
245
- // the trust writer so they hash identically).
246
- shim(dispatch: string, spex: string): { content: string; cmd: (e: string) => string }
247
- // make a dispatched/self-launched agent run the hooks with zero prompts. Codex writes PROJECT trust — and, on
248
- // a binary without `--dangerously-bypass-hook-trust`, per-hook trusted_hash blocks — into the GLOBAL
249
- // ~/.codex/config.toml (codex's security model: trust is global-only). PROJECT trust is UNCONDITIONAL: it
250
- // ENABLES the project config layer so codex discovers our hooks at all, a tier bypass_hook_trust does NOT
251
- // cover. Claude is a no-op (it relies on folder-trust). `cmdFor` MUST be the same per-event command the shim
252
- // emitted.
253
- writeTrust(proj: string, cmdFor: (e: string) => string): readonly string[]
254
-
255
- // --- the `/` menu ---
256
- // the slash-command list, computed the way THIS harness computes its own `/` menu.
257
- slashCommands(): SlashCommand[]
258
-
259
- // --- runtime: liveness + prompt delivery ([[harness-delivery]]) ---
260
- // is this session's agent process up? The caller passes the runtime facts it already computed in ONE
261
- // snapshot (see sessions.ts liveSnapshot): the window's presence, a PaneProbe — the pane's root pid plus one
262
- // whole-box process table — AND `socketLive`, whether a CONNECT to this session's rendezvous socket found a
263
- // live listener (the caller probes all windowed sessions once per snapshot). The adapter adds only its own
264
- // channel check. claude: online iff the window is up AND its reclaude rendezvous socket has a live LISTENER
265
- // (`socketLive` — a connect that a live claude accepts and a stale socket FILE refuses; claude IGNORES the
266
- // pane probe). codex: online iff the window is up AND the launch-registered `agent.pid` is alive
267
- // (`pane.pidAlive`, the hot-tier kill-0 verdict — zero ps scan); a pre-registration session with no agent.pid
268
- // (`pidAlive` undefined) falls back to the LEGACY whole-box tree walk — a codex-ish process (`codex` by any
269
- // name, or the `node` its CLI runs under) live in the pane pid's DESCENDANT tree, NOT the pane's foreground
270
- // command name (that is `bash`, the launch wrapper, even while the TUI renders — field-confirmed), and NOT the
271
- // SHARED per-project app-server socket (it stays bound after a failed `--remote resume` dropped the pane back
272
- // to the shell). A missing probe (tmux/ps couldn't report) is not-live. The 'starting' boot
273
- // grace lives in the caller (sessions.ts liveness), so a still-booting pane reads starting, not offline.
274
- liveness(rec: HarnessLivenessRecord, tmuxAlive: boolean, runtimeDir?: string, pane?: PaneProbe, socketLive?: boolean): 'online' | 'offline'
275
- // A completed launch command is only transport acceptance. An adapter with stronger runtime ownership may
276
- // keep the caller waiting until the launched conversation is genuinely addressable. The lazy record source
277
- // lets a one-shot launch publish its native id while readiness is pending. The returned adapter-owned fence
278
- // names the facts that established readiness and revalidates those SAME facts across the caller's record
279
- // commit. Null at the deadline is a launch failure; adapters without this seam retain the generic bounded
280
- // liveness fence.
281
- launchReady?(current: () => HarnessLaunchReadyRecord | null, deadline: number): Promise<HarnessLaunchReadinessFence | null>
282
- // Exact leaf ownership evidence consumed by lifecycle teardown. The adapter returns the one argv identity
283
- // token it registered for this record (session id, harness thread/generation, or null when unprovable);
284
- // product lifecycle code never branches on harness names to invent this identity.
285
- leafOwnerNeedle?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): string | null
286
- // Poke a live session and report whether this immediate channel accepted the attempt. Claude-family adapters
287
- // write one idempotent rendezvous reply; Codex uses JSON-RPC on the same app-server WebSocket the
288
- // visible TUI uses — it reads the thread live and either `turn/steer`s the message INTO an in-progress turn
289
- // (mid-turn, not queued for after the agent stops) or `turn/start`s a fresh turn when the thread is idle.
290
- // `ok=false` leaves the message OWED on the session's delivery queue, for a later pass to hand over.
291
- deliver(rec: HarnessDeliveryRecord, text: string): Promise<DispatchResult>
292
- // Observe native turn failures that this harness does not expose as a lifecycle hook. The adapter owns the
293
- // transport subscription; sessions owns observer reconciliation and the active-only lifecycle CAS.
294
- observeTurnFailures?(rec: HarnessDeliveryRecord, onFailure: (failure: TurnFailure) => void): FailureSubscription
295
- // Hard-interrupt the current turn through the harness's native control plane. Optional because a harness
296
- // without a confirmed native interrupt must refuse rather than emulate one with a signal or PTY key.
297
- interrupt?(rec: HarnessDeliveryRecord): Promise<DispatchResult>
298
- // Remove this harness's ephemeral runtime transport after stop/close. This is the runtime inverse of
299
- // launch: rendezvous owners unlink rvSock, claude-headless unlinks its control socket, Codex owns no
300
- // per-session socket. Product teardown calls only this adapter method.
301
- // Async because removal is CONDITIONAL on proof: a transport is only ours to remove once its listener is
302
- // proven dead (see unlinkSocks), and that proof is a connect probe.
303
- cleanupRuntime(rec: HarnessLivenessRecord): Promise<void>
304
- // Archive preflight runs BEFORE any leaf signal. It may inspect shared references to refuse an active or
305
- // unknown target turn, but it must not mutate the shared runtime; coldRuntime is the sole commit primitive.
306
- // Its optional receipt is opaque adapter authority: product code may only pass the same object back to the
307
- // stop guard and coldRuntime, never inspect it or synthesize a recursive/archive mode.
308
- coldPreflight?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): Promise<HarnessColdPreflight>
309
- // A record that is already archived needs a target-only continuing-cold proof. Unlike mutation preflight,
310
- // this must not thread/read unrelated loaded siblings merely to retire a target whose runtime is absent.
311
- coldRetirementPreflight?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): Promise<{ ok: true; alreadyCold: true } | { ok: false; reason: string }>
312
- // Optional cold-storage proof/cleanup. A harness with a per-session loaded reference must remove exactly that
313
- // reference or return a loud reason; adapters without such a resident reference return {ok:true}.
314
- coldRuntime?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }, receipt?: unknown): Promise<{ ok: true } | { ok: false; reason: string }>
315
- restoreRuntime?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }, receipt?: unknown): Promise<{ ok: true } | { ok: false; reason: string }>
316
- // Recovery for an unreadable governed record. This accepts no record-shaped ownership claim: the adapter must
317
- // prove the native target has zero other governed owners, is idle and descendant-free, then archive only it.
318
- quarantineOrphanThread?(threadId: string, opts: { excludingSessionId: string }): Promise<HarnessOrphanThreadQuarantine>
319
- // Project-scoped runtimes are adapter facts. Resource governance consumes these descriptors to report
320
- // references and protect a sibling-owned control plane without learning harness command names.
321
- sharedRuntimes?(runtimeDir: string): readonly SharedRuntimeDescriptor[]
322
- // Select the exact shared descriptor a record owns when an adapter has more than one resident generation.
323
- // Null is an unproven binding and must fail closed before lifecycle mutation.
324
- targetDescriptorKey?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): string | null
325
- // The one pane state where this harness swallows an immediate poke: given live pane text, return the reason
326
- // to skip that courtesy attempt or null when the pane can take it. sendText consults this after append;
327
- // absent on harnesses whose poke ignores pane state. Claude's
328
- // TUI's sessions panel ("← for agents") enqueues an injected reply to the panel context and never drains it
329
- // — verified live: parsed + enqueued, no dequeue, no turn, daemon silent.
330
- deliveryBlockedBy?(paneText: string): string | null
331
- // --- materialize: clean (the inverse of write — [[harness-select]] prunes a deselected harness) ---
332
- // clean is the EXACT inverse of materialize's per-harness write: SURGICALLY remove ONLY SpexCode's own
333
- // artifacts — the managed contract block (sentinels), the generated shim file, the trust block, and the
334
- // skill/agent files named in `arts` — never the user's surrounding prose, their other settings, or any .spec
335
- // data. materialize calls it for every UNSELECTED harness, so dropping a harness from spexcode.json's
336
- // `harnesses` prunes that harness's products on the next re-materialize.
337
- clean(proj: string, arts: HarnessArtifacts, preserveProject?: boolean): void
338
- // the inverse of writeTrust: strip THIS project's spexcode trust block from the harness's global config.
339
- // Codex removes its `~/.codex/config.toml` block; Claude is a no-op (it wrote none).
340
- removeTrust(proj: string): void
341
-
342
- // the relaunch tail reopen() hands launch() to bring the SAME work back up. claude resumes the same
343
- // conversation (`--resume <id>`, the id we pinned at launch). codex's own thread id is un-pinnable on the
344
- // launch flag, so the BACKEND owns it: it `thread/start`s the thread and stores the id at launch, so reopen
345
- // resumes the SAME conversation via codex's own `resume <thread-id>` subcommand (the stored harnessSessionId,
346
- // its rollout persisted on disk). Only a session whose thread id was never stored relaunches FRESH (empty
347
- // tail) in the same worktree/record — there is nothing to resume.
348
- resumeArg(rec: { session: string; harnessSessionId?: string | null }): string
349
- }
350
-
351
- // `ok` describes only this round's immediate adapter poke: the socket write, native controller request, or
352
- // app-server turn request reached its channel. [[dispatch]] has already decided delivery at the timeline append,
353
- // so an error means only that the reader will show the line at a later turn boundary. Defined here because this
354
- // is the adapter result sessions.ts consumes for same-turn timing.
355
- export type DispatchResult = { ok: boolean; error?: string }
356
- export type HarnessDeliveryRecord = {
357
- session: string
358
- worktreePath?: string
359
- harnessSessionId?: string | null
360
- runtimeDir?: string
361
- launchCmd?: string | null
362
- // The message's timeline id ([[session-timeline]]). Codex maps it to its native `clientUserMessageId`, while
363
- // other adapters may ignore it; product routing never needs to know which harness recognizes the marker.
364
- mid?: string
365
- }
366
- // the on-demand surface artifacts a materialize pass wrote, by node NAME — so clean() knows EXACTLY which
367
- // skill subdirs / agent files are SpexCode's to remove (name-scoped, never a blind wipe of a dir the user may
368
- // also populate). materialize passes the live skill/agent node names; clean reconstructs the same paths.
369
- export type HarnessArtifacts = { skills: readonly string[]; agents: readonly string[] }
370
-
371
- // @@@ rendezvous control socket - claude's DETERMINISTIC, ONLY input path for PROMPTS to sessions WE launch.
372
- // sessions.ts starts `claude` with CLAUDE_BG_BACKEND=daemon + CLAUDE_BG_RENDEZVOUS_SOCK=<this path> set ONLY on
373
- // that one spawned command (env prefix, never global). claude opens a unix socket here; writing one line
374
- // `{"type":"reply","text":"…"}\n` injects + submits the text as a prompt — no PTY typing, so multi-line input
375
- // and Enters can't be corrupted the way `tmux send-keys` was. It lives in tmpdir tied to the claude process, so
376
- // no extra lifecycle. liveness CONNECTS to it (a live LISTENER, not merely the file — see rendezvousListening);
377
- // deliver writes to it.
378
- //
379
- // The path is a LAUNCH-TIME FACT, recorded — not a formula every consumer re-derives. The id alone was not
380
- // enough to name it: `SPEXCODE_HOME` scopes the store and `SPEXCODE_TMUX` scopes the tmux server, so two
381
- // worlds on one box (a fixture, a migration, a copied record) can hold the same session id — and while the
382
- // path ignored that scoping, they SHARED this socket. That is how an isolated teardown reached out and
383
- // stranded a live production agent, and delivery would have crossed the same way. So the path a launch hands
384
- // its agent is derived from the runtime the session belongs to (`runtimeRoot()` — the same identity that
385
- // scopes its store) and STAMPED beside the record, exactly like `agent.pid`: a launch-time fact, readable by
386
- // everyone who needs to reach that agent afterwards. Recording it (rather than re-deriving) also means the
387
- // derivation can change again without stranding anything already running.
388
- // `legacyRvSock` is the answer for a session launched BEFORE the stamp existed — its agent really did bind
389
- // the unscoped path — so those keep working untouched, and the fallback retires as they turn over.
390
- export const legacyRvSock = (id: string) => join(tmpdir(), `spexcode-rv-${id}.sock`)
391
-
392
- // @@@ rendezvousSocketBase - macOS's TMPDIR commonly expands to /var/folders/<long-user-path>, leaving a
393
- // UUID-scoped socket beyond its 104-byte sun_path limit. It can be created and observed by pathname but every
394
- // connect fails EINVAL, which looks exactly like an unresponsive worker. Use a literal short /tmp spelling
395
- // (not tmpdir(), whose resolved form is long on macOS) with the same private per-uid directory pattern as the
396
- // Codex runtime socket. Existing launches retain their stamped old path through rvSock(); only new launches use
397
- // this base.
398
- const rendezvousSocketBase = () => {
399
- const base = join('/tmp', `spexcode-rv-${process.getuid?.() ?? 0}`)
400
- mkdirSync(base, { recursive: true, mode: 0o700 })
401
- return base
402
- }
403
- export const scopedRvSock = (id: string, dir = runtimeRoot()) =>
404
- join(rendezvousSocketBase(), `spexcode-rv-${createHash('sha1').update(dir).digest('hex').slice(0, 12)}-${id}.sock`)
405
- const rvStamp = (id: string) => sessionArtifactPath(id, 'rv.path')
406
- export const rvSock = (id: string): string => {
407
- try { return readFileSync(rvStamp(id), 'utf8').trim() || legacyRvSock(id) } catch { return legacyRvSock(id) }
408
- }
409
- // launch's half: derive this session's socket in ITS runtime and record it, so every later reader (launch env,
410
- // liveness probe, delivery, teardown) reads the one path the agent actually bound.
411
- export function stampRvSock(id: string, dir = runtimeRoot()): string {
412
- const path = scopedRvSock(id, dir)
413
- mkdirSync(dirname(rvStamp(id)), { recursive: true })
414
- writeFileSync(rvStamp(id), path)
415
- return path
416
- }
417
-
418
- // @@@ rendezvousListening - the LISTENER check that IS claude's liveness truth ([[state]], [[harness-adapter]]).
419
- // A crashed/killed claude can leave its rvSock FILE on disk (a unix-domain socket path is NOT auto-unlinked on
420
- // an unclean exit), so the old `existsSync(rvSock)` read a DEAD pane as `online` for as long as the stale file
421
- // lingered — the incident's "dead pane stuck `working` for 30+ min". The honest signal is a live LISTENER:
422
- // connect() to the socket. The verdict is TRI-STATE, because only two probe results actually PROVE anything:
423
- // 'live' — the connect completed: a real claude is accepting.
424
- // 'dead' — ECONNREFUSED (a stale file nothing listens on) / ENOENT (no file): death PROVEN, instantly.
425
- // 'unproven' — the probe itself failed to conclude: a TIMEOUT (under load the prober's event loop fires the
426
- // expired timer before the pending connect event — the thrashed-backend incident where every live worker
427
- // read offline in one board answer), or EAGAIN (the listen backlog is FULL, which proves a listener is
428
- // alive-but-busy, the opposite of dead). Collapsing these into 'dead' is how a load spike masqueraded as
429
- // a graveyard (issue #40); the caller must render unproven death as `unknown`, never `offline`.
430
- // The common cases cost no waiting (connect/refuse/absent are instant); the short timeout only bounds the
431
- // wedged/thrashed path. Never throws.
432
- export type ListenerProbe = 'live' | 'dead' | 'unproven'
433
- const PROVEN_DEAD = new Set(['ECONNREFUSED', 'ENOENT'])
434
- export function listenerAt(path: string, timeoutMs = 800): Promise<ListenerProbe> {
435
- return new Promise((resolve) => {
436
- let settled = false
437
- let c: ReturnType<typeof createConnection> | undefined
438
- const done = (v: ListenerProbe) => {
439
- if (settled) return
440
- settled = true
441
- clearTimeout(timer)
442
- try { c?.destroy() } catch { /* */ }
443
- resolve(v)
444
- }
445
- const timer = setTimeout(() => done('unproven'), timeoutMs)
446
- try { c = createConnection({ path }) } catch { return done('unproven') }
447
- c.on('connect', () => done('live'))
448
- c.on('error', (e) => done(PROVEN_DEAD.has((e as NodeJS.ErrnoException).code ?? '') ? 'dead' : 'unproven'))
449
- })
450
- }
451
- export const rendezvousListening = (id: string, timeoutMs = 800): Promise<ListenerProbe> => listenerAt(rvSock(id), timeoutMs)
452
- // The app-server Unix socket MUST live on a SHORT, sun_path-safe path — NOT nested under the project runtime
453
- // dir. macOS caps `sun_path` at ~104 bytes, and `runtimeRoot()` flattens the ENTIRE project path into one
454
- // dash-segment (`encodeProject`), so `<runtimeRoot>/codex-app-server.sock` blew past the cap on a deep macOS
455
- // project (~111 chars) → `path must be shorter than SUN_LEN` + connect EINVAL, and the app-server never bound
456
- // (Linux's 108 limit + shorter `/root` paths happened to fit; macOS did not). So the socket is
457
- // `<socketBase>/spexcode-cx-<hash>.sock`, where `<hash>` is a short STABLE digest of the PROJECT identity — the
458
- // `dir` (runtimeDir) the callers pass — so launch, liveness, and delivery all compute the IDENTICAL sock for a
459
- // given project (the ONE-app-server-per-project invariant). This is UNCONDITIONAL on every platform (a short
460
- // hashed path is strictly better everywhere — no darwin branch; platform differences stay at this path seam).
461
- // `<socketBase>` = the `SPEXCODE_CODEX_SOCKET_DIR` override, else an OWNED per-uid subdir of the platform
462
- // tmpdir (`spexcode-cx-<uid>`, created 0700) — NEVER bare tmpdir: codex (0.137+ field-confirmed) refuses to
463
- // bind a unix socket directly in the shared sticky `/tmp` on a host with `fs.protected_regular=2` (EPERM), so
464
- // the bare-tmpdir default failed every codex launch on a stock hardened Ubuntu out of the box (github#30),
465
- // while the SAME codex binds fine in any owned subdirectory. Per-uid (not one shared `spexcode-cx`) so a
466
- // second user on the box never lands in the first user's 0700 dir. The derivation GUARANTEES the dir exists
467
- // (idempotent mkdir) so every consumer — launch bake, liveness connect, delivery, tests — shares one creation
468
- // point. The `.pid`/`.log`/`.lock` files carry no sun_path limit and stay in `runtimeRoot`.
469
- export const codexAppServerSock = (dir = runtimeRoot()) => {
470
- return codexGenerationSocketPath(dir)
471
- }
472
- export const codexAppServerPid = (dir = runtimeRoot()) => join(dir, 'codex-app-server.pid')
473
- export const codexAppServerReceipt = (dir = runtimeRoot()) => join(dir, 'codex-app-server.detached.json')
474
- const codexAppServerLegacyScope = (dir = runtimeRoot()) => join(dir, 'codex-app-server.scope')
475
- type CodexRuntimeGenerationProof = Readonly<{
476
- identity: VerifiedDetachedRuntime
477
- socket: Readonly<{ path: string; dev: number; ino: number }>
478
- }>
479
- function codexRuntimeGenerationProof(dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir)): CodexRuntimeGenerationProof | null {
480
- try {
481
- const pid = Number(readFileSync(endpoint.pidFile, 'utf8').trim())
482
- const detached = verifyDetachedRuntime(pid, endpoint.receiptFile)
483
- const socketPath = endpoint.socketPath
484
- const socket = statSync(socketPath)
485
- if (!(pid > 0) || !detached.ok || !socket.isSocket()) return null
486
- return Object.freeze({
487
- identity: detached.identity,
488
- socket: Object.freeze({ path: socketPath, dev: socket.dev, ino: socket.ino }),
489
- })
490
- } catch { return null }
491
- }
492
- const codexRuntimeGenerationToken = (proof: CodexRuntimeGenerationProof) =>
493
- `${detachedRuntimeGenerationToken(proof.identity)}|${proof.socket.path}|${proof.socket.dev}:${proof.socket.ino}`
494
- function codexRuntimeGeneration(dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir)): string | null {
495
- const proof = codexRuntimeGenerationProof(dir, endpoint)
496
- return proof ? codexRuntimeGenerationToken(proof) : null
497
- }
498
-
499
- function codexMutationGeneration(dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir)): string | null {
500
- const current = codexRuntimeGeneration(dir, endpoint)
501
- if (current) return current
502
- if (endpoint.id !== 'legacy') return null
503
- let pid: number
504
- try {
505
- pid = Number(readFileSync(codexAppServerPid(dir), 'utf8').trim())
506
- if (!Number.isInteger(pid) || pid <= 0 || !statSync(codexAppServerSock(dir)).isSocket()) return null
507
- } catch { return null }
508
- if (!migrateLegacyDetachedRuntimeReceipt(pid, codexAppServerLegacyScope(dir), codexAppServerReceipt(dir))) return null
509
- return codexRuntimeGeneration(dir, endpoint)
510
- }
511
-
512
- const codexDescriptorKey = (endpoint: CodexGenerationEndpoint) => endpoint.id === 'legacy' ? 'codex-app-server' : `codex-app-server:${endpoint.id}`
513
-
514
- function codexEndpointForRecord(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }, dir = runtimeRoot()): CodexGenerationEndpoint | null {
515
- if (!rec.harnessSessionId) return null
516
- const ledger = readCodexGenerationLedger(dir)
517
- if (ledger.revision === 0 && !ledger.current && !Object.keys(ledger.generations).length) return legacyCodexGenerationEndpoint(dir)
518
- return resolveCodexGenerationForSession(dir, rec.session, rec.harnessSessionId)
519
- }
520
-
521
- // the spex launcher (bin/spex.mjs), baked into the codex launch script (mirrors materialize.ts's SPEX) so
522
- // the launch shell can call back into `spex codex-launch` to own the thread + fire the first turn before it
523
- // exec's the visible TUI. The launcher, never a raw `tsx cli.ts` pair: it owns tsx resolution and the
524
- // mid-merge guard (conflicted source → one line + exit 75, not a stacktrace).
525
- const PKG = fileURLToPath(new URL('..', import.meta.url))
526
- const SPEX = join(PKG, 'bin', 'spex.mjs')
527
-
528
- // The timeline is the message's copy, so rendezvous needs no receipt protocol. It writes one idempotent poke
529
- // carrying the timeline mid and reports only whether that write reached the local transport.
530
- type ClaudeForkTransport = { sock: string; auth: string }
531
-
532
- // The backend need not share the agent's config root: an explicitly chosen launcher can point Claude at its
533
- // own home. A moved source process remains alive by definition, so its one config-dir environment field is the
534
- // live authority for locating the daemon roster. A stale/reused pid can at worst name a roster with no exact
535
- // successor; it cannot select one without the moved/session-source checks below.
536
- function claudeConfigRoots(sourceSessionId: string, runtimeDir?: string): string[] {
537
- const roots: string[] = []
538
- try {
539
- const pidFile = runtimeDir ? join(runtimeDir, 'sessions', sourceSessionId, 'agent.pid') : sessionArtifactPath(sourceSessionId, 'agent.pid')
540
- const pid = Number(readFileSync(pidFile, 'utf8').trim())
541
- if (Number.isInteger(pid) && pid > 0) {
542
- const env = readFileSync(`/proc/${pid}/environ`, 'utf8').split('\0')
543
- const config = env.find((entry) => entry.startsWith('CLAUDE_CONFIG_DIR='))?.slice('CLAUDE_CONFIG_DIR='.length)
544
- if (config) roots.push(config)
545
- }
546
- } catch { /* source not available or this platform does not expose procfs */ }
547
- if (process.env.CLAUDE_CONFIG_DIR) roots.push(process.env.CLAUDE_CONFIG_DIR)
548
- roots.push(join(homedir(), '.claude'))
549
- return [...new Set(roots)]
550
- }
551
-
552
- // A moved Claude conversation is a daemon-owned fork. Its launch-time socket still answers, but that process
553
- // no longer returns to its prompt. A successor hook may have already recorded its exact Claude session id in
554
- // `moved`; use that durable identity first, then retain the roster's source-transcript relation for deployments
555
- // without the stamp. The roster remains the sole source of the live socket and current auth token. Keep this
556
- // lookup Claude-local so other rendezvous adapters cannot adopt a coincidentally matching id.
557
- function claudeForkTransport(sourceSessionId: string, runtimeDir?: string): ClaudeForkTransport | null {
558
- const moved = (() => {
559
- try {
560
- const stamp = runtimeDir ? join(runtimeDir, 'sessions', sourceSessionId, 'moved') : sessionArtifactPath(sourceSessionId, 'moved')
561
- return readFileSync(stamp, 'utf8').trim()
562
- } catch {
563
- return ''
564
- }
565
- })()
566
- for (const configDir of claudeConfigRoots(sourceSessionId, runtimeDir)) {
567
- try {
568
- const roster = JSON.parse(readFileSync(join(configDir, 'daemon', 'roster.json'), 'utf8')) as { workers?: Record<string, any> }
569
- const workers = Object.values(roster.workers ?? {})
570
- const usable = (worker: any) => typeof worker?.rendezvousSock === 'string' && typeof worker.rvAuth === 'string'
571
- const recorded = workers.filter((worker) => usable(worker) && moved && worker?.sessionId === moved)
572
- const candidates = recorded.length ? recorded : workers.filter((worker) => {
573
- const launch = worker?.dispatch?.launch
574
- if (launch?.mode !== 'resume' || launch.fork !== true || typeof launch.sessionId !== 'string') return false
575
- const source = basename(launch.sessionId).replace(/\.jsonl$/, '')
576
- return source === sourceSessionId && usable(worker)
577
- })
578
- const worker = candidates.sort((a, b) => Number(b.startedAt ?? 0) - Number(a.startedAt ?? 0))[0]
579
- if (worker) return { sock: worker.rendezvousSock, auth: worker.rvAuth }
580
- } catch { /* this Claude config has no readable daemon roster */ }
581
- }
582
- return null
583
- }
584
-
585
- function replyViaSocket(sock: string, text: string, mid?: string, auth?: string): Promise<DispatchResult> {
586
- return new Promise((resolve) => {
587
- let settled = false
588
- let c: ReturnType<typeof createConnection>
589
- const done = (r: DispatchResult) => {
590
- if (settled) return
591
- settled = true
592
- if (!r.ok) try { c?.destroy() } catch { /* */ }
593
- resolve(r)
594
- }
595
- try {
596
- c = createConnection({ path: sock })
597
- } catch (e) {
598
- done({ ok: false, error: `rendezvous socket connect threw: ${String(e)}` })
599
- return
600
- }
601
- c.on('error', (e: NodeJS.ErrnoException) => {
602
- const code = e?.code || String(e)
603
- done({ ok: false, error: `rendezvous socket error: ${code}` })
604
- })
605
- c.on('close', () => done({ ok: false, error: 'rendezvous connection closed before the poke was written' }))
606
- c.on('connect', () => c.write(
607
- `${auth ? JSON.stringify({ role: 'controller', auth }) + '\n' : ''}${JSON.stringify({ type: 'reply', text, ...(mid ? { mid } : {}) })}\n`,
608
- (error) => {
609
- if (error) return done({ ok: false, error: `rendezvous socket write failed: ${error.message}` })
610
- c.end()
611
- done({ ok: true })
612
- },
613
- ))
614
- })
615
- }
616
- const POKE_ATTEMPTS = 2
617
- async function pokeRendezvous(sock: string, text: string, mid?: string, auth?: string): Promise<DispatchResult> {
618
- let last: DispatchResult = { ok: false, error: 'not attempted' }
619
- for (let attempt = 0; attempt < POKE_ATTEMPTS; attempt++) {
620
- last = await replyViaSocket(sock, text, mid, auth)
621
- if (last.ok) return last
622
- }
623
- return { ok: false, error: `rendezvous poke failed after ${POKE_ATTEMPTS} attempts: ${last.error ?? 'unknown error'}` }
624
- }
625
-
626
- export async function deliverViaRendezvous(id: string, text: string, mid?: string): Promise<DispatchResult> {
627
- return pokeRendezvous(rvSock(id), text, mid)
628
- }
629
-
630
- export async function deliverViaClaudeRendezvous(id: string, text: string, mid?: string, runtimeDir?: string): Promise<DispatchResult> {
631
- const fork = claudeForkTransport(id, runtimeDir)
632
- const sourceSock = rvSock(id)
633
- if (!fork) return pokeRendezvous(sourceSock, text, mid)
634
-
635
- const forkResult = await pokeRendezvous(fork.sock, text, mid, fork.auth)
636
- if (forkResult.ok || fork.sock === sourceSock) return forkResult
637
-
638
- const sourceResult = await pokeRendezvous(sourceSock, text, mid)
639
- if (sourceResult.ok) return sourceResult
640
- return { ok: false, error: `fork rendezvous failed: ${forkResult.error}; source fallback failed: ${sourceResult.error}` }
641
- }
642
-
643
- export async function deliverViaSocketOrWake(
644
- id: string,
645
- text: string,
646
- mid: string | undefined,
647
- coldWake: () => Promise<DispatchResult>,
648
- unprovenError: string,
649
- ): Promise<DispatchResult> {
650
- const probe = await rendezvousListening(id)
651
- if (probe === 'live') return deliverViaRendezvous(id, text, mid)
652
- if (probe === 'unproven') return { ok: false, error: unprovenError }
653
- return coldWake()
654
- }
655
-
656
- type JsonRpc = { id?: number; method?: string; params?: unknown; result?: unknown; error?: { code?: number; message?: string } }
657
-
658
- // The JSON-RPC the delivery handshake speaks, in send order. Method names + param shapes are pinned to codex
659
- // 0.142.3 (`codex app-server generate-ts` → ClientRequest.ts / v2/*Params.ts): the visible TUI is launched with
660
- // `codex --remote unix://<sock>`, so its thread is ALREADY loaded in this server — we must NOT `thread/resume`
661
- // it (that re-loads a thread the live TUI already owns). Instead `thread/loaded/list` PROVES the captured thread
662
- // is the one the pane is showing, then `thread/read{includeTurns}` reveals whether a turn is in progress (and
663
- // its id). The 4th, injecting message is CHOSEN from that read — see codexInjectMessage.
664
- const codexTextInput = (text: string) => [{ type: 'text', text, text_elements: [] }]
665
- export function codexHandshakeMessages(threadId: string): JsonRpc[] {
666
- return [
667
- {
668
- id: 1,
669
- method: 'initialize',
670
- params: {
671
- clientInfo: { name: 'spexcode', title: 'SpexCode', version: '0.0.0' },
672
- capabilities: { experimentalApi: true, requestAttestation: false },
673
- },
674
- },
675
- { method: 'initialized', params: {} },
676
- { id: 2, method: 'thread/loaded/list', params: {} },
677
- { id: 3, method: 'thread/read', params: { threadId, includeTurns: true } },
678
- ]
679
- }
680
-
681
- // the message that injects `text`. STEER (turn/steer) when an active turn id is known — codex processes it
682
- // WITHOUT waiting for the current turn to end (the human's "工具调用完就插入": injected the moment the running
683
- // tool call returns), so a busy agent reacts mid-turn instead of queuing the message for after it stops.
684
- // `TurnSteerParams` REQUIRES the live turn id as `expectedTurnId` (the server rejects a stale one) — so this is
685
- // only sent with a turnId read live from the thread, never from SpexCode's session status. When the thread is
686
- // idle (no active turn id), START a fresh turn (turn/start). `id` is parameterized so a steer that loses the
687
- // expectedTurnId race (turn ended in the read→steer window) can retry as a turn/start with id 5.
688
- export function codexInjectMessage(threadId: string, text: string, cwd: string | undefined, activeTurnId: string | null, id = 4, clientUserMessageId?: string): JsonRpc {
689
- const marker = clientUserMessageId ? { clientUserMessageId } : {}
690
- if (activeTurnId)
691
- return { id, method: 'turn/steer', params: { threadId, input: codexTextInput(text), expectedTurnId: activeTurnId, ...marker } }
692
- return { id, method: 'turn/start', params: { threadId, input: codexTextInput(text), ...(cwd ? { cwd } : {}), ...marker } }
693
- }
694
-
695
- // the in-progress turn id from a `thread/read{includeTurns}` result, or null when the thread is idle. With
696
- // includeTurns the Thread carries its turns, each with a TurnStatus ("completed"|"interrupted"|"failed"|
697
- // "inProgress"); the live turn is the `inProgress` one and its id is exactly what turn/steer's precondition needs.
698
- export function activeTurnIdFromThread(readResult: unknown): string | null {
699
- const thread = (readResult as { thread?: { turns?: Array<{ id?: string; status?: string }> } })?.thread
700
- const turns = Array.isArray(thread?.turns) ? thread.turns : []
701
- const active = turns.find((t) => t?.status === 'inProgress')
702
- return active?.id ?? null
703
- }
704
-
705
- // The app-server and the visible `--remote … resume` TUI share ONE socket, so they MUST be the SAME codex
706
- // install — a version split across that socket breaks the thread/start→resume handoff (an app-server on one
707
- // version creates a thread a differently-versioned resume can't find; an old-enough app-server can't serve
708
- // `--remote unix://` at all). So `serverCmd` is DERIVED from the in-effect `codexCmd`'s binary (its first shell
709
- // token, dropping args like `--yolo`) whenever it isn't explicitly forced: `<bin> app-server` then runs the
710
- // SAME install as `<bin> --remote … resume`. Bare `codex` is NOT the default anymore — on a multi-install host
711
- // (e.g. homebrew codex shadowing an nvm codex) a bare `codex` resolves via the login-shell PATH to a DIFFERENT
712
- // binary than the launcher's, which is exactly the version-skew bug. `SPEXCODE_CODEX_SERVER_CMD` stays the
713
- // explicit escape hatch (highest precedence). Caveat: if `codexCmd`'s first token is a WRAPPER script rather
714
- // than codex itself, the derived `<wrapper> app-server` only works if the wrapper forwards to codex — the
715
- // common direct-binary case (`codex …`, `/abs/codex --yolo`) is what this fixes.
716
- export function codexBinary(codexCmd: string): string {
717
- return codexCmd.trim().split(/\s+/)[0] || 'codex'
718
- }
719
- // codex >=0.142 adds `--dangerously-bypass-hook-trust` — run our OWN (vetted) dispatch hooks without a persisted
720
- // trusted_hash. We PREFER it over reverse-engineering codexHookHash: that hash is pinned to one codex version's
721
- // format and silently breaks on a bump (codex then skips ALL our hooks -> no Stop gate, no mark-active, sessions
722
- // die undeclared). The flag is version-robust. But an OLDER codex HARD-ERRORS on the unknown flag (the whole
723
- // app-server fails to boot), so we CAPABILITY-PROBE the binary once (`--help` grep) and only pass it when
724
- // present; otherwise the writeCodexTrust hash path still stands in. Memoized — a per-binary constant.
725
- const bypassProbe = new Map<string, boolean>()
726
- export function codexSupportsBypassHookTrust(binary: string): boolean {
727
- // explicit escape hatch (also what makes this deterministic in tests): force the capability on/off regardless
728
- // of the binary — e.g. if the `--help` probe is unreliable on a wrapper, or to pin behaviour.
729
- const env = process.env.SPEXCODE_CODEX_BYPASS_HOOK_TRUST
730
- if (env !== undefined) return env === '1' || env === 'true'
731
- const hit = bypassProbe.get(binary)
732
- if (hit !== undefined) return hit
733
- let ok = false
734
- try { ok = execFileSync(binary, ['--help'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).includes('--dangerously-bypass-hook-trust') } catch { ok = false }
735
- bypassProbe.set(binary, ok)
736
- return ok
737
- }
738
-
739
- // Headless adapters all feed non-zero ephemeral turn exits through one state writer. Keep this reporter at the
740
- // adapter seam: controllers may call it directly, while shell-homed turns use headlessTurnFailureShell below.
741
- export async function reportHeadlessTurnExit(id: string, harness: string, code: number | null, cwd = process.cwd()): Promise<void> {
742
- if (code === 0) return
743
- const exitCode = code === null ? 'signal' : String(code)
744
- try {
745
- await pexec(SPEX, ['internal', 'session-turn-fail', id, harness, exitCode], { cwd, env: process.env })
746
- } catch (error) {
747
- console.error(`[spex ${harness}] could not record turn failure for ${id}: ${(error as Error).message}`)
748
- }
749
- }
750
-
751
- export function headlessTurnFailureShell(harness: string, swallow = true): string {
752
- return `${shQuote(SPEX)} internal session-turn-fail "$SPEXCODE_SESSION_ID" ${shQuote(harness)} "$__spex_rc"${swallow ? ' || true' : ''}`
753
- }
754
- // @@@ sessionIdentityEnvVars - every environment variable that names ONE session: the launch-injected record
755
- // id plus each adapter's own `sessionEnvVar`. Adapter-derived, so a new harness needs no edit here. A
756
- // per-session process is entitled to carry them; a SHARED, project-scoped daemon must not — see the app-server
757
- // spawn below.
758
- export function sessionIdentityEnvVars(): string[] {
759
- return [...new Set(['SPEXCODE_SESSION_ID', ...HARNESSES.map((h) => h.sessionEnvVar)])].filter(Boolean)
760
- }
761
- export function codexLaunchCommand(id: string, codexCmd = 'codex', serverCmd?: string, dir = runtimeRoot(), attachTui = true): string {
762
- const server = process.env.SPEXCODE_CODEX_SERVER_CMD || serverCmd || codexBinary(codexCmd)
763
- // The bypass flag ONLY reaches a thread's hook trust as a per-request `config` override, NOT as a CLI flag on
764
- // the shared `app-server` process (the app-server never reads its own `--dangerously-bypass-hook-trust` for a
765
- // thread — it was INERT there, the bug). Two thread paths carry it: (1) the BACKEND-owned `thread/start` sends
766
- // `config.bypass_hook_trust` from codex-launch ([[harness-adapter]]); (2) the visible `--remote … resume` TUI,
767
- // where codex's OWN client forwards this flag into its thread/start+thread/resume config — so a reopen in a
768
- // fresh app-server (where codex-launch never runs) still trusts our hooks. Hence the flag lives on the resume
769
- // TUI, never on the app-server invocation. Guarded against a double-flag when an env override already carries it.
770
- const tuiBypass = !codexCmd.includes('--dangerously-bypass-hook-trust') && codexSupportsBypassHookTrust(codexBinary(codexCmd)) ? ' --dangerously-bypass-hook-trust' : ''
771
- const script = [
772
- `dir=${shQuote(dir)}`,
773
- // codex-launch's bypass-trust gate (and writeTrust's) resolves the codex binary from SPEXCODE_CODEX_CMD;
774
- // WE already hold the launcher's real cmd here (it drives the app-server + resume TUI + tuiBypass above), so
775
- // pin it into the environment the codex-launch child inherits. Without this the child falls back to a bare
776
- // `codex`, which on a multi-install box (e.g. an old Homebrew codex on PATH beside the launcher's newer one)
777
- // probes the WRONG binary — deciding "no --dangerously-bypass-hook-trust support" and silently dropping the
778
- // thread/start bypass, so the worktree's hooks stay untrusted and NO lifecycle hooks fire.
779
- `export SPEXCODE_CODEX_CMD=${shQuote(codexCmd)}`,
780
- // The runtime command is the single generation-ledger boundary. A new turn receives canonical `current`;
781
- // resume resolves its existing session/thread binding, so a LIVE root never has its conversation moved to a
782
- // replacement. Both spellings carry the server command because either may be the launch that has to start a
783
- // root: after a host restart the bound generation is a corpse, and resume rebuilds one to load the same
784
- // on-disk rollout. It prints only shell assignments for the exact proven endpoint.
785
- 'if [ "$1" = "--resume" ]; then',
786
- ` eval "$( ${SPEX} internal codex-generation-session "$dir" "$SPEXCODE_SESSION_ID" "$2" ${shQuote(server)} )" || exit 1`,
787
- 'else',
788
- ` eval "$( ${SPEX} internal codex-generation-current "$dir" ${shQuote(server)} )" || exit 1`,
789
- 'fi',
790
- // TWO launch modes, on ONE tail channel ("$@"). reopen() hands a `--resume <thread-id>` tail (see
791
- // codexHarness.resumeArg) to bring the SAME conversation back: resume that OWNED thread DIRECTLY — no new
792
- // thread, no first-turn prompt. ANY other tail is a NEW launch: BACKEND owns the thread — `codex-launch`
793
- // does thread/start { cwd = this worktree } on the shared per-project app-server, stores the new id on the
794
- // governed record (SPEXCODE_SESSION_ID), and fires the tail as the FIRST turn, materializing the rollout.
795
- // Either way it ends with a thread id, which the visible TUI then RESUMES (the rollout persists on disk),
796
- // rendering it natively. A new launch's tail is always ONE single-quoted prompt arg, so it can never be the
797
- // literal "--resume" marker — the discriminator is unambiguous. codex-launch only prints an id once its
798
- // rollout has landed (resume-ready), so a fail-loud (empty output / non-zero) must ABORT — never `resume ""`.
799
- `if [ "$1" = "--resume" ]; then`,
800
- ` tid=$2`,
801
- ...(attachTui ? [] : [
802
- // A headless forced reopen has no TUI to attach and the shared app-server already owns the thread. Keep it
803
- // a no-op instead of calling codex-launch without a prompt (which would mint an unrelated empty thread).
804
- `elif [ "$#" -eq 0 ]; then`,
805
- ` exit 0`,
806
- ]),
807
- `else`,
808
- ` tid=$(${SPEX} internal codex-launch "$sock" "$PWD" "$@")`,
809
- ` __spex_rc=$?`,
810
- ...(attachTui ? [` [ "$__spex_rc" -eq 0 ] || exit 1`] : [
811
- ` if [ "$__spex_rc" -ne 0 ]; then ${headlessTurnFailureShell('codex-headless')}; exit "$__spex_rc"; fi`,
812
- ]),
813
- `fi`,
814
- `[ -n "$tid" ] || { echo "[spex] codex-launch produced no resumable thread" >&2; exit 1; }`,
815
- // The visible TUI is the OTHER entry point that creates an execution context for this session (a fresh
816
- // launch attaches to the thread codex-launch just made; a reopen resumes an existing one), so it injects
817
- // the same per-thread identity through codex's own `-c` override. Same rule, both entry points: whoever
818
- // creates a context stamps that context's record id, and nothing downstream re-derives it.
819
- ...(attachTui ? [`exec ${codexCmd}${tuiBypass} -c ${shQuote(`shell_environment_policy.set.SPEXCODE_SESSION_ID=${id}`)} --remote unix://"$sock" resume "$tid"`] : []),
820
- ].join('\n')
821
- return `bash -lc ${shQuote(script)} spexcode-codex`
822
- }
823
-
824
- function rpcError(e: unknown): string {
825
- return String((e as Error)?.message || e)
826
- }
827
-
828
- // --- minimal RFC6455 client framing ------------------------------------------------------------------------
829
- // The codex app-server `--listen unix://<sock>` transport is a WebSocket endpoint at path `/rpc` (the visible
830
- // `codex --remote` TUI upgrades the very same way). So we speak WebSocket over the Unix socket — NOT a raw byte
831
- // stream, and NOT `codex app-server proxy` (a dumb byte relay that performs no HTTP upgrade, so the server
832
- // rejects its bytes as an invalid upgrade and closes — the old 502). One JSON-RPC message = one masked text
833
- // frame; the server's frames come back unmasked. We only ever exchange small frames, so this is deliberately
834
- // small: text + the control frames (ping→pong, close) we must honor, plus continuation reassembly for safety.
835
- function encodeWsFrame(opcode: number, payload: Buffer): Buffer {
836
- const len = payload.length
837
- const mask = randomBytes(4)
838
- let header: Buffer
839
- if (len < 126) header = Buffer.from([0x80 | opcode, 0x80 | len])
840
- else if (len < 65536) header = Buffer.from([0x80 | opcode, 0x80 | 126, (len >> 8) & 0xff, len & 0xff])
841
- else { header = Buffer.alloc(10); header[0] = 0x80 | opcode; header[1] = 0x80 | 127; header.writeBigUInt64BE(BigInt(len), 2) }
842
- const masked = Buffer.alloc(len)
843
- for (let i = 0; i < len; i++) masked[i] = payload[i] ^ mask[i % 4]
844
- return Buffer.concat([header, mask, masked])
845
- }
846
- const wsText = (s: string) => encodeWsFrame(0x1, Buffer.from(s, 'utf8'))
847
-
848
- // Decode the unmasked server→client frames accumulated in `buf`, handing each complete text message to
849
- // `onText`; honors ping→pong and a close. Shared by every app-server WS client here. Returns the (possibly
850
- // shrunk) buffer + whether a close was seen, plus the running fragment state threaded back in on each call.
851
- type FrameState = { buf: Buffer; fragOp: number; fragBuf: Buffer }
852
- function drainWsFrames(s: FrameState, conn: Socket, onText: (json: string) => void): boolean {
853
- for (;;) {
854
- if (s.buf.length < 2) return false
855
- const b0 = s.buf[0], b1 = s.buf[1], op = b0 & 0x0f, fin = (b0 & 0x80) !== 0, masked = (b1 & 0x80) !== 0
856
- let len = b1 & 0x7f, off = 2
857
- if (len === 126) { if (s.buf.length < 4) return false; len = s.buf.readUInt16BE(2); off = 4 }
858
- else if (len === 127) { if (s.buf.length < 10) return false; len = Number(s.buf.readBigUInt64BE(2)); off = 10 }
859
- const dataStart = off + (masked ? 4 : 0)
860
- if (s.buf.length < dataStart + len) return false
861
- let payload = s.buf.slice(dataStart, dataStart + len)
862
- if (masked) { const mk = s.buf.slice(off, off + 4); const u = Buffer.alloc(len); for (let i = 0; i < len; i++) u[i] = payload[i] ^ mk[i % 4]; payload = u }
863
- s.buf = s.buf.slice(dataStart + len)
864
- if (op === 0x8) return true // close
865
- if (op === 0x9) { conn.write(encodeWsFrame(0xa, payload)); continue } // ping → pong
866
- if (op === 0xa) continue // pong
867
- if (op === 0x0) s.fragBuf = Buffer.concat([s.fragBuf, payload]) // continuation
868
- else { s.fragOp = op; s.fragBuf = payload }
869
- if (fin) { if (s.fragOp === 0x1) onText(s.fragBuf.toString('utf8')); s.fragBuf = Buffer.alloc(0); s.fragOp = 0 }
870
- }
871
- }
872
- const WS_UPGRADE = (key: string) => `GET /rpc HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: ${key}\r\n\r\n`
873
- const wsInitialize: JsonRpc = { id: 1, method: 'initialize', params: { clientInfo: { name: 'spexcode', title: 'SpexCode', version: '0.0.0' }, capabilities: { experimentalApi: true, requestAttestation: false } } }
874
-
875
- // Codex has no StopFailure hook, but its app-server has the stronger native signal: every subscribed turn ends
876
- // with turn/completed and a final completed/interrupted/failed status. Rejoin is atomic with subscription, so
877
- // this observer also survives backend replacement; a thread already in systemError is reconciled from its
878
- // latest turn before later live notifications take over.
879
- export function codexTurnFailureObserver(
880
- rec: HarnessDeliveryRecord,
881
- onFailure: (failure: TurnFailure) => void,
882
- ): FailureSubscription {
883
- const threadId = rec.harnessSessionId
884
- if (!threadId) return { close: () => {}, closed: Promise.resolve(null) }
885
- const runtimeDir = rec.runtimeDir || runtimeRoot()
886
- const endpoint = codexEndpointForRecord(rec, runtimeDir)
887
- if (!endpoint) {
888
- return {
889
- close: () => {},
890
- closed: Promise.resolve(`Codex turn observer refused: no exact generation binding for session ${rec.session}`),
891
- }
892
- }
893
- const sock = endpoint.socketPath
894
- const conn: Socket = createConnection(sock)
895
- const frames: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
896
- let upgraded = false, settled = false
897
- let reconciliationTimer: ReturnType<typeof setTimeout> | null = null
898
- let resolveClosed!: (reason: string | null) => void
899
- const closed = new Promise<string | null>((resolve) => { resolveClosed = resolve })
900
- const cancelReconciliation = () => {
901
- if (!reconciliationTimer) return
902
- clearTimeout(reconciliationTimer)
903
- reconciliationTimer = null
904
- }
905
- const finish = (reason: string | null) => {
906
- if (settled) return
907
- settled = true
908
- clearTimeout(timer)
909
- cancelReconciliation()
910
- try { conn.destroy() } catch {}
911
- resolveClosed(reason)
912
- }
913
- const timer = setTimeout(() => finish('Codex turn observer did not subscribe within 5000ms'), 5000)
914
- timer.unref?.()
915
- const send = (message: JsonRpc) => conn.write(wsText(JSON.stringify(message)))
916
- const report = (turn: unknown, fallbackMessage?: string) => {
917
- const value = turn as { status?: unknown; completedAt?: unknown; error?: { message?: unknown } | null }
918
- if (value?.status !== 'failed' && !fallbackMessage) return
919
- const nativeMessage = typeof value?.error?.message === 'string' ? value.error.message.trim() : ''
920
- onFailure({
921
- message: nativeMessage || fallbackMessage || 'Codex turn failed',
922
- completedAt: typeof value?.completedAt === 'number' && Number.isFinite(value.completedAt) ? value.completedAt : null,
923
- })
924
- }
925
- conn.on('error', (error) => finish(`Codex turn observer connection failed: ${rpcError(error)}`))
926
- conn.on('close', () => finish('Codex turn observer connection closed'))
927
- conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))))
928
- const handle = (json: string) => {
929
- let message: JsonRpc
930
- try { message = JSON.parse(json) } catch { return }
931
- if (message.error) return finish(`Codex turn observer request failed: ${message.error.message || JSON.stringify(message.error)}`)
932
- if (message.id === 1 && message.result) {
933
- send({ method: 'initialized', params: {} })
934
- return send({
935
- id: 2,
936
- method: 'thread/resume',
937
- params: { threadId, excludeTurns: true, initialTurnsPage: { limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' } },
938
- })
939
- }
940
- if (message.id === 2 && message.result) {
941
- clearTimeout(timer)
942
- const result = message.result as { thread?: { status?: { type?: unknown } }; initialTurnsPage?: { data?: unknown } }
943
- if (result.thread?.status?.type === 'systemError') {
944
- const turns = result.initialTurnsPage?.data
945
- const latest = Array.isArray(turns) ? turns[0] : null
946
- // Give a concurrently-starting turn's native notification precedence over this historical snapshot.
947
- reconciliationTimer = setTimeout(() => {
948
- reconciliationTimer = null
949
- report(latest, 'Codex thread entered systemError before the turn observer subscribed')
950
- }, 100)
951
- reconciliationTimer.unref?.()
952
- }
953
- return
954
- }
955
- if (message.method === 'turn/started') {
956
- const params = message.params as { threadId?: unknown } | undefined
957
- if (params?.threadId === threadId) cancelReconciliation()
958
- }
959
- if (message.method === 'turn/completed') {
960
- const params = message.params as { threadId?: unknown; turn?: unknown } | undefined
961
- if (params?.threadId === threadId) {
962
- cancelReconciliation()
963
- report(params.turn)
964
- }
965
- }
966
- }
967
- conn.on('data', (chunk: Buffer) => {
968
- frames.buf = Buffer.concat([frames.buf, chunk])
969
- if (!upgraded) {
970
- const split = frames.buf.indexOf('\r\n\r\n')
971
- if (split < 0) return
972
- const head = frames.buf.slice(0, split).toString('utf8')
973
- if (!/^HTTP\/1\.1 101/.test(head)) return finish(`Codex app-server refused turn observer: ${head.split('\r\n')[0]}`)
974
- upgraded = true
975
- frames.buf = frames.buf.slice(split + 4)
976
- send(wsInitialize)
977
- }
978
- if (drainWsFrames(frames, conn, handle)) finish('Codex app-server closed the turn observer')
979
- })
980
- return { close: () => finish(null), closed }
981
- }
982
-
983
- // Protocol-verified cold/restore/control seam. The Codex schema (`codex app-server generate-json-schema --experimental`)
984
- // defines thread/archive and thread/unarchive with {threadId}, plus turn/interrupt with {threadId, turnId}; no
985
- // guessed method or process command is used.
986
- type CodexGenerationFence = { dir: string; endpoint: CodexGenerationEndpoint; generation: string }
987
- // A failed mutation says whether the server can still commit it. `refused` means the request never reached the
988
- // server or the server answered by rejecting it, so the target is provably unchanged and compensation is safe.
989
- // `unknown` means the request was sent and no verdict came back — the server may still be executing it, so
990
- // sending anything else down the same connection queues behind that work and fails too.
991
- type CodexMutationOutcome = { ok: true } | { ok: false; error: string; commit: 'refused' | 'unknown' }
992
- function codexThreadMutation(sock: string, method: 'thread/archive' | 'thread/unarchive' | 'turn/interrupt', threadId: string, fence?: CodexGenerationFence, turnId?: string, budgetMs = CODEX_MUTATION_BASE_MS): Promise<CodexMutationOutcome> {
993
- const generationError = () => fence && codexRuntimeGeneration(fence.dir, fence.endpoint) !== fence.generation
994
- ? `Codex ${method} refused because the shared app-server generation changed`
995
- : null
996
- const before = generationError()
997
- if (before) return Promise.resolve({ ok: false, error: before, commit: 'refused' })
998
- if (method === 'turn/interrupt' && !turnId)
999
- return Promise.resolve({ ok: false, error: 'Codex turn interrupt needs an exact turn id', commit: 'refused' })
1000
- return new Promise((resolve) => {
1001
- const conn: Socket = createConnection(sock)
1002
- const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
1003
- let upgraded = false, settled = false, requested = false
1004
- const done = (r: CodexMutationOutcome) => {
1005
- if (settled) return
1006
- settled = true
1007
- clearTimeout(timer)
1008
- try { conn.destroy() } catch {}
1009
- resolve(r)
1010
- }
1011
- // Once the request is on the wire the server owns it, so every unanswered end — timeout, socket error,
1012
- // early close, a generation swap — leaves the commit unknown rather than refused.
1013
- const unanswered = (error: string) => done({ ok: false, error, commit: requested ? 'unknown' : 'refused' })
1014
- const timer = setTimeout(() => unanswered(generationError() || `Codex ${method} did not answer within ${budgetMs}ms`), budgetMs)
1015
- conn.on('error', (e) => unanswered(generationError() || `Codex ${method} connection failed: ${rpcError(e)}`))
1016
- conn.on('close', () => { if (!settled) unanswered(`Codex app-server closed during ${method}`) })
1017
- const send = (m: JsonRpc) => conn.write(wsText(JSON.stringify(m)))
1018
- conn.on('connect', () => {
1019
- const changed = generationError()
1020
- if (changed) return unanswered(changed)
1021
- conn.write(WS_UPGRADE(randomBytes(16).toString('base64')))
1022
- })
1023
- const handle = (json: string) => {
1024
- let m: JsonRpc
1025
- try { m = JSON.parse(json) } catch { return }
1026
- // The server answered by rejecting, so the target is provably unchanged whether or not we had sent it.
1027
- if (m.error) return done({ ok: false, error: generationError() || `Codex ${method} failed: ${m.error.message || JSON.stringify(m.error)}`, commit: 'refused' })
1028
- if (m.id === 1 && m.result) {
1029
- const changed = generationError()
1030
- if (changed) return unanswered(changed)
1031
- send({ method: 'initialized', params: {} })
1032
- requested = true
1033
- return send({ id: 2, method, params: method === 'turn/interrupt' ? { threadId, turnId } : { threadId } })
1034
- }
1035
- if (m.id === 2 && m.result) {
1036
- const changed = generationError()
1037
- return changed ? unanswered(changed) : done({ ok: true })
1038
- }
1039
- }
1040
- conn.on('data', (chunk: Buffer) => {
1041
- fs.buf = Buffer.concat([fs.buf, chunk])
1042
- if (!upgraded) {
1043
- const i = fs.buf.indexOf('\r\n\r\n')
1044
- if (i < 0) return
1045
- const head = fs.buf.slice(0, i).toString('utf8')
1046
- if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `Codex app-server refused WebSocket upgrade for ${method}`, commit: 'refused' })
1047
- upgraded = true
1048
- fs.buf = fs.buf.slice(i + 4)
1049
- send(wsInitialize)
1050
- }
1051
- if (drainWsFrames(fs, conn, handle)) unanswered(`Codex app-server closed during ${method}`)
1052
- })
1053
- })
1054
- }
1055
-
1056
- type CodexPagedIdsResult = { ok: true; ids: string[] } | { ok: false; error: string }
1057
- // Dashboard/resource probes keep their own short budget; this target-scoped census is only entered by a
1058
- // lifecycle mutation that already holds the session transition lock and must tolerate a busy app-server.
1059
- const CODEX_MUTATION_CENSUS_MS = 15_000
1060
- // A mutation's response budget. `thread/unarchive` and `turn/interrupt` are state flips the server answers at
1061
- // once — measured 36ms to unarchive the very same 279 MB thread that took 47.7s to archive — so they keep the
1062
- // base. `thread/archive` on a LOADED thread differs in kind: the server flushes that thread's whole in-memory
1063
- // rollout inside shutdown_and_wait before it commits, so the wait is proportional to accumulated history
1064
- // (measured 47.7s for 279 MB, ~5.9 MB/s, against ~1.5s for a notLoaded member that flushes nothing). A fixed
1065
- // ceiling therefore never bounds the operation; it only picks the transcript size above which archive stops
1066
- // working, and raising it just moves that size. The scaled term is deliberately pessimistic — a floor rate ~6x
1067
- // under the measured one — because its job is to catch a WEDGED server, not to predict a flush: a machine
1068
- // several times slower still archives, while a hung one still fails loudly.
1069
- const CODEX_MUTATION_BASE_MS = 15_000
1070
- const CODEX_ARCHIVE_FLUSH_FLOOR_BYTES_PER_MS = 1000
1071
- const codexArchiveBudgetMs = (bytes: number) => CODEX_MUTATION_BASE_MS + Math.ceil(bytes / CODEX_ARCHIVE_FLUSH_FLOOR_BYTES_PER_MS)
1072
- // Codex treats an omitted or empty sourceKinds filter as "interactive" defaults. Cold proof must census the
1073
- // entire native thread graph, including subAgent/thread-spawn rows that have no Spex record, so the adapter
1074
- // supplies every protocol source kind explicitly for its thread/list calls.
1075
- export const CODEX_THREAD_SOURCE_KINDS = [
1076
- 'cli', 'vscode', 'exec', 'appServer', 'subAgent', 'subAgentReview', 'subAgentCompact',
1077
- 'subAgentThreadSpawn', 'subAgentOther', 'unknown',
1078
- ] as const
1079
- function codexPagedIds(
1080
- sock: string,
1081
- method: 'thread/list' | 'thread/loaded/list',
1082
- params: Record<string, unknown>,
1083
- extractId: (item: unknown) => string | null,
1084
- label: string,
1085
- onItem?: (item: unknown) => void,
1086
- timeoutMs = CODEX_MUTATION_CENSUS_MS,
1087
- ): Promise<CodexPagedIdsResult> {
1088
- return new Promise((resolve) => {
1089
- const conn: Socket = createConnection(sock)
1090
- const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
1091
- let upgraded = false, settled = false, requestId = 2, cursor: string | null = null
1092
- const ids = new Set<string>()
1093
- const done = (result: CodexPagedIdsResult) => {
1094
- if (settled) return
1095
- settled = true; clearTimeout(timer); try { conn.destroy() } catch {}; resolve(result)
1096
- }
1097
- const timer = setTimeout(() => done({ ok: false, error: `Codex ${label} timed out after ${timeoutMs}ms` }), timeoutMs)
1098
- conn.on('error', (error) => done({ ok: false, error: `Codex ${label} failed: ${rpcError(error)}` }))
1099
- conn.on('close', () => { if (!settled) done({ ok: false, error: `Codex app-server closed during ${label}` }) })
1100
- const send = (message: JsonRpc) => conn.write(wsText(JSON.stringify(message)))
1101
- const requestPage = () => send({ id: requestId, method, params: { ...params, ...(cursor ? { cursor } : {}), limit: 100 } })
1102
- conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))))
1103
- const handle = (json: string) => {
1104
- let message: JsonRpc
1105
- try { message = JSON.parse(json) } catch { return }
1106
- if (message.error) return done({ ok: false, error: `Codex ${label} failed: ${message.error.message || JSON.stringify(message.error)}` })
1107
- if (message.id === 1 && message.result) { send({ method: 'initialized', params: {} }); return requestPage() }
1108
- if (message.id !== requestId || !message.result) return
1109
- const page = message.result as { data?: unknown; nextCursor?: unknown }
1110
- if (Array.isArray(page.data)) for (const item of page.data) {
1111
- onItem?.(item)
1112
- const id = extractId(item)
1113
- if (typeof id === 'string') ids.add(id)
1114
- }
1115
- cursor = typeof page.nextCursor === 'string' && page.nextCursor ? page.nextCursor : null
1116
- if (!cursor) return done({ ok: true, ids: [...ids] })
1117
- requestId++
1118
- requestPage()
1119
- }
1120
- conn.on('data', (chunk: Buffer) => {
1121
- fs.buf = Buffer.concat([fs.buf, chunk])
1122
- if (!upgraded) {
1123
- const i = fs.buf.indexOf('\r\n\r\n')
1124
- if (i < 0) return
1125
- const head = fs.buf.slice(0, i).toString('utf8')
1126
- if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `Codex app-server refused loaded-reference census: ${head.split('\r\n')[0]}` })
1127
- upgraded = true; fs.buf = fs.buf.slice(i + 4)
1128
- send(wsInitialize)
1129
- }
1130
- if (drainWsFrames(fs, conn, handle)) done({ ok: false, error: `Codex app-server closed during ${label}` })
1131
- })
1132
- })
1133
- }
1134
-
1135
- // Lightweight resident census: unlike the full shared-runtime probe, this scans only paginated manager IDs
1136
- // and never issues thread/read includeTurns for each loaded reference.
1137
- export async function codexLoadedReferenceIds(sock: string): Promise<{ ok: true; referenceIds: string[] } | { ok: false; error: string }> {
1138
- const result = await codexPagedIds(sock, 'thread/loaded/list', {}, (item) => {
1139
- if (typeof item === 'string') return item
1140
- const value = item as { id?: unknown; threadId?: unknown } | null
1141
- return typeof value?.id === 'string' ? value.id : typeof value?.threadId === 'string' ? value.threadId : null
1142
- }, 'loaded-reference census')
1143
- return result.ok ? { ok: true, referenceIds: result.ids } : result
1144
- }
1145
-
1146
- const CODEX_RUNNING_TURN_READ_MS = 15_000
1147
-
1148
- // @@@ presence vs identity - two different questions, deliberately not one helper.
1149
- // A gate asks "is a turn in flight right now"; thread/list answers that for every thread at once, at a cost
1150
- // that tracks the thread COUNT. Interrupt must additionally name the turn to interrupt, and only a turn read
1151
- // carries the id — a cost that tracks that one thread's persisted HISTORY. So this read stays for interrupt,
1152
- // where the target is by definition active and short-lived, and no gate may be routed back through it.
1153
- function codexRunningTurn(sock: string, threadId: string): Promise<{ ok: true; turnPresence: 'idle' | 'active' | 'unknown'; turnId?: string } | { ok: false; error: string }> {
1154
- return new Promise((resolve) => {
1155
- const conn: Socket = createConnection(sock)
1156
- const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
1157
- let upgraded = false, settled = false
1158
- const done = (result: { ok: true; turnPresence: 'idle' | 'active' | 'unknown'; turnId?: string } | { ok: false; error: string }) => {
1159
- if (settled) return
1160
- settled = true; clearTimeout(timer); try { conn.destroy() } catch {}; resolve(result)
1161
- }
1162
- const timer = setTimeout(() => done({ ok: false, error: `Codex target thread ${threadId} turn census timed out after ${CODEX_RUNNING_TURN_READ_MS}ms` }), CODEX_RUNNING_TURN_READ_MS)
1163
- conn.on('error', (error) => done({ ok: false, error: `Codex target thread ${threadId} turn census failed: ${rpcError(error)}` }))
1164
- conn.on('close', () => { if (!settled) done({ ok: false, error: `Codex app-server closed during target thread ${threadId} turn census` }) })
1165
- const send = (message: JsonRpc) => conn.write(wsText(JSON.stringify(message)))
1166
- conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))))
1167
- const handle = (json: string) => {
1168
- let message: JsonRpc
1169
- try { message = JSON.parse(json) } catch { return }
1170
- if (message.error) return done({ ok: false, error: `Codex target thread ${threadId} turn census failed: ${message.error.message || JSON.stringify(message.error)}` })
1171
- if (message.id === 1 && message.result) {
1172
- send({ method: 'initialized', params: {} })
1173
- // The guard needs only the current turn. `thread/read {includeTurns:true}` materializes the
1174
- // entire persisted history, so an old but otherwise healthy thread can time out before close.
1175
- return send({ id: 2, method: 'thread/turns/list', params: { threadId, limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' } })
1176
- }
1177
- if (message.id !== 2 || !message.result) return
1178
- const turns = (message.result as { data?: unknown }).data
1179
- if (!Array.isArray(turns)) return done({ ok: true, turnPresence: 'unknown' })
1180
- const active = turns.find((turn): turn is { id?: unknown; status?: unknown } =>
1181
- !!turn && typeof turn === 'object' && (turn as { status?: unknown }).status === 'inProgress')
1182
- if (!active) return done({ ok: true, turnPresence: 'idle' })
1183
- return typeof active.id === 'string' && active.id
1184
- ? done({ ok: true, turnPresence: 'active', turnId: active.id })
1185
- : done({ ok: true, turnPresence: 'unknown' })
1186
- }
1187
- conn.on('data', (chunk: Buffer) => {
1188
- fs.buf = Buffer.concat([fs.buf, chunk])
1189
- if (!upgraded) {
1190
- const i = fs.buf.indexOf('\r\n\r\n')
1191
- if (i < 0) return
1192
- const head = fs.buf.slice(0, i).toString('utf8')
1193
- if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `Codex app-server refused target thread ${threadId} turn census: ${head.split('\r\n')[0]}` })
1194
- upgraded = true; fs.buf = fs.buf.slice(i + 4); send(wsInitialize)
1195
- }
1196
- if (drainWsFrames(fs, conn, handle)) done({ ok: false, error: `Codex app-server closed during target thread ${threadId} turn census` })
1197
- })
1198
- })
1199
- }
1200
-
1201
- const CODEX_INTERRUPT_SETTLE_MS = 15_000
1202
-
1203
- async function interruptCodexTurn(rec: HarnessDeliveryRecord): Promise<DispatchResult> {
1204
- if (!rec.harnessSessionId) return { ok: false, error: 'no exact Codex thread identity is registered' }
1205
- const threadId = rec.harnessSessionId
1206
- const dir = rec.runtimeDir || runtimeRoot()
1207
- const endpoint = codexEndpointForRecord(rec, dir)
1208
- if (!endpoint) return { ok: false, error: 'no exact Codex generation binding is registered for this target' }
1209
- const generation = codexRuntimeGeneration(dir, endpoint)
1210
- if (!generation) return { ok: false, error: 'Codex shared app-server generation is unproven' }
1211
- const fence = { dir, endpoint, generation }
1212
- const before = await codexRunningTurn(endpoint.socketPath, threadId)
1213
- if (!before.ok) return { ok: false, error: before.error }
1214
- if (codexRuntimeGeneration(dir, endpoint) !== generation)
1215
- return { ok: false, error: 'shared Codex app-server generation changed during interrupt preflight' }
1216
- if (before.turnPresence === 'idle') return { ok: true }
1217
- if (before.turnPresence !== 'active' || !before.turnId)
1218
- return { ok: false, error: `Codex target thread ${threadId} turn state is unknown` }
1219
- const interrupted = await codexThreadMutation(endpoint.socketPath, 'turn/interrupt', threadId, fence, before.turnId)
1220
- if (!interrupted.ok) return { ok: false, error: interrupted.error }
1221
- const deadline = Date.now() + CODEX_INTERRUPT_SETTLE_MS
1222
- for (;;) {
1223
- const after = await codexRunningTurn(endpoint.socketPath, threadId)
1224
- if (!after.ok) return { ok: false, error: after.error }
1225
- if (codexRuntimeGeneration(dir, endpoint) !== generation)
1226
- return { ok: false, error: 'shared Codex app-server generation changed during interrupt settlement' }
1227
- if (after.turnPresence === 'idle') return { ok: true }
1228
- if (after.turnPresence === 'unknown') return { ok: false, error: `Codex target thread ${threadId} turn state is unknown after interrupt` }
1229
- if (Date.now() >= deadline) return { ok: false, error: `Codex target thread ${threadId} remained active after interrupt` }
1230
- await new Promise((resolve) => setTimeout(resolve, 100))
1231
- }
1232
- }
1233
-
1234
- // The app-server's loaded/list is cursor-paginated. Archive proof must scan every page; a first page that omits
1235
- // a sibling/descendant is not a cold proof. This helper is also used by the descendant guard below.
1236
- export function codexThreadList(sock: string, params: Record<string, unknown>): Promise<{ ok: true; ids: string[] } | { ok: false; error: string }> {
1237
- return codexThreadCollection(sock, params).then((result) => result.ok ? { ok: true, ids: result.ids } : result)
1238
- }
1239
-
1240
- // Every thread/list row carries the app-server's live turn state for that thread, in the protocol's own
1241
- // three variants. `notLoaded` duplicates what thread/loaded/list reports; `idle`/`active` answer the only
1242
- // question a lifecycle gate asks. Any other shape is `unknown` and fails closed — never derived from
1243
- // something cheaper.
1244
- type CodexThreadStatus = 'notLoaded' | 'idle' | 'active' | 'unknown'
1245
- const codexRowStatus = (row: { status?: unknown }): CodexThreadStatus => {
1246
- const type = (row.status as { type?: unknown } | null | undefined)?.type
1247
- return type === 'notLoaded' || type === 'idle' || type === 'active' ? type : 'unknown'
1248
- }
1249
-
1250
- type CodexThreadCollectionResult =
1251
- | { ok: true; ids: string[]; parentById: Map<string, string | null>; statusById: Map<string, CodexThreadStatus> }
1252
- | { ok: false; error: string }
1253
-
1254
- function codexThreadCollection(sock: string, params: Record<string, unknown>): Promise<CodexThreadCollectionResult> {
1255
- const sourceKinds = Array.isArray(params.sourceKinds) && params.sourceKinds.length
1256
- ? params.sourceKinds
1257
- : [...CODEX_THREAD_SOURCE_KINDS]
1258
- const parentById = new Map<string, string | null>()
1259
- const statusById = new Map<string, CodexThreadStatus>()
1260
- const conflictingParents = new Set<string>()
1261
- return codexPagedIds(sock, 'thread/list', { ...params, sourceKinds, useStateDbOnly: true }, (item) => {
1262
- if (typeof item === 'string') return item
1263
- const id = (item as { id?: unknown } | null)?.id
1264
- return typeof id === 'string' ? id : null
1265
- }, 'thread/list', (item) => {
1266
- if (!item || typeof item !== 'object') return
1267
- const row = item as { id?: unknown; parentThreadId?: unknown; status?: unknown }
1268
- if (typeof row.id !== 'string') return
1269
- const parent = typeof row.parentThreadId === 'string' ? row.parentThreadId : null
1270
- if (parentById.has(row.id) && parentById.get(row.id) !== parent) conflictingParents.add(row.id)
1271
- parentById.set(row.id, parent)
1272
- // Parent ownership is a fact about the graph, so a disagreement across pages is a census fault.
1273
- // Turn state is live, so a mid-drain change is not a fault — it is simply no longer knowable here.
1274
- const status = codexRowStatus(row)
1275
- statusById.set(row.id, statusById.has(row.id) && statusById.get(row.id) !== status ? 'unknown' : status)
1276
- }).then((result) => {
1277
- if (!result.ok) return result
1278
- if (conflictingParents.size) return { ok: false as const, error: `Codex thread/list returned conflicting parent ownership for ${[...conflictingParents].join(', ')}` }
1279
- return { ...result, parentById, statusById }
1280
- })
1281
- }
1282
-
1283
- // The gate's question is about the tip — is a turn in flight right now — and thread/list already answers it
1284
- // for every thread at once, at a cost that tracks how many threads exist. Reading turns instead costs the
1285
- // target's whole persisted history against a fixed budget, so a long-lived session becomes unmutatable.
1286
- const codexPresenceFromStatus = (status: CodexThreadStatus | undefined): SharedRuntimeMutationGuard['targetTurnPresence'] =>
1287
- status === 'idle' || status === 'active' ? status : 'unknown'
1288
-
1289
- async function codexTargetMutationGuard(threadId: string, dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir)): Promise<SharedRuntimeMutationGuard> {
1290
- const generationBefore = codexMutationGeneration(dir, endpoint)
1291
- if (!generationBefore) return { healthy: false, referenceIds: [], targetTurnPresence: 'unknown', descendantIds: [], error: 'Codex shared app-server generation is unproven' }
1292
- const sock = endpoint.socketPath
1293
- // The descendant collections are ancestor-filtered and therefore exclude the target itself, so the
1294
- // target's own turn state comes from the whole-collection census. These run concurrently with the rest.
1295
- const [loaded, activeDescendants, archivedDescendants, activeList, archivedList] = await Promise.all([
1296
- codexLoadedReferenceIds(sock),
1297
- codexThreadList(sock, { ancestorThreadId: threadId, archived: false, sourceKinds: [] }),
1298
- codexThreadList(sock, { ancestorThreadId: threadId, archived: true, sourceKinds: [] }),
1299
- codexThreadCollection(sock, { archived: false, sourceKinds: [] }),
1300
- codexThreadCollection(sock, { archived: true, sourceKinds: [] }),
1301
- ])
1302
- const referenceIds = loaded.ok ? loaded.referenceIds : []
1303
- const descendantIds = activeDescendants.ok && archivedDescendants.ok
1304
- ? [...new Set([...activeDescendants.ids, ...archivedDescendants.ids])]
1305
- : []
1306
- if (!loaded.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: loaded.error }
1307
- if (!activeDescendants.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: activeDescendants.error }
1308
- if (!archivedDescendants.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: archivedDescendants.error }
1309
- if (!activeList.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: activeList.error }
1310
- if (!archivedList.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: archivedList.error }
1311
- const targetTurnPresence: SharedRuntimeMutationGuard['targetTurnPresence'] = referenceIds.includes(threadId)
1312
- ? codexPresenceFromStatus(activeList.statusById.get(threadId) ?? archivedList.statusById.get(threadId))
1313
- : 'none'
1314
- if (codexRuntimeGeneration(dir, endpoint) !== generationBefore)
1315
- return { healthy: false, referenceIds, targetTurnPresence, descendantIds, error: 'shared Codex app-server generation changed during target guard' }
1316
- return { healthy: true, referenceIds, targetTurnPresence, descendantIds }
1317
- }
1318
-
1319
- const CODEX_COLD_PLAN = Symbol('codex-cold-plan')
1320
- type CodexColdPlan = Readonly<{
1321
- [CODEX_COLD_PLAN]: true
1322
- kind: 'codex-cold-subtree-v1'
1323
- threadId: string
1324
- generation: string
1325
- endpoint: CodexGenerationEndpoint
1326
- guard: SharedRuntimeMutationGuard
1327
- descendantIds: readonly string[]
1328
- parentEdges: readonly (readonly [string, string])[]
1329
- subtreeIds: readonly string[]
1330
- activeIds: readonly string[]
1331
- archivedIds: readonly string[]
1332
- }>
1333
- type CodexColdPreflight = { ok: true; alreadyCold?: boolean; receipt: CodexColdPlan } | { ok: false; reason: string }
1334
-
1335
- const sameIdSet = (left: readonly string[], right: readonly string[]) =>
1336
- left.length === right.length && left.every((id) => right.includes(id))
1337
-
1338
- const sameParentEdges = (left: readonly (readonly [string, string])[], right: readonly (readonly [string, string])[]) =>
1339
- left.length === right.length && left.every(([id, parent]) => right.some(([otherId, otherParent]) => id === otherId && parent === otherParent))
1340
-
1341
- const isCodexColdPlan = (value: unknown): value is CodexColdPlan => {
1342
- if (!value || typeof value !== 'object') return false
1343
- const plan = value as Partial<CodexColdPlan>
1344
- return plan[CODEX_COLD_PLAN] === true && plan.kind === 'codex-cold-subtree-v1' && typeof plan.threadId === 'string' &&
1345
- typeof plan.generation === 'string' && isEndpointLike(plan.endpoint) && Array.isArray(plan.descendantIds) &&
1346
- Array.isArray(plan.parentEdges) && Array.isArray(plan.subtreeIds) &&
1347
- Array.isArray(plan.activeIds) && Array.isArray(plan.archivedIds) && !!plan.guard
1348
- }
1349
-
1350
- function isEndpointLike(value: unknown): value is CodexGenerationEndpoint {
1351
- return !!value && typeof value === 'object' && typeof (value as CodexGenerationEndpoint).id === 'string' &&
1352
- typeof (value as CodexGenerationEndpoint).pidFile === 'string' && typeof (value as CodexGenerationEndpoint).receiptFile === 'string' &&
1353
- typeof (value as CodexGenerationEndpoint).socketPath === 'string'
1354
- }
1355
-
1356
- async function codexColdPreflightOnce(threadId: string, dir = runtimeRoot(), expectedGeneration?: string, endpoint = legacyCodexGenerationEndpoint(dir)): Promise<CodexColdPreflight> {
1357
- const generation = expectedGeneration ?? codexMutationGeneration(dir, endpoint)
1358
- if (!generation || codexRuntimeGeneration(dir, endpoint) !== generation)
1359
- return { ok: false, reason: 'Codex shared app-server generation is unproven or changed before subtree census' }
1360
- const sock = endpoint.socketPath
1361
- const [loaded, activeDescendants, archivedDescendants, archivedList, activeList] = await Promise.all([
1362
- codexLoadedReferenceIds(sock),
1363
- codexThreadCollection(sock, { ancestorThreadId: threadId, archived: false, sourceKinds: [] }),
1364
- codexThreadCollection(sock, { ancestorThreadId: threadId, archived: true, sourceKinds: [] }),
1365
- codexThreadCollection(sock, { archived: true, sourceKinds: [] }),
1366
- codexThreadCollection(sock, { archived: false, sourceKinds: [] }),
1367
- ])
1368
- if (codexRuntimeGeneration(dir, endpoint) !== generation)
1369
- return { ok: false, reason: 'shared Codex app-server generation changed during subtree census' }
1370
- if (!loaded.ok) return { ok: false, reason: loaded.error }
1371
- if (!activeDescendants.ok) return { ok: false, reason: activeDescendants.error }
1372
- if (!archivedDescendants.ok) return { ok: false, reason: archivedDescendants.error }
1373
- if (!archivedList.ok) return { ok: false, reason: archivedList.error }
1374
- if (!activeList.ok) return { ok: false, reason: activeList.error }
1375
-
1376
- const activeDescendantSet = new Set(activeDescendants.ids)
1377
- const archivedDescendantSet = new Set(archivedDescendants.ids)
1378
- const duplicateDescendants = activeDescendants.ids.filter((id) => archivedDescendantSet.has(id))
1379
- if (duplicateDescendants.length)
1380
- return { ok: false, reason: `Codex subtree members occur in both active and archived descendant collections (${duplicateDescendants.join(', ')})` }
1381
- const descendantIds = [...activeDescendants.ids, ...archivedDescendants.ids]
1382
- if (descendantIds.includes(threadId)) return { ok: false, reason: `Codex target ${threadId} is duplicated in its own descendant closure` }
1383
-
1384
- const parentById = new Map([...activeDescendants.parentById, ...archivedDescendants.parentById])
1385
- const depthById = new Map<string, number>()
1386
- for (const id of descendantIds) {
1387
- const seen = new Set([id])
1388
- let cursor = id
1389
- let depth = 0
1390
- while (cursor !== threadId) {
1391
- const next = parentById.get(cursor)
1392
- if (!next) return { ok: false, reason: `Codex descendant ${id} has no complete parent chain to target ${threadId} (unowned or reassigned)` }
1393
- if (seen.has(next)) return { ok: false, reason: `Codex descendant ${id} has a cyclic parent chain` }
1394
- seen.add(next)
1395
- cursor = next
1396
- depth++
1397
- }
1398
- depthById.set(id, depth)
1399
- }
1400
-
1401
- const activeSet = new Set(activeList.ids)
1402
- const archivedSet = new Set(archivedList.ids)
1403
- const subtreeIds = [...descendantIds, threadId]
1404
- for (const id of subtreeIds) {
1405
- const inActive = activeSet.has(id)
1406
- const inArchived = archivedSet.has(id)
1407
- if (!inActive && !inArchived)
1408
- return { ok: false, reason: `Codex subtree member ${id} is absent from both native collections (unowned or reassigned)` }
1409
- if (inActive && inArchived)
1410
- return { ok: false, reason: `Codex subtree member ${id} occurs in both active and archived native collections` }
1411
- if (id !== threadId) {
1412
- const expectedActive = activeDescendantSet.has(id)
1413
- if (inActive !== expectedActive)
1414
- return { ok: false, reason: `Codex subtree member ${id} changed collection assignment during ownership census` }
1415
- }
1416
- }
1417
-
1418
- // Every subtree member was just proven to occur in exactly one whole-collection census, so that census
1419
- // already carries each one's live turn state. No second round of native reads, and therefore no second
1420
- // generation fence — nothing was read between the fence above and here.
1421
- const statusById = new Map([...activeList.statusById, ...archivedList.statusById])
1422
- const loadedSet = new Set(loaded.referenceIds)
1423
- const loadedSubtreeIds = subtreeIds.filter((id) => loadedSet.has(id))
1424
- for (const id of loadedSubtreeIds) {
1425
- const presence = codexPresenceFromStatus(statusById.get(id))
1426
- if (presence === 'active') return { ok: false, reason: `Codex subtree member ${id} has an active turn` }
1427
- if (presence === 'unknown') return { ok: false, reason: `Codex subtree member ${id} turn state is unknown` }
1428
- if (archivedSet.has(id)) return { ok: false, reason: `Codex archived subtree member ${id} remains loaded` }
1429
- }
1430
-
1431
- // Proven, not assumed: a loaded target is one of the members the loop above just cleared.
1432
- const targetTurnPresence: SharedRuntimeMutationGuard['targetTurnPresence'] = loadedSet.has(threadId) ? 'idle' : 'none'
1433
- const guard: SharedRuntimeMutationGuard = {
1434
- healthy: true,
1435
- referenceIds: [...loaded.referenceIds],
1436
- targetTurnPresence,
1437
- descendantIds: [...descendantIds],
1438
- }
1439
- const activeIds = [...activeDescendants.ids]
1440
- .sort((left, right) => (depthById.get(right) ?? 0) - (depthById.get(left) ?? 0))
1441
- .concat(activeSet.has(threadId) ? [threadId] : [])
1442
- const archivedIds = [...archivedDescendants.ids, ...(archivedSet.has(threadId) ? [threadId] : [])]
1443
- const parentEdges = descendantIds.map((id) => [id, parentById.get(id)!] as const)
1444
- const receipt: CodexColdPlan = Object.freeze({
1445
- [CODEX_COLD_PLAN]: true as const,
1446
- kind: 'codex-cold-subtree-v1',
1447
- threadId,
1448
- generation,
1449
- endpoint,
1450
- guard,
1451
- descendantIds: Object.freeze([...descendantIds]),
1452
- parentEdges: Object.freeze(parentEdges),
1453
- subtreeIds: Object.freeze([...subtreeIds]),
1454
- activeIds: Object.freeze(activeIds),
1455
- archivedIds: Object.freeze(archivedIds),
1456
- })
1457
- return { ok: true, ...(activeIds.length ? {} : { alreadyCold: true }), receipt }
1458
- }
1459
-
1460
- // A busy app-server can refuse one WebSocket census while accepting the next. The refusal is transport-local,
1461
- // so retry the complete proof (including generation fencing) within the terminal operation's finite budget;
1462
- // semantic ownership refusals still return immediately and never turn into repeated native reads.
1463
- const CODEX_COLD_PREFLIGHT_MAX_ATTEMPTS = 6
1464
- const CODEX_COLD_PREFLIGHT_RETRY_MS = 250
1465
- const CODEX_COLD_PREFLIGHT_DEADLINE_MS = 30_000
1466
- const isTransientCodexCensusFailure = (reason: string): boolean =>
1467
- /(?:timed out|connection|closed during|refused .*census|census failed|app-server busy)/i.test(reason)
1468
-
1469
- async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expectedGeneration?: string, endpoint = legacyCodexGenerationEndpoint(dir)): Promise<CodexColdPreflight> {
1470
- const deadline = Date.now() + CODEX_COLD_PREFLIGHT_DEADLINE_MS
1471
- for (let attempt = 0; attempt < CODEX_COLD_PREFLIGHT_MAX_ATTEMPTS; attempt++) {
1472
- const result = await codexColdPreflightOnce(threadId, dir, expectedGeneration, endpoint)
1473
- if (result.ok || !isTransientCodexCensusFailure(result.reason) || attempt === CODEX_COLD_PREFLIGHT_MAX_ATTEMPTS - 1) return result
1474
- const remaining = deadline - Date.now()
1475
- if (remaining <= 0) return result
1476
- const delay = Math.min(CODEX_COLD_PREFLIGHT_RETRY_MS * 2 ** attempt, remaining)
1477
- await new Promise((resolve) => setTimeout(resolve, delay))
1478
- }
1479
- throw new Error('unreachable Codex cold preflight retry state')
1480
- }
1481
-
1482
- async function codexQuarantineOrphanThread(threadId: string, opts: { excludingSessionId: string }): Promise<HarnessOrphanThreadQuarantine> {
1483
- const dir = runtimeRoot()
1484
- const generation = codexMutationGeneration(dir)
1485
- if (!generation) return { ok: false, reason: 'Codex shared app-server generation is unproven' }
1486
- const owners = governedSharedRuntimeOwners(dir, 'codex-app-server', threadId, opts.excludingSessionId)
1487
- if (owners === null) return { ok: false, reason: 'governed Codex thread-owner census is unreadable' }
1488
- if (owners.length) return { ok: false, reason: `Codex native thread ${threadId} has governed owner(s) ${owners.join(', ')}` }
1489
- const before = await codexColdPreflight(threadId, dir, generation)
1490
- if (!before.ok) return before
1491
- const plan = before.receipt
1492
- if (plan.descendantIds.length || plan.guard.descendantIds.length)
1493
- return { ok: false, reason: `Codex native thread ${threadId} has descendants (${[...new Set([...plan.descendantIds, ...plan.guard.descendantIds])].join(', ')})` }
1494
- if (plan.guard.targetTurnPresence === 'active' || plan.guard.targetTurnPresence === 'unknown')
1495
- return { ok: false, reason: `Codex native thread ${threadId} is ${plan.guard.targetTurnPresence === 'active' ? 'active' : 'unknown'}` }
1496
- if (plan.subtreeIds.length !== 1 || plan.subtreeIds[0] !== threadId)
1497
- return { ok: false, reason: `Codex native thread ${threadId} has an ambiguous ownership closure` }
1498
- const unchangedOwners = () => governedSharedRuntimeOwners(dir, 'codex-app-server', threadId, opts.excludingSessionId)
1499
- const rollback = () => codexRestoreColdPlan(plan, dir)
1500
- if (plan.activeIds.length === 0) {
1501
- if (plan.archivedIds.length !== 1 || plan.archivedIds[0] !== threadId || plan.guard.referenceIds.includes(threadId))
1502
- return { ok: false, reason: `Codex native thread ${threadId} is not uniquely archived and unloaded` }
1503
- const afterOwners = unchangedOwners()
1504
- if (afterOwners === null || afterOwners.length) return { ok: false, reason: 'governed Codex thread-owner census changed during quarantine verification' }
1505
- return { ok: true, audit: { adapter: 'codex', threadId, action: 'already-unloaded' }, compensate: async () => ({ ok: true }) }
1506
- }
1507
- if (plan.activeIds.length !== 1 || plan.activeIds[0] !== threadId || plan.archivedIds.length)
1508
- return { ok: false, reason: `Codex native thread ${threadId} is not one exact active orphan` }
1509
- const siblingIds = plan.guard.referenceIds.filter((id) => id !== threadId)
1510
- const legacy = legacyCodexGenerationEndpoint(dir)
1511
- // Quarantine archives one exact orphan, so it pays the same flush a subtree member does when that orphan is
1512
- // loaded; the budget is derived the same way rather than being a second, differently-wrong constant.
1513
- let orphanBudgetMs = CODEX_MUTATION_BASE_MS
1514
- if (plan.guard.referenceIds.includes(threadId)) {
1515
- const rollout = codexRolloutBytes(threadId)
1516
- if ('unreadable' in rollout) return { ok: false, reason: `Codex native thread ${threadId} is loaded and its rollout exists but cannot be measured, so the archive flush budget is unknown` }
1517
- orphanBudgetMs = codexArchiveBudgetMs(rollout.bytes)
1518
- }
1519
- const archived = await codexThreadMutation(legacy.socketPath, 'thread/archive', threadId, { dir, endpoint: legacy, generation }, undefined, orphanBudgetMs)
1520
- if (!archived.ok) return { ok: false, reason: `${archived.error} while archiving orphan Codex thread ${threadId}${archived.commit === 'unknown' ? '; commit state is unknown' : ''}` }
1521
- const after = await codexColdPreflight(threadId, dir, generation)
1522
- const failed = (reason: string): HarnessOrphanThreadQuarantine => ({ ok: false, reason })
1523
- if (!after.ok) {
1524
- const restored = await rollback()
1525
- return failed(restored.ok ? after.reason : `${after.reason}; ${restored.reason}`)
1526
- }
1527
- const afterOwners = unchangedOwners()
1528
- const afterPlan = after.receipt
1529
- const valid = afterPlan.descendantIds.length === 0 && afterPlan.subtreeIds.length === 1 && afterPlan.subtreeIds[0] === threadId &&
1530
- afterPlan.activeIds.length === 0 && afterPlan.archivedIds.length === 1 && afterPlan.archivedIds[0] === threadId &&
1531
- !afterPlan.guard.referenceIds.includes(threadId) && siblingIds.every((id) => afterPlan.guard.referenceIds.includes(id)) &&
1532
- afterOwners !== null && afterOwners.length === 0
1533
- if (!valid) {
1534
- const restored = await rollback()
1535
- return failed(restored.ok ? `Codex orphan thread ${threadId} changed during archive verification` : `Codex orphan thread ${threadId} changed during archive verification; ${restored.reason}`)
1536
- }
1537
- return { ok: true, audit: { adapter: 'codex', threadId, action: 'archived' }, compensate: rollback }
1538
- }
1539
-
1540
- async function codexMutationGuard(
1541
- threadId: string,
1542
- dir = runtimeRoot(),
1543
- opts: { coldReceipt?: unknown } = {},
1544
- endpoint = legacyCodexGenerationEndpoint(dir),
1545
- ): Promise<SharedRuntimeMutationGuard> {
1546
- if (opts.coldReceipt === undefined) return codexTargetMutationGuard(threadId, dir, endpoint)
1547
- if (!isCodexColdPlan(opts.coldReceipt) || opts.coldReceipt.threadId !== threadId)
1548
- return { healthy: false, referenceIds: [], targetTurnPresence: 'unknown', descendantIds: [], error: 'adapter cold teardown receipt is invalid' }
1549
- if (opts.coldReceipt.endpoint.id !== endpoint.id) return { healthy: false, referenceIds: [], targetTurnPresence: 'unknown', descendantIds: [], error: 'adapter cold teardown receipt names a different generation' }
1550
- const current = await codexColdPreflight(threadId, dir, opts.coldReceipt.generation, endpoint)
1551
- if (!current.ok) {
1552
- const guard = await codexTargetMutationGuard(threadId, dir, endpoint)
1553
- return { ...guard, healthy: false, coldTeardownAuthorized: false, error: current.reason }
1554
- }
1555
- const authorized = sameIdSet(opts.coldReceipt.descendantIds, current.receipt.descendantIds) &&
1556
- sameParentEdges(opts.coldReceipt.parentEdges, current.receipt.parentEdges) &&
1557
- sameIdSet(opts.coldReceipt.activeIds, current.receipt.activeIds) &&
1558
- sameIdSet(opts.coldReceipt.archivedIds, current.receipt.archivedIds)
1559
- return {
1560
- ...current.receipt.guard,
1561
- healthy: authorized,
1562
- coldTeardownAuthorized: authorized,
1563
- ...(authorized ? {} : { error: 'adapter cold teardown receipt no longer matches the target subtree' }),
1564
- }
1565
- }
1566
-
1567
- async function codexRestoreColdPlan(plan: CodexColdPlan, dir = runtimeRoot()): Promise<{ ok: true } | { ok: false; reason: string }> {
1568
- if (codexRuntimeGeneration(dir, plan.endpoint) !== plan.generation)
1569
- return { ok: false, reason: 'shared Codex app-server generation changed, so no compensation was attempted' }
1570
- const sock = plan.endpoint.socketPath
1571
- const [activeBefore, archivedBefore] = await Promise.all([
1572
- codexThreadList(sock, { archived: false, sourceKinds: [] }),
1573
- codexThreadList(sock, { archived: true, sourceKinds: [] }),
1574
- ])
1575
- if (!activeBefore.ok || !archivedBefore.ok)
1576
- return { ok: false, reason: 'archive state is unknown and could not be reconciled' }
1577
- if (codexRuntimeGeneration(dir, plan.endpoint) !== plan.generation)
1578
- return { ok: false, reason: 'shared Codex app-server generation changed, so no compensation was attempted' }
1579
- const activeSet = new Set(activeBefore.ids)
1580
- const archivedSet = new Set(archivedBefore.ids)
1581
- if (plan.archivedIds.some((id) => !archivedSet.has(id) || activeSet.has(id)))
1582
- return { ok: false, reason: 'an originally-archived Codex subtree member changed collection; compensation was not authorized' }
1583
- if (plan.activeIds.some((id) => activeSet.has(id) === archivedSet.has(id)))
1584
- return { ok: false, reason: 'an originally-active Codex subtree member has ambiguous collection state' }
1585
- const fence = { dir, endpoint: plan.endpoint, generation: plan.generation }
1586
- const restoreIds = [...plan.activeIds].reverse().filter((id) => archivedSet.has(id))
1587
- for (const id of restoreIds) {
1588
- const restored = await codexThreadMutation(sock, 'thread/unarchive', id, fence)
1589
- if (!restored.ok) return { ok: false, reason: `compensation failed for ${id}: ${restored.error}` }
1590
- }
1591
- const [activeAfter, archivedAfter] = await Promise.all([
1592
- codexThreadList(sock, { archived: false, sourceKinds: [] }),
1593
- codexThreadList(sock, { archived: true, sourceKinds: [] }),
1594
- ])
1595
- const restored = activeAfter.ok && archivedAfter.ok && codexRuntimeGeneration(dir, plan.endpoint) === plan.generation &&
1596
- plan.activeIds.every((id) => activeAfter.ids.includes(id) && !archivedAfter.ids.includes(id)) &&
1597
- plan.archivedIds.every((id) => archivedAfter.ids.includes(id) && !activeAfter.ids.includes(id))
1598
- return restored ? { ok: true } : { ok: false, reason: 'compensation failed or archive state is unknown' }
1599
- }
1600
-
1601
- // Read a loaded thread id off the app-server via `thread/loaded/list`. With the backend now OWNING the thread
1602
- // id at launch (codexStartThread → stored on the record), this is only the DELIVERY FALLBACK for a pre-existing
1603
- // session whose id was never stored: it returns the first loaded thread. On a shared per-project server several
1604
- // threads may be loaded, so it is no longer the deterministic capture path — the stored id is. Never throws.
1605
- export function codexThreadId(sock: string): Promise<{ ok: true; threadId: string } | { ok: false; error: string }> {
1606
- return new Promise((resolve) => {
1607
- const conn: Socket = createConnection(sock)
1608
- const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
1609
- let upgraded = false, settled = false
1610
- const done = (r: { ok: true; threadId: string } | { ok: false; error: string }) => {
1611
- if (settled) return
1612
- settled = true
1613
- clearTimeout(timer)
1614
- try { conn.destroy() } catch { /* */ }
1615
- resolve(r)
1616
- }
1617
- const timer = setTimeout(() => done({ ok: false, error: 'codex app-server did not list threads within 5000ms' }), 5000)
1618
- conn.on('error', (e) => done({ ok: false, error: `codex app-server connection failed: ${rpcError(e)}` }))
1619
- conn.on('close', () => done({ ok: false, error: 'codex app-server closed before thread/loaded/list was answered' }))
1620
- const send = (m: JsonRpc) => conn.write(wsText(JSON.stringify(m)))
1621
- conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))))
1622
- const handle = (json: string) => {
1623
- let m: JsonRpc
1624
- try { m = JSON.parse(json) } catch { return }
1625
- if (m.error) return done({ ok: false, error: `codex app-server ${m.id ? `request ${m.id}` : 'notification'} failed: ${m.error.message || JSON.stringify(m.error)}` })
1626
- if (m.id === 1 && m.result) { send({ method: 'initialized', params: {} }); return send({ id: 2, method: 'thread/loaded/list', params: {} }) }
1627
- if (m.id === 2 && m.result) {
1628
- const data = (m.result as { data?: unknown }).data
1629
- const ids = Array.isArray(data) ? data.filter((x): x is string => typeof x === 'string') : []
1630
- return ids.length ? done({ ok: true, threadId: ids[0] }) : done({ ok: false, error: 'no loaded thread on the app-server socket yet (TUI still booting?)' })
1631
- }
1632
- }
1633
- conn.on('data', (chunk: Buffer) => {
1634
- fs.buf = Buffer.concat([fs.buf, chunk])
1635
- if (!upgraded) {
1636
- const i = fs.buf.indexOf('\r\n\r\n')
1637
- if (i < 0) return
1638
- const head = fs.buf.slice(0, i).toString('utf8')
1639
- if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `codex app-server refused the WebSocket upgrade: ${head.split('\r\n')[0]}` })
1640
- upgraded = true
1641
- fs.buf = fs.buf.slice(i + 4)
1642
- send(wsInitialize)
1643
- }
1644
- if (drainWsFrames(fs, conn, handle)) done({ ok: false, error: 'codex app-server sent a WebSocket close before thread/loaded/list was confirmed' })
1645
- })
1646
- })
1647
- }
1648
-
1649
- // Resource ownership asks the adapter for what the shared server actually owns now. Records are joined later;
1650
- // they are never treated as references by themselves. A loaded thread is a control-plane reference and its
1651
- // fresh inProgress turn (the same predicate used by delivery) distinguishes active from addressable-idle.
1652
- export function codexSharedRuntimeProbe(dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir)): Promise<SharedRuntimeProbe> {
1653
- const sock = endpoint.socketPath
1654
- return (async () => {
1655
- // File presence is not process identity. A dead PID plus a stale socket file is the normal crash residue;
1656
- // only a live PID and a live listener establish a resident control plane. This keeps a deliberately absent
1657
- // root a healthy empty census while leaving live-but-ambiguous roots loud and visible.
1658
- let pid = 0
1659
- try { pid = Number(readFileSync(endpoint.pidFile, 'utf8').trim()) } catch { /* absent/stale */ }
1660
- const pidLive = pid > 0 && !!processStartToken(pid)
1661
- const listener = await listenerAt(sock, 800)
1662
- if (!pidLive && listener === 'dead') return { healthy: true, references: [] }
1663
- if (!pidLive || listener !== 'live') return { healthy: false, references: [], error: 'Codex shared root state is unknown (PID/listener identity is not proven)' }
1664
- const generation = codexRuntimeGeneration(dir, endpoint)
1665
- if (!generation) return { healthy: false, references: [], error: 'Codex shared root detached receipt/socket generation is not proven' }
1666
- return new Promise<SharedRuntimeProbe>((resolve) => {
1667
- const conn: Socket = createConnection(sock)
1668
- const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
1669
- const references = new Map<string, SharedRuntimeProbe['references'][number]>()
1670
- const requests = new Map<number, string>()
1671
- const loadedRequests = new Set<number>()
1672
- const loadedIds = new Set<string>()
1673
- let loadedRequestId = 2
1674
- let loadedCursor: string | null = null
1675
- let upgraded = false
1676
- let settled = false
1677
- let timer: NodeJS.Timeout
1678
- const done = (result: SharedRuntimeProbe) => {
1679
- if (settled) return
1680
- settled = true
1681
- clearTimeout(timer)
1682
- try { conn.destroy() } catch { /* */ }
1683
- resolve(result.healthy && codexRuntimeGeneration(dir, endpoint) !== generation
1684
- ? { healthy: false, references: result.references, error: 'Codex shared root detached receipt/socket generation changed during ownership probe' }
1685
- : result)
1686
- }
1687
- const fail = (error: string) => done({ healthy: false, references: [...references.values()], error })
1688
- timer = setTimeout(() => fail('codex app-server ownership probe timed out after 5000ms'), 5000)
1689
- conn.on('error', (e) => fail(`codex app-server ownership probe failed: ${rpcError(e)}`))
1690
- conn.on('close', () => fail('codex app-server closed during ownership probe'))
1691
- const send = (m: JsonRpc) => conn.write(wsText(JSON.stringify(m)))
1692
- conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))))
1693
- const handle = (json: string) => {
1694
- let m: JsonRpc
1695
- try { m = JSON.parse(json) } catch { return }
1696
- if (m.error) {
1697
- if (typeof m.id === 'number' && loadedRequests.has(m.id)) return fail(`codex app-server loaded/list failed: ${m.error.message || JSON.stringify(m.error)}`)
1698
- const request = typeof m.id === 'number' ? requests.get(m.id) : undefined
1699
- if (request) {
1700
- requests.delete(m.id!)
1701
- if (!requests.size) done({ healthy: true, references: [...references.values()] })
1702
- return
1703
- }
1704
- return fail(`codex app-server ownership request ${m.id ?? 'notification'} failed: ${m.error.message || JSON.stringify(m.error)}`)
1705
- }
1706
- if (m.id === 1 && m.result) {
1707
- send({ method: 'initialized', params: {} })
1708
- loadedRequests.add(loadedRequestId)
1709
- return send({ id: loadedRequestId, method: 'thread/loaded/list', params: { limit: 100 } })
1710
- }
1711
- if (typeof m.id === 'number' && loadedRequests.has(m.id) && m.result) {
1712
- loadedRequests.delete(m.id)
1713
- const data = (m.result as { data?: unknown }).data
1714
- const ids = [...new Set(Array.isArray(data) ? data.flatMap((item) => {
1715
- if (typeof item === 'string') return [item]
1716
- const id = (item as { id?: unknown; threadId?: unknown })?.id ?? (item as { threadId?: unknown })?.threadId
1717
- return typeof id === 'string' ? [id] : []
1718
- }) : [])]
1719
- for (const threadId of ids) loadedIds.add(threadId)
1720
- const next = (m.result as { nextCursor?: unknown }).nextCursor
1721
- loadedCursor = typeof next === 'string' && next ? next : null
1722
- if (loadedCursor) {
1723
- loadedRequestId++
1724
- loadedRequests.add(loadedRequestId)
1725
- return send({ id: loadedRequestId, method: 'thread/loaded/list', params: { cursor: loadedCursor, limit: 100 } })
1726
- }
1727
- // Continue with the complete paginated set, not just the first manager page.
1728
- if (!loadedIds.size) return done({ healthy: true, references: [] })
1729
- loadedIds.forEach((threadId) => {
1730
- const id = 100 + requests.size
1731
- references.set(threadId, { referenceId: threadId, turnPresence: 'unknown' })
1732
- requests.set(id, threadId)
1733
- send({ id, method: 'thread/read', params: { threadId, includeTurns: true } })
1734
- })
1735
- return
1736
- }
1737
- if (typeof m.id === 'number' && requests.has(m.id) && m.result) {
1738
- const threadId = requests.get(m.id)!
1739
- requests.delete(m.id)
1740
- const thread = (m.result as { thread?: { turns?: Array<{ id?: string; status?: string }> } }).thread
1741
- const turnId = activeTurnIdFromThread(m.result)
1742
- references.set(threadId, {
1743
- referenceId: threadId,
1744
- turnPresence: !Array.isArray(thread?.turns) ? 'unknown' : turnId ? 'active' : 'idle',
1745
- ...(turnId ? { turnId } : {}),
1746
- })
1747
- if (!requests.size) done({ healthy: true, references: [...references.values()] })
1748
- }
1749
- }
1750
- conn.on('data', (chunk: Buffer) => {
1751
- fs.buf = Buffer.concat([fs.buf, chunk])
1752
- if (!upgraded) {
1753
- const i = fs.buf.indexOf('\r\n\r\n')
1754
- if (i < 0) return
1755
- const head = fs.buf.slice(0, i).toString('utf8')
1756
- if (!/^HTTP\/1\.1 101/.test(head)) return fail(`codex app-server refused ownership probe: ${head.split('\r\n')[0]}`)
1757
- upgraded = true
1758
- fs.buf = fs.buf.slice(i + 4)
1759
- send(wsInitialize)
1760
- }
1761
- if (drainWsFrames(fs, conn, handle)) fail('codex app-server closed during ownership probe')
1762
- })
1763
- })
1764
- })()
1765
- }
1766
-
1767
- // @@@ codexStartThread - the BACKEND owns the thread. On the shared PER-PROJECT app-server we `thread/start
1768
- // { cwd }` (codex resolves config/hooks/AGENTS.md from that worktree cwd — exactly as claude loads CLAUDE.md
1769
- // per-worktree — so one project-scoped server behaves analogously to a per-worktree launch), and the result
1770
- // carries the new thread id (`result.thread.id`). The launcher stores that id on the governed record and
1771
- // fires the first turn; there is no capture hook and no rollout/cwd scan. Same WS framing as codexThreadId.
1772
- // Never throws.
1773
- // @@@ codexStartThreadParams - what a BACKEND-owned thread is created with. `config` is the per-request
1774
- // override map (the only channel that reaches a thread): `bypass_hook_trust` so our hooks run, and
1775
- // `shell_environment_policy.set` so every command this thread spawns carries the governed record id. The
1776
- // latter is codex's answer to a structural fact — a codex tool shell descends from the SHARED app-server, so
1777
- // it must inherit no identity and be given its own instead (verified live: the shell reports exactly the
1778
- // injected id, and the launcher's env leaks nothing).
1779
- export function codexStartThreadParams(cwd?: string, bypassHookTrust = false, shellEnv?: Record<string, string>): Record<string, unknown> {
1780
- const config = {
1781
- ...(bypassHookTrust ? { bypass_hook_trust: true } : {}),
1782
- ...(shellEnv && Object.keys(shellEnv).length ? { shell_environment_policy: { set: shellEnv } } : {}),
1783
- }
1784
- return { ...(cwd ? { cwd } : {}), ...(Object.keys(config).length ? { config } : {}) }
1785
- }
1786
- export function codexStartThread(sock: string, cwd?: string, bypassHookTrust = false, shellEnv?: Record<string, string>): Promise<{ ok: true; threadId: string } | { ok: false; error: string }> {
1787
- return new Promise((resolve) => {
1788
- const conn: Socket = createConnection(sock)
1789
- const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
1790
- let upgraded = false, settled = false
1791
- const done = (r: { ok: true; threadId: string } | { ok: false; error: string }) => {
1792
- if (settled) return
1793
- settled = true
1794
- clearTimeout(timer)
1795
- try { conn.destroy() } catch { /* */ }
1796
- resolve(r)
1797
- }
1798
- const timer = setTimeout(() => done({ ok: false, error: 'codex app-server did not start a thread within 15000ms' }), 15000)
1799
- conn.on('error', (e) => done({ ok: false, error: `codex app-server connection failed: ${rpcError(e)}` }))
1800
- conn.on('close', () => done({ ok: false, error: 'codex app-server closed before thread/start was answered' }))
1801
- const send = (m: JsonRpc) => conn.write(wsText(JSON.stringify(m)))
1802
- conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))))
1803
- const handle = (json: string) => {
1804
- let m: JsonRpc
1805
- try { m = JSON.parse(json) } catch { return }
1806
- if (m.error) return done({ ok: false, error: `codex app-server ${m.id ? `request ${m.id}` : 'notification'} failed: ${m.error.message || JSON.stringify(m.error)}` })
1807
- if (m.id === 1 && m.result) {
1808
- send({ method: 'initialized', params: {} })
1809
- // thread/start's `config` is the per-request override map the app-server reads (config_manager reads
1810
- // `request_overrides["bypass_hook_trust"]`) — the ONLY channel that reaches the thread config; the
1811
- // `--dangerously-bypass-hook-trust` flag on the `codex app-server` invocation is INERT (the app-server
1812
- // never reads it for a thread), so a BACKEND-owned thread must carry the bypass here, exactly as codex's
1813
- // own `--remote resume` TUI client injects it. Without it the worktree's UNtrusted `.codex` config layer
1814
- // stays disabled → no local hooks discovered → no Stop gate. Only on the bypass path (older codex without
1815
- // the flag uses writeCodexTrust's hash and never sees this key).
1816
- // The same override map carries the thread's IDENTITY. A codex tool shell is spawned by the SHARED
1817
- // app-server, so it can inherit no session id — and must not, that leak was github#76. Codex's own
1818
- // `shell_environment_policy.set` injects vars into every command THIS thread spawns, so the backend
1819
- // stamps the governed record id there at thread creation, the same moment and the same knowledge with
1820
- // which a claude launch bakes it into its agent's env. Identity then arrives per-thread, needing no
1821
- // alias, no store lookup, and no cwd anywhere downstream.
1822
- return send({ id: 2, method: 'thread/start', params: codexStartThreadParams(cwd, bypassHookTrust, shellEnv) })
1823
- }
1824
- if (m.id === 2 && m.result) {
1825
- const tid = (m.result as { thread?: { id?: string } })?.thread?.id
1826
- return tid ? done({ ok: true, threadId: tid }) : done({ ok: false, error: 'codex thread/start returned no thread id' })
1827
- }
1828
- }
1829
- conn.on('data', (chunk: Buffer) => {
1830
- fs.buf = Buffer.concat([fs.buf, chunk])
1831
- if (!upgraded) {
1832
- const i = fs.buf.indexOf('\r\n\r\n')
1833
- if (i < 0) return
1834
- const head = fs.buf.slice(0, i).toString('utf8')
1835
- if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `codex app-server refused the WebSocket upgrade: ${head.split('\r\n')[0]}` })
1836
- upgraded = true
1837
- fs.buf = fs.buf.slice(i + 4)
1838
- send(wsInitialize)
1839
- }
1840
- if (drainWsFrames(fs, conn, handle)) done({ ok: false, error: 'codex app-server sent a WebSocket close before thread/start was confirmed' })
1841
- })
1842
- })
1843
- }
1844
-
1845
- const codexTurnConfirmMs = () => {
1846
- const configured = Number(process.env.SPEXCODE_CODEX_TURN_CONFIRM_MS)
1847
- return Number.isFinite(configured) && configured >= 100 ? configured : 15_000
1848
- }
1849
-
1850
- function sendCodexAppServerTurn(sock: string, threadId: string, text: string, cwd?: string, clientUserMessageId?: string): Promise<DispatchResult> {
1851
- return new Promise((resolve) => {
1852
- const conn: Socket = createConnection(sock)
1853
- const hs = codexHandshakeMessages(threadId) // [initialize(1), initialized, thread/loaded/list(2), thread/read(3)]
1854
- let buf = Buffer.alloc(0), upgraded = false, settled = false
1855
- let fragOp = 0, fragBuf = Buffer.alloc(0)
1856
- let steering = false // the id-4 message we sent was a steer → an expectedTurnId race may retry as start(5)
1857
- const done = (r: DispatchResult) => {
1858
- if (settled) return
1859
- settled = true
1860
- clearTimeout(timer)
1861
- try { conn.destroy() } catch { /* */ }
1862
- resolve(r)
1863
- }
1864
- const unresolved = (error: string) => done({ ok: false, error })
1865
- const timer = setTimeout(() => unresolved(`codex app-server did not confirm the turn within ${codexTurnConfirmMs()}ms`), codexTurnConfirmMs())
1866
- conn.on('error', (e) => unresolved(`codex app-server connection failed: ${rpcError(e)}`))
1867
- conn.on('close', () => unresolved('codex app-server closed the connection before the turn was confirmed'))
1868
- const send = (m: JsonRpc) => conn.write(wsText(JSON.stringify(m)))
1869
- conn.on('connect', () => {
1870
- const key = randomBytes(16).toString('base64')
1871
- conn.write(`GET /rpc HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: ${key}\r\n\r\n`)
1872
- })
1873
- const handle = (json: string) => {
1874
- let m: JsonRpc
1875
- try { m = JSON.parse(json) } catch { return }
1876
- if (m.error) {
1877
- if (m.id === 4 && steering) // active turn ended in the read→steer window → just start a fresh turn
1878
- return send(codexInjectMessage(threadId, text, cwd, null, 5, clientUserMessageId))
1879
- if (m.id === 3) // thread not readable yet (a freshly-started thread is "not materialized
1880
- return send(codexInjectMessage(threadId, text, cwd, null, 5, clientUserMessageId)) // before its first user message") → just turn/start
1881
- return done({ ok: false, error: `codex app-server ${m.id ? `request ${m.id}` : 'notification'} failed: ${m.error.message || JSON.stringify(m.error)}` })
1882
- }
1883
- // JSON-RPC initialization is ordered. Under a quiet server the premature notification happened to win;
1884
- // under shared app-server load it was ignored and every later turn waited until the old 5s wall expired.
1885
- if (m.id === 1 && m.result) { send(hs[1]); return send(hs[2]) } // initialize ack → initialized → ask which threads are loaded
1886
- if (m.id === 2 && m.result) { // loaded-thread list → confirm OUR thread is live, then read it
1887
- const loaded = (m.result as { data?: unknown })?.data
1888
- if (Array.isArray(loaded) && !loaded.includes(threadId))
1889
- return done({ ok: false, error: `Codex thread ${threadId} is not loaded in the app-server (loaded: ${loaded.join(', ') || 'none'}) — immediate poke not accepted` })
1890
- return send(hs[3]) // thread is live → read it to decide steer-vs-start
1891
- }
1892
- if (m.id === 3 && m.result) { // thread read → in-progress turn? steer into it; else start a new one
1893
- const turnId = activeTurnIdFromThread(m.result)
1894
- steering = !!turnId
1895
- return send(codexInjectMessage(threadId, text, cwd, turnId, 4, clientUserMessageId)) // id 4: turn/steer the live turn, or turn/start
1896
- }
1897
- if ((m.id === 4 || m.id === 5) && m.result) return done({ ok: true }) // steer/start accepted → the model has the message
1898
- }
1899
- const drainFrames = () => {
1900
- for (;;) {
1901
- if (buf.length < 2) return
1902
- const b0 = buf[0], b1 = buf[1], op = b0 & 0x0f, fin = (b0 & 0x80) !== 0, masked = (b1 & 0x80) !== 0
1903
- let len = b1 & 0x7f, off = 2
1904
- if (len === 126) { if (buf.length < 4) return; len = buf.readUInt16BE(2); off = 4 }
1905
- else if (len === 127) { if (buf.length < 10) return; len = Number(buf.readBigUInt64BE(2)); off = 10 }
1906
- const dataStart = off + (masked ? 4 : 0)
1907
- if (buf.length < dataStart + len) return
1908
- let payload = buf.slice(dataStart, dataStart + len)
1909
- if (masked) { const mk = buf.slice(off, off + 4); const u = Buffer.alloc(len); for (let i = 0; i < len; i++) u[i] = payload[i] ^ mk[i % 4]; payload = u }
1910
- buf = buf.slice(dataStart + len)
1911
- if (op === 0x8) return unresolved('codex app-server sent a WebSocket close before turn/start was confirmed')
1912
- if (op === 0x9) { conn.write(encodeWsFrame(0xa, payload)); continue } // ping → pong
1913
- if (op === 0xa) continue // pong
1914
- if (op === 0x0) fragBuf = Buffer.concat([fragBuf, payload]) // continuation
1915
- else { fragOp = op; fragBuf = payload }
1916
- if (fin) { if (fragOp === 0x1) handle(fragBuf.toString('utf8')); fragBuf = Buffer.alloc(0); fragOp = 0 }
1917
- }
1918
- }
1919
- conn.on('data', (chunk: Buffer) => {
1920
- buf = Buffer.concat([buf, chunk])
1921
- if (!upgraded) {
1922
- const i = buf.indexOf('\r\n\r\n')
1923
- if (i < 0) return
1924
- const head = buf.slice(0, i).toString('utf8')
1925
- if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `codex app-server refused the WebSocket upgrade: ${head.split('\r\n')[0]}` })
1926
- upgraded = true
1927
- buf = buf.slice(i + 4)
1928
- send(hs[0]) // wait for initialize before its required initialized notification
1929
- }
1930
- drainFrames()
1931
- })
1932
- })
1933
- }
1934
-
1935
- // fire a turn on an owned thread over the per-project socket — the same steer-vs-start delivery the live UI
1936
- // uses. The launcher calls this to materialize a freshly-started thread's rollout (the first turn = the launch
1937
- // prompt), and delivery reuses it for follow-ups. Exported so the CLI's `codex-launch` can fire the first turn.
1938
- export function codexTurn(sock: string, threadId: string, text: string, cwd?: string, clientUserMessageId?: string): Promise<DispatchResult> {
1939
- return sendCodexAppServerTurn(sock, threadId, text, cwd, clientUserMessageId)
1940
- }
1941
-
1942
- // @@@ codex rollout on disk - the visible TUI resumes a thread via `codex --remote resume <tid>`, which reads
1943
- // the thread's ROLLOUT FILE (`<CODEX_HOME>/sessions/YYYY/MM/DD/rollout-<ts>-<tid>.jsonl`) — so a thread the
1944
- // TUI can render is exactly one whose rollout exists on disk. VERIFIED live (real codex 0.142.5): `thread/start`
1945
- // ALONE writes NO rollout — only the first fired turn materializes it; and a FRESHLY-spawned app-server accepts
1946
- // thread/start+turn but does NOT persist the rollout for its first ~2-4s (a warm-up window) — the SAME thread's
1947
- // rollout just lands a few seconds LATE (not lost). Handing the id to `resume` before then is the "no rollout
1948
- // found for thread id" failure, so codex-launch WAITS for the rollout to land before it trusts the id.
1949
- // does a rollout file for this thread id exist yet? Rollouts are grouped by date; walk day-dirs newest-first
1950
- // (lexical order = chronological on zero-padded YYYY/MM/DD) and return on first hit — the fresh rollout lives in
1951
- // the newest real dir, so the common case reads one dir. The walk is exhaustive, never capped at "the newest few
1952
- // dirs": future-dated junk under sessions/ (a test once planted 2099/12/* in the real CODEX_HOME) sorts above
1953
- // every real day-dir, and a cap let three such dirs mask ALL real rollouts — every codex launch then failed
1954
- // "persisted no rollout" with the rollout sitting on disk. A full walk is a readdir per day-dir — still cheap.
1955
- export function codexRolloutExists(threadId: string, root?: string): boolean {
1956
- return codexRolloutPath(threadId, root) !== null
1957
- }
1958
- // The same day-dir walk, answering how big that rollout is. `thread/archive` on a LOADED thread flushes the
1959
- // thread's in-memory rollout inside the server's shutdown_and_wait before it commits, so this size IS the work
1960
- // an archive asks for; a notLoaded thread flushes nothing and its size is irrelevant. NO rollout file is a real
1961
- // `0`, not an error: a thread that has started but not yet persisted (thread/start alone writes none, and a
1962
- // fresh app-server lags 2-4s) has nothing to flush, so refusing it would be a false refusal. Only a file that
1963
- // exists and cannot be measured is unreadable, and that fails closed rather than passing as small.
1964
- export function codexRolloutBytes(threadId: string, root?: string): { bytes: number } | { unreadable: true } {
1965
- const path = codexRolloutPath(threadId, root)
1966
- if (path) { try { return { bytes: statSync(path).size } } catch { return { unreadable: true } } }
1967
- return { bytes: 0 }
1968
- }
1969
- // poll until the thread's rollout lands (resume-ready) or the budget runs out. Returns false on timeout so the
1970
- // caller can FAIL LOUD instead of handing `resume` / the stored record a non-resumable id. The budget must
1971
- // exceed launch.sh's fast-fail threshold so a genuine failure exits PAST it — the retry loop then treats it as a
1972
- // real end, not a daemon race, and never sprays fresh (duplicate-prompt) threads.
1973
- export async function waitForCodexRollout(threadId: string, timeoutMs = 20000): Promise<boolean> {
1974
- const deadline = Date.now() + timeoutMs
1975
- for (;;) {
1976
- if (codexRolloutExists(threadId)) return true
1977
- if (Date.now() >= deadline) return false
1978
- await new Promise((r) => setTimeout(r, 250))
1979
- }
1980
- }
1981
-
1982
- // codex's immediate poke uses the app-server JSON-RPC channel that also powers rich clients, never TUI typing.
1983
- // The visible TUI is launched against the same project app-server Unix socket, so this injects into the same
1984
- // thread the pane is showing — steering an in-progress turn or starting one if idle. A missing captured thread
1985
- // id or socket makes this poke fail; there is no tmux send-keys fallback because that reports "typed", not "accepted".
1986
- const pexec = promisify(execFile)
1987
- const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
1988
- async function deliverViaCodexAppServer(rec: HarnessDeliveryRecord, text: string): Promise<DispatchResult> {
1989
- // the socket is PER-PROJECT (the runtime root), shared by every worktree's thread; the owned thread id on
1990
- // the record picks out THIS session's thread.
1991
- const runtimeDir = rec.runtimeDir ?? runtimeRoot()
1992
- const endpoint = rec.harnessSessionId ? codexEndpointForRecord(rec, runtimeDir) : currentCodexGeneration(runtimeDir)
1993
- if (!endpoint) return { ok: false, error: `no exact Codex generation binding for session ${rec.session} — immediate poke unavailable` }
1994
- const sock = endpoint.socketPath
1995
- if (!existsSync(sock)) return { ok: false, error: `no Codex app-server socket for this project — immediate poke unavailable` }
1996
- // use the backend-owned thread id stored at launch; fall back to reading the one loaded thread only if it's
1997
- // empty (a pre-existing session from before the id was stored).
1998
- let threadId = rec.harnessSessionId
1999
- if (!threadId) {
2000
- const r = await codexThreadId(sock)
2001
- if (!r.ok) return { ok: false, error: `${r.error} — immediate poke unavailable` }
2002
- threadId = r.threadId
2003
- }
2004
- return sendCodexAppServerTurn(sock, threadId!, text, rec.worktreePath, rec.mid)
2005
- }
2006
-
2007
- // idempotent replace of the content between sentinels; the user's own content above/below is preserved. The
2008
- // comment STYLE is a parameter so ONE primitive serves every managed file — HTML for the md contracts
2009
- // (CLAUDE.md/AGENTS.md), `#` for .gitignore — instead of a per-file-type writer. Default = HTML (the md case).
2010
- export function writeManagedBlock(file: string, body: string, comment: readonly [string, string] = ['<!-- ', ' -->']): boolean {
2011
- const [open, close] = comment
2012
- const START = `${open}spexcode:start${close}`
2013
- const END = `${open}spexcode:end${close}`
2014
- const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
2015
- const block = `${START}\n${body}\n${END}`
2016
- const cur = existsSync(file) ? readFileSync(file, 'utf8') : ''
2017
- const re = new RegExp(`${esc(START)}[\\s\\S]*?${esc(END)}`)
2018
- const next = re.test(cur) ? cur.replace(re, block) : cur.trim() ? `${cur.replace(/\n*$/, '')}\n\n${block}\n` : `${block}\n`
2019
- return writeFileIfChanged(file, next)
2020
- }
2021
-
2022
- // the INVERSE of writeManagedBlock: strip the spexcode sentinel block (with the blank space around it),
2023
- // leaving every other byte of the user's file intact. When deleteIfEmpty and nothing but whitespace remains,
2024
- // remove the file — it was WHOLLY ours (e.g. a CLAUDE.md that carried only the generated contract block). Same
2025
- // comment-style parameter so ONE primitive un-writes every managed file. No-op when the file/block is absent.
2026
- export function removeManagedBlock(file: string, comment: readonly [string, string] = ['<!-- ', ' -->'], deleteIfEmpty = false): void {
2027
- if (!existsSync(file)) return
2028
- const [open, close] = comment
2029
- const START = `${open}spexcode:start${close}`
2030
- const END = `${open}spexcode:end${close}`
2031
- const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
2032
- const re = new RegExp(`\\n*${esc(START)}[\\s\\S]*?${esc(END)}\\n*`)
2033
- const cur = existsSync(file) ? readFileSync(file, 'utf8') : ''
2034
- if (!re.test(cur)) return
2035
- // remove ONLY our block plus the blank lines writeManagedBlock inserted around it; do NOT normalize the
2036
- // user's OWN whitespace elsewhere — this must leave every other byte intact so it is a faithful INVERSE of
2037
- // writeManagedBlock's append. A global `\n{3,}→\n\n` collapse used to sit here and mutated pre-existing
2038
- // blank-line runs in the user's file, which broke the policy round-trip ([[residence]]): a mode flip
2039
- // and back left a spurious one-line diff on a .gitignore that had internal blank lines. The leading-newline
2040
- // strip is GUARDED the same way: it exists only for a block sitting at the TOP of the file (whose '\n'
2041
- // replacement would otherwise become a leading blank) — a host file that BEGINS with its own blank lines
2042
- // keeps them ([[content-filter]]'s invariant, same bug class as the shim's old unconditional strip).
2043
- const atTop = (re.exec(cur)?.index ?? -1) === 0
2044
- const replaced = cur.replace(re, '\n')
2045
- const out = atTop ? replaced.replace(/^\n+/, '') : replaced
2046
- if (deleteIfEmpty && !out.trim()) { rmSync(file, { force: true }); return }
2047
- writeFileSync(file, out)
2048
- }
2049
-
2050
- // the shim for one harness: every event → `SPEX='…' bash <dispatch> <harnessId> <Event>`. The harness id is
2051
- // baked in so dispatch.sh can export SPEXCODE_HARNESS (the detector for the shell side). SPEX is inherited by
2052
- // the cli-needing handlers.
2053
- function buildShim(id: HarnessId, events: readonly string[], dispatch: string, spex: string): { content: string; cmd: (e: string) => string } {
2054
- const cmd = (e: string) => `SPEX='${spex}' bash ${dispatch} ${id} ${e}`
2055
- const hooks: Record<string, unknown> = {}
2056
- for (const e of events) hooks[e] = [{ hooks: [{ type: 'command', command: cmd(e) }] }]
2057
- return { content: JSON.stringify({ hooks }, null, 2), cmd }
2058
- }
2059
-
2060
- // ---------------------------------------------------------------------------------------------------------
2061
- // Codex trust — the codex-rs trusted_hash, reverse-engineered + pinned. Lives in the Codex adapter (it is a
2062
- // codex-only fact); Claude has no analog.
2063
-
2064
- // Codex trust keys + the hash use snake_case event labels (codex hook_event_key_label).
2065
- const SNAKE: Record<string, string> = {
2066
- SessionStart: 'session_start', UserPromptSubmit: 'user_prompt_submit', PreToolUse: 'pre_tool_use',
2067
- PostToolUse: 'post_tool_use', Stop: 'stop',
2068
- }
2069
-
2070
- // @@@ codexHookHash - the trusted_hash codex computes (from codex-rs: command_hook_hash + version_for_toml):
2071
- // sha256 of the canonical (recursively key-sorted, compact) JSON of {event_name, hooks:[{type,command,timeout,
2072
- // async}]}; None fields omitted. Verified against live codex 0.142.3 samples.
2073
- export function codexHookHash(snakeEvent: string, command: string, timeout = 600, asyncFlag = false): string {
2074
- const canon = (v: unknown): unknown =>
2075
- v && typeof v === 'object' && !Array.isArray(v)
2076
- ? Object.fromEntries(Object.keys(v as object).sort().map((k) => [k, canon((v as Record<string, unknown>)[k])]))
2077
- : Array.isArray(v) ? v.map(canon) : v
2078
- const obj = { event_name: snakeEvent, hooks: [{ type: 'command', command, timeout, async: asyncFlag }] }
2079
- return 'sha256:' + createHash('sha256').update(JSON.stringify(canon(obj))).digest('hex')
2080
- }
2081
-
2082
- // @@@ stripCodexTrustFor - remove EVERY prior definition of THIS project's codex trust from a config.toml body,
2083
- // in ANY form: our own sentinel block (whatever past format its comments used), a BARE `[projects."<proj>"]`
2084
- // table (codex AUTO-writes one the moment it trusts a folder interactively/`exec` — NOT sentinel-wrapped), and
2085
- // any `[hooks.state."<hooksJson>:…"]` tables. This is what makes the UNCONDITIONAL write duplicate-SAFE and
2086
- // SELF-HEALING: codex REFUSES to load a config.toml with a duplicate key ("duplicate key"), so a sentinel-only
2087
- // replace (the old behaviour) that missed a pre-existing bare/old block APPENDED a second `[projects."<proj>"]`
2088
- // and took codex fully OFFLINE (the real cause of the public-vps outage). It is TABLE-scoped and STRING-compared
2089
- // (no regex escaping of the path), so other projects' trust, the shared parent tables (`[projects]`,
2090
- // `[hooks.state]`), and every other config key are untouched; a skipped table's body ends at the next header,
2091
- // blank, or comment, so a user comment attached to a following table is preserved.
2092
- function stripCodexTrustFor(cur: string, proj: string, hooksJson: string): string {
2093
- const projHeader = `[projects."${proj}"]`
2094
- const hooksPrefix = `[hooks.state."${hooksJson}:`
2095
- const out: string[] = []
2096
- let skip = false
2097
- for (const line of cur.split('\n')) {
2098
- const t = line.trim()
2099
- const isHeader = /^\[\[?/.test(t) // a TOML table / array-of-tables header
2100
- if (skip) { if (t === '' || t.startsWith('#') || isHeader) skip = false; else continue } // end THIS table's body
2101
- if (isHeader && (t === projHeader || t.startsWith(hooksPrefix))) { skip = true; continue }
2102
- if (t === `# spexcode:trust:${proj} (managed — do not edit)` || t === `# spexcode:trust:end:${proj}`) continue
2103
- out.push(line)
2104
- }
2105
- return out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '').replace(/\n*$/, '')
2106
- }
2107
-
2108
- // additively stamp PROJECT trust (`[projects."<proj>"] trust_level = "trusted"`) AND the per-hook
2109
- // `trusted_hash` blocks for each event into the user's GLOBAL ~/.codex/config.toml, so a dispatched or
2110
- // self-launched codex trusts THIS project's config layer (enabling hook discovery) AND treats each hook as
2111
- // already-reviewed (no "Hooks need review" prompt on a persistent resume — see writeTrust). ALL prior
2112
- // definitions of this project's trust (ours, bare, or old-format) are STRIPPED first, so the write can never
2113
- // leave a DUPLICATE key (which breaks codex config loading) and self-heals a config that already carried one.
2114
- // Scoped to THIS project path; never touches the user's other config. CODEX_HOME respected for testability.
2115
- // (`events` may be empty for a trust-only stamp in tests.)
2116
- export function writeCodexTrust(proj: string, events: readonly string[], cmdFor: (e: string) => string): string {
2117
- const home = process.env.CODEX_HOME || join(homedir(), '.codex')
2118
- const file = join(home, 'config.toml')
2119
- const hooksJson = join(proj, '.codex', 'hooks.json')
2120
- const lines = [`[projects."${proj}"]`, 'trust_level = "trusted"']
2121
- for (const e of events) {
2122
- const snake = SNAKE[e]
2123
- lines.push(`[hooks.state."${hooksJson}:${snake}:0:0"]`, `trusted_hash = "${codexHookHash(snake, cmdFor(e))}"`)
2124
- }
2125
- const blk = `# spexcode:trust:${proj} (managed — do not edit)\n${lines.join('\n')}\n# spexcode:trust:end:${proj}`
2126
- const cleaned = stripCodexTrustFor(existsSync(file) ? readFileSync(file, 'utf8') : '', proj, hooksJson)
2127
- if (!existsSync(home)) mkdirSync(home, { recursive: true })
2128
- writeFileIfChanged(file, cleaned ? `${cleaned}\n\n${blk}\n` : `${blk}\n`)
2129
- return file
2130
- }
2131
-
2132
- // the inverse of writeCodexTrust: strip THIS project's codex trust from the GLOBAL config.toml — the SAME
2133
- // removal writeCodexTrust does before it writes, so uninstall fully clears our trust (sentinel, bare, and
2134
- // hooks.state) and can never leave a half-block. No-op when the file/nothing-of-ours is absent (so it never
2135
- // rewrites/normalizes a config that carries none of our trust). CODEX_HOME respected for testability.
2136
- function removeCodexTrust(proj: string): void {
2137
- const home = process.env.CODEX_HOME || join(homedir(), '.codex')
2138
- const file = join(home, 'config.toml')
2139
- if (!existsSync(file)) return
2140
- const hooksJson = join(proj, '.codex', 'hooks.json')
2141
- const cur = readFileSync(file, 'utf8')
2142
- if (!cur.includes(`[projects."${proj}"]`) && !cur.includes(`[hooks.state."${hooksJson}:`) &&
2143
- !cur.includes(`# spexcode:trust:${proj} `) && !cur.includes(`# spexcode:trust:end:${proj}`)) return
2144
- const cleaned = stripCodexTrustFor(cur, proj, hooksJson)
2145
- writeFileSync(file, cleaned ? `${cleaned}\n` : '')
2146
- }
2147
-
2148
- // is this file git-tracked in proj? (guards cleanHarness's deleteIfEmpty; env-stripped git, never throws)
2149
- function isTrackedFile(proj: string, f: string): boolean {
2150
- try { git(['-C', proj, 'ls-files', '--error-unmatch', f]); return true } catch { return false }
2151
- }
2152
-
2153
- // @@@ cleanHarness - the shared clean: the inverse of materialize's per-harness write, expressed PURELY
2154
- // through the adapter's own path methods so it can never drift from what write put there. Each step is
2155
- // surgical, gated on a SpexCode identity stamp: the contract files carry the managed-block sentinels; the shim
2156
- // is a generated file whose command line names our `dispatch.sh`; the trust is a sentinel-delimited config
2157
- // block; the skill/agent files sit at name-scoped paths reconstructed from `arts`. So it removes ONLY our own
2158
- // blocks and our own named products — never a user's CLAUDE.md/AGENTS.md prose, a hand-made settings.json, or
2159
- // a sibling skill/agent the user added, and NEVER any .spec data.
2160
- function cleanHarness(h: Harness, proj: string, arts: HarnessArtifacts, preserveProject = false): void {
2161
- // deleteIfEmpty ONLY for an UNTRACKED contract file: a wholly-ours generated file goes; a HOST-TRACKED file
2162
- // that carried nothing but our block (an empty committed CLAUDE.md we folded into) is stripped back to its
2163
- // pristine emptiness but never deleted — deleting a tracked file would surface as a `D` in the host's status.
2164
- for (const f of h.contractFiles(proj)) removeManagedBlock(f, ['<!-- ', ' -->'], !isTrackedFile(proj, f))
2165
- const shim = h.shimFile(proj)
2166
- if ((h.shimScope === 'tree' || !preserveProject) && existsSync(shim) && readFileSync(shim, 'utf8').includes('dispatch.sh')) rmSync(shim, { force: true })
2167
- const anchor = h.worktreeHookAnchor(proj) // the linked-worktree anchor copy, same identity gate as the shim
2168
- if (anchor && existsSync(anchor) && readFileSync(anchor, 'utf8').includes('dispatch.sh')) rmSync(anchor, { force: true })
2169
- if (!preserveProject) h.removeTrust(proj)
2170
- const sd = h.skillDir(proj)
2171
- if (sd) for (const n of arts.skills) rmSync(join(sd, n), { recursive: true, force: true })
2172
- const ad = h.agentDir(proj)
2173
- if (ad) for (const n of arts.agents) rmSync(join(ad, `${n}.md`), { force: true })
2174
- }
2175
-
2176
- // ---------------------------------------------------------------------------------------------------------
2177
- // codex per-session liveness signal — a codex process live in the pane's DESCENDANT tree, NOT the pane's
2178
- // foreground command name, and NOT the shared app-server socket.
2179
-
2180
- // @@@ paneTreeRunsCodex - the codex TUI is alive iff a codex-ish process is live SOMEWHERE in the launch
2181
- // pane's descendant process tree. The pane's FOREGROUND name is NOT the signal: the pane runs
2182
- // `bash <launch.sh>` → `bash -lc <codex script>` → node (the codex CLI) → the vendored `codex` binary, and
2183
- // tmux's `pane_current_command` reports the OUTERMOST of those — `bash` — for the entire life of a healthy,
2184
- // rendering TUI (field-confirmed on macmini and Linux). So "foreground == codex" false-read every live codex
2185
- // as offline, and the earlier sock-presence check false-read a dead one as online (the SHARED per-project
2186
- // app-server socket survives a failed `--remote resume`). The honest shape test: HEALTHY = codex (by whatever
2187
- // name — `codex`, the vendored musl binary, or the `node` its CLI runs under) exists among the pane pid's
2188
- // descendants; FAILED = the launch script's bounded retries exhausted, everything under the pane exited, and
2189
- // the pane sits at the bare shell — no codex/node anywhere below it. The walk is over ONE whole-box
2190
- // pid→(ppid, comm) snapshot the caller took (a single `ps` for the whole session list); missing probe data
2191
- // (tmux/ps couldn't report) is not-live, and the caller's boot grace still shows a fresh launch — whose tree
2192
- // may not yet contain codex — as 'starting', not 'offline'.
2193
- const CODEXISH = /^(codex|node)/i // the vendored binary ('codex', 'codex-x86_64…') or the CLI's node runtime
2194
- // the shared descendant-tree walk: does a process matching `re` live BELOW the pane pid? (The pane pid itself
2195
- // is the shell, so descendants only.) Pure over the caller's one ps snapshot.
2196
- function paneTreeRuns(pane: PaneProbe | undefined, re: RegExp): boolean {
2197
- if (!pane?.panePid || !pane.procs?.size) return false
2198
- const kids = new Map<number, number[]>()
2199
- for (const [pid, p] of pane.procs) {
2200
- const arr = kids.get(p.ppid); if (arr) arr.push(pid); else kids.set(p.ppid, [pid])
2201
- }
2202
- const stack = [...(kids.get(pane.panePid) ?? [])] // descendants only — the pane pid itself is the shell
2203
- while (stack.length) {
2204
- const pid = stack.pop()!
2205
- const comm = pane.procs.get(pid)?.comm ?? ''
2206
- if (re.test(comm.slice(comm.lastIndexOf('/') + 1))) return true // basename — macOS ps comm is a full path
2207
- const c = kids.get(pid); if (c) stack.push(...c)
2208
- }
2209
- return false
2210
- }
2211
- export function paneTreeRunsCodex(pane?: PaneProbe): boolean { return paneTreeRuns(pane, CODEXISH) }
2212
-
2213
- // ONE whole-box pid→(ppid, comm) snapshot (a single `ps` spawn) — the table paneTreeRuns walks. Owned here
2214
- // (beside its consumers) and shared with sessions.ts's liveSnapshot, so the two probe layers can never parse
2215
- // ps differently. A failed/timed-out ps returns an empty table: the callers read that as not-provably-running.
2216
- export async function procSnapshot(timeoutMs = 4000): Promise<ProcTable> {
2217
- const t: ProcTable = new Map()
2218
- let out = ''
2219
- try { ({ stdout: out } = await pexec('ps', ['-eo', 'pid=,ppid=,comm='], { timeout: timeoutMs, killSignal: 'SIGKILL' })) } catch { return t }
2220
- for (const line of out.split('\n')) {
2221
- const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line)
2222
- if (m) t.set(Number(m[1]), { ppid: Number(m[2]), comm: m[3].trim() })
2223
- }
2224
- return t
2225
- }
2226
-
2227
- // ---------------------------------------------------------------------------------------------------------
2228
- // the two implementations.
2229
-
2230
- const CLAUDE_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'StopFailure', 'Notification'] as const
2231
- const CODEX_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'] as const
2232
- // the five claude-shaped events pi's generated extension SYNTHESIZES from its own lifecycle (session_start →
2233
- // SessionStart, input → UserPromptSubmit, tool_call → PreToolUse, tool_result → PostToolUse, agent_end +
2234
- // agent_settled → Stop). pi has no idle/attention or failed-stop event → no Notification/StopFailure, same
2235
- // real gap as codex.
2236
- const PI_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'] as const
2237
- // z-code reads Claude-compatible hooks but has neither the idle Notification nor StopFailure lifecycle event.
2238
- // This is a real harness difference, not a TODO: the existing Claude-only idle state is unavailable.
2239
- const ZCODE_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'] as const
2240
-
2241
- // the resolved base launcher command per harness (the wrapper that sets the config-dir env), shared by
2242
- // launchCmd and baseCmd so the two never diverge: the launcher's pinned `cmd` wins. The plain command is only
2243
- // the fallback for a truly-old record with NO pinned cmd and NO launcher name — compatibility must preserve
2244
- // the harness's normal permission model, never silently introduce an automatic-permission flag. There is no
2245
- // env/config-field resolution because launchers are ordinary named config entries ([[launcher-select]]).
2246
- const claudeBaseCmd = (cmd?: string) => cmd || 'claude'
2247
- const codexBaseCmd = (cmd?: string) => cmd || 'codex'
2248
- const piBaseCmd = (cmd?: string) => cmd || 'pi' // pi runs tools without permission prompts — no yolo flag exists or is needed
2249
- const opencodeBaseCmd = (cmd?: string) => cmd || 'opencode'
2250
- const zcodeBaseCmd = (cmd?: string) => cmd || 'zcode'
2251
-
2252
- // @@@ opencodeLaunchCommand - the tail-branching launch script (the codex marker pattern, minus any server:
2253
- // opencode is a per-session process like claude). The caller-appended tail ("$@") is EITHER one single-quoted
2254
- // prompt arg (a NEW launch → `--prompt`), or a resume marker from opencodeHarness.resumeArg: `--resume <id>`
2255
- // re-attaches the owned opencode session (`--session <id>`, the SAME conversation), `--continue` re-attaches
2256
- // the worktree's last session when no id was ever captured (the plugin failed before its first event). A new
2257
- // launch's tail can never BE a literal marker (it's one quoted prompt), so the branch is unambiguous.
2258
- export function opencodeLaunchCommand(opencodeCmd = 'opencode'): string {
2259
- const script = [
2260
- `if [ "\${1:-}" = "--resume" ]; then`,
2261
- // the marker carries the owned session id — export it so the plugin can seed rootSession at load: a
2262
- // resumed session re-fires NO bus event until poked, so without this the rendezvous daemon rejects
2263
- // every delivery (resume-continuity A-side: continuity ✓, steerability ✗).
2264
- ` export SPEXCODE_OPENCODE_RESUME_ID="$2"`,
2265
- ` exec ${opencodeCmd} --session "$2"`,
2266
- `elif [ "\${1:-}" = "--continue" ]; then`,
2267
- // no owned id to seed — mark the continue-resume so the plugin knows to ask the SDK for the
2268
- // reattached session (scoped to this marker so a FRESH launch can never adopt a stale session).
2269
- ` export SPEXCODE_OPENCODE_CONTINUE=1`,
2270
- ` exec ${opencodeCmd} --continue`,
2271
- `elif [ -n "\${1:-}" ]; then`,
2272
- ` exec ${opencodeCmd} --prompt "$1"`,
2273
- `else`,
2274
- ` exec ${opencodeCmd}`,
2275
- `fi`,
2276
- ].join('\n')
2277
- return `bash -lc ${shQuote(script)} spexcode-opencode`
2278
- }
2279
-
2280
- const socketListenerLiveness: Harness['liveness'] = (_rec, tmuxAlive, _runtimeDir, _pane, socketLive) =>
2281
- (tmuxAlive && !!socketLive ? 'online' : 'offline')
2282
-
2283
- const socketListenerOrPidAliveLiveness: Harness['liveness'] = (_rec, tmuxAlive, _runtimeDir, pane, socketLive) =>
2284
- (tmuxAlive && (!!socketLive || pane?.pidAlive === true) ? 'online' : 'offline')
2285
-
2286
- const panePidLiveness: Harness['liveness'] = (_rec, tmuxAlive, _runtimeDir, pane) =>
2287
- (tmuxAlive && pane?.pidAlive === true ? 'online' : 'offline')
2288
-
2289
- const recordOnline: Harness['liveness'] = (rec) => rec.stopped ? 'offline' : 'online'
2290
-
2291
- // @@@ unlinkSocks - remove ONLY the transport this teardown PROVED dead. `cleanupRuntime` unlinks *their*
2292
- // socket, and the honest test of "theirs" is that the agent it just killed is GONE. It used to unlink on
2293
- // faith, which is unsound because a socket path is derived from the session id ALONE: it is the one
2294
- // per-session resource NOT scoped by the store (`SPEXCODE_HOME`) or the tmux server (`SPEXCODE_TMUX`), so an
2295
- // isolated instance closing an id that also names a LIVE session elsewhere had its `kill-session` miss (that
2296
- // IS namespaced) while this unlink landed (it is not) — deleting a working agent's socket out from under it.
2297
- // The damage is invisible and permanent: the listener stays bound to an unlinked path, so nothing can ever
2298
- // connect again (delivery fails its existsSync gate) and every probe ENOENTs, which the liveness axis reads as
2299
- // PROVEN death — a live worker reading `offline`, which in turn disarms the relaunch guard.
2300
- // So: poll until death is proven, then unlink. A listener still answering past the wall is somebody's live
2301
- // agent — mine that failed to die, or one that was never mine — and either way it is not ours to remove: leave
2302
- // it and say so. `unproven` is not proof either, so it is left too. The asymmetry is deliberate: a dead-but-
2303
- // unlinked file is harmless residue the next teardown reaps, while a wrong unlink strands a working agent.
2304
- const SOCK_DEATH_WALL_MS = 2000 // a killed agent releases its listener in well under this; the wall only bounds the wrong case
2305
- const SOCK_DEATH_POLL_MS = 100
2306
- export const unlinkSocks = async (...paths: string[]): Promise<void> => {
2307
- for (const path of paths) {
2308
- if (!existsSync(path)) continue
2309
- const deadline = Date.now() + SOCK_DEATH_WALL_MS
2310
- let probe = await listenerAt(path)
2311
- while (probe !== 'dead' && Date.now() < deadline) {
2312
- await new Promise((r) => setTimeout(r, SOCK_DEATH_POLL_MS))
2313
- probe = await listenerAt(path)
2314
- }
2315
- if (probe !== 'dead') {
2316
- console.warn(`spex: left ${path} in place — ${probe === 'live'
2317
- ? 'a listener is still answering it, so it belongs to a running agent (this teardown did not kill it, or it was never ours)'
2318
- : 'the listener probe could not conclude, and death was never proven'}`)
2319
- continue
2320
- }
2321
- try { rmSync(path, { force: true }) } catch { /* already gone */ }
2322
- }
2323
- }
2324
-
2325
- const rendezvousLaunchEnv = (id: string): string[] => [
2326
- 'CLAUDE_BG_BACKEND=daemon',
2327
- `CLAUDE_BG_RENDEZVOUS_SOCK=${rvSock(id)}`,
2328
- ]
2329
- const noLaunchEnv = (): string[] => []
2330
-
2331
- export const claudeHarness: Harness = {
2332
- id: 'claude',
2333
- dispatchId: 'claude',
2334
- headless: false,
2335
- events: CLAUDE_EVENTS,
2336
- ownsRendezvous: true, // reclaude opens the rendezvous control socket (prompt delivery + liveness)
2337
- paneTitleIsSelfSummary: true, // claude writes its live task summary into the OSC pane title → headline derives from it
2338
- executionTrace: readProjectJsonlExecutionTrace,
2339
- launchCmd: (_id, _rt, cmd) => claudeBaseCmd(cmd), // claude's full invocation IS its base command (the tail is appended by the caller)
2340
- baseCmd: claudeBaseCmd,
2341
- sessionIdArg: (id) => `--session-id ${id}`, // the caller chooses the id
2342
- sessionEnvVar: 'CLAUDE_CODE_SESSION_ID',
2343
- launchEnv: rendezvousLaunchEnv,
2344
- shimFile: (proj) => join(proj, '.claude', 'settings.json'),
2345
- shimScope: 'tree',
2346
- worktreeHookAnchor: () => null, // claude's shim already lives in the worktree (.claude/settings.json) — self-anchors, no root rewrite
2347
- contractFiles: (proj) => [join(proj, 'CLAUDE.md')],
2348
- skillDir: (proj) => join(proj, '.claude', 'skills'),
2349
- agentDir: (proj) => join(proj, '.claude', 'agents'),
2350
- shim: (dispatch, spex) => buildShim('claude', CLAUDE_EVENTS, dispatch, spex),
2351
- writeTrust: () => [], // Claude relies on folder-trust — no artifact to report
2352
- removeTrust: () => { /* Claude wrote no trust — nothing to strip */ },
2353
- clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject) },
2354
- slashCommands: claudeSlashCommands,
2355
- // online iff the window is up AND a LIVE LISTENER is on the rendezvous socket (`socketLive`, connect-probed by
2356
- // the caller) — NOT the mere existence of a stale socket FILE a crashed claude leaves behind (the 30-min
2357
- // dead-pane-reads-working bug). See rendezvousListening.
2358
- liveness: socketListenerLiveness,
2359
- leafOwnerNeedle: (rec) => rec.session,
2360
- deliver: (rec, text) => deliverViaClaudeRendezvous(rec.session, text, rec.mid, rec.runtimeDir),
2361
- cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
2362
- coldRuntime: async () => ({ ok: true }),
2363
- // The TUI's sessions panel ("← for agents") swallows an injected reply into PANEL context and never drains it
2364
- // (verified live: `queue-operation: enqueue` with no dequeue, no turn, daemon silent), so skip this courtesy
2365
- // poke and leave the timeline reader to show the message. Matched on the panel's own
2366
- // strings — the new-session composer placeholder, or its footer key hints together (either alone could drift
2367
- // across claude versions; requiring the footer PAIR keeps a prose false-positive unlikely).
2368
- deliveryBlockedBy: (paneText) =>
2369
- paneText.includes('describe a task for a new session') || (paneText.includes('enter to return') && paneText.includes('space to reply'))
2370
- ? 'the claude TUI is focused on its sessions panel ("← for agents"), which silently swallows injected prompts — press Enter in the session terminal to return to the composer, then resend'
2371
- : null,
2372
- resumeArg: (rec) => `--resume ${rec.session}`,
2373
- // claude's settled launch failures, in its own words: a `--resume` id it has no conversation for (the id was
2374
- // never claude's, or its transcript is gone), and a rejected credential. Both are the same command failing
2375
- // the same way every time — the human must repair the conversation or the login, so the transport stops at
2376
- // one attempt and shows this line instead of burying it under two more identical failures.
2377
- fatalLaunchOutput: ['No conversation found with session ID', 'Invalid API key', 'Please run /login'],
2378
- }
2379
-
2380
- // Claude headless is a separate harness, not a claude mode. Its materialize half is exactly Claude's and is
2381
- // reused by object composition; the whole runtime half is replaced by the stream-json controller.
2382
- export const claudeHeadlessHarness: Harness = {
2383
- ...claudeHarness,
2384
- id: 'claude-headless',
2385
- headless: true,
2386
- runtimeOwnership: 'adapter',
2387
- ownsRendezvous: false,
2388
- paneTitleIsSelfSummary: false,
2389
- launchCmd: (id, runtimeDir, cmd) => claudeHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), claudeBaseCmd(cmd)),
2390
- launchEnv: noLaunchEnv,
2391
- // Liveness is the intact, non-stopped record's property. A missing controller/child fails loudly at control
2392
- // time rather than turning an idle (no child) session into a speculative offline row.
2393
- liveness: recordOnline,
2394
- deliver: deliverViaClaudeHeadless,
2395
- interrupt: interruptClaudeHeadless,
2396
- cleanupRuntime: (rec) => unlinkSocks(claudeHeadlessSock(rec.session)),
2397
- coldRuntime: async () => ({ ok: false, reason: 'claude-headless has no exact resident unload verification' }),
2398
- deliveryBlockedBy: undefined,
2399
- }
2400
-
2401
- function codexRuntimeDescriptor(endpoint: CodexGenerationEndpoint, runtimeDir: string): SharedRuntimeDescriptor {
2402
- return {
2403
- key: codexDescriptorKey(endpoint),
2404
- label: endpoint.id === 'legacy' ? 'Codex app-server' : `Codex app-server ${endpoint.id.slice(0, 18)}`,
2405
- pidFile: endpoint.pidFile,
2406
- receiptFile: endpoint.receiptFile,
2407
- residency: async () => {
2408
- let pid = 0
2409
- try { pid = Number(readFileSync(endpoint.pidFile, 'utf8').trim()) } catch { /* stale/missing pid */ }
2410
- const pidLive = pid > 0 && !!processStartToken(pid)
2411
- const listener = await listenerAt(endpoint.socketPath, 800)
2412
- if (!pidLive && listener === 'dead') return { healthy: true, referenceIds: [], rootAbsent: true }
2413
- if (pidLive && (!codexRuntimeGeneration(runtimeDir, endpoint) || listener !== 'live'))
2414
- return { healthy: false, referenceIds: [], error: 'Codex shared root identity/socket generation is not proven' }
2415
- if (!pidLive || listener !== 'live')
2416
- return { healthy: false, referenceIds: [], error: 'Codex shared root state is unknown' }
2417
- const result = await codexLoadedReferenceIds(endpoint.socketPath)
2418
- return result.ok ? { healthy: true, referenceIds: result.referenceIds } : { healthy: false, referenceIds: [], error: result.error }
2419
- },
2420
- mutationGuard: (targetReferenceId, opts) => codexMutationGuard(targetReferenceId, runtimeDir, opts, endpoint),
2421
- probe: () => codexSharedRuntimeProbe(runtimeDir, endpoint),
2422
- }
2423
- }
2424
-
2425
- function codexRuntimeDescriptors(runtimeDir: string): SharedRuntimeDescriptor[] {
2426
- const endpoints = codexGenerationEndpoints(runtimeDir)
2427
- return (endpoints.length ? endpoints : [legacyCodexGenerationEndpoint(runtimeDir)])
2428
- .map((endpoint) => codexRuntimeDescriptor(endpoint, runtimeDir))
2429
- }
2430
-
2431
- export const codexHarness: Harness = {
2432
- id: 'codex',
2433
- dispatchId: 'codex',
2434
- headless: false,
2435
- sharedRuntimeSpawn: true,
2436
- events: CODEX_EVENTS,
2437
- ownsRendezvous: false, // no reclaude daemon — liveness + prompts through the project app-server socket
2438
- paneTitleIsSelfSummary: false, // codex's pane title is a spinner + the cwd folder name, NOT a task summary → headline uses the prompt
2439
- executionTrace: readCodexExecutionTrace,
2440
- launchCmd: (id, runtimeDir, cmd) => codexLaunchCommand(id, codexBaseCmd(cmd), undefined, runtimeDir ?? runtimeRoot()), // the full app-server+TUI script BUILT AROUND the resolved base command; ONE app-server per PROJECT
2441
- baseCmd: codexBaseCmd,
2442
- sessionIdArg: () => '', // codex assigns its own id (the backend owns it via thread/start)
2443
- sessionEnvVar: 'CODEX_THREAD_ID',
2444
- launchEnv: noLaunchEnv,
2445
- // Codex discovers a LINKED worktree's PROJECT hooks from the ROOT CHECKOUT's `.codex`, NOT the worktree's
2446
- // (codex-rs `root_checkout_hooks_folder_for_dir` rewrites the hooks-config folder to <repo_root>/<rel>/.codex
2447
- // for any linked worktree). Every worktree's thread (cwd = worktree root) therefore reads the SAME
2448
- // <mainCheckout>/.codex/hooks.json — so the codex hooks shim + its trust materialize at the MAIN checkout
2449
- // (one per project, mirroring the per-project runtime tier), while the AGENTS.md contract + skills stay
2450
- // per-worktree (codex loads THOSE by walking the thread cwd). dispatch.sh resolves `proj` from the thread
2451
- // cwd, so one shared shim serves every worktree.
2452
- shimFile: (proj) => join(mainCheckout(proj), '.codex', 'hooks.json'),
2453
- shimScope: 'project',
2454
- // a LINKED worktree also needs its OWN `.codex/hooks.json` so codex-rs anchors the project config layer for
2455
- // the worktree cwd (without a `.codex/` under the worktree root, codex builds no layer, so the rewritten
2456
- // root-checkout hooks are never discovered and NO hooks fire — bypass_hook_trust cannot rescue a layer that
2457
- // was never built). Its content is ignored (the rewrite reads the root's shim above), so it is a pure anchor.
2458
- // Only for a genuine worktree: on the main checkout, shimFile already wrote `.codex/hooks.json` there.
2459
- worktreeHookAnchor: (proj) => (mainCheckout(proj) === proj ? null : join(proj, '.codex', 'hooks.json')),
2460
- contractFiles: (proj) => [join(proj, 'AGENTS.md')],
2461
- skillDir: (proj) => join(proj, '.codex', 'skills'),
2462
- agentDir: () => null, // codex has no file-discovered agent-definition primitive — materialize skips it
2463
- shim: (dispatch, spex) => buildShim('codex', CODEX_EVENTS, dispatch, spex),
2464
- // Write the FULL codex trust — BOTH tiers, UNCONDITIONALLY — because `bypass_hook_trust` covers neither on
2465
- // the dispatched-worker path:
2466
- // (1) PROJECT trust (`[projects."<mainCheckout>"] trust_level = "trusted"`) ENABLES the project config
2467
- // layer — the precondition for codex to DISCOVER our hooks AT ALL. codex-rs `get_layers` drops a
2468
- // disabled (untrusted) project layer BEFORE hook discovery runs, and bypass_hook_trust is read only
2469
- // AFTER, per-handler — so it can NEVER enable a layer. A dispatched worker's app-server does NOT
2470
- // auto-trust the project (only the interactive TUI / `codex exec` approval flow does), so without this
2471
- // an untrusted worktree thread fires ZERO hooks ("Project-local config, hooks … are disabled until the
2472
- // project is trusted").
2473
- // (2) per-HOOK trust (the reverse-engineered `trusted_hash` blocks — codexHookHash) marks each hook Trusted
2474
- // so it is NOT "new or changed". This is REQUIRED even though the launch carries
2475
- // `--dangerously-bypass-hook-trust`: our visible TUI attaches to the backend-owned thread via `codex …
2476
- // resume <tid>`, and codex-rs FORCES the startup hook-review prompt on a PERSISTENT RESUME regardless of
2477
- // the flag (`bypass_hook_trust_for_startup_review = config.bypass_hook_trust && !is_persistent_resume`,
2478
- // tui/src/lib.rs) — an untrusted/modified hook (no matching hash) leaves the worker WEDGED at an
2479
- // interactive "Hooks need review" menu. Matching hashes make review_needed_count == 0, so codex skips
2480
- // the prompt and the worker runs unattended. bypass_hook_trust stays on `thread/start` + the resume flag
2481
- // as DEFENCE for the non-resume paths (and if a version bump makes a hash mismatch, the app-server
2482
- // thread still runs the hooks); it does not REPLACE the hashes here.
2483
- writeTrust: (proj, cmdFor) => [writeCodexTrust(mainCheckout(proj), CODEX_EVENTS, cmdFor)],
2484
- // trust is keyed by the MAIN checkout (where the codex shim materializes) — strip it at the same key.
2485
- removeTrust: (proj) => removeCodexTrust(mainCheckout(proj)),
2486
- clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject) },
2487
- slashCommands: codexSlashCommands,
2488
- // online iff the tmux window is up AND the agent is live. PRIMARY: the launch-registered `agent.pid` hot-tier
2489
- // verdict (`pidAlive`) — a 100ms syscall (kill-0), no ps scan. LEGACY: a pre-registration session has no
2490
- // agent.pid (`pidAlive` undefined) → fall back to the whole-box ps DESCENDANT-tree walk (paneTreeRunsCodex):
2491
- // a codex-ish process live below the pane pid, NOT the pane's foreground command (that is `bash`, the launch
2492
- // wrapper, even while the TUI renders — the field-confirmed false-OFFLINE) and NOT the app-server socket
2493
- // (SHARED per-project, it survives a failed `--remote resume` — the earlier false-ONLINE). The legacy path
2494
- // self-extinguishes as pre-registration sessions close.
2495
- liveness: (_rec, tmuxAlive, _runtimeDir, pane) => {
2496
- if (!tmuxAlive) return 'offline'
2497
- if (pane?.pidAlive !== undefined) return pane.pidAlive ? 'online' : 'offline'
2498
- return paneTreeRunsCodex(pane) ? 'online' : 'offline'
2499
- },
2500
- leafOwnerNeedle: (rec) => rec.harnessSessionId ?? null,
2501
- deliver: (rec, text) => deliverViaCodexAppServer(rec, text),
2502
- observeTurnFailures: codexTurnFailureObserver,
2503
- interrupt: interruptCodexTurn,
2504
- cleanupRuntime: async () => { /* project-scoped app-server is shared; no per-session transport to remove */ },
2505
- targetDescriptorKey: (rec) => {
2506
- const endpoint = codexEndpointForRecord(rec)
2507
- return endpoint ? codexDescriptorKey(endpoint) : null
2508
- },
2509
- coldRetirementPreflight: async (rec) => {
2510
- if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
2511
- const threadId = rec.harnessSessionId
2512
- const dir = runtimeRoot()
2513
- const endpoint = codexEndpointForRecord(rec, dir)
2514
- if (!endpoint) return { ok: false, reason: 'no exact Codex generation binding is registered for this target' }
2515
- const generationBefore = codexRuntimeGeneration(dir, endpoint)
2516
- if (!generationBefore) return { ok: false, reason: 'Codex shared app-server generation is unproven' }
2517
- const result = await codexColdPreflight(threadId, dir, generationBefore, endpoint)
2518
- if (codexRuntimeGeneration(dir, endpoint) !== generationBefore)
2519
- return { ok: false, reason: 'shared Codex app-server generation changed during cold retirement guard' }
2520
- if (!result.ok) return result
2521
- if (!result.alreadyCold)
2522
- return { ok: false, reason: `Codex target subtree ${result.receipt.activeIds.join(', ')} is not fully archived` }
2523
- return { ok: true, alreadyCold: true }
2524
- },
2525
- coldPreflight: async (rec) => {
2526
- if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
2527
- const endpoint = codexEndpointForRecord(rec)
2528
- return endpoint ? codexColdPreflight(rec.harnessSessionId, runtimeRoot(), undefined, endpoint)
2529
- : { ok: false, reason: 'no exact Codex generation binding is registered for this target' }
2530
- },
2531
- coldRuntime: async (rec, suppliedReceipt) => {
2532
- if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
2533
- const threadId = rec.harnessSessionId
2534
- const dir = runtimeRoot()
2535
- const endpoint = codexEndpointForRecord(rec, dir)
2536
- if (!endpoint) return { ok: false, reason: 'no exact Codex generation binding is registered for this target' }
2537
- const sock = endpoint.socketPath
2538
- const generationBefore = codexRuntimeGeneration(dir, endpoint)
2539
- if (!generationBefore) return { ok: false, reason: 'Codex shared app-server generation is unproven' }
2540
- if (suppliedReceipt !== undefined && (!isCodexColdPlan(suppliedReceipt) || suppliedReceipt.threadId !== threadId))
2541
- return { ok: false, reason: 'Codex cold teardown receipt is missing, malformed, or names a different target' }
2542
- const frozenPlan = isCodexColdPlan(suppliedReceipt) ? suppliedReceipt : null
2543
- if (frozenPlan && (frozenPlan.generation !== generationBefore || frozenPlan.endpoint.id !== endpoint.id))
2544
- return { ok: false, reason: 'shared Codex app-server generation changed after archive preflight' }
2545
- const preflight = await codexColdPreflight(threadId, dir, frozenPlan?.generation ?? generationBefore, endpoint)
2546
- if (!preflight.ok) return preflight
2547
- const plan = frozenPlan ?? preflight.receipt
2548
- if (frozenPlan && (!sameIdSet(frozenPlan.descendantIds, preflight.receipt.descendantIds) ||
2549
- !sameParentEdges(frozenPlan.parentEdges, preflight.receipt.parentEdges) ||
2550
- !sameIdSet(frozenPlan.activeIds, preflight.receipt.activeIds) ||
2551
- !sameIdSet(frozenPlan.archivedIds, preflight.receipt.archivedIds)))
2552
- return { ok: false, reason: 'Codex target subtree ownership or collection assignment changed after archive preflight' }
2553
- if (codexRuntimeGeneration(dir, endpoint) !== plan.generation)
2554
- return { ok: false, reason: 'shared Codex app-server generation changed during target subtree guard' }
2555
- if (plan.activeIds.length === 0) return { ok: true }
2556
- const subtreeSet = new Set(plan.subtreeIds)
2557
- const siblingBefore = plan.guard.referenceIds.filter((referenceId) => !subtreeSet.has(referenceId))
2558
- const fence = { dir, endpoint, generation: plan.generation }
2559
-
2560
- const compensate = async (reason: string): Promise<{ ok: false; reason: string }> => {
2561
- const restored = await codexRestoreColdPlan(plan, dir)
2562
- return { ok: false, reason: restored.ok ? reason : `${reason}; ${restored.reason}` }
2563
- }
2564
-
2565
- const coldCheck = async (): Promise<{ ok: true } | { ok: false; reason: string }> => {
2566
- const after = await codexColdPreflight(threadId, dir, plan.generation, endpoint)
2567
- if (!after.ok) return after
2568
- if (codexRuntimeGeneration(dir, endpoint) !== plan.generation) return { ok: false, reason: 'shared Codex app-server generation changed during archive' }
2569
- if (!sameIdSet(plan.descendantIds, after.receipt.descendantIds) || !sameParentEdges(plan.parentEdges, after.receipt.parentEdges))
2570
- return { ok: false, reason: `Codex target descendant closure changed during archive (before=${plan.descendantIds.join(', ')}; after=${after.receipt.descendantIds.join(', ')})` }
2571
- if (after.receipt.activeIds.length)
2572
- return { ok: false, reason: `Codex target subtree remains in the active collection (${after.receipt.activeIds.join(', ')})` }
2573
- if (!sameIdSet(plan.subtreeIds, after.receipt.archivedIds))
2574
- return { ok: false, reason: 'Codex target subtree is not uniquely archived after cold teardown' }
2575
- const afterIds = new Set(after.receipt.guard.referenceIds.filter((referenceId) => !subtreeSet.has(referenceId)))
2576
- if (siblingBefore.some((referenceId) => !afterIds.has(referenceId))) return { ok: false, reason: 'a pre-existing shared Codex sibling reference disappeared during archive' }
2577
- return { ok: true }
2578
- }
2579
- const loadedSet = new Set(plan.guard.referenceIds)
2580
- for (const id of plan.activeIds) {
2581
- // Only a loaded member pays the rollout flush, and an unreadable size must not become a small budget,
2582
- // so it fails closed before the server is asked to mutate anything.
2583
- let budgetMs = CODEX_MUTATION_BASE_MS
2584
- if (loadedSet.has(id)) {
2585
- const rollout = codexRolloutBytes(id)
2586
- if ('unreadable' in rollout) return compensate(`Codex subtree member ${id} is loaded and its rollout exists but cannot be measured, so the archive flush budget is unknown`)
2587
- budgetMs = codexArchiveBudgetMs(rollout.bytes)
2588
- }
2589
- const archived = await codexThreadMutation(sock, 'thread/archive', id, fence, undefined, budgetMs)
2590
- if (archived.ok) continue
2591
- const reason = `${archived.error} while archiving Codex subtree member ${id}`
2592
- // Compensating an unknown commit is what turns one slow member into a false "compensation failed": the
2593
- // unarchive queues behind an archive the server is still executing and times out too. Report the unknown
2594
- // commit instead — that is the recovery token resume already reconciles.
2595
- if (archived.commit === 'unknown') return { ok: false, reason: `${reason}; commit state is unknown and no compensation was attempted` }
2596
- return compensate(reason)
2597
- }
2598
- let verified: { ok: true } | { ok: false; reason: string } = { ok: false, reason: 'Codex archive verification timed out' }
2599
- const verifyDeadline = Date.now() + 30_000
2600
- for (let attempt = 0; attempt < 6 && Date.now() < verifyDeadline; attempt++) {
2601
- verified = await coldCheck()
2602
- if (verified.ok) break
2603
- if (Date.now() < verifyDeadline) await new Promise((resolve) => setTimeout(resolve, 100))
2604
- }
2605
- if (verified.ok) return verified
2606
- return compensate(verified.reason)
2607
- },
2608
- quarantineOrphanThread: codexQuarantineOrphanThread,
2609
- restoreRuntime: async (rec, suppliedReceipt) => {
2610
- if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
2611
- if (suppliedReceipt !== undefined) {
2612
- if (!isCodexColdPlan(suppliedReceipt) || suppliedReceipt.threadId !== rec.harnessSessionId)
2613
- return { ok: false, reason: 'Codex cold compensation receipt is invalid or names a different target' }
2614
- return codexRestoreColdPlan(suppliedReceipt)
2615
- }
2616
- const endpoint = codexEndpointForRecord(rec)
2617
- if (!endpoint) return { ok: false, reason: 'no exact Codex generation binding is registered for this target' }
2618
- const sock = endpoint.socketPath
2619
- const reconcile = async (): Promise<{ ok: true } | { ok: false; reason: string }> => {
2620
- const [active, archived] = await Promise.all([
2621
- codexThreadList(sock, { archived: false, sourceKinds: [] }),
2622
- codexThreadList(sock, { archived: true, sourceKinds: [] }),
2623
- ])
2624
- if (!active.ok || !archived.ok) return { ok: false, reason: 'Codex restore state could not be reconciled' }
2625
- const inActive = active.ids.includes(rec.harnessSessionId!)
2626
- const inArchived = archived.ids.includes(rec.harnessSessionId!)
2627
- if (inActive && !inArchived) return { ok: true }
2628
- if (inArchived && !inActive) return { ok: false, reason: 'Codex thread remains archived; restore can be retried' }
2629
- return { ok: false, reason: 'Codex restore state is ambiguous (thread in both or neither collection)' }
2630
- }
2631
- const restored = await codexThreadMutation(sock, 'thread/unarchive', rec.harnessSessionId)
2632
- if (!restored.ok) return reconcile()
2633
- return reconcile()
2634
- },
2635
- sharedRuntimes: codexRuntimeDescriptors,
2636
- // owned thread id → `--resume <id>` MARKER the codex launch script reads to resume that thread DIRECTLY (NOT
2637
- // a tail handed to a bare `codex` — the script's final `codex … resume "$tid"` performs codex's own resume on
2638
- // the owned id, the SAME conversation); none → empty tail → relaunch a FRESH thread on the same worktree/record.
2639
- resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : ''),
2640
- // codex's own settled failure: a thread id whose rollout is not on disk can never be resumed, so the launch
2641
- // that says so has already decided. (Its transient sibling — the rollout still being written — is handled
2642
- // BEFORE launch by waitForCodexRollout, so what reaches here is the permanent case.)
2643
- fatalLaunchOutput: ['no rollout found for thread id'],
2644
- }
2645
-
2646
- type CodexHeadlessLaunchReadinessProof = Readonly<{
2647
- kind: 'codex-headless-shared-runtime'
2648
- descriptorKey: string
2649
- generation: CodexRuntimeGenerationProof
2650
- target: Readonly<{
2651
- sessionId: string
2652
- threadId: string
2653
- ownerSessionId: string
2654
- ownerCount: 1
2655
- ownerState: 'governed'
2656
- referenceState: 'loaded'
2657
- protectsControlPlane: true
2658
- }>
2659
- }>
2660
-
2661
- const sameCodexHeadlessReadinessProof = (left: CodexHeadlessLaunchReadinessProof, right: CodexHeadlessLaunchReadinessProof) =>
2662
- left.kind === right.kind &&
2663
- left.descriptorKey === right.descriptorKey &&
2664
- codexRuntimeGenerationToken(left.generation) === codexRuntimeGenerationToken(right.generation) &&
2665
- left.target.sessionId === right.target.sessionId &&
2666
- left.target.threadId === right.target.threadId &&
2667
- left.target.ownerSessionId === right.target.ownerSessionId &&
2668
- left.target.ownerCount === right.target.ownerCount &&
2669
- left.target.ownerState === right.target.ownerState &&
2670
- left.target.referenceState === right.target.referenceState &&
2671
- left.target.protectsControlPlane === right.target.protectsControlPlane
2672
-
2673
- const governedSharedRuntimeOwners = (runtimeDir: string, descriptorKey: string, threadId: string, excludingSessionId?: string): string[] | null => {
2674
- const root = join(runtimeDir, 'sessions')
2675
- let entries
2676
- try { entries = readdirSync(root, { withFileTypes: true }) }
2677
- catch (error) { return (error as NodeJS.ErrnoException).code === 'ENOENT' ? [] : null }
2678
- const owners: string[] = []
2679
- for (const entry of entries) {
2680
- if (!entry.isDirectory()) continue
2681
- if (entry.name === excludingSessionId) continue
2682
- let parsed: unknown
2683
- try { parsed = JSON.parse(readFileSync(join(root, entry.name, 'session.json'), 'utf8')) }
2684
- catch (error) {
2685
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
2686
- return null
2687
- }
2688
- if (!parsed || typeof parsed !== 'object') return null
2689
- const record = parsed as { session_id?: unknown; governed?: unknown; harness?: unknown; harness_session_id?: unknown }
2690
- if (typeof record.session_id !== 'string') return null
2691
- if (record.governed !== true) continue
2692
- const harnessId = typeof record.harness === 'string' && record.harness ? record.harness : defaultHarness.id
2693
- let sharesDescriptor = false
2694
- try { sharesDescriptor = (harnessById(harnessId).sharedRuntimes?.(runtimeDir) ?? []).some((descriptor) => descriptor.key === descriptorKey) }
2695
- catch { return null }
2696
- if (sharesDescriptor && record.harness_session_id === threadId) owners.push(record.session_id)
2697
- }
2698
- return owners
2699
- }
2700
-
2701
- async function codexHeadlessReadinessProof(current: () => HarnessLaunchReadyRecord | null): Promise<CodexHeadlessLaunchReadinessProof | null> {
2702
- const record = current()
2703
- if (!record?.governed || record.stopped || record.archived || !record.harnessSessionId) return null
2704
- const endpoint = codexEndpointForRecord(record, record.runtimeDir)
2705
- if (!endpoint) return null
2706
- const descriptor = codexHeadlessHarness.sharedRuntimes?.(record.runtimeDir)
2707
- .find((candidate) => candidate.key === codexDescriptorKey(endpoint))
2708
- if (!descriptor?.residency) return null
2709
- const generationBefore = codexRuntimeGenerationProof(record.runtimeDir, endpoint)
2710
- if (!generationBefore) return null
2711
- let resident: Awaited<ReturnType<NonNullable<SharedRuntimeDescriptor['residency']>>>
2712
- try { resident = await descriptor.residency() }
2713
- catch { return null }
2714
- if (!resident.healthy) return null
2715
- if (!resident.referenceIds.includes(record.harnessSessionId)) return null
2716
- const owners = governedSharedRuntimeOwners(record.runtimeDir, descriptor.key, record.harnessSessionId)
2717
- if (!owners || owners.length !== 1 || owners[0] !== record.session) return null
2718
- const generationAfter = codexRuntimeGenerationProof(record.runtimeDir, endpoint)
2719
- if (!generationAfter || codexRuntimeGenerationToken(generationBefore) !== codexRuntimeGenerationToken(generationAfter)) return null
2720
- return Object.freeze({
2721
- kind: 'codex-headless-shared-runtime',
2722
- descriptorKey: descriptor.key,
2723
- generation: generationAfter,
2724
- target: Object.freeze({
2725
- sessionId: record.session,
2726
- threadId: record.harnessSessionId,
2727
- ownerSessionId: owners[0],
2728
- ownerCount: 1,
2729
- ownerState: 'governed',
2730
- referenceState: 'loaded',
2731
- protectsControlPlane: true,
2732
- }),
2733
- })
2734
- }
2735
-
2736
- // Codex headless is an independent adapter: its materialization and app-server delivery are exactly Codex's,
2737
- // while launch only runs the backend-owned thread/start + first turn. There is no TUI to attach after that turn;
2738
- // the shared project app-server keeps the thread addressable and idle sends use the inherited JSON-RPC channel.
2739
- export const codexHeadlessHarness: Harness = {
2740
- ...codexHarness,
2741
- id: 'codex-headless',
2742
- headless: true,
2743
- runtimeOwnership: 'adapter',
2744
- launchOneShot: true,
2745
- launchCmd: (id, runtimeDir, cmd) => codexHeadlessLaunchCommand(id, codexBaseCmd(cmd), undefined, runtimeDir ?? runtimeRoot()),
2746
- // Record-backed liveness is the family contract for sleeping headless threads. An explicit stop is the one
2747
- // offline marker; other app-server/thread failures surface through delivery rather than speculative liveness.
2748
- liveness: recordOnline,
2749
- launchReady: async (current, deadline) => {
2750
- for (;;) {
2751
- const proof = await codexHeadlessReadinessProof(current)
2752
- if (proof) return {
2753
- proof,
2754
- validate: async (latest) => {
2755
- const currentProof = await codexHeadlessReadinessProof(latest)
2756
- return !!currentProof && sameCodexHeadlessReadinessProof(proof, currentProof)
2757
- },
2758
- }
2759
- const remaining = deadline - Date.now()
2760
- if (remaining <= 0) return null
2761
- await new Promise((resolve) => setTimeout(resolve, Math.min(200, remaining)))
2762
- }
2763
- },
2764
- // There is no TUI to restart and the project app-server keeps the thread addressable. A forced reopen therefore
2765
- // runs the headless launch's empty-tail no-op; normal resume remains guarded by record-backed online liveness.
2766
- resumeArg: () => '',
2767
- }
2768
-
2769
- // @@@ piHarness - the pi adapter (@earendil-works/pi-coding-agent). pi is the CLOSEST to claude of the four:
2770
- // the caller pins the session id at launch (`--session-id <id>`, creating the session if missing), the shim
2771
- // lives IN the worktree, and the rendezvous prompt/liveness channel is REUSED wholesale — pi has no external
2772
- // hook binding (its lifecycle surface is the in-process extension API), so the shim is a GENERATED TypeScript
2773
- // extension (.pi/extensions/spexcode.ts, run natively by pi) that forwards five claude-shaped events to
2774
- // dispatch.sh AND binds this session's rendezvous socket itself (the adapter's launchEnv exports
2775
- // CLAUDE_BG_RENDEZVOUS_SOCK) speaking the reclaude line protocol — so
2776
- // deliverViaRendezvous and the socket-listener liveness work through the same adapter seam. Trust: pi gates project-local
2777
- // extensions behind saved per-directory trust (~/.pi/agent/trust.json), so writeTrust stamps the main
2778
- // checkout there (the nearest-parent lookup covers nested worktrees) and the launch carries `--approve` as
2779
- // one-run defence. See pi-harness.ts for the extension source + trust mechanics.
2780
- export const piHarness: Harness = {
2781
- id: 'pi',
2782
- dispatchId: 'pi',
2783
- headless: false,
2784
- events: PI_EVENTS,
2785
- ownsRendezvous: true, // the generated extension binds rvSock(id) and speaks the reclaude protocol
2786
- paneTitleIsSelfSummary: false, // pi's pane title is not an agent-written task summary → headline uses the prompt preview
2787
- executionTrace: readSessionJsonlExecutionTrace,
2788
- launchCmd: (_id, _rt, cmd) => `${piBaseCmd(cmd)} --approve`, // --approve = one-run project trust (belt to writeTrust's braces)
2789
- baseCmd: piBaseCmd,
2790
- sessionIdArg: (id) => `--session-id ${id}`, // caller pins the exact session id, claude-style (created if missing)
2791
- sessionEnvVar: 'PI_SESSION_ID', // exported by the generated extension at session_start; tool subprocesses inherit it
2792
- launchEnv: rendezvousLaunchEnv,
2793
- shimFile: (proj) => join(proj, '.pi', 'extensions', 'spexcode.ts'),
2794
- shimScope: 'tree',
2795
- worktreeHookAnchor: () => null, // the extension lives in the worktree and self-anchors, like claude
2796
- contractFiles: (proj) => [join(proj, 'AGENTS.md')], // pi auto-loads AGENTS.md context files (shared with codex — writeManagedBlock is idempotent)
2797
- skillDir: (proj) => join(proj, '.pi', 'skills'), // Agent Skills standard dirs, discovered after project trust
2798
- agentDir: () => null, // pi has no file-discovered sub-agent primitive — materialize skips it
2799
- shim: (dispatch, spex) => ({
2800
- content: piExtensionSource(dispatch, spex),
2801
- cmd: (e: string) => `SPEX='${spex}' bash ${dispatch} pi ${e}`, // what the extension actually spawns, for parity with buildShim
2802
- }),
2803
- writeTrust: (proj) => [writePiTrust(mainCheckout(proj))], // trust keys on the MAIN checkout; nearest-parent lookup covers worktrees
2804
- removeTrust: (proj) => removePiTrust(mainCheckout(proj)),
2805
- clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject) },
2806
- slashCommands: piSlashCommands,
2807
- // claude's exact liveness: the window is up AND a live LISTENER answers on the rendezvous socket — the
2808
- // socket the generated extension binds. socketLive is already probed for every windowed session.
2809
- liveness: socketListenerLiveness,
2810
- leafOwnerNeedle: (rec) => rec.session,
2811
- deliver: (rec, text) => deliverViaRendezvous(rec.session, text, rec.mid),
2812
- cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
2813
- coldRuntime: async () => ({ ok: true }),
2814
- // reopen the SAME conversation: `--session <id>` resumes the exact session we pinned at launch and FAILS
2815
- // LOUD when its file is gone (unlike `--session-id`, which would silently mint a fresh empty session).
2816
- resumeArg: (rec) => `--session ${rec.session}`,
2817
- }
2818
-
2819
- // pi-headless is an independent harness: its materialization surface is literally pi's, while a resident
2820
- // controller owns non-interactive text-mode turns. Active turns steer through pi's rendezvous extension;
2821
- // idle delivery cold-wakes the exact saved session with `--session` (never `--session-id`, which would create a
2822
- // new conversation). The controller deliberately reports record-backed liveness, matching Claude headless.
2823
- export const piHeadlessHarness: Harness = {
2824
- ...piHarness,
2825
- id: 'pi-headless',
2826
- headless: true,
2827
- runtimeOwnership: 'adapter',
2828
- paneTitleIsSelfSummary: false,
2829
- launchCmd: (id, runtimeDir, cmd) => piHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), piBaseCmd(cmd)),
2830
- liveness: recordOnline,
2831
- deliver: deliverViaPiHeadless,
2832
- cleanupRuntime: (rec) => unlinkSocks(piHeadlessSock(rec.session), rvSock(rec.session)),
2833
- coldRuntime: async () => ({ ok: false, reason: 'pi-headless has no exact resident unload verification' }),
2834
- deliveryBlockedBy: undefined,
2835
- resumeArg: (rec) => `--session ${rec.session}`,
2836
- }
2837
-
2838
- const ZCODE_CONTROL_UNAVAILABLE = 'zcode has no control channel; start a new session instead of delivering to an existing one'
2839
-
2840
- // z-code's app-server is stdin/stdout NDJSON, unlike Codex's Unix-socket WebSocket + thread RPC. This row
2841
- // intentionally covers the one-turn `--prompt` launcher and Claude-compatible hooks only; control operations
2842
- // refuse rather than pretending the incompatible protocol accepted them.
2843
- export const zcodeHarness: Harness = {
2844
- id: 'zcode',
2845
- dispatchId: 'zcode',
2846
- headless: true,
2847
- launchOneShot: true,
2848
- events: ZCODE_EVENTS,
2849
- ownsRendezvous: false,
2850
- paneTitleIsSelfSummary: false,
2851
- executionTrace: noExecutionTrace,
2852
- launchCmd: (_id, _rt, cmd) => `${zcodeBaseCmd(cmd)} --prompt`,
2853
- baseCmd: zcodeBaseCmd,
2854
- sessionIdArg: () => '',
2855
- sessionEnvVar: 'ZCODE_SESSION_ID',
2856
- launchEnv: noLaunchEnv,
2857
- shimFile: (proj) => join(proj, '.zcode', 'settings.json'),
2858
- shimScope: 'tree',
2859
- worktreeHookAnchor: () => null,
2860
- contractFiles: (proj) => [join(proj, 'AGENTS.md')],
2861
- skillDir: (proj) => join(proj, '.zcode', 'skills'),
2862
- agentDir: (proj) => join(proj, '.zcode', 'agents'),
2863
- shim: (dispatch, spex) => buildShim('zcode', ZCODE_EVENTS, dispatch, spex),
2864
- writeTrust: () => [],
2865
- removeTrust: () => { /* z-code wrote no trust artifact */ },
2866
- clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject) },
2867
- slashCommands: () => [],
2868
- liveness: panePidLiveness,
2869
- leafOwnerNeedle: (rec) => rec.session,
2870
- deliver: async () => { throw new Error(ZCODE_CONTROL_UNAVAILABLE) },
2871
- cleanupRuntime: async () => { /* one-shot z-code owns no SpexCode transport to remove */ },
2872
- coldRuntime: async () => ({ ok: true }),
2873
- resumeArg: () => { throw new Error(ZCODE_CONTROL_UNAVAILABLE) },
2874
- }
2875
-
2876
- export const opencodeHarness: Harness = {
2877
- id: 'opencode',
2878
- dispatchId: 'opencode',
2879
- headless: false,
2880
- events: OPENCODE_EVENTS,
2881
- // LITERALLY true: the generated plugin ([[opencode-harness]], opencode.ts) BINDS the per-session rendezvous
2882
- // socket the launch env hands it, so the shared reply poke and socket-listener liveness are reused verbatim.
2883
- ownsRendezvous: true,
2884
- paneTitleIsSelfSummary: false, // opencode's TUI title is not the agent's live task self-summary → headline uses the prompt
2885
- executionTrace: readLocalStoreExecutionTrace,
2886
- launchCmd: (_id, _rt, cmd) => opencodeLaunchCommand(opencodeBaseCmd(cmd)), // the tail-branching script (prompt vs --resume/--continue marker)
2887
- baseCmd: opencodeBaseCmd,
2888
- sessionIdArg: () => '', // opencode mints its own session id; the plugin's first event reports it back (opencode-capture)
2889
- // opencode exports NO per-session env var to its tool subprocesses (probed, 1.18.3). Identity flows through
2890
- // the launch-injected SPEXCODE_SESSION_ID — honest here because each opencode TUI is a per-session process
2891
- // (no codex-style shared-server contamination). This var is therefore never set; envSessionId's
2892
- // SPEXCODE_SESSION_ID tier resolves the record.
2893
- sessionEnvVar: 'OPENCODE_SESSION_ID',
2894
- launchEnv: rendezvousLaunchEnv,
2895
- // the "shim" is a generated opencode PLUGIN in the worktree's own tree — opencode auto-loads project plugins
2896
- // by walking the cwd, so like claude it self-anchors and needs no root-checkout rewrite or worktree anchor.
2897
- shimFile: (proj) => join(proj, '.opencode', 'plugins', 'spexcode.ts'),
2898
- shimScope: 'tree',
2899
- worktreeHookAnchor: () => null,
2900
- contractFiles: (proj) => [join(proj, 'AGENTS.md')], // opencode reads AGENTS.md natively (same file codex owns; the managed block is idempotent across writers)
2901
- skillDir: (proj) => join(proj, '.opencode', 'skills'),
2902
- agentDir: (proj) => join(proj, '.opencode', 'agents'),
2903
- // content = the plugin source; cmd = the SAME per-event command the plugin bakes into dispatch calls, so
2904
- // any consumer that hashes/inspects commands sees one truth (trust is a no-op here regardless).
2905
- shim: (dispatch, spex) => ({ content: opencodePluginSource(dispatch, spex), cmd: (e) => `SPEX='${spex}' bash ${dispatch} opencode ${e}` }),
2906
- writeTrust: () => [], // permission policy stays with the launcher command; no trust artifact to report
2907
- removeTrust: () => { /* nothing was written */ },
2908
- clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject) },
2909
- slashCommands: opencodeSlashCommands,
2910
- // online iff the window is up AND the agent answers on a channel: PREFER the rendezvous socket listener
2911
- // (the plugin is alive), FALL BACK to the launch-registered agent.pid (kill-0) so a plugin that failed to
2912
- // load still reads honestly from the process signal instead of a false offline.
2913
- liveness: socketListenerOrPidAliveLiveness,
2914
- leafOwnerNeedle: (rec) => rec.session,
2915
- deliver: (rec, text) => deliverViaRendezvous(rec.session, text, rec.mid),
2916
- cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
2917
- coldRuntime: async () => ({ ok: true }),
2918
- // owned opencode session id → `--resume <id>` marker (the launch script re-attaches `--session <id>`, the
2919
- // SAME conversation); never captured → `--continue` marker (opencode's own "last session in this directory",
2920
- // which in a dedicated worktree is this worker's). The discriminator is sound for the same reason codex's
2921
- // is: a NEW launch's tail is always ONE single-quoted prompt arg, never a literal marker.
2922
- resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : '--continue'),
2923
- }
2924
-
2925
- // OpenCode headless is a separate harness, not an opencode mode. Its materialize half is exactly
2926
- // opencodeHarness; only the one-turn runtime and its capability row differ.
2927
- export const opencodeHeadlessHarness: Harness = {
2928
- ...opencodeHarness,
2929
- id: 'opencode-headless',
2930
- headless: true,
2931
- runtimeOwnership: 'adapter',
2932
- launchCmd: (_id, _runtimeDir, cmd) => opencodeHeadlessLaunchCommand(opencodeBaseCmd(cmd)),
2933
- // A sleeping native conversation is still addressable by its non-stopped record. Transport breakage belongs
2934
- // to the next delivery, where the live rendezvous or pane wake reports it loudly.
2935
- liveness: recordOnline,
2936
- coldRuntime: async () => ({ ok: false, reason: 'opencode-headless has no exact resident unload verification' }),
2937
- deliver: async (rec, text) => {
2938
- return deliverViaSocketOrWake(
2939
- rec.session,
2940
- text,
2941
- rec.mid,
2942
- () => spawnOpenCodeHeadlessTurn(rec, text, opencodeBaseCmd(rec.launchCmd ?? undefined), rvSock(rec.session)),
2943
- `opencode-headless rendezvous probe was inconclusive for session ${rec.session} - refusing to start a possibly duplicate turn`,
2944
- )
2945
- },
2946
- }
2947
-
2948
- // every adapter — materialize iterates this to write each harness's artifacts in one pass.
2949
- export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness, opencodeHarness, piHarness, zcodeHarness, claudeHeadlessHarness, opencodeHeadlessHarness, piHeadlessHarness, codexHeadlessHarness]
2950
-
2951
- // the legacy/default adapter for old records and config defaults. New launches derive harness from a launcher.
2952
- export const defaultHarness: Harness = claudeHarness
2953
-
2954
- // the registry lookup as DATA. A sweep over records nobody is currently asking about (a removed plugin, a
2955
- // renamed id) must report an unresolvable harness rather than abort, so it resolves through this.
2956
- export function harnessByIdOrNull(id: string): Harness | null {
2957
- return HARNESSES.find((x) => x.id === id) ?? null
2958
- }
2959
-
2960
- // resolve an adapter by id (the detector). Throws on an unknown id — fail loud, never silently default.
2961
- export function harnessById(id: string): Harness {
2962
- const h = harnessByIdOrNull(id)
2963
- if (!h) throw new Error(`unknown harness '${id}' (known: ${HARNESSES.map((x) => x.id).join(', ')})`)
2964
- return h
2965
- }
2966
-
2967
- // --- named launcher profiles ([[launcher-select]]) ----------------------------------------------------------
2968
- // a launcher = a `{ harness, cmd }` entry in spexcode.json's `sessions.launchers`, keyed by a
2969
- // human-chosen name. `claude` and `codex` are NOT special built-ins — `spex init` SEEDS them as ordinary named
2970
- // launchers (with the regular command path), so they are edited like any other. harness defaults to claude.
2971
- // resolveLauncher throws fail-loud on an unknown name (a session must never silently launch under the wrong
2972
- // auth) and validates the harness id. There is NO env-derived built-in fallback: this registry lists exactly
2973
- // the config's real launchers; dashboardLauncherList applies only the dashboard visibility projection.
2974
- export type Launcher = { name: string; harness: string; cmd: string; headless: boolean }
2975
- export type LauncherDefault = { default: string | null; error: string | null }
2976
-
2977
- // the complete configured named launchers from spexcode.json, as a stable name-sorted list (for CLI/session
2978
- // resolution and downstream projections). Picking a launcher is the ONLY launch choice; the old separate
2979
- // harness pick is gone.
2980
- export function launcherList(root = mainCheckout()): Launcher[] {
2981
- const m = readConfig(root).sessions?.launchers || {}
2982
- return Object.keys(m)
2983
- .map((name) => {
2984
- const harness = harnessById(m[name].harness || defaultHarness.id)
2985
- return { name, harness: harness.id, cmd: m[name].cmd, headless: harness.headless }
2986
- })
2987
- .sort((a, b) => a.name.localeCompare(b.name))
2988
- }
2989
-
2990
- // The dashboard's visibility projection. It never removes a launcher from the complete config/CLI path;
2991
- // it only narrows GET /api/settings for the New Session picker ([[launcher-visibility]]).
2992
- export function dashboardLauncherList(root = mainCheckout()): Launcher[] {
2993
- const showHeadless = readConfig(root).dashboard?.showHeadlessLaunchers === true
2994
- return launcherList(root).filter((launcher) => showHeadless || !launcher.headless)
2995
- }
2996
-
2997
- export const MISSING_DEFAULT_LAUNCHER_ERROR =
2998
- 'sessions.defaultLauncher is required for a launch without --launcher; set it in spexcode.json or spexcode.local.json (for example {"sessions":{"defaultLauncher":"claude"}})'
2999
-
3000
- // the configured default launcher NAME ([[launcher-select]]) — the profile `spex session new`/a dropdown pick with no
3001
- // explicit choice resolves. Missing config is a fail-loud setup error, never an implicit fallthrough to a
3002
- // `claude` launcher (which `spex init` seeds by name, so a default can point at it explicitly).
3003
- export function defaultLauncher(root = mainCheckout()): string {
3004
- const name = readConfig(root).sessions?.defaultLauncher?.trim()
3005
- if (!name) throw new Error(MISSING_DEFAULT_LAUNCHER_ERROR)
3006
- return name
3007
- }
3008
-
3009
- export function launcherDefault(root = mainCheckout()): LauncherDefault {
3010
- try {
3011
- const name = defaultLauncher(root)
3012
- resolveLauncher(name, root)
3013
- return { default: name, error: null }
3014
- } catch (e) {
3015
- return { default: null, error: String((e as Error).message || e) }
3016
- }
3017
- }
3018
-
3019
- export function resolveLauncher(name: string, root = mainCheckout()): Launcher {
3020
- const l = readConfig(root).sessions?.launchers?.[name]
3021
- if (!l) throw new Error(`unknown launcher '${name}' (configured: ${launcherList(root).map((x) => x.name).join(', ') || 'none'})`)
3022
- if (!l.cmd) throw new Error(`launcher '${name}' is missing cmd`)
3023
- const harness = harnessById(l.harness || defaultHarness.id) // validate the harness id fail-loud
3024
- return { name, harness: harness.id, cmd: l.cmd, headless: harness.headless }
3025
- }