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
@@ -89,8 +89,16 @@ export const SpexcodePlugin = async (ctx) => {
89
89
  }
90
90
 
91
91
  // The shared rendezvous server receives best-effort reply pokes. An unadopted plugin simply leaves the
92
- // timeline line for its next turn boundary; the SDK injection remains host-owned.
93
- rt.serveRendezvous(injectPrompt, { canInject: () => !!(client && rootSession) })
92
+ // timeline line for its next turn boundary; the SDK injection remains host-owned. An interrupt is
93
+ // opencode's own session abort on the adopted root session, confirmed back over the same socket.
94
+ rt.serveRendezvous(injectPrompt, {
95
+ canInject: () => !!(client && rootSession),
96
+ interrupt: async () => {
97
+ if (!client || !rootSession) throw new Error("no opencode session is adopted - nothing to interrupt")
98
+ const r = await client.session.abort({ path: { id: rootSession } })
99
+ if (r === false || (r && r.data === false)) throw new Error("no opencode turn is running - nothing to interrupt")
100
+ },
101
+ })
94
102
 
95
103
  const toolPayload = (input, output) => {
96
104
  const sid = (input && input.sessionID) || ""
@@ -39,21 +39,34 @@ export default function spexcode(pi: ExtensionAPI) {
39
39
  const toolFields = (toolName: string, input: unknown) =>
40
40
  ({ tool_name: TOOL[toolName] ?? toolName, tool_input: rt.toolInput(input, "path") })
41
41
 
42
+ // the RUNNING turn's context, kept from the events pi hands us mid-turn: ctx.abort() is pi's own "stop the
43
+ // current agent operation" — the one honest interrupt — and only a turn that is actually streaming has one.
44
+ let turn: { abort: () => void; isIdle: () => boolean } | null = null
42
45
  // bound as soon as the extension loads so liveness reads online early; only a governed launch carries the
43
46
  // socket env (sessions.ts rvEnv) — a self-launched bare \`pi\` skips this.
44
- const rv = rt.serveRendezvous((text: string) => pi.sendUserMessage(text, { deliverAs: "steer" }))
47
+ const rv = rt.serveRendezvous((text: string) => pi.sendUserMessage(text, { deliverAs: "steer" }), {
48
+ interrupt: () => {
49
+ if (!turn || turn.isIdle()) throw new Error("no pi turn is running - nothing to interrupt")
50
+ turn.abort()
51
+ },
52
+ })
45
53
 
46
54
  pi.on("session_start", async (_event, ctx) => {
47
55
  sessionId = ctx.sessionManager.getSessionId() || sessionId
48
56
  process.env.PI_SESSION_ID = sessionId // tool subprocesses inherit it → spex CLI knows its session
49
57
  await rt.dispatchEvent("SessionStart")
50
58
  })
51
- pi.on("input", async (event) => { await rt.dispatchEvent("UserPromptSubmit", { prompt: event.text }) })
52
- pi.on("tool_call", async (event) => {
59
+ // agent_start is the earliest event a running turn hands us before the first model token — so an
60
+ // interrupt that arrives while the model is still thinking finds a turn to abort rather than nothing.
61
+ pi.on("agent_start", async (_event, ctx) => { turn = ctx })
62
+ pi.on("turn_start", async (_event, ctx) => { turn = ctx })
63
+ pi.on("input", async (event, ctx) => { turn = ctx; await rt.dispatchEvent("UserPromptSubmit", { prompt: event.text }) })
64
+ pi.on("tool_call", async (event, ctx) => {
65
+ turn = ctx
53
66
  const r = await rt.dispatchEvent("PreToolUse", toolFields(event.toolName, event.input))
54
67
  if (rt.blocked(r)) return { block: true, reason: rt.blockReason(r, "blocked by a SpexCode hook") }
55
68
  })
56
- pi.on("tool_result", async (event) => { await rt.dispatchEvent("PostToolUse", toolFields(event.toolName, event.input)) })
69
+ pi.on("tool_result", async (event, ctx) => { turn = ctx; await rt.dispatchEvent("PostToolUse", toolFields(event.toolName, event.input)) })
57
70
  // Stop rides the runtime's dispatchStop on TWO bindings that never duplicate: agent_end performs the
58
71
  // NORMAL dispatch (pi awaits its listeners inside the run loop and drains a queued teach as the SAME
59
72
  // awaited prompt's continuation — never the orphaned settle-time prompt whose late inject threw
@@ -69,7 +82,7 @@ export default function spexcode(pi: ExtensionAPI) {
69
82
  async (reason: string) => pi.sendUserMessage(reason, { deliverAs: "steer" }),
70
83
  "a SpexCode Stop hook blocked this stop without giving a reason",
71
84
  )
72
- pi.on("agent_end", async () => { await stop() })
85
+ pi.on("agent_end", async () => { turn = null; await stop() })
73
86
  pi.on("agent_settled", async () => { if (rt.stopPending()) await stop() })
74
87
  pi.on("session_shutdown", async () => { rv?.close() })
75
88
  }
@@ -3,6 +3,7 @@ import type { DispatchResult, HarnessDeliveryRecord } from './harness.js';
3
3
  export declare const piHeadlessSock: (id: string) => string;
4
4
  export declare function piHeadlessLaunchCommand(id: string, runtimeDir: string, piCmd: string): string;
5
5
  export declare const deliverViaPiHeadless: (rec: HarnessDeliveryRecord, text: string) => Promise<DispatchResult>;
6
+ export declare const interruptPiHeadless: (rec: HarnessDeliveryRecord) => Promise<DispatchResult>;
6
7
  export declare function piHeadlessColdRuntime(rec: Pick<HarnessDeliveryRecord, 'session'>): Promise<DispatchResult>;
7
8
  export declare class PiHeadlessController {
8
9
  private readonly id;
@@ -19,6 +20,7 @@ export declare class PiHeadlessController {
19
20
  private terminateTurn;
20
21
  private waitForExit;
21
22
  private accept;
23
+ private interrupt;
22
24
  private handle;
23
25
  private spawnTurn;
24
26
  }
@@ -12,6 +12,7 @@ const CONTROL_TIMEOUT_MS = 30_000;
12
12
  const START_TIMEOUT_MS = 30_000;
13
13
  const TERM_EXIT_GRACE_MS = 500;
14
14
  const KILL_EXIT_GRACE_MS = 2_000;
15
+ const INTERRUPT_EXIT_GRACE_MS = 15_000;
15
16
  /** The resident controller socket is distinct from pi's per-turn rendezvous socket. */
16
17
  export const piHeadlessSock = (id) => join(tmpdir(), `spexcode-ph-${id}.sock`);
17
18
  export function piHeadlessLaunchCommand(id, runtimeDir, piCmd) {
@@ -21,6 +22,13 @@ export const deliverViaPiHeadless = (rec, text) => controlRequest(piHeadlessSock
21
22
  name: 'pi-headless', session: rec.session, timeoutMs: CONTROL_TIMEOUT_MS,
22
23
  rejected: 'pi-headless controller rejected the request',
23
24
  });
25
+ // The controller owns the turn child, so it is the one actor that can both abort the turn natively and
26
+ // know when that turn is actually over — an interrupt confirmed here means no pi process serves this
27
+ // session any more, and the next delivery is a clean cold wake rather than a poke into an exiting agent.
28
+ export const interruptPiHeadless = (rec) => controlRequest(piHeadlessSock(rec.session), { type: 'interrupt' }, {
29
+ name: 'pi-headless', session: rec.session, timeoutMs: CONTROL_TIMEOUT_MS,
30
+ rejected: 'pi-headless controller rejected the interrupt',
31
+ });
24
32
  // The resident controller and a running pi turn own two per-session listeners. The generic lifecycle
25
33
  // teardown has already proved and removed the exact controller leaf before this runs; cold filing is valid
26
34
  // only once neither listener can still accept work for this session.
@@ -137,7 +145,25 @@ export class PiHeadlessController {
137
145
  });
138
146
  });
139
147
  }
148
+ // Abort the running turn through pi's own ctx.abort() (the shim answers over the child's rendezvous socket,
149
+ // so pi records the turn as aborted and the conversation stays resumable), then wait for that child to
150
+ // exit. A turn whose extension holds no context yet — pi still booting, before its first agent event —
151
+ // is still THIS controller's process, so it is terminated as the owner rather than left to run.
152
+ async interrupt() {
153
+ const turn = this.child;
154
+ if (!turn || turn.process.exitCode !== null)
155
+ return { ok: false, error: `no pi-headless turn is running for session ${this.id} - nothing to interrupt` };
156
+ const { interruptViaRendezvous } = await import('./harness.js');
157
+ const aborted = await interruptViaRendezvous(this.id, 'pi-headless');
158
+ if (!aborted.ok && !/no pi turn is running|nothing to interrupt/.test(aborted.error || ''))
159
+ return aborted;
160
+ if (!aborted.ok || !await this.waitForExit(turn, INTERRUPT_EXIT_GRACE_MS))
161
+ await this.terminateTurn(turn);
162
+ return { ok: true };
163
+ }
140
164
  async handle(request) {
165
+ if (request.type === 'interrupt')
166
+ return this.interrupt();
141
167
  if (request.type !== 'deliver')
142
168
  return { ok: false, error: 'unknown pi-headless control request' };
143
169
  if (!request.text)
@@ -171,6 +197,7 @@ export class PiHeadlessController {
171
197
  if (this.child === turn)
172
198
  this.child = null;
173
199
  resolveExit(code);
200
+ // an aborted child leaves non-zero too; the session layer's interrupt marker reads that exit as the interrupt it was
174
201
  if (code !== 0 && !this.closing)
175
202
  void import('./harness.js').then(({ reportHeadlessTurnExit }) => reportHeadlessTurnExit(this.id, 'pi-headless', code, this.cwd));
176
203
  });
@@ -1,7 +1,7 @@
1
1
  import { execFile, spawn } from 'node:child_process';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { promisify } from 'node:util';
4
- import { alive, withSessionInputLock } from './sessions.js';
4
+ import { alive } from './sessions.js';
5
5
  const pexec = promisify(execFile);
6
6
  const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode';
7
7
  const HELPER = fileURLToPath(new URL('./pty-helper.mjs', import.meta.url));
@@ -9,8 +9,9 @@ const subscribers = new Map();
9
9
  const BSU = Buffer.from('\x1b[?2026h');
10
10
  const ESU = Buffer.from('\x1b[?2026l');
11
11
  const GEOMETRY_STABILIZATION_MS = 400;
12
- // A hidden but still-alive browser keeps its own client briefly so a quick return is continuous. A dead
13
- // socket bypasses this window and detaches immediately.
12
+ // A hidden but still-alive browser keeps its own native client briefly so a quick return skips helper spawn
13
+ // and tmux attach. It receives no output while hidden; the return always repaints. A dead socket bypasses
14
+ // this window and detaches immediately.
14
15
  const LINGER_MS = Number(process.env.SPEXCODE_TERM_LINGER_MS) > 0 ? Number(process.env.SPEXCODE_TERM_LINGER_MS) : 30_000;
15
16
  function subscriptionMap(id) {
16
17
  let map = subscribers.get(id);
@@ -26,7 +27,7 @@ function isCurrent(bridge) {
26
27
  }
27
28
  function deliver(bridge, data) {
28
29
  const subscription = currentSubscription(bridge.id, bridge.viewer);
29
- if (!subscription || (!subscription.visible && !subscription.lingering))
30
+ if (!subscription?.visible)
30
31
  return;
31
32
  try {
32
33
  bridge.viewer.send(data);
@@ -174,7 +175,6 @@ function cancelLinger(subscription) {
174
175
  if (subscription.lingerTimer)
175
176
  clearTimeout(subscription.lingerTimer);
176
177
  subscription.lingerTimer = undefined;
177
- subscription.lingering = false;
178
178
  }
179
179
  function cancelRestore(subscription) {
180
180
  if (subscription.restoreTimer)
@@ -309,7 +309,7 @@ export function attachViewer(id, viewer) {
309
309
  const previous = map.get(viewer);
310
310
  if (previous)
311
311
  killBridge(previous);
312
- map.set(viewer, { visible: false, lingering: false, cols: 0, rows: 0 });
312
+ map.set(viewer, { visible: false, cols: 0, rows: 0 });
313
313
  }
314
314
  export function hideViewer(id, viewer) {
315
315
  const subscription = currentSubscription(id, viewer);
@@ -318,7 +318,6 @@ export function hideViewer(id, viewer) {
318
318
  subscription.visible = false;
319
319
  if (!subscription.bridge)
320
320
  return;
321
- subscription.lingering = true;
322
321
  if (subscription.lingerTimer)
323
322
  return;
324
323
  subscription.lingerTimer = setTimeout(() => {
@@ -346,15 +345,15 @@ export function resizeBridge(id, viewer, colsValue, rowsValue) {
346
345
  const subscription = currentSubscription(id, viewer);
347
346
  if (!subscription)
348
347
  return;
349
- const seamless = subscription.lingering && !!subscription.bridge
350
- && subscription.bridge.cols === cols && subscription.bridge.rows === rows;
351
348
  cancelLinger(subscription);
352
349
  subscription.visible = true;
353
350
  subscription.cols = cols;
354
351
  subscription.rows = rows;
355
352
  const { bridge, created } = ensureBridge(id, viewer, subscription, cols, rows);
356
- if (!bridge || created || seamless)
353
+ if (!bridge || created)
357
354
  return;
355
+ // A helper kept alive across a hidden window has been rendering to nobody: the browser cache behind this
356
+ // claim is stale by exactly that window, so an unchanged grid still answers with one atomic repaint.
358
357
  if (bridge.cols === cols && bridge.rows === rows) {
359
358
  beginDelivery(bridge);
360
359
  queueRefresh(bridge);
@@ -364,14 +363,15 @@ export function resizeBridge(id, viewer, colsValue, rowsValue) {
364
363
  }
365
364
  }
366
365
  const MAX_INPUT_BYTES = 64 * 1024;
366
+ // Input is transport: it goes straight to the helper. It never waits for, or is refused by, the session record
367
+ // lock — that lock serializes lifecycle writers, which hold it for a noticeable share of a busy session's wall
368
+ // time, and a keystroke that lost that race used to vanish silently.
367
369
  export function forwardInput(id, viewer, data) {
368
370
  const subscription = currentSubscription(id, viewer);
369
371
  if (!subscription?.visible || !subscription.bridge || !data || Buffer.byteLength(data, 'utf8') > MAX_INPUT_BYTES)
370
372
  return false;
371
- return withSessionInputLock(id, () => {
372
- sendControl(subscription.bridge, { t: 'input', data });
373
- return true;
374
- }) ?? false;
373
+ sendControl(subscription.bridge, { t: 'input', data });
374
+ return true;
375
375
  }
376
376
  async function restoreBridge(id, viewer, subscription) {
377
377
  subscription.restoreTimer = undefined;
@@ -86,7 +86,9 @@ export async function issuesReview(query, requestedPage) {
86
86
  }),
87
87
  };
88
88
  }
89
- const byNewest = (a, b) => Number(b.filterKind === EVAL_FILTER_KIND.RESULT) - Number(a.filterKind === EVAL_FILTER_KIND.RESULT)
89
+ const orderableEval = (item) => item.filterKind === EVAL_FILTER_KIND.RESULT
90
+ || item.filterKind === EVAL_FILTER_KIND.DEFERRED;
91
+ const byNewest = (a, b) => Number(orderableEval(b)) - Number(orderableEval(a))
90
92
  || String(b.ts ?? '').localeCompare(String(a.ts ?? ''))
91
93
  || String(a.node ?? '').localeCompare(String(b.node ?? ''))
92
94
  || String(a.scenario ?? '').localeCompare(String(b.scenario ?? ''));
@@ -114,10 +116,11 @@ export function trunkEvalReviewItems(nodes) {
114
116
  ...reading,
115
117
  expected: scenario.expected ?? reading.expected,
116
118
  tags: scenario.tags,
117
- state: evalReviewState(reading),
119
+ ...(reading.freshnessDeferred
120
+ ? { state: 'deferred', filterKind: EVAL_FILTER_KIND.DEFERRED }
121
+ : { state: evalReviewState(reading), filterKind: EVAL_FILTER_KIND.RESULT }),
118
122
  node: node.id,
119
123
  hue: node.hue,
120
- filterKind: EVAL_FILTER_KIND.RESULT,
121
124
  });
122
125
  }
123
126
  }
@@ -149,10 +152,11 @@ export function scopedEvalReviewItems(model) {
149
152
  expected: scenario.expected ?? reading.expected,
150
153
  tags: scenario.tags,
151
154
  impact: scenario.impact,
152
- state: evalReviewState(reading),
155
+ ...(reading.freshnessDeferred
156
+ ? { state: 'deferred', filterKind: EVAL_FILTER_KIND.DEFERRED }
157
+ : { state: evalReviewState(reading), filterKind: EVAL_FILTER_KIND.RESULT }),
153
158
  node: node.id,
154
159
  hue: node.hue,
155
- filterKind: EVAL_FILTER_KIND.RESULT,
156
160
  };
157
161
  items.push(item);
158
162
  }
@@ -278,9 +282,10 @@ export function timelineEvalReviewItems(timeline, node) {
278
282
  })),
279
283
  ...[...latest.values()].map((reading) => ({
280
284
  ...reading,
281
- state: evalReviewState(reading),
285
+ ...(reading.freshnessDeferred
286
+ ? { state: 'deferred', filterKind: EVAL_FILTER_KIND.DEFERRED }
287
+ : { state: evalReviewState(reading), filterKind: EVAL_FILTER_KIND.RESULT }),
282
288
  node,
283
- filterKind: EVAL_FILTER_KIND.RESULT,
284
289
  filterKey: `${EVAL_FILTER_KIND.RESULT}:${reading.scenario}`,
285
290
  })),
286
291
  ...(timeline.dangling ?? []).map((track) => ({
@@ -10,6 +10,16 @@ export type BackendInstanceRecord = {
10
10
  export declare function readBackendInstanceRecords(root?: string): BackendInstanceRecord[];
11
11
  export declare function registerBackendInstance(instanceId: string, pid?: number, root?: string): BackendInstanceRecord;
12
12
  export declare function unregisterBackendInstance(instanceId: string, pid?: number): void;
13
+ type ProcBackendCandidate = BackendInstanceRecord & {
14
+ candidatePid: number;
15
+ command: string;
16
+ };
17
+ /**
18
+ * Reap only backend children whose supervisor identity is provably stale. A dead registry PID alone is not
19
+ * enough: the live process must carry the exact instance id and project root, run the backend entrypoint, and
20
+ * have been orphaned to PID 1. Ordinary launchers, shells, and app-server generations do not match.
21
+ */
22
+ export declare function reapOrphanBackendInstances(root?: string): ProcBackendCandidate[];
13
23
  export declare function spawnDetachedRuntime(opts: {
14
24
  cwd: string;
15
25
  logFile: string;
@@ -19,3 +29,4 @@ export declare function spawnDetachedRuntime(opts: {
19
29
  args: string[];
20
30
  env?: NodeJS.ProcessEnv;
21
31
  }): ProcessIdentity;
32
+ export {};
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { spawn } from 'node:child_process';
3
- import { closeSync, mkdirSync, openSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { closeSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { repoRoot } from '@spexcode/spec-core';
6
6
  import { readJsonConfig, runtimeRoot } from '@spexcode/spec-core';
@@ -47,6 +47,84 @@ export function unregisterBackendInstance(instanceId, pid = process.pid) {
47
47
  }
48
48
  catch { /* not ours / already removed */ }
49
49
  }
50
+ const procRows = () => {
51
+ try {
52
+ return readdirSync('/proc').filter((name) => /^\d+$/.test(name)).map(Number);
53
+ }
54
+ catch {
55
+ return [];
56
+ }
57
+ };
58
+ const procEnv = (pid) => {
59
+ try {
60
+ return Object.fromEntries(readFileSync(`/proc/${pid}/environ`, 'utf8').split('\0').flatMap((entry) => {
61
+ const split = entry.indexOf('=');
62
+ return split > 0 ? [[entry.slice(0, split), entry.slice(split + 1)]] : [];
63
+ }));
64
+ }
65
+ catch {
66
+ return {};
67
+ }
68
+ };
69
+ const procCommand = (pid) => {
70
+ try {
71
+ return readFileSync(`/proc/${pid}/cmdline`, 'utf8').replaceAll('\0', ' ').trim();
72
+ }
73
+ catch {
74
+ return '';
75
+ }
76
+ };
77
+ const procParent = (pid) => {
78
+ try {
79
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
80
+ const close = stat.lastIndexOf(')');
81
+ const fields = stat.slice(close + 2).split(' ');
82
+ return Number(fields[1]) || null;
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ };
88
+ const isBackendCommand = (command) => /(?:^|\/)spec-cli\/(?:src\/index\.ts|dist\/index\.js)(?:\s|$)/.test(command);
89
+ /**
90
+ * Reap only backend children whose supervisor identity is provably stale. A dead registry PID alone is not
91
+ * enough: the live process must carry the exact instance id and project root, run the backend entrypoint, and
92
+ * have been orphaned to PID 1. Ordinary launchers, shells, and app-server generations do not match.
93
+ */
94
+ export function reapOrphanBackendInstances(root = repoRoot()) {
95
+ const records = readBackendInstanceRecords(root);
96
+ const liveCurrent = records.filter((record) => processStartToken(record.pid) === record.startToken);
97
+ const currentIds = new Set(liveCurrent.map((record) => record.instanceId));
98
+ const candidates = [];
99
+ for (const record of records) {
100
+ if (currentIds.has(record.instanceId))
101
+ continue;
102
+ if (processStartToken(record.pid) === record.startToken)
103
+ continue;
104
+ for (const pid of procRows()) {
105
+ const env = procEnv(pid);
106
+ if (env.SPEXCODE_INSTANCE_ID !== record.instanceId || env.SPEXCODE_PROJECT_ROOT !== root)
107
+ continue;
108
+ if (procParent(pid) !== 1)
109
+ continue;
110
+ const command = procCommand(pid);
111
+ if (!isBackendCommand(command))
112
+ continue;
113
+ candidates.push({ ...record, candidatePid: pid, command });
114
+ }
115
+ }
116
+ for (const candidate of candidates) {
117
+ try {
118
+ process.kill(candidate.candidatePid, 'SIGTERM');
119
+ }
120
+ catch { /* exact process already exited */ }
121
+ try {
122
+ rmSync(backendInstancePath(candidate.instanceId), { force: true });
123
+ }
124
+ catch { /* stale record is advisory */ }
125
+ }
126
+ return candidates;
127
+ }
50
128
  export function spawnDetachedRuntime(opts) {
51
129
  mkdirSync(dirname(opts.logFile), { recursive: true });
52
130
  mkdirSync(dirname(opts.pidFile), { recursive: true });
@@ -0,0 +1,23 @@
1
+ import { type CommittedSessionChange, type ProductionSessionApplication } from '@spexcode/session-application';
2
+ type SessionApplicationCommitWake = (recipients: readonly string[]) => void;
3
+ /** The application owns commit ordering; Spex supplies the adopter transport wake. */
4
+ export declare function setSessionApplicationCommitWake(wake: SessionApplicationCommitWake): void;
5
+ /** Allow the adopter to refresh its board stream after the canonical transaction commits. */
6
+ export declare function setSessionApplicationCommitObserver(observer: (change: Pick<CommittedSessionChange, 'recipients'>) => void): void;
7
+ /** The backend's sole session application composition. Path selection is shared with self-launch. */
8
+ export declare function configuredSessionApplication(): ProductionSessionApplication;
9
+ export type SessionApplicationCutoverState = 'ready' | 'residue' | 'fresh' | 'migration-required' | 'fenced' | 'ambiguous';
10
+ /** Inspect cutover state without initializing a fresh database as a side effect of a rejected request. */
11
+ export declare function sessionApplicationCutoverState(): SessionApplicationCutoverState;
12
+ /** Resolve the canonical application. A legacy tree is cut over at its first canonical access; runtime code
13
+ * never gets to choose between JSON and SQLite based on the caller or request path. */
14
+ export declare function configuredSessionApplicationIfCutover(): ProductionSessionApplication | undefined;
15
+ /** Initialize a fresh canonical store only after an accepted create has crossed its no-side-effect boundary. */
16
+ export declare function initializeFreshSessionApplication(): ProductionSessionApplication;
17
+ export declare function acquireFreshSessionApplicationForCreate(): {
18
+ application: ProductionSessionApplication;
19
+ owned: boolean;
20
+ };
21
+ export declare function releaseFreshSessionApplicationForCreate(owned: boolean, committed: boolean): void;
22
+ export declare function resetConfiguredSessionApplicationForTest(): void;
23
+ export {};
@@ -0,0 +1,189 @@
1
+ import { existsSync, readdirSync, rmSync, unlinkSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { openProjectSessionApplication, legacyResidueExists, migrateJsonSessionRecords, } from '@spexcode/session-application';
4
+ import { requireLocalDatabasePath, resolveDatabasePath } from '@spexcode/session-selflaunch';
5
+ import { runtimeRoot } from '@spexcode/spec-core';
6
+ import { jsonMigrationFencePath } from '@spexcode/session-application';
7
+ let cached;
8
+ let cachedPath;
9
+ let freshStoreOwned = false;
10
+ let freshStoreCommitted = false;
11
+ let freshStoreLeases = 0;
12
+ let commitWake = () => { };
13
+ let commitObserver;
14
+ /** The application owns commit ordering; Spex supplies the adopter transport wake. */
15
+ export function setSessionApplicationCommitWake(wake) {
16
+ commitWake = wake;
17
+ }
18
+ /** Allow the adopter to refresh its board stream after the canonical transaction commits. */
19
+ export function setSessionApplicationCommitObserver(observer) {
20
+ commitObserver = observer;
21
+ }
22
+ /** The backend's sole session application composition. Path selection is shared with self-launch. */
23
+ export function configuredSessionApplication() {
24
+ const databasePath = resolveDatabasePath();
25
+ if (cached !== undefined && cachedPath === databasePath)
26
+ return cached;
27
+ if (cached !== undefined)
28
+ cached.close();
29
+ cached = openProjectSessionApplication({
30
+ databasePath,
31
+ locality: path => { requireLocalDatabasePath(path); },
32
+ onCommitted: result => {
33
+ commitWake(result.recipients);
34
+ if (commitObserver)
35
+ setImmediate(() => commitObserver?.(result));
36
+ },
37
+ onRuntimeBound: sessionId => commitWake([sessionId]),
38
+ });
39
+ cachedPath = databasePath;
40
+ return cached;
41
+ }
42
+ // A marked store is `ready` only once its legacy tree holds nothing left to absorb. The scan runs once per process
43
+ // per store: after residue is migrated (or none was found) no legacy writer exists to create more, so later calls
44
+ // answer from memory instead of walking every session directory on the hot path.
45
+ const settledResidueStores = new Set();
46
+ /** Inspect cutover state without initializing a fresh database as a side effect of a rejected request. */
47
+ export function sessionApplicationCutoverState() {
48
+ const databasePath = resolveDatabasePath();
49
+ const recordsRoot = join(runtimeRoot(), 'sessions');
50
+ const storeKey = `${databasePath}\0${recordsRoot}`;
51
+ if (existsSync(`${databasePath}.json-migration.json`)) {
52
+ if (settledResidueStores.has(storeKey))
53
+ return 'ready';
54
+ if (legacyResidueExists(recordsRoot))
55
+ return 'residue';
56
+ settledResidueStores.add(storeKey);
57
+ return 'ready';
58
+ }
59
+ if (existsSync(jsonMigrationFencePath(recordsRoot)))
60
+ return 'fenced';
61
+ if (existsSync(recordsRoot) && readdirSync(recordsRoot, { withFileTypes: true }).some(entry => entry.isDirectory()))
62
+ return 'migration-required';
63
+ if (existsSync(databasePath))
64
+ return 'ambiguous';
65
+ return 'fresh';
66
+ }
67
+ /** Resolve the canonical application. A legacy tree is cut over at its first canonical access; runtime code
68
+ * never gets to choose between JSON and SQLite based on the caller or request path. */
69
+ export function configuredSessionApplicationIfCutover() {
70
+ const state = sessionApplicationCutoverState();
71
+ if (state === 'ready')
72
+ return configuredSessionApplication();
73
+ if (state === 'migration-required' || state === 'residue')
74
+ return initializeMigratedSessionApplication(state);
75
+ if (state === 'fresh')
76
+ return initializeFreshSessionApplication();
77
+ if (state === 'fenced' || state === 'ambiguous') {
78
+ throw new Error(`session application cutover is ${state}; refusing a legacy/runtime split`);
79
+ }
80
+ return undefined;
81
+ }
82
+ // One importer for both shapes of legacy tree. Before the marker it installs the canonical store; after the
83
+ // marker it absorbs whatever residue the tree still holds into the store this process already owns, then
84
+ // retires the tree. Either way the tree is gone when this returns, so the cutover state settles to `ready`.
85
+ function initializeMigratedSessionApplication(state) {
86
+ const databasePath = resolveDatabasePath();
87
+ const recordsRoot = join(runtimeRoot(), 'sessions');
88
+ const locality = (path) => { requireLocalDatabasePath(path); };
89
+ if (state === 'migration-required') {
90
+ migrateJsonSessionRecords({ databasePath, recordsRoot, locality });
91
+ return configuredSessionApplication();
92
+ }
93
+ const application = configuredSessionApplication();
94
+ const report = migrateJsonSessionRecords({ databasePath, recordsRoot, locality, application });
95
+ settledResidueStores.add(`${databasePath}\0${recordsRoot}`);
96
+ const residue = report.residue;
97
+ if (residue) {
98
+ console.log(`[session-application] migrated legacy residue from ${recordsRoot}: ${residue.records} record(s), ${residue.events} event(s), ${residue.parentEdges} parent edge(s), ${residue.watchEdges} watch edge(s), ${residue.pending} pending; ${residue.unclaimed.length} unclaimed dir(s)${residue.unclaimed.length ? ` (${residue.unclaimed.join(', ')})` : ''}; backup ${residue.backupRoot}`);
99
+ }
100
+ return application;
101
+ }
102
+ /** Initialize a fresh canonical store only after an accepted create has crossed its no-side-effect boundary. */
103
+ export function initializeFreshSessionApplication() {
104
+ const state = sessionApplicationCutoverState();
105
+ if (state === 'ready')
106
+ return configuredSessionApplication();
107
+ if (state === 'residue')
108
+ return initializeMigratedSessionApplication(state);
109
+ if (state !== 'fresh')
110
+ throw new Error(`cannot initialize a fresh session store from cutover state: ${state}`);
111
+ const databasePath = resolveDatabasePath();
112
+ const recordsRoot = join(runtimeRoot(), 'sessions');
113
+ migrateJsonSessionRecords({ databasePath, recordsRoot, locality: path => { requireLocalDatabasePath(path); } });
114
+ return configuredSessionApplication();
115
+ }
116
+ export function acquireFreshSessionApplicationForCreate() {
117
+ const state = sessionApplicationCutoverState();
118
+ if (state === 'fresh') {
119
+ const application = initializeFreshSessionApplication();
120
+ freshStoreOwned = true;
121
+ freshStoreCommitted = false;
122
+ freshStoreLeases++;
123
+ return { application, owned: true };
124
+ }
125
+ if (state === 'ready' && freshStoreOwned && !freshStoreCommitted) {
126
+ freshStoreLeases++;
127
+ return { application: configuredSessionApplication(), owned: true };
128
+ }
129
+ if (state === 'residue')
130
+ return { application: initializeMigratedSessionApplication(state), owned: false };
131
+ if (state !== 'ready')
132
+ throw new Error(`cannot initialize a session store from cutover state: ${state}`);
133
+ return { application: configuredSessionApplication(), owned: false };
134
+ }
135
+ export function releaseFreshSessionApplicationForCreate(owned, committed) {
136
+ if (!owned)
137
+ return;
138
+ if (committed)
139
+ freshStoreCommitted = true;
140
+ freshStoreLeases = Math.max(0, freshStoreLeases - 1);
141
+ if (freshStoreLeases !== 0)
142
+ return;
143
+ if (freshStoreCommitted) {
144
+ try {
145
+ unlinkSync(jsonMigrationFencePath(join(runtimeRoot(), 'sessions')));
146
+ }
147
+ catch (error) {
148
+ if (error.code !== 'ENOENT')
149
+ throw error;
150
+ }
151
+ return;
152
+ }
153
+ const application = cached;
154
+ const rows = application?.protocol.withTransaction(tx => Number(tx.query('SELECT COUNT(*) AS count FROM session_application_state')[0]?.count ?? 0)) ?? 0;
155
+ if (rows !== 0) {
156
+ freshStoreCommitted = true;
157
+ try {
158
+ unlinkSync(jsonMigrationFencePath(join(runtimeRoot(), 'sessions')));
159
+ }
160
+ catch (error) {
161
+ if (error.code !== 'ENOENT')
162
+ throw error;
163
+ }
164
+ return;
165
+ }
166
+ application?.close();
167
+ cached = undefined;
168
+ const databasePath = resolveDatabasePath();
169
+ const recordsRoot = join(runtimeRoot(), 'sessions');
170
+ for (const path of [databasePath, `${databasePath}.json-migration.json`, jsonMigrationFencePath(recordsRoot)]) {
171
+ try {
172
+ unlinkSync(path);
173
+ }
174
+ catch (error) {
175
+ if (error.code !== 'ENOENT')
176
+ throw error;
177
+ }
178
+ }
179
+ rmSync(`${databasePath}.json-migration-backup`, { recursive: true, force: true });
180
+ freshStoreOwned = false;
181
+ }
182
+ export function resetConfiguredSessionApplicationForTest() {
183
+ cached?.close();
184
+ cached = undefined;
185
+ settledResidueStores.clear();
186
+ freshStoreOwned = false;
187
+ freshStoreCommitted = false;
188
+ freshStoreLeases = 0;
189
+ }
@@ -27,7 +27,11 @@ export async function sessionStateKit(sessionId) {
27
27
  // the agent-authored state writers resolve WHICH session by id: a `--session <id>` flag (the lifecycle
28
28
  // hooks pass it, parsed from the payload, since they no longer have a cwd `.session`) wins, else the
29
29
  // harness env var (ownSessionId — the agent's own `spex session …` carries the harness session id).
30
- const sess = sessionId;
30
+ // Hook payloads from Codex carry the native thread id, while the canonical
31
+ // application is keyed by the Spex session id. Resolve that alias once at
32
+ // the state-writer boundary; shell hooks stay transport-only and every
33
+ // lifecycle producer writes the same canonical record.
34
+ const sess = sessionId ? (l.readAliasedRawRecord(sessionId)?.session_id ?? sessionId) : undefined;
31
35
  // @@@ no-record diagnosis ([[state]]) - the session store resolves from the CURRENT directory (runtimeRoot
32
36
  // ← the cwd's git common dir), so the classic declaration failure is a cd OUTSIDE the session's project —
33
37
  // and a bare "no session record" told the author none of that (field-reported). Name the actual cause and
@@ -99,6 +103,14 @@ export async function runSessionDeclaration(verb, argv = process.argv) {
99
103
  if (proposal === 'nothing')
100
104
  nothingProposalTrap();
101
105
  const { s, sess, mark, noRecord, noteEcho } = await sessionStateKit(sessionId);
106
+ if (proposal === 'merge') {
107
+ const readiness = s.mergeReadiness('merge');
108
+ if (!readiness.ready) {
109
+ console.error(`review declaration refused: ${readiness.reason}`);
110
+ process.exitCode = 1;
111
+ return;
112
+ }
113
+ }
102
114
  let closeNote = proposal === 'close' ? CLOSE_CLEANUP : '';
103
115
  if (proposal === 'close') {
104
116
  // The closeout nudge is advisory: a declaration must land even if the local issue store fails.
@@ -6,6 +6,12 @@ type SessionFileLock = <T>(id: string, body: () => T) => T;
6
6
  export declare const SESSION_FILE_PREVIEW_MAX_BYTES: number;
7
7
  export type SessionFilePreviewKind = 'text' | 'image' | 'html';
8
8
  export declare function listSessionFiles(id: string): string[];
9
+ export type SessionFileStatus = {
10
+ path: string;
11
+ valid: boolean;
12
+ reason?: string;
13
+ };
14
+ export declare function inspectSessionFiles(id: string): SessionFileStatus[];
9
15
  export declare function readSessionFiles(id: string): string[];
10
16
  export declare function addSessionFile(id: string, input: string, lock: SessionFileLock, cwd?: string): {
11
17
  path: string;