spexcode 0.6.7 → 0.7.0-next.0

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 (247) hide show
  1. package/README.md +12 -7
  2. package/node_modules/@spexcode/session-application/dist/index.d.ts +17 -0
  3. package/node_modules/@spexcode/session-application/dist/index.js +35 -0
  4. package/node_modules/@spexcode/session-application/dist/migration.d.ts +51 -0
  5. package/node_modules/@spexcode/session-application/dist/migration.js +694 -0
  6. package/node_modules/@spexcode/session-application/dist/production.d.ts +109 -0
  7. package/node_modules/@spexcode/session-application/dist/production.js +438 -0
  8. package/node_modules/@spexcode/session-application/dist/schema.d.ts +2 -0
  9. package/node_modules/@spexcode/session-application/dist/schema.js +39 -0
  10. package/node_modules/@spexcode/session-application/package.json +29 -0
  11. package/node_modules/@spexcode/session-events/dist/errors.d.ts +6 -0
  12. package/node_modules/@spexcode/session-events/dist/errors.js +11 -0
  13. package/node_modules/@spexcode/session-events/dist/index.d.ts +46 -0
  14. package/node_modules/@spexcode/session-events/dist/index.js +245 -0
  15. package/node_modules/@spexcode/session-events/dist/schema.d.ts +3 -0
  16. package/node_modules/@spexcode/session-events/dist/schema.js +44 -0
  17. package/node_modules/@spexcode/session-events/package.json +24 -0
  18. package/node_modules/@spexcode/session-protocol/dist/canonical.d.ts +36 -0
  19. package/node_modules/@spexcode/session-protocol/dist/canonical.js +139 -0
  20. package/node_modules/@spexcode/session-protocol/dist/engine.d.ts +22 -0
  21. package/node_modules/@spexcode/session-protocol/dist/engine.js +478 -0
  22. package/node_modules/@spexcode/session-protocol/dist/errors.d.ts +8 -0
  23. package/node_modules/@spexcode/session-protocol/dist/errors.js +39 -0
  24. package/node_modules/@spexcode/session-protocol/dist/index.d.ts +60 -0
  25. package/node_modules/@spexcode/session-protocol/dist/index.js +4 -0
  26. package/node_modules/@spexcode/session-protocol/dist/schema.d.ts +36 -0
  27. package/node_modules/@spexcode/session-protocol/dist/schema.js +192 -0
  28. package/node_modules/@spexcode/{session-core → session-protocol}/package.json +4 -8
  29. package/node_modules/@spexcode/session-runtime/dist/errors.d.ts +6 -0
  30. package/node_modules/@spexcode/session-runtime/dist/errors.js +11 -0
  31. package/node_modules/@spexcode/session-runtime/dist/index.d.ts +33 -0
  32. package/node_modules/@spexcode/session-runtime/dist/index.js +193 -0
  33. package/node_modules/@spexcode/session-runtime/dist/schema.d.ts +2 -0
  34. package/node_modules/@spexcode/session-runtime/dist/schema.js +37 -0
  35. package/node_modules/@spexcode/session-runtime/package.json +24 -0
  36. package/node_modules/@spexcode/session-selflaunch/bin/spex-session.mjs +4 -0
  37. package/node_modules/@spexcode/session-selflaunch/dist/cli.d.ts +25 -0
  38. package/node_modules/@spexcode/session-selflaunch/dist/cli.js +156 -0
  39. package/node_modules/@spexcode/session-selflaunch/dist/index.d.ts +28 -0
  40. package/node_modules/@spexcode/session-selflaunch/dist/index.js +25 -0
  41. package/node_modules/@spexcode/session-selflaunch/dist/locality.d.ts +31 -0
  42. package/node_modules/@spexcode/session-selflaunch/dist/locality.js +135 -0
  43. package/node_modules/@spexcode/session-selflaunch/dist/path.d.ts +13 -0
  44. package/node_modules/@spexcode/session-selflaunch/dist/path.js +45 -0
  45. package/node_modules/@spexcode/session-selflaunch/package.json +35 -0
  46. package/node_modules/@spexcode/session-topology/dist/errors.d.ts +6 -0
  47. package/node_modules/@spexcode/session-topology/dist/errors.js +11 -0
  48. package/node_modules/@spexcode/session-topology/dist/index.d.ts +24 -0
  49. package/node_modules/@spexcode/session-topology/dist/index.js +173 -0
  50. package/node_modules/@spexcode/session-topology/dist/schema.d.ts +3 -0
  51. package/node_modules/@spexcode/session-topology/dist/schema.js +27 -0
  52. package/node_modules/@spexcode/session-topology/package.json +24 -0
  53. package/node_modules/@spexcode/spec-cli/bin/spex.mjs +175 -21
  54. package/node_modules/@spexcode/spec-cli/dist/cli.js +121 -59
  55. package/node_modules/@spexcode/spec-cli/dist/client.d.ts +1 -3
  56. package/node_modules/@spexcode/spec-cli/dist/client.js +49 -30
  57. package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.d.ts +11 -0
  58. package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.js +46 -9
  59. package/node_modules/@spexcode/spec-cli/dist/delivery-lock.d.ts +2 -0
  60. package/node_modules/@spexcode/spec-cli/dist/delivery-lock.js +58 -0
  61. package/node_modules/@spexcode/spec-cli/dist/doctor.js +53 -11
  62. package/node_modules/@spexcode/spec-cli/dist/file-write.js +14 -2
  63. package/node_modules/@spexcode/spec-cli/dist/gateway-hub.js +2 -1
  64. package/node_modules/@spexcode/spec-cli/dist/gateway.js +6 -3
  65. package/node_modules/@spexcode/spec-cli/dist/graphCache.js +32 -2
  66. package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.js +57 -2
  67. package/node_modules/@spexcode/spec-cli/dist/graphStream.d.ts +2 -0
  68. package/node_modules/@spexcode/spec-cli/dist/graphStream.js +83 -3
  69. package/node_modules/@spexcode/spec-cli/dist/guide.js +29 -18
  70. package/node_modules/@spexcode/spec-cli/dist/harness-select.js +16 -3
  71. package/node_modules/@spexcode/spec-cli/dist/harness.d.ts +24 -6
  72. package/node_modules/@spexcode/spec-cli/dist/harness.js +510 -72
  73. package/node_modules/@spexcode/spec-cli/dist/help.js +11 -8
  74. package/node_modules/@spexcode/spec-cli/dist/hook-prompts.js +8 -0
  75. package/node_modules/@spexcode/spec-cli/dist/host-resources.js +29 -8
  76. package/node_modules/@spexcode/spec-cli/dist/host.d.ts +7 -0
  77. package/node_modules/@spexcode/spec-cli/dist/host.js +98 -1
  78. package/node_modules/@spexcode/spec-cli/dist/index.js +294 -36
  79. package/node_modules/@spexcode/spec-cli/dist/init.js +1 -1
  80. package/node_modules/@spexcode/spec-cli/dist/lint.js +70 -35
  81. package/node_modules/@spexcode/spec-cli/dist/listen.d.ts +3 -2
  82. package/node_modules/@spexcode/spec-cli/dist/listen.js +14 -2
  83. package/node_modules/@spexcode/spec-cli/dist/machine-peer.d.ts +2 -1
  84. package/node_modules/@spexcode/spec-cli/dist/machine-peer.js +46 -15
  85. package/node_modules/@spexcode/spec-cli/dist/materialize.d.ts +2 -2
  86. package/node_modules/@spexcode/spec-cli/dist/materialize.js +176 -35
  87. package/node_modules/@spexcode/spec-cli/dist/opencode.js +10 -2
  88. package/node_modules/@spexcode/spec-cli/dist/pi-harness.js +18 -5
  89. package/node_modules/@spexcode/spec-cli/dist/pi-headless.d.ts +2 -0
  90. package/node_modules/@spexcode/spec-cli/dist/pi-headless.js +27 -0
  91. package/node_modules/@spexcode/spec-cli/dist/pty-bridge.js +14 -14
  92. package/node_modules/@spexcode/spec-cli/dist/reviews.js +12 -7
  93. package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.d.ts +11 -0
  94. package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.js +79 -1
  95. package/node_modules/@spexcode/spec-cli/dist/session-application.d.ts +23 -0
  96. package/node_modules/@spexcode/spec-cli/dist/session-application.js +189 -0
  97. package/node_modules/@spexcode/spec-cli/dist/session-declarations.js +13 -1
  98. package/node_modules/@spexcode/spec-cli/dist/session-files.d.ts +6 -0
  99. package/node_modules/@spexcode/spec-cli/dist/session-files.js +13 -1
  100. package/node_modules/@spexcode/spec-cli/dist/session-follow.js +39 -22
  101. package/node_modules/@spexcode/{session-core/dist/record-lock.d.ts → spec-cli/dist/session-record-lock.d.ts} +0 -4
  102. package/node_modules/@spexcode/spec-cli/dist/session-record-lock.js +94 -0
  103. package/node_modules/@spexcode/spec-cli/dist/session-runtime-adapter.d.ts +44 -0
  104. package/node_modules/@spexcode/spec-cli/dist/session-runtime-adapter.js +37 -0
  105. package/node_modules/@spexcode/spec-cli/dist/session-timeline.d.ts +25 -2
  106. package/node_modules/@spexcode/spec-cli/dist/session-timeline.js +68 -11
  107. package/node_modules/@spexcode/spec-cli/dist/session-transcript.d.ts +57 -0
  108. package/node_modules/@spexcode/spec-cli/dist/session-transcript.js +137 -0
  109. package/node_modules/@spexcode/spec-cli/dist/session-web.js +4 -4
  110. package/node_modules/@spexcode/spec-cli/dist/sessions.d.ts +112 -15
  111. package/node_modules/@spexcode/spec-cli/dist/sessions.js +1553 -764
  112. package/node_modules/@spexcode/spec-cli/dist/shim-runtime.js +14 -0
  113. package/node_modules/@spexcode/spec-cli/dist/source-list.d.ts +13 -0
  114. package/node_modules/@spexcode/spec-cli/dist/source-list.js +99 -0
  115. package/node_modules/@spexcode/spec-cli/dist/source-read.d.ts +16 -0
  116. package/node_modules/@spexcode/spec-cli/dist/source-read.js +84 -0
  117. package/node_modules/@spexcode/spec-cli/dist/spec-attachments.d.ts +7 -0
  118. package/node_modules/@spexcode/spec-cli/dist/spec-attachments.js +89 -0
  119. package/node_modules/@spexcode/spec-cli/dist/spec-body-edit.d.ts +23 -0
  120. package/node_modules/@spexcode/spec-cli/dist/spec-body-edit.js +138 -0
  121. package/node_modules/@spexcode/spec-cli/dist/supervise.js +15 -6
  122. package/node_modules/@spexcode/spec-cli/hooks/dispatch.sh +19 -31
  123. package/node_modules/@spexcode/spec-cli/hooks/harness.sh +6 -6
  124. package/node_modules/@spexcode/spec-cli/package.json +8 -6
  125. package/node_modules/@spexcode/spec-cli/templates/hooks/post-checkout +4 -2
  126. package/node_modules/@spexcode/spec-cli/templates/hooks/post-merge +2 -1
  127. package/node_modules/@spexcode/spec-cli/templates/hooks/pre-commit +5 -3
  128. package/node_modules/@spexcode/spec-cli/templates/hooks/reference-transaction +5 -3
  129. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/commands/spec.md +2 -7
  130. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/idle/idle.sh +4 -10
  131. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  132. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +22 -24
  133. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +10 -2
  134. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-fail/fail.sh +8 -7
  135. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
  136. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-listen/session-listen.sh +133 -0
  137. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-listen/spec.md +36 -0
  138. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -0
  139. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  140. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +17 -20
  141. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/skills/merge/spec.md +33 -0
  142. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/skills/spec.md +2 -6
  143. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/spec.md +7 -0
  144. package/node_modules/@spexcode/spec-core/dist/anchors.d.ts +13 -2
  145. package/node_modules/@spexcode/spec-core/dist/anchors.js +311 -15
  146. package/node_modules/@spexcode/spec-core/dist/git.d.ts +4 -0
  147. package/node_modules/@spexcode/spec-core/dist/git.js +9 -9
  148. package/node_modules/@spexcode/spec-core/dist/layout.d.ts +12 -1
  149. package/node_modules/@spexcode/spec-core/dist/layout.js +4 -2
  150. package/node_modules/@spexcode/spec-core/dist/resilience.d.ts +1 -0
  151. package/node_modules/@spexcode/spec-core/dist/resilience.js +8 -0
  152. package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.d.ts +1 -0
  153. package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.js +7 -3
  154. package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.d.ts +1 -0
  155. package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.js +4 -0
  156. package/node_modules/@spexcode/spec-core/dist/specs.d.ts +2 -0
  157. package/node_modules/@spexcode/spec-core/dist/specs.js +30 -0
  158. package/node_modules/@spexcode/spec-core/package.json +4 -1
  159. package/node_modules/@spexcode/spec-core/templates/spexcode.json +36 -13
  160. package/node_modules/@spexcode/spec-eval/dist/cli.js +1 -1
  161. package/node_modules/@spexcode/spec-eval/dist/evaltab.d.ts +1 -0
  162. package/node_modules/@spexcode/spec-eval/dist/evaltab.js +6 -1
  163. package/node_modules/@spexcode/spec-eval/dist/freshness.d.ts +1 -1
  164. package/node_modules/@spexcode/spec-eval/dist/freshness.js +12 -12
  165. package/node_modules/@spexcode/spec-eval/dist/host.d.ts +2 -1
  166. package/node_modules/@spexcode/spec-eval/dist/sessioneval.d.ts +2 -1
  167. package/node_modules/@spexcode/spec-eval/dist/sessioneval.js +20 -3
  168. package/node_modules/@spexcode/spec-eval/package.json +2 -2
  169. package/node_modules/@spexcode/spec-forge/package.json +2 -2
  170. package/node_modules/@spexcode/transcript/dist/frames.d.ts +44 -0
  171. package/node_modules/@spexcode/transcript/dist/frames.js +85 -0
  172. package/node_modules/@spexcode/transcript/dist/index.d.ts +5 -0
  173. package/node_modules/@spexcode/transcript/dist/index.js +5 -0
  174. package/node_modules/@spexcode/transcript/dist/live.d.ts +16 -0
  175. package/node_modules/@spexcode/transcript/dist/live.js +65 -0
  176. package/node_modules/@spexcode/transcript/dist/parsers.d.ts +47 -0
  177. package/node_modules/@spexcode/transcript/dist/parsers.js +285 -0
  178. package/node_modules/@spexcode/transcript/dist/readers.d.ts +10 -0
  179. package/node_modules/@spexcode/transcript/dist/readers.js +296 -0
  180. package/node_modules/@spexcode/transcript/dist/turns.d.ts +42 -0
  181. package/node_modules/@spexcode/transcript/dist/turns.js +11 -0
  182. package/node_modules/@spexcode/transcript/package.json +30 -0
  183. package/node_modules/@vscode/tree-sitter-wasm/LICENSE +21 -0
  184. package/node_modules/@vscode/tree-sitter-wasm/README.md +36 -0
  185. package/node_modules/@vscode/tree-sitter-wasm/SECURITY.md +41 -0
  186. package/node_modules/@vscode/tree-sitter-wasm/cgmanifest.json +16 -0
  187. package/node_modules/@vscode/tree-sitter-wasm/package.json +42 -0
  188. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-bash.wasm +0 -0
  189. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-c-sharp.wasm +0 -0
  190. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-cpp.wasm +0 -0
  191. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-css.wasm +0 -0
  192. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-go.wasm +0 -0
  193. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ini.wasm +0 -0
  194. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-java.wasm +0 -0
  195. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-javascript.wasm +0 -0
  196. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-php.wasm +0 -0
  197. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-powershell.wasm +0 -0
  198. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-python.wasm +0 -0
  199. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-regex.wasm +0 -0
  200. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ruby.wasm +0 -0
  201. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-rust.wasm +0 -0
  202. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-tsx.wasm +0 -0
  203. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-typescript.wasm +0 -0
  204. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.js +4075 -0
  205. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.wasm +0 -0
  206. package/node_modules/@vscode/tree-sitter-wasm/wasm/web-tree-sitter.d.ts +1027 -0
  207. package/node_modules/smol-toml/LICENSE +24 -0
  208. package/node_modules/smol-toml/README.md +418 -0
  209. package/node_modules/smol-toml/dist/date.d.ts +41 -0
  210. package/node_modules/smol-toml/dist/date.js +127 -0
  211. package/node_modules/smol-toml/dist/error.d.ts +38 -0
  212. package/node_modules/smol-toml/dist/error.js +63 -0
  213. package/node_modules/smol-toml/dist/extract.js +69 -0
  214. package/node_modules/smol-toml/dist/index.cjs +734 -0
  215. package/node_modules/smol-toml/dist/index.d.ts +43 -0
  216. package/node_modules/smol-toml/dist/index.js +33 -0
  217. package/node_modules/smol-toml/dist/parse.d.ts +36 -0
  218. package/node_modules/smol-toml/dist/parse.js +149 -0
  219. package/node_modules/smol-toml/dist/primitive.js +238 -0
  220. package/node_modules/smol-toml/dist/stringify.d.ts +31 -0
  221. package/node_modules/smol-toml/dist/stringify.js +181 -0
  222. package/node_modules/smol-toml/dist/struct.js +179 -0
  223. package/node_modules/smol-toml/dist/util.d.ts +38 -0
  224. package/node_modules/smol-toml/dist/util.js +89 -0
  225. package/node_modules/smol-toml/package.json +68 -0
  226. package/package.json +9 -3
  227. package/node_modules/@spexcode/session-core/dist/delivery-queue.d.ts +0 -24
  228. package/node_modules/@spexcode/session-core/dist/delivery-queue.js +0 -206
  229. package/node_modules/@spexcode/session-core/dist/index.d.ts +0 -5
  230. package/node_modules/@spexcode/session-core/dist/index.js +0 -5
  231. package/node_modules/@spexcode/session-core/dist/internal.d.ts +0 -3
  232. package/node_modules/@spexcode/session-core/dist/internal.js +0 -3
  233. package/node_modules/@spexcode/session-core/dist/message.d.ts +0 -22
  234. package/node_modules/@spexcode/session-core/dist/message.js +0 -53
  235. package/node_modules/@spexcode/session-core/dist/record-lock.js +0 -152
  236. package/node_modules/@spexcode/session-core/dist/runtime-session.d.ts +0 -62
  237. package/node_modules/@spexcode/session-core/dist/runtime-session.js +0 -326
  238. package/node_modules/@spexcode/session-core/dist/session-cursors.d.ts +0 -14
  239. package/node_modules/@spexcode/session-core/dist/session-cursors.js +0 -82
  240. package/node_modules/@spexcode/session-core/dist/session-timeline.d.ts +0 -47
  241. package/node_modules/@spexcode/session-core/dist/session-timeline.js +0 -216
  242. package/node_modules/@spexcode/spec-cli/dist/execution-trace.d.ts +0 -26
  243. package/node_modules/@spexcode/spec-cli/dist/execution-trace.js +0 -475
  244. package/node_modules/@spexcode/spec-cli/dist/session-execution.d.ts +0 -10
  245. package/node_modules/@spexcode/spec-cli/dist/session-execution.js +0 -70
  246. package/node_modules/@spexcode/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +0 -53
  247. package/node_modules/@spexcode/spec-cli/hooks/compat/mark-active-sed-v0.fixture +0 -46
@@ -1,4 +1,5 @@
1
1
  import { closeSync, openSync, readSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, readdirSync, statSync } from 'node:fs';
2
+ import { parse as parseToml } from 'smol-toml';
2
3
  import { join, dirname, basename } from 'node:path';
3
4
  import { homedir, tmpdir } from 'node:os';
4
5
  import { createHash, randomBytes } from 'node:crypto';
@@ -12,14 +13,14 @@ import { piExtensionSource, writePiTrust, removePiTrust } from './pi-harness.js'
12
13
  import { claudeHeadlessColdRuntime, claudeHeadlessLaunchCommand, claudeHeadlessSock, deliverViaClaudeHeadless, interruptClaudeHeadless } from './claude-headless.js';
13
14
  import { codexHeadlessLaunchCommand } from './codex-headless.js';
14
15
  import { opencodeHeadlessColdRuntime, opencodeHeadlessLaunchCommand, spawnOpenCodeHeadlessTurn } from './opencode-headless.js';
15
- import { piHeadlessLaunchCommand, piHeadlessSock, deliverViaPiHeadless, piHeadlessColdRuntime } from './pi-headless.js';
16
+ import { piHeadlessLaunchCommand, piHeadlessSock, deliverViaPiHeadless, interruptPiHeadless, piHeadlessColdRuntime } from './pi-headless.js';
16
17
  import { runtimeRoot, mainCheckout, readConfig, sessionArtifactPath, spexcodeHome } from '@spexcode/spec-core';
17
18
  import { git } from '@spexcode/spec-core';
18
19
  import { shQuote } from './sh.js';
19
20
  import { detachedRuntimeGenerationToken, migrateLegacyDetachedRuntimeReceipt, processStartToken, verifyDetachedRuntime } from '@spexcode/spec-core';
20
21
  import { codexGenerationEndpoints, codexGenerationSocketPath, currentCodexGeneration, legacyCodexGenerationEndpoint, readCodexGenerationLedger, resolveCodexGenerationForSession } from './codex-runtime-generations.js';
21
22
  import { writeFileIfChanged } from './file-write.js';
22
- import { codexRolloutPath, noExecutionTrace, readCodexExecutionTrace, readLocalStoreExecutionTrace, readProjectJsonlExecutionTrace, readSessionJsonlExecutionTrace } from './execution-trace.js';
23
+ import { claudeTranscript, codexRolloutPath, codexTranscript, opencodeTranscript, piTranscript, unsupportedTranscript } from '@spexcode/transcript';
23
24
  import { harnessIdentity, HARNESS_IDENTITIES } from '@spexcode/spec-core';
24
25
  // One project-wide resident-reference census for read projections. A shared app-server descriptor is probed
25
26
  // once per call, then its result is joined to every record that names that adapter/thread. Product readers must
@@ -302,21 +303,38 @@ async function claudeDeliveryTransport(rec) {
302
303
  return rendezvousDeliveryTransport(rec);
303
304
  }
304
305
  const unprovenDeliveryTransport = async () => ({ kind: 'unproven' });
305
- function replyViaSocket(sock, text, mid, auth) {
306
+ // Claude's rendezvous daemon owns one connection at a time. A liveness probe can therefore destroy a
307
+ // delivery connection after its write has reached the kernel but before the daemon parses the line. Send the
308
+ // reply and an in-order repaint probe in one chunk: repaint-done proves the reply line was parsed first; a
309
+ // close/reset before that proves the whole chunk was discarded and is safe to retry. An open connection that
310
+ // outlives the wall is treated as busy, not lost, preserving the no-false-failure behavior for active turns.
311
+ function replyViaSocket(sock, text, mid, auth, wallMs = 10_000) {
306
312
  return new Promise((resolve) => {
307
313
  let settled = false;
308
314
  let c;
315
+ let wall;
309
316
  const done = (r) => {
310
317
  if (settled)
311
318
  return;
312
319
  settled = true;
313
- if (!r.ok)
320
+ clearTimeout(wall);
321
+ if (r.ok) {
322
+ // A confirmed repaint has already crossed the parser. On a busy timeout, finish the stream cleanly so
323
+ // the daemon can consume the buffered pair before its FIN; never destroy an unparsed write.
324
+ try {
325
+ c?.end();
326
+ }
327
+ catch { /* */ }
328
+ }
329
+ else {
314
330
  try {
315
331
  c?.destroy();
316
332
  }
317
333
  catch { /* */ }
334
+ }
318
335
  resolve(r);
319
336
  };
337
+ wall = setTimeout(() => done({ ok: true }), wallMs);
320
338
  try {
321
339
  c = createConnection({ path: sock });
322
340
  }
@@ -326,29 +344,49 @@ function replyViaSocket(sock, text, mid, auth) {
326
344
  }
327
345
  c.on('error', (e) => {
328
346
  const code = e?.code || String(e);
329
- done({ ok: false, error: `rendezvous socket error: ${code}` });
347
+ done({ ok: false, kicked: code === 'ECONNRESET' || code === 'EPIPE', error: `rendezvous socket error: ${code} — prompt NOT delivered` });
348
+ });
349
+ c.on('close', () => done({ ok: false, kicked: true, error: 'rendezvous connection closed before the daemon parsed the prompt (kicked by a concurrent connect)' }));
350
+ c.on('connect', () => c.write(`${auth ? JSON.stringify({ role: 'controller', auth }) + '\n' : ''}${JSON.stringify({ type: 'reply', text, ...(mid ? { mid } : {}) })}\n${JSON.stringify({ type: 'repaint' })}\n`));
351
+ let buf = '';
352
+ c.on('data', (chunk) => {
353
+ buf += chunk.toString('utf8');
354
+ let nl;
355
+ while ((nl = buf.indexOf('\n')) >= 0) {
356
+ const line = buf.slice(0, nl);
357
+ buf = buf.slice(nl + 1);
358
+ let type = '';
359
+ try {
360
+ type = JSON.parse(line)?.type ?? '';
361
+ }
362
+ catch {
363
+ continue;
364
+ }
365
+ if (type === 'repaint-done')
366
+ return done({ ok: true });
367
+ if (type === 'reply-rejected' || type === 'auth-rejected')
368
+ return done({ ok: false, error: `rendezvous daemon rejected the prompt (${type}) — prompt NOT delivered` });
369
+ if (type === 'shutting-down')
370
+ return done({ ok: false, error: 'agent is shutting down — prompt NOT delivered' });
371
+ }
330
372
  });
331
- c.on('close', () => done({ ok: false, error: 'rendezvous connection closed before the poke was written' }));
332
- c.on('connect', () => c.write(`${auth ? JSON.stringify({ role: 'controller', auth }) + '\n' : ''}${JSON.stringify({ type: 'reply', text, ...(mid ? { mid } : {}) })}\n`, (error) => {
333
- if (error)
334
- return done({ ok: false, error: `rendezvous socket write failed: ${error.message}` });
335
- c.end();
336
- done({ ok: true });
337
- }));
338
373
  });
339
374
  }
340
375
  const POKE_ATTEMPTS = 2;
341
- async function pokeRendezvous(sock, text, mid, auth) {
376
+ async function pokeRendezvous(sock, text, mid, auth, wallMs) {
342
377
  let last = { ok: false, error: 'not attempted' };
343
378
  for (let attempt = 0; attempt < POKE_ATTEMPTS; attempt++) {
344
- last = await replyViaSocket(sock, text, mid, auth);
379
+ last = await replyViaSocket(sock, text, mid, auth, wallMs);
345
380
  if (last.ok)
346
381
  return last;
382
+ if (!last.kicked)
383
+ break;
384
+ await new Promise((resolve) => setTimeout(resolve, 60 + Math.random() * 140));
347
385
  }
348
386
  return { ok: false, error: `rendezvous poke failed after ${POKE_ATTEMPTS} attempts: ${last.error ?? 'unknown error'}` };
349
387
  }
350
- export async function deliverViaRendezvous(id, text, mid) {
351
- return pokeRendezvous(rvSock(id), text, mid);
388
+ export async function deliverViaRendezvous(id, text, mid, wallMs) {
389
+ return pokeRendezvous(rvSock(id), text, mid, undefined, wallMs);
352
390
  }
353
391
  export async function deliverViaClaudeRendezvous(id, text, mid, runtimeDir) {
354
392
  const fork = claudeForkTransport(id, runtimeDir);
@@ -371,12 +409,89 @@ export async function deliverViaSocketOrWake(id, text, mid, coldWake, unprovenEr
371
409
  return { ok: false, error: unprovenError };
372
410
  return coldWake();
373
411
  }
412
+ const RENDEZVOUS_INTERRUPT_WALL_MS = 10_000;
413
+ const RENDEZVOUS_INTERRUPT_SETTLE_MS = 15_000;
414
+ // @@@ interruptViaRendezvous - one `interrupt` line to the session's LIVE rendezvous listener, confirmed by the
415
+ // shim's own answer ([[shim-runtime]] runs the host's native abort — pi's ctx.abort(), opencode's
416
+ // session.abort — and writes interrupt-done / interrupt-rejected). Unlike a reply poke, whose durable copy is
417
+ // the timeline, an interrupt has nothing to fall back on: silence is a loud failure, never an optimistic ok.
418
+ // A generative shim serves its socket only while a turn process is alive, so a dead listener means no turn
419
+ // is running — nothing to interrupt — and an unproven probe sends nothing. `settle` additionally waits for
420
+ // that listener to go dead: for a one-turn-per-process adapter the abort ends the process, and confirming
421
+ // only then means a delivery that follows the interrupt wakes cold instead of poking an exiting agent.
422
+ export async function interruptViaRendezvous(id, harness, opts = {}) {
423
+ const probe = await rendezvousListening(id);
424
+ if (probe === 'dead')
425
+ return { ok: false, error: `no ${harness} turn is running for session ${id} - nothing to interrupt` };
426
+ if (probe === 'unproven')
427
+ return { ok: false, error: `${harness} rendezvous listener for session ${id} is unproven - interrupt NOT sent` };
428
+ const answered = await sendRendezvousInterrupt(id, harness);
429
+ if (!answered.ok || !opts.settle)
430
+ return answered;
431
+ const deadline = Date.now() + RENDEZVOUS_INTERRUPT_SETTLE_MS;
432
+ for (;;) {
433
+ const state = await rendezvousListening(id);
434
+ if (state === 'dead')
435
+ return { ok: true };
436
+ if (Date.now() >= deadline)
437
+ return { ok: false, error: `${harness} confirmed the interrupt but its turn process for session ${id} is still ${state === 'live' ? 'serving' : 'unproven'} after ${RENDEZVOUS_INTERRUPT_SETTLE_MS}ms` };
438
+ await new Promise((resolve) => setTimeout(resolve, 200));
439
+ }
440
+ }
441
+ function sendRendezvousInterrupt(id, harness) {
442
+ return new Promise((resolve) => {
443
+ let settled = false;
444
+ let c;
445
+ const done = (r) => {
446
+ if (settled)
447
+ return;
448
+ settled = true;
449
+ clearTimeout(wall);
450
+ try {
451
+ c?.destroy();
452
+ }
453
+ catch { /* */ }
454
+ resolve(r);
455
+ };
456
+ const wall = setTimeout(() => done({ ok: false, error: `${harness} did not confirm the interrupt for session ${id} within ${RENDEZVOUS_INTERRUPT_WALL_MS}ms (a shim materialized before interrupt support never answers - rerun spex materialize)` }), RENDEZVOUS_INTERRUPT_WALL_MS);
457
+ try {
458
+ c = createConnection({ path: rvSock(id) });
459
+ }
460
+ catch (e) {
461
+ done({ ok: false, error: `rendezvous socket connect threw: ${String(e)} - interrupt NOT sent` });
462
+ return;
463
+ }
464
+ c.on('error', (e) => done({ ok: false, error: `rendezvous socket error: ${e?.code || String(e)} - interrupt NOT confirmed` }));
465
+ c.on('close', () => done({ ok: false, error: `rendezvous connection closed before ${harness} answered the interrupt` }));
466
+ c.on('connect', () => c.write(`${JSON.stringify({ type: 'interrupt' })}\n`));
467
+ let buf = '';
468
+ c.on('data', (chunk) => {
469
+ buf += chunk.toString('utf8');
470
+ let nl;
471
+ while ((nl = buf.indexOf('\n')) >= 0) {
472
+ const line = buf.slice(0, nl);
473
+ buf = buf.slice(nl + 1);
474
+ let msg = {};
475
+ try {
476
+ msg = JSON.parse(line);
477
+ }
478
+ catch {
479
+ continue;
480
+ }
481
+ if (msg.type === 'interrupt-done')
482
+ return done({ ok: true });
483
+ if (msg.type === 'interrupt-rejected')
484
+ return done({ ok: false, error: `${harness} rejected the interrupt: ${msg.error || 'no reason given'}` });
485
+ }
486
+ });
487
+ });
488
+ }
374
489
  // The JSON-RPC the delivery handshake speaks, in send order. Method names + param shapes are pinned to codex
375
490
  // 0.142.3 (`codex app-server generate-ts` → ClientRequest.ts / v2/*Params.ts): the visible TUI is launched with
376
491
  // `codex --remote unix://<sock>`, so its thread is ALREADY loaded in this server — we must NOT `thread/resume`
377
492
  // it (that re-loads a thread the live TUI already owns). Instead `thread/loaded/list` PROVES the captured thread
378
- // is the one the pane is showing, then `thread/read{includeTurns}` reveals whether a turn is in progress (and
379
- // its id). The 4th, injecting message is CHOSEN from that read — see codexInjectMessage.
493
+ // is the one the pane is showing. The failure observer owns the active native turn id from app-server
494
+ // notifications; delivery does not read the thread or replay its history — see codexInjectMessage.
380
495
  const codexTextInput = (text) => [{ type: 'text', text, text_elements: [] }];
381
496
  export function codexHandshakeMessages(threadId) {
382
497
  return [
@@ -390,7 +505,6 @@ export function codexHandshakeMessages(threadId) {
390
505
  },
391
506
  { method: 'initialized', params: {} },
392
507
  { id: 2, method: 'thread/loaded/list', params: {} },
393
- { id: 3, method: 'thread/read', params: { threadId, includeTurns: true } },
394
508
  ];
395
509
  }
396
510
  // the message that injects `text`. STEER (turn/steer) when an active turn id is known — codex processes it
@@ -406,7 +520,9 @@ export function codexInjectMessage(threadId, text, cwd, activeTurnId, id = 4, cl
406
520
  return { id, method: 'turn/steer', params: { threadId, input: codexTextInput(text), expectedTurnId: activeTurnId, ...marker } };
407
521
  return { id, method: 'turn/start', params: { threadId, input: codexTextInput(text), ...(cwd ? { cwd } : {}), ...marker } };
408
522
  }
409
- // the in-progress turn id from a `thread/read{includeTurns}` result, or null when the thread is idle. With
523
+ // the in-progress turn id from a `thread/read{includeTurns}` result, or null when the thread is idle. This is
524
+ // retained for ownership/replay probes; delivery uses codexObservedActiveTurnId because it must not request the
525
+ // full history. With
410
526
  // includeTurns the Thread carries its turns, each with a TurnStatus ("completed"|"interrupted"|"failed"|
411
527
  // "inProgress"); the live turn is the `inProgress` one and its id is exactly what turn/steer's precondition needs.
412
528
  export function activeTurnIdFromThread(readResult) {
@@ -415,6 +531,20 @@ export function activeTurnIdFromThread(readResult) {
415
531
  const active = turns.find((t) => t?.status === 'inProgress');
416
532
  return active?.id ?? null;
417
533
  }
534
+ // The app-server's lightweight thread/read intentionally omits the turn list. Keep the native active id
535
+ // observed by the failure subscription so a send can steer without replaying the whole conversation.
536
+ const codexActiveTurns = new Map();
537
+ export function codexObservedActiveTurnId(threadId) {
538
+ return codexActiveTurns.get(threadId) || null;
539
+ }
540
+ function rememberCodexActiveTurn(threadId, turnId) {
541
+ if (typeof turnId === 'string' && turnId)
542
+ codexActiveTurns.set(threadId, turnId);
543
+ }
544
+ function forgetCodexActiveTurn(threadId, turnId) {
545
+ if (turnId === undefined || codexActiveTurns.get(threadId) === turnId)
546
+ codexActiveTurns.delete(threadId);
547
+ }
418
548
  // The app-server and the visible `--remote … resume` TUI share ONE socket, so they MUST be the SAME codex
419
549
  // install — a version split across that socket breaks the thread/start→resume handoff (an app-server on one
420
550
  // version creates a thread a differently-versioned resume can't find; an old-enough app-server can't serve
@@ -539,8 +669,13 @@ export function codexLaunchCommand(id, codexCmd = 'codex', serverCmd, dir = runt
539
669
  `if [ "$1" = "--resume" ]; then`,
540
670
  ` tid=$2`,
541
671
  ...(attachTui ? [] : [
542
- // A headless forced reopen has no TUI to attach and the shared app-server already owns the thread. Keep it
543
- // a no-op instead of calling codex-launch without a prompt (which would mint an unrelated empty thread).
672
+ // A headless resume has no TUI to reload the thread. The shared app-server evicts an idle thread from its
673
+ // loaded set, and headless readiness proves online only for a RESIDENT thread, so resume must reopen it
674
+ // here — the load the visible TUI's `resume "$tid"` would have done — or readiness times out for a thread
675
+ // that is fine on disk. codex-generation-session above already ensured the app-server, so `$sock` is live.
676
+ ` ${SPEX} internal codex-reopen "$sock" "$tid" || exit 1`,
677
+ // A headless forced reopen with NO thread id and no prompt has nothing to attach and nothing to launch.
678
+ // Keep it a no-op instead of calling codex-launch without a prompt (which would mint an unrelated thread).
544
679
  `elif [ "$#" -eq 0 ]; then`,
545
680
  ` exit 0`,
546
681
  ]),
@@ -556,7 +691,11 @@ export function codexLaunchCommand(id, codexCmd = 'codex', serverCmd, dir = runt
556
691
  // launch attaches to the thread codex-launch just made; a reopen resumes an existing one), so it injects
557
692
  // the same per-thread identity through codex's own `-c` override. Same rule, both entry points: whoever
558
693
  // creates a context stamps that context's record id, and nothing downstream re-derives it.
559
- ...(attachTui ? [`exec ${codexCmd}${tuiBypass} -c ${shQuote(`shell_environment_policy.set.SPEXCODE_SESSION_ID=${id}`)} --remote unix://"$sock" resume "$tid"`] : []),
694
+ // A remote Codex TUI cannot inherit the launch shell's cwd: when `tui.resume_cwd = "current"` is
695
+ // configured, Codex requires an explicit workspace root for every `--remote` invocation. Keep the
696
+ // directory tied to the generated script's actual pane cwd so linked worktrees with spaces remain one
697
+ // argument and the resumed thread loads the same project context as thread/start above.
698
+ ...(attachTui ? [`exec ${codexCmd}${tuiBypass} -c ${shQuote(`shell_environment_policy.set.SPEXCODE_SESSION_ID=${id}`)} --cd "$PWD" --remote unix://"$sock" resume "$tid"`] : []),
560
699
  ].join('\n');
561
700
  return `bash -lc ${shQuote(script)} spexcode-codex`;
562
701
  }
@@ -590,7 +729,7 @@ function encodeWsFrame(opcode, payload) {
590
729
  return Buffer.concat([header, mask, masked]);
591
730
  }
592
731
  const wsText = (s) => encodeWsFrame(0x1, Buffer.from(s, 'utf8'));
593
- function drainWsFrames(s, conn, onText) {
732
+ function drainWsFrames(s, conn, onText, acceptPayload = () => true) {
594
733
  for (;;) {
595
734
  if (s.buf.length < 2)
596
735
  return false;
@@ -635,7 +774,7 @@ function drainWsFrames(s, conn, onText) {
635
774
  s.fragBuf = payload;
636
775
  }
637
776
  if (fin) {
638
- if (s.fragOp === 0x1)
777
+ if (s.fragOp === 0x1 && acceptPayload(s.fragBuf))
639
778
  onText(s.fragBuf.toString('utf8'));
640
779
  s.fragBuf = Buffer.alloc(0);
641
780
  s.fragOp = 0;
@@ -644,6 +783,9 @@ function drainWsFrames(s, conn, onText) {
644
783
  }
645
784
  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`;
646
785
  const wsInitialize = { id: 1, method: 'initialize', params: { clientInfo: { name: 'spexcode', title: 'SpexCode', version: '0.0.0' }, capabilities: { experimentalApi: true, requestAttestation: false } } };
786
+ // Native Codex can take 15-17s to answer thread/resume under a loaded app-server. A shorter
787
+ // deadline creates a retry storm in the session reconciler, not a useful failure signal.
788
+ export const CODEX_TURN_OBSERVER_SUBSCRIBE_MS = 30_000;
647
789
  // Codex has no StopFailure hook, but its app-server has the stronger native signal: every subscribed turn ends
648
790
  // with turn/completed and a final completed/interrupted/failed status. Rejoin is atomic with subscription, so
649
791
  // this observer also survives backend replacement; a thread already in systemError is reconciled from its
@@ -665,6 +807,9 @@ export function codexTurnFailureObserver(rec, onFailure) {
665
807
  const frames = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) };
666
808
  let upgraded = false, settled = false;
667
809
  let reconciliationTimer = null;
810
+ let readySettled = false;
811
+ let resolveReady;
812
+ const ready = new Promise((resolve) => { resolveReady = resolve; });
668
813
  let resolveClosed;
669
814
  const closed = new Promise((resolve) => { resolveClosed = resolve; });
670
815
  const cancelReconciliation = () => {
@@ -677,6 +822,10 @@ export function codexTurnFailureObserver(rec, onFailure) {
677
822
  if (settled)
678
823
  return;
679
824
  settled = true;
825
+ if (!readySettled) {
826
+ readySettled = true;
827
+ resolveReady(false);
828
+ }
680
829
  clearTimeout(timer);
681
830
  cancelReconciliation();
682
831
  try {
@@ -685,7 +834,7 @@ export function codexTurnFailureObserver(rec, onFailure) {
685
834
  catch { }
686
835
  resolveClosed(reason);
687
836
  };
688
- const timer = setTimeout(() => finish('Codex turn observer did not subscribe within 5000ms'), 5000);
837
+ const timer = setTimeout(() => finish(`Codex turn observer did not subscribe within ${CODEX_TURN_OBSERVER_SUBSCRIBE_MS}ms`), CODEX_TURN_OBSERVER_SUBSCRIBE_MS);
689
838
  timer.unref?.();
690
839
  const send = (message) => conn.write(wsText(JSON.stringify(message)));
691
840
  const report = (turn, fallbackMessage) => {
@@ -702,6 +851,11 @@ export function codexTurnFailureObserver(rec, onFailure) {
702
851
  conn.on('close', () => finish('Codex turn observer connection closed'));
703
852
  conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))));
704
853
  const handle = (json) => {
854
+ // A resumed Codex thread can stream large goal/progress notifications while the turn runs. They are
855
+ // irrelevant to this failure observer; avoid JSON.parse on those payloads so one active turn cannot make
856
+ // the backend spend its memory and CPU re-materializing a transcript it does not use.
857
+ if (json.includes('"method"') && !json.includes('"method":"turn/started"') && !json.includes('"method":"turn/completed"'))
858
+ return;
705
859
  let message;
706
860
  try {
707
861
  message = JSON.parse(json);
@@ -721,10 +875,18 @@ export function codexTurnFailureObserver(rec, onFailure) {
721
875
  }
722
876
  if (message.id === 2 && message.result) {
723
877
  clearTimeout(timer);
878
+ if (!readySettled) {
879
+ readySettled = true;
880
+ resolveReady(true);
881
+ }
724
882
  const result = message.result;
883
+ const initialTurns = result.initialTurnsPage?.data;
884
+ const initialActive = Array.isArray(initialTurns)
885
+ ? initialTurns.find((turn) => turn?.status === 'inProgress')
886
+ : null;
887
+ rememberCodexActiveTurn(threadId, initialActive?.id);
725
888
  if (result.thread?.status?.type === 'systemError') {
726
- const turns = result.initialTurnsPage?.data;
727
- const latest = Array.isArray(turns) ? turns[0] : null;
889
+ const latest = Array.isArray(initialTurns) ? initialTurns[0] : null;
728
890
  // Give a concurrently-starting turn's native notification precedence over this historical snapshot.
729
891
  reconciliationTimer = setTimeout(() => {
730
892
  reconciliationTimer = null;
@@ -736,12 +898,15 @@ export function codexTurnFailureObserver(rec, onFailure) {
736
898
  }
737
899
  if (message.method === 'turn/started') {
738
900
  const params = message.params;
739
- if (params?.threadId === threadId)
901
+ if (params?.threadId === threadId) {
902
+ rememberCodexActiveTurn(threadId, params.turn?.id);
740
903
  cancelReconciliation();
904
+ }
741
905
  }
742
906
  if (message.method === 'turn/completed') {
743
907
  const params = message.params;
744
908
  if (params?.threadId === threadId) {
909
+ forgetCodexActiveTurn(threadId, params.turn?.id);
745
910
  cancelReconciliation();
746
911
  report(params.turn);
747
912
  }
@@ -760,10 +925,13 @@ export function codexTurnFailureObserver(rec, onFailure) {
760
925
  frames.buf = frames.buf.slice(split + 4);
761
926
  send(wsInitialize);
762
927
  }
763
- if (drainWsFrames(frames, conn, handle))
928
+ if (drainWsFrames(frames, conn, handle, (payload) => {
929
+ const method = Buffer.from('"method"');
930
+ return !payload.includes(method) || payload.includes(Buffer.from('"turn/started"')) || payload.includes(Buffer.from('"turn/completed"'));
931
+ }))
764
932
  finish('Codex app-server closed the turn observer');
765
933
  });
766
- return { close: () => finish(null), closed };
934
+ return { close: () => finish(null), closed, ready };
767
935
  }
768
936
  function codexThreadMutation(sock, method, threadId, fence, turnId, budgetMs = CODEX_MUTATION_BASE_MS) {
769
937
  const generationError = () => fence && codexRuntimeGeneration(fence.dir, fence.endpoint) !== fence.generation
@@ -860,6 +1028,69 @@ const CODEX_MUTATION_CENSUS_MS = 15_000;
860
1028
  const CODEX_MUTATION_BASE_MS = 15_000;
861
1029
  const CODEX_ARCHIVE_FLUSH_FLOOR_BYTES_PER_MS = 1000;
862
1030
  const codexArchiveBudgetMs = (bytes) => CODEX_MUTATION_BASE_MS + Math.ceil(bytes / CODEX_ARCHIVE_FLUSH_FLOOR_BYTES_PER_MS);
1031
+ // @@@ codexReopenThread - load an evicted thread back into the shared app-server WITHOUT running a turn. The
1032
+ // app-server evicts an idle thread from its in-memory loaded set, and codex-headless readiness/liveness proves
1033
+ // online only when the thread is resident (thread/loaded/list). A visible-TUI resume reloads it implicitly via
1034
+ // `resume "$tid"`; headless has no TUI, so its resume must issue this `thread/resume` itself or readiness times
1035
+ // out for a thread that is perfectly fine on disk. `excludeTurns` + a one-row page keeps a huge rollout from
1036
+ // streaming back — we only need the load, not the history. Idempotent: reopening an already-loaded thread is a
1037
+ // no-op the server answers at once.
1038
+ export function codexReopenThread(sock, threadId, budgetMs = 20_000) {
1039
+ return new Promise((resolve) => {
1040
+ const conn = createConnection(sock);
1041
+ const fs = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) };
1042
+ let upgraded = false, settled = false;
1043
+ const done = (r) => {
1044
+ if (settled)
1045
+ return;
1046
+ settled = true;
1047
+ clearTimeout(timer);
1048
+ try {
1049
+ conn.destroy();
1050
+ }
1051
+ catch { /* */ }
1052
+ resolve(r);
1053
+ };
1054
+ const timer = setTimeout(() => done({ ok: false, error: `codex app-server did not reopen thread ${threadId} within ${budgetMs}ms` }), budgetMs);
1055
+ conn.on('error', (e) => done({ ok: false, error: `codex app-server connection failed: ${rpcError(e)}` }));
1056
+ conn.on('close', () => done({ ok: false, error: 'codex app-server closed before thread/resume was answered' }));
1057
+ const send = (m) => conn.write(wsText(JSON.stringify(m)));
1058
+ conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))));
1059
+ const handle = (json) => {
1060
+ let m;
1061
+ try {
1062
+ m = JSON.parse(json);
1063
+ }
1064
+ catch {
1065
+ return;
1066
+ }
1067
+ if (m.error)
1068
+ return done({ ok: false, error: `codex app-server ${m.id ? `request ${m.id}` : 'notification'} failed: ${m.error.message || JSON.stringify(m.error)}` });
1069
+ if (m.id === 1 && m.result) {
1070
+ send({ method: 'initialized', params: {} });
1071
+ return send({ id: 2, method: 'thread/resume', params: { threadId, excludeTurns: true, initialTurnsPage: { limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' } } });
1072
+ }
1073
+ if (m.id === 2 && m.result)
1074
+ return done({ ok: true });
1075
+ };
1076
+ conn.on('data', (chunk) => {
1077
+ fs.buf = Buffer.concat([fs.buf, chunk]);
1078
+ if (!upgraded) {
1079
+ const i = fs.buf.indexOf('\r\n\r\n');
1080
+ if (i < 0)
1081
+ return;
1082
+ const head = fs.buf.slice(0, i).toString('utf8');
1083
+ if (!/^HTTP\/1\.1 101/.test(head))
1084
+ return done({ ok: false, error: 'codex app-server refused WebSocket upgrade for thread/resume' });
1085
+ upgraded = true;
1086
+ fs.buf = fs.buf.slice(i + 4);
1087
+ send(wsInitialize);
1088
+ }
1089
+ if (drainWsFrames(fs, conn, handle))
1090
+ done({ ok: false, error: 'codex app-server closed during thread/resume' });
1091
+ });
1092
+ });
1093
+ }
863
1094
  // Codex treats an omitted or empty sourceKinds filter as "interactive" defaults. Cold proof must census the
864
1095
  // entire native thread graph, including subAgent/thread-spawn rows that have no Spex record, so the adapter
865
1096
  // supplies every protocol source kind explicitly for its thread/list calls.
@@ -1579,7 +1810,7 @@ export function codexThreadId(sock) {
1579
1810
  // Resource ownership asks the adapter for what the shared server actually owns now. Records are joined later;
1580
1811
  // they are never treated as references by themselves. A loaded thread is a control-plane reference and its
1581
1812
  // fresh inProgress turn (the same predicate used by delivery) distinguishes active from addressable-idle.
1582
- export function codexSharedRuntimeProbe(dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir)) {
1813
+ export function codexSharedRuntimeProbe(dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir), referenceIds) {
1583
1814
  const sock = endpoint.socketPath;
1584
1815
  return (async () => {
1585
1816
  // File presence is not process identity. A dead PID plus a stale socket file is the normal crash residue;
@@ -1676,11 +1907,19 @@ export function codexSharedRuntimeProbe(dir = runtimeRoot(), endpoint = legacyCo
1676
1907
  // Continue with the complete paginated set, not just the first manager page.
1677
1908
  if (!loadedIds.size)
1678
1909
  return done({ healthy: true, references: [] });
1679
- loadedIds.forEach((threadId) => {
1910
+ const wanted = referenceIds === undefined ? [...loadedIds] : [...loadedIds].filter((threadId) => referenceIds.includes(threadId));
1911
+ loadedIds.forEach((threadId) => references.set(threadId, { referenceId: threadId, turnPresence: 'unknown' }));
1912
+ // A draining generation deliberately has no native turn reads. Complete the census after recording
1913
+ // loaded ownership; leaving the request map empty would otherwise wait for the global timeout forever.
1914
+ if (!wanted.length)
1915
+ return done({ healthy: true, references: [...references.values()] });
1916
+ wanted.forEach((threadId) => {
1680
1917
  const id = 100 + requests.size;
1681
- references.set(threadId, { referenceId: threadId, turnPresence: 'unknown' });
1682
1918
  requests.set(id, threadId);
1683
- send({ id, method: 'thread/read', params: { threadId, includeTurns: true } });
1919
+ // Ownership sampling only needs the native status, never the persisted turn history. The old
1920
+ // includeTurns:true request made a periodic resource report replay every loaded conversation and
1921
+ // was the direct source of 5s probe timeouts on the draining generation.
1922
+ send({ id, method: 'thread/read', params: { threadId, includeTurns: false } });
1684
1923
  });
1685
1924
  return;
1686
1925
  }
@@ -1689,9 +1928,10 @@ export function codexSharedRuntimeProbe(dir = runtimeRoot(), endpoint = legacyCo
1689
1928
  requests.delete(m.id);
1690
1929
  const thread = m.result.thread;
1691
1930
  const turnId = activeTurnIdFromThread(m.result);
1931
+ const nativeStatus = typeof thread?.status === 'string' ? thread.status : thread?.status?.type;
1692
1932
  references.set(threadId, {
1693
1933
  referenceId: threadId,
1694
- turnPresence: !Array.isArray(thread?.turns) ? 'unknown' : turnId ? 'active' : 'idle',
1934
+ turnPresence: turnId || nativeStatus === 'active' ? 'active' : nativeStatus === 'idle' ? 'idle' : 'unknown',
1695
1935
  ...(turnId ? { turnId } : {}),
1696
1936
  });
1697
1937
  if (!requests.size)
@@ -1812,7 +2052,7 @@ const codexTurnConfirmMs = () => {
1812
2052
  function sendCodexAppServerTurn(sock, threadId, text, cwd, clientUserMessageId) {
1813
2053
  return new Promise((resolve) => {
1814
2054
  const conn = createConnection(sock);
1815
- const hs = codexHandshakeMessages(threadId); // [initialize(1), initialized, thread/loaded/list(2), thread/read(3)]
2055
+ const hs = codexHandshakeMessages(threadId); // [initialize(1), initialized, thread/loaded/list(2)]
1816
2056
  let buf = Buffer.alloc(0), upgraded = false, settled = false;
1817
2057
  let fragOp = 0, fragBuf = Buffer.alloc(0);
1818
2058
  let steering = false; // the id-4 message we sent was a steer → an expectedTurnId race may retry as start(5)
@@ -1857,14 +2097,11 @@ function sendCodexAppServerTurn(sock, threadId, text, cwd, clientUserMessageId)
1857
2097
  send(hs[1]);
1858
2098
  return send(hs[2]);
1859
2099
  } // initialize ack → initialized → ask which threads are loaded
1860
- if (m.id === 2 && m.result) { // loaded-thread list → confirm OUR thread is live, then read it
2100
+ if (m.id === 2 && m.result) { // loaded-thread list → confirm OUR thread is live, then inject
1861
2101
  const loaded = m.result?.data;
1862
2102
  if (Array.isArray(loaded) && !loaded.includes(threadId))
1863
2103
  return done({ ok: false, error: `Codex thread ${threadId} is not loaded in the app-server (loaded: ${loaded.join(', ') || 'none'}) — immediate poke not accepted` });
1864
- return send(hs[3]); // thread is live → read it to decide steer-vs-start
1865
- }
1866
- if (m.id === 3 && m.result) { // thread read → in-progress turn? steer into it; else start a new one
1867
- const turnId = activeTurnIdFromThread(m.result);
2104
+ const turnId = codexObservedActiveTurnId(threadId);
1868
2105
  steering = !!turnId;
1869
2106
  return send(codexInjectMessage(threadId, text, cwd, turnId, 4, clientUserMessageId)); // id 4: turn/steer the live turn, or turn/start
1870
2107
  }
@@ -2106,6 +2343,157 @@ export function removeManagedBlock(file, comment = ['<!-- ', ' -->'], deleteIfEm
2106
2343
  }
2107
2344
  writeFileSync(file, out);
2108
2345
  }
2346
+ // @@@ managed-json-hooks - the JSON counterpart of writeManagedBlock/removeManagedBlock, for a shim file the
2347
+ // host agent SHARES with the user. `.claude/settings.json` (and `.codex/hooks.json`, `.zcode/settings.json`)
2348
+ // is the user's project config — permissions, env, statusLine, their own hooks — that merely HAPPENS to be
2349
+ // where the harness also discovers ours. A whole-file write there is silent data loss, and a whole-file
2350
+ // delete on uninstall makes it permanent for an untracked (gitignored) file. JSON has no comment syntax, so
2351
+ // the sentinel that scopes ownership is the hook COMMAND itself: every entry we write invokes `dispatch.sh`,
2352
+ // and ONLY such entries are ever removed. Everything else round-trips — other keys, other events, foreign
2353
+ // hook groups, and the user's half of a group that mixes both.
2354
+ const isOurHookEntry = (entry) => !!entry && typeof entry === 'object' && typeof entry.command === 'string'
2355
+ && entry.command.includes('dispatch.sh');
2356
+ // drop OUR entries from one event's group list, keeping every foreign group byte-for-byte and keeping the
2357
+ // user's half of a mixed group. A group whose entries were all ours disappears with it.
2358
+ function stripOurHookGroups(list) {
2359
+ if (!Array.isArray(list))
2360
+ return Array.isArray(list) ? list : [];
2361
+ const kept = [];
2362
+ for (const group of list) {
2363
+ const inner = group?.hooks;
2364
+ if (!group || typeof group !== 'object' || !Array.isArray(inner)) {
2365
+ kept.push(group);
2366
+ continue;
2367
+ }
2368
+ const rest = inner.filter((e) => !isOurHookEntry(e));
2369
+ if (rest.length === inner.length)
2370
+ kept.push(group); // nothing of ours in here
2371
+ else if (rest.length)
2372
+ kept.push({ ...group, hooks: rest }); // mixed group — keep their half
2373
+ }
2374
+ return kept;
2375
+ }
2376
+ // read a shared shim file as JSON. Absent → {} (we are about to create it). UNPARSEABLE → throw: the file is
2377
+ // the user's, and overwriting prose we cannot read is exactly the data loss this primitive exists to prevent
2378
+ // (the harness itself cannot read it either, so the repair is the same one they already need).
2379
+ function readSharedShim(file) {
2380
+ if (!existsSync(file))
2381
+ return {};
2382
+ const raw = readFileSync(file, 'utf8');
2383
+ if (!raw.trim())
2384
+ return {};
2385
+ // (re-serialization NORMALIZES the host's formatting — 2-space, one member per line. Their CONTENT all
2386
+ // round-trips; their layout does not, because no JSON writer can reproduce hand-compacted objects. The one
2387
+ // byte-level convention we DO honor is the trailing newline, below.)
2388
+ try {
2389
+ const parsed = JSON.parse(raw);
2390
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
2391
+ throw new Error('not a JSON object');
2392
+ return parsed;
2393
+ }
2394
+ catch (e) {
2395
+ throw new Error(`it is not readable JSON (${e.message}). SpexCode co-owns only its own hook entries in this file and will not overwrite content it cannot parse — fix the JSON (your harness cannot read it either), then re-run \`spex materialize\`.`);
2396
+ }
2397
+ }
2398
+ // does this file end with a newline today? A host file keeps its own convention; a file we are about to
2399
+ // create gets the POSIX one. (Absent → true, so a fresh shim is newline-terminated.)
2400
+ function trailingNewline(file) {
2401
+ if (!existsSync(file))
2402
+ return true;
2403
+ try {
2404
+ return /\n$/.test(readFileSync(file, 'utf8'));
2405
+ }
2406
+ catch {
2407
+ return true;
2408
+ }
2409
+ }
2410
+ const hostHooksOf = (host) => {
2411
+ const h = host.hooks;
2412
+ return h && typeof h === 'object' && !Array.isArray(h) ? h : {};
2413
+ };
2414
+ // MERGE our per-event hook groups into a shared shim file: our previous entries are stripped first (so a
2415
+ // changed dispatch path or event set self-heals instead of accumulating), then re-appended AFTER the user's
2416
+ // groups for that event. Every other key keeps its value and its position.
2417
+ export function writeManagedJsonHooks(file, hooks) {
2418
+ const eol = trailingNewline(file) ? '\n' : '';
2419
+ const host = readSharedShim(file);
2420
+ const merged = {};
2421
+ for (const [event, list] of Object.entries(hostHooksOf(host))) {
2422
+ const kept = stripOurHookGroups(list);
2423
+ if (kept.length)
2424
+ merged[event] = kept;
2425
+ }
2426
+ for (const [event, groups] of Object.entries(hooks))
2427
+ merged[event] = [...(merged[event] ?? []), ...groups];
2428
+ return writeFileIfChanged(file, JSON.stringify({ ...host, hooks: merged }, null, 2) + eol);
2429
+ }
2430
+ // the INVERSE: strip our entries, leave every other byte of meaning intact. The file is REMOVED only when
2431
+ // nothing of the user's remains ({} after our entries go) — the same deleteIfEmpty rule removeManagedBlock
2432
+ // applies to a wholly-ours CLAUDE.md, and safe here only because the write half no longer clobbers.
2433
+ export function removeManagedJsonHooks(file) {
2434
+ if (!existsSync(file))
2435
+ return;
2436
+ const eol = trailingNewline(file) ? '\n' : '';
2437
+ let host;
2438
+ try {
2439
+ host = readSharedShim(file);
2440
+ }
2441
+ catch {
2442
+ return;
2443
+ } // unparseable → not provably ours, leave it alone
2444
+ const rest = { ...host };
2445
+ const merged = {};
2446
+ for (const [event, list] of Object.entries(hostHooksOf(host))) {
2447
+ const kept = stripOurHookGroups(list);
2448
+ if (kept.length)
2449
+ merged[event] = kept;
2450
+ }
2451
+ if (Object.keys(merged).length)
2452
+ rest.hooks = merged;
2453
+ else
2454
+ delete rest.hooks;
2455
+ if (!Object.keys(rest).length) {
2456
+ rmSync(file, { force: true });
2457
+ return;
2458
+ }
2459
+ writeFileIfChanged(file, JSON.stringify(rest, null, 2) + eol);
2460
+ }
2461
+ // does anything of the USER's survive in this shared shim file — the JSON analogue of the contract files'
2462
+ // host-content test ([[residence]])? Everything except our identity-stamped hook entries counts. This is what
2463
+ // decides the file's residence: wholly ours → hidden by the tree's ignore block exactly like any other machine
2464
+ // fact; carrying their content → left visible, because hiding a file the user owns is data-loss shaped.
2465
+ export function sharedShimHasHostContent(file) {
2466
+ if (!existsSync(file))
2467
+ return false;
2468
+ let host;
2469
+ try {
2470
+ host = readSharedShim(file);
2471
+ }
2472
+ catch {
2473
+ return true;
2474
+ } // unreadable → assume theirs
2475
+ const rest = { ...host };
2476
+ delete rest.hooks;
2477
+ if (Object.keys(rest).length)
2478
+ return true;
2479
+ return Object.values(hostHooksOf(host)).some((list) => stripOurHookGroups(list).length > 0);
2480
+ }
2481
+ // the identity stamp on every generated skill/agent file. It is what lets BOTH halves of the pass tell our
2482
+ // artifact from a same-named file the user wrote: the erase phase refuses to delete an unstamped file, and
2483
+ // the write phase refuses to overwrite one ([[harness-delivery]]).
2484
+ export const GENERATED_MARK = '<!-- spexcode:generated -->';
2485
+ // is this path ours to replace or remove? An absent file is (nothing to lose); a present one only when it
2486
+ // carries the stamp. Unreadable → not provably ours.
2487
+ export function isGeneratedArtifact(file) {
2488
+ if (!existsSync(file))
2489
+ return true;
2490
+ try {
2491
+ return readFileSync(file, 'utf8').includes(GENERATED_MARK);
2492
+ }
2493
+ catch {
2494
+ return false;
2495
+ }
2496
+ }
2109
2497
  // the shim for one harness: every event → `SPEX='…' bash <dispatch> <harnessId> <Event>`. The harness id is
2110
2498
  // baked in so dispatch.sh can export SPEXCODE_HARNESS (the detector for the shell side). SPEX is inherited by
2111
2499
  // the cli-needing handlers.
@@ -2114,7 +2502,9 @@ function buildShim(id, events, dispatch, spex) {
2114
2502
  const hooks = {};
2115
2503
  for (const e of events)
2116
2504
  hooks[e] = [{ hooks: [{ type: 'command', command: cmd(e) }] }];
2117
- return { content: JSON.stringify({ hooks }, null, 2), cmd };
2505
+ // `content` stays the standalone rendering (what a shim file that is wholly ours would hold); `hooks` is
2506
+ // what the merge writer folds into the user's shared config file. Every buildShim harness is 'shared-json'.
2507
+ return { content: JSON.stringify({ hooks }, null, 2), hooks, cmd };
2118
2508
  }
2119
2509
  // ---------------------------------------------------------------------------------------------------------
2120
2510
  // Codex trust — the codex-rs trusted_hash, reverse-engineered + pinned. Lives in the Codex adapter (it is a
@@ -2168,6 +2558,18 @@ function stripCodexTrustFor(cur, proj, hooksJson) {
2168
2558
  }
2169
2559
  return out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '').replace(/\n*$/, '');
2170
2560
  }
2561
+ // @@@ assertCodexConfigParses - ~/.codex/config.toml has TWO uncoordinated writers, codex itself and this adapter,
2562
+ // and neither rewrite is something the other waits for. A body read mid-write (a line cut short) or one already
2563
+ // broken must never be written back: codex refuses the whole file, and every dispatched thread then dies at
2564
+ // config load. The check is on the bytes about to land, so stripping this project's own duplicate still heals.
2565
+ function assertCodexConfigParses(file, body) {
2566
+ try {
2567
+ parseToml(body);
2568
+ }
2569
+ catch (error) {
2570
+ throw new Error(`refusing to rewrite ${file}: it does not parse as TOML (${error instanceof Error ? error.message : String(error)}); the file is broken or another writer is mid-write — repair it, then rerun`);
2571
+ }
2572
+ }
2171
2573
  // additively stamp PROJECT trust (`[projects."<proj>"] trust_level = "trusted"`) AND the per-hook
2172
2574
  // `trusted_hash` blocks for each event into the user's GLOBAL ~/.codex/config.toml, so a dispatched or
2173
2575
  // self-launched codex trusts THIS project's config layer (enabling hook discovery) AND treats each hook as
@@ -2187,9 +2589,11 @@ export function writeCodexTrust(proj, events, cmdFor) {
2187
2589
  }
2188
2590
  const blk = `# spexcode:trust:${proj} (managed — do not edit)\n${lines.join('\n')}\n# spexcode:trust:end:${proj}`;
2189
2591
  const cleaned = stripCodexTrustFor(existsSync(file) ? readFileSync(file, 'utf8') : '', proj, hooksJson);
2592
+ const next = cleaned ? `${cleaned}\n\n${blk}\n` : `${blk}\n`;
2593
+ assertCodexConfigParses(file, next);
2190
2594
  if (!existsSync(home))
2191
2595
  mkdirSync(home, { recursive: true });
2192
- writeFileIfChanged(file, cleaned ? `${cleaned}\n\n${blk}\n` : `${blk}\n`);
2596
+ writeFileIfChanged(file, next);
2193
2597
  return file;
2194
2598
  }
2195
2599
  // the inverse of writeCodexTrust: strip THIS project's codex trust from the GLOBAL config.toml — the SAME
@@ -2207,7 +2611,9 @@ function removeCodexTrust(proj) {
2207
2611
  !cur.includes(`# spexcode:trust:${proj} `) && !cur.includes(`# spexcode:trust:end:${proj}`))
2208
2612
  return;
2209
2613
  const cleaned = stripCodexTrustFor(cur, proj, hooksJson);
2210
- writeFileSync(file, cleaned ? `${cleaned}\n` : '');
2614
+ const next = cleaned ? `${cleaned}\n` : '';
2615
+ assertCodexConfigParses(file, next);
2616
+ writeFileIfChanged(file, next);
2211
2617
  }
2212
2618
  // is this file git-tracked in proj? (guards cleanHarness's deleteIfEmpty; env-stripped git, never throws)
2213
2619
  function isTrackedFile(proj, f) {
@@ -2233,21 +2639,35 @@ function cleanHarness(h, proj, arts, preserveProject = false) {
2233
2639
  for (const f of h.contractFiles(proj))
2234
2640
  removeManagedBlock(f, ['<!-- ', ' -->'], !isTrackedFile(proj, f));
2235
2641
  const shim = h.shimFile(proj);
2236
- if ((h.shimScope === 'tree' || !preserveProject) && existsSync(shim) && readFileSync(shim, 'utf8').includes('dispatch.sh'))
2237
- rmSync(shim, { force: true });
2642
+ // a SHARED config file is un-written entry by entry (and disappears only if nothing of the user's is left);
2643
+ // a file wholly ours goes whole, gated on its own dispatch.sh stamp.
2644
+ if (h.shimScope === 'tree' || !preserveProject) {
2645
+ if (h.shimOwnership === 'shared-json')
2646
+ removeManagedJsonHooks(shim);
2647
+ else if (existsSync(shim) && readFileSync(shim, 'utf8').includes('dispatch.sh'))
2648
+ rmSync(shim, { force: true });
2649
+ }
2238
2650
  const anchor = h.worktreeHookAnchor(proj); // the linked-worktree anchor copy, same identity gate as the shim
2239
2651
  if (anchor && existsSync(anchor) && readFileSync(anchor, 'utf8').includes('dispatch.sh'))
2240
2652
  rmSync(anchor, { force: true });
2241
2653
  if (!preserveProject)
2242
2654
  h.removeTrust(proj);
2655
+ // the name sweep is identity-gated exactly like the stamp sweep: a live spec node named `distill` says
2656
+ // WHICH path to look at, never that the file sitting there is ours. A user's same-named skill (the write
2657
+ // half now refuses to overwrite it) must survive our uninstall.
2243
2658
  const sd = h.skillDir(proj);
2659
+ const stamped = (f) => existsSync(f) && isGeneratedArtifact(f);
2244
2660
  if (sd)
2245
- for (const n of arts.skills)
2246
- rmSync(join(sd, n), { recursive: true, force: true });
2661
+ for (const n of arts.skills) {
2662
+ if (stamped(join(sd, n, 'SKILL.md')))
2663
+ rmSync(join(sd, n), { recursive: true, force: true });
2664
+ }
2247
2665
  const ad = h.agentDir(proj);
2248
2666
  if (ad)
2249
- for (const n of arts.agents)
2250
- rmSync(join(ad, `${n}.md`), { force: true });
2667
+ for (const n of arts.agents) {
2668
+ if (stamped(join(ad, `${n}.md`)))
2669
+ rmSync(join(ad, `${n}.md`), { force: true });
2670
+ }
2251
2671
  }
2252
2672
  // ---------------------------------------------------------------------------------------------------------
2253
2673
  // codex per-session liveness signal — a codex process live in the pane's DESCENDANT tree, NOT the pane's
@@ -2412,9 +2832,9 @@ export const claudeHarness = {
2412
2832
  dispatchId: 'claude',
2413
2833
  headless: false,
2414
2834
  events: CLAUDE_EVENTS,
2415
- ownsRendezvous: true, // reclaude opens the rendezvous control socket (prompt delivery + liveness)
2835
+ ownsRendezvous: true, // claude's background daemon opens the rendezvous control socket (prompt delivery + liveness)
2416
2836
  paneTitleIsSelfSummary: true, // claude writes its live task summary into the OSC pane title → headline derives from it
2417
- executionTrace: readProjectJsonlExecutionTrace,
2837
+ transcript: claudeTranscript,
2418
2838
  launchCmd: (_id, _rt, cmd) => claudeBaseCmd(cmd), // claude's full invocation IS its base command (the tail is appended by the caller)
2419
2839
  baseCmd: claudeBaseCmd,
2420
2840
  oneShotTurn: (prompt, cmd) => ({ command: `${claudeBaseCmd(cmd)} -p`, stdin: prompt }), // --print reads the prompt from stdin
@@ -2423,6 +2843,7 @@ export const claudeHarness = {
2423
2843
  launchEnv: rendezvousLaunchEnv,
2424
2844
  shimFile: (proj) => join(proj, '.claude', 'settings.json'),
2425
2845
  shimScope: 'tree',
2846
+ shimOwnership: 'shared-json',
2426
2847
  worktreeHookAnchor: () => null, // claude's shim already lives in the worktree (.claude/settings.json) — self-anchors, no root rewrite
2427
2848
  contractFiles: (proj) => [join(proj, 'CLAUDE.md')],
2428
2849
  skillDir: (proj) => join(proj, '.claude', 'skills'),
@@ -2503,7 +2924,13 @@ function codexRuntimeDescriptor(endpoint, runtimeDir) {
2503
2924
  return result.ok ? { healthy: true, referenceIds: result.referenceIds } : { healthy: false, referenceIds: [], error: result.error };
2504
2925
  },
2505
2926
  mutationGuard: (targetReferenceId, opts) => codexMutationGuard(targetReferenceId, runtimeDir, opts, endpoint),
2506
- probe: () => codexSharedRuntimeProbe(runtimeDir, endpoint),
2927
+ probe: (referenceIds) => {
2928
+ // A draining generation keeps serving its bound sessions but is no longer a control-plane candidate.
2929
+ // Listing loaded ids preserves ownership evidence; reading native turn state on that old server only
2930
+ // adds load and can time out while the current generation is healthy.
2931
+ const generation = readCodexGenerationLedger(runtimeDir).generations[endpoint.id];
2932
+ return codexSharedRuntimeProbe(runtimeDir, endpoint, generation?.state === 'draining' ? [] : referenceIds);
2933
+ },
2507
2934
  };
2508
2935
  }
2509
2936
  function codexRuntimeDescriptors(runtimeDir) {
@@ -2525,9 +2952,9 @@ export const codexHarness = {
2525
2952
  headless: false,
2526
2953
  sharedRuntimeSpawn: true,
2527
2954
  events: CODEX_EVENTS,
2528
- ownsRendezvous: false, // no reclaude daemon — liveness + prompts through the project app-server socket
2955
+ ownsRendezvous: false, // no rendezvous daemon — liveness + prompts through the project app-server socket
2529
2956
  paneTitleIsSelfSummary: false, // codex's pane title is a spinner + the cwd folder name, NOT a task summary → headline uses the prompt
2530
- executionTrace: readCodexExecutionTrace,
2957
+ transcript: codexTranscript,
2531
2958
  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
2532
2959
  baseCmd: codexBaseCmd,
2533
2960
  oneShotTurn: (prompt, cmd) => ({ command: `${codexBaseCmd(cmd)} exec -`, stdin: prompt }), // `exec -` reads the prompt from stdin
@@ -2543,6 +2970,7 @@ export const codexHarness = {
2543
2970
  // cwd, so one shared shim serves every worktree.
2544
2971
  shimFile: (proj) => join(mainCheckout(proj), '.codex', 'hooks.json'),
2545
2972
  shimScope: 'project',
2973
+ shimOwnership: 'shared-json',
2546
2974
  // a LINKED worktree also needs its OWN `.codex/hooks.json` so codex-rs anchors the project config layer for
2547
2975
  // the worktree cwd (without a `.codex/` under the worktree root, codex builds no layer, so the rewritten
2548
2976
  // root-checkout hooks are never discovered and NO hooks fire — bypass_hook_trust cannot rescue a layer that
@@ -2789,7 +3217,7 @@ const governedSharedRuntimeOwners = (runtimeDir, descriptorKey, threadId, exclud
2789
3217
  continue;
2790
3218
  let parsed;
2791
3219
  try {
2792
- parsed = JSON.parse(readFileSync(join(root, entry.name, 'session.json'), 'utf8'));
3220
+ parsed = JSON.parse(readFileSync(join(root, entry.name, 'runtime.json'), 'utf8'));
2793
3221
  }
2794
3222
  catch (error) {
2795
3223
  if (error.code === 'ENOENT')
@@ -2825,11 +3253,13 @@ async function codexHeadlessReadinessProof(current) {
2825
3253
  return null;
2826
3254
  const descriptor = codexHeadlessHarness.sharedRuntimes?.(record.runtimeDir)
2827
3255
  .find((candidate) => candidate.key === codexDescriptorKey(endpoint));
2828
- if (!descriptor?.residency)
3256
+ if (!descriptor)
2829
3257
  return null;
2830
3258
  const generationBefore = codexRuntimeGenerationProof(record.runtimeDir, endpoint);
2831
3259
  if (!generationBefore)
2832
3260
  return null;
3261
+ if (!descriptor.residency)
3262
+ return null;
2833
3263
  let resident;
2834
3264
  try {
2835
3265
  resident = await descriptor.residency();
@@ -2874,9 +3304,15 @@ export const codexHeadlessHarness = {
2874
3304
  runtimeOwnership: 'adapter',
2875
3305
  launchOneShot: true,
2876
3306
  launchCmd: (id, runtimeDir, cmd) => codexHeadlessLaunchCommand(id, codexBaseCmd(cmd), undefined, runtimeDir ?? runtimeRoot()),
2877
- // Record-backed liveness is the family contract for sleeping headless threads. An explicit stop is the one
2878
- // offline marker; other app-server/thread failures surface through delivery rather than speculative liveness.
2879
- liveness: recordOnline,
3307
+ // A headless thread has no pane to witness it. The shared app-server generation is therefore the adapter's
3308
+ // minimum liveness witness: a stale session record must not keep a dead project runtime online forever. The
3309
+ // session layer adds the one exact loaded-reference census before publishing this reading on the board.
3310
+ liveness: (rec, _tmuxAlive, runtimeDir) => {
3311
+ if (rec.stopped || rec.archived || !rec.harnessSessionId)
3312
+ return 'offline';
3313
+ const endpoint = codexEndpointForRecord(rec, runtimeDir ?? runtimeRoot());
3314
+ return endpoint && codexRuntimeGenerationProof(runtimeDir ?? runtimeRoot(), endpoint) ? 'online' : 'offline';
3315
+ },
2880
3316
  launchReady: async (current, deadline) => {
2881
3317
  for (;;) {
2882
3318
  const proof = await codexHeadlessReadinessProof(current);
@@ -2904,7 +3340,7 @@ export const codexHeadlessHarness = {
2904
3340
  // hook binding (its lifecycle surface is the in-process extension API), so the shim is a GENERATED TypeScript
2905
3341
  // extension (.pi/extensions/spexcode.ts, run natively by pi) that forwards five claude-shaped events to
2906
3342
  // dispatch.sh AND binds this session's rendezvous socket itself (the adapter's launchEnv exports
2907
- // CLAUDE_BG_RENDEZVOUS_SOCK) speaking the reclaude line protocol — so
3343
+ // CLAUDE_BG_RENDEZVOUS_SOCK) speaking claude's rendezvous line protocol — so
2908
3344
  // deliverViaRendezvous and the socket-listener liveness work through the same adapter seam. Trust: pi gates project-local
2909
3345
  // extensions behind saved per-directory trust (~/.pi/agent/trust.json), so writeTrust stamps the main
2910
3346
  // checkout there (the nearest-parent lookup covers nested worktrees) and the launch carries `--approve` as
@@ -2914,9 +3350,9 @@ export const piHarness = {
2914
3350
  dispatchId: 'pi',
2915
3351
  headless: false,
2916
3352
  events: PI_EVENTS,
2917
- ownsRendezvous: true, // the generated extension binds rvSock(id) and speaks the reclaude protocol
3353
+ ownsRendezvous: true, // the generated extension binds rvSock(id) and speaks claude's rendezvous protocol
2918
3354
  paneTitleIsSelfSummary: false, // pi's pane title is not an agent-written task summary → headline uses the prompt preview
2919
- executionTrace: readSessionJsonlExecutionTrace,
3355
+ transcript: piTranscript,
2920
3356
  launchCmd: (_id, _rt, cmd) => `${piBaseCmd(cmd)} --approve`, // --approve = one-run project trust (belt to writeTrust's braces)
2921
3357
  baseCmd: piBaseCmd,
2922
3358
  sessionIdArg: (id) => `--session-id ${id}`, // caller pins the exact session id, claude-style (created if missing)
@@ -2924,6 +3360,7 @@ export const piHarness = {
2924
3360
  launchEnv: rendezvousLaunchEnv,
2925
3361
  shimFile: (proj) => join(proj, '.pi', 'extensions', 'spexcode.ts'),
2926
3362
  shimScope: 'tree',
3363
+ shimOwnership: 'exclusive',
2927
3364
  worktreeHookAnchor: () => null, // the extension lives in the worktree and self-anchors, like claude
2928
3365
  contractFiles: (proj) => [join(proj, 'AGENTS.md')], // pi auto-loads AGENTS.md context files (shared with codex — writeManagedBlock is idempotent)
2929
3366
  skillDir: (proj) => join(proj, '.pi', 'skills'), // Agent Skills standard dirs, discovered after project trust
@@ -2965,6 +3402,8 @@ export const piHeadlessHarness = {
2965
3402
  launchCmd: (id, runtimeDir, cmd) => piHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), piBaseCmd(cmd)),
2966
3403
  liveness: sessionHomeLiveness,
2967
3404
  deliver: deliverViaPiHeadless,
3405
+ // the controller aborts its own turn child natively and confirms only once that child is gone
3406
+ interrupt: interruptPiHeadless,
2968
3407
  cleanupRuntime: (rec) => unlinkSocks(piHeadlessSock(rec.session), rvSock(rec.session)),
2969
3408
  coldRuntime: async (rec) => {
2970
3409
  const result = await piHeadlessColdRuntime(rec);
@@ -2985,7 +3424,7 @@ export const zcodeHarness = {
2985
3424
  events: ZCODE_EVENTS,
2986
3425
  ownsRendezvous: false,
2987
3426
  paneTitleIsSelfSummary: false,
2988
- executionTrace: noExecutionTrace,
3427
+ transcript: unsupportedTranscript('zcode'),
2989
3428
  launchCmd: (_id, _rt, cmd) => `${zcodeBaseCmd(cmd)} --prompt`,
2990
3429
  baseCmd: zcodeBaseCmd,
2991
3430
  // z-code's one-turn launcher already IS the non-interactive shape; it takes the prompt as an argument.
@@ -2995,6 +3434,7 @@ export const zcodeHarness = {
2995
3434
  launchEnv: noLaunchEnv,
2996
3435
  shimFile: (proj) => join(proj, '.zcode', 'settings.json'),
2997
3436
  shimScope: 'tree',
3437
+ shimOwnership: 'shared-json',
2998
3438
  worktreeHookAnchor: () => null,
2999
3439
  contractFiles: (proj) => [join(proj, 'AGENTS.md')],
3000
3440
  skillDir: (proj) => join(proj, '.zcode', 'skills'),
@@ -3020,7 +3460,7 @@ export const opencodeHarness = {
3020
3460
  // socket the launch env hands it, so the shared reply poke and socket-listener liveness are reused verbatim.
3021
3461
  ownsRendezvous: true,
3022
3462
  paneTitleIsSelfSummary: false, // opencode's TUI title is not the agent's live task self-summary → headline uses the prompt
3023
- executionTrace: readLocalStoreExecutionTrace,
3463
+ transcript: opencodeTranscript,
3024
3464
  launchCmd: (_id, _rt, cmd) => opencodeLaunchCommand(opencodeBaseCmd(cmd)), // the tail-branching script (prompt vs --resume/--continue marker)
3025
3465
  baseCmd: opencodeBaseCmd,
3026
3466
  // `opencode run` takes the message positionally; it documents no stdin form, so the prompt is an argument.
@@ -3036,6 +3476,7 @@ export const opencodeHarness = {
3036
3476
  // by walking the cwd, so like claude it self-anchors and needs no root-checkout rewrite or worktree anchor.
3037
3477
  shimFile: (proj) => join(proj, '.opencode', 'plugins', 'spexcode.ts'),
3038
3478
  shimScope: 'tree',
3479
+ shimOwnership: 'exclusive',
3039
3480
  worktreeHookAnchor: () => null,
3040
3481
  contractFiles: (proj) => [join(proj, 'AGENTS.md')], // opencode reads AGENTS.md natively (same file codex owns; the managed block is idempotent across writers)
3041
3482
  skillDir: (proj) => join(proj, '.opencode', 'skills'),
@@ -3080,6 +3521,9 @@ export const opencodeHeadlessHarness = {
3080
3521
  deliver: async (rec, text) => {
3081
3522
  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`);
3082
3523
  },
3524
+ // `opencode run` serves the rendezvous socket only for the turn it runs, so the plugin's session abort IS the
3525
+ // interrupt, and the listener going dead is the turn's exit — confirmed only then, so the next delivery wakes cold
3526
+ interrupt: (rec) => interruptViaRendezvous(rec.session, 'opencode-headless', { settle: true }),
3083
3527
  };
3084
3528
  // every adapter — materialize iterates this to write each harness's artifacts in one pass.
3085
3529
  export const HARNESSES = [claudeHarness, codexHarness, opencodeHarness, piHarness, zcodeHarness, claudeHeadlessHarness, opencodeHeadlessHarness, piHeadlessHarness, codexHeadlessHarness];
@@ -3109,12 +3553,6 @@ export function launcherList(root = mainCheckout()) {
3109
3553
  })
3110
3554
  .sort((a, b) => a.name.localeCompare(b.name));
3111
3555
  }
3112
- // The dashboard's visibility projection. It never removes a launcher from the complete config/CLI path;
3113
- // it only narrows GET /api/settings for the New Session picker ([[launcher-visibility]]).
3114
- export function dashboardLauncherList(root = mainCheckout()) {
3115
- const showHeadless = readConfig(root).dashboard?.showHeadlessLaunchers === true;
3116
- return launcherList(root).filter((launcher) => showHeadless || !launcher.headless);
3117
- }
3118
3556
  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"}})';
3119
3557
  // the configured default launcher NAME ([[launcher-select]]) — the profile `spex session new`/a dropdown pick with no
3120
3558
  // explicit choice resolves. Missing config is a fail-loud setup error, never an implicit fallthrough to a