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,31 +1,87 @@
1
- import { execFile, spawn } from 'node:child_process';
1
+ import { execFile, execFileSync, spawn } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { createHash, randomUUID } from 'node:crypto';
4
- import { readFileSync, writeFileSync, appendFileSync, existsSync, renameSync, linkSync, mkdirSync, rmSync, readdirSync, realpathSync, statSync, unlinkSync } from 'node:fs';
4
+ import { readFileSync, writeFileSync, existsSync, renameSync, linkSync, mkdirSync, rmSync, readdirSync, realpathSync, statSync, unlinkSync } from 'node:fs';
5
5
  import { join, dirname, isAbsolute, resolve, sep } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
+ import { rm as rmAsync, readdir as readdirAsync } from 'node:fs/promises';
7
8
  import { seedWorktreeHostState } from './worktree-sources.js';
8
- import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, withGitAbortSignal, isGitObjectId } from '@spexcode/spec-core';
9
+ import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, parseStatPath, withGitAbortSignal, isGitObjectId } from '@spexcode/spec-core';
9
10
  import { loadConfig, loadSpecs, loadSpecsLite } from '@spexcode/spec-core';
10
11
  import { adapterLoadedReferenceState, assertRvSockPath, defaultHarness, HARNESSES, sessionIdentityEnvVars, defaultLauncher, harnessById, procSnapshot, resolveLauncher, rendezvousListening, stampRvSock } from './harness.js';
11
12
  import { materialize } from './materialize.js';
12
13
  import { mainBranch, mainRoot, gitCommonDir, readConfig, runtimeRoot, treeSlotDir, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, rawLaunchReadinessOriginal, readRecordEntry, readAliasedRecordEntry, readPublicRecordEntry, envSessionId, isSessionLifecycle, isSessionProposal } from '@spexcode/spec-core';
13
14
  import { readSessionFiles } from './session-files.js';
14
15
  import { readSessionWebs } from './session-web.js';
15
- import { acceptMessage, drain, recordStatus, lastHumanSendVia, owesDelivery, pendingMessages } from '@spexcode/session-core';
16
- import { pendingSnapshot, replacePendingWhileLocked, revokePendingFromWhileLocked, revokeSenderDelivery, withDeliveryLocks, trySessionRecordLockSync, withSessionRecordLock, withSessionRecordLockSync as coreWithSessionRecordLockSync } from '@spexcode/session-core/internal';
16
+ import { acquireFreshSessionApplicationForCreate, configuredSessionApplicationIfCutover, initializeFreshSessionApplication, releaseFreshSessionApplicationForCreate, sessionApplicationCutoverState, setSessionApplicationCommitWake } from './session-application.js';
17
+ import { jsonMigrationFencePath } from '@spexcode/session-application';
18
+ import { withDeliveryLocks } from './delivery-lock.js';
19
+ import { withSessionRecordLock, withSessionRecordLockSync as coreWithSessionRecordLockSync } from './session-record-lock.js';
17
20
  import { stripRefSigil } from './mentions.js';
18
21
  import { shQuote } from './sh.js';
19
22
  import { assertSessionOwnerSafe, assertSessionStopSafe, ResourceConflict } from './host-resources.js';
20
23
  import { processStartToken } from '@spexcode/spec-core';
21
- import { bindCodexGeneration, codexGenerationBindingForSession, commitCodexGenerationRegistration, prepareCodexGenerationClose, prepareCodexGenerationRegistration, readCodexGenerationLedger } from './codex-runtime-generations.js';
24
+ import { bindCodexGeneration, codexGenerationBindingForSession, commitCodexGenerationRegistration, prepareCodexGenerationRegistration, readCodexGenerationLedger } from './codex-runtime-generations.js';
22
25
  import { cliEntrypointArgs } from './tsx-bin.js';
26
+ import { lastHumanSendVia, recordStatus } from './session-timeline.js';
23
27
  const pexec = promisify(execFile);
24
28
  export const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode';
25
29
  const DEFER_FOOTPRINT_REFRESH = { SPEXCODE_DEFER_FOOTPRINT_REFRESH: 'session-create' };
26
30
  const HARNESS = defaultHarness;
27
31
  const COLS = 120, ROWS = 32;
28
32
  const DEFAULT_MAX_ACTIVE = 8;
33
+ const worktreeTrashDir = (root) => join(root, '.worktrees', '.trash');
34
+ const pendingTrashDeletes = [];
35
+ let trashDeleteRunning = false;
36
+ let trashDeleteScheduled = false;
37
+ async function drainWorktreeTrash() {
38
+ while (pendingTrashDeletes.length) {
39
+ const path = pendingTrashDeletes.shift();
40
+ try {
41
+ await rmAsync(path, { recursive: true, force: true });
42
+ if (existsSync(path))
43
+ throw new Error('path remains after recursive removal');
44
+ }
45
+ catch (error) {
46
+ console.error(`spex: deferred worktree deletion failed for ${path}; retained for next backend startup: ${error instanceof Error ? error.message : error}`);
47
+ }
48
+ }
49
+ trashDeleteRunning = false;
50
+ }
51
+ function queueWorktreeTrash(path) {
52
+ pendingTrashDeletes.push(path);
53
+ if (trashDeleteRunning || trashDeleteScheduled)
54
+ return;
55
+ trashDeleteScheduled = true;
56
+ setImmediate(() => {
57
+ trashDeleteScheduled = false;
58
+ trashDeleteRunning = true;
59
+ void drainWorktreeTrash();
60
+ });
61
+ }
62
+ /** Start the one process-local serial reaper and resume any crash leftovers. */
63
+ export function startWorktreeTrashReaper() {
64
+ const dir = worktreeTrashDir(mainRoot());
65
+ readdirAsync(dir, { withFileTypes: true }).then((entries) => {
66
+ for (const entry of entries)
67
+ queueWorktreeTrash(join(dir, entry.name));
68
+ }).catch((error) => {
69
+ if (error?.code !== 'ENOENT')
70
+ console.error(`spex: deferred worktree trash cleanup failed for ${dir}; retrying next startup: ${error instanceof Error ? error.message : error}`);
71
+ });
72
+ }
73
+ function moveWorktreeToTrash(root, path) {
74
+ const worktrees = resolve(join(root, '.worktrees'));
75
+ const source = resolve(path);
76
+ // Session creation uses <root>/.worktrees/<name>. Keep an adjacent trash for legacy/manual records whose
77
+ // recorded path predates that layout; the normal product path always lands in the governed .worktrees/.trash.
78
+ const parent = dirname(source);
79
+ const dir = parent === worktrees ? worktreeTrashDir(root) : join(parent, '.trash');
80
+ mkdirSync(dir, { recursive: true });
81
+ const target = join(dir, `wt-${Date.now()}-${randomUUID().slice(0, 12)}`);
82
+ renameSync(source, target);
83
+ return target;
84
+ }
29
85
  function maxActive() {
30
86
  let v;
31
87
  try {
@@ -47,7 +103,7 @@ function maxActive() {
47
103
  // propagated when set, because the session inherits the tmux SERVER's env (not the backend's), so without this
48
104
  // an overridden home would silently leak the session's hook-state + codex-trust to the default ~/.spexcode /
49
105
  // ~/.codex. Deterministic: the session's store = the backend's store, never the ambient env's.
50
- const rvEnv = (id, harness = HARNESS) => {
106
+ const rvEnv = (id, harness = HARNESS, nativeStartToken) => {
51
107
  // SPEXCODE_SESSION_ID is the governed record id, and it is the SESSION'S OWN — so the launch STRIPS every
52
108
  // session-identity variable it may have inherited (the pane inherits the tmux SERVER's env, which may carry
53
109
  // a foreign session's ids from whoever started it) before setting this one. Identity is established HERE,
@@ -65,9 +121,15 @@ const rvEnv = (id, harness = HARNESS) => {
65
121
  `SPEXCODE_SESSION_ID=${id}`,
66
122
  `SPEXCODE_SESSION_IDENTITY_VARS=${shQuote(sessionIdentityEnvVars().join(','))}`,
67
123
  `SPEXCODE_PROJECT_ROOT=${shQuote(mainRoot())}`,
124
+ ...(nativeStartToken ? [`SPEXCODE_NATIVE_START_TOKEN=${shQuote(nativeStartToken)}`] : []),
68
125
  ...harness.launchEnv(id), ...homeVars].join(' ');
69
126
  };
70
127
  const PROPOSAL_STATUS = { merge: 'review', nothing: 'done', close: 'close-pending' };
128
+ // Awaiting is the durable lifecycle row; its proposal selects the user-facing display status. Keep this
129
+ // projection in the session package so backend reconciliation and offline client reads cannot drift apart.
130
+ export function displayStatusForProposal(proposal) {
131
+ return PROPOSAL_STATUS[proposal ?? 'nothing'];
132
+ }
71
133
  function normalizeCloseSource(raw) {
72
134
  if (raw == null)
73
135
  return { kind: 'user' };
@@ -80,64 +142,6 @@ function normalizeCloseSource(raw) {
80
142
  return { kind: 'unverified-session-claim', id: source.id.trim() };
81
143
  throw new ResourceConflict('refusing session close: source must be user or an unverified session claim');
82
144
  }
83
- function appendCloseLedger(id, rec, source) {
84
- const path = join(runtimeRoot(), 'session-close-ledger.ndjson');
85
- const event = {
86
- version: 1,
87
- action: 'close-authorized',
88
- at: new Date().toISOString(),
89
- source,
90
- target: {
91
- id,
92
- harness: rec.harness,
93
- thread: rec.harnessSessionId,
94
- worktree: rec.worktreePath,
95
- branch: rec.branch,
96
- },
97
- };
98
- appendFileSync(path, `${JSON.stringify(event)}\n`);
99
- }
100
- export function findSessionClosure(selector) {
101
- const query = stripRefSigil(selector).trim();
102
- if (!query)
103
- return null;
104
- const path = join(runtimeRoot(), 'session-close-ledger.ndjson');
105
- let text;
106
- try {
107
- text = readFileSync(path, 'utf8');
108
- }
109
- catch (error) {
110
- if (error.code === 'ENOENT')
111
- return null;
112
- throw error;
113
- }
114
- const matches = new Map();
115
- for (const line of text.split('\n')) {
116
- if (!line.trim())
117
- continue;
118
- let event;
119
- try {
120
- event = JSON.parse(line);
121
- }
122
- catch {
123
- throw new ResourceConflict('session close history is unreadable');
124
- }
125
- if (!event || typeof event !== 'object' || event.action !== 'close-authorized')
126
- continue;
127
- const target = event.target;
128
- const id = target && typeof target === 'object' ? target.id : undefined;
129
- const closedAt = event.at;
130
- if (typeof id !== 'string' || !id || typeof closedAt !== 'string' || !closedAt)
131
- throw new ResourceConflict('session close history is malformed');
132
- if (id === query || id.startsWith(query))
133
- matches.set(id, { id, closedAt });
134
- }
135
- if (!matches.size)
136
- return null;
137
- if (matches.size > 1)
138
- throw new ResourceConflict(`close history for ${query} is ambiguous: ${[...matches.keys()].map((id) => id.slice(0, 8)).join(', ')}`);
139
- return [...matches.values()][0];
140
- }
141
145
  function storeDir(id) { const d = sessionStoreDir(id); mkdirSync(d, { recursive: true }); return d; }
142
146
  function writePromptFile(id, prompt) {
143
147
  try {
@@ -245,19 +249,57 @@ export function canDrainQueued(rec, authority = backendLaunchAuthority()) {
245
249
  return rec.status === 'queued' && !rec.stopped && (rec.launchOwner === null || rec.launchOwner === authority);
246
250
  }
247
251
  // typed read of a session's record from the global store (null if it has none — a self-launched session that
248
- // only ever wrote spec-discipline sentinels has a store dir but no session.json). Goes through layout's
252
+ // only ever wrote spec-discipline sentinels has a store dir but no runtime.json). Goes through layout's
249
253
  // readAliasedRawRecord (the seam that owns the path + the codex-thread-id alias), then validates the loose
250
254
  // on-disk fields into the typed shape — so a codex hook resolving by its thread id reaches the real record.
251
255
  function readRecord(id) {
252
256
  const entry = readAliasedRecordEntry(id);
253
- if (entry.kind === 'absent')
254
- return null;
257
+ if (entry.kind === 'absent') {
258
+ // A migrated session may retain its canonical application row after an envelope was removed or never
259
+ // materialized. Lifecycle hooks must still reach that row; do not turn missing runtime metadata into a
260
+ // silent "not governed" result. The minimal projection deliberately carries no guessed resource identity.
261
+ const application = configuredSessionApplicationIfCutover();
262
+ const state = application?.readState(id);
263
+ if (!state)
264
+ return null;
265
+ return {
266
+ session: id,
267
+ governed: true,
268
+ worktreePath: '', branch: null, node: null, title: null, name: null, parent: state.parentSessionId,
269
+ status: state.status,
270
+ proposal: isSessionProposal(state.proposal) ? state.proposal : null,
271
+ merges: 0, note: state.note, sortKey: null, createdAt: state.updatedAtMs,
272
+ harness: 'claude', harnessSessionId: null, runtimeStartToken: null,
273
+ stopped: false, archived: false, closedAt: null, coldProof: null, adapterRecovery: null,
274
+ launcher: null, launchCmd: null, launchOwner: null, launchReadinessStartedAt: null,
275
+ createRequestId: null, createPayloadHash: null, zcodeChildSessionIds: [], base: null,
276
+ diffComments: [], launchReadinessPending: null,
277
+ };
278
+ }
255
279
  if (entry.kind === 'corrupt')
256
280
  throw new SessionRecordUnusable('corrupt', id, corruptReason(entry));
257
281
  try {
258
- return fromRaw(entry.raw);
282
+ const record = fromRaw(entry.raw);
283
+ // After cutover, runtime.json is only the runtime/worktree envelope. Lifecycle is owned by the
284
+ // session application. Overlaying here keeps every internal caller on the same fact instead of
285
+ // letting a stale JSON snapshot steer a launch, close, or hook decision.
286
+ const application = configuredSessionApplicationIfCutover();
287
+ if (!application || !record.governed)
288
+ return record;
289
+ const state = application.readState(record.session);
290
+ if (!state)
291
+ throw new ResourceConflict(`session ${record.session} has no canonical application state after JSON cutover`);
292
+ return {
293
+ ...record,
294
+ status: state.status,
295
+ proposal: isSessionProposal(state.proposal) ? state.proposal : null,
296
+ note: state.note,
297
+ parent: state.parentSessionId,
298
+ };
259
299
  }
260
300
  catch (error) {
301
+ if (error instanceof ResourceConflict)
302
+ throw error;
261
303
  throw new SessionRecordUnusable('corrupt', id, `session record is unreadable: ${sessionRecordPath(id)} — ${error instanceof Error ? error.message : String(error)}. The file is kept as-is; nothing will rewrite it.`);
262
304
  }
263
305
  }
@@ -275,7 +317,9 @@ const corruptReason = (e) => `session record is unreadable: ${e.path} — ${e.er
275
317
  function retirementReason(rec) {
276
318
  if (!rec.worktreePath || existsSync(rec.worktreePath))
277
319
  return null;
278
- return `session ${rec.session.slice(0, 8)} is retired: its worktree ${rec.worktreePath} no longer exists, so it cannot work, be marked active/idle, or be relaunched. Close it (\`spex session close <id>\`) to drop the record.`;
320
+ if (rec.archived)
321
+ return `session ${rec.session.slice(0, 8)} is closed and read-only: its worktree ${rec.worktreePath} no longer exists`;
322
+ return `session ${rec.session.slice(0, 8)} is retired: its worktree ${rec.worktreePath} no longer exists, so it cannot work, be marked active/idle, or be relaunched`;
279
323
  }
280
324
  function readLiveRecord(id) {
281
325
  const rec = readRecord(id);
@@ -291,22 +335,6 @@ export function withSessionRecordLockSync(id, body) {
291
335
  return coreWithSessionRecordLockSync(id, body);
292
336
  }
293
337
  const withRecordLockSync = withSessionRecordLockSync;
294
- // Synchronous terminal input is another product turn-entry path. The PTY bridge uses this narrow seam to
295
- // enqueue input while holding the same durable record lock as archive, so an archive preflight cannot pass idle
296
- // and then race a just-queued TUI turn.
297
- export function withSessionInputLock(id, body) {
298
- // PTY input is synchronous. A single non-blocking open is the only safe barrier: EEXIST rejects this input
299
- // regardless of owner PID, so a same-process async archive can never be frozen behind Atomics.wait.
300
- const release = trySessionRecordLockSync(id);
301
- if (!release)
302
- return null;
303
- try {
304
- return body();
305
- }
306
- finally {
307
- release();
308
- }
309
- }
310
338
  const COLD_PROOF_VERSION = 'cold-v1';
311
339
  function coldProofFor(rec) {
312
340
  const adapter = harnessById(rec.harness || defaultHarness.id).id;
@@ -332,6 +360,9 @@ export function fromRaw(raw) {
332
360
  if (pendingRaw && !pendingStatus)
333
361
  throw new Error(`session '${raw.session_id}' launch readiness original has invalid lifecycle '${pendingRaw.status}'`);
334
362
  const pendingProposal = pendingRaw && isSessionProposal(pendingRaw.proposal) ? pendingRaw.proposal : null;
363
+ if (raw.closed_at != null && raw.closed_at !== ''
364
+ && (typeof raw.closed_at !== 'string' || !Number.isFinite(Date.parse(raw.closed_at))))
365
+ throw new Error(`session '${raw.session_id}' has invalid closed_at`);
335
366
  if (pendingRaw?.proposal && !pendingProposal)
336
367
  throw new Error(`session '${raw.session_id}' launch readiness original has invalid proposal '${pendingRaw.proposal}'`);
337
368
  const zcodeChildSessionIds = raw.zcode_child_session_ids ?? [];
@@ -339,6 +370,18 @@ export function fromRaw(raw) {
339
370
  || zcodeChildSessionIds.some((id) => typeof id !== 'string' || !id || id.trim() !== id)
340
371
  || new Set(zcodeChildSessionIds).size !== zcodeChildSessionIds.length)
341
372
  throw new Error(`session '${raw.session_id}' has invalid zcode_child_session_ids`);
373
+ const diffComments = raw.diff_comments ?? [];
374
+ if (!Array.isArray(diffComments) || diffComments.some((comment) => !comment || typeof comment !== 'object'))
375
+ throw new Error(`session '${raw.session_id}' has invalid diff_comments`);
376
+ const parsedDiffComments = diffComments.map((comment) => {
377
+ const c = comment;
378
+ if (!c.id || typeof c.id !== 'string' || typeof c.file_path !== 'string' || !c.file_path
379
+ || !Number.isInteger(c.line_start) || c.line_start < 1 || !Number.isInteger(c.line_end) || c.line_end < c.line_start
380
+ || typeof c.body !== 'string' || !c.body.trim() || typeof c.diff_identity !== 'string'
381
+ || !(c.sent_at === null || typeof c.sent_at === 'string'))
382
+ throw new Error(`session '${raw.session_id}' has invalid diff comment`);
383
+ return { id: c.id, filePath: c.file_path, lineStart: c.line_start, lineEnd: c.line_end, body: c.body, diffIdentity: c.diff_identity, sentAt: c.sent_at };
384
+ });
342
385
  return {
343
386
  session: raw.session_id, governed: !!raw.governed, worktreePath: raw.worktree_path || '', branch: raw.branch || null,
344
387
  node: raw.node || null, title: raw.title || null, name: raw.name || null, parent: raw.parent || null,
@@ -346,23 +389,30 @@ export function fromRaw(raw) {
346
389
  note: raw.note || null, sortKey, createdAt: Number(raw.createdAt) || 0,
347
390
  harness: raw.harness || 'claude', // records written before the harness field default to claude
348
391
  harnessSessionId: raw.harness_session_id || null,
392
+ runtimeStartToken: raw.runtime_start_token || null,
349
393
  stopped: !!raw.stopped, // records written before explicit stop tracking were not stopped
350
- archived: !!raw.archived, // records written before archive → absent → not shelved
394
+ archived: !!raw.archived, // records written before close retention → absent → working
395
+ closedAt: typeof raw.closed_at === 'string' && raw.closed_at ? raw.closed_at : null,
351
396
  coldProof: raw.cold_proof || null, // legacy archived rows have no proof and remain visible until re-archived
352
397
  adapterRecovery: raw.adapter_recovery || null,
353
398
  launcher: raw.launcher || null, // records written before launchers → null → old-record fallback
354
399
  launchCmd: raw.launch_cmd || null, // records written before the pin → null → fall back to launcher name / ambient
355
400
  launchOwner: launchOwner || null,
401
+ launchReadinessStartedAt: Number.isFinite(Number(raw.launch_readiness_started_at))
402
+ ? Number(raw.launch_readiness_started_at) : null,
356
403
  createRequestId: raw.create_request_id || null,
357
404
  createPayloadHash: raw.create_payload_hash || null,
358
405
  zcodeChildSessionIds: [...zcodeChildSessionIds],
359
406
  base: raw.base || null, // records written before pinned bases → null → the source-of-truth branch
407
+ forkCommit: raw.fork_commit || null, // records written before the fork commit → null → recovered from the branch reflog
408
+ diffComments: parsedDiffComments,
360
409
  launchReadinessPending: pendingRaw ? {
361
410
  version: 1,
362
411
  startedAt: raw.launch_readiness_pending.startedAt,
363
412
  original: {
364
413
  status: pendingStatus, proposal: pendingProposal, note: pendingRaw.note || null,
365
414
  stopped: pendingRaw.stopped, archived: pendingRaw.archived,
415
+ closedAt: pendingRaw.closed_at || null,
366
416
  coldProof: pendingRaw.cold_proof || null, adapterRecovery: pendingRaw.adapter_recovery || null,
367
417
  },
368
418
  } : null,
@@ -382,6 +432,7 @@ function launchReadinessPending(original) {
382
432
  note: original.note,
383
433
  stopped: original.stopped,
384
434
  archived: original.archived,
435
+ closedAt: original.closedAt,
385
436
  coldProof: original.coldProof ?? null,
386
437
  adapterRecovery: original.adapterRecovery ?? null,
387
438
  },
@@ -392,12 +443,42 @@ function restoreLaunchReadinessOriginal(rec) {
392
443
  return original ? { ...rec, ...original, launchReadinessPending: null } : rec;
393
444
  }
394
445
  // Rebuild the full disk projection so retired keys disappear on the next write.
446
+ function assertLegacyJsonWritesAllowed() {
447
+ const fence = jsonMigrationFencePath(join(runtimeRoot(), 'sessions'));
448
+ if (existsSync(fence) && !configuredSessionApplicationIfCutover()) {
449
+ throw new ResourceConflict(`legacy JSON session store is fenced for one-time migration: ${fence}`);
450
+ }
451
+ }
395
452
  function writeRecord(rec) {
453
+ assertLegacyJsonWritesAllowed();
454
+ const application = configuredSessionApplicationIfCutover();
455
+ // The JSON file is runtime/worktree metadata after cutover, not a lifecycle store. Once the canonical row
456
+ // exists, omit the four old lifecycle keys entirely; retaining them would leave a second apparent fact for
457
+ // readers and tempt a future path to trust the wrong writer. New records still need the legacy shape until
458
+ // their canonical row is created, and non-governed external runtime records keep their own contract.
459
+ const envelope = application && rec.governed ? readAliasedRecordEntry(rec.session) : null;
460
+ const canonicalMetadataOnly = envelope?.kind === 'ok' && rec.governed && !!application;
461
+ const lifecycle = { status: rawLifecycleStatus(rec), proposal: rec.proposal, note: rec.note, parent: rec.parent };
462
+ // A queued legacy envelope may still carry its lease until this metadata rewrite. The lease is an
463
+ // operational launch claim, not a lifecycle fact, so preserve only that field while the typed record clears it.
464
+ const envelopeLaunchOwner = envelope?.kind === 'ok'
465
+ ? envelope.raw.launch_owner?.trim() || null
466
+ : null;
396
467
  let previous = null;
397
468
  try {
398
469
  previous = readRecord(rec.session);
399
470
  }
400
471
  catch { /* a new or damaged record has no prior transition */ }
472
+ const metadataChanged = !previous || [
473
+ 'governed', 'worktreePath', 'branch', 'node', 'title', 'name', 'merges', 'sortKey', 'createdAt',
474
+ 'harness', 'harnessSessionId', 'runtimeStartToken', 'stopped', 'archived', 'closedAt', 'coldProof',
475
+ 'adapterRecovery', 'launcher', 'launchCmd', 'launchOwner', 'launchReadinessStartedAt', 'createRequestId',
476
+ 'createPayloadHash', 'zcodeChildSessionIds', 'base', 'forkCommit', 'diffComments', 'launchReadinessPending',
477
+ ].some((key) => JSON.stringify(previous[key]) !== JSON.stringify(rec[key]));
478
+ // Once a canonical row exists, a lifecycle-only write is already complete when the application transition
479
+ // commits. Rewriting runtime.json here would recreate a second, stale status/proposal/note authority.
480
+ if (canonicalMetadataOnly && previous && !metadataChanged)
481
+ return;
401
482
  const obj = {
402
483
  session_id: rec.session,
403
484
  governed: rec.governed,
@@ -406,28 +487,37 @@ function writeRecord(rec) {
406
487
  node: rec.node ?? '',
407
488
  title: rec.title ?? '',
408
489
  name: rec.name ?? '',
409
- parent: rec.parent ?? '',
410
- status: rawLifecycleStatus(rec),
411
- proposal: rec.proposal ?? '',
412
490
  merges: rec.merges,
413
- note: rec.note ?? '',
414
491
  sortkey: rec.sortKey ?? '',
415
492
  createdAt: rec.createdAt,
416
493
  harness: rec.harness || 'claude',
417
494
  harness_session_id: rec.harnessSessionId ?? '',
418
495
  stopped: rec.stopped,
419
496
  archived: rec.archived,
497
+ // Pre-field records stay byte-shape compatible until a real close publishes the timestamp. In particular,
498
+ // a failed resume must be able to restore an old working record without inventing an empty metadata key.
499
+ ...(rec.closedAt ? { closed_at: rec.closedAt } : {}),
420
500
  cold_proof: rec.coldProof ?? '',
421
501
  adapter_recovery: rec.adapterRecovery ?? '',
422
502
  launcher: rec.launcher ?? '',
423
503
  launch_cmd: rec.launchCmd ?? '',
424
- launch_owner: rec.status === 'queued' ? rec.launchOwner ?? '' : '',
504
+ launch_owner: (lifecycle.status === 'queued' || lifecycle.status === OWNED_QUEUE_RAW_STATUS)
505
+ ? rec.launchOwner ?? envelopeLaunchOwner ?? '' : '',
506
+ ...(rec.launchReadinessStartedAt ? { launch_readiness_started_at: rec.launchReadinessStartedAt } : {}),
507
+ ...(rec.runtimeStartToken ? { runtime_start_token: rec.runtimeStartToken } : {}),
425
508
  create_request_id: rec.createRequestId ?? '',
426
509
  create_payload_hash: rec.createPayloadHash ?? '',
427
510
  ...(rec.zcodeChildSessionIds?.length ? { zcode_child_session_ids: rec.zcodeChildSessionIds } : {}),
428
511
  // Written only when the creator pinned one: an unpinned record keeps its exact legacy bytes, so a
429
512
  // restore-the-frozen-record path stays byte-identical instead of silently gaining a key.
430
513
  ...(rec.base ? { base: rec.base } : {}),
514
+ // The commit `git worktree add` actually started from, written on every create since it was introduced.
515
+ // Conditional like `base` above, so a record written before it keeps its exact legacy bytes.
516
+ ...(rec.forkCommit ? { fork_commit: rec.forkCommit } : {}),
517
+ ...((rec.diffComments ?? []).length ? { diff_comments: (rec.diffComments ?? []).map((comment) => ({
518
+ id: comment.id, file_path: comment.filePath, line_start: comment.lineStart, line_end: comment.lineEnd,
519
+ body: comment.body, diff_identity: comment.diffIdentity, sent_at: comment.sentAt,
520
+ })) } : {}),
431
521
  launch_readiness_pending: rec.launchReadinessPending ? {
432
522
  version: 1,
433
523
  startedAt: rec.launchReadinessPending.startedAt,
@@ -437,104 +527,62 @@ function writeRecord(rec) {
437
527
  note: rec.launchReadinessPending.original.note ?? '',
438
528
  stopped: rec.launchReadinessPending.original.stopped,
439
529
  archived: rec.launchReadinessPending.original.archived,
530
+ closed_at: rec.launchReadinessPending.original.closedAt,
440
531
  cold_proof: rec.launchReadinessPending.original.coldProof ?? '',
441
532
  adapter_recovery: rec.launchReadinessPending.original.adapterRecovery ?? '',
442
533
  },
443
534
  } : '',
535
+ ...(canonicalMetadataOnly ? {} : {
536
+ parent: lifecycle.parent ?? '',
537
+ status: lifecycle.status,
538
+ proposal: lifecycle.proposal ?? '',
539
+ note: lifecycle.note ?? '',
540
+ }),
444
541
  };
445
542
  const dir = sessionStoreDir(rec.session);
446
543
  mkdirSync(dir, { recursive: true });
447
544
  const path = sessionRecordPath(rec.session);
448
- const tmp = join(dir, `.session.json.${process.pid}.tmp`);
545
+ const tmp = join(dir, `.runtime.json.${process.pid}.tmp`);
449
546
  writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n');
450
547
  renameSync(tmp, path); // atomic within the dir: a concurrent reader sees the old record or the new one
451
548
  const previousPublic = previous ? publicRecord(previous) : null;
452
549
  const nextPublic = publicRecord(rec);
453
- if (rec.governed && previousPublic && (previousPublic.status !== nextPublic.status
550
+ if (!application && rec.governed && previousPublic && (previousPublic.status !== nextPublic.status
454
551
  || previousPublic.proposal !== nextPublic.proposal || previousPublic.note !== nextPublic.note)) {
455
552
  recordStatus(rec.session, nextPublic.status, nextPublic.proposal, nextPublic.note);
456
553
  scheduleWatchNotifications(rec);
457
554
  }
458
555
  }
459
- const watchPath = (target) => sessionArtifactPath(target, 'watchers.json');
460
- function readWatchEntries(target) {
461
- try {
462
- const raw = JSON.parse(readFileSync(watchPath(target), 'utf8'));
463
- if (!Array.isArray(raw))
464
- return [];
465
- const parent = readRecord(target)?.parent ?? '';
466
- const seen = new Set();
467
- return raw.flatMap((entry) => {
468
- if (!entry || typeof entry !== 'object')
469
- return [];
470
- const watcher = entry.watcher;
471
- const createdAt = entry.createdAt;
472
- if (!watcher || typeof watcher !== 'string' || typeof createdAt !== 'string' || seen.has(watcher))
473
- return [];
474
- seen.add(watcher);
475
- const rawSources = entry.sources;
476
- const sources = Array.isArray(rawSources)
477
- ? [...new Set(rawSources.filter((source) => source === 'manual' || source === 'parent'))]
478
- // The former one-source format cannot name an origin. Its child pointer is the only durable witness
479
- // that this watcher was installed for parent supervision; every other legacy row is a manual watch.
480
- : [watcher === parent ? 'parent' : 'manual'];
481
- const snapshotPending = entry.snapshotPending;
482
- return sources.length ? [{ watcher, createdAt, sources,
483
- ...(sources.includes('parent') && typeof snapshotPending === 'string' && snapshotPending ? { snapshotPending } : {}),
484
- }] : [];
485
- });
486
- }
487
- catch {
556
+ function canonicalWatchEntries(target) {
557
+ const application = configuredSessionApplicationIfCutover();
558
+ if (!application)
559
+ return null;
560
+ if (!application.readState(target))
488
561
  return [];
489
- }
490
- }
491
- function writeWatchEntries(target, entries) {
492
- const path = watchPath(target);
493
- if (!entries.length) {
494
- try {
495
- unlinkSync(path);
562
+ const seen = new Map();
563
+ for (const edge of application.topology.parents(target)) {
564
+ if (edge.relationType !== 'parent' && !edge.relationType.startsWith('watch'))
565
+ continue;
566
+ const source = edge.relationType === 'parent' || edge.relationType === 'watch:parent' ? 'parent' : 'manual';
567
+ const current = seen.get(edge.fromSessionId);
568
+ if (current) {
569
+ if (!current.sources.includes(source))
570
+ current.sources.push(source);
571
+ continue;
496
572
  }
497
- catch { /* already absent */ }
498
- ;
499
- return;
573
+ seen.set(edge.fromSessionId, {
574
+ watcher: edge.fromSessionId,
575
+ createdAt: new Date(edge.createdAtMs).toISOString(),
576
+ sources: [source],
577
+ });
500
578
  }
501
- const dir = sessionStoreDir(target);
502
- mkdirSync(dir, { recursive: true });
503
- const tmp = join(dir, `.watchers.json.${process.pid}.tmp`);
504
- writeFileSync(tmp, JSON.stringify(entries, null, 2) + '\n');
505
- renameSync(tmp, path);
506
- }
507
- function addWatchSource(entries, watcher, source, deferInitialSnapshot = false) {
508
- const existing = entries.find((entry) => entry.watcher === watcher);
509
- const snapshotPending = deferInitialSnapshot && !existing?.sources.includes('manual') ? randomUUID() : undefined;
510
- if (!existing)
511
- return { entries: [...entries, {
512
- watcher, createdAt: new Date().toISOString(), sources: [source], ...(snapshotPending ? { snapshotPending } : {}),
513
- }], added: true };
514
- if (existing.sources.includes(source))
515
- return { entries, added: false };
516
- return {
517
- entries: entries.map((entry) => entry === existing ? {
518
- ...entry, sources: [...entry.sources, source], ...(snapshotPending ? { snapshotPending } : {}),
519
- } : entry),
520
- added: true,
521
- };
579
+ return [...seen.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.watcher.localeCompare(b.watcher));
522
580
  }
523
- function removeWatchSource(entries, watcher, source) {
524
- let removed = false;
525
- const next = entries.flatMap((entry) => {
526
- if (entry.watcher !== watcher || !entry.sources.includes(source))
527
- return [entry];
528
- removed = true;
529
- const sources = entry.sources.filter((candidate) => candidate !== source);
530
- if (!sources.length)
531
- return [];
532
- if (source !== 'parent')
533
- return [{ ...entry, sources }];
534
- const { snapshotPending: _pending, ...withoutParentDebt } = entry;
535
- return [{ ...withoutParentDebt, sources }];
536
- });
537
- return { entries: next, removed };
581
+ function readWatchEntries(target) {
582
+ const canonical = canonicalWatchEntries(target);
583
+ if (canonical)
584
+ return canonical;
585
+ throw new ResourceConflict('session application is unavailable; refusing the legacy watcher path');
538
586
  }
539
587
  function managedWatchRecord(id) {
540
588
  const rec = readRecord(id);
@@ -544,7 +592,7 @@ function managedWatchRecord(id) {
544
592
  }
545
593
  function watchMessage(target) {
546
594
  const status = target.status === 'awaiting'
547
- ? PROPOSAL_STATUS[target.proposal ?? 'nothing']
595
+ ? displayStatusForProposal(target.proposal)
548
596
  : target.status === 'active' ? 'working' : target.status;
549
597
  const note = target.note ? ` — ${target.note}` : '';
550
598
  return `[spex watch] ${target.session} is ${status}${note}`;
@@ -555,180 +603,81 @@ function shouldDeliverWatchTransition(target, sources) {
555
603
  }
556
604
  function scheduleWatchNotifications(target) {
557
605
  const watchers = readWatchEntries(target.session)
558
- .filter((entry) => !entry.snapshotPending && shouldDeliverWatchTransition(target, entry.sources))
606
+ .filter((entry) => shouldDeliverWatchTransition(target, entry.sources))
559
607
  .map((entry) => entry.watcher);
560
608
  if (!watchers.length)
561
609
  return;
562
610
  queueMicrotask(() => {
563
611
  for (const watcher of watchers) {
564
- void sendText(watcher, watchMessage(target), target.session).then((result) => {
612
+ void sendText(watcher, watchMessage(target), target.session, { allowStranded: true }).then((result) => {
565
613
  if (!result.ok)
566
614
  console.error(`spex session watch: could not deliver ${target.session} state to ${watcher}: ${result.error}`);
567
615
  });
568
616
  }
569
617
  });
570
618
  }
571
- const watchSnapshotState = (target) => JSON.stringify([target.status, target.proposal, target.note]);
572
- async function deliverPendingWatchSnapshots(targetId, forceCurrent = true) {
573
- const pending = readWatchEntries(targetId).filter((entry) => entry.snapshotPending);
574
- for (const original of pending) {
575
- const token = original.snapshotPending;
576
- let force = forceCurrent;
577
- for (;;) {
578
- const target = readRecord(targetId);
579
- const entry = readWatchEntries(targetId)
580
- .find((candidate) => candidate.watcher === original.watcher && candidate.snapshotPending === token);
581
- if (!target || !entry)
582
- break;
583
- const state = watchSnapshotState(target);
584
- const shouldDeliver = force || shouldDeliverWatchTransition(target, entry.sources);
585
- if (!shouldDeliver) {
586
- let settled = false;
587
- await withRecordLock(targetId, async () => {
588
- const current = readRecord(targetId);
589
- const entries = readWatchEntries(targetId);
590
- const pendingEntry = entries.find((candidate) => candidate.watcher === original.watcher && candidate.snapshotPending === token);
591
- if (!current || !pendingEntry || watchSnapshotState(current) !== state)
592
- return;
593
- const next = entries.map((candidate) => {
594
- if (candidate !== pendingEntry)
595
- return candidate;
596
- const { snapshotPending: _pending, ...cleared } = candidate;
597
- return cleared;
598
- });
599
- writeWatchEntries(targetId, next);
600
- settled = true;
601
- });
602
- if (settled)
603
- break;
604
- force = false;
605
- continue;
606
- }
607
- const identity = `${targetId}\0${entry.watcher}\0${token}\0${state}`;
608
- const delivered = await sendText(entry.watcher, watchMessage(target), targetId, {
609
- idempotency: {
610
- operation: 'watch-initial-snapshot',
611
- requestDigest: digest(identity),
612
- payloadHash: digest(`watch-initial-snapshot\0${identity}\0${watchMessage(target)}`),
613
- },
614
- acceptGuard: async () => {
615
- const current = readRecord(targetId);
616
- const stillPending = readWatchEntries(targetId)
617
- .some((candidate) => candidate.watcher === entry.watcher && candidate.snapshotPending === token);
618
- if (!current || !stillPending || watchSnapshotState(current) !== state)
619
- throw new ResourceConflict('watch initial snapshot changed before acceptance');
620
- },
621
- });
622
- if (!delivered.ok) {
623
- if (delivered.error?.includes('watch initial snapshot changed before acceptance'))
624
- continue;
625
- console.error(`spex session watch: could not deliver initial ${targetId} state to ${entry.watcher}: ${delivered.error}`);
626
- break;
627
- }
628
- force = false;
629
- let settled = false;
630
- await withRecordLock(targetId, async () => {
631
- const current = readRecord(targetId);
632
- const entries = readWatchEntries(targetId);
633
- const pendingEntry = entries.find((candidate) => candidate.watcher === entry.watcher && candidate.snapshotPending === token);
634
- if (!current || !pendingEntry) {
635
- settled = true;
636
- return;
637
- }
638
- const currentState = watchSnapshotState(current);
639
- if (currentState !== state && shouldDeliverWatchTransition(current, pendingEntry.sources))
640
- return;
641
- const next = entries.map((candidate) => {
642
- if (candidate !== pendingEntry)
643
- return candidate;
644
- const { snapshotPending: _pending, ...cleared } = candidate;
645
- return cleared;
646
- });
647
- writeWatchEntries(targetId, next);
648
- settled = true;
649
- });
650
- if (settled)
651
- break;
652
- }
653
- }
654
- }
655
- async function clearPendingWatchSnapshots(targetId) {
656
- await withRecordLock(targetId, async () => {
657
- const entries = readWatchEntries(targetId);
658
- const next = entries.map((entry) => {
659
- if (!entry.snapshotPending)
660
- return entry;
661
- const { snapshotPending: _pending, ...settled } = entry;
662
- return settled;
663
- });
664
- if (next.some((entry, index) => entry !== entries[index]))
665
- writeWatchEntries(targetId, next);
666
- });
667
- }
668
619
  export async function subscribeSessionWatch(watcher, targets, source = 'manual') {
669
620
  managedWatchRecord(watcher);
621
+ const application = configuredSessionApplicationIfCutover();
622
+ if (!application)
623
+ throw new ResourceConflict('session application is unavailable; refusing the legacy watcher path');
670
624
  const watched = [];
625
+ const channel = source === 'parent' ? 'watch:parent' : 'watch:manual';
671
626
  for (const target of [...new Set(targets)]) {
672
627
  if (target === watcher)
673
628
  throw new ResourceConflict('a session cannot watch itself');
674
- let targetRecord = null;
675
- let added = false;
676
- let pending = false;
677
- withRecordLockSync(target, () => {
678
- targetRecord = managedWatchRecord(target);
679
- const entries = readWatchEntries(target);
680
- const next = addWatchSource(entries, watcher, source, source === 'parent' && targetRecord.status === 'queued');
681
- if (next.added)
682
- writeWatchEntries(target, next.entries);
683
- added = next.added;
684
- pending = next.entries.some((entry) => entry.watcher === watcher && !!entry.snapshotPending);
685
- });
686
- if (pending) {
687
- if (source === 'manual')
688
- await deliverPendingWatchSnapshots(target);
629
+ const targetRecord = managedWatchRecord(target);
630
+ try {
631
+ application.attachWatcher(watcher, target, channel);
689
632
  }
690
- else if (source === 'manual' || added) {
691
- const delivered = await sendText(watcher, watchMessage(targetRecord), target);
692
- if (!delivered.ok)
693
- throw new ResourceConflict(`watch established but could not queue ${target}'s current state for ${watcher}: ${delivered.error}`);
633
+ catch (error) {
634
+ if (!(error instanceof Error) || !/already exists|duplicate|active topology edge/i.test(error.message))
635
+ throw error;
694
636
  }
637
+ const message = watchMessage(targetRecord);
638
+ application.enqueueMessage(watcher, {
639
+ kind: 'session.prompt.v1',
640
+ body: Buffer.from(message, 'utf8'),
641
+ senderSessionId: target,
642
+ idempotencyKey: digest(`watch-initial-snapshot\0${watcher}\0${target}\0${source}\0${message}`),
643
+ });
695
644
  watched.push(target);
696
645
  }
697
646
  return { watched };
698
647
  }
699
648
  export function listSessionWatches(watcher) {
700
649
  managedWatchRecord(watcher);
701
- const watches = [];
702
- for (const target of listSessionIds()) {
703
- const entries = readWatchEntries(target);
704
- const active = entries.filter((entry) => {
705
- try {
706
- return !!readRecord(entry.watcher)?.governed;
707
- }
708
- catch {
709
- return false;
710
- }
711
- });
712
- if (active.length !== entries.length)
713
- writeWatchEntries(target, active);
714
- for (const entry of active)
715
- if (entry.watcher === watcher)
716
- watches.push({ target, createdAt: entry.createdAt });
717
- }
718
- return watches.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.target.localeCompare(b.target));
650
+ const application = configuredSessionApplicationIfCutover();
651
+ if (!application)
652
+ throw new ResourceConflict('session application is unavailable; refusing the legacy watcher path');
653
+ // `listWatchers` defaults to the bare `watch` channel. Canonical policy stores the source
654
+ // (`watch:parent`/`watch:manual`) in the relation type, so listing must inspect every watch
655
+ // channel or a valid parent watch appears to have disappeared.
656
+ const edges = ['watch', 'watch:parent', 'watch:manual']
657
+ .flatMap(channel => application.listWatchers(watcher, channel))
658
+ .filter((edge, index, all) => all.findIndex(other => other.toSessionId === edge.toSessionId) === index);
659
+ return edges
660
+ .map(edge => ({ target: edge.toSessionId, createdAt: new Date(edge.createdAtMs).toISOString() }))
661
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.target.localeCompare(b.target));
719
662
  }
720
663
  export function cancelSessionWatch(watcher, targets) {
721
664
  managedWatchRecord(watcher);
665
+ const application = configuredSessionApplicationIfCutover();
666
+ if (!application)
667
+ throw new ResourceConflict('session application is unavailable; refusing the legacy watcher path');
722
668
  let cancelled = 0;
723
669
  for (const target of [...new Set(targets)]) {
724
- withRecordLockSync(target, () => {
725
- const entries = readWatchEntries(target);
726
- const next = removeWatchSource(entries, watcher, 'manual');
727
- if (next.removed) {
728
- writeWatchEntries(target, next.entries);
670
+ for (const channel of ['watch:manual', 'watch']) {
671
+ try {
672
+ application.detachWatcher(watcher, target, channel);
729
673
  cancelled++;
674
+ break;
730
675
  }
731
- });
676
+ catch (error) {
677
+ if (!(error instanceof Error) || !/does not exist|unknown/i.test(error.message))
678
+ throw error;
679
+ }
680
+ }
732
681
  }
733
682
  return cancelled;
734
683
  }
@@ -762,65 +711,65 @@ function assertReparentable(children, parent, records) {
762
711
  }
763
712
  export async function reparentSessionRecords(rawChildren, parent) {
764
713
  const children = [...new Set(rawChildren)].sort();
714
+ const application = configuredSessionApplicationIfCutover();
715
+ if (!application)
716
+ throw new ResourceConflict('session application is unavailable; refusing the legacy reparent path');
765
717
  const notify = [];
766
718
  await withRecordLock('session-reparent-transaction', async () => {
767
- // Read former supervisors only after the transaction fence: a concurrent reparent may change exactly
768
- // this relation, and its real sender lock is part of the next transaction's outgoing-message boundary.
769
719
  const before = new Map(children.map((id) => [id, managedWatchRecord(id)]));
770
720
  assertReparentable(children, parent, before);
771
721
  const formerParents = [...new Set([...before.values()].flatMap((record) => record.parent ? [record.parent] : []))];
772
722
  await withRecordLocks([...children, ...formerParents].sort(), () => withDeliveryLocks(children, async () => {
773
723
  const current = new Map(children.map((id) => [id, managedWatchRecord(id)]));
774
724
  assertReparentable(children, parent, current);
775
- const snapshots = children.map((id) => ({ id, record: current.get(id), watchers: readWatchEntries(id), pending: pendingSnapshot(id) }));
776
- try {
777
- for (const snapshot of snapshots) {
778
- const { record, watchers } = snapshot;
779
- const hadNewParent = !!parent && watchers.some((entry) => entry.watcher === parent && entry.sources.includes('parent'));
780
- const withoutFormerParent = record.parent
781
- ? removeWatchSource(watchers, record.parent, 'parent').entries
782
- : watchers;
783
- const nextWatchers = parent
784
- ? addWatchSource(withoutFormerParent, parent, 'parent').entries
785
- : withoutFormerParent;
786
- if (nextWatchers !== watchers)
787
- writeWatchEntries(snapshot.id, nextWatchers);
788
- if (record.parent !== parent)
789
- writeRecord({ ...record, parent });
790
- if (parent && (record.parent !== parent || !hadNewParent))
791
- notify.push({ ...record, parent });
792
- }
793
- for (const snapshot of snapshots) {
794
- if (snapshot.record.parent && snapshot.record.parent !== parent)
795
- revokePendingFromWhileLocked(snapshot.id, snapshot.record.parent);
725
+ for (const [id, record] of current) {
726
+ const state = application.readState(id);
727
+ if (!state)
728
+ throw new ResourceConflict(`session ${id} has no canonical application state during reparent`);
729
+ if (state.parentSessionId !== record.parent) {
730
+ throw new ResourceConflict(`session ${id} canonical/record parent mismatch: record=${record.parent ?? 'null'} canonical=${state.parentSessionId ?? 'null'}`);
796
731
  }
797
732
  }
798
- catch (error) {
799
- let rollbackFailure = null;
800
- for (const snapshot of [...snapshots].reverse()) {
733
+ for (const [id, record] of current) {
734
+ if (record.parent === parent)
735
+ continue;
736
+ const change = application.transitionSession(id, { parentSessionId: parent, reason: 'reparent' });
737
+ if (record.parent) {
801
738
  try {
802
- replacePendingWhileLocked(snapshot.id, snapshot.pending);
803
- writeWatchEntries(snapshot.id, snapshot.watchers);
804
- writeRecord(snapshot.record);
739
+ application.detachWatcher(record.parent, id, 'watch:parent');
740
+ }
741
+ catch (error) {
742
+ if (!(error instanceof Error) || !/does not exist|unknown/i.test(error.message))
743
+ throw error;
805
744
  }
806
- catch (rollback) {
807
- rollbackFailure ??= rollback;
745
+ for (const message of application.readPendingMessages(id)) {
746
+ if (message.senderSessionId === record.parent)
747
+ application.dequeuePendingMessage(id, message.messageId);
808
748
  }
809
749
  }
810
- const detail = error instanceof Error ? error.message : String(error);
811
- const rollbackDetail = rollbackFailure instanceof Error ? `; rollback also failed: ${rollbackFailure.message}` : '';
812
- throw new ResourceConflict(`reparent did not commit: ${detail}${rollbackDetail}`);
750
+ if (parent) {
751
+ try {
752
+ application.attachWatcher(parent, id, 'watch:parent');
753
+ }
754
+ catch (error) {
755
+ if (!(error instanceof Error) || !/already exists|duplicate|active topology edge/i.test(error.message))
756
+ throw error;
757
+ }
758
+ // The transition above published to the OLD watcher set. The new supervisor learns the child's current
759
+ // state here, keyed by that transition's own event so a retried rewrite never sends it twice.
760
+ const moved = { ...record, parent };
761
+ application.enqueueMessage(parent, {
762
+ kind: 'session.prompt.v1',
763
+ body: Buffer.from(watchMessage(moved), 'utf8'),
764
+ senderSessionId: id,
765
+ idempotencyKey: digest(`reparent-snapshot\0${change.event.eventId}`),
766
+ });
767
+ notify.push(moved);
768
+ }
813
769
  }
814
770
  }));
815
771
  });
816
- const notified = [];
817
- if (parent)
818
- for (const child of notify) {
819
- const delivered = await sendText(parent, watchMessage(child), child.session);
820
- if (!delivered.ok)
821
- throw new ResourceConflict(`reparent committed but could not queue ${child.session}'s current state for ${parent}: ${delivered.error}`);
822
- notified.push(child.session);
823
- }
772
+ const notified = notify.map((child) => child.session);
824
773
  return { children, parent, notified };
825
774
  }
826
775
  // tmux rewrites CONTROL characters in a format string before printing them — 3.6a turns both a tab and a raw
@@ -1083,18 +1032,18 @@ export function liveness(rec, snap) {
1083
1032
  return 'unknown';
1084
1033
  return 'offline';
1085
1034
  }
1086
- function reconcile(rec, snap) {
1035
+ function reconcile(rec, snap, residentLiveness) {
1087
1036
  // record integrity outranks both axes: a session whose worktree is gone has no work to be in any state
1088
1037
  // about. It reads `retired` — a terminal, human-closable row, never a lifecycle a hook can write back over.
1089
- if (retirementReason(rec))
1090
- return 'retired';
1091
1038
  if (rec.archived)
1092
1039
  return 'offline';
1040
+ if (retirementReason(rec))
1041
+ return 'retired';
1093
1042
  if (rec.status === 'awaiting')
1094
- return PROPOSAL_STATUS[rec.proposal || 'nothing'];
1043
+ return displayStatusForProposal(rec.proposal);
1095
1044
  if (rec.status !== 'active' && rec.status !== 'idle')
1096
1045
  return rec.status; // parked | error | asking | queued (no tmux yet)
1097
- const lv = liveness(rec, snap);
1046
+ const lv = residentLiveness ?? liveness(rec, snap);
1098
1047
  if (lv !== 'online')
1099
1048
  return lv; // 'offline' | 'starting' | 'unknown'
1100
1049
  return rec.status === 'idle' ? 'idle' : 'working';
@@ -1119,13 +1068,26 @@ export function reviewIdentity(id) {
1119
1068
  label: deriveLabel({ id, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch }),
1120
1069
  };
1121
1070
  }
1071
+ export function sessionHookState(id) {
1072
+ const rec = readRecord(id);
1073
+ if (!rec)
1074
+ return null;
1075
+ const application = configuredSessionApplicationIfCutover();
1076
+ const state = application?.readState(id);
1077
+ return {
1078
+ governed: rec.governed,
1079
+ status: (state?.status ?? rec.status),
1080
+ proposal: (state?.proposal || rec.proposal || null),
1081
+ note: state?.note ?? (rec.note || null),
1082
+ };
1083
+ }
1122
1084
  function corruptSession(id, entry) {
1123
1085
  const label = `${id.slice(0, 8)} (unreadable record)`;
1124
1086
  return {
1125
1087
  id, node: null, branch: null, path: '', label, title: label, raw: { name: null, title: null },
1126
1088
  parent: null, harness: defaultHarness.id, capabilities: { headless: false }, launcher: null,
1127
1089
  lifecycle: 'active', proposal: null, merges: 0, status: 'corrupt', liveness: 'unknown',
1128
- note: corruptReason(entry), archived: false, prompt: null, promptPreview: null, created: 0,
1090
+ note: corruptReason(entry), archived: false, closedAt: null, prompt: null, promptPreview: null, created: 0,
1129
1091
  activity: null, sortKey: null, archiveHazard: null, files: [], web: [],
1130
1092
  };
1131
1093
  }
@@ -1138,7 +1100,7 @@ export function toSession(rec, status, lv, activity = null) {
1138
1100
  const pp = prompt ? oneLinePreview(prompt) : null;
1139
1101
  const parts = { id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch, activity: act, note: rec.note, promptPreview: pp };
1140
1102
  const harness = harnessById(rec.harness || defaultHarness.id);
1141
- return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts), title: deriveTitle(parts), raw: { name: rec.name, title: rec.title }, path: rec.worktreePath, parent: rec.parent, harness: harness.id, capabilities: { headless: harness.headless }, launcher: rec.launcher, lifecycle: rec.status, proposal: rec.proposal, merges: rec.merges, note: rec.note, status, liveness: lv, archived: rec.archived, archiveHazard: null, prompt, promptPreview: pp, created: rec.createdAt, activity: act, sortKey: rec.sortKey, files: readSessionFiles(rec.session), web: readSessionWebs(rec.session), ...(rec.zcodeChildSessionIds?.length ? { zcodeChildSessionIds: [...rec.zcodeChildSessionIds] } : {}) };
1103
+ return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts), title: deriveTitle(parts), raw: { name: rec.name, title: rec.title }, path: rec.worktreePath, parent: rec.parent, harness: harness.id, capabilities: { headless: harness.headless }, launcher: rec.launcher, lifecycle: rec.status, proposal: rec.proposal, merges: rec.merges, note: rec.note, status, liveness: lv, archived: rec.archived, closedAt: rec.archived ? rec.closedAt : null, archiveHazard: null, prompt, promptPreview: pp, created: rec.createdAt, activity: act, sortKey: rec.sortKey, files: readSessionFiles(rec.session), web: readSessionWebs(rec.session), ...(rec.zcodeChildSessionIds?.length ? { zcodeChildSessionIds: [...rec.zcodeChildSessionIds] } : {}) };
1142
1104
  }
1143
1105
  // @@@zcode child identity - ZCode owns the child id and SpexCode owns the session record. The writer accepts
1144
1106
  // only their exact declared pair; names, worktrees, branches, and timestamps are deliberately not candidates.
@@ -1197,6 +1159,44 @@ export async function sessionPrompt(id) {
1197
1159
  throw e;
1198
1160
  }
1199
1161
  }
1162
+ // The archive overlay has no reader for the session model. Keep this projection separate from listSessions so
1163
+ // opening it skips the live tmux census, resident adapter probes, and files/web reads, and carries no full prompt bytes.
1164
+ export async function listArchivedSessionIndex(probe) {
1165
+ const rows = [];
1166
+ for (const id of listSessionIds()) {
1167
+ let entry;
1168
+ try {
1169
+ entry = readPublicRecordEntry(id);
1170
+ }
1171
+ catch {
1172
+ continue;
1173
+ }
1174
+ if (entry.kind !== 'ok')
1175
+ continue;
1176
+ const rec = fromRaw(entry.raw);
1177
+ if (!rec.governed || !rec.archived)
1178
+ continue;
1179
+ const parts = {
1180
+ id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch,
1181
+ activity: null, note: rec.note, promptPreview: null,
1182
+ };
1183
+ // Name and note are ahead of the prompt in deriveTitle's precedence. Avoid touching the prompt artifact
1184
+ // unless both are absent; only then can its preview change the visible title.
1185
+ if (!rec.name && !rec.note?.trim()) {
1186
+ probe && (probe.promptReads = (probe.promptReads || 0) + 1);
1187
+ const prompt = readPromptFile(id);
1188
+ parts.promptPreview = prompt ? oneLinePreview(prompt) : null;
1189
+ }
1190
+ rows.push({
1191
+ id: rec.session,
1192
+ title: deriveTitle(parts),
1193
+ label: deriveLabel(parts),
1194
+ closedAt: rec.closedAt,
1195
+ node: rec.node,
1196
+ });
1197
+ }
1198
+ return rows;
1199
+ }
1200
1200
  // Preserve rows through a transient record-read failure; prune after the store entry disappears.
1201
1201
  const lastKnownSession = new Map();
1202
1202
  // A BOARD row carries the launch ask only as its one-line preview. The full text is served by the
@@ -1222,9 +1222,23 @@ export async function listSessions(includeArchived = false) {
1222
1222
  }
1223
1223
  catch { /* guardSession below preserves the last-known row for a transient read failure */ }
1224
1224
  }
1225
- // Only archived adapter records need the resident-ID join. If there are none, this read path performs zero
1226
- // control-plane probes; resources still owns the full turn/read probe for its detailed report.
1227
- const censusRecords = [...snapshots.values()].flatMap(({ entry, rec }) => entry.kind === 'ok' && entry.liveness === null && rec && rec.governed && rec.archived && rec.harnessSessionId
1225
+ const canonical = configuredSessionApplicationIfCutover();
1226
+ const canonicalStates = new Map();
1227
+ if (canonical) {
1228
+ for (const [id, snapshot] of snapshots) {
1229
+ if (!snapshot.rec?.governed)
1230
+ continue;
1231
+ const state = canonical.readState(id);
1232
+ if (!state)
1233
+ throw new ResourceConflict(`session ${id} has no canonical application state after JSON cutover`);
1234
+ canonicalStates.set(id, state);
1235
+ }
1236
+ }
1237
+ // Adapter-owned records have no pane witness. Join one project-wide resident-ID census to every exact
1238
+ // bound target, including live rows; otherwise a dead shared app-server could leave a stale headless record
1239
+ // online indefinitely. The descriptor probe remains one-per-generation, not one RPC per session.
1240
+ const censusRecords = [...snapshots.values()].flatMap(({ entry, rec }) => entry.kind === 'ok' && rec && rec.governed
1241
+ && rec.harnessSessionId && harnessById(rec.harness || defaultHarness.id).runtimeOwnership === 'adapter'
1228
1242
  ? [{ ...rec, harness: rec.harness || defaultHarness.id }]
1229
1243
  : []);
1230
1244
  const residentCensus = censusRecords.length ? await adapterLoadedReferenceState(censusRecords) : new Map();
@@ -1263,35 +1277,43 @@ export async function listSessions(includeArchived = false) {
1263
1277
  lastKnownSession.delete(id);
1264
1278
  return null;
1265
1279
  } // no record, or a self-launched (non-board) one
1280
+ const projectedRecord = canonicalRecordProjection(rec, canonicalStates.get(id));
1266
1281
  // A forced public liveness comes only from the shared record projection. Do not let live process/thread
1267
1282
  // evidence punch through it (including archive hazard repair).
1268
1283
  if (entry.kind === 'ok' && entry.liveness === 'offline') {
1269
- const pending = boardRow(toSession(rec, 'offline', 'offline'));
1284
+ const pending = boardRow(toSession(projectedRecord, 'offline', 'offline'));
1270
1285
  lastKnownSession.set(id, pending);
1271
1286
  return pending;
1272
1287
  }
1273
1288
  // the pane title → headline activity, gated by THIS session's harness ([[harness-adapter]]): claude's title
1274
1289
  // is its task self-summary (used); codex's is the cwd folder name (refused → headline falls to the prompt).
1275
1290
  const activity = paneActivity(harnessById(rec.harness || defaultHarness.id), snap.titles.get(id));
1276
- const sessionHarness = harnessById(rec.harness || defaultHarness.id);
1277
- const resident = rec.harnessSessionId
1278
- ? residentCensus.get(`${rec.harness || defaultHarness.id}:${rec.harnessSessionId}`)
1291
+ const sessionHarness = harnessById(projectedRecord.harness || defaultHarness.id);
1292
+ const resident = projectedRecord.harnessSessionId
1293
+ ? residentCensus.get(`${projectedRecord.harness || defaultHarness.id}:${projectedRecord.harnessSessionId}`)
1279
1294
  : undefined;
1280
- const residentRequired = sessionHarness.runtimeOwnership === 'adapter' && !!rec.harnessSessionId && !!sessionHarness.sharedRuntimes?.(runtimeRoot()).length;
1281
- const physical = rec.archived
1295
+ const residentRequired = sessionHarness.runtimeOwnership === 'adapter' && !!projectedRecord.harnessSessionId && !!sessionHarness.sharedRuntimes?.(runtimeRoot()).length;
1296
+ const physical = projectedRecord.archived
1282
1297
  ? (sessionHarness.runtimeOwnership === 'adapter'
1283
1298
  ? (resident && !resident.healthy ? 'unknown' : resident?.loaded ? 'online' : snap.windows.has(id) ? 'online' : 'offline')
1284
- : liveness({ ...rec, archived: false, stopped: false }, snap))
1299
+ : liveness({ ...projectedRecord, archived: false, stopped: false }, snap))
1285
1300
  : null;
1286
1301
  // Only a physically-offline record projects as archived. A legacy archived+live/unknown record is exposed
1287
1302
  // as ordinary working-set state with its real liveness/status and one backend-owned hazard marker. A
1288
1303
  // missing durable cold proof is also legacy: leaf liveness alone cannot prove a Codex loaded thread was
1289
1304
  // unloaded, so it remains visible until an explicit archive repair.
1290
- const cleanCold = rec.archived && !changedDuringCensus.has(id) && hasValidColdProof(rec) && physical === 'offline' && (!residentRequired || resident?.healthy === true);
1291
- const projected = rec.archived && !cleanCold ? { ...rec, archived: false, stopped: false } : rec;
1292
- const projectedLv = projected === rec ? liveness(rec, snap) : physical;
1293
- const s = boardRow(toSession(projected, reconcile(projected, snap), projectedLv, activity));
1294
- if (projected !== rec)
1305
+ const cleanCold = projectedRecord.archived && !changedDuringCensus.has(id) && hasValidColdProof(projectedRecord) && physical === 'offline' && (!residentRequired || resident?.healthy === true);
1306
+ const projected = projectedRecord.archived && !cleanCold ? { ...projectedRecord, archived: false, stopped: false } : projectedRecord;
1307
+ const projectedLv = projected === projectedRecord
1308
+ ? sessionHarness.runtimeOwnership === 'adapter'
1309
+ ? adapterResidentLiveness(projectedRecord, resident)
1310
+ : liveness(projectedRecord, snap)
1311
+ : physical;
1312
+ const s = boardRow(toSession(projected, reconcile(projected, snap, projectedLv), projectedLv, activity));
1313
+ // Canonical projection deliberately creates a fresh object for every governed row. That identity change is not
1314
+ // an archive failure: a hazard belongs only to a record that was actually archived and then had its cold proof
1315
+ // rejected. Otherwise every live row would inherit the missing-cold-witness message after cutover.
1316
+ if (projectedRecord.archived && !cleanCold)
1295
1317
  s.archiveHazard = changedDuringCensus.has(id)
1296
1318
  ? 'archived runtime hazard: record changed while adapter residency was being reconciled; retry exact archive'
1297
1319
  : hasValidColdProof(rec)
@@ -1308,7 +1330,7 @@ export async function listSessions(includeArchived = false) {
1308
1330
  lastKnownSession.set(id, s);
1309
1331
  return s;
1310
1332
  }, () => {
1311
- // DEGRADED: the record dir still exists but reading session.json failed transiently. NEVER drop a live
1333
+ // DEGRADED: the record dir still exists but reading runtime.json failed transiently. NEVER drop a live
1312
1334
  // session — serve its last-known row. (No last-known means a first sighting raced a failure; nothing to
1313
1335
  // show yet, and it reappears on the next build.)
1314
1336
  return lastKnownSession.get(id) ?? null;
@@ -1436,7 +1458,7 @@ export function withPeerSenderHint(text, sender, sshAddress, machineId) {
1436
1458
  const who = sender.label && sender.label !== sender.id ? `session "${sender.label}" (${sender.id})` : `session ${sender.id}`;
1437
1459
  return `${text}\n\n— from ${who} on machine ${machineId}. To reply: spex session send --ssh ${sshAddress} ${sender.id} "<your reply>"`;
1438
1460
  }
1439
- export const withNoteReplyHint = (text) => `${text}\n\n— REQUIRED REPLY TRANSPORT (PER-MESSAGE): this terminal-free sender CANNOT see normal assistant/final output. Do not stop after only printing the answer. As your FINAL action, put your COMPLETE reply to this message in the truthful declaration's --note. For an answered exploratory question or simple answer awaiting the sender's follow-up, run \`spex session ask --note "<complete reply>"\`; if the true state is done or parked, put the same complete reply in that declaration's --note instead. This declaration command is reply transport, not part of the requested work, and remains REQUIRED even when the message says to use no tools, make no tool calls, or only print/reply. A later message arriving WITHOUT this notice means the sender is back at a terminal and reads your normal output again.`;
1461
+ export const withNoteReplyHint = (text) => `${text}\n\n— REPLY TRANSPORT: This sender cannot read normal assistant output. Before ending this turn, make your FINAL tool call a Spex declaration carrying the COMPLETE reply in --note: use \`session ask\` when waiting for a human reply; use \`done\` or \`park\` when that is the truthful state. This rule applies even when asked to only print/reply or make no tool calls.\n\nFor multi-line replies, preserve real LF characters. \`functions.exec\` runs a shell command through bash, so never interpolate \`JSON.stringify(note)\` into it; use stdin, a heredoc, or base64, then pass \`--note \"$note\"\`. Never use \`String.raw\` or literal backslash+n. Do not call any tool after the declaration.`;
1440
1462
  export const withTerminalReplyHint = (text) => `${text}\n\n— sent from a terminal-attached client: the sender now reads your terminal output directly. Reply in your normal conversation output from here on — stop putting replies in declaration --notes (the earlier terminal-free notices no longer apply; a --note can go back to being a short status line).`;
1441
1463
  export const slugify = (s) => (s || 'session').normalize('NFC').replace(/[^\p{L}\p{N}_-]+/gu, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '') || 'session';
1442
1464
  const MENTION = /\[\[(\.?[\p{L}\p{N}_-]+)\]\]/u;
@@ -1552,6 +1574,10 @@ export function launchPreflight(rec) {
1552
1574
  }
1553
1575
  // @@@ launch quoting - single-quote a string for a POSIX shell, `'` → `'\''`. Used to nest the whole agent
1554
1576
  // invocation inside the birth-registration `sh -c '…'` wrapper without any segment double-expanding.
1577
+ // 后端把这条命令输入交互式 shell,脚本路径必须作为一个 shell 参数传递。
1578
+ export function launchShellCommand(file) {
1579
+ return `bash ${shQuote(file)}`;
1580
+ }
1555
1581
  export function launchScript(id, tail, harness = HARNESS, cmd) {
1556
1582
  const file = join(storeDir(id), 'launch.sh');
1557
1583
  // NO --append-system-prompt / --settings: the contract + hooks are materialized into the worktree at
@@ -1559,7 +1585,7 @@ export function launchScript(id, tail, harness = HARNESS, cmd) {
1559
1585
  // agent. The launch line is just the rendezvous env + the harness command + the session-id/spec-pointer/prompt tail.
1560
1586
  // `cmd` is the session's persisted launcher command ([[launcher-select]]); when set it OVERRIDES the harness's
1561
1587
  // ambient default so resume reuses the same auth. Undefined is only for old records before launch_cmd existed.
1562
- const invocation = `${rvEnv(id, harness)} ${harness.launchCmd(id, runtimeRoot(), cmd)} ${tail}`;
1588
+ const invocation = `${rvEnv(id, harness, readRecord(id)?.runtimeStartToken)} ${harness.launchCmd(id, runtimeRoot(), cmd)} ${tail}`;
1563
1589
  // @@@ birth registration - record the AGENT's real pid BEFORE exec, the anchor of the 100ms hot death tier
1564
1590
  // ([[state]]). Each attempt runs `sh -c '<pid-write>; exec env <invocation>'`: the sh writes its own `$$` to
1565
1591
  // agent.pid, then `exec env` REPLACES that sh in place — so the pid persists down the whole command chain
@@ -1613,7 +1639,7 @@ export function launchScript(id, tail, harness = HARNESS, cmd) {
1613
1639
  // -t "$TMUX_PANE" names THIS pane explicitly (tmux still resolves the server from $TMUX), so the capture
1614
1640
  // can never land on a neighbouring pane; run outside tmux the call fails, nothing matches, and the plain
1615
1641
  // bounded retry stands.
1616
- ` if tmux capture-pane -p -S -400 -t "\${TMUX_PANE:-}" 2>/dev/null | sed -n "/$__spex_mark/,\\$p" | grep -Eq ${shQuote(fatal)}; then`,
1642
+ ` if tmux capture-pane -p -S -400 -t "\${TMUX_PANE:-.}" 2>/dev/null | sed -n "/$__spex_mark/,\\$p" | grep -Eq ${shQuote(fatal)}; then`,
1617
1643
  ` printf '[spex launch] attempt %s exited in %ss (rc=%s) - the launcher reported a failure retrying cannot fix (see above); not retrying\\n' "$__spex_try" "$(( SECONDS - __spex_t0 ))" "$__spex_rc" >&2`,
1618
1644
  ` exit $__spex_rc`,
1619
1645
  ` fi`,
@@ -1634,7 +1660,8 @@ async function launch(id, path, tail, harness = HARNESS, cmd) {
1634
1660
  if (harness.ownsRendezvous)
1635
1661
  stampRvSock(id);
1636
1662
  await tmux(['new-session', '-d', '-s', id, '-x', String(COLS), '-y', String(ROWS), '-c', path]);
1637
- await tmux(['send-keys', '-t', id, '-l', '--', `bash ${launchScript(id, tail, harness, cmd)}`]);
1663
+ const file = launchScript(id, tail, harness, cmd);
1664
+ await tmux(['send-keys', '-t', id, '-l', '--', launchShellCommand(file)]);
1638
1665
  await tmux(['send-keys', '-t', id, 'Enter']);
1639
1666
  launchedAt.set(id, Date.now()); // stamp the boot window so reconcile reads 'starting', not 'offline', until the socket is up
1640
1667
  }
@@ -1642,10 +1669,9 @@ const OCCUPIES_SLOT = new Set(['working', 'parked', 'starting']); // starting's
1642
1669
  function isOccupying(s, snap) {
1643
1670
  if (!OCCUPIES_SLOT.has(s.status))
1644
1671
  return false; // waiting-on-human / proposed / queued / dead → free
1645
- const rec = readRecord(s.id);
1646
- if (!rec)
1647
- return false;
1648
- return harnessById(rec.harness || defaultHarness.id).liveness(rec, snap.windows.has(rec.session), runtimeRoot(), snap.windows.get(rec.session), snap.sockets.has(rec.session)) === 'online';
1672
+ // `listSessions` already joined the adapter resident census and projected the resulting liveness. Re-reading
1673
+ // the harness here would resurrect the old record-backed codex-headless shortcut and disagree with the row.
1674
+ return s.liveness === 'online';
1649
1675
  }
1650
1676
  // sessions we've JUST launched whose agent hasn't come online yet. During that boot window reconcile reads them
1651
1677
  // `offline` (the adapter's online-signal not up yet) and isOccupying would miss them, so the drainer would
@@ -1675,23 +1701,167 @@ async function withSessionTransition(id, body) {
1675
1701
  }
1676
1702
  }
1677
1703
  let draining = false; // re-entrancy guard: only one drain pass runs at a time (no double-launch)
1678
- function noteQueuedLaunchFailureUnlocked(id, error) {
1704
+ // A native receipt is bound before the readiness fence validates it. Suppress only that immediate wake so
1705
+ // queued prompts cannot drain during the candidate window; the successful publication path drains normally.
1706
+ const readinessWakeSuppressed = new Set();
1707
+ function noteQueuedLaunchFailureUnlocked(id, error, terminal = true, label, live = false) {
1679
1708
  const reason = error instanceof Error ? error.message : String(error);
1680
- const note = `queued launch readiness failed: ${reason}`;
1709
+ const note = `${label ?? (terminal ? 'queued launch readiness failed' : 'launch readiness warning')}: ${reason}`;
1681
1710
  console.error(`spex: session ${id}: ${note}`);
1682
1711
  const rec = readRecord(id);
1683
- if (rec && rec.note !== note)
1684
- writeRecord({ ...rec, note });
1712
+ if (rec && !retirementReason(rec) && (rec.note !== note
1713
+ || (terminal && (rec.status !== 'error' || !rec.stopped || rec.launchReadinessStartedAt != null))
1714
+ || (!terminal && live && (rec.status === 'error' || rec.stopped)))) {
1715
+ // Readiness failure is terminal for this launch attempt. Keep the exact reason on the record,
1716
+ // publish an offline/error transition, and clear every durable/in-memory ownership marker so close
1717
+ // and a later explicit resume have an honest starting point.
1718
+ if (terminal) {
1719
+ publishCanonicalLifecycle(rec, 'error', null, note);
1720
+ writeRecord({ ...rec, status: 'error', proposal: null, stopped: true, note, launchOwner: null, launchReadinessStartedAt: null });
1721
+ }
1722
+ else {
1723
+ const status = live && (rec.status === 'error' || rec.stopped) ? 'active' : rec.status;
1724
+ const stopped = live ? false : rec.stopped;
1725
+ const restored = { ...rec, status, stopped, note, launchOwner: null, launchReadinessStartedAt: null };
1726
+ // A live post-receipt timeout is a diagnostic, not a new parent-watch transition. If an older failed
1727
+ // attempt already published `error`, however, the canonical row must be repaired to the live status or
1728
+ // the JSON write below would leave the sole lifecycle authority disagreeing with the runtime witness.
1729
+ // Publish even when status is unchanged: the warning note is canonical too. Active recipients exclude
1730
+ // the parent, so this diagnostic cannot manufacture a parent-watch transition.
1731
+ publishCanonicalLifecycle(restored, status, restored.proposal, note);
1732
+ writeRecord(restored);
1733
+ }
1734
+ }
1735
+ }
1736
+ function clearReadinessResidueUnlocked(rec, clearDiagnostic) {
1737
+ const application = configuredSessionApplicationIfCutover();
1738
+ const next = {
1739
+ ...rec,
1740
+ status: 'active',
1741
+ stopped: false,
1742
+ note: clearDiagnostic ? null : rec.note,
1743
+ launchReadinessStartedAt: null,
1744
+ };
1745
+ if (application?.readState(rec.session) && (clearDiagnostic || rec.status !== 'active' || rec.stopped)) {
1746
+ application.transitionSession(rec.session, {
1747
+ status: 'active',
1748
+ proposal: rec.proposal,
1749
+ note: next.note,
1750
+ parentSessionId: rec.parent,
1751
+ recipientSessionIds: [],
1752
+ });
1753
+ }
1754
+ writeRecord(next);
1755
+ }
1756
+ export function canonicalWatchRecipients(application, sessionId, status) {
1757
+ const recipients = new Set();
1758
+ for (const edge of application.topology.parents(sessionId)) {
1759
+ // The canonical topology stores the structural parent edge as the durable parent-watch source. Older
1760
+ // migrated rows may also have an explicit watch:parent edge; both represent the same policy source.
1761
+ if (edge.relationType !== 'parent' && !edge.relationType.startsWith('watch'))
1762
+ continue;
1763
+ if (status === 'active' && (edge.relationType === 'parent' || edge.relationType === 'watch:parent'))
1764
+ continue;
1765
+ recipients.add(edge.fromSessionId);
1766
+ }
1767
+ return [...recipients];
1685
1768
  }
1686
- function observeQueuedLaunchReadiness(id, harness) {
1687
- void waitForReady(id, harness)
1769
+ export function sessionHasPendingDelivery(id, application = configuredSessionApplicationIfCutover() ?? null) {
1770
+ if (!application)
1771
+ throw new ResourceConflict(`session application is unavailable for ${id}`);
1772
+ const runtime = application.resolveRuntime?.(id, 'spex-governed');
1773
+ if (runtime === null)
1774
+ return false;
1775
+ try {
1776
+ return application.readPendingMessages(id).length > 0;
1777
+ }
1778
+ catch (error) {
1779
+ // A legacy record can outlive its migrated protocol address. It has no canonical queue to drain;
1780
+ // treating that address as owed makes the supervisor retry the same impossible lookup forever.
1781
+ if (error?.code === 'PROTOCOL_SESSION_UNKNOWN'
1782
+ || /unknown protocol address/i.test(error instanceof Error ? error.message : String(error)))
1783
+ return false;
1784
+ throw error;
1785
+ }
1786
+ }
1787
+ export function canonicalRecordProjection(rec, canonical) {
1788
+ // The application row is the only lifecycle fact after cutover. A JSON status is historical envelope data,
1789
+ // so it must not win merely because it says waiting/error/archived while the canonical row says otherwise.
1790
+ if (!canonical) {
1791
+ return rec;
1792
+ }
1793
+ return {
1794
+ ...rec,
1795
+ status: canonical.status,
1796
+ proposal: canonical.proposal,
1797
+ note: canonical.note,
1798
+ parent: canonical.parentSessionId,
1799
+ };
1800
+ }
1801
+ function publishCanonicalLifecycle(rec, status, proposal, note) {
1802
+ const application = configuredSessionApplicationIfCutover();
1803
+ if (!application)
1804
+ return;
1805
+ if (!application.readState(rec.session)) {
1806
+ application.createSession({ sessionId: rec.session, status, proposal, note, parentSessionId: rec.parent });
1807
+ if (rec.parent)
1808
+ application.attachWatcher(rec.parent, rec.session, 'watch:parent');
1809
+ return;
1810
+ }
1811
+ application.transitionSession(rec.session, {
1812
+ status,
1813
+ proposal,
1814
+ note,
1815
+ parentSessionId: rec.parent,
1816
+ recipientSessionIds: canonicalWatchRecipients(application, rec.session, status),
1817
+ });
1818
+ }
1819
+ async function launchReadinessWitnessAlive(id, harness, current) {
1820
+ if (harness.runtimeOwnership === 'adapter') {
1821
+ const state = await adapterRuntimeLiveness({ ...current, stopped: false, archived: false });
1822
+ return state === 'online';
1823
+ }
1824
+ if (agentAlive(id) === true)
1825
+ return true;
1826
+ try {
1827
+ const snap = await liveSnapshot(id);
1828
+ return harness.liveness(current, snap.windows.has(id), runtimeRoot(), snap.windows.get(id), snap.sockets.has(id)) === 'online';
1829
+ }
1830
+ catch {
1831
+ return false;
1832
+ }
1833
+ }
1834
+ export function adapterResidentLiveness(rec, resident) {
1835
+ if (rec.stopped || rec.archived)
1836
+ return 'offline';
1837
+ if (!rec.harnessSessionId)
1838
+ return 'offline';
1839
+ if (!resident)
1840
+ return 'unknown';
1841
+ if (!resident.healthy)
1842
+ return 'unknown';
1843
+ return resident.loaded ? 'online' : 'offline';
1844
+ }
1845
+ async function adapterRuntimeLiveness(rec) {
1846
+ if (rec.stopped || rec.archived)
1847
+ return 'offline';
1848
+ const harness = harnessById(rec.harness || defaultHarness.id);
1849
+ if (harness.runtimeOwnership !== 'adapter')
1850
+ return liveness(rec, await liveSnapshot());
1851
+ if (!rec.harnessSessionId)
1852
+ return 'offline';
1853
+ const states = await adapterLoadedReferenceState([{ ...rec, harness: harness.id }], runtimeRoot());
1854
+ return adapterResidentLiveness(rec, states.get(`${harness.id}:${rec.harnessSessionId}`));
1855
+ }
1856
+ function observeQueuedLaunchReadiness(id, harness, timeoutMs = SOCKET_READY_TIMEOUT_MS) {
1857
+ void waitForReady(id, harness, undefined, timeoutMs)
1688
1858
  .then(async (readiness) => {
1689
1859
  if (!readiness) {
1690
1860
  const committed = !!readRecord(id)?.harnessSessionId;
1691
1861
  throw new ResourceConflict(harness.launchPayloadProof
1692
1862
  ? committed
1693
- ? 'post-proof adapter liveness did not become ready before launch readiness timed out'
1694
- : 'native identity and first-turn rollout proof did not arrive before launch readiness timed out'
1863
+ ? 'post-receipt adapter liveness did not become ready before launch readiness timed out'
1864
+ : 'native identity and first-turn rollout receipt did not arrive before launch readiness timed out'
1695
1865
  : 'adapter liveness did not become ready before launch readiness timed out');
1696
1866
  }
1697
1867
  let readyToPublish = false;
@@ -1708,23 +1878,35 @@ function observeQueuedLaunchReadiness(id, harness) {
1708
1878
  const current = readRecord(id);
1709
1879
  if (!current)
1710
1880
  return;
1711
- if (current.status === 'queued')
1712
- writeRecord({ ...current, status: 'active', proposal: null, note: null, launchOwner: null });
1881
+ if (current.status === 'queued') {
1882
+ publishCanonicalLifecycle(current, 'active', null, null);
1883
+ writeRecord({ ...current, status: 'active', proposal: null, note: null, stopped: false, launchOwner: null, launchReadinessStartedAt: null });
1884
+ }
1885
+ else if (current.launchReadinessStartedAt != null)
1886
+ writeRecord({ ...current, launchReadinessStartedAt: null });
1713
1887
  readyToPublish = true;
1714
1888
  });
1715
1889
  if (!readyToPublish)
1716
1890
  return;
1717
- await deliverPendingWatchSnapshots(id);
1718
1891
  await drainSession(id);
1719
1892
  })
1720
1893
  .catch(async (error) => {
1894
+ const reason = error instanceof Error ? error.message : String(error);
1895
+ const timedOut = /timed out|did not become ready/i.test(reason);
1896
+ let live = false;
1897
+ let terminal = timedOut;
1721
1898
  try {
1722
- await withRecordLock(id, async () => noteQueuedLaunchFailureUnlocked(id, error));
1899
+ await withRecordLock(id, async () => {
1900
+ const current = readRecord(id);
1901
+ if (timedOut && current)
1902
+ live = await launchReadinessWitnessAlive(id, harness, current);
1903
+ terminal = timedOut && !live;
1904
+ noteQueuedLaunchFailureUnlocked(id, error, terminal, live ? 'launch readiness warning' : undefined, live);
1905
+ });
1723
1906
  }
1724
1907
  catch (recordError) {
1725
- console.error(`spex: session ${id}: queued launch failure could not be recorded: ${recordError instanceof Error ? recordError.message : String(recordError)}; original failure: ${error instanceof Error ? error.message : String(error)}`);
1908
+ console.error(`spex: session ${id}: queued launch failure could not be recorded: ${recordError instanceof Error ? recordError.message : String(recordError)}; original failure: ${reason}`);
1726
1909
  }
1727
- await clearPendingWatchSnapshots(id);
1728
1910
  })
1729
1911
  .finally(() => launching.delete(id));
1730
1912
  }
@@ -1743,7 +1925,7 @@ async function startQueuedUnlocked(id) {
1743
1925
  if (!canDrainQueued(wt.rec))
1744
1926
  return 'retryable';
1745
1927
  const h = harnessById(wt.rec.harness || defaultHarness.id);
1746
- if (h.launchPayloadProof && existsSync(sessionArtifactPath(id, 'launch.proof'))) {
1928
+ if (h.launchPayloadProof && hasReadableLaunchReceipt(id)) {
1747
1929
  launching.add(id);
1748
1930
  let readinessOwnsSlot = false;
1749
1931
  try {
@@ -1751,11 +1933,13 @@ async function startQueuedUnlocked(id) {
1751
1933
  consumeHarnessLaunchProofUnlocked(id);
1752
1934
  }
1753
1935
  catch (error) {
1754
- noteQueuedLaunchFailureUnlocked(id, error);
1936
+ noteQueuedLaunchFailureUnlocked(id, error, false);
1755
1937
  throw error;
1756
1938
  }
1757
1939
  const recovered = readRecord(id) || wt.rec;
1758
- writeRecord({ ...recovered, status: 'active', proposal: null, note: null, launchOwner: null });
1940
+ const readinessStartedAt = Date.now();
1941
+ publishCanonicalLifecycle(recovered, 'active', null, null);
1942
+ writeRecord({ ...recovered, status: 'active', proposal: null, note: null, launchOwner: null, launchReadinessStartedAt: readinessStartedAt });
1759
1943
  observeQueuedLaunchReadiness(id, h);
1760
1944
  readinessOwnsSlot = true;
1761
1945
  return 'started';
@@ -1788,11 +1972,17 @@ async function startQueuedUnlocked(id) {
1788
1972
  launching.add(id); // hold the slot across the boot window BEFORE we launch, so a concurrent count can't race us
1789
1973
  let readinessOwnsSlot = false;
1790
1974
  try {
1975
+ const readinessStartedAt = Date.now();
1976
+ const stamped = readRecord(id) || wt.rec;
1977
+ writeRecord({ ...stamped, launchReadinessStartedAt: readinessStartedAt });
1791
1978
  try {
1792
1979
  const sq = shQuote(launchPrompt);
1793
1980
  await launch(id, wt.path, `${h.sessionIdArg(id)} ${sq}`.trim(), h, launcherCmd(wt.rec));
1794
1981
  }
1795
1982
  catch {
1983
+ const failedLaunch = readRecord(id);
1984
+ if (failedLaunch)
1985
+ writeRecord({ ...failedLaunch, launchReadinessStartedAt: null });
1796
1986
  return 'retryable'; // launch failed → stays `queued`, with its initial debt, for the next drain tick
1797
1987
  }
1798
1988
  // the note this record may carry is the QUEUED state's word (a launch-blocker message stamped above); the
@@ -1800,7 +1990,8 @@ async function startQueuedUnlocked(id) {
1800
1990
  // belongs to the state currently declared" true for every writer — the invariant [[session-label]]'s
1801
1991
  // headline precedence stands on.
1802
1992
  const launched = readRecord(id) || wt.rec;
1803
- writeRecord({ ...launched, status: 'active', proposal: null, note: null, launchOwner: null });
1993
+ publishCanonicalLifecycle(launched, 'active', null, null);
1994
+ writeRecord({ ...launched, status: 'active', proposal: null, note: null, stopped: false, launchOwner: null, launchReadinessStartedAt: readinessStartedAt });
1804
1995
  if (!h.launchPayloadProof)
1805
1996
  removeLaunchFile(id);
1806
1997
  // release the boot-window hold once the socket is up (then isOccupying takes over) or after the bounded
@@ -1825,16 +2016,60 @@ async function drainQueueUnlocked() {
1825
2016
  const [sessions, snap] = await Promise.all([listSessions(), liveSnapshot()]);
1826
2017
  for (const session of sessions) {
1827
2018
  const rec = readRecord(session.id);
1828
- if (!rec || launching.has(session.id) || !readWatchEntries(session.id).some((entry) => entry.snapshotPending))
2019
+ if (!rec || launching.has(session.id))
1829
2020
  continue;
1830
- if (rec.status === 'queued')
2021
+ // Older timed-out rows predate the durable readiness timestamp. Reconcile their recorded failure
2022
+ // before any queue/watch work so a backend restart cannot resurrect the old active/limbo projection.
2023
+ if (rec.status !== 'queued' && /^queued launch readiness failed:/.test(rec.note || '')) {
2024
+ const priorReason = (rec.note || '').replace(/^queued launch readiness failed:\s*/, '') || 'launch readiness timed out';
2025
+ const harness = harnessById(rec.harness || defaultHarness.id);
2026
+ const live = await launchReadinessWitnessAlive(session.id, harness, rec);
2027
+ if (live) {
2028
+ await withRecordLock(session.id, async () => {
2029
+ const current = readRecord(session.id);
2030
+ if (current && !current.archived && !current.stopped)
2031
+ clearReadinessResidueUnlocked(current, true);
2032
+ });
2033
+ continue;
2034
+ }
2035
+ await withRecordLock(session.id, async () => noteQueuedLaunchFailureUnlocked(session.id, priorReason, !live, live ? 'launch readiness warning' : undefined, live));
1831
2036
  continue;
1832
- if (rec.status === 'active' && !rec.stopped && !rec.archived) {
2037
+ }
2038
+ // A pre-fix active row may still carry the authoritative launch artifact without a timestamp. Its
2039
+ // mtime is the only durable age witness available; seed the new field so the same bounded recovery
2040
+ // rule applies on this and later restarts.
2041
+ if (rec.status === 'active' && !rec.stopped && existsSync(sessionArtifactPath(session.id, 'launch')) && rec.launchReadinessStartedAt == null) {
2042
+ const harness = harnessById(rec.harness || defaultHarness.id);
2043
+ const live = await launchReadinessWitnessAlive(session.id, harness, rec);
2044
+ if (!live) {
2045
+ let startedAt = Date.now();
2046
+ try {
2047
+ startedAt = statSync(sessionArtifactPath(session.id, 'launch')).mtimeMs;
2048
+ }
2049
+ catch { /* race: observer below will fail loud */ }
2050
+ writeRecord({ ...rec, launchReadinessStartedAt: startedAt });
2051
+ }
2052
+ }
2053
+ const refreshed = readRecord(session.id) || rec;
2054
+ if (refreshed.launchReadinessStartedAt && !refreshed.stopped && !refreshed.archived) {
2055
+ const harness = harnessById(refreshed.harness || defaultHarness.id);
2056
+ const live = await launchReadinessWitnessAlive(session.id, harness, refreshed);
2057
+ if (live) {
2058
+ await withRecordLock(session.id, async () => {
2059
+ const current = readRecord(session.id);
2060
+ if (current && !current.archived && !current.stopped) {
2061
+ clearReadinessResidueUnlocked(current, /^launch readiness warning:/.test(current.note || ''));
2062
+ }
2063
+ });
2064
+ continue;
2065
+ }
1833
2066
  launching.add(session.id);
1834
- observeQueuedLaunchReadiness(session.id, harnessById(rec.harness || defaultHarness.id));
2067
+ const remaining = Math.max(0, SOCKET_READY_TIMEOUT_MS - (Date.now() - refreshed.launchReadinessStartedAt));
2068
+ observeQueuedLaunchReadiness(session.id, harness, remaining);
1835
2069
  continue;
1836
2070
  }
1837
- await deliverPendingWatchSnapshots(session.id, false);
2071
+ if (rec.status === 'queued')
2072
+ continue;
1838
2073
  }
1839
2074
  // if the liveness probe FAILED (tmux timing out — the overload condition), occupancy is UNKNOWABLE: every
1840
2075
  // session would read window-less and isOccupying would undercount, so the drainer would OVER-launch and pile
@@ -1844,13 +2079,6 @@ async function drainQueueUnlocked() {
1844
2079
  break;
1845
2080
  const occupied = sessions.reduce((n, s) => n + (launching.has(s.id) || isOccupying(s, snap) ? 1 : 0), 0);
1846
2081
  if (occupied >= cap) {
1847
- const authority = backendLaunchAuthority();
1848
- await Promise.all(sessions.filter((session) => {
1849
- if (session.status !== 'queued')
1850
- return false;
1851
- const rec = readRecord(session.id);
1852
- return !!rec && canDrainQueued(rec, authority);
1853
- }).map((session) => deliverPendingWatchSnapshots(session.id)));
1854
2082
  break;
1855
2083
  }
1856
2084
  const authority = backendLaunchAuthority();
@@ -1864,8 +2092,6 @@ async function drainQueueUnlocked() {
1864
2092
  break;
1865
2093
  const started = await startQueued(next.id);
1866
2094
  if (started !== 'started') {
1867
- if (started === 'blocked')
1868
- await clearPendingWatchSnapshots(next.id);
1869
2095
  break; // launch failed → stop this pass; a later tick retries
1870
2096
  }
1871
2097
  }
@@ -1880,6 +2106,18 @@ const requestQueueDrain = () => {
1880
2106
  console.error(`spex: queue drain failed: ${error instanceof Error ? error.message : String(error)}`);
1881
2107
  });
1882
2108
  };
2109
+ // Canonical state commits already own the durable recipient queue. This is only the post-commit wake that hands
2110
+ // each queued recipient to its existing runtime; a failed or absent runtime leaves the message pending for retry.
2111
+ setSessionApplicationCommitWake((recipients) => {
2112
+ const wakeRecipients = recipients.filter(recipient => !readinessWakeSuppressed.has(recipient));
2113
+ queueMicrotask(() => {
2114
+ for (const recipient of wakeRecipients) {
2115
+ void drainSession(recipient).catch((error) => {
2116
+ console.error(`spex: canonical delivery wake failed for ${recipient}: ${error instanceof Error ? error.message : String(error)}`);
2117
+ });
2118
+ }
2119
+ });
2120
+ });
1883
2121
  let supervisingQueue = false;
1884
2122
  export function superviseQueue(intervalMs = 3000) {
1885
2123
  if (supervisingQueue)
@@ -1907,22 +2145,28 @@ export function superviseDelivery(intervalMs = 2000) {
1907
2145
  supervisingDelivery = true;
1908
2146
  const tick = async () => {
1909
2147
  try {
2148
+ const application = configuredSessionApplicationIfCutover();
1910
2149
  for (const id of listSessionIds()) {
1911
- if (!owesDelivery(id))
2150
+ if (!sessionHasPendingDelivery(id, application))
1912
2151
  continue;
1913
2152
  try {
1914
2153
  await drainSession(id);
1915
2154
  }
1916
- catch { /* an adapter that refused stays owed; next tick retries */ }
2155
+ catch (error) {
2156
+ console.error(`spex: delivery retry failed for ${id}: ${error instanceof Error ? error.message : String(error)}`);
2157
+ }
1917
2158
  }
1918
2159
  }
1919
- catch { /* transient store read; next tick retries */ }
2160
+ catch (error) {
2161
+ console.error(`spex: delivery retry sweep failed: ${error instanceof Error ? error.message : String(error)}`);
2162
+ }
1920
2163
  setTimeout(tick, intervalMs).unref();
1921
2164
  };
1922
2165
  void tick();
1923
2166
  }
1924
2167
  const turnFailureObservers = new Map();
1925
2168
  let supervisingTurnFailures = false;
2169
+ let startingTurnFailureObserver = false;
1926
2170
  const TURN_FAILURE_OBSERVER_STABLE_MS = 5000;
1927
2171
  export function turnFailureNote(harness, failure) {
1928
2172
  const message = failure.message.replace(/\s+/g, ' ').trim().slice(0, 500) || 'turn failed';
@@ -1953,7 +2197,10 @@ export function reconcileTurnFailureObservers() {
1953
2197
  catch {
1954
2198
  continue;
1955
2199
  }
1956
- if (!rec?.governed || rec.stopped || rec.archived || !rec.harnessSessionId)
2200
+ // Native turn failure observation is for an executing turn, not a durable roster census. Asking, awaiting,
2201
+ // and parked records have no turn to observe; subscribing them creates one expensive app-server resume per
2202
+ // idle record and lets stale observers accumulate after a backend restart.
2203
+ if (!rec?.governed || rec.stopped || rec.archived || rec.status !== 'active' || !rec.harnessSessionId)
1957
2204
  continue;
1958
2205
  const harness = harnessById(rec.harness || defaultHarness.id);
1959
2206
  if (!harness.observeTurnFailures)
@@ -1977,11 +2224,16 @@ export function reconcileTurnFailureObservers() {
1977
2224
  }
1978
2225
  continue;
1979
2226
  }
2227
+ // Codex thread/resume is an expensive native subscription under load. Admit one observer at a time so a
2228
+ // backend restart cannot fan out N concurrent history reconciliations and exhaust CPU/RSS before any can settle.
2229
+ if (startingTurnFailureObserver)
2230
+ continue;
1980
2231
  if (state && now < state.retryAt)
1981
2232
  continue;
1982
2233
  state ??= { fingerprint: target.fingerprint, subscription: null, startedAt: 0, failures: 0, retryAt: 0, lastReason: null };
1983
2234
  state.startedAt = now;
1984
2235
  turnFailureObservers.set(id, state);
2236
+ startingTurnFailureObserver = true;
1985
2237
  try {
1986
2238
  const subscription = target.harness.observeTurnFailures({
1987
2239
  session: id,
@@ -2000,7 +2252,12 @@ export function reconcileTurnFailureObservers() {
2000
2252
  }
2001
2253
  });
2002
2254
  state.subscription = subscription;
2255
+ if (subscription.ready)
2256
+ void subscription.ready.then(() => { startingTurnFailureObserver = false; }, () => { startingTurnFailureObserver = false; });
2257
+ else
2258
+ startingTurnFailureObserver = false;
2003
2259
  void subscription.closed.then((reason) => {
2260
+ startingTurnFailureObserver = false;
2004
2261
  if (turnFailureObservers.get(id) !== state)
2005
2262
  return;
2006
2263
  if (reason)
@@ -2010,6 +2267,7 @@ export function reconcileTurnFailureObservers() {
2010
2267
  });
2011
2268
  }
2012
2269
  catch (error) {
2270
+ startingTurnFailureObserver = false;
2013
2271
  deferTurnFailureObserver(id, target.harness.id, state, error instanceof Error ? error.message : String(error));
2014
2272
  }
2015
2273
  }
@@ -2150,6 +2408,13 @@ export async function sessionCreateRequest(body, options = {}) {
2150
2408
  if (input.base !== undefined && typeof input.base !== 'string')
2151
2409
  return { status: 400, error: 'session-create base must be a string' };
2152
2410
  const base = typeof input.base === 'string' && input.base.trim() ? input.base.trim() : null;
2411
+ const cutoverState = sessionApplicationCutoverState();
2412
+ if (cutoverState === 'fenced')
2413
+ return { status: 409, error: 'legacy JSON session store is fenced for one-time migration', code: 'session_create_failed', phase: 'request' };
2414
+ if (cutoverState === 'migration-required')
2415
+ return { status: 409, error: 'legacy JSON session store must be migrated before creating sessions', code: 'session_create_failed', phase: 'request' };
2416
+ if (cutoverState === 'ambiguous')
2417
+ return { status: 409, error: 'session database exists without a migration marker', code: 'session_create_failed', phase: 'request' };
2153
2418
  let key;
2154
2419
  try {
2155
2420
  key = normalizeCreateKey(options.requestKey);
@@ -2164,6 +2429,23 @@ export async function sessionCreateRequest(body, options = {}) {
2164
2429
  // immutable creation input because it publishes the record's existing display override. `base` joins them
2165
2430
  // for the same reason and with the same shape: absent, it must not perturb an existing receipt's bytes.
2166
2431
  const payloadHash = digest(JSON.stringify({ prompt, parent, launcher: launcher ?? null, ...(name ? { name } : {}), ...(base ? { base } : {}) }));
2432
+ let freshStoreOwned = false;
2433
+ let freshStoreCommitted = false;
2434
+ try {
2435
+ const acquired = acquireFreshSessionApplicationForCreate();
2436
+ freshStoreOwned = acquired.owned;
2437
+ }
2438
+ catch (error) {
2439
+ return { status: 409, error: error instanceof Error ? error.message : String(error), code: 'session_create_failed', phase: 'request' };
2440
+ }
2441
+ try {
2442
+ assertLegacyJsonWritesAllowed();
2443
+ }
2444
+ catch (error) {
2445
+ releaseFreshSessionApplicationForCreate(freshStoreOwned, false);
2446
+ const message = error instanceof Error ? error.message : String(error);
2447
+ return { status: 409, error: message, code: 'session_create_failed', phase: 'request' };
2448
+ }
2167
2449
  const controller = new AbortController();
2168
2450
  const cancel = () => controller.abort(new SessionCreateError('session_create_cancelled', 'request', 'session creation caller disconnected', 408));
2169
2451
  if (options.signal?.aborted)
@@ -2176,6 +2458,8 @@ export async function sessionCreateRequest(body, options = {}) {
2176
2458
  try {
2177
2459
  try {
2178
2460
  const session = await prepareSession(prompt, parent, launcher, name, { id, requestDigest, payloadHash, base, signal: controller.signal });
2461
+ await options.onPublished?.(session);
2462
+ freshStoreCommitted = true;
2179
2463
  traceSessionCreate(id, requestDigest, 'request', 'finish');
2180
2464
  return { status: 201, session };
2181
2465
  }
@@ -2189,6 +2473,7 @@ export async function sessionCreateRequest(body, options = {}) {
2189
2473
  }
2190
2474
  }
2191
2475
  finally {
2476
+ releaseFreshSessionApplicationForCreate(freshStoreOwned, freshStoreCommitted);
2192
2477
  clearTimeout(timer);
2193
2478
  options.signal?.removeEventListener('abort', cancel);
2194
2479
  }
@@ -2244,7 +2529,7 @@ export async function createSession(prompt, launcher, name, base) {
2244
2529
  const refused = await probeSessionCreateAuthority(target);
2245
2530
  if (refused) {
2246
2531
  console.error('spex: no backend reachable — launching in-process (caller env owns auth, no concurrency cap)');
2247
- const fallback = await sessionCreateRequest(body, { requestKey });
2532
+ const fallback = await sessionCreateRequest(body, { requestKey, onPublished: projectCreatedSession });
2248
2533
  if (fallback.status === 201)
2249
2534
  return fallback.session;
2250
2535
  const error = new Error(`${fallback.code || 'session_create_failed'}: ${fallback.error}`);
@@ -2284,6 +2569,27 @@ export async function createSession(prompt, launcher, name, base) {
2284
2569
  }
2285
2570
  return await res.json();
2286
2571
  }
2572
+ export function projectCreatedSession(session) {
2573
+ const application = initializeFreshSessionApplication();
2574
+ try {
2575
+ application.createSession({
2576
+ sessionId: session.id,
2577
+ status: session.lifecycle,
2578
+ parentSessionId: session.parent,
2579
+ proposal: session.proposal,
2580
+ note: session.note,
2581
+ });
2582
+ if (session.parent)
2583
+ application.attachWatcher(session.parent, session.id, 'watch:parent');
2584
+ }
2585
+ catch (error) {
2586
+ const state = application.readState(session.id);
2587
+ const sameProjection = state?.status === session.lifecycle
2588
+ && state.parentSessionId === (session.parent ?? null);
2589
+ if (!sameProjection)
2590
+ throw error;
2591
+ }
2592
+ }
2287
2593
  export function spawnerClause(p) {
2288
2594
  if (!p?.worktreePath)
2289
2595
  return '';
@@ -2545,7 +2851,7 @@ function existingCreateReceipt(rec) {
2545
2851
  const h = harnessById(rec.harness || defaultHarness.id);
2546
2852
  if (rec.status === 'queued')
2547
2853
  return toSession(rec, 'queued', 'offline');
2548
- const status = rec.status === 'active' ? 'working' : rec.status === 'awaiting' ? PROPOSAL_STATUS[rec.proposal ?? 'nothing'] : rec.status;
2854
+ const status = rec.status === 'active' ? 'working' : rec.status === 'awaiting' ? displayStatusForProposal(rec.proposal) : rec.status;
2549
2855
  return toSession(rec, status, rec.stopped ? 'offline' : h.headless ? 'online' : 'starting');
2550
2856
  }
2551
2857
  async function proveSessionCandidate(path, branch, signal) {
@@ -2695,13 +3001,21 @@ async function prepareSession(prompt, parent, launcher, name, context) {
2695
3001
  traceSessionCreate(id, requestDigest, phase, 'start', 'seed-worktree-host-state');
2696
3002
  seedWorktreeHostState(root, path);
2697
3003
  traceSessionCreate(id, requestDigest, phase, 'finish', 'seed-worktree-host-state');
3004
+ // The branch ref right after `worktree add` IS the fork point. Record it: it is the only thing that
3005
+ // later separates "this branch never authored a commit" from "its commits landed in the base", and
3006
+ // git ancestry alone cannot tell those apart. A read that fails leaves it null — the diff reader
3007
+ // recovers the same commit from the branch's creation reflog entry.
3008
+ const forkResolved = await withGitAbortSignal(signal, () => gitTry(['-C', root, 'rev-parse', '--verify', `refs/heads/${branch}^{commit}`]));
3009
+ const forkCommit = forkResolved.ok && isGitObjectId(root, forkResolved.stdout.trim()) ? forkResolved.stdout.trim() : null;
2698
3010
  let rec = {
2699
3011
  session: id, governed: true, worktreePath: path, branch,
2700
3012
  node: ref || null, title, name, parent: parent && parent !== id ? parent : null,
2701
3013
  status: 'queued', proposal: null, merges: 0, note: null, sortKey: null, createdAt: Date.now(),
2702
- harness: h.id, harnessSessionId: null, stopped: false, archived: false, coldProof: null, adapterRecovery: null, launcher: chosen.name,
3014
+ harness: h.id, harnessSessionId: null, runtimeStartToken: randomUUID(), stopped: false, archived: false, closedAt: null, coldProof: null, adapterRecovery: null, launcher: chosen.name,
2703
3015
  launchCmd: pinned, launchOwner: backendLaunchAuthority(), createRequestId: requestDigest, createPayloadHash: payloadHash,
3016
+ diffComments: [],
2704
3017
  ...(base ? { base } : {}),
3018
+ ...(forkCommit ? { forkCommit } : {}),
2705
3019
  };
2706
3020
  owned.store = true;
2707
3021
  const dir = storeDir(id);
@@ -2720,13 +3034,8 @@ async function prepareSession(prompt, parent, launcher, name, context) {
2720
3034
  if (gitMismatch)
2721
3035
  throw new SessionCreateError('session_create_failed', phase, `refusing session publication: ${gitMismatch}`, 500);
2722
3036
  throwIfCreateAborted(signal, phase);
3037
+ publishCanonicalLifecycle(rec, rec.status, rec.proposal, rec.note);
2723
3038
  writeRecord(rec);
2724
- if (rec.parent && readRecord(rec.parent)?.governed) {
2725
- const watchers = readWatchEntries(id);
2726
- const next = addWatchSource(watchers, rec.parent, 'parent', true);
2727
- if (next.added)
2728
- writeWatchEntries(id, next.entries);
2729
- }
2730
3039
  published = true;
2731
3040
  const receiptFailure = publishedSessionCandidateReceiptRetirementFailure(rec, root);
2732
3041
  if (receiptFailure)
@@ -2819,12 +3128,24 @@ async function waitForReady(id, harness, pending, timeoutMs = SOCKET_READY_TIMEO
2819
3128
  const deadline = Date.now() + timeoutMs;
2820
3129
  if (harness.launchPayloadProof && !current()?.harnessSessionId) {
2821
3130
  for (;;) {
2822
- if (existsSync(sessionArtifactPath(id, 'launch.proof'))) {
2823
- if (recordLockHeld)
2824
- consumeHarnessLaunchProofUnlocked(id);
2825
- else
2826
- await withRecordLock(id, async () => consumeHarnessLaunchProofUnlocked(id));
2827
- break;
3131
+ if (hasReadableLaunchReceipt(id)) {
3132
+ // The probe above ran outside the fence, so another consumer (the drain, a resume recovery) may have
3133
+ // taken the receipt first. Under the fence a receipt that is already gone with the identity bound is
3134
+ // that consumer's success, not a missing receipt; gone and unbound means keep waiting.
3135
+ const consume = () => {
3136
+ readinessWakeSuppressed.add(id);
3137
+ try {
3138
+ if (!hasReadableLaunchReceipt(id))
3139
+ return !!readRecord(id)?.harnessSessionId;
3140
+ consumeHarnessLaunchProofUnlocked(id);
3141
+ return true;
3142
+ }
3143
+ finally {
3144
+ readinessWakeSuppressed.delete(id);
3145
+ }
3146
+ };
3147
+ if (recordLockHeld ? consume() : await withRecordLock(id, async () => consume()))
3148
+ break;
2828
3149
  }
2829
3150
  if (Date.now() >= deadline)
2830
3151
  return null;
@@ -2851,7 +3172,57 @@ async function waitForReady(id, harness, pending, timeoutMs = SOCKET_READY_TIMEO
2851
3172
  await new Promise((r) => setTimeout(r, SOCKET_POLL_MS));
2852
3173
  }
2853
3174
  }
2854
- const restingLifecycle = (status) => status === 'active' || status === 'queued' ? 'idle' : status;
3175
+ // An explicit successful resume is a new runtime attempt. A prior terminal launch/turn error must not
3176
+ // survive that handoff as current lifecycle truth; waiting declarations remain waiting declarations.
3177
+ const restingLifecycle = (status) => status === 'active' || status === 'queued' || status === 'error' ? 'idle' : status;
3178
+ const resumeNote = (status, note) => status === 'error' ? null : note;
3179
+ const archiveRef = (id) => `refs/spex-archive/${id}`;
3180
+ function archiveWorktreeState(id, path) {
3181
+ const root = mainRoot();
3182
+ try {
3183
+ const parent = git(['-C', path, 'rev-parse', 'HEAD']).trim();
3184
+ git(['-C', path, 'add', '-A']);
3185
+ const tree = git(['-C', path, 'write-tree']).trim();
3186
+ const commit = git(['-C', path, '-c', 'user.name=SpexCode', '-c', 'user.email=spexcode@localhost', 'commit-tree', tree, '-p', parent, '-m', `spex close archive ${id}`]).trim();
3187
+ if (!/^[0-9a-f]{40,64}$/.test(commit))
3188
+ throw new Error('archive commit was malformed');
3189
+ git(['-C', root, 'update-ref', archiveRef(id), commit]);
3190
+ const stored = git(['-C', root, 'rev-parse', '--verify', `${archiveRef(id)}^{commit}`]).trim();
3191
+ if (stored !== commit)
3192
+ throw new Error('archive ref publication was not verified');
3193
+ return commit;
3194
+ }
3195
+ catch (error) {
3196
+ const detail = error instanceof Error ? error.message.split('\n')[0] : String(error);
3197
+ throw new ResourceConflict(`refusing to close ${id}: could not publish ${archiveRef(id)}${detail ? ` - ${detail}` : ''}`);
3198
+ }
3199
+ }
3200
+ async function restoreArchivedWorktree(id, rec) {
3201
+ if (existsSync(rec.worktreePath))
3202
+ return;
3203
+ if (!rec.branch)
3204
+ throw new ResourceConflict(`session ${id} has no branch to restore its archived worktree`);
3205
+ const ref = archiveRef(id);
3206
+ const archive = await gitTry(['-C', mainRoot(), 'rev-parse', '--verify', `${ref}^{commit}`]);
3207
+ const start = await gitTry(['-C', mainRoot(), 'rev-parse', '--verify', `refs/heads/${rec.branch}^{commit}`]);
3208
+ if (!start.ok)
3209
+ throw new ResourceConflict(`session ${id} branch ${rec.branch} is missing`);
3210
+ await gitTry(['-C', mainRoot(), 'worktree', 'add', rec.worktreePath, rec.branch]).then((result) => {
3211
+ if (!result.ok)
3212
+ throw new ResourceConflict(`git worktree add failed: ${result.stderr.trim() || result.failure}`);
3213
+ });
3214
+ if (!archive.ok)
3215
+ return;
3216
+ const patch = git(['-C', mainRoot(), 'diff', '--binary', `${rec.branch}..${ref}`]);
3217
+ if (!patch)
3218
+ return;
3219
+ try {
3220
+ execFileSync('git', ['-C', rec.worktreePath, 'apply', '--binary', '-'], { input: patch, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
3221
+ }
3222
+ catch (error) {
3223
+ throw new ResourceConflict(`session ${id} archived changes could not be restored: ${error instanceof Error ? error.message : String(error)}`);
3224
+ }
3225
+ }
2855
3226
  async function resumeSessionUnlocked(id, opts = {}) {
2856
3227
  const { force = false, guard = true } = opts;
2857
3228
  let wt;
@@ -2865,6 +3236,17 @@ async function resumeSessionUnlocked(id, opts = {}) {
2865
3236
  }
2866
3237
  if (!wt)
2867
3238
  return { ok: false, error: `no such session ${id}` };
3239
+ if (wt.rec.archived && retirementReason(wt.rec)) {
3240
+ try {
3241
+ await restoreArchivedWorktree(id, wt.rec);
3242
+ wt = await findWorktree(id);
3243
+ if (!wt)
3244
+ return { ok: false, error: `session ${id} disappeared while restoring its archived worktree` };
3245
+ }
3246
+ catch (error) {
3247
+ return { ok: false, refused: true, error: `session ${id}: archived worktree restore failed: ${error instanceof Error ? error.message : String(error)}` };
3248
+ }
3249
+ }
2868
3250
  // A process that died while validating left an internal candidate behind. This record lock proves no live
2869
3251
  // resume still owns it. Restore the frozen public original before doing any transport work and require an
2870
3252
  // explicit retry; stale runtime evidence is never adopted into a fresh launch attempt.
@@ -2891,19 +3273,19 @@ async function resumeSessionUnlocked(id, opts = {}) {
2891
3273
  // A prior adapter process may have proven identity + first-turn durability just before its session owner
2892
3274
  // died. Consume that receipt before choosing a recovery tail, so retry resumes the proven thread instead of
2893
3275
  // creating another one with the same first prompt.
2894
- if (h.launchPayloadProof && existsSync(sessionArtifactPath(id, 'launch.proof'))) {
3276
+ if (h.launchPayloadProof && hasReadableLaunchReceipt(id)) {
2895
3277
  consumeHarnessLaunchProofUnlocked(id);
2896
3278
  wt = await findWorktree(id);
2897
3279
  if (!wt)
2898
- return { ok: false, error: `session ${id} disappeared while recovering native launch proof` };
3280
+ return { ok: false, error: `session ${id} disappeared while recovering native launch receipt` };
2899
3281
  }
2900
3282
  // An archived record is expected to be stopped, but the guard must still inspect physical liveness in case
2901
3283
  // it is a legacy/invariant-violating row. Ignore filing and stale stop metadata for this one safety probe so
2902
3284
  // resume can never kill a live leaf merely because the record was hidden.
2903
3285
  const probeRec = wt.rec.archived ? { ...wt.rec, archived: false, stopped: false } : wt.rec;
2904
- const resumeSnap = await liveSnapshot();
3286
+ const resumeSnap = h.runtimeOwnership === 'adapter' ? null : await liveSnapshot();
2905
3287
  const lv = h.runtimeOwnership === 'adapter'
2906
- ? (resumeSnap.windows.has(id) ? 'online' : 'offline')
3288
+ ? await adapterRuntimeLiveness(probeRec)
2907
3289
  : liveness(probeRec, resumeSnap); // FRESH, honest liveness (listener-verified)
2908
3290
  if (guard && !force && lv === 'online')
2909
3291
  return { ok: false, refused: true, error: `session ${id} is ALIVE — refusing to relaunch, which would kill a live worker mid-work. To steer it, send it a message; use force only for a genuinely wedged (but alive) process.` };
@@ -2914,7 +3296,7 @@ async function resumeSessionUnlocked(id, opts = {}) {
2914
3296
  const recovery = await h.restoreRuntime?.(wt.rec);
2915
3297
  if (recovery && !recovery.ok)
2916
3298
  return { ok: false, refused: true, error: `session ${id}: recovery required before resume — ${recovery.reason}` };
2917
- writeRecord({ ...(readRecord(id) || wt.rec), adapterRecovery: null, coldProof: null, archived: false, stopped: true });
3299
+ writeRecord({ ...(readRecord(id) || wt.rec), adapterRecovery: null, coldProof: null, archived: false, closedAt: null, stopped: true });
2918
3300
  wt = await findWorktree(id);
2919
3301
  if (!wt)
2920
3302
  return { ok: false, error: `session ${id} disappeared during adapter recovery` };
@@ -2923,8 +3305,8 @@ async function resumeSessionUnlocked(id, opts = {}) {
2923
3305
  // Make the durable row visible/offline before any adapter unarchive or launch RPC. Any later failure leaves
2924
3306
  // a retryable unarchived record rather than archived:true with a newly loaded target thread.
2925
3307
  const pendingRecovery = wt.rec.adapterRecovery || 'restore-runtime-pending';
2926
- writeRecord({ ...wt.rec, archived: false, stopped: true, coldProof: wt.rec.coldProof, adapterRecovery: pendingRecovery });
2927
- const visible = readRecord(id) || { ...wt.rec, archived: false, stopped: true, coldProof: wt.rec.coldProof, adapterRecovery: pendingRecovery };
3308
+ writeRecord({ ...wt.rec, archived: false, closedAt: null, stopped: true, coldProof: wt.rec.coldProof, adapterRecovery: pendingRecovery });
3309
+ const visible = readRecord(id) || { ...wt.rec, archived: false, closedAt: null, stopped: true, coldProof: wt.rec.coldProof, adapterRecovery: pendingRecovery };
2928
3310
  const restored = await h.restoreRuntime?.(visible);
2929
3311
  if (restored && !restored.ok)
2930
3312
  return { ok: false, refused: true, error: `session ${id}: ${restored.reason}` };
@@ -2936,8 +3318,16 @@ async function resumeSessionUnlocked(id, opts = {}) {
2936
3318
  // fall through to a metadata-only no-op.
2937
3319
  // Archived sessions have no runtime by invariant. Resume first leaves cold storage, then the normal
2938
3320
  // starting -> online launch path recreates the same conversation.
2939
- const current = wasArchived ? (readRecord(id) || { ...wt.rec, archived: false, stopped: true, coldProof: null }) : wt.rec;
2940
- const resumed = { ...current, archived: false, coldProof: null, status: restingLifecycle(current.status), stopped: false };
3321
+ const current = wasArchived ? (readRecord(id) || { ...wt.rec, archived: false, closedAt: null, stopped: true, coldProof: null }) : wt.rec;
3322
+ const resumed = {
3323
+ ...current,
3324
+ archived: false,
3325
+ closedAt: null,
3326
+ coldProof: null,
3327
+ status: restingLifecycle(current.status),
3328
+ note: resumeNote(current.status, current.note),
3329
+ stopped: false,
3330
+ };
2941
3331
  if (force || lv === 'offline') {
2942
3332
  let resumeTail;
2943
3333
  try {
@@ -2969,8 +3359,10 @@ async function resumeSessionUnlocked(id, opts = {}) {
2969
3359
  const candidate = {
2970
3360
  ...latest,
2971
3361
  archived: false,
3362
+ closedAt: null,
2972
3363
  coldProof: null,
2973
3364
  status: restingLifecycle(latest.status),
3365
+ note: resumeNote(latest.status, latest.note),
2974
3366
  stopped: false,
2975
3367
  launchReadinessPending: launchReadinessPending(preResume),
2976
3368
  };
@@ -2994,11 +3386,19 @@ async function resumeSessionUnlocked(id, opts = {}) {
2994
3386
  error: `session ${id}: launch readiness changed across the pending publication${readinessError ? ` - ${readinessError}` : ''}; the session remains stopped and can be retried`,
2995
3387
  };
2996
3388
  }
2997
- const published = readRecord(id) || candidate;
3389
+ // `readRecord` projects the still-public pre-resume lifecycle while the candidate fence is pending.
3390
+ // Carrying that stale projection into the final publish used to leave queued/error rows unchanged in
3391
+ // SQLite even though the runtime envelope had crossed readiness. Publish the candidate lifecycle while
3392
+ // retaining the latest non-lifecycle envelope fields.
3393
+ const latestPublished = readRecord(id) || candidate;
3394
+ const published = { ...latestPublished, status: candidate.status, proposal: candidate.proposal, note: candidate.note };
3395
+ publishCanonicalLifecycle(published, candidate.status, candidate.proposal, candidate.note);
2998
3396
  writeRecord({ ...published, launchReadinessPending: null });
2999
3397
  }
3000
- else
3398
+ else {
3399
+ publishCanonicalLifecycle(current, resumed.status, resumed.proposal, resumed.note);
3001
3400
  writeRecord(resumed);
3401
+ }
3002
3402
  return { ok: true };
3003
3403
  }
3004
3404
  export const resumeSession = (id, opts = {}) => withSessionTransition(id, async () => {
@@ -3012,9 +3412,28 @@ export function markState(status, opts = {}) {
3012
3412
  if (!id)
3013
3413
  return false;
3014
3414
  return withRecordLockSync(id, () => {
3415
+ const raw = readRecord(id);
3416
+ if (raw?.archived)
3417
+ throw new ResourceConflict(`refusing lifecycle change for closed session ${id}: it is read-only; resume it before changing state`);
3015
3418
  const rec = readLiveRecord(id);
3016
- if (!rec)
3419
+ if (!rec?.governed)
3017
3420
  return false;
3421
+ const application = configuredSessionApplicationIfCutover();
3422
+ if (application) {
3423
+ const proposal = status === 'awaiting' ? (opts.proposal ?? 'nothing') : null;
3424
+ const note = opts.note ?? null;
3425
+ const current = application.readState(id);
3426
+ if (current && current.status === status && current.proposal === proposal && current.note === note)
3427
+ return true;
3428
+ const recipients = canonicalWatchRecipients(application, id, status);
3429
+ application.transitionSession(id, {
3430
+ status,
3431
+ proposal,
3432
+ note,
3433
+ recipientSessionIds: recipients,
3434
+ });
3435
+ return true;
3436
+ }
3018
3437
  const proposal = status === 'awaiting' ? (opts.proposal ?? 'nothing') : null;
3019
3438
  writeRecord({
3020
3439
  ...rec, status,
@@ -3024,22 +3443,95 @@ export function markState(status, opts = {}) {
3024
3443
  return true;
3025
3444
  });
3026
3445
  }
3446
+ // A human prompt is the explicit re-entry from a waiting turn; runtime liveness is not.
3447
+ export function markHumanPromptActive(sessionId) {
3448
+ try {
3449
+ const rec = readRecord(sessionId);
3450
+ const canonical = sessionHookState(sessionId);
3451
+ // The canonical lifecycle decides whether this record is writable. Any real human re-entry can
3452
+ // resume a waiting declaration, including an `awaiting` close/merge proposal; the old envelope
3453
+ // status is only migration metadata and must never veto the re-entry.
3454
+ if (!rec || !canonical || rec.archived || retirementReason(rec))
3455
+ return false;
3456
+ return markState('active', { sessionId });
3457
+ }
3458
+ catch (error) {
3459
+ // The message/PTY write is already accepted; a raced close or unreadable record must not turn it into a false send failure.
3460
+ console.error(`spex: could not publish human-input activity for ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
3461
+ return false;
3462
+ }
3463
+ }
3027
3464
  export const markDone = (proposal = 'nothing', sessionId, note) => markState('awaiting', { proposal, note, sessionId });
3028
- export const markError = (sessionId) => markState('error', { sessionId });
3029
3465
  export function markTurnFailure(sessionId, note) {
3030
3466
  if (!sessionId)
3031
3467
  return false;
3032
3468
  return withRecordLockSync(sessionId, () => {
3033
3469
  const rec = readLiveRecord(sessionId);
3034
- if (!rec || rec.status !== 'active' || rec.stopped || rec.archived)
3470
+ if (!rec?.governed || rec.status !== 'active' || rec.stopped || rec.archived)
3035
3471
  return false;
3472
+ const application = configuredSessionApplicationIfCutover();
3473
+ if (application) {
3474
+ application.transitionSession(sessionId, {
3475
+ status: 'error', proposal: null, note,
3476
+ recipientSessionIds: canonicalWatchRecipients(application, sessionId, 'error'),
3477
+ });
3478
+ return true;
3479
+ }
3036
3480
  writeRecord({ ...rec, status: 'error', proposal: null, note });
3037
3481
  return true;
3038
3482
  });
3039
3483
  }
3484
+ // @@@ interrupt projection - a CONFIRMED human interrupt ended the turn, and the record says so the way an
3485
+ // undeclared stop does: `asking`, with the reason, because the agent now waits for the human's next message.
3486
+ // Active-only like every other turn-outcome writer: a declaration that landed first (the agent answered
3487
+ // before the abort reached it) stays authoritative. The marker is stamped before the abort is sent so the
3488
+ // adapter's own exit report — a one-turn process leaves with a non-zero code when aborted — reads the same
3489
+ // outcome instead of filing a failed turn; it expires so a genuine failure later is never mistaken for it.
3490
+ export const INTERRUPTED_NOTE = 'interrupted: the human stopped this turn; the next message continues the conversation';
3491
+ const INTERRUPT_MARKER_TTL_MS = 15_000;
3492
+ const interruptMarkerPath = (id) => sessionArtifactPath(id, 'turn.interrupted');
3493
+ export function stampInterrupt(id) {
3494
+ mkdirSync(storeDir(id), { recursive: true });
3495
+ writeFileSync(interruptMarkerPath(id), String(Date.now()));
3496
+ }
3497
+ export function clearInterruptMarker(id) {
3498
+ try {
3499
+ unlinkSync(interruptMarkerPath(id));
3500
+ }
3501
+ catch { /* never stamped, or already consumed */ }
3502
+ }
3503
+ function consumeInterruptMarker(id) {
3504
+ let at = NaN;
3505
+ try {
3506
+ at = Number(readFileSync(interruptMarkerPath(id), 'utf8'));
3507
+ }
3508
+ catch {
3509
+ return false;
3510
+ }
3511
+ clearInterruptMarker(id);
3512
+ return Number.isFinite(at) && Date.now() - at <= INTERRUPT_MARKER_TTL_MS;
3513
+ }
3514
+ function projectInterruptedUnlocked(sessionId) {
3515
+ const rec = readLiveRecord(sessionId);
3516
+ if (!rec?.governed || rec.status !== 'active' || rec.stopped || rec.archived)
3517
+ return false;
3518
+ const application = configuredSessionApplicationIfCutover();
3519
+ if (application) {
3520
+ application.transitionSession(sessionId, {
3521
+ status: 'asking', proposal: null, note: INTERRUPTED_NOTE,
3522
+ recipientSessionIds: canonicalWatchRecipients(application, sessionId, 'asking'),
3523
+ });
3524
+ return true;
3525
+ }
3526
+ writeRecord({ ...rec, status: 'asking', proposal: null, note: INTERRUPTED_NOTE });
3527
+ return true;
3528
+ }
3529
+ export const markInterrupted = (sessionId) => withRecordLockSync(sessionId, () => projectInterruptedUnlocked(sessionId));
3040
3530
  export function markHeadlessTurnFailure(sessionId, harness, exitCode) {
3041
3531
  if (exitCode === '0')
3042
3532
  return false;
3533
+ if (consumeInterruptMarker(sessionId))
3534
+ return markInterrupted(sessionId);
3043
3535
  const outcome = /^\d+$/.test(exitCode) ? `exit code ${exitCode}` : `signal ${exitCode}`;
3044
3536
  return markTurnFailure(sessionId, `${harness} turn exited with ${outcome}`);
3045
3537
  }
@@ -3063,8 +3555,22 @@ function bindHarnessSessionIdUnlocked(rec, harnessSessionId, generationId = proc
3063
3555
  registrationPrepared = true;
3064
3556
  }
3065
3557
  }
3558
+ const application = configuredSessionApplicationIfCutover();
3559
+ const nativeStartToken = rec.runtimeStartToken || process.env.SPEXCODE_NATIVE_START_TOKEN?.trim();
3560
+ if (application && !nativeStartToken)
3561
+ throw new ResourceConflict(`refusing to bind runtime for ${id}: native start token is missing`);
3066
3562
  try {
3067
3563
  writeRecord({ ...rec, harnessSessionId, coldProof: null, adapterRecovery: null });
3564
+ if (application) {
3565
+ if (!nativeStartToken)
3566
+ throw new ResourceConflict(`refusing to bind runtime for ${id}: native start token is missing`);
3567
+ application.bindRuntime(id, {
3568
+ namespace: 'spex-governed',
3569
+ runtimeKind: rec.harness || defaultHarness.id,
3570
+ nativeSessionId: harnessSessionId,
3571
+ nativeStartToken,
3572
+ });
3573
+ }
3068
3574
  }
3069
3575
  catch (error) {
3070
3576
  if (codex && generationId && registrationPrepared) {
@@ -3080,9 +3586,26 @@ function bindHarnessSessionIdUnlocked(rec, harnessSessionId, generationId = proc
3080
3586
  if (codex && generationId)
3081
3587
  commitCodexGenerationRegistration(root, id, harnessSessionId, generationId);
3082
3588
  }
3083
- function readHarnessLaunchProof(id) {
3589
+ const NATIVE_LAUNCH_RECEIPT_FILE = 'launch.receipt';
3590
+ const LEGACY_NATIVE_LAUNCH_RECEIPT_FILE = 'launch.proof'; // dead-words-ok: one-release reader preserves staged receipts created before the protocol rename
3591
+ function launchReceiptPath(id) {
3592
+ return sessionArtifactPath(id, NATIVE_LAUNCH_RECEIPT_FILE);
3593
+ }
3594
+ function readableLaunchReceiptPath(id) {
3595
+ const current = launchReceiptPath(id);
3596
+ if (existsSync(current))
3597
+ return current;
3598
+ const legacy = sessionArtifactPath(id, LEGACY_NATIVE_LAUNCH_RECEIPT_FILE);
3599
+ return existsSync(legacy) ? legacy : null;
3600
+ }
3601
+ function hasReadableLaunchReceipt(id) {
3602
+ return readableLaunchReceiptPath(id) !== null;
3603
+ }
3604
+ function readHarnessLaunchProof(id, path = readableLaunchReceiptPath(id)) {
3605
+ if (!path)
3606
+ throw new ResourceConflict(`native launch receipt for ${id} is missing`);
3084
3607
  try {
3085
- const proof = JSON.parse(readFileSync(sessionArtifactPath(id, 'launch.proof'), 'utf8'));
3608
+ const proof = JSON.parse(readFileSync(path, 'utf8'));
3086
3609
  if (!proof || proof.version !== 1 || typeof proof.sessionId !== 'string'
3087
3610
  || typeof proof.harnessId !== 'string' || typeof proof.harnessSessionId !== 'string' || !proof.harnessSessionId
3088
3611
  || typeof proof.launchPayloadHash !== 'string'
@@ -3091,7 +3614,7 @@ function readHarnessLaunchProof(id) {
3091
3614
  return proof;
3092
3615
  }
3093
3616
  catch (error) {
3094
- throw new ResourceConflict(`native launch proof for ${id} is unreadable: ${error instanceof Error ? error.message : String(error)}`);
3617
+ throw new ResourceConflict(`native launch receipt for ${id} is unreadable: ${error instanceof Error ? error.message : String(error)}`);
3095
3618
  }
3096
3619
  }
3097
3620
  function sameHarnessLaunchProof(left, right) {
@@ -3099,6 +3622,32 @@ function sameHarnessLaunchProof(left, right) {
3099
3622
  && left.harnessSessionId === right.harnessSessionId && left.launchPayloadHash === right.launchPayloadHash
3100
3623
  && left.generationId === right.generationId;
3101
3624
  }
3625
+ // A native launch can be proven just before its visible TUI exits. A shell-level retry must be able to ask
3626
+ // for that exact target without replaying the first prompt. This read-only resolver accepts either side of the
3627
+ // proof-consumption boundary: the durable record after identity binding, or the staged receipt while the record
3628
+ // lock has not consumed it yet. Any mismatch remains a loud resource conflict; returning null means no proof has
3629
+ // been established and a fresh first-turn attempt is still allowed.
3630
+ export function existingHarnessLaunchTarget(id) {
3631
+ const rec = readLiveRecord(id);
3632
+ if (!rec)
3633
+ return null;
3634
+ const harness = harnessById(rec.harness || defaultHarness.id);
3635
+ if (!harness.launchPayloadProof)
3636
+ return null;
3637
+ const receiptPath = readableLaunchReceiptPath(id);
3638
+ if (receiptPath) {
3639
+ const proof = readHarnessLaunchProof(id, receiptPath);
3640
+ if (proof.sessionId !== id || proof.harnessId !== harness.id)
3641
+ throw new ResourceConflict(`native launch receipt for ${id} does not match the governed adapter identity`);
3642
+ const pending = readLaunchFile(id);
3643
+ if (pending != null && proof.launchPayloadHash !== createHash('sha256').update(pending).digest('hex'))
3644
+ throw new ResourceConflict(`native launch receipt for ${id} does not match the authoritative resolved launch payload`);
3645
+ if (rec.harnessSessionId && rec.harnessSessionId !== proof.harnessSessionId)
3646
+ throw new ResourceConflict(`refusing to replace exact harness thread identity for ${id}; staged launch receipt differs from the record`);
3647
+ return proof.harnessSessionId;
3648
+ }
3649
+ return rec.harnessSessionId || null;
3650
+ }
3102
3651
  export function stageHarnessLaunchProof(sessionId, harnessSessionId, launchPayload) {
3103
3652
  const id = sessionId || ownSessionId();
3104
3653
  if (!id || !harnessSessionId)
@@ -3108,17 +3657,17 @@ export function stageHarnessLaunchProof(sessionId, harnessSessionId, launchPaylo
3108
3657
  return false;
3109
3658
  const harness = harnessById(rec.harness || defaultHarness.id);
3110
3659
  if (!harness.launchPayloadProof)
3111
- throw new ResourceConflict(`harness ${harness.id} does not use native launch-payload proof`);
3660
+ throw new ResourceConflict(`harness ${harness.id} does not use native launch-payload receipts`);
3112
3661
  const pending = readLaunchFile(id);
3113
3662
  if (pending == null)
3114
- throw new ResourceConflict(`refusing native launch proof for ${id}: authoritative resolved launch payload is missing`);
3663
+ throw new ResourceConflict(`refusing native launch receipt for ${id}: authoritative resolved launch payload is missing`);
3115
3664
  if (pending !== launchPayload)
3116
- throw new ResourceConflict(`refusing native launch proof for ${id}: first-turn payload differs from the authoritative resolved launch payload`);
3665
+ throw new ResourceConflict(`refusing native launch receipt for ${id}: first-turn payload differs from the authoritative resolved launch payload`);
3117
3666
  const generationId = process.env.SPEXCODE_CODEX_GENERATION?.trim() || null;
3118
3667
  if (rec.harness === 'codex' || rec.harness === 'codex-headless') {
3119
3668
  const ledger = readCodexGenerationLedger(runtimeRoot());
3120
3669
  if (ledger.revision > 0 && !generationId)
3121
- throw new ResourceConflict(`refusing native launch proof for ${id}: launch did not provide an exact Codex generation id`);
3670
+ throw new ResourceConflict(`refusing native launch receipt for ${id}: launch did not provide an exact Codex generation id`);
3122
3671
  if (generationId && (!ledger.generations[generationId] || ledger.generations[generationId].state === 'reclaimed'))
3123
3672
  throw new ResourceConflict(`refusing to bind Codex thread ${harnessSessionId}: generation ${generationId} is absent or reclaimed`);
3124
3673
  }
@@ -3130,7 +3679,14 @@ export function stageHarnessLaunchProof(sessionId, harnessSessionId, launchPaylo
3130
3679
  launchPayloadHash: createHash('sha256').update(launchPayload).digest('hex'),
3131
3680
  generationId,
3132
3681
  };
3133
- const path = sessionArtifactPath(id, 'launch.proof');
3682
+ const existingPath = readableLaunchReceiptPath(id);
3683
+ if (existingPath) {
3684
+ const staged = readHarnessLaunchProof(id, existingPath);
3685
+ if (sameHarnessLaunchProof(staged, proof))
3686
+ return true;
3687
+ throw new ResourceConflict(`refusing to replace native launch receipt for ${id}: the staged session, thread, payload, or generation differs`);
3688
+ }
3689
+ const path = launchReceiptPath(id);
3134
3690
  const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
3135
3691
  writeFileSync(temp, `${JSON.stringify(proof, null, 2)}\n`, { mode: 0o600 });
3136
3692
  try {
@@ -3140,10 +3696,10 @@ export function stageHarnessLaunchProof(sessionId, harnessSessionId, launchPaylo
3140
3696
  catch (error) {
3141
3697
  if (error.code !== 'EEXIST')
3142
3698
  throw error;
3143
- const staged = readHarnessLaunchProof(id);
3699
+ const staged = readHarnessLaunchProof(id, path);
3144
3700
  if (sameHarnessLaunchProof(staged, proof))
3145
3701
  return true;
3146
- throw new ResourceConflict(`refusing to replace native launch proof for ${id}: the staged session, thread, payload, or generation differs`);
3702
+ throw new ResourceConflict(`refusing to replace native launch receipt for ${id}: the staged session, thread, payload, or generation differs`);
3147
3703
  }
3148
3704
  finally {
3149
3705
  rmSync(temp, { force: true });
@@ -3154,14 +3710,15 @@ function consumeHarnessLaunchProofUnlocked(id) {
3154
3710
  if (!rec)
3155
3711
  return false;
3156
3712
  const harness = harnessById(rec.harness || defaultHarness.id);
3157
- const proof = readHarnessLaunchProof(id);
3713
+ const receiptPath = readableLaunchReceiptPath(id);
3714
+ const proof = readHarnessLaunchProof(id, receiptPath);
3158
3715
  if (proof.sessionId !== id || proof.harnessId !== harness.id)
3159
- throw new ResourceConflict(`native launch proof for ${id} does not match the governed adapter identity`);
3716
+ throw new ResourceConflict(`native launch receipt for ${id} does not match the governed adapter identity`);
3160
3717
  const pending = readLaunchFile(id);
3161
3718
  if (pending == null && rec.harnessSessionId !== proof.harnessSessionId)
3162
- throw new ResourceConflict(`refusing native launch proof for ${id}: authoritative resolved launch payload is missing`);
3719
+ throw new ResourceConflict(`refusing native launch receipt for ${id}: authoritative resolved launch payload is missing`);
3163
3720
  if (pending != null && proof.launchPayloadHash !== createHash('sha256').update(pending).digest('hex'))
3164
- throw new ResourceConflict(`native launch proof for ${id} does not match the authoritative resolved launch payload`);
3721
+ throw new ResourceConflict(`native launch receipt for ${id} does not match the authoritative resolved launch payload`);
3165
3722
  bindHarnessSessionIdUnlocked(rec, proof.harnessSessionId, proof.generationId || undefined);
3166
3723
  if (pending != null) {
3167
3724
  try {
@@ -3169,17 +3726,18 @@ function consumeHarnessLaunchProofUnlocked(id) {
3169
3726
  }
3170
3727
  catch (error) {
3171
3728
  if (error.code !== 'ENOENT') {
3172
- console.error(`spex: native launch proof committed for ${id}, but launch could not be consumed: ${error instanceof Error ? error.message : String(error)}`);
3729
+ console.error(`spex: native launch receipt committed for ${id}, but launch could not be consumed: ${error instanceof Error ? error.message : String(error)}`);
3173
3730
  return true;
3174
3731
  }
3175
3732
  }
3176
3733
  }
3177
3734
  try {
3178
- rmSync(sessionArtifactPath(id, 'launch.proof'));
3735
+ if (receiptPath)
3736
+ rmSync(receiptPath);
3179
3737
  }
3180
3738
  catch (error) {
3181
3739
  if (error.code !== 'ENOENT')
3182
- console.error(`spex: native launch proof committed for ${id}, but launch.proof could not be consumed: ${error instanceof Error ? error.message : String(error)}`);
3740
+ console.error(`spex: native launch receipt committed for ${id}, but the receipt could not be consumed: ${error instanceof Error ? error.message : String(error)}`);
3183
3741
  }
3184
3742
  return true;
3185
3743
  }
@@ -3193,7 +3751,7 @@ export function markHarnessSessionId(sessionId, harnessSessionId) {
3193
3751
  return false;
3194
3752
  const harness = harnessById(rec.harness || defaultHarness.id);
3195
3753
  if (harness.launchPayloadProof)
3196
- throw new ResourceConflict(`harness ${harness.id} must stage native identity together with authoritative first-turn payload proof`);
3754
+ throw new ResourceConflict(`harness ${harness.id} must stage native identity together with an authoritative first-turn payload receipt`);
3197
3755
  bindHarnessSessionIdUnlocked(rec, harnessSessionId);
3198
3756
  return true;
3199
3757
  });
@@ -3204,9 +3762,13 @@ export function markIdle(sessionId) {
3204
3762
  return false;
3205
3763
  return withRecordLockSync(id, () => {
3206
3764
  const rec = readLiveRecord(id);
3207
- if (!rec || rec.status !== 'active')
3208
- return false; // active-only: never clobber a declaration
3209
- writeRecord({ ...rec, status: 'idle' });
3765
+ if (!rec?.governed || rec.status !== 'active')
3766
+ return false; // managed active-only: never clobber a declaration
3767
+ publishCanonicalLifecycle(rec, 'idle', null, null);
3768
+ // After cutover the JSON file is only the runtime/worktree envelope. Do not mirror this inferred
3769
+ // lifecycle transition into it: doing so creates a second, stale-looking status surface for hooks.
3770
+ if (!configuredSessionApplicationIfCutover())
3771
+ writeRecord({ ...rec, status: 'idle' });
3210
3772
  return true;
3211
3773
  });
3212
3774
  }
@@ -3244,6 +3806,265 @@ function porcelainPath(line) {
3244
3806
  p = p.slice(arrow + 4);
3245
3807
  return p;
3246
3808
  }
3809
+ export function commitUrlForRemote(remote, commit) {
3810
+ const raw = remote.trim();
3811
+ let host = '', path = '';
3812
+ try {
3813
+ const url = new URL(raw);
3814
+ if (url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'ssh:') {
3815
+ host = url.host;
3816
+ path = url.pathname;
3817
+ }
3818
+ }
3819
+ catch {
3820
+ const scp = /^(?:[^@/]+@)?([^:/]+):(.+)$/.exec(raw);
3821
+ if (scp)
3822
+ [, host, path] = scp;
3823
+ }
3824
+ path = path.replace(/^\/+|\/+$/g, '').replace(/\.git$/, '');
3825
+ if (!host || !path)
3826
+ return null;
3827
+ const commitPath = host.toLowerCase().includes('gitlab') ? '-/commit' : 'commit';
3828
+ return `https://${host}/${path}/${commitPath}/${commit}`;
3829
+ }
3830
+ // The branch diff is a proof over commits, not over a working directory: refs and objects are shared with
3831
+ // the main checkout, so a session whose worktree directory is gone (landed and cleaned, or reaped) keeps a
3832
+ // provable diff for as long as its branch ref survives. Anchor git at the live worktree when it exists and
3833
+ // at the main checkout otherwise; only a branch whose ref is gone everywhere is honestly unavailable, and
3834
+ // that refusal is a structured conflict (409 {error, code}) — never a raw git ENOENT turned into a 500.
3835
+ async function diffAnchorRoot(wt) {
3836
+ if (!wt.branch)
3837
+ throw new ResourceConflict(`session ${wt.rec.session} has no branch to diff`, 'diff-unavailable');
3838
+ if (wt.path && existsSync(wt.path))
3839
+ return wt.path;
3840
+ const main = mainRoot();
3841
+ const proven = await gitTry(['-C', main, 'rev-parse', '--verify', `refs/heads/${wt.branch}^{commit}`]);
3842
+ if (proven.ok)
3843
+ return main;
3844
+ throw new ResourceConflict(`session ${wt.rec.session} has no worktree on disk and its branch ${wt.branch} no longer exists`, 'diff-unavailable');
3845
+ }
3846
+ // @@@ forkCommitOf - the commit the branch was created at, from the most authoritative source that has it.
3847
+ // The record carries it for every session created since it was introduced. Older records recover the same
3848
+ // commit from the branch ref's OLDEST reflog entry, which is where git itself wrote the `worktree add` start
3849
+ // point. Neither available (reflog pruned, or a branch adopted from outside this flow) → null, and the caller
3850
+ // falls back to what ancestry alone can prove.
3851
+ async function forkCommitOf(root, wt) {
3852
+ if (wt.rec.forkCommit && isGitObjectId(root, wt.rec.forkCommit))
3853
+ return wt.rec.forkCommit;
3854
+ if (!wt.branch)
3855
+ return null;
3856
+ const log = await gitTry(['-C', root, 'reflog', 'show', '--no-abbrev', '--format=%H', `refs/heads/${wt.branch}`]);
3857
+ if (!log.ok)
3858
+ return null;
3859
+ const entries = log.stdout.split('\n').map((line) => line.trim()).filter(Boolean);
3860
+ const created = entries[entries.length - 1];
3861
+ return created && isGitObjectId(root, created) ? created : null;
3862
+ }
3863
+ async function diffHeadPair(root, wt) {
3864
+ if (!wt.branch)
3865
+ throw new ResourceConflict(`session ${wt.rec.session} has no branch to diff`, 'diff-unavailable');
3866
+ const baseRef = wt.rec.base || mainBranch();
3867
+ const [headOut, baseOut] = await Promise.all([
3868
+ gitTry(['-C', root, 'rev-parse', '--verify', `refs/heads/${wt.branch}^{commit}`]),
3869
+ gitTry(['-C', root, 'rev-parse', '--verify', `${baseRef}^{commit}`]),
3870
+ ]);
3871
+ const head = headOut.ok ? headOut.stdout.trim() : '', resolvedBase = baseOut.ok ? baseOut.stdout.trim() : '';
3872
+ if (!head || !resolvedBase || !isGitObjectId(root, head) || !isGitObjectId(root, resolvedBase))
3873
+ throw new ResourceConflict(`session ${wt.rec.session} diff heads are unproven`, 'diff-unavailable');
3874
+ const mergeBaseOut = await gitTry(['-C', root, 'merge-base', resolvedBase, head]);
3875
+ const mergeBase = mergeBaseOut.ok ? mergeBaseOut.stdout.trim() : '';
3876
+ if (!mergeBase || !isGitObjectId(root, mergeBase))
3877
+ throw new ResourceConflict(`session ${wt.rec.session} diff merge-base is unproven`, 'diff-unavailable');
3878
+ const [ancestor, remote, forkCommit] = await Promise.all([
3879
+ gitTry(['-C', root, 'merge-base', '--is-ancestor', head, resolvedBase]),
3880
+ gitTry(['-C', root, 'remote', 'get-url', 'origin']),
3881
+ forkCommitOf(root, wt),
3882
+ ]);
3883
+ // A branch that never authored a commit is ALSO an ancestor of its base, so ancestry must be asked second.
3884
+ // Without a fork commit the only provable form of "authored nothing" is a head that is still the base head.
3885
+ const authoredNothing = forkCommit ? head === forkCommit : head === resolvedBase;
3886
+ return {
3887
+ branch: wt.branch, baseRef, base: resolvedBase, head, mergeBase,
3888
+ branchState: authoredNothing ? 'no-commits' : ancestor.ok ? 'merged' : 'open',
3889
+ commitUrl: remote.ok ? commitUrlForRemote(remote.stdout, head) : null,
3890
+ };
3891
+ }
3892
+ // @@@ workingFiles - the session's uncommitted changes, enumerated from ONE porcelain status plus ONE numstat.
3893
+ // Untracked files count their own lines rather than spawning a git child each: the metadata call stays two
3894
+ // processes however dirty the tree is, and nothing here touches the index — an `--intent-to-add` would mutate
3895
+ // the worktree a live agent is working in.
3896
+ const WORKING_STATUS = { '??': 'untracked', A: 'added', D: 'deleted', R: 'renamed', C: 'copied', T: 'type-changed' };
3897
+ async function workingFiles(root) {
3898
+ const [statusOut, numstatOut] = await Promise.all([
3899
+ gitA(['-C', root, '-c', 'core.quotePath=false', 'status', '--porcelain', '--untracked-files=all']),
3900
+ gitA(['-C', root, '-c', 'core.quotePath=false', 'diff', '--numstat', '-M', 'HEAD']),
3901
+ ]);
3902
+ const counts = new Map();
3903
+ for (const line of numstatOut.split('\n')) {
3904
+ const m = line.match(/^(-|\d+)\t(-|\d+)\t(.+)$/);
3905
+ if (!m)
3906
+ continue;
3907
+ const { to } = parseStatPath(m[3]);
3908
+ counts.set(to, { additions: m[1] === '-' ? 0 : +m[1], deletions: m[2] === '-' ? 0 : +m[2] });
3909
+ }
3910
+ const files = [];
3911
+ for (const line of statusOut.split('\n')) {
3912
+ if (!line.trim())
3913
+ continue;
3914
+ const code = line.slice(0, 2);
3915
+ const path = porcelainPath(line);
3916
+ const arrow = line.indexOf(' -> ');
3917
+ const oldPath = arrow >= 0 ? line.slice(3, arrow) : '';
3918
+ const letter = code.trim().replace(/[^A-Z?]/g, '').slice(0, 1) || 'M';
3919
+ const status = WORKING_STATUS[code === '??' ? '??' : letter] ?? 'modified';
3920
+ files.push({
3921
+ path,
3922
+ ...(oldPath && oldPath !== path ? { oldPath } : {}),
3923
+ status,
3924
+ ...(counts.get(path) ?? (code === '??' ? untrackedCounts(join(root, path)) : { additions: 0, deletions: 0 })),
3925
+ });
3926
+ }
3927
+ return files.sort((a, b) => a.path.localeCompare(b.path));
3928
+ }
3929
+ // An untracked file is entirely new, so its addition count is its line count. A NUL byte means git would
3930
+ // print `-`/`-` for a binary blob; report the same nothing rather than a line count of bytes.
3931
+ function untrackedCounts(absolute) {
3932
+ try {
3933
+ const body = readFileSync(absolute);
3934
+ if (body.includes(0))
3935
+ return { additions: 0, deletions: 0 };
3936
+ const text = body.toString('utf8');
3937
+ return { additions: text.length ? text.replace(/\n$/, '').split('\n').length : 0, deletions: 0 };
3938
+ }
3939
+ catch {
3940
+ return { additions: 0, deletions: 0 };
3941
+ }
3942
+ }
3943
+ async function workingPatch(root, file, untracked) {
3944
+ if (untracked) {
3945
+ // --no-index against /dev/null renders a whole new file as one addition hunk. It exits 1 when the two
3946
+ // sides differ, which is the normal case here, so the patch is read off stdout rather than off `ok`.
3947
+ const out = await gitTry(['-C', root, '--no-pager', 'diff', '--no-ext-diff', '--unified=40', '--no-index', '--', '/dev/null', file.path]);
3948
+ return out.stdout;
3949
+ }
3950
+ return gitA(['-C', root, '--no-pager', 'diff', '--no-ext-diff', '--unified=40', 'HEAD', '--', ...(file.oldPath ? [file.oldPath, file.path] : [file.path])]);
3951
+ }
3952
+ // A working file's identity must move when its CONTENT moves, or a stale editor and a stale comment anchor
3953
+ // would survive an edit. Size and mtime are what change on every write, and they cost one stat.
3954
+ function workingIdentity(root, file) {
3955
+ let stamp = 'gone';
3956
+ try {
3957
+ const s = statSync(join(root, file.path));
3958
+ stamp = `${s.size}:${s.mtimeMs}`;
3959
+ }
3960
+ catch { /* deleted in the worktree */ }
3961
+ return createHash('sha256').update(`working\0${file.path}\0${file.oldPath || ''}\0${stamp}`).digest('hex');
3962
+ }
3963
+ export async function sessionDiff(id, filePath, offset = 0, limit = 120_000, scope = 'branch') {
3964
+ const wt = await findWorktree(id);
3965
+ if (!wt)
3966
+ return null;
3967
+ const root = await diffAnchorRoot(wt);
3968
+ const pair = await diffHeadPair(root, wt);
3969
+ // The working tree is the session's OWN directory or it is not knowable. `root` falls back to the main
3970
+ // checkout once the worktree is gone ([[diff-document]]), and that checkout's dirty files belong to whoever
3971
+ // is working there — never to this session.
3972
+ const liveRoot = wt.path && existsSync(wt.path) ? wt.path : null;
3973
+ const window = (patch) => patch.slice(offset, offset + limit);
3974
+ // A per-file fetch names its scope, so only that scope is enumerated: opening one file in a worktree with a
3975
+ // hundred dirty paths must not re-walk the other scope's git reads.
3976
+ const branch = scope === 'branch' || !filePath ? await mergeBaseDiff(root, pair.base, pair.head) : [];
3977
+ const branchSelected = scope === 'branch' && filePath ? branch.filter((file) => file.path === filePath || file.oldPath === filePath) : (filePath ? [] : branch);
3978
+ const files = await Promise.all(branchSelected.map(async (file) => {
3979
+ const identity = createHash('sha256').update(`${pair.mergeBase}\0${pair.head}\0${file.path}\0${file.oldPath || ''}`).digest('hex');
3980
+ if (!filePath)
3981
+ return { ...file, patch: '', diffIdentity: identity };
3982
+ const patch = await gitA(['-C', root, '--no-pager', 'diff', '--no-ext-diff', '--unified=40', pair.mergeBase, pair.head, '--', ...(file.oldPath ? [file.oldPath, file.path] : [file.path])]);
3983
+ return { ...file, patch: window(patch), diffIdentity: identity };
3984
+ }));
3985
+ const dirty = liveRoot && (scope === 'working' || !filePath) ? await workingFiles(liveRoot) : [];
3986
+ const workingSelected = scope === 'working' && filePath ? dirty.filter((file) => file.path === filePath || file.oldPath === filePath) : (filePath ? [] : dirty);
3987
+ const working = await Promise.all(workingSelected.map(async (file) => {
3988
+ const identity = workingIdentity(liveRoot, file);
3989
+ if (!filePath)
3990
+ return { ...file, patch: '', diffIdentity: identity };
3991
+ const patch = await workingPatch(liveRoot, file, file.status === 'untracked');
3992
+ return { ...file, patch: window(patch), diffIdentity: identity };
3993
+ }));
3994
+ return {
3995
+ id, scope: 'branch', ...pair, files,
3996
+ working: { readable: !!liveRoot, files: working },
3997
+ comments: wt.rec.diffComments ?? [],
3998
+ };
3999
+ }
4000
+ export async function saveDiffComment(id, input) {
4001
+ const body = input.body.trim();
4002
+ if (!input.filePath || !body || !Number.isInteger(input.lineStart) || input.lineStart < 1 || !Number.isInteger(input.lineEnd) || input.lineEnd < input.lineStart || !input.diffIdentity)
4003
+ throw new ResourceConflict('diff comment needs a file, line range, body, and diff identity');
4004
+ return withRecordLock(id, async () => {
4005
+ const rec = readLiveRecord(id);
4006
+ if (!rec)
4007
+ return null;
4008
+ const comment = { id: input.id || randomUUID(), filePath: input.filePath, lineStart: input.lineStart, lineEnd: input.lineEnd, body, diffIdentity: input.diffIdentity, sentAt: null };
4009
+ const comments = (rec.diffComments ?? []).filter((candidate) => candidate.id !== comment.id);
4010
+ writeRecord({ ...rec, diffComments: [...comments, comment] });
4011
+ return comment;
4012
+ });
4013
+ }
4014
+ // A review conversation you can only append to is not a conversation. Saving, editing and sending all
4015
+ // existed; nothing could take a row back, so a comment filed on the wrong line — or a probe left by a
4016
+ // measurement — stayed on the record forever. Retract is the same shape as the other two `retract` verbs
4017
+ // this product already has ([[session-files]], eval): it removes the row under the record lock and says
4018
+ // which one it removed. Already-DELIVERED text is not recalled — the agent read it — so this retracts the
4019
+ // record's row, never the message that was sent.
4020
+ export async function retractDiffComment(id, commentId) {
4021
+ if (!commentId)
4022
+ throw new ResourceConflict('retracting a diff comment needs its id');
4023
+ return withRecordLock(id, async () => {
4024
+ const rec = readLiveRecord(id);
4025
+ if (!rec)
4026
+ return null;
4027
+ const comments = rec.diffComments ?? [];
4028
+ const removed = comments.find((comment) => comment.id === commentId);
4029
+ if (!removed)
4030
+ return null;
4031
+ writeRecord({ ...rec, diffComments: comments.filter((comment) => comment.id !== commentId) });
4032
+ return removed;
4033
+ });
4034
+ }
4035
+ export async function sendDiffComments(id, ids) {
4036
+ const selected = await withRecordLock(id, async () => {
4037
+ const rec = readLiveRecord(id);
4038
+ if (!rec)
4039
+ return null;
4040
+ const wanted = ids?.length ? new Set(ids) : null;
4041
+ return (rec.diffComments ?? []).filter((comment) => !comment.sentAt && (!wanted || wanted.has(comment.id)));
4042
+ });
4043
+ if (!selected)
4044
+ return { ok: false, error: `no such session ${id}` };
4045
+ if (!selected.length)
4046
+ return { ok: false, error: 'no unsent diff comments' };
4047
+ const text = ['Review comments on the branch diff:', ...selected.map((comment) => {
4048
+ const lines = comment.lineStart === comment.lineEnd ? `L${comment.lineStart}` : `L${comment.lineStart}-L${comment.lineEnd}`;
4049
+ return `- ${comment.filePath}:${lines}\n ${comment.body.replace(/\n/g, '\n ')}`;
4050
+ })].join('\n');
4051
+ const sent = await sendText(id, text);
4052
+ if (!sent.ok)
4053
+ return { ok: false, error: sent.error || 'could not send diff comments' };
4054
+ const sentAt = new Date().toISOString();
4055
+ await withRecordLock(id, async () => {
4056
+ const rec = readLiveRecord(id);
4057
+ if (!rec)
4058
+ return;
4059
+ const selectedById = new Map(selected.map((comment) => [comment.id, comment]));
4060
+ writeRecord({ ...rec, diffComments: (rec.diffComments ?? []).map((comment) => {
4061
+ const before = selectedById.get(comment.id);
4062
+ const unchanged = before && !comment.sentAt && comment.body === before.body && comment.diffIdentity === before.diffIdentity;
4063
+ return unchanged ? { ...comment, sentAt } : comment;
4064
+ }) });
4065
+ });
4066
+ return { ok: true, sentAt, count: selected.length };
4067
+ }
3247
4068
  async function reviewHeadPair(root, branch, base) {
3248
4069
  const branchRef = `refs/heads/${branch}`, baseRef = `refs/heads/${base}`;
3249
4070
  const output = await gitA(['-C', root, 'for-each-ref', '--sort=refname', '--format=%(refname)%00%(objectname)', branchRef, baseRef]);
@@ -3350,16 +4171,7 @@ export async function mergeSession(id) {
3350
4171
  return { dispatched: false, reason: 'no such mergeable session' };
3351
4172
  const r = await sendText(id, MERGE_PROMPT, undefined, {
3352
4173
  deferDrain: true,
3353
- acceptGuard: async (rec) => {
3354
- if (!rec.governed || rec.status !== 'awaiting' || rec.proposal !== 'merge') {
3355
- const error = new ResourceConflict(`session ${id} is not a governed awaiting merge proposal`);
3356
- Object.assign(error, { code: 'session_merge_not_proposed' });
3357
- throw error;
3358
- }
3359
- },
3360
4174
  });
3361
- if (r.code === 'session_merge_not_proposed')
3362
- return { dispatched: false, reason: r.error || 'merge dispatch refused', code: r.code, status: 409 };
3363
4175
  if (!r.ok)
3364
4176
  return { dispatched: false, reason: r.error || 'could not dispatch merge prompt' };
3365
4177
  await resumeSession(id, { guard: false });
@@ -3627,7 +4439,7 @@ async function inspectSessionLeafIdentity(id, rec) {
3627
4439
  const startAfter = sessionLeafStartToken(pid);
3628
4440
  const candidate = sessionLeafReceiptCandidate(id, pid, panePid, procs, startBefore, startAfter);
3629
4441
  if (!candidate.ok || !candidate.receipt)
3630
- return { state: 'unknown', pid, reason: candidate.reason || 'leaf birth receipt proof failed' };
4442
+ return { state: 'unknown', pid, reason: candidate.reason || 'leaf birth receipt validation failed' };
3631
4443
  if (readAgentPid(path) !== pid || sessionLeafStartToken(pid) !== candidate.receipt.startToken)
3632
4444
  return { state: 'unknown', pid, reason: `leaf PID ${pid} identity changed before receipt commit` };
3633
4445
  writeSessionLeafReceipt(id, candidate.receipt);
@@ -3681,7 +4493,7 @@ async function stopAgentProcess(id, rec, requireCold = false, coldReceipt) {
3681
4493
  if (requireCold) {
3682
4494
  const cold = await harness.coldRuntime?.(rec, coldReceipt);
3683
4495
  if (cold && !cold.ok)
3684
- throw new ResourceConflict(`refusing to archive ${id}: ${cold.reason}`);
4496
+ throw new ResourceConflict(`refusing to close ${id}: ${cold.reason}`);
3685
4497
  }
3686
4498
  }
3687
4499
  async function stopSessionUnlocked(id) {
@@ -3706,25 +4518,23 @@ async function stopSessionUnlocked(id) {
3706
4518
  return !!wt;
3707
4519
  }
3708
4520
  export const stopSession = (id) => withSessionTransition(id, () => withRecordLock(id, () => stopSessionUnlocked(id)));
3709
- async function archiveSessionUnlocked(id, on = true) {
4521
+ async function coldStopSessionUnlocked(id) {
3710
4522
  let wt;
3711
4523
  try {
3712
4524
  wt = await findWorktree(id);
3713
4525
  }
3714
4526
  catch (e) {
3715
4527
  if (e instanceof SessionRecordUnusable)
3716
- throw new ResourceConflict(`refusing to archive ${id}: ${e.message}`);
4528
+ throw new ResourceConflict(`refusing to close ${id}: ${e.message}`);
3717
4529
  throw e;
3718
4530
  }
3719
4531
  if (!wt)
3720
4532
  return false;
3721
- if (!on)
3722
- throw new ResourceConflict('unarchive is not a record-only transition; use resume to restore the runtime');
3723
4533
  if (wt.rec.status === 'queued')
3724
- throw new ResourceConflict(`refusing to archive ${id}: queued sessions have only a prepared launch prompt; resume/startQueued is their lifecycle`);
4534
+ throw new ResourceConflict(`refusing to close ${id}: queued sessions have only a prepared launch prompt; close handles their prepared tree directly`);
3725
4535
  const retired = retirementReason(wt.rec);
3726
4536
  if (retired)
3727
- throw new ResourceConflict(`refusing to archive ${id}: ${retired}`);
4537
+ throw new ResourceConflict(`refusing to close ${id}: ${retired}`);
3728
4538
  archiving.add(id);
3729
4539
  try {
3730
4540
  const h = harnessById(wt.rec.harness || defaultHarness.id);
@@ -3738,12 +4548,12 @@ async function archiveSessionUnlocked(id, on = true) {
3738
4548
  if (wt.rec.archived && hasValidColdProof(wt.rec)) {
3739
4549
  const proofSnap = await liveSnapshot(id);
3740
4550
  if (proofSnap.probeFailed)
3741
- throw new ResourceConflict(`refusing to re-archive ${id}: liveness probe failed; the exact leaf may have respawned`);
4551
+ throw new ResourceConflict(`refusing to close ${id}: liveness probe failed; the exact leaf may have respawned`);
3742
4552
  const proofLv = h.runtimeOwnership === 'adapter'
3743
4553
  ? (proofSnap.windows.has(id) ? 'online' : 'offline')
3744
4554
  : liveness({ ...wt.rec, archived: false, stopped: false }, proofSnap);
3745
4555
  if (proofLv === 'unknown' || proofLv === 'starting')
3746
- throw new ResourceConflict(`refusing to re-archive ${id}: session liveness is ${proofLv}; exact cold state is unproven`);
4556
+ throw new ResourceConflict(`refusing to close ${id}: session liveness is ${proofLv}; exact cold state is unproven`);
3747
4557
  if (proofLv === 'offline') {
3748
4558
  // A deliberately stopped shared control plane is a valid empty resident census. A durable proof plus
3749
4559
  // an adapter-owned root-absent fact is the only idempotent short-circuit; a live root still has to prove
@@ -3768,28 +4578,25 @@ async function archiveSessionUnlocked(id, on = true) {
3768
4578
  }
3769
4579
  }
3770
4580
  }
3771
- // Legacy/respawned archives are made visible before repair. Any refusal below therefore leaves an unarchived
3772
- // row instead of relying on a hidden bit while a runtime proof is missing.
4581
+ // Legacy/respawned closed rows are probed as ordinary runtime records, but the durable archived bit is kept
4582
+ // untouched until the new close publication succeeds.
3773
4583
  if (wt.rec.archived) {
3774
- writeRecord({ ...wt.rec, archived: false, coldProof: null });
3775
- wt = await findWorktree(id);
3776
- if (!wt)
3777
- return false;
4584
+ wt = { ...wt, rec: { ...wt.rec, archived: false, coldProof: null } };
3778
4585
  }
3779
4586
  const snap = await liveSnapshot(id);
3780
4587
  if (snap.probeFailed)
3781
- throw new ResourceConflict(`refusing to archive ${id}: liveness probe failed; the leaf may still be live`);
4588
+ throw new ResourceConflict(`refusing to close ${id}: liveness probe failed; the leaf may still be live`);
3782
4589
  const lv = h.runtimeOwnership === 'adapter'
3783
4590
  ? 'offline'
3784
4591
  : liveness({ ...wt.rec, archived: false, stopped: false }, snap);
3785
4592
  if (lv === 'unknown' || lv === 'starting')
3786
- throw new ResourceConflict(`refusing to archive ${id}: session liveness is ${lv}; exact leaf ownership is unproven`);
4593
+ throw new ResourceConflict(`refusing to close ${id}: session liveness is ${lv}; exact leaf ownership is unproven`);
3787
4594
  // The adapter guard runs BEFORE any tmux/process signal. Active/unknown native turns and ambiguous descendant
3788
4595
  // ownership refuse here; a verified adapter receipt carries an exact subtree through to coldRuntime's commit.
3789
4596
  assertSessionOwnerSafe(id, h.id);
3790
4597
  const preflight = await h.coldPreflight?.({ ...wt.rec, archived: false, stopped: lv === 'offline' });
3791
4598
  if (preflight && !preflight.ok)
3792
- throw new ResourceConflict(`refusing to archive ${id}: ${preflight.reason}`);
4599
+ throw new ResourceConflict(`refusing to close ${id}: ${preflight.reason}`);
3793
4600
  // Even a proven-offline leaf can leave a stale rendezvous/socket or adapter artifact. Reuse the same exact
3794
4601
  // teardown seam with the explicit stopped marker so cleanupRuntime gets its ownership check and no second
3795
4602
  // cleanup primitive is invented.
@@ -3801,12 +4608,12 @@ async function archiveSessionUnlocked(id, on = true) {
3801
4608
  coldCommitted = true;
3802
4609
  const latest = readRecord(id);
3803
4610
  if (!latest)
3804
- throw new ResourceConflict(`refusing to archive ${id}: session record disappeared before filing`);
4611
+ throw new ResourceConflict(`refusing to close ${id}: session record disappeared before archive-ref publication`);
3805
4612
  // The leaf identity kill and adapter cold proof established process/transport absence. Record-backed
3806
4613
  // adapters intentionally project online until the archive write, so display liveness is not physical
3807
4614
  // evidence here. A target pane appearing after the stop proof is the remaining shared runtime witness.
3808
4615
  await assertTargetTmuxAbsent(id, 'before archive filing');
3809
- writeRecord({ ...latest, archived: true, stopped: true, coldProof: coldProofFor(latest), adapterRecovery: null });
4616
+ writeRecord({ ...latest, stopped: true, coldProof: coldProofFor(latest), adapterRecovery: null });
3810
4617
  }
3811
4618
  catch (error) {
3812
4619
  if (coldCommitted) {
@@ -3832,102 +4639,10 @@ async function archiveSessionUnlocked(id, on = true) {
3832
4639
  archiving.delete(id);
3833
4640
  }
3834
4641
  }
3835
- export const archiveSession = (id, on = true) => {
3836
- if (!on)
3837
- return archiveSessionUnarchive(id);
3838
- return withSessionTransition(id, () => withRecordLock(id, () => archiveSessionUnlocked(id, on)));
3839
- };
3840
- async function archiveSessionUnarchive(id) {
3841
- const wt = await findWorktree(id);
3842
- if (!wt)
3843
- return false;
3844
- if (!wt.rec.archived)
3845
- return true;
3846
- const resumed = await resumeSession(id);
3847
- if (!resumed.ok)
3848
- throw new ResourceConflict(resumed.error || `refusing to resume ${id}`);
3849
- return true;
3850
- }
3851
- // @@@ cold retirement - archive already returned the target's runtime, so closing a proven-cold row must not
3852
- // re-enter the live stop guard and make unrelated shared-root references prove ownership again. Verify only
3853
- // that the target-bound cold proof is still current and that no target PID/window/socket/thread has reappeared.
3854
- // This is read-only: no signal, adapter mutation, or shared-root cleanup belongs on the cold path.
3855
- async function assertColdRetirementSafe(id, rec) {
3856
- if (!rec.archived || !rec.stopped || !hasValidColdProof(rec))
3857
- throw new ResourceConflict(`refusing to close archived session ${id}: target-bound cold witness is missing or stale`);
3858
- if (rec.adapterRecovery)
3859
- throw new ResourceConflict(`refusing to close archived session ${id}: adapter recovery is pending (${rec.adapterRecovery})`);
3860
- const [snap, socket] = await Promise.all([liveSnapshot(id), rendezvousListening(id)]);
3861
- if (snap.probeFailed)
3862
- throw new ResourceConflict(`refusing to close archived session ${id}: liveness probe failed; target runtime absence is unproven`);
3863
- if (snap.windows.has(id))
3864
- throw new ResourceConflict(`refusing to close archived session ${id}: target tmux window has reappeared`);
3865
- if (socket === 'live')
3866
- throw new ResourceConflict(`refusing to close archived session ${id}: target rendezvous transport has reappeared`);
3867
- if (socket === 'unproven')
3868
- throw new ResourceConflict(`refusing to close archived session ${id}: target rendezvous state is ambiguous`);
3869
- const leaf = await inspectSessionLeafIdentity(id, rec);
3870
- if (leaf.state === 'owned')
3871
- throw new ResourceConflict(`refusing to close archived session ${id}: target leaf PID ${leaf.identity.pid} is live or recycled; ownership is ambiguous`);
3872
- if (leaf.state === 'unknown')
3873
- throw new ResourceConflict(`refusing to close archived session ${id}: ${leaf.reason}; ownership is ambiguous`);
3874
- const harness = harnessById(rec.harness || defaultHarness.id);
3875
- if (harness.coldRetirementPreflight) {
3876
- const proof = await harness.coldRetirementPreflight(rec);
3877
- if (!proof.ok)
3878
- throw new ResourceConflict(`refusing to close archived session ${id}: ${proof.reason}`);
3879
- return;
3880
- }
3881
- const descriptors = harness.sharedRuntimes?.(runtimeRoot()) ?? [];
3882
- let everySharedRootAbsent = descriptors.length > 0;
3883
- for (const descriptor of descriptors) {
3884
- const resident = descriptor.residency
3885
- ? await descriptor.residency()
3886
- : await descriptor.probe().then((probe) => ({ healthy: probe.healthy, referenceIds: probe.references.map((reference) => reference.referenceId), error: probe.error }));
3887
- if (!resident.healthy)
3888
- throw new ResourceConflict(`refusing to close archived session ${id}: ${resident.error || `${descriptor.label} resident census is unhealthy`}`);
3889
- if (rec.harnessSessionId && resident.referenceIds.includes(rec.harnessSessionId))
3890
- throw new ResourceConflict(`refusing to close archived session ${id}: target adapter thread ${rec.harnessSessionId} is loaded`);
3891
- everySharedRootAbsent = everySharedRootAbsent && resident.rootAbsent === true;
3892
- }
3893
- if (harness.coldPreflight && !everySharedRootAbsent) {
3894
- const proof = await harness.coldPreflight(rec);
3895
- if (!proof.ok)
3896
- throw new ResourceConflict(`refusing to close archived session ${id}: ${proof.reason}`);
3897
- if (!proof.alreadyCold)
3898
- throw new ResourceConflict(`refusing to close archived session ${id}: target adapter collection is not proven cold`);
3899
- }
3900
- }
3901
- async function assertDiscardableWorktree(id, path, branch, kind) {
3902
- if (existsSync(path)) {
3903
- const status = await gitTry(['-C', path, 'status', '--porcelain', '--untracked-files=all']);
3904
- if (!status.ok)
3905
- throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} worktree status is unreadable`);
3906
- if (status.stdout.trim())
3907
- throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} worktree has dirty work`);
3908
- }
3909
- if (branch) {
3910
- const resolved = await gitTry(['-C', mainRoot(), 'rev-parse', '--verify', `${branch}^{commit}`]);
3911
- if (resolved.ok) {
3912
- const count = await gitTry(['-C', mainRoot(), 'rev-list', '--count', `${mainBranch()}..${branch}`]);
3913
- if (!count.ok)
3914
- throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} branch ancestry is unreadable`);
3915
- const ahead = Number(count.stdout.trim());
3916
- if (!Number.isFinite(ahead) || ahead !== 0)
3917
- throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} branch is ${Number.isFinite(ahead) ? ahead : 'an unknown number of'} commit(s) ahead`);
3918
- }
3919
- else if (resolved.failure !== 'exit') {
3920
- throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} branch identity is unreadable`);
3921
- }
3922
- else if (existsSync(path)) {
3923
- throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} worktree exists but branch ${branch} is missing`);
3924
- }
3925
- }
3926
- }
3927
4642
  // A never-launched queue owns only prepared disk state. The transition/record locks around close serialize
3928
4643
  // this check with startQueued: whichever wins decides whether the record is still a queue or has become live.
3929
4644
  // No shared-runtime probe belongs here because a valid prepared row has no adapter thread to look up.
3930
- async function assertQueuedRetirementSafe(id, rec, path, branch) {
4645
+ async function assertQueuedCloseSafe(id, rec) {
3931
4646
  if (rec.status !== 'queued' || rec.harnessSessionId)
3932
4647
  throw new ResourceConflict(`refusing to close queued session ${id}: the record has a target thread or is no longer queued`);
3933
4648
  if (rec.adapterRecovery || launching.has(id))
@@ -3946,14 +4661,20 @@ async function assertQueuedRetirementSafe(id, rec, path, branch) {
3946
4661
  const pid = readAgentPid(pidPath);
3947
4662
  throw new ResourceConflict(`refusing to close queued session ${id}: target leaf PID artifact ${Number.isFinite(pid) && pid > 0 ? pid : 'is unreadable'}; never-launched ownership is ambiguous`);
3948
4663
  }
3949
- await assertDiscardableWorktree(id, path, branch, 'prepared');
4664
+ // close preserves the prepared tree's dirty state in refs/spex-archive/<id>; cleanliness is not a
4665
+ // precondition for the soft terminal transition.
3950
4666
  }
3951
4667
  // A launch may leave its row active before Codex publishes the native thread binding. This close path owns
3952
4668
  // only the record's dead local launch residue; an unbound app-server peer stays unowned and untouched.
3953
- async function assertUnboundRetirementSafe(id, rec, path, branch) {
4669
+ async function assertUnboundCloseSafe(id, rec) {
3954
4670
  if (harnessById(rec.harness || defaultHarness.id).exactNativeTargetId(rec) || rec.status === 'queued' || rec.archived)
3955
4671
  throw new ResourceConflict(`refusing to close unbound session ${id}: it is not an unbound live-record residue`);
3956
- if (rec.adapterRecovery || rec.launchReadinessPending || launching.has(id) || existsSync(sessionArtifactPath(id, 'launch')))
4672
+ const failureStamped = /^queued launch readiness failed:/.test(rec.note || '') || rec.status === 'error' || rec.stopped;
4673
+ const readinessStartedAt = rec.launchReadinessStartedAt ?? rec.launchReadinessPending?.startedAt ?? null;
4674
+ const readinessInProgress = !failureStamped && (readinessStartedAt != null
4675
+ ? Date.now() - readinessStartedAt < SOCKET_READY_TIMEOUT_MS
4676
+ : launching.has(id));
4677
+ if (rec.adapterRecovery || readinessInProgress)
3957
4678
  throw new ResourceConflict(`refusing to close unbound session ${id}: launch or recovery is still in progress`);
3958
4679
  const [snap, socket] = await Promise.all([liveSnapshot(id), rendezvousListening(id)]);
3959
4680
  if (snap.probeFailed)
@@ -3968,55 +4689,45 @@ async function assertUnboundRetirementSafe(id, rec, path, branch) {
3968
4689
  const leaf = await inspectSessionLeafIdentity(id, rec);
3969
4690
  if (leaf.state !== 'missing' && leaf.state !== 'dead')
3970
4691
  throw new ResourceConflict(`refusing to close unbound session ${id}: ${leaf.state === 'unknown' ? leaf.reason : 'target leaf identity is live or ambiguous'}`);
3971
- await assertDiscardableWorktree(id, path, branch, 'unbound');
3972
4692
  }
3973
- async function closeOwnedSessionUnlocked(id, wt, source, unboundRetired = false) {
4693
+ async function closeOwnedSessionUnlocked(id, wt, _source, unboundStopped = false) {
3974
4694
  const root = mainRoot();
3975
4695
  const receiptFailure = publishedSessionCandidateReceiptRetirementFailure(wt.rec, root);
3976
4696
  if (receiptFailure)
3977
- throw new ResourceConflict(`refusing destructive close for ${id}: ${receiptFailure}; public record and resources remain the authority fence`);
3978
- const closesCodexBinding = (wt.rec.harness === 'codex' || wt.rec.harness === 'codex-headless') && !!wt.rec.harnessSessionId;
3979
- const retired = !wt.rec.archived && !!retirementReason(wt.rec);
3980
- // A retired row has already lost its worktree; close is its explicit record-only terminal cleanup.
3981
- if (!retired) {
3982
- if (wt.rec.archived)
3983
- await assertColdRetirementSafe(id, wt.rec);
3984
- else if (wt.rec.status === 'queued')
3985
- await assertQueuedRetirementSafe(id, wt.rec, wt.path, wt.branch);
3986
- else if (!unboundRetired)
3987
- throw new ResourceConflict(`refusing to close ${id}: target runtime was not cold-retired first`);
3988
- }
3989
- // The marker protects only the destructive half. A failed cold proof must leave a normal, resumable binding.
3990
- if (closesCodexBinding)
3991
- prepareCodexGenerationClose(runtimeRoot(), id, wt.rec.harnessSessionId);
3992
- appendCloseLedger(id, wt.rec, source);
4697
+ throw new ResourceConflict(`refusing close for ${id}: ${receiptFailure}; public record and resources remain the authority fence`);
4698
+ const retired = !!retirementReason(wt.rec);
4699
+ if (!retired && wt.rec.status === 'queued')
4700
+ await assertQueuedCloseSafe(id, wt.rec);
4701
+ if (!retired && !unboundStopped && wt.rec.status !== 'queued') {
4702
+ await coldStopSessionUnlocked(id);
4703
+ wt = (await findWorktree(id)) || wt;
4704
+ }
4705
+ // The archive ref is published before any worktree removal. A failed ref write leaves the complete
4706
+ // worktree and record in place for retry.
4707
+ if (!retired && existsSync(wt.path))
4708
+ archiveWorktreeState(id, wt.path);
4709
+ const latest = readRecord(id);
4710
+ if (!latest)
4711
+ throw new ResourceConflict(`refusing to finish close for ${id}: session record disappeared before publication`);
4712
+ writeRecord({
4713
+ ...latest,
4714
+ archived: true,
4715
+ closedAt: latest.closedAt || new Date().toISOString(),
4716
+ stopped: true,
4717
+ coldProof: latest.coldProof || coldProofFor(latest),
4718
+ adapterRecovery: null,
4719
+ });
3993
4720
  let slot = null;
3994
4721
  try {
3995
- slot = treeSlotDir(wt.path);
4722
+ slot = existsSync(wt.path) ? treeSlotDir(wt.path) : null;
3996
4723
  }
3997
4724
  catch { /* tree already unresolvable — nothing to key the slot by */ }
3998
- // a retired session's worktree/branch are already gone; removing them is a no-op to skip, not a failure.
3999
4725
  if (existsSync(wt.path)) {
4000
- const removed = await gitTry(['-C', root, 'worktree', 'remove', '--force', wt.path]);
4001
- if (!removed.ok)
4002
- throw new ResourceConflict(`refusing to finish close for ${id}: worktree removal failed`);
4003
- if (existsSync(wt.path))
4004
- throw new ResourceConflict(`refusing to finish close for ${id}: worktree remains after removal`);
4005
- }
4006
- if (wt.branch) {
4007
- const branchRef = `refs/heads/${wt.branch}`;
4008
- const present = await gitTry(['-C', root, 'rev-parse', '--verify', '--quiet', branchRef]);
4009
- if (present.ok) {
4010
- const removed = await gitTry(['-C', root, 'branch', '-D', wt.branch]);
4011
- if (!removed.ok)
4012
- throw new ResourceConflict(`refusing to finish close for ${id}: branch removal failed`);
4013
- const remaining = await gitTry(['-C', root, 'rev-parse', '--verify', '--quiet', branchRef]);
4014
- if (remaining.ok || remaining.failure !== 'exit')
4015
- throw new ResourceConflict(`refusing to finish close for ${id}: branch remains or its removal is unproven`);
4016
- }
4017
- else if (present.failure !== 'exit') {
4018
- throw new ResourceConflict(`refusing to finish close for ${id}: branch presence is unreadable`);
4019
- }
4726
+ const trashed = moveWorktreeToTrash(root, wt.path);
4727
+ const pruned = await gitTry(['-C', root, 'worktree', 'prune']);
4728
+ if (!pruned.ok)
4729
+ console.error(`spex: deferred worktree ${id} was renamed to ${trashed}, but git worktree prune failed: ${pruned.stderr.trim() || pruned.failure}`);
4730
+ queueWorktreeTrash(trashed);
4020
4731
  }
4021
4732
  if (slot) {
4022
4733
  try {
@@ -4024,20 +4735,6 @@ async function closeOwnedSessionUnlocked(id, wt, source, unboundRetired = false)
4024
4735
  }
4025
4736
  catch { /* best-effort GC */ }
4026
4737
  }
4027
- try {
4028
- rmSync(sessionStoreDir(id), { recursive: true, force: true });
4029
- }
4030
- catch (error) {
4031
- throw new ResourceConflict(`refusing to finish close for ${id}: session record/prompt removal failed (${error instanceof Error ? error.message : String(error)})`);
4032
- }
4033
- if (existsSync(sessionStoreDir(id)))
4034
- throw new ResourceConflict(`refusing to finish close for ${id}: session record removal failed`);
4035
- // The close still owns this sender's record lock. Marking after its store is gone lets any send already
4036
- // admitted finish before close returns, while every later send and every retry sweep sees terminal output.
4037
- revokeSenderDelivery(id);
4038
- if (closesCodexBinding && wt.rec.harnessSessionId) {
4039
- bindCodexGeneration(runtimeRoot(), id, wt.rec.harnessSessionId, null);
4040
- }
4041
4738
  requestQueueDrain(); // a close frees a slot — start the next queued session if any
4042
4739
  return true;
4043
4740
  }
@@ -4053,7 +4750,7 @@ async function closeSessionUnlocked(id, source) {
4053
4750
  const runtime = sessionStoreDir(id);
4054
4751
  const evidence = quarantined
4055
4752
  ? `Original bytes were copied to ${quarantined}`
4056
- : `Original bytes remain at ${join(runtime, 'session.json')}; no quarantine copy could be made`;
4753
+ : `Original bytes remain at ${join(runtime, 'runtime.json')}; no quarantine copy could be made`;
4057
4754
  let guard = 'no readable session record proves the adapter or leaf owner';
4058
4755
  try {
4059
4756
  await stopAgentProcess(id, null);
@@ -4061,42 +4758,29 @@ async function closeSessionUnlocked(id, source) {
4061
4758
  catch (error) {
4062
4759
  guard = error instanceof Error ? error.message : String(error);
4063
4760
  }
4064
- throw new SessionRecordUnusable('corrupt', id, `refusing destructive close for ${id}: the unreadable record proves no adapter, leaf, worktree, or branch owner (${guard}). ${evidence}. Runtime remains at ${runtime}; worktree and branch ownership is unknown and was not touched; no process signal or deletion was attempted.`);
4761
+ throw new SessionRecordUnusable('corrupt', id, `refusing close for ${id}: the unreadable record proves no adapter, leaf, worktree, or branch owner (${guard}). ${evidence}. Runtime remains at ${runtime}; worktree and branch ownership is unknown and was not touched; no process signal or deletion was attempted.`);
4065
4762
  }
4066
4763
  if (!wt)
4067
4764
  return false;
4068
- let unboundRetired = false;
4765
+ let unboundStopped = false;
4069
4766
  if (!retirementReason(wt.rec) && !wt.rec.archived && wt.rec.status !== 'queued') {
4070
4767
  const harness = harnessById(wt.rec.harness || defaultHarness.id);
4071
4768
  if (!harness.exactNativeTargetId(wt.rec)) {
4072
- await assertUnboundRetirementSafe(id, wt.rec, wt.path, wt.branch);
4769
+ await assertUnboundCloseSafe(id, wt.rec);
4073
4770
  await tmuxOk(['kill-session', '-t', id]);
4074
- await assertTargetTmuxAbsent(id, 'after unbound residue retirement');
4771
+ await assertTargetTmuxAbsent(id, 'after unbound residue close');
4075
4772
  await harness.cleanupRuntime(wt.rec);
4076
- unboundRetired = true;
4773
+ unboundStopped = true;
4077
4774
  }
4078
4775
  else {
4079
- // A confirmed terminal close may end an exact native turn before cold proof. Ordinary archive deliberately
4080
- // remains non-destructive while a turn is active; close already means discard this session's work.
4081
- assertSessionOwnerSafe(id, harness.id);
4082
- const interrupt = harness.interrupt;
4083
- if (interrupt) {
4084
- const result = await interrupt({ ...wt.rec, runtimeDir: runtimeRoot() });
4085
- if (!result.ok)
4086
- throw new ResourceConflict(`refusing to close ${id}: native interrupt failed (${result.error || 'unknown error'})`);
4087
- }
4088
- const archived = await archiveSessionUnlocked(id);
4089
- if (!archived)
4090
- return false;
4091
- wt = await findWorktree(id);
4092
- if (!wt)
4093
- return false;
4776
+ // closeOwnedSessionUnlocked performs the one exact cold-stop proof immediately before archive-ref
4777
+ // publication. Keeping that seam in one place prevents a second adapter mutation on retry.
4094
4778
  }
4095
4779
  }
4096
4780
  const target = wt;
4097
4781
  return target.branch
4098
- ? withRecordLock(sessionCandidateLockId(target.path, target.branch), () => closeOwnedSessionUnlocked(id, target, source, unboundRetired))
4099
- : closeOwnedSessionUnlocked(id, target, source, unboundRetired);
4782
+ ? withRecordLock(sessionCandidateLockId(target.path, target.branch), () => closeOwnedSessionUnlocked(id, target, source, unboundStopped))
4783
+ : closeOwnedSessionUnlocked(id, target, source, unboundStopped);
4100
4784
  }
4101
4785
  export const closeSession = (id, rawSource) => {
4102
4786
  const source = normalizeCloseSource(rawSource);
@@ -4229,7 +4913,7 @@ export async function quarantineCorruptRecord(id, rawWitness) {
4229
4913
  const git = await proveQuarantineGitAbsent(id, witness);
4230
4914
  const adapter = await proveQuarantineAdapter(id, witness);
4231
4915
  const bundle = join(quarantineRoot(id), `${observedAt.replace(/[:.]/g, '-')}-${randomUUID()}`);
4232
- const stored = join(bundle, 'session.json');
4916
+ const stored = join(bundle, 'runtime.json');
4233
4917
  const provenance = join(bundle, 'provenance.json');
4234
4918
  const audit = {
4235
4919
  version: 1,
@@ -4279,10 +4963,10 @@ export async function restoreQuarantinedRecord(id) {
4279
4963
  throw new ResourceConflict(`refusing to restore ${id}: no quarantine bundle exists`);
4280
4964
  throw new ResourceConflict(`refusing to restore ${id}: quarantine bundle inventory is unreadable`);
4281
4965
  }
4282
- const bundle = bundles.map((name) => join(quarantineRoot(id), name)).find((path) => existsSync(join(path, 'session.json')) && existsSync(join(path, 'provenance.json')));
4966
+ const bundle = bundles.map((name) => join(quarantineRoot(id), name)).find((path) => existsSync(join(path, 'runtime.json')) && existsSync(join(path, 'provenance.json')));
4283
4967
  if (!bundle)
4284
4968
  throw new ResourceConflict(`refusing to restore ${id}: no complete quarantine bundle exists`);
4285
- const stored = join(bundle, 'session.json');
4969
+ const stored = join(bundle, 'runtime.json');
4286
4970
  let provenance;
4287
4971
  try {
4288
4972
  provenance = JSON.parse(readFileSync(join(bundle, 'provenance.json'), 'utf8'));
@@ -4486,6 +5170,7 @@ export function formatTable(sessions, color = true, scope = { kind: 'sessions' }
4486
5170
  });
4487
5171
  return [heading, header, ...rows, statusLegend(color)].join('\n');
4488
5172
  }
5173
+ export const EMPTY_PROMPT_ERROR = 'empty prompt — nothing to dispatch';
4489
5174
  class StrandedDeliveryError extends Error {
4490
5175
  }
4491
5176
  async function strandedDeliveryError(rec) {
@@ -4495,100 +5180,199 @@ async function strandedDeliveryError(rec) {
4495
5180
  const transport = await h.deliveryTransport({ ...rec, runtimeDir: runtimeRoot() });
4496
5181
  if (transport.kind !== 'unreachable' || agentAlive(rec.session) !== true)
4497
5182
  return null;
4498
- const queued = pendingMessages(rec.session).length;
5183
+ const application = configuredSessionApplicationIfCutover();
5184
+ const queued = application?.readPendingMessages(rec.session).length ?? 0;
4499
5185
  const noun = queued === 1 ? 'message is' : 'messages are';
4500
5186
  return new StrandedDeliveryError(`session ${rec.session} is stranded: ${transport.reason} while its registered agent process is still alive; ${queued} queued ${noun} waiting with no transport to claim them. Use \`spex session send ${rec.session} --keys "<keys>"\` to steer the live tmux pane, then repair the control transport before sending text.`);
4501
5187
  }
4502
5188
  export async function sendText(id, text, from, opts = {}) {
4503
- if (!text)
4504
- return { ok: false, error: 'empty prompt — nothing to dispatch' };
4505
- let replayed = false;
4506
- try {
4507
- // Taking a declared sender's record lock makes close a real outgoing fence even across backend processes:
4508
- // a send either appends before close obtains the fence (and close's revocation voids its debt), or sees
4509
- // the terminal marker before it records anything. Arbitrary legacy `from` values keep working; they just
4510
- // name an otherwise-unused lock until a matching session is closed.
4511
- let rec = null;
4512
- const accepted = await acceptMessage({
4513
- target: id,
4514
- text,
4515
- from,
4516
- idempotency: opts.idempotency,
4517
- validate: async () => {
4518
- rec = readRecord(id);
4519
- if (!rec)
4520
- throw new ResourceConflict(`no session record for ${id} — prompt NOT delivered`);
4521
- },
4522
- prepare: async () => {
4523
- await opts.acceptGuard?.(rec);
4524
- const stranded = await strandedDeliveryError(rec);
4525
- if (stranded)
4526
- throw stranded;
4527
- // Composed at ACCEPT time, once: the log keeps the raw conversational text plus the effective reply channel,
4528
- // the queue keeps the transport form. Composing again at handover would let a later send change the hints on
4529
- // a message that was already accepted.
4530
- const prompt = await composeSessionPrompt(text, rec, { from, replyVia: opts.replyVia });
4531
- return { text: prompt.text, ...(prompt.replyVia ? { replyVia: prompt.replyVia } : {}) };
4532
- },
4533
- });
4534
- replayed = accepted.replayed;
4535
- }
4536
- catch (error) {
4537
- const code = error?.code;
4538
- const detail = error instanceof StrandedDeliveryError
4539
- ? error.message
4540
- : `could not append the message to session ${id}'s log: ${error instanceof Error ? error.message : String(error)} — prompt NOT delivered`;
5189
+ if (!text.trim())
5190
+ return { ok: false, error: EMPTY_PROMPT_ERROR };
5191
+ const application = configuredSessionApplicationIfCutover();
5192
+ if (application) {
5193
+ let message;
5194
+ let replayed = false;
5195
+ try {
5196
+ const rec = readRecord(id);
5197
+ if (!rec)
5198
+ throw new ResourceConflict(`no session record for ${id} — prompt NOT delivered`);
5199
+ await opts.acceptGuard?.(rec);
5200
+ const prompt = await composeSessionPrompt(text, rec, { from, replyVia: opts.replyVia });
5201
+ const idempotencyKey = opts.idempotency?.requestDigest ?? (opts.deliveryKey?.trim() || null);
5202
+ const existing = idempotencyKey
5203
+ ? application.readMessageHistory(id).find(message => message.idempotencyKey === idempotencyKey)
5204
+ : undefined;
5205
+ message = existing ?? application.enqueueConversationMessage(id, {
5206
+ kind: 'session.prompt.v1',
5207
+ body: Buffer.from(prompt.text, 'utf8'),
5208
+ senderSessionId: from ?? null,
5209
+ idempotencyKey,
5210
+ }, { text, from: from ?? null, ...(prompt.replyVia ? { replyVia: prompt.replyVia } : {}) });
5211
+ replayed = !!existing;
5212
+ }
5213
+ catch (error) {
5214
+ return { ok: false, error: `could not append the message to session ${id}'s application queue: ${error instanceof Error ? error.message : String(error)}` };
5215
+ }
5216
+ // Acceptance and handover are separate boundaries. A committed SQLite message remains a successful
5217
+ // command even when the runtime is currently unbound; binding/resume is the explicit event that makes
5218
+ // the durable debt drainable. Reporting the post-commit refusal as an append failure made command-box
5219
+ // callers show a false error despite the prompt already being safely queued.
5220
+ if (!opts.deferDrain) {
5221
+ try {
5222
+ await drainSession(id);
5223
+ }
5224
+ catch (error) {
5225
+ if (!(error instanceof ResourceConflict) || !/no bound spex-governed runtime/u.test(error.message))
5226
+ throw error;
5227
+ }
5228
+ }
5229
+ const pending = application.readPendingMessages(id).some(candidate => candidate.messageId === message.messageId);
5230
+ // Queue acceptance is not runtime activity. A prompt remains owed while the adapter is unbound,
5231
+ // restarting, or refusing the insert; only the handoff that removes this exact message may re-enter
5232
+ // a waiting session as active. This keeps a queued command from painting a dead pane as working.
5233
+ if (!from && !pending)
5234
+ markHumanPromptActive(id);
5235
+ // @@@ a deferred drain measured NOTHING, and must not be reported as a refusal.
5236
+ // `pending` is read microseconds after the enqueue with no await in between, so when the drain was
5237
+ // deferred it is answering "did I skip the handover" — always yes — rather than "did the adapter refuse".
5238
+ // Reporting that as `queued` made every first Command Box send claim the transport was still owed while
5239
+ // the prompt was in fact in the agent's pane milliseconds later, and the claim was UNCONDITIONAL: no
5240
+ // transport state, harness, or runtime binding could change it. Name the deferral instead, so the one
5241
+ // caller that defers can say "accepted, handover in flight" and the callers that DO drain keep a
5242
+ // `queued` that still means what it says.
4541
5243
  return {
4542
- ok: false,
4543
- error: detail,
4544
- ...(code ? { code } : {}),
5244
+ ok: true,
5245
+ delivery: opts.deferDrain ? 'deferred' : pending ? 'queued' : 'accepted',
5246
+ ...(opts.idempotency || opts.deliveryKey ? { replayed } : {}),
4545
5247
  };
4546
5248
  }
4547
- // Awaited, not fire-and-forget: an unawaited insert can lose its race with a short-lived caller's exit,
4548
- // costing that send its same-turn arrival. Draining HERE rather than leaving it to the sweep is what puts
4549
- // the text in a live agent's current turn instead of up to one tick later.
4550
- if (!opts.deferDrain)
4551
- await drainSession(id);
4552
- return { ok: true, ...(opts.idempotency ? { replayed } : {}) };
5249
+ throw new ResourceConflict('session application is unavailable; refusing the legacy delivery path');
4553
5250
  }
4554
5251
  // @@@ drainSession - hand over what this session is owed, as ordinary prompts. Safe to call from anywhere and
4555
5252
  // at any time: the queue's own lock serializes concurrent passes, and an empty queue costs one existsSync.
4556
5253
  // The retry sweep in `serve` calls this for the sessions whose queues an earlier pass could not empty.
4557
5254
  export async function drainSession(id) {
4558
- if (!owesDelivery(id))
4559
- return;
4560
- const rec = readRecord(id);
4561
- if (!rec)
4562
- return;
4563
- const h = harnessById(rec.harness || defaultHarness.id);
4564
- if (h.launchPayloadProof && !rec.harnessSessionId)
4565
- return;
4566
- await drain(id, async (msg) => {
4567
- // the pane guard ([[harness-adapter]] deliveryBlockedBy): the ONE pane state where the harness swallows a
4568
- // prompt its channel confirms (claude's sessions panel), checkable only from the pane. Treated as a REFUSAL
4569
- // rather than a skip the message stays owed and the sweep hands it over once the pane leaves that state.
4570
- if (h.deliveryBlockedBy) {
4571
- try {
4572
- if (h.deliveryBlockedBy(await tmux(['capture-pane', '-p', '-t', rec.session], TMUX_PROBE_TIMEOUT_MS)))
4573
- return false;
5255
+ const application = configuredSessionApplicationIfCutover();
5256
+ if (application) {
5257
+ const rec = readRecord(id);
5258
+ if (!rec)
5259
+ return;
5260
+ // An empty canonical queue is a successful no-op. Do not turn a resume with no owed prompt into a
5261
+ // runtime-binding error; require a bound adapter only when there is a message that must be handed over.
5262
+ if (application.readPendingMessages(id).length === 0)
5263
+ return;
5264
+ const h = harnessById(rec.harness || defaultHarness.id);
5265
+ const binding = application.resolveRuntime(id, 'spex-governed');
5266
+ if (!binding || binding.status !== 'bound') {
5267
+ // Leaf adapters own their per-session controller and can deliver without a shared native identity.
5268
+ // Preserve the governed transport while that identity is absent, then acknowledge the same canonical
5269
+ // queue directly. Shared adapter runtimes (Codex) remain fail-closed until their exact binding exists.
5270
+ const leafWithoutNativeIdentity = !rec.harnessSessionId && (rec.harness === 'claude' || h.runtimeOwnership === 'leaf');
5271
+ if (leafWithoutNativeIdentity) {
5272
+ await withDeliveryLocks([id], async () => {
5273
+ for (;;) {
5274
+ const pending = application.readPendingMessages(id);
5275
+ const msg = pending[0];
5276
+ if (!msg)
5277
+ return;
5278
+ const text = canonicalMessageText(msg, rec);
5279
+ if (h.deliveryBlockedBy) {
5280
+ try {
5281
+ if (h.deliveryBlockedBy(await tmux(['capture-pane', '-p', '-t', rec.session], TMUX_PROBE_TIMEOUT_MS)))
5282
+ return;
5283
+ }
5284
+ catch { /* no pane to consult — let the adapter decide */ }
5285
+ }
5286
+ const delivered = await h.deliver({ ...rec, runtimeDir: runtimeRoot(), mid: msg.messageId }, text);
5287
+ if (!delivered.ok)
5288
+ return;
5289
+ const removed = application.dequeuePendingMessage(id, msg.messageId);
5290
+ if (!removed || removed.messageId !== msg.messageId)
5291
+ throw new ResourceConflict(`canonical queue head changed while delivering ${id}`);
5292
+ }
5293
+ });
5294
+ return;
4574
5295
  }
4575
- catch { /* no pane to consult let the insert itself decide */ }
5296
+ throw new ResourceConflict(`canonical delivery for ${id} remains pending: no bound spex-governed runtime`);
4576
5297
  }
4577
- const delivered = await h.deliver({ ...rec, runtimeDir: runtimeRoot(), mid: msg.mid }, msg.text);
4578
- return delivered.ok;
4579
- });
5298
+ await withDeliveryLocks([id], async () => {
5299
+ for (;;) {
5300
+ const pending = application.readPendingMessages(id);
5301
+ const msg = pending[0];
5302
+ if (!msg)
5303
+ return;
5304
+ const text = canonicalMessageText(msg, rec);
5305
+ if (h.deliveryBlockedBy) {
5306
+ try {
5307
+ if (h.deliveryBlockedBy(await tmux(['capture-pane', '-p', '-t', rec.session], TMUX_PROBE_TIMEOUT_MS)))
5308
+ return;
5309
+ }
5310
+ catch { /* no pane to consult — let the adapter decide */ }
5311
+ }
5312
+ const delivered = await h.deliver({ ...rec, runtimeDir: runtimeRoot(), mid: msg.messageId }, text);
5313
+ if (!delivered.ok)
5314
+ return;
5315
+ const removed = application.dequeueForRuntime(id, 'spex-governed', binding.bindingGeneration, msg.messageId);
5316
+ if (!removed || removed.messageId !== msg.messageId)
5317
+ throw new ResourceConflict(`canonical queue head changed while delivering ${id}`);
5318
+ }
5319
+ });
5320
+ return;
5321
+ }
5322
+ throw new ResourceConflict('session application is unavailable; refusing the legacy delivery path');
5323
+ }
5324
+ // `recipient` is the session this text is delivered TO; a state message speaks about its `sessionId`, the
5325
+ // watched subject, and the notice must name that subject — never the reader of the notice.
5326
+ export function canonicalMessageText(message, recipient) {
5327
+ if (message.kind === 'session.prompt.v1')
5328
+ return Buffer.from(message.body).toString('utf8');
5329
+ if (message.kind === 'session.state.changed.v1') {
5330
+ try {
5331
+ const change = JSON.parse(Buffer.from(message.body).toString('utf8'));
5332
+ if (typeof change.sessionId !== 'string' || !change.sessionId)
5333
+ throw new ResourceConflict(`canonical state message delivered to ${recipient.session} names no subject session`);
5334
+ return watchMessage({ ...recipient, session: change.sessionId, status: (change.status ?? recipient.status), proposal: change.proposal ?? null, note: change.note ?? null, parent: change.parentSessionId ?? null });
5335
+ }
5336
+ catch (error) {
5337
+ if (error instanceof ResourceConflict)
5338
+ throw error;
5339
+ throw new ResourceConflict(`canonical state message for ${recipient.session} is not valid JSON`);
5340
+ }
5341
+ }
5342
+ throw new ResourceConflict(`canonical message kind ${message.kind} cannot be delivered as session text`);
4580
5343
  }
4581
- // Hard interrupt is adapter-native control, distinct from stop's process teardown. A harness without a
4582
- // confirmed native primitive refuses loudly; there is no signal/PTY fallback that could target the wrong turn.
5344
+ // Hard interrupt is adapter-native control, distinct from stop's process teardown. A harness with a native
5345
+ // primitive uses it. Without one the transport decides: a HEADLESS adapter has no keyboard, so it refuses
5346
+ // loudly rather than emulating an interrupt with a signal that could hit the wrong process; a PANE-BACKED
5347
+ // TUI has an operator's keyboard by definition, so its interrupt is the key that operator would press —
5348
+ // C-c into its own pane, through the raw-key channel below — and only while its lifecycle is actually
5349
+ // active, because the same key on an idle TUI is a second Ctrl-C away from quitting it.
4583
5350
  export async function interruptSession(id) {
5351
+ // The lifecycle read and the key send share ONE record lock: a declaration that lands between them would
5352
+ // otherwise turn "interrupt the working turn" into "Ctrl-C an idle TUI", so there is no window.
4584
5353
  return withRecordLock(id, async () => {
4585
5354
  const rec = readRecord(id);
4586
5355
  if (!rec)
4587
5356
  return { ok: false, error: `no session record for ${id} - nothing to interrupt` };
4588
5357
  const h = harnessById(rec.harness || defaultHarness.id);
4589
- if (!h.interrupt)
5358
+ if (h.interrupt) {
5359
+ // stamped BEFORE the abort: the adapter's exit report can race the confirmation, and either order must
5360
+ // read "interrupted" (see the interrupt projection); a refused interrupt leaves no trace behind.
5361
+ stampInterrupt(id);
5362
+ const result = await h.interrupt({ ...rec, runtimeDir: runtimeRoot() });
5363
+ if (!result.ok) {
5364
+ clearInterruptMarker(id);
5365
+ return result;
5366
+ }
5367
+ projectInterruptedUnlocked(id);
5368
+ return result;
5369
+ }
5370
+ if (h.headless)
4590
5371
  return { ok: false, error: `harness ${h.id} has no native hard-interrupt control` };
4591
- return h.interrupt({ ...rec, runtimeDir: runtimeRoot() });
5372
+ if (rec.status !== 'active')
5373
+ return { ok: false, error: `session ${id} is not working (lifecycle ${rec.status}) - nothing to interrupt` };
5374
+ const sent = await sendRawKeysLocked(id, ['C-c']);
5375
+ return sent ? { ok: true } : { ok: false, error: `session ${id} has no live pane to interrupt` };
4592
5376
  });
4593
5377
  }
4594
5378
  // @@@ rawKey - the RAW-KEYSTROKE nav path, kept DELIBERATELY on `tmux send-keys` and NEVER the rendezvous
@@ -4649,19 +5433,24 @@ function rawKeyArgs(id, key) {
4649
5433
  // ORDER, so they reach the pane in exactly the order they were struck. Concurrent per-key POSTs used to race
4650
5434
  // (browser + server + send-keys all parallel) and scramble the sequence; a single serialised batch cannot.
4651
5435
  // An unknown token is skipped without dropping the rest; false only if the tmux session is gone or nothing sent.
5436
+ // the send itself, for a caller that already holds the record lock (rawKey below; interruptSession above)
5437
+ async function sendRawKeysLocked(id, keys) {
5438
+ const list = keys.filter((k) => typeof k === 'string' && k.length > 0);
5439
+ if (list.length === 0 || !(await alive(id)))
5440
+ return false;
5441
+ let sent = false;
5442
+ for (const k of list) {
5443
+ const args = rawKeyArgs(id, k);
5444
+ if (!args)
5445
+ continue;
5446
+ await tmux(args);
5447
+ sent = true;
5448
+ }
5449
+ return sent;
5450
+ }
4652
5451
  export async function rawKey(id, key) {
4653
- return withRecordLock(id, async () => {
4654
- const list = (Array.isArray(key) ? key : [key]).filter((k) => typeof k === 'string' && k.length > 0);
4655
- if (list.length === 0 || !(await alive(id)))
4656
- return false;
4657
- let sent = false;
4658
- for (const k of list) {
4659
- const args = rawKeyArgs(id, k);
4660
- if (!args)
4661
- continue;
4662
- await tmux(args);
4663
- sent = true;
4664
- }
4665
- return sent;
4666
- });
5452
+ const sent = await withRecordLock(id, () => sendRawKeysLocked(id, Array.isArray(key) ? key : [key]));
5453
+ // Raw-key remote control is transport fallback, not a lifecycle event. Freshness belongs to the
5454
+ // harness turn hooks or a successfully handed-over durable prompt; navigation keys cannot forge working.
5455
+ return sent;
4667
5456
  }