spexcode 0.6.2 → 0.6.5

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