@gajae-code/coding-agent 0.11.1 → 0.11.2

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 (404) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/types/cli/config-cli.d.ts +11 -1
  3. package/dist/types/cli/notify-cli.d.ts +1 -0
  4. package/dist/types/cli/session-picker.d.ts +2 -1
  5. package/dist/types/cli/setup-cli.d.ts +1 -1
  6. package/dist/types/commands/mcp-serve.d.ts +33 -0
  7. package/dist/types/commands/mcp.d.ts +1 -1
  8. package/dist/types/commands/notify.d.ts +0 -2
  9. package/dist/types/commands/sdk.d.ts +1 -1
  10. package/dist/types/config/atomic-yaml-patch.d.ts +18 -0
  11. package/dist/types/config/file-lock.d.ts +14 -4
  12. package/dist/types/config/keybindings.d.ts +42 -1
  13. package/dist/types/config/model-auth.d.ts +3 -0
  14. package/dist/types/config/model-bindings-applier.d.ts +13 -0
  15. package/dist/types/config/model-discovery-manager.d.ts +49 -0
  16. package/dist/types/config/model-equivalence.d.ts +20 -0
  17. package/dist/types/config/model-registry.d.ts +16 -15
  18. package/dist/types/config/model-resolver.d.ts +28 -18
  19. package/dist/types/config/models-config-schema.d.ts +11 -0
  20. package/dist/types/config/settings-schema.d.ts +56 -1
  21. package/dist/types/config/settings.d.ts +14 -11
  22. package/dist/types/coordinator-mcp/policy.d.ts +5 -0
  23. package/dist/types/coordinator-mcp/question-gate-codec.d.ts +129 -0
  24. package/dist/types/coordinator-mcp/question-state.d.ts +356 -0
  25. package/dist/types/coordinator-mcp/server.d.ts +4 -0
  26. package/dist/types/discovery/helpers.d.ts +0 -6
  27. package/dist/types/edit/index.d.ts +3 -0
  28. package/dist/types/eval/py/env.d.ts +9 -0
  29. package/dist/types/extensibility/extensions/loader.d.ts +2 -1
  30. package/dist/types/extensibility/extensions/runner.d.ts +2 -2
  31. package/dist/types/extensibility/extensions/types.d.ts +34 -3
  32. package/dist/types/extensibility/hooks/runner.d.ts +1 -1
  33. package/dist/types/extensibility/shared-events.d.ts +2 -0
  34. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +10 -1
  35. package/dist/types/gjc-runtime/deep-interview-state.d.ts +44 -0
  36. package/dist/types/gjc-runtime/goal-mode-request.d.ts +11 -1
  37. package/dist/types/gjc-runtime/launch-worktree.d.ts +4 -2
  38. package/dist/types/gjc-runtime/linux-proc.d.ts +29 -0
  39. package/dist/types/gjc-runtime/session-resolution.d.ts +2 -2
  40. package/dist/types/gjc-runtime/state-migrations.d.ts +1 -1
  41. package/dist/types/gjc-runtime/state-writer.d.ts +2 -1
  42. package/dist/types/gjc-runtime/team-launch.d.ts +59 -0
  43. package/dist/types/gjc-runtime/team-notify.d.ts +45 -0
  44. package/dist/types/gjc-runtime/team-runtime.d.ts +29 -121
  45. package/dist/types/gjc-runtime/team-store.d.ts +160 -0
  46. package/dist/types/gjc-runtime/team-workers.d.ts +78 -0
  47. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +8 -0
  48. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +13 -0
  49. package/dist/types/gjc-runtime/ultragoal-evidence.d.ts +38 -0
  50. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +7 -0
  51. package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +23 -0
  52. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +55 -33
  53. package/dist/types/gjc-runtime/workflow-cli-common.d.ts +8 -0
  54. package/dist/types/gjc-runtime/workflow-manifest.d.ts +3 -0
  55. package/dist/types/goals/continuation-timeout-guard.d.ts +22 -0
  56. package/dist/types/goals/runtime.d.ts +1 -0
  57. package/dist/types/goals/state.d.ts +8 -0
  58. package/dist/types/harness-control-plane/lifecycle-reconciliation.d.ts +17 -0
  59. package/dist/types/harness-control-plane/phase-rollup.d.ts +4 -7
  60. package/dist/types/harness-control-plane/receipt-ingest.d.ts +5 -0
  61. package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
  62. package/dist/types/hindsight/state.d.ts +11 -4
  63. package/dist/types/lsp/lspmux.d.ts +5 -3
  64. package/dist/types/lsp/path-trust.d.ts +1 -0
  65. package/dist/types/main.d.ts +3 -2
  66. package/dist/types/memories/storage.d.ts +1 -0
  67. package/dist/types/modes/action-registry.d.ts +29 -0
  68. package/dist/types/modes/components/chrome.d.ts +14 -0
  69. package/dist/types/modes/components/command-palette.d.ts +27 -0
  70. package/dist/types/modes/components/custom-provider-wizard.d.ts +1 -0
  71. package/dist/types/modes/components/index.d.ts +2 -1
  72. package/dist/types/modes/components/pet-selector.d.ts +1 -1
  73. package/dist/types/modes/components/plan-preview-overlay.d.ts +43 -0
  74. package/dist/types/modes/components/queue-mode-selector.d.ts +1 -1
  75. package/dist/types/modes/components/queue-pane.d.ts +12 -0
  76. package/dist/types/modes/components/read-tool-group.d.ts +6 -0
  77. package/dist/types/modes/components/session-observer-overlay.d.ts +3 -4
  78. package/dist/types/modes/components/sessions-dashboard.d.ts +32 -0
  79. package/dist/types/modes/components/settings-selector.d.ts +1 -1
  80. package/dist/types/modes/components/show-images-selector.d.ts +1 -1
  81. package/dist/types/modes/components/status-line/types.d.ts +1 -1
  82. package/dist/types/modes/components/status-line.d.ts +1 -94
  83. package/dist/types/modes/components/tasks-pane.d.ts +15 -0
  84. package/dist/types/modes/components/theme-selector.d.ts +1 -1
  85. package/dist/types/modes/components/tool-execution.d.ts +15 -0
  86. package/dist/types/modes/components/tool-status-header.d.ts +111 -0
  87. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +42 -0
  88. package/dist/types/modes/controllers/goal-mode-controller.d.ts +62 -0
  89. package/dist/types/modes/controllers/input-controller.d.ts +8 -1
  90. package/dist/types/modes/controllers/mode-gate.d.ts +8 -0
  91. package/dist/types/modes/controllers/plan-mode-controller.d.ts +65 -0
  92. package/dist/types/modes/controllers/selector-controller.d.ts +15 -3
  93. package/dist/types/modes/controllers/stt-controller.d.ts +26 -0
  94. package/dist/types/modes/interactive-mode.d.ts +19 -17
  95. package/dist/types/modes/jobs-observer.d.ts +2 -0
  96. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +4 -11
  97. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +20 -2
  98. package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +31 -0
  99. package/dist/types/modes/tasks-aggregator.d.ts +39 -0
  100. package/dist/types/modes/transcript-item-registry.d.ts +60 -0
  101. package/dist/types/modes/types.d.ts +20 -13
  102. package/dist/types/modes/utils/ui-helpers.d.ts +2 -2
  103. package/dist/types/runtime-mcp/manager.d.ts +1 -1
  104. package/dist/types/sdk/broker/broker.d.ts +78 -12
  105. package/dist/types/sdk/broker/lifecycle-codec.d.ts +4 -0
  106. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +15 -1
  107. package/dist/types/sdk/broker/lifecycle.d.ts +6 -2
  108. package/dist/types/sdk/broker/process-incarnation.d.ts +2 -0
  109. package/dist/types/sdk/broker/session-index.d.ts +0 -1
  110. package/dist/types/sdk/broker/state-version.d.ts +2 -0
  111. package/dist/types/sdk/bus/chat-effect-journal.d.ts +1 -0
  112. package/dist/types/sdk/bus/config-commands.d.ts +1 -1
  113. package/dist/types/sdk/bus/config.d.ts +11 -0
  114. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  115. package/dist/types/sdk/bus/html-format.d.ts +3 -2
  116. package/dist/types/sdk/bus/index.d.ts +27 -0
  117. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
  118. package/dist/types/sdk/bus/notification-service.d.ts +20 -0
  119. package/dist/types/sdk/bus/rate-limit-pool.d.ts +13 -2
  120. package/dist/types/sdk/bus/recent-activity.d.ts +21 -12
  121. package/dist/types/sdk/bus/rich-document.d.ts +7 -0
  122. package/dist/types/sdk/bus/rich-render.d.ts +18 -41
  123. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +9 -4
  124. package/dist/types/sdk/bus/telegram-daemon.d.ts +46 -3
  125. package/dist/types/sdk/bus/threaded-inbound.d.ts +1 -1
  126. package/dist/types/sdk/bus/threaded-render.d.ts +13 -0
  127. package/dist/types/sdk/bus/topic-registry.d.ts +24 -2
  128. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  129. package/dist/types/sdk/host/host.d.ts +2 -0
  130. package/dist/types/sdk/index.d.ts +1 -0
  131. package/dist/types/sdk/session-directory.d.ts +56 -0
  132. package/dist/types/session/agent-session.d.ts +82 -47
  133. package/dist/types/session/agent-storage.d.ts +7 -0
  134. package/dist/types/session/fallback-chain-controller.d.ts +10 -2
  135. package/dist/types/session/internal/managed-session-scope.d.ts +102 -0
  136. package/dist/types/session/internal/managed-session-storage.d.ts +38 -0
  137. package/dist/types/session/messages.d.ts +16 -0
  138. package/dist/types/session/session-manager-internal.d.ts +14 -0
  139. package/dist/types/session/session-manager.d.ts +82 -12
  140. package/dist/types/session/session-storage.d.ts +43 -3
  141. package/dist/types/session/volatile-context-pruning.d.ts +15 -0
  142. package/dist/types/skill-state/{deep-interview-mutation-guard.d.ts → workflow-mutation-guard.d.ts} +24 -8
  143. package/dist/types/skill-state/workflow-state-contract.d.ts +1 -3
  144. package/dist/types/slash-commands/builtin-registry.d.ts +4 -1
  145. package/dist/types/system-prompt.d.ts +2 -11
  146. package/dist/types/task/executor.d.ts +3 -1
  147. package/dist/types/thinking.d.ts +7 -0
  148. package/dist/types/tool-discovery/tool-index.d.ts +1 -50
  149. package/dist/types/tools/ask.d.ts +64 -2
  150. package/dist/types/tools/bash.d.ts +3 -0
  151. package/dist/types/tools/browser/dead-tab-recovery.d.ts +24 -0
  152. package/dist/types/tools/browser/tab-supervisor.d.ts +21 -0
  153. package/dist/types/tools/cron.d.ts +2 -0
  154. package/dist/types/tools/fetch.d.ts +1 -0
  155. package/dist/types/tools/index.d.ts +30 -15
  156. package/dist/types/tools/output-meta.d.ts +1 -0
  157. package/dist/types/tools/read.d.ts +3 -0
  158. package/dist/types/tools/search-tool-bm25.d.ts +3 -4
  159. package/dist/types/utils/event-bus.d.ts +1 -1
  160. package/dist/types/web/search/index.d.ts +3 -1
  161. package/package.json +21 -9
  162. package/scripts/build-sdk-package-smoke.ts +34 -1
  163. package/scripts/generate-hotkeys-docs.ts +74 -0
  164. package/scripts/generate-sdk-operation-inventory.ts +64 -20
  165. package/scripts/verify-gjc-sdk-canonicalization.ts +182 -2
  166. package/src/async/job-manager.ts +49 -6
  167. package/src/cli/config-cli.ts +75 -2
  168. package/src/cli/notify-cli.ts +92 -26
  169. package/src/cli/session-picker.ts +20 -3
  170. package/src/cli/setup-cli.ts +1 -1
  171. package/src/cli.ts +1 -1
  172. package/src/commands/config.ts +1 -1
  173. package/src/commands/harness.ts +34 -106
  174. package/src/commands/launch.ts +6 -4
  175. package/src/commands/mcp-serve.ts +85 -8
  176. package/src/commands/mcp.ts +2 -2
  177. package/src/commands/notify.ts +8 -4
  178. package/src/commands/sdk.ts +61 -40
  179. package/src/commands/ultragoal.ts +4 -1
  180. package/src/config/atomic-yaml-patch.ts +80 -13
  181. package/src/config/file-lock.ts +105 -43
  182. package/src/config/keybindings.ts +64 -20
  183. package/src/config/model-auth.ts +9 -0
  184. package/src/config/model-bindings-applier.ts +139 -0
  185. package/src/config/model-discovery-manager.ts +224 -0
  186. package/src/config/model-equivalence.ts +66 -0
  187. package/src/config/model-registry.ts +169 -302
  188. package/src/config/model-resolver.ts +361 -369
  189. package/src/config/models-config-schema.ts +5 -4
  190. package/src/config/settings-schema.ts +159 -2
  191. package/src/config/settings.ts +458 -482
  192. package/src/coordinator-mcp/policy.ts +76 -11
  193. package/src/coordinator-mcp/question-gate-codec.ts +274 -0
  194. package/src/coordinator-mcp/question-state.ts +778 -0
  195. package/src/coordinator-mcp/server.ts +1702 -238
  196. package/src/debug/index.ts +1 -1
  197. package/src/defaults/gjc/skills/deep-interview/SKILL.md +13 -1
  198. package/src/defaults/gjc/skills/team/SKILL.md +16 -0
  199. package/src/discovery/helpers.ts +1 -50
  200. package/src/edit/index.ts +64 -0
  201. package/src/eval/py/env.ts +26 -0
  202. package/src/eval/py/kernel.ts +5 -3
  203. package/src/extensibility/extensions/loader.ts +9 -1
  204. package/src/extensibility/extensions/runner.ts +12 -3
  205. package/src/extensibility/extensions/types.ts +39 -1
  206. package/src/extensibility/gjc-plugins/installer.ts +16 -16
  207. package/src/extensibility/hooks/runner.ts +2 -2
  208. package/src/extensibility/runtime-skill-discovery.ts +33 -13
  209. package/src/extensibility/shared-events.ts +2 -0
  210. package/src/gjc-runtime/deep-interview-recorder.ts +63 -3
  211. package/src/gjc-runtime/deep-interview-runtime.ts +76 -27
  212. package/src/gjc-runtime/deep-interview-state.ts +317 -5
  213. package/src/gjc-runtime/goal-mode-request.ts +55 -8
  214. package/src/gjc-runtime/launch-tmux.ts +2 -5
  215. package/src/gjc-runtime/launch-worktree.ts +5 -5
  216. package/src/gjc-runtime/linux-proc.ts +71 -0
  217. package/src/gjc-runtime/ralplan-runtime.ts +74 -90
  218. package/src/gjc-runtime/session-resolution.ts +18 -3
  219. package/src/gjc-runtime/session-state-sidecar.ts +6 -107
  220. package/src/gjc-runtime/state-migrations.ts +1 -1
  221. package/src/gjc-runtime/state-runtime.ts +320 -249
  222. package/src/gjc-runtime/state-writer.ts +36 -40
  223. package/src/gjc-runtime/team-launch.ts +319 -0
  224. package/src/gjc-runtime/team-notify.ts +326 -0
  225. package/src/gjc-runtime/team-runtime.ts +1099 -1482
  226. package/src/gjc-runtime/team-store.ts +828 -0
  227. package/src/gjc-runtime/team-workers.ts +962 -0
  228. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +2 -11
  229. package/src/gjc-runtime/tmux-owner-isolation.ts +14 -9
  230. package/src/gjc-runtime/tmux-sessions.ts +4 -21
  231. package/src/gjc-runtime/ultragoal-change-set.ts +172 -0
  232. package/src/gjc-runtime/ultragoal-evidence.ts +874 -0
  233. package/src/gjc-runtime/ultragoal-guard.ts +29 -5
  234. package/src/gjc-runtime/ultragoal-pipeline.ts +306 -0
  235. package/src/gjc-runtime/ultragoal-runtime.ts +84 -1311
  236. package/src/gjc-runtime/workflow-cli-common.ts +30 -0
  237. package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
  238. package/src/gjc-runtime/workflow-manifest.ts +18 -0
  239. package/src/goals/continuation-timeout-guard.ts +79 -0
  240. package/src/goals/runtime.ts +7 -5
  241. package/src/goals/state.ts +18 -0
  242. package/src/harness-control-plane/lifecycle-reconciliation.ts +64 -0
  243. package/src/harness-control-plane/phase-rollup.ts +4 -7
  244. package/src/harness-control-plane/receipt-ingest.ts +5 -0
  245. package/src/harness-control-plane/receipt-spool.ts +51 -7
  246. package/src/harness-control-plane/session-lease.ts +12 -130
  247. package/src/hindsight/backend.ts +26 -27
  248. package/src/hindsight/state.ts +101 -32
  249. package/src/hooks/skill-state.ts +17 -11
  250. package/src/internal-urls/docs-index.generated.ts +21 -18
  251. package/src/lsp/client.ts +1 -1
  252. package/src/lsp/config.ts +145 -26
  253. package/src/lsp/index.ts +1 -1
  254. package/src/lsp/lspmux.ts +39 -10
  255. package/src/lsp/path-trust.ts +81 -0
  256. package/src/main.ts +28 -8
  257. package/src/memories/index.ts +1 -0
  258. package/src/memories/storage.ts +6 -2
  259. package/src/modes/action-registry.ts +165 -0
  260. package/src/modes/components/chrome.ts +40 -0
  261. package/src/modes/components/command-palette.ts +168 -0
  262. package/src/modes/components/custom-editor.ts +1 -1
  263. package/src/modes/components/custom-provider-wizard.ts +51 -14
  264. package/src/modes/components/index.ts +2 -1
  265. package/src/modes/components/pet-selector.ts +11 -17
  266. package/src/modes/components/plan-preview-overlay.ts +295 -0
  267. package/src/modes/components/queue-mode-selector.ts +10 -27
  268. package/src/modes/components/queue-pane.ts +79 -0
  269. package/src/modes/components/read-tool-group.ts +13 -0
  270. package/src/modes/components/session-observer-overlay.ts +207 -772
  271. package/src/modes/components/sessions-dashboard.ts +181 -0
  272. package/src/modes/components/settings-selector.ts +1 -1
  273. package/src/modes/components/show-images-selector.ts +10 -24
  274. package/src/modes/components/status-line/types.ts +1 -1
  275. package/src/modes/components/status-line.ts +1 -969
  276. package/src/modes/components/tasks-pane.ts +73 -0
  277. package/src/modes/components/theme-selector.ts +11 -31
  278. package/src/modes/components/tool-execution.ts +22 -0
  279. package/src/modes/components/tool-status-header.ts +1056 -0
  280. package/src/modes/components/transcript-viewer-overlay.ts +384 -0
  281. package/src/modes/controllers/btw-controller.ts +1 -0
  282. package/src/modes/controllers/event-controller.ts +3 -2
  283. package/src/modes/controllers/extension-ui-controller.ts +24 -1
  284. package/src/modes/controllers/goal-mode-controller.ts +474 -0
  285. package/src/modes/controllers/input-controller.ts +764 -229
  286. package/src/modes/controllers/mode-gate.ts +24 -0
  287. package/src/modes/controllers/plan-mode-controller.ts +517 -0
  288. package/src/modes/controllers/selector-controller.ts +225 -10
  289. package/src/modes/controllers/stt-controller.ts +125 -0
  290. package/src/modes/interactive-mode.ts +289 -1150
  291. package/src/modes/jobs-observer.ts +3 -0
  292. package/src/modes/runtime-init.ts +8 -0
  293. package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -106
  294. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +71 -16
  295. package/src/modes/shared/agent-wire/workflow-gate-types.ts +177 -0
  296. package/src/modes/tasks-aggregator.ts +199 -0
  297. package/src/modes/transcript-item-registry.ts +229 -0
  298. package/src/modes/types.ts +45 -18
  299. package/src/modes/utils/ui-helpers.ts +17 -9
  300. package/src/prompts/agent-fragments/ralplan-persistence.md +6 -0
  301. package/src/prompts/agent-fragments/restricted-bash.md +1 -0
  302. package/src/prompts/agents/architect.md +2 -8
  303. package/src/prompts/agents/critic.md +2 -8
  304. package/src/prompts/agents/executor.md +3 -0
  305. package/src/prompts/agents/planner.md +4 -12
  306. package/src/prompts/system/custom-system-prompt.md +1 -23
  307. package/src/prompts/system/plan-mode-approved.md +8 -0
  308. package/src/prompts/system/system-prompt.md +17 -96
  309. package/src/prompts/tools/bash.md +4 -4
  310. package/src/prompts/tools/browser.md +15 -52
  311. package/src/prompts/tools/find.md +2 -2
  312. package/src/prompts/tools/hashline.md +13 -55
  313. package/src/prompts/tools/read.md +3 -6
  314. package/src/prompts/tools/search-tool-bm25.md +0 -2
  315. package/src/prompts/tools/search.md +2 -4
  316. package/src/prompts/tools/skill-discovery.md +1 -2
  317. package/src/prompts/tools/task.md +1 -1
  318. package/src/runtime-mcp/manager.ts +1 -1
  319. package/src/sdk/broker/broker.ts +206 -78
  320. package/src/sdk/broker/discovery.ts +1 -1
  321. package/src/sdk/broker/ensure.ts +3 -1
  322. package/src/sdk/broker/lifecycle-codec.ts +11 -0
  323. package/src/sdk/broker/lifecycle-ledger.ts +371 -74
  324. package/src/sdk/broker/lifecycle.ts +1399 -142
  325. package/src/sdk/broker/process-incarnation.ts +5 -13
  326. package/src/sdk/broker/session-index.ts +145 -56
  327. package/src/sdk/broker/state-version.ts +21 -0
  328. package/src/sdk/broker/transport.ts +1 -1
  329. package/src/sdk/bus/chat-daemon-cli.ts +2 -0
  330. package/src/sdk/bus/chat-daemon-control.ts +8 -28
  331. package/src/sdk/bus/chat-effect-journal.ts +10 -2
  332. package/src/sdk/bus/config-commands.ts +5 -4
  333. package/src/sdk/bus/config.ts +98 -0
  334. package/src/sdk/bus/conversation-store.ts +5 -29
  335. package/src/sdk/bus/daemon-paths.ts +2 -0
  336. package/src/sdk/bus/html-format.ts +20 -3
  337. package/src/sdk/bus/index.ts +778 -93
  338. package/src/sdk/bus/lifecycle-control-runtime.ts +91 -33
  339. package/src/sdk/bus/natives-augment.d.ts +26 -1
  340. package/src/sdk/bus/notification-service.ts +168 -20
  341. package/src/sdk/bus/rate-limit-pool.ts +58 -14
  342. package/src/sdk/bus/recent-activity.ts +188 -74
  343. package/src/sdk/bus/rich-document.ts +258 -0
  344. package/src/sdk/bus/rich-render.ts +62 -70
  345. package/src/sdk/bus/telegram-daemon-cli.ts +15 -59
  346. package/src/sdk/bus/telegram-daemon-contract.ts +9 -4
  347. package/src/sdk/bus/telegram-daemon.ts +1389 -237
  348. package/src/sdk/bus/threaded-inbound.ts +8 -3
  349. package/src/sdk/bus/threaded-render.ts +57 -1
  350. package/src/sdk/bus/topic-registry.ts +120 -13
  351. package/src/sdk/host/control/dispatch.ts +14 -2
  352. package/src/sdk/host/control/operations.ts +6 -1
  353. package/src/sdk/host/host.ts +31 -1
  354. package/src/sdk/host/query/handlers.ts +20 -22
  355. package/src/sdk/host/query/revision-store.ts +80 -57
  356. package/src/sdk/index.ts +1 -0
  357. package/src/sdk/protocol/operation-inventory.generated.json +943 -1907
  358. package/src/sdk/session-directory.ts +141 -0
  359. package/src/sdk/session.ts +458 -479
  360. package/src/session/agent-session.ts +1700 -583
  361. package/src/session/agent-storage.ts +18 -0
  362. package/src/session/cache-economics.ts +25 -8
  363. package/src/session/fallback-chain-controller.ts +53 -3
  364. package/src/session/internal/managed-session-scope.ts +2226 -0
  365. package/src/session/internal/managed-session-storage.ts +453 -0
  366. package/src/session/messages.ts +91 -2
  367. package/src/session/session-manager-internal.ts +33 -0
  368. package/src/session/session-manager.ts +1112 -333
  369. package/src/session/session-storage.ts +444 -39
  370. package/src/session/volatile-context-pruning.ts +78 -0
  371. package/src/setup/credential-auto-import.ts +2 -1
  372. package/src/setup/hermes/templates/operator-instructions.v1.md +1 -1
  373. package/src/setup/provider-onboarding.ts +36 -61
  374. package/src/setup/provider-presets.json +5 -0
  375. package/src/skill-state/active-state.ts +3 -13
  376. package/src/skill-state/{deep-interview-mutation-guard.ts → workflow-mutation-guard.ts} +47 -29
  377. package/src/skill-state/workflow-state-contract.ts +6 -41
  378. package/src/slash-commands/builtin-registry.ts +46 -11
  379. package/src/system-prompt.ts +37 -44
  380. package/src/task/agents.ts +19 -1
  381. package/src/task/executor.ts +90 -6
  382. package/src/task/index.ts +36 -21
  383. package/src/thinking.ts +21 -6
  384. package/src/tool-discovery/tool-index.ts +2 -134
  385. package/src/tools/ask.ts +157 -21
  386. package/src/tools/ast-edit.ts +1 -1
  387. package/src/tools/bash.ts +38 -0
  388. package/src/tools/browser/dead-tab-recovery.ts +78 -0
  389. package/src/tools/browser/tab-supervisor.ts +282 -36
  390. package/src/tools/computer.ts +10 -3
  391. package/src/tools/cron.ts +30 -16
  392. package/src/tools/eval.ts +1 -2
  393. package/src/tools/fetch.ts +11 -4
  394. package/src/tools/index.ts +76 -24
  395. package/src/tools/output-meta.ts +13 -5
  396. package/src/tools/read.ts +37 -1
  397. package/src/tools/resource-gc.ts +2 -2
  398. package/src/tools/search-tool-bm25.ts +11 -74
  399. package/src/utils/event-bus.ts +9 -4
  400. package/src/web/search/index.ts +3 -3
  401. package/dist/types/discovery/plugin-dir-roots.d.ts +0 -15
  402. package/dist/types/runtime-mcp/discoverable-tool-metadata.d.ts +0 -7
  403. package/src/discovery/plugin-dir-roots.ts +0 -28
  404. package/src/runtime-mcp/discoverable-tool-metadata.ts +0 -25
@@ -3,7 +3,9 @@ import { createHash, randomUUID } from "node:crypto";
3
3
  import * as fsSync from "node:fs";
4
4
  import * as fs from "node:fs/promises";
5
5
  import path from "node:path";
6
- import { getSessionsDir, resolveEquivalentPath } from "@gajae-code/utils";
6
+ import type { NativeExactUnlinkResult } from "@gajae-code/natives";
7
+ import * as native from "@gajae-code/natives";
8
+ import { resolveEquivalentPath } from "@gajae-code/utils";
7
9
 
8
10
  import {
9
11
  ensureLaunchWorktree,
@@ -11,18 +13,25 @@ import {
11
13
  type GjcLaunchWorktreePlan,
12
14
  planLaunchWorktree,
13
15
  } from "../../gjc-runtime/launch-worktree";
14
-
15
- import { SessionManager } from "../../session/session-manager";
16
+ import { validateManagedArtifactTree } from "../../session/internal/managed-session-storage";
16
17
  import {
17
18
  FileSessionStorage,
18
19
  SessionDeleteVerificationError,
20
+ type SessionStorageFileIdentity,
21
+ type SessionStorageSnapshot,
22
+ type VerifiedSessionDeleteResult,
19
23
  type VerifiedSessionDeleteTarget,
20
24
  } from "../../session/session-storage";
21
25
  import { SdkClient, SdkClientError } from "../client/client";
26
+ import {
27
+ type LogicalSessionCandidate,
28
+ listManagedSessionCandidates,
29
+ type ManagedSessionScope,
30
+ resolveManagedSessionScope,
31
+ } from "../session-directory";
22
32
  import type { SdkStartupFailure, SdkStartupRollbackResult } from "../startup-capability";
23
-
24
- import type { Broker, BrokerCleanupEvidence, BrokerResponse } from "./broker";
25
-
33
+ import type { Broker, BrokerCleanupEvidence, BrokerCleanupIdentity, BrokerResponse } from "./broker";
34
+ import { decodeLifecycleUtf8, parseLifecycleJson } from "./lifecycle-codec";
26
35
  import type {
27
36
  LifecycleCleanupProof,
28
37
  LifecycleDurableEffectsReceipt,
@@ -51,6 +60,9 @@ const MAX_READY_TIMEOUT_MS = 60_000;
51
60
  const POLL_MS = 50;
52
61
  const CLOSE_TIMEOUT_MS = 2_000;
53
62
  const MAX_RECEIVED_AT_SKEW_MS = 5_000;
63
+ const MAX_LIFECYCLE_METADATA_BYTES = 4096;
64
+ const MAX_EFFECT_MARKER_LENGTH = 128;
65
+ const MAX_PROCESS_INCARNATION_LENGTH = 256;
54
66
 
55
67
  export interface LifecycleDeadlines {
56
68
  receivedAt: number;
@@ -101,6 +113,13 @@ const lifecycleTimingsForTest = new WeakMap<Broker, LifecycleTiming>();
101
113
  type LifecycleCommand = SdkInternalSpawnCommand | { file: string; args: string[] };
102
114
  type LifecycleCommandResolver = () => LifecycleCommand;
103
115
  const lifecycleCommandResolversForTest = new WeakMap<Broker, LifecycleCommandResolver>();
116
+ const lifecycleCleanupHooksForTest = new WeakMap<Broker, () => void>();
117
+
118
+ /** Test-only hook for simulating a crash immediately after one exact lifecycle detach. */
119
+ export function setLifecycleCleanupHookForTest(broker: Broker, hook: (() => void) | undefined): void {
120
+ if (hook) lifecycleCleanupHooksForTest.set(broker, hook);
121
+ else lifecycleCleanupHooksForTest.delete(broker);
122
+ }
104
123
 
105
124
  export function setLifecycleCommandResolverForTest(
106
125
  broker: Broker,
@@ -172,6 +191,7 @@ export interface SessionLifecycleTranscriptIdentity {
172
191
  size: number;
173
192
  mtimeMs: number;
174
193
  mtimeNs: string;
194
+ sha256: string;
175
195
  }
176
196
 
177
197
  export interface SessionLifecycleLaunchRequest {
@@ -211,7 +231,9 @@ function isSessionLifecycleTranscriptIdentity(value: unknown): value is SessionL
211
231
  Number.isFinite(identity.mtimeMs) &&
212
232
  identity.mtimeMs >= 0 &&
213
233
  typeof identity.mtimeNs === "string" &&
214
- /^\d+$/.test(identity.mtimeNs)
234
+ /^\d+$/.test(identity.mtimeNs) &&
235
+ typeof identity.sha256 === "string" &&
236
+ /^[a-f0-9]{64}$/.test(identity.sha256)
215
237
  );
216
238
  }
217
239
 
@@ -286,6 +308,23 @@ type SessionLaunch = {
286
308
  };
287
309
 
288
310
  type CleanupEvidence = BrokerCleanupEvidence;
311
+ type CleanupIdentity = {
312
+ dev: bigint;
313
+ ino: bigint;
314
+ size: number;
315
+ mtimeNs: bigint;
316
+ sha256: string;
317
+ };
318
+
319
+ function serializeCleanupIdentity(identity: CleanupIdentity): BrokerCleanupIdentity {
320
+ return {
321
+ dev: identity.dev.toString(),
322
+ ino: identity.ino.toString(),
323
+ size: identity.size,
324
+ mtimeNs: identity.mtimeNs.toString(),
325
+ sha256: identity.sha256,
326
+ };
327
+ }
289
328
 
290
329
  const fail = (code: string, message: string, cleanup?: CleanupEvidence): BrokerResponse => ({
291
330
  ok: false,
@@ -384,6 +423,7 @@ type ResumeScope = {
384
423
  size: number;
385
424
  mtimeMs: number;
386
425
  mtimeNs: bigint;
426
+ sha256: string;
387
427
  };
388
428
  };
389
429
  function sameResumeLocator(record: LiveResumeRecord, cwd: string, root: string): boolean {
@@ -399,7 +439,8 @@ function sameResumeSessionIdentity(left: ResumeScope, right: ResumeScope): boole
399
439
  left.sessionIdentity.ino === right.sessionIdentity.ino &&
400
440
  left.sessionIdentity.size === right.sessionIdentity.size &&
401
441
  left.sessionIdentity.mtimeMs === right.sessionIdentity.mtimeMs &&
402
- left.sessionIdentity.mtimeNs === right.sessionIdentity.mtimeNs
442
+ left.sessionIdentity.mtimeNs === right.sessionIdentity.mtimeNs &&
443
+ left.sessionIdentity.sha256 === right.sessionIdentity.sha256
403
444
  );
404
445
  }
405
446
  function sameLiveResumeRecord(expected: LiveResumeRecord, current: LiveResumeRecord): boolean {
@@ -424,6 +465,7 @@ function serializeTranscriptIdentity(identity: {
424
465
  size: number;
425
466
  mtimeMs: number;
426
467
  mtimeNs: bigint;
468
+ sha256: string;
427
469
  }): SessionLifecycleTranscriptIdentity {
428
470
  return {
429
471
  dev: identity.dev.toString(),
@@ -431,32 +473,56 @@ function serializeTranscriptIdentity(identity: {
431
473
  size: identity.size,
432
474
  mtimeMs: identity.mtimeMs,
433
475
  mtimeNs: identity.mtimeNs.toString(),
476
+ sha256: identity.sha256,
434
477
  };
435
478
  }
479
+ async function managedCandidates(
480
+ broker: Broker,
481
+ cwd: string,
482
+ label: "Saved" | "Source",
483
+ ): Promise<
484
+ | {
485
+ candidates: readonly LogicalSessionCandidate[];
486
+ migrationPolicy: "copy-retain" | "disabled";
487
+ scope: ManagedSessionScope;
488
+ }
489
+ | BrokerResponse
490
+ > {
491
+ const resolved = await resolveManagedSessionScope({ cwd, agentDir: broker.settings.agentDir });
492
+ if (resolved.kind !== "resolved")
493
+ return fail("invalid_input", `${label} session scope is invalid: ${resolved.message}`);
494
+ const migration = await broker.settings.resolveDirectoryMigration(cwd);
495
+ if (migration !== "copy-retain" && migration !== "disabled")
496
+ return fail("invalid_input", "Broker directory migration policy is invalid.");
497
+ const listed = await listManagedSessionCandidates({ scope: resolved.scope });
498
+ if (listed.kind !== "complete")
499
+ return fail("invalid_input", `${label} session storage could not be verified for the requested workspace.`);
500
+ return { candidates: listed.owned, migrationPolicy: migration, scope: resolved.scope };
501
+ }
436
502
 
437
- function validateSavedTranscript(
503
+ async function validateSavedTranscript(
438
504
  broker: Broker,
439
505
  cwd: string,
440
506
  suppliedPath: string | undefined,
441
507
  expectedSessionId: string | undefined,
442
508
  label: "Saved" | "Source",
443
- ): ValidatedTranscript | BrokerResponse {
444
- const inventory = SessionManager.inventorySessionsStrict(cwd, {
445
- sessionDir: SessionManager.getDefaultSessionDir(cwd, broker.settings.agentDir),
446
- });
447
- if (inventory.kind !== "complete")
448
- return fail("invalid_input", `${label} session storage could not be verified for the requested workspace.`);
509
+ ): Promise<ValidatedTranscript | BrokerResponse> {
510
+ const inventory = await managedCandidates(broker, cwd, label);
511
+ if ("ok" in inventory) return inventory;
449
512
  const canonicalPath = suppliedPath ? path.resolve(suppliedPath) : undefined;
450
513
  const matches = inventory.candidates.filter(
451
514
  candidate =>
452
515
  (canonicalPath === undefined || candidate.path === canonicalPath) &&
453
- (expectedSessionId === undefined || candidate.id === expectedSessionId),
516
+ (expectedSessionId === undefined || candidate.sessionId === expectedSessionId),
454
517
  );
455
- if (matches.length !== 1 || !isCanonicalSessionId(matches[0]!.id))
518
+ if (matches.length !== 1 || !isCanonicalSessionId(matches[0]!.sessionId))
456
519
  return fail("invalid_input", `${label} saved session does not match the requested workspace and session id.`);
457
520
  const match = matches[0]!;
458
- return { path: match.path, id: match.id, identity: serializeTranscriptIdentity(match.identity) };
521
+ if (inventory.migrationPolicy === "disabled" && match.provenance === "legacy")
522
+ return fail("legacy_migration_disabled", `${label} legacy session migration is disabled for this workspace.`);
523
+ return { path: match.path, id: match.sessionId, identity: serializeTranscriptIdentity(match.identity) };
459
524
  }
525
+
460
526
  async function validateLiveResumeScope(
461
527
  broker: Broker,
462
528
  input: Input,
@@ -499,18 +565,18 @@ async function validateLiveResumeScope(
499
565
  return fail("endpoint_stale", "Live session does not match the requested resume scope.");
500
566
  const sessionPath = text(input.sessionPath);
501
567
  if (!sessionPath) return fail("invalid_input", "sessionPath is required to resume a saved session.");
502
- const inventory = SessionManager.inventorySessionsStrict(cwd, {
503
- sessionDir: SessionManager.getDefaultSessionDir(cwd, broker.settings.agentDir),
504
- });
505
- if (inventory.kind !== "complete")
568
+ const inventory = await managedCandidates(broker, cwd, "Saved");
569
+ if ("ok" in inventory)
506
570
  return fail("endpoint_stale", "Requested saved session could not be verified for the requested workspace.");
507
571
  const canonicalSessionPath = path.resolve(sessionPath);
508
572
  const matches = inventory.candidates.filter(
509
- candidate => candidate.id === requestedSessionId && candidate.path === canonicalSessionPath,
573
+ candidate => candidate.sessionId === requestedSessionId && candidate.path === canonicalSessionPath,
510
574
  );
511
575
  if (matches.length !== 1)
512
576
  return fail("endpoint_stale", "Requested saved session does not match the live session scope.");
513
577
  const session = matches[0]!;
578
+ if (inventory.migrationPolicy === "disabled" && matches[0]!.provenance === "legacy")
579
+ return fail("legacy_migration_disabled", "Saved legacy session migration is disabled for this workspace.");
514
580
  return {
515
581
  cwd,
516
582
  stateRoot: root,
@@ -609,15 +675,25 @@ function hasObservedProcessExit(pid: number): boolean {
609
675
 
610
676
  function isEffectMarker(value: unknown): value is EffectMarker {
611
677
  if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
612
- const marker = value as Partial<EffectMarker>;
678
+ const marker = value as Record<string, unknown>;
613
679
  return (
614
680
  typeof marker.pid === "number" &&
615
681
  Number.isSafeInteger(marker.pid) &&
616
682
  marker.pid > 0 &&
617
683
  typeof marker.effectMarker === "string" &&
618
- marker.effectMarker.length > 0 &&
684
+ /^[A-Za-z0-9._-]+$/.test(marker.effectMarker) &&
685
+ marker.effectMarker.length <= MAX_EFFECT_MARKER_LENGTH &&
619
686
  typeof marker.incarnation === "string" &&
620
- marker.incarnation.length > 0
687
+ marker.incarnation.length > 0 &&
688
+ marker.incarnation.length <= MAX_PROCESS_INCARNATION_LENGTH
689
+ );
690
+ }
691
+
692
+ function isExactEffectMarker(value: unknown): value is EffectMarker {
693
+ return (
694
+ isEffectMarker(value) &&
695
+ Object.keys(value).length === 3 &&
696
+ Object.keys(value).every(key => key === "pid" || key === "effectMarker" || key === "incarnation")
621
697
  );
622
698
  }
623
699
 
@@ -627,8 +703,10 @@ function sameEffectMarker(left: EffectMarker, right: EffectMarker): boolean {
627
703
 
628
704
  async function readEffectMarker(file: string): Promise<EffectMarker | undefined> {
629
705
  try {
630
- const marker: unknown = JSON.parse(await fs.readFile(file, "utf8"));
631
- return isEffectMarker(marker) ? marker : undefined;
706
+ const captured = captureLifecycleFile(file, true, true);
707
+ if (!captured) return undefined;
708
+ const marker: unknown = parseLifecycleJson(captured.bytes);
709
+ return isExactEffectMarker(marker) ? marker : undefined;
632
710
  } catch {
633
711
  return undefined;
634
712
  }
@@ -711,7 +789,8 @@ function isLifecycleTranscriptEvidence(value: unknown): value is LifecycleTransc
711
789
  Object.keys(record).length === 2 &&
712
790
  typeof record.digest === "string" &&
713
791
  /^[a-f0-9]{64}$/.test(record.digest) &&
714
- isSessionLifecycleTranscriptIdentity(record.identity)
792
+ isSessionLifecycleTranscriptIdentity(record.identity) &&
793
+ record.digest === record.identity.sha256
715
794
  );
716
795
  }
717
796
 
@@ -767,6 +846,10 @@ export async function writeSessionLifecycleFailure(
767
846
  ): Promise<void> {
768
847
  if (!isSdkStartupFailure(failure))
769
848
  throw new Error("Lifecycle startup failure does not satisfy the canonical failure contract.");
849
+ if (transcript && !isLifecycleTranscriptEvidence(transcript))
850
+ throw new Error(
851
+ "Lifecycle startup failure transcript evidence does not bind its content digest to its identity.",
852
+ );
770
853
 
771
854
  const incarnation = ownerIncarnation ?? processIncarnation(process.pid);
772
855
  if (!incarnation) return;
@@ -814,24 +897,43 @@ export async function writeSessionLifecycleFailure(
814
897
  async function readLifecycleFailureArtifact(
815
898
  file: string,
816
899
  expected: EffectMarker,
817
- ): Promise<{ artifact: LifecycleFailureArtifact; bytes: Buffer; digest: string } | undefined> {
900
+ ): Promise<
901
+ | {
902
+ artifact: LifecycleFailureArtifact;
903
+ bytes: Buffer;
904
+ digest: string;
905
+ identity: { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint; sha256: string };
906
+ }
907
+ | undefined
908
+ > {
818
909
  let handle: fs.FileHandle | undefined;
819
910
  try {
820
911
  handle = await fs.open(file, fsSync.constants.O_RDONLY | fsSync.constants.O_NOFOLLOW);
821
- const stat = await handle.stat();
822
- if (!stat.isFile() || stat.size > 4096) return undefined;
823
- const bytes = Buffer.alloc(stat.size + 1);
912
+ const stat = await handle.stat({ bigint: true });
913
+ if (!stat.isFile() || stat.size > 4096n) return undefined;
914
+ const bytes = Buffer.alloc(Number(stat.size) + 1);
824
915
  const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
825
916
  if (bytesRead > 4096) return undefined;
826
917
  const raw = bytes.subarray(0, bytesRead);
827
- const value: unknown = JSON.parse(raw.toString("utf8"));
918
+ const value: unknown = parseLifecycleJson(raw);
828
919
  if (
829
920
  !isLifecycleFailureArtifact(value) ||
830
921
  !sameEffectMarker(value, expected) ||
831
- canonicalJson(value) !== raw.toString("utf8")
922
+ canonicalJson(value) !== decodeLifecycleUtf8(raw)
832
923
  )
833
924
  return undefined;
834
- return { artifact: value, bytes: raw, digest: createHash("sha256").update(raw).digest("hex") };
925
+ return {
926
+ artifact: value,
927
+ bytes: raw,
928
+ digest: createHash("sha256").update(raw).digest("hex"),
929
+ identity: {
930
+ dev: stat.dev,
931
+ ino: stat.ino,
932
+ size: stat.size,
933
+ mtimeNs: stat.mtimeNs,
934
+ sha256: createHash("sha256").update(raw).digest("hex"),
935
+ },
936
+ };
835
937
  } catch {
836
938
  return undefined;
837
939
  } finally {
@@ -839,6 +941,721 @@ async function readLifecycleFailureArtifact(
839
941
  }
840
942
  }
841
943
 
944
+ function exactUnlinkLifecycleFile(
945
+ file: string,
946
+ identity: { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint; sha256: string },
947
+ plannedPath: string,
948
+ ): NativeExactUnlinkResult {
949
+ return native.exactUnlink(file, { ...identity, quarantineName: path.basename(plannedPath) });
950
+ }
951
+
952
+ type LifecycleCleanupFile = NonNullable<BrokerCleanupEvidence["lifecycleFiles"]>[number];
953
+
954
+ function sameLifecycleCleanupIdentity(
955
+ left: { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint; sha256: string },
956
+ right: BrokerCleanupIdentity,
957
+ ): boolean {
958
+ return (
959
+ left.dev.toString() === right.dev &&
960
+ left.ino.toString() === right.ino &&
961
+ left.size === BigInt(right.size) &&
962
+ left.mtimeNs.toString() === right.mtimeNs &&
963
+ left.sha256 === right.sha256
964
+ );
965
+ }
966
+
967
+ function lifecycleCleanupPlan(
968
+ root: string,
969
+ id: string,
970
+ expected: EffectMarker,
971
+ evidence: { identity: { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint; sha256: string } },
972
+ ): CleanupEvidence {
973
+ const directory = path.join(root, "sdk");
974
+ const candidates = [
975
+ lifecycleFailurePath(root, id, expected.effectMarker),
976
+ path.join(directory, `${id}.json`),
977
+ lifecycleReadyPath(root, id),
978
+ lifecycleMarkerPath(root, id),
979
+ ];
980
+ const files: LifecycleCleanupFile[] = candidates.flatMap(file => {
981
+ const captured = captureLifecycleFile(
982
+ file,
983
+ true,
984
+ file === lifecycleMarkerPath(root, id) || file === lifecycleReadyPath(root, id),
985
+ );
986
+
987
+ if (!captured) return [];
988
+ if (file === lifecycleMarkerPath(root, id) || file === lifecycleReadyPath(root, id)) {
989
+ let marker: unknown;
990
+ try {
991
+ marker = parseLifecycleJson(captured.bytes);
992
+ } catch {
993
+ throw new Error("Lifecycle marker changed before cleanup intent persistence.");
994
+ }
995
+ if (!isExactEffectMarker(marker) || !sameEffectMarker(marker, expected))
996
+ throw new Error("Lifecycle marker changed before cleanup intent persistence.");
997
+ }
998
+ if (file.endsWith(`${id}.json`)) {
999
+ let endpoint: { pid?: unknown };
1000
+ try {
1001
+ endpoint = parseLifecycleJson(captured.bytes) as { pid?: unknown };
1002
+ } catch {
1003
+ throw new Error("Lifecycle endpoint changed before cleanup intent persistence.");
1004
+ }
1005
+ if (endpoint.pid !== expected.pid)
1006
+ throw new Error("Lifecycle endpoint changed before cleanup intent persistence.");
1007
+ }
1008
+ const identity = file === candidates[0] ? evidence.identity : captured.identity;
1009
+ if (
1010
+ file === candidates[0] &&
1011
+ !sameLifecycleCleanupIdentity(
1012
+ captured.identity,
1013
+ serializeCleanupIdentity({ ...identity, size: Number(identity.size) }),
1014
+ )
1015
+ )
1016
+ throw new Error("Lifecycle failure artifact changed before cleanup intent persistence.");
1017
+ const attempt = 1;
1018
+ const suffix = randomUUID();
1019
+ return [
1020
+ {
1021
+ path: file,
1022
+ identity: serializeCleanupIdentity({ ...identity, size: Number(identity.size) }),
1023
+ attempt,
1024
+ plannedPath: path.join(directory, `.gjc-delete-${suffix}-${path.basename(file)}`),
1025
+ },
1026
+ ];
1027
+ });
1028
+ return { phase: "lifecycle", sessionId: id, metadataRoot: root, lifecycleFiles: files };
1029
+ }
1030
+
1031
+ function escapeRegExp(value: string): string {
1032
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1033
+ }
1034
+
1035
+ function isCanonicalLifecycleCleanupOriginal(root: string, id: string, original: string): boolean {
1036
+ const directory = path.join(path.resolve(root), "sdk");
1037
+ if (path.dirname(original) !== directory) return false;
1038
+ const basename = path.basename(original);
1039
+ return (
1040
+ basename === `${id}.json` ||
1041
+ basename === `${id}.lifecycle.json` ||
1042
+ basename === `${id}.lifecycle.ready.json` ||
1043
+ new RegExp(`^${escapeRegExp(id)}\\.lifecycle\\.failure\\.[A-Za-z0-9._-]{1,128}\\.json$`).test(basename)
1044
+ );
1045
+ }
1046
+
1047
+ function lifecycleCleanupHasMixedMetadataSchema(cleanup: CleanupEvidence): boolean {
1048
+ return [
1049
+ cleanup.metadataIdentity,
1050
+ cleanup.metadataPath,
1051
+ cleanup.metadataAttempt,
1052
+ cleanup.plannedMetadataPath,
1053
+ cleanup.detachedMetadataPath,
1054
+ cleanup.metadataCompleted,
1055
+ ].some(value => value !== undefined);
1056
+ }
1057
+
1058
+ function isCleanupIdentity(value: unknown): value is BrokerCleanupIdentity {
1059
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
1060
+ const identity = value as Record<string, unknown>;
1061
+ return (
1062
+ Object.keys(identity).length === 5 &&
1063
+ Object.keys(identity).every(
1064
+ key => key === "dev" || key === "ino" || key === "size" || key === "mtimeNs" || key === "sha256",
1065
+ ) &&
1066
+ typeof identity.dev === "string" &&
1067
+ /^\d+$/.test(identity.dev) &&
1068
+ typeof identity.ino === "string" &&
1069
+ /^\d+$/.test(identity.ino) &&
1070
+ typeof identity.size === "number" &&
1071
+ Number.isSafeInteger(identity.size) &&
1072
+ identity.size >= 0 &&
1073
+ typeof identity.mtimeNs === "string" &&
1074
+ /^\d+$/.test(identity.mtimeNs) &&
1075
+ typeof identity.sha256 === "string" &&
1076
+ /^[a-f0-9]{64}$/.test(identity.sha256)
1077
+ );
1078
+ }
1079
+
1080
+ function isLifecycleCleanupFile(value: unknown): value is LifecycleCleanupFile {
1081
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
1082
+ const file = value as Record<string, unknown>;
1083
+ const allowed = new Set(["path", "identity", "attempt", "plannedPath", "detachedPath", "completed"]);
1084
+ return (
1085
+ Object.keys(file).every(key => allowed.has(key)) &&
1086
+ typeof file.path === "string" &&
1087
+ file.path.length > 0 &&
1088
+ typeof file.plannedPath === "string" &&
1089
+ file.plannedPath.length > 0 &&
1090
+ isCleanupIdentity(file.identity) &&
1091
+ typeof file.attempt === "number" &&
1092
+ Number.isSafeInteger(file.attempt) &&
1093
+ file.attempt > 0 &&
1094
+ (file.detachedPath === undefined || (typeof file.detachedPath === "string" && file.detachedPath.length > 0)) &&
1095
+ (file.completed === undefined || file.completed === true)
1096
+ );
1097
+ }
1098
+
1099
+ function isLifecycleCleanupEvidence(cleanup: CleanupEvidence): boolean {
1100
+ if (typeof cleanup !== "object" || cleanup === null || Array.isArray(cleanup)) return false;
1101
+ const record = cleanup as Record<string, unknown>;
1102
+ const allowed = new Set(["phase", "sessionId", "metadataRoot", "lifecycleDeleteMetadata", "lifecycleFiles"]);
1103
+ return (
1104
+ Object.keys(record).every(key => allowed.has(key)) &&
1105
+ record.phase === "lifecycle" &&
1106
+ typeof record.sessionId === "string" &&
1107
+ isCanonicalSessionId(record.sessionId) &&
1108
+ typeof record.metadataRoot === "string" &&
1109
+ record.metadataRoot.length > 0 &&
1110
+ Array.isArray(record.lifecycleFiles) &&
1111
+ record.lifecycleFiles.length > 0 &&
1112
+ record.lifecycleFiles.length <= 4 &&
1113
+ (record.lifecycleDeleteMetadata === undefined || record.lifecycleDeleteMetadata === true) &&
1114
+ record.lifecycleFiles.every(isLifecycleCleanupFile)
1115
+ );
1116
+ }
1117
+
1118
+ function validateLifecycleCleanupShape(cleanup: CleanupEvidence): BrokerResponse | undefined {
1119
+ if (
1120
+ !isLifecycleCleanupEvidence(cleanup) ||
1121
+ lifecycleCleanupHasMixedMetadataSchema(cleanup) ||
1122
+ (cleanup.lifecycleDeleteMetadata === true && cleanup.lifecycleFiles!.length > 2)
1123
+ )
1124
+ return fail("terminal_uncertain", "Lifecycle cleanup replay lacks a complete unambiguous schema.");
1125
+ const files = cleanup.lifecycleFiles!;
1126
+ const paths = new Set<string>();
1127
+ for (const file of files) {
1128
+ if (!validateLifecycleCleanupFile(cleanup.metadataRoot!, cleanup.sessionId!, file))
1129
+ return fail("terminal_uncertain", "Lifecycle cleanup replay contains an invalid path authority.");
1130
+ const entryPaths = new Map<string, "path" | "plannedPath" | "detachedPath">();
1131
+ for (const [field, candidate] of [
1132
+ ["path", file.path],
1133
+ ["plannedPath", file.plannedPath],
1134
+ ["detachedPath", file.detachedPath],
1135
+ ] as const) {
1136
+ if (candidate === undefined) continue;
1137
+ const resolved = path.resolve(candidate);
1138
+ const previousField = entryPaths.get(resolved);
1139
+ if (previousField !== undefined) {
1140
+ if (
1141
+ (previousField === "plannedPath" && field === "detachedPath") ||
1142
+ (previousField === "detachedPath" && field === "plannedPath")
1143
+ )
1144
+ continue;
1145
+ return fail("terminal_uncertain", "Lifecycle cleanup replay contains duplicate path authority.");
1146
+ }
1147
+ entryPaths.set(resolved, field);
1148
+ if (paths.has(resolved))
1149
+ return fail("terminal_uncertain", "Lifecycle cleanup replay contains duplicate path authority.");
1150
+ paths.add(resolved);
1151
+ }
1152
+ }
1153
+ return undefined;
1154
+ }
1155
+
1156
+ function validateLifecycleCleanupFile(root: string, id: string, file: LifecycleCleanupFile): boolean {
1157
+ const directory = path.join(path.resolve(root), "sdk");
1158
+ const original = path.resolve(file.path);
1159
+ const planned = path.resolve(file.plannedPath);
1160
+ if (
1161
+ !isCanonicalLifecycleCleanupOriginal(root, id, original) ||
1162
+ path.dirname(planned) !== directory ||
1163
+ !path.basename(planned).startsWith(".gjc-delete-") ||
1164
+ (file.detachedPath !== undefined && path.dirname(path.resolve(file.detachedPath)) !== directory)
1165
+ )
1166
+ return false;
1167
+ return isCleanupIdentity(file.identity);
1168
+ }
1169
+
1170
+ function lifecycleCleanupCandidates(file: LifecycleCleanupFile): string[] {
1171
+ return [file.path, file.detachedPath, file.plannedPath].filter(
1172
+ (value, index, values): value is string => typeof value === "string" && values.indexOf(value) === index,
1173
+ );
1174
+ }
1175
+
1176
+ function lifecycleMetadataReplayFiles(cleanup: CleanupEvidence): LifecycleCleanupFile[] | undefined {
1177
+ if (cleanup.lifecycleDeleteMetadata !== true) return undefined;
1178
+ return cleanup.lifecycleFiles?.length ? cleanup.lifecycleFiles : undefined;
1179
+ }
1180
+
1181
+ function absentLifecyclePath(file: string): boolean {
1182
+ try {
1183
+ fsSync.lstatSync(file);
1184
+ return false;
1185
+ } catch (error) {
1186
+ return (error as NodeJS.ErrnoException).code === "ENOENT";
1187
+ }
1188
+ }
1189
+
1190
+ function isLifecycleCleanupResponse(value: LifecycleFileCapture | BrokerResponse | undefined): value is BrokerResponse {
1191
+ return typeof value === "object" && value !== null && "ok" in value;
1192
+ }
1193
+
1194
+ function validateLifecycleMetadataReplay(cleanup: CleanupEvidence): BrokerResponse | undefined {
1195
+ const files = lifecycleMetadataReplayFiles(cleanup);
1196
+ if (!files) return undefined;
1197
+ const root = path.resolve(cleanup.metadataRoot!);
1198
+ const id = cleanup.sessionId!;
1199
+ const markerPath = lifecycleMarkerPath(root, id);
1200
+ const readyPath = lifecycleReadyPath(root, id);
1201
+ const authorities = new Set<string>();
1202
+ const candidates = new Set<string>();
1203
+ for (const file of files) {
1204
+ const authority = path.resolve(file.path);
1205
+ if ((authority !== markerPath && authority !== readyPath) || authorities.has(authority))
1206
+ return fail("terminal_uncertain", "Lifecycle metadata replay contains duplicate or non-canonical authority.");
1207
+ authorities.add(authority);
1208
+ for (const candidate of lifecycleCleanupCandidates(file)) {
1209
+ const resolved = path.resolve(candidate);
1210
+ if (candidates.has(resolved))
1211
+ return fail("terminal_uncertain", "Lifecycle metadata replay contains duplicate candidate authority.");
1212
+ candidates.add(resolved);
1213
+ }
1214
+ }
1215
+ const markerEntry = files.find(file => path.resolve(file.path) === markerPath);
1216
+ const readyEntry = files.find(file => path.resolve(file.path) === readyPath);
1217
+ const capture = (file: string): LifecycleFileCapture | undefined | BrokerResponse => {
1218
+ try {
1219
+ return captureLifecycleFile(file, true, true);
1220
+ } catch {
1221
+ return fail("terminal_uncertain", "Lifecycle metadata sibling could not be safely inspected.");
1222
+ }
1223
+ };
1224
+ const marker = capture(markerPath);
1225
+ const ready = capture(readyPath);
1226
+ if (isLifecycleCleanupResponse(marker)) return marker;
1227
+ if (isLifecycleCleanupResponse(ready)) return ready;
1228
+ if (marker && (!markerEntry || !sameLifecycleCleanupIdentity(marker.identity, markerEntry.identity)))
1229
+ return fail("terminal_uncertain", "Lifecycle marker sibling lacks exact replay authority.");
1230
+ if (ready && (!readyEntry || !sameLifecycleCleanupIdentity(ready.identity, readyEntry.identity)))
1231
+ return fail("terminal_uncertain", "Lifecycle readiness sibling lacks exact replay authority.");
1232
+ for (const file of files) {
1233
+ let activeCandidates = 0;
1234
+ for (const candidate of lifecycleCleanupCandidates(file)) {
1235
+ let current: LifecycleFileCapture | undefined;
1236
+ try {
1237
+ current = captureLifecycleFile(candidate, true, true);
1238
+ } catch {
1239
+ return fail("terminal_uncertain", "Lifecycle metadata candidate could not be safely inspected.");
1240
+ }
1241
+ if (!current) continue;
1242
+ if (!sameLifecycleCleanupIdentity(current.identity, file.identity))
1243
+ return fail("terminal_uncertain", "Lifecycle metadata candidate lacks exact replay authority.");
1244
+ activeCandidates++;
1245
+ }
1246
+ if (file.completed && activeCandidates > 0)
1247
+ return fail(
1248
+ "terminal_uncertain",
1249
+ "Lifecycle cleanup receipt marks a metadata target complete while a candidate remains.",
1250
+ );
1251
+ if (!file.completed && activeCandidates > 1)
1252
+ return fail("terminal_uncertain", "Lifecycle metadata replay has multiple active candidates.");
1253
+ }
1254
+ if (ready && !markerEntry)
1255
+ return fail("terminal_uncertain", "Lifecycle readiness metadata lacks canonical marker authority.");
1256
+ if (!ready) return undefined;
1257
+ let readyMarker: EffectMarker;
1258
+ try {
1259
+ const value: unknown = parseLifecycleJson(ready.bytes);
1260
+ if (!isExactEffectMarker(value)) throw new Error("invalid ready marker");
1261
+
1262
+ readyMarker = value;
1263
+ } catch {
1264
+ return fail("terminal_uncertain", "Lifecycle readiness metadata ownership could not be verified.");
1265
+ }
1266
+ if (!markerEntry)
1267
+ return fail("terminal_uncertain", "Lifecycle readiness metadata lacks canonical marker authority.");
1268
+ if (!marker) {
1269
+ if (createHash("sha256").update(canonicalJson(readyMarker)).digest("hex") !== markerEntry.identity.sha256)
1270
+ return fail(
1271
+ "terminal_uncertain",
1272
+ "Lifecycle readiness metadata is not bound to the completed marker authority.",
1273
+ );
1274
+ return undefined;
1275
+ }
1276
+ try {
1277
+ const value: unknown = parseLifecycleJson(marker.bytes);
1278
+ if (!isExactEffectMarker(value) || !sameEffectMarker(value, readyMarker)) throw new Error("mismatched marker");
1279
+ } catch {
1280
+ return fail("terminal_uncertain", "Lifecycle metadata siblings do not share one owner marker.");
1281
+ }
1282
+ return undefined;
1283
+ }
1284
+
1285
+ function lifecycleMetadataReplayAbsent(cleanup: CleanupEvidence): boolean {
1286
+ const files = lifecycleMetadataReplayFiles(cleanup);
1287
+ if (!files) return true;
1288
+ const root = path.resolve(cleanup.metadataRoot!);
1289
+ const id = cleanup.sessionId!;
1290
+ const required = new Set<string>([lifecycleMarkerPath(root, id), lifecycleReadyPath(root, id)]);
1291
+ for (const file of files) for (const candidate of lifecycleCleanupCandidates(file)) required.add(candidate);
1292
+ return [...required].every(absentLifecyclePath);
1293
+ }
1294
+
1295
+ /**
1296
+ * Base dev persisted metadata cleanup one file at a time. Accept only its
1297
+ * identity-bound marker receipt and translate it into the current replay plan.
1298
+ */
1299
+ function hasExactLegacyMetadataCleanupKeys(cleanup: CleanupEvidence): boolean {
1300
+ if (typeof cleanup !== "object" || cleanup === null || Array.isArray(cleanup)) return false;
1301
+ const allowed = new Set([
1302
+ "phase",
1303
+ "sessionId",
1304
+ "metadataRoot",
1305
+ "metadataIdentity",
1306
+ "metadataPath",
1307
+ "metadataAttempt",
1308
+ "plannedMetadataPath",
1309
+ "detachedMetadataPath",
1310
+ "metadataCompleted",
1311
+ ]);
1312
+ return Object.keys(cleanup as Record<string, unknown>).every(key => allowed.has(key));
1313
+ }
1314
+
1315
+ function legacyMetadataCleanupPlan(cleanup: CleanupEvidence): CleanupEvidence | undefined {
1316
+ if (
1317
+ !hasExactLegacyMetadataCleanupKeys(cleanup) ||
1318
+ cleanup.phase !== "metadata" ||
1319
+ typeof cleanup.sessionId !== "string" ||
1320
+ !isCanonicalSessionId(cleanup.sessionId) ||
1321
+ typeof cleanup.metadataRoot !== "string" ||
1322
+ cleanup.metadataRoot.length === 0 ||
1323
+ typeof cleanup.metadataPath !== "string" ||
1324
+ cleanup.metadataPath.length === 0 ||
1325
+ !cleanup.metadataIdentity ||
1326
+ typeof cleanup.plannedMetadataPath !== "string" ||
1327
+ cleanup.plannedMetadataPath.length === 0 ||
1328
+ (cleanup.detachedMetadataPath !== undefined &&
1329
+ (typeof cleanup.detachedMetadataPath !== "string" || cleanup.detachedMetadataPath.length === 0)) ||
1330
+ (cleanup.metadataCompleted !== undefined && cleanup.metadataCompleted !== true)
1331
+ )
1332
+ return undefined;
1333
+ const root = path.resolve(cleanup.metadataRoot);
1334
+ const directory = path.join(root, "sdk");
1335
+ const markerPath = lifecycleMarkerPath(root, cleanup.sessionId);
1336
+ const readyPath = lifecycleReadyPath(root, cleanup.sessionId);
1337
+ const metadataPath = path.resolve(cleanup.metadataPath);
1338
+ const plannedPath = path.resolve(cleanup.plannedMetadataPath);
1339
+ const detachedPath = cleanup.detachedMetadataPath && path.resolve(cleanup.detachedMetadataPath);
1340
+ if (
1341
+ metadataPath !== markerPath ||
1342
+ path.dirname(plannedPath) !== directory ||
1343
+ !path.basename(plannedPath).startsWith(".gjc-delete-") ||
1344
+ (detachedPath !== undefined && path.dirname(detachedPath) !== directory) ||
1345
+ (cleanup.metadataAttempt !== undefined &&
1346
+ (!Number.isSafeInteger(cleanup.metadataAttempt) || cleanup.metadataAttempt < 1))
1347
+ )
1348
+ return undefined;
1349
+ const persistedIdentity = cleanupIdentity(cleanup.metadataIdentity);
1350
+ if (!persistedIdentity) return undefined;
1351
+
1352
+ const captureExactRegular = (
1353
+ file: string,
1354
+ ): { kind: "absent" } | { kind: "present"; capture: LifecycleFileCapture } | undefined => {
1355
+ try {
1356
+ const stat = fsSync.lstatSync(file);
1357
+ if (stat.isSymbolicLink() || !stat.isFile()) return undefined;
1358
+ const capture = captureLifecycleFile(file, true, true);
1359
+
1360
+ return capture ? { kind: "present", capture } : undefined;
1361
+ } catch (error) {
1362
+ return (error as NodeJS.ErrnoException).code === "ENOENT" ? { kind: "absent" } : undefined;
1363
+ }
1364
+ };
1365
+
1366
+ const markerCandidates = [metadataPath, detachedPath, plannedPath].filter(
1367
+ (candidate, index, candidates): candidate is string =>
1368
+ typeof candidate === "string" && candidates.indexOf(candidate) === index,
1369
+ );
1370
+ let activeMarker: LifecycleFileCapture | undefined;
1371
+ for (const candidate of markerCandidates) {
1372
+ const current = captureExactRegular(candidate);
1373
+ if (!current) return undefined;
1374
+ if (current.kind === "absent") continue;
1375
+ if (!sameLifecycleCleanupIdentity(current.capture.identity, serializeCleanupIdentity(persistedIdentity)))
1376
+ return undefined;
1377
+ if (activeMarker) return undefined;
1378
+ activeMarker = current.capture;
1379
+ }
1380
+ const markerCompleted = !activeMarker;
1381
+ // Legacy receipts can crash after exact marker unlink and before recording metadataCompleted.
1382
+ // With every authorized marker candidate absent, only the persisted marker digest may bind a ready sibling.
1383
+ let marker: EffectMarker | undefined;
1384
+ if (activeMarker) {
1385
+ try {
1386
+ const value: unknown = parseLifecycleJson(activeMarker.bytes);
1387
+ if (!isExactEffectMarker(value)) return undefined;
1388
+
1389
+ marker = value;
1390
+ } catch {
1391
+ return undefined;
1392
+ }
1393
+ }
1394
+
1395
+ const ready = captureExactRegular(readyPath);
1396
+ if (!ready) return undefined;
1397
+ let readyMarker: EffectMarker | undefined;
1398
+ if (ready.kind === "present") {
1399
+ try {
1400
+ const value: unknown = parseLifecycleJson(ready.capture.bytes);
1401
+ if (!isExactEffectMarker(value)) return undefined;
1402
+
1403
+ readyMarker = value;
1404
+ } catch {
1405
+ return undefined;
1406
+ }
1407
+ if (marker && !sameEffectMarker(marker, readyMarker)) return undefined;
1408
+ if (!marker && createHash("sha256").update(canonicalJson(readyMarker)).digest("hex") !== persistedIdentity.sha256)
1409
+ return undefined;
1410
+ }
1411
+
1412
+ return {
1413
+ phase: "lifecycle",
1414
+ sessionId: cleanup.sessionId,
1415
+ metadataRoot: root,
1416
+ lifecycleDeleteMetadata: true,
1417
+ lifecycleFiles: [
1418
+ {
1419
+ path: metadataPath,
1420
+ identity: serializeCleanupIdentity({
1421
+ ...(activeMarker?.identity ?? persistedIdentity),
1422
+ size: Number((activeMarker?.identity ?? persistedIdentity).size),
1423
+ }),
1424
+ attempt: cleanup.metadataAttempt ?? 1,
1425
+ plannedPath,
1426
+ ...(detachedPath ? { detachedPath } : {}),
1427
+ ...(markerCompleted ? { completed: true as const } : {}),
1428
+ },
1429
+ ...(ready.kind === "present"
1430
+ ? [
1431
+ {
1432
+ path: readyPath,
1433
+ identity: serializeCleanupIdentity({
1434
+ ...ready.capture.identity,
1435
+ size: Number(ready.capture.identity.size),
1436
+ }),
1437
+ attempt: 1,
1438
+ plannedPath: path.join(directory, `.gjc-delete-${randomUUID()}-${path.basename(readyPath)}`),
1439
+ },
1440
+ ]
1441
+ : []),
1442
+ ],
1443
+ };
1444
+ }
1445
+
1446
+ function lifecycleDeleteMetadataCleanupPlan(
1447
+ metadataRoot: string,
1448
+ id: string,
1449
+ files: ReadonlyArray<{ metadataPath: string; metadata: LifecycleFileCapture }>,
1450
+ ): CleanupEvidence {
1451
+ return {
1452
+ phase: "lifecycle",
1453
+ sessionId: id,
1454
+ metadataRoot,
1455
+ lifecycleDeleteMetadata: true,
1456
+ lifecycleFiles: files.map(({ metadataPath, metadata }) => ({
1457
+ path: metadataPath,
1458
+ identity: serializeCleanupIdentity({
1459
+ dev: metadata.identity.dev,
1460
+ ino: metadata.identity.ino,
1461
+ size: Number(metadata.identity.size),
1462
+ mtimeNs: metadata.identity.mtimeNs,
1463
+ sha256: metadata.identity.sha256,
1464
+ }),
1465
+ attempt: 1,
1466
+ plannedPath: path.join(
1467
+ path.dirname(metadataPath),
1468
+ `.gjc-delete-${randomUUID()}-${path.basename(metadataPath)}`,
1469
+ ),
1470
+ })),
1471
+ };
1472
+ }
1473
+
1474
+ type LifecycleDeleteMetadataPreflight = { cleanup: CleanupEvidence } | BrokerResponse;
1475
+
1476
+ /**
1477
+ * Capture lifecycle metadata before deleting any saved user data. A fresh delete
1478
+ * may only clean metadata owned by one dead lifecycle process.
1479
+ */
1480
+ function preflightLifecycleDeleteMetadata(
1481
+ root: string,
1482
+ id: string,
1483
+ record: { pid: number } | undefined,
1484
+ readIncarnation: (pid: number) => string | undefined,
1485
+ ): LifecycleDeleteMetadataPreflight {
1486
+ const metadataPaths = [lifecycleMarkerPath(root, id), lifecycleReadyPath(root, id)];
1487
+ const lifecycleMetadata: Array<{
1488
+ metadataPath: string;
1489
+ metadata: LifecycleFileCapture;
1490
+ marker: EffectMarker;
1491
+ }> = [];
1492
+ for (const metadataPath of metadataPaths) {
1493
+ let metadata: LifecycleFileCapture | undefined;
1494
+ try {
1495
+ metadata = captureLifecycleFile(metadataPath, true, true);
1496
+ } catch {
1497
+ return fail("terminal_uncertain", "Lifecycle metadata path is occupied by an unsafe object.");
1498
+ }
1499
+ if (!metadata) continue;
1500
+ let marker: unknown;
1501
+ try {
1502
+ marker = parseLifecycleJson(metadata.bytes);
1503
+ } catch {
1504
+ return fail("terminal_uncertain", "Lifecycle metadata ownership could not be verified.");
1505
+ }
1506
+ if (
1507
+ !isExactEffectMarker(marker) ||
1508
+ (record && marker.pid !== record.pid) ||
1509
+ observeProcess(marker.pid, marker.incarnation, readIncarnation) !== "exited"
1510
+ )
1511
+ return fail("terminal_uncertain", "Lifecycle metadata ownership could not be verified.");
1512
+ lifecycleMetadata.push({ metadataPath, metadata, marker });
1513
+ }
1514
+ const canonicalLifecycleMarker = lifecycleMetadata.find(
1515
+ metadata => metadata.metadataPath === lifecycleMarkerPath(root, id),
1516
+ );
1517
+ const lifecycleReadyMarker = lifecycleMetadata.find(
1518
+ metadata => metadata.metadataPath === lifecycleReadyPath(root, id),
1519
+ );
1520
+ if (lifecycleReadyMarker && !canonicalLifecycleMarker)
1521
+ return fail(
1522
+ "terminal_uncertain",
1523
+ "Lifecycle readiness metadata lacks canonical marker authority for fresh cleanup.",
1524
+ );
1525
+ if (
1526
+ canonicalLifecycleMarker &&
1527
+ lifecycleReadyMarker &&
1528
+ !sameEffectMarker(canonicalLifecycleMarker.marker, lifecycleReadyMarker.marker)
1529
+ )
1530
+ return fail("terminal_uncertain", "Lifecycle metadata siblings do not share one owner marker.");
1531
+ return { cleanup: lifecycleDeleteMetadataCleanupPlan(root, id, lifecycleMetadata) };
1532
+ }
1533
+
1534
+ async function reconcileLifecycleCleanup(
1535
+ broker: Broker,
1536
+ identity: string,
1537
+ cleanup: CleanupEvidence,
1538
+ completion: BrokerResponse = fail("spawn_failed", "No ready SDK endpoint remains available."),
1539
+ ): Promise<BrokerResponse> {
1540
+ const shapeValidation = validateLifecycleCleanupShape(cleanup);
1541
+ if (shapeValidation) return shapeValidation;
1542
+ let activeCleanup =
1543
+ cleanup.lifecycleDeleteMetadata === true || completion.ok
1544
+ ? { ...cleanup, lifecycleDeleteMetadata: true as const }
1545
+ : cleanup;
1546
+ const metadataReplayValidation = validateLifecycleMetadataReplay(activeCleanup);
1547
+ if (metadataReplayValidation) return metadataReplayValidation;
1548
+ for (let index = 0; index < activeCleanup.lifecycleFiles!.length; index++) {
1549
+ const file = activeCleanup.lifecycleFiles![index];
1550
+ if (!validateLifecycleCleanupFile(activeCleanup.metadataRoot!, activeCleanup.sessionId!, file))
1551
+ return fail("terminal_uncertain", "Lifecycle cleanup replay contains an invalid path authority.");
1552
+ const candidates = lifecycleCleanupCandidates(file);
1553
+ if (file.completed) {
1554
+ for (const candidate of candidates) {
1555
+ try {
1556
+ fsSync.lstatSync(candidate);
1557
+ return fail(
1558
+ "terminal_uncertain",
1559
+ "Lifecycle cleanup receipt marks a target complete while an authorized candidate remains.",
1560
+ );
1561
+ } catch (error) {
1562
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT")
1563
+ return fail("terminal_uncertain", "Lifecycle cleanup completion could not be safely inspected.");
1564
+ }
1565
+ }
1566
+ continue;
1567
+ }
1568
+ let activePath: string | undefined;
1569
+ let captured: LifecycleFileCapture | undefined;
1570
+ let foundUnauthorized = false;
1571
+ for (const candidate of candidates) {
1572
+ try {
1573
+ const stat = fsSync.lstatSync(candidate);
1574
+ if (stat.isSymbolicLink() || !stat.isFile()) {
1575
+ foundUnauthorized = true;
1576
+ continue;
1577
+ }
1578
+ } catch (error) {
1579
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
1580
+ foundUnauthorized = true;
1581
+ continue;
1582
+ }
1583
+ const current = captureLifecycleFile(candidate, true, true);
1584
+
1585
+ if (!current) {
1586
+ foundUnauthorized = true;
1587
+ continue;
1588
+ }
1589
+ if (sameLifecycleCleanupIdentity(current.identity, file.identity) && !activePath) {
1590
+ activePath = candidate;
1591
+ captured = current;
1592
+ } else {
1593
+ foundUnauthorized = true;
1594
+ }
1595
+ }
1596
+ if (foundUnauthorized)
1597
+ return fail("terminal_uncertain", "Lifecycle cleanup target identity changed before reconciliation.");
1598
+ if (!captured || !activePath) {
1599
+ const lifecycleFiles = activeCleanup.lifecycleFiles!.map((candidate, candidateIndex) =>
1600
+ candidateIndex === index ? { ...candidate, completed: true as const } : candidate,
1601
+ );
1602
+ activeCleanup = { ...activeCleanup, lifecycleFiles };
1603
+ await broker.ledger.transition(identity, "effect_started", {
1604
+ response: fail("cleanup_pending", "Lifecycle cleanup completion was durably reconciled.", activeCleanup),
1605
+ });
1606
+ continue;
1607
+ }
1608
+
1609
+ if (file.detachedPath || activePath === file.plannedPath) {
1610
+ const nextFile: LifecycleCleanupFile = {
1611
+ ...file,
1612
+ detachedPath: activePath,
1613
+ attempt: (file.attempt ?? 1) + 1,
1614
+ plannedPath: path.join(path.dirname(file.path), `.gjc-delete-${randomUUID()}-${path.basename(file.path)}`),
1615
+ };
1616
+ const lifecycleFiles = activeCleanup.lifecycleFiles!.map((candidate, candidateIndex) =>
1617
+ candidateIndex === index ? nextFile : candidate,
1618
+ );
1619
+ activeCleanup = { ...activeCleanup, lifecycleFiles };
1620
+ await broker.ledger.transition(identity, "effect_started", {
1621
+ response: fail(
1622
+ "cleanup_pending",
1623
+ "Lifecycle retry cleanup is preauthorized for durable reconciliation.",
1624
+ activeCleanup,
1625
+ ),
1626
+ });
1627
+ }
1628
+ const currentFile = activeCleanup.lifecycleFiles![index];
1629
+ const result = native.exactUnlink(activePath, {
1630
+ ...captured.identity,
1631
+ quarantineName: path.basename(currentFile.plannedPath),
1632
+ });
1633
+ if (!result.ok) {
1634
+ const lifecycleFiles = activeCleanup.lifecycleFiles!.map((candidate, candidateIndex) =>
1635
+ candidateIndex === index && result.detachedPath
1636
+ ? { ...candidate, detachedPath: result.detachedPath }
1637
+ : candidate,
1638
+ );
1639
+ return fail("cleanup_pending", `Lifecycle cleanup remains pending: ${result.code ?? "unknown"}`, {
1640
+ ...activeCleanup,
1641
+ lifecycleFiles,
1642
+ });
1643
+ }
1644
+ const lifecycleFiles = activeCleanup.lifecycleFiles!.map((candidate, candidateIndex) =>
1645
+ candidateIndex === index ? { ...candidate, completed: true as const } : candidate,
1646
+ );
1647
+ activeCleanup = { ...activeCleanup, lifecycleFiles };
1648
+ await broker.ledger.transition(identity, "effect_started", {
1649
+ response: fail("cleanup_pending", "Lifecycle cleanup completion was durably reconciled.", activeCleanup),
1650
+ });
1651
+ lifecycleCleanupHooksForTest.get(broker)?.();
1652
+ }
1653
+ if (!lifecycleMetadataReplayAbsent(activeCleanup))
1654
+ return fail("terminal_uncertain", "Lifecycle metadata replay left an authorized sibling behind.");
1655
+ await syncDirectory(path.join(activeCleanup.metadataRoot!, "sdk"));
1656
+ return completion;
1657
+ }
1658
+
842
1659
  async function readSessionLifecycleFailure(
843
1660
  root: string,
844
1661
  id: string,
@@ -882,25 +1699,116 @@ async function hasOwnedReadinessEvidence(
882
1699
  );
883
1700
  }
884
1701
 
1702
+ type LifecycleFileCapture = {
1703
+ bytes: Buffer;
1704
+ identity: { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint; sha256: string };
1705
+ digest: string;
1706
+ };
1707
+
1708
+ function captureLifecycleFile(file: string, requireRegular = false, bounded = false): LifecycleFileCapture | undefined {
1709
+ let descriptor: number | undefined;
1710
+ try {
1711
+ const preflight = bounded ? fsSync.lstatSync(file, { bigint: true }) : undefined;
1712
+ if (
1713
+ preflight &&
1714
+ (!preflight.isFile() || preflight.size === 0n || preflight.size > BigInt(MAX_LIFECYCLE_METADATA_BYTES))
1715
+ ) {
1716
+ if (requireRegular) throw new Error("Lifecycle metadata is not a bounded regular file.");
1717
+ return undefined;
1718
+ }
1719
+ descriptor = fsSync.openSync(file, fsSync.constants.O_RDONLY | fsSync.constants.O_NOFOLLOW);
1720
+ const stat = fsSync.fstatSync(descriptor, { bigint: true });
1721
+ if (
1722
+ !stat.isFile() ||
1723
+ (bounded &&
1724
+ (stat.size === 0n ||
1725
+ stat.size > BigInt(MAX_LIFECYCLE_METADATA_BYTES) ||
1726
+ !preflight ||
1727
+ stat.dev !== preflight.dev ||
1728
+ stat.ino !== preflight.ino))
1729
+ ) {
1730
+ if (requireRegular) throw new Error("Lifecycle cleanup candidate is not an exact bounded regular file.");
1731
+ return undefined;
1732
+ }
1733
+ const bytes = fsSync.readFileSync(descriptor);
1734
+ const current = fsSync.fstatSync(descriptor, { bigint: true });
1735
+ if (
1736
+ !current.isFile() ||
1737
+ current.dev !== stat.dev ||
1738
+ current.ino !== stat.ino ||
1739
+ current.size !== stat.size ||
1740
+ current.mtimeNs !== stat.mtimeNs
1741
+ )
1742
+ return undefined;
1743
+ return {
1744
+ bytes,
1745
+ identity: {
1746
+ dev: stat.dev,
1747
+ ino: stat.ino,
1748
+ size: stat.size,
1749
+ mtimeNs: stat.mtimeNs,
1750
+ sha256: createHash("sha256").update(bytes).digest("hex"),
1751
+ },
1752
+ digest: createHash("sha256").update(bytes).digest("hex"),
1753
+ };
1754
+ } catch (error) {
1755
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
1756
+ throw error;
1757
+ } finally {
1758
+ if (descriptor !== undefined) fsSync.closeSync(descriptor);
1759
+ }
1760
+ }
1761
+
885
1762
  async function removeOwnedLifecycleArtifacts(root: string, id: string, expected: EffectMarker): Promise<boolean> {
886
1763
  const marker = await readEffectMarker(lifecycleMarkerPath(root, id));
887
1764
  if (!marker || !sameEffectMarker(marker, expected)) return false;
888
1765
  const endpointPath = path.join(root, "sdk", `${id}.json`);
889
- try {
890
- const endpoint = JSON.parse(await fs.readFile(endpointPath, "utf8")) as { pid?: unknown };
891
- if (endpoint.pid !== expected.pid || !hasObservedProcessExit(expected.pid)) return false;
892
- await fs.rm(endpointPath);
893
- } catch (error) {
894
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false;
1766
+ const plannedEndpointPath = path.join(
1767
+ path.dirname(endpointPath),
1768
+ `.gjc-delete-endpoint-${expected.effectMarker}-${path.basename(endpointPath)}`,
1769
+ );
1770
+ const retryEndpointPath = path.join(
1771
+ path.dirname(endpointPath),
1772
+ `.gjc-delete-endpoint-retry-${expected.effectMarker}-${path.basename(endpointPath)}`,
1773
+ );
1774
+ const finalEndpointPath = path.join(
1775
+ path.dirname(endpointPath),
1776
+ `.gjc-delete-endpoint-final-${expected.effectMarker}-${path.basename(endpointPath)}`,
1777
+ );
1778
+ const endpointSource = [endpointPath, plannedEndpointPath, retryEndpointPath, finalEndpointPath].find(candidate => {
1779
+ try {
1780
+ return fsSync.lstatSync(candidate).isFile();
1781
+ } catch {
1782
+ return false;
1783
+ }
1784
+ });
1785
+ const endpoint = endpointSource ? captureLifecycleFile(endpointSource) : undefined;
1786
+ if (endpoint && endpointSource) {
1787
+ let parsed: { pid?: unknown };
1788
+ try {
1789
+ parsed = parseLifecycleJson(endpoint.bytes) as { pid?: unknown };
1790
+ } catch {
1791
+ return false;
1792
+ }
1793
+ if (parsed.pid !== expected.pid || !hasObservedProcessExit(expected.pid)) return false;
1794
+ if (createHash("sha256").update(endpoint.bytes).digest("hex") !== endpoint.digest) return false;
1795
+ const endpointRemoval = exactUnlinkLifecycleFile(
1796
+ endpointSource,
1797
+ endpoint.identity,
1798
+ endpointSource === endpointPath
1799
+ ? plannedEndpointPath
1800
+ : endpointSource === plannedEndpointPath
1801
+ ? retryEndpointPath
1802
+ : finalEndpointPath,
1803
+ );
1804
+ if (!endpointRemoval.ok) return false;
895
1805
  }
896
- if (!(await endpointRemoved(root, id))) return false;
897
1806
  const currentMarker = await readEffectMarker(lifecycleMarkerPath(root, id));
898
1807
  if (!currentMarker || !sameEffectMarker(currentMarker, expected)) return false;
899
- try {
900
- await fs.rm(lifecycleReadyPath(root, id), { force: true });
901
- } catch {
902
- return false;
903
- }
1808
+ const readyPath = lifecycleReadyPath(root, id);
1809
+ const ready = captureLifecycleFile(readyPath, true, true);
1810
+ if (ready && createHash("sha256").update(ready.bytes).digest("hex") !== ready.digest) return false;
1811
+ // Readiness mutation is deferred to the same ledger-backed cleanup transaction.
904
1812
  return true;
905
1813
  }
906
1814
 
@@ -1019,7 +1927,7 @@ async function terminateSpawnedChild(
1019
1927
  expected &&
1020
1928
  !broker.index.hasHostRegistrationForLifecycle(id, pid, expected.effectMarker)
1021
1929
  )
1022
- return endpointRemoved(root, id);
1930
+ return true;
1023
1931
  const registeredBeforeTermination =
1024
1932
  rollbackGeneration === undefined || rollbackGeneration === null
1025
1933
  ? undefined
@@ -1291,7 +2199,7 @@ async function launchInput(
1291
2199
  if (!requested) return fail("invalid_input", "sessionId is required to resume a saved session.");
1292
2200
  const savedPath = text(input.sessionPath);
1293
2201
  if (!savedPath) return fail("invalid_input", "sessionPath is required to resume a saved session.");
1294
- const saved = validateSavedTranscript(broker, cwd, savedPath, requested, "Saved");
2202
+ const saved = await validateSavedTranscript(broker, cwd, savedPath, requested, "Saved");
1295
2203
  if ("ok" in saved) return saved;
1296
2204
  return {
1297
2205
  id: requested,
@@ -1310,7 +2218,7 @@ async function launchInput(
1310
2218
  const sourceSessionPath = text(input.sourceSessionPath) ?? text(input.sourcePath) ?? text(input.sessionPath);
1311
2219
  if (!sourceSessionId && !sourceSessionPath)
1312
2220
  return fail("invalid_input", "sourceSessionId or sourceSessionPath is required to fork a session.");
1313
- const source = validateSavedTranscript(broker, sourceCwd, sourceSessionPath, sourceSessionId, "Source");
2221
+ const source = await validateSavedTranscript(broker, sourceCwd, sourceSessionPath, sourceSessionId, "Source");
1314
2222
  if ("ok" in source) return source;
1315
2223
  return {
1316
2224
  id: randomUUID(),
@@ -1326,22 +2234,149 @@ async function launchInput(
1326
2234
  };
1327
2235
  }
1328
2236
 
1329
- function within(root: string, candidate: string): boolean {
1330
- const relative = path.relative(root, candidate);
1331
- return relative !== "" && !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative);
1332
- }
1333
2237
  type ValidatedDelete = {
1334
2238
  storage: FileSessionStorage;
1335
2239
  target: VerifiedSessionDeleteTarget;
1336
2240
  metadataRoot: string;
1337
2241
  };
2242
+ function cleanupIdentity(
2243
+ identity: BrokerCleanupEvidence["transcriptIdentity"],
2244
+ allowEmptySha256 = false,
2245
+ ): SessionStorageFileIdentity | undefined {
2246
+ if (
2247
+ !identity ||
2248
+ !/^[0-9]+$/.test(identity.dev) ||
2249
+ !/^[0-9]+$/.test(identity.ino) ||
2250
+ !Number.isSafeInteger(identity.size) ||
2251
+ identity.size < 0 ||
2252
+ !/^[0-9]+$/.test(identity.mtimeNs) ||
2253
+ (!allowEmptySha256 && !/^[a-f0-9]{64}$/.test(identity.sha256)) ||
2254
+ (allowEmptySha256 && identity.sha256 !== "" && !/^[a-f0-9]{64}$/.test(identity.sha256))
2255
+ )
2256
+ return undefined;
2257
+ return {
2258
+ dev: BigInt(identity.dev),
2259
+ ino: BigInt(identity.ino),
2260
+ size: identity.size,
2261
+ mtimeNs: BigInt(identity.mtimeNs),
2262
+ sha256: identity.sha256,
2263
+ };
2264
+ }
2265
+
2266
+ function replayDeleteTarget(cleanup: CleanupEvidence): ValidatedDelete | BrokerResponse {
2267
+ const transcriptIdentity = cleanupIdentity(cleanup.transcriptIdentity);
2268
+ if (
2269
+ (cleanup.phase !== "artifacts" && cleanup.phase !== "transcript") ||
2270
+ !cleanup.sessionId ||
2271
+ !isCanonicalSessionId(cleanup.sessionId) ||
2272
+ !cleanup.sessionsRoot ||
2273
+ !cleanup.transcriptPath ||
2274
+ !cleanup.cwd ||
2275
+ !cleanup.metadataRoot ||
2276
+ !transcriptIdentity
2277
+ ) {
2278
+ return fail("terminal_uncertain", "Cleanup replay lacks a complete ledger-bound deletion target.");
2279
+ }
2280
+ const artifactsIdentity = cleanupIdentity(cleanup.artifactsIdentity, true);
2281
+ const artifactTreeIdentity =
2282
+ cleanup.artifactsRemoved === true || !cleanup.artifactTree
2283
+ ? undefined
2284
+ : cleanupIdentity(cleanup.artifactTree.identity, true);
2285
+ if (cleanup.artifactsRemoved !== true && cleanup.artifactTree && !artifactTreeIdentity)
2286
+ return fail("terminal_uncertain", "Artifact tree cleanup lacks its ledger-bound identity.");
2287
+
2288
+ if (cleanup.detachedArtifactsPath && !artifactsIdentity)
2289
+ return fail("terminal_uncertain", "Detached artifact cleanup lacks its ledger-bound identity.");
2290
+ const plannedArtifactsPath = cleanup.plannedArtifactsPath;
2291
+ const plannedTranscriptPath = cleanup.plannedTranscriptPath;
2292
+ if (
2293
+ (plannedArtifactsPath &&
2294
+ (path.dirname(plannedArtifactsPath) !== path.dirname(cleanup.transcriptPath) ||
2295
+ !path.basename(plannedArtifactsPath).startsWith(".gjc-delete-"))) ||
2296
+ (plannedTranscriptPath &&
2297
+ (path.dirname(plannedTranscriptPath) !== path.dirname(cleanup.transcriptPath) ||
2298
+ !path.basename(plannedTranscriptPath).startsWith(".gjc-delete-"))) ||
2299
+ (cleanup.artifactsRemoved !== true &&
2300
+ cleanup.artifactTree &&
2301
+ (path.dirname(cleanup.artifactTree.plannedPath) !== path.dirname(cleanup.transcriptPath) ||
2302
+ !path.basename(cleanup.artifactTree.plannedPath).startsWith(".gjc-delete-") ||
2303
+ (cleanup.artifactTree.detachedPath !== undefined &&
2304
+ path.dirname(cleanup.artifactTree.detachedPath) !== path.dirname(cleanup.transcriptPath))))
2305
+ )
2306
+ return fail("terminal_uncertain", "Cleanup replay has invalid preauthorized quarantine paths.");
2307
+ if (
2308
+ cleanup.phase === "transcript" &&
2309
+ cleanup.artifactsRemoved === true &&
2310
+ ![cleanup.transcriptPath, cleanup.detachedTranscriptPath, plannedTranscriptPath].some(
2311
+ candidate => typeof candidate === "string" && fsSync.existsSync(candidate),
2312
+ )
2313
+ )
2314
+ return { ok: true, result: { sessionId: cleanup.sessionId } };
2315
+ const artifactRemovingPath =
2316
+ cleanup.artifactsRemoved === true
2317
+ ? undefined
2318
+ : cleanup.artifactTree
2319
+ ? `${cleanup.artifactTree.plannedPath}.removing`
2320
+ : plannedArtifactsPath
2321
+ ? `${plannedArtifactsPath}.removing`
2322
+ : undefined;
2323
+ const recoveredDetachedArtifactsPath =
2324
+ cleanup.detachedArtifactsPath && fsSync.existsSync(cleanup.detachedArtifactsPath)
2325
+ ? cleanup.detachedArtifactsPath
2326
+ : artifactRemovingPath && fsSync.existsSync(artifactRemovingPath)
2327
+ ? artifactRemovingPath
2328
+ : plannedArtifactsPath &&
2329
+ !fsSync.existsSync(cleanup.transcriptPath.slice(0, -6)) &&
2330
+ fsSync.existsSync(plannedArtifactsPath)
2331
+ ? plannedArtifactsPath
2332
+ : undefined;
2333
+ const recoveredDetachedTranscriptPath =
2334
+ cleanup.detachedTranscriptPath && fsSync.existsSync(cleanup.detachedTranscriptPath)
2335
+ ? cleanup.detachedTranscriptPath
2336
+ : plannedTranscriptPath &&
2337
+ !fsSync.existsSync(cleanup.transcriptPath) &&
2338
+ fsSync.existsSync(plannedTranscriptPath)
2339
+ ? plannedTranscriptPath
2340
+ : undefined;
2341
+ return {
2342
+ storage: new FileSessionStorage(),
2343
+ target: {
2344
+ sessionsRoot: cleanup.sessionsRoot,
2345
+ transcriptPath: cleanup.transcriptPath,
2346
+ sessionId: cleanup.sessionId,
2347
+ cwd: cleanup.cwd,
2348
+ transcriptIdentity,
2349
+ ...(cleanup.artifactsRemoved === true ? { artifactsRemoved: true } : {}),
2350
+ ...(cleanup.artifactsRemoved !== true && artifactsIdentity
2351
+ ? { expectedArtifactsIdentity: artifactsIdentity }
2352
+ : {}),
2353
+ ...(cleanup.artifactsRemoved !== true && recoveredDetachedArtifactsPath
2354
+ ? { detachedArtifactsPath: recoveredDetachedArtifactsPath }
2355
+ : {}),
2356
+ ...(recoveredDetachedTranscriptPath ? { detachedTranscriptPath: recoveredDetachedTranscriptPath } : {}),
2357
+ ...(plannedArtifactsPath ? { plannedArtifactsPath } : {}),
2358
+ ...(plannedTranscriptPath ? { plannedTranscriptPath } : {}),
2359
+ ...(cleanup.artifactsRemoved !== true && cleanup.artifactTree && artifactTreeIdentity
2360
+ ? {
2361
+ expectedArtifactsIdentity: artifactTreeIdentity,
2362
+ expectedArtifactsTree: cleanup.artifactTree.snapshot,
2363
+ detachedArtifactsPath: cleanup.artifactTree.detachedPath ?? recoveredDetachedArtifactsPath,
2364
+ plannedArtifactsPath: cleanup.artifactTree.plannedPath,
2365
+ }
2366
+ : {}),
2367
+ },
2368
+ metadataRoot: cleanup.metadataRoot,
2369
+ };
2370
+ }
1338
2371
 
1339
2372
  async function validateDeletePath(
1340
2373
  broker: Broker,
1341
2374
  input: Input,
1342
2375
  id: string,
1343
2376
  record: { locator: { repo: string; stateRoot: string } } | undefined,
2377
+ cleanup?: CleanupEvidence,
1344
2378
  ): Promise<ValidatedDelete | BrokerResponse> {
2379
+ if (cleanup) return replayDeleteTarget(cleanup);
1345
2380
  const sessionPath = text(input.sessionPath);
1346
2381
  const cwd = lifecycleCwd(input);
1347
2382
  if (!sessionPath || !cwd)
@@ -1355,62 +2390,48 @@ async function validateDeletePath(
1355
2390
  )
1356
2391
  return fail("invalid_input", "session.delete locator does not match the indexed session.");
1357
2392
 
1358
- const unresolved = path.resolve(sessionPath);
1359
- const storageRoot = await fs
1360
- .realpath(getSessionsDir(broker.settings.agentDir))
1361
- .catch(() => path.resolve(getSessionsDir(broker.settings.agentDir)));
1362
- const canonicalParent = await fs.realpath(path.dirname(unresolved)).catch(() => path.dirname(unresolved));
1363
- const candidate = path.join(canonicalParent, path.basename(unresolved));
1364
- if (!candidate.endsWith(".jsonl") || !within(storageRoot, candidate))
1365
- return fail("invalid_input", "session.delete path is outside the configured session storage root.");
1366
- try {
1367
- if ((await fs.lstat(candidate)).isSymbolicLink())
1368
- return fail("invalid_input", "session.delete path is a symlink.");
1369
- } catch {
1370
- return fail("not_found", "Requested saved session does not exist.");
1371
- }
1372
- let resolved: string;
1373
- try {
1374
- resolved = await fs.realpath(candidate);
1375
- } catch {
1376
- return fail("not_found", "Requested saved session does not exist.");
1377
- }
1378
- if (!within(storageRoot, resolved))
1379
- return fail("invalid_input", "session.delete path resolves outside the configured session storage root.");
2393
+ const inventory = await managedCandidates(broker, cwd, "Saved");
2394
+ if ("ok" in inventory) return inventory;
2395
+ const candidatePath = path.resolve(sessionPath);
2396
+ const matches = inventory.candidates.filter(
2397
+ candidate => candidate.path === candidatePath && candidate.sessionId === id,
2398
+ );
2399
+ if (matches.length !== 1)
2400
+ return fail("invalid_input", "session.delete path is not an owned managed session for the configured cwd.");
2401
+ const match = matches[0]!;
2402
+ if (inventory.migrationPolicy === "disabled" && match.provenance === "legacy")
2403
+ return fail("legacy_migration_disabled", "Saved legacy session migration is disabled for this workspace.");
1380
2404
 
1381
2405
  const storage = new FileSessionStorage();
1382
- let snapshot: ReturnType<FileSessionStorage["readSnapshotSync"]>;
2406
+ let snapshot: SessionStorageSnapshot;
1383
2407
  try {
1384
- snapshot = storage.readSnapshotSync(resolved);
2408
+ snapshot = storage.readSnapshotSync(candidatePath);
1385
2409
  } catch {
1386
2410
  return fail("not_found", "Requested saved session does not exist or cannot be read.");
1387
2411
  }
1388
- try {
1389
- const newline = snapshot.bytes.indexOf(0x0a);
1390
- const firstLine = Buffer.from(
1391
- snapshot.bytes.subarray(0, newline === -1 ? snapshot.bytes.length : newline),
1392
- ).toString("utf8");
1393
- const header = JSON.parse(firstLine) as { type?: unknown; id?: unknown; cwd?: unknown };
1394
- if (header.type !== "session" || header.id !== id)
1395
- return fail("invalid_input", "session.delete path does not contain the requested session.");
1396
- if (typeof header.cwd !== "string")
1397
- return fail("invalid_input", "session.delete transcript cwd does not match the configured cwd.");
1398
- const transcriptCwd = header.cwd;
1399
- const headerCwd = await fs.realpath(transcriptCwd).catch(() => path.resolve(transcriptCwd));
1400
- const requestedCwd = await fs.realpath(cwd).catch(() => cwd);
1401
- if (headerCwd !== requestedCwd)
1402
- return fail("invalid_input", "session.delete transcript cwd does not match the configured cwd.");
1403
- } catch {
1404
- return fail("invalid_input", "Requested saved session has an invalid header.");
1405
- }
2412
+ const digest = createHash("sha256").update(snapshot.bytes).digest("hex");
2413
+ if (
2414
+ snapshot.stat.dev !== match.identity.dev ||
2415
+ snapshot.stat.ino !== match.identity.ino ||
2416
+ snapshot.stat.size !== match.identity.size ||
2417
+ snapshot.stat.mtimeNs !== match.identity.mtimeNs ||
2418
+ digest !== match.identity.sha256
2419
+ )
2420
+ return fail("invalid_input", "session.delete session changed after managed ownership was verified.");
1406
2421
  return {
1407
2422
  storage,
1408
2423
  target: {
1409
- sessionsRoot: storageRoot,
1410
- transcriptPath: resolved,
2424
+ sessionsRoot: inventory.scope.sessionsRoot,
2425
+ transcriptPath: candidatePath,
1411
2426
  sessionId: id,
1412
2427
  cwd,
1413
- transcriptIdentity: { dev: snapshot.stat.dev, ino: snapshot.stat.ino },
2428
+ transcriptIdentity: {
2429
+ dev: snapshot.stat.dev,
2430
+ ino: snapshot.stat.ino,
2431
+ size: snapshot.stat.size,
2432
+ mtimeNs: snapshot.stat.mtimeNs,
2433
+ sha256: digest,
2434
+ },
1414
2435
  },
1415
2436
  metadataRoot: requestedRoot,
1416
2437
  };
@@ -1530,8 +2551,9 @@ async function executeLifecycleResponse(
1530
2551
  operation: string,
1531
2552
  input: Input,
1532
2553
  identity: string,
2554
+ cleanup?: CleanupEvidence,
1533
2555
  ): Promise<BrokerResponse> {
1534
- const requestedSessionId = sessionId(input);
2556
+ const requestedSessionId = cleanup && operation === "session.delete" ? cleanup.sessionId : sessionId(input);
1535
2557
  if (requestedSessionId !== undefined && !isCanonicalSessionId(requestedSessionId))
1536
2558
  return fail("invalid_input", "sessionId must be a canonical safe identifier.");
1537
2559
  const requestedSourceSessionId = text(input.sourceSessionId) ?? text(input.sourceId);
@@ -1785,7 +2807,7 @@ async function executeLifecycleResponse(
1785
2807
  };
1786
2808
  }
1787
2809
 
1788
- const id = sessionId(input);
2810
+ const id = cleanup && operation === "session.delete" ? cleanup.sessionId : sessionId(input);
1789
2811
  if (!id) return fail("invalid_input", "sessionId is required.");
1790
2812
  if (!isCanonicalSessionId(id)) return fail("invalid_input", "sessionId must be a canonical safe identifier.");
1791
2813
  await broker.index.refresh();
@@ -1914,12 +2936,102 @@ async function executeLifecycleResponse(
1914
2936
  if (record?.terminalUncertain)
1915
2937
  return fail("terminal_uncertain", "Session ownership is uncertain and cannot be deleted safely.");
1916
2938
  if (record?.live) return fail("live_session", "Refusing to delete a live session; close it first.");
1917
- const validated = await validateDeletePath(broker, input, id, record);
2939
+ const validated = await validateDeletePath(broker, input, id, record, cleanup);
1918
2940
  if ("ok" in validated) return validated;
1919
- await broker.ledger.transition(identity, "effect_started", { intendedSessionId: id, effectMarker: randomUUID() });
1920
- let deleted: Awaited<ReturnType<FileSessionStorage["deleteSessionVerified"]>>;
2941
+ const metadataPreflight = preflightLifecycleDeleteMetadata(validated.metadataRoot, id, record, value =>
2942
+ processIncarnationForBroker(broker, value),
2943
+ );
2944
+ if ("ok" in metadataPreflight) return metadataPreflight;
2945
+ const metadataCleanup = metadataPreflight.cleanup;
2946
+ let cleanupTarget: VerifiedSessionDeleteTarget = {
2947
+ ...validated.target,
2948
+ ...(validated.target.plannedArtifactsPath &&
2949
+ validated.target.detachedArtifactsPath !== validated.target.plannedArtifactsPath
2950
+ ? {}
2951
+ : {
2952
+ plannedArtifactsPath: path.join(
2953
+ path.dirname(validated.target.transcriptPath),
2954
+ `.gjc-delete-${randomUUID()}-artifacts`,
2955
+ ),
2956
+ }),
2957
+ ...(validated.target.plannedTranscriptPath &&
2958
+ validated.target.detachedTranscriptPath !== validated.target.plannedTranscriptPath
2959
+ ? {}
2960
+ : {
2961
+ plannedTranscriptPath: path.join(
2962
+ path.dirname(validated.target.transcriptPath),
2963
+ `.gjc-delete-${randomUUID()}-transcript`,
2964
+ ),
2965
+ }),
2966
+ };
2967
+ if (!cleanupTarget.artifactsRemoved && !cleanupTarget.expectedArtifactsIdentity) {
2968
+ const artifactsPath = cleanupTarget.transcriptPath.slice(0, -6);
2969
+ try {
2970
+ const stat = fsSync.lstatSync(artifactsPath, { bigint: true });
2971
+ if (stat.isSymbolicLink() || !stat.isDirectory())
2972
+ return fail("terminal_uncertain", "Artifact cleanup target is not an exact directory.");
2973
+ validateManagedArtifactTree(artifactsPath);
2974
+ const tree = native.snapshotDirectoryTree(artifactsPath);
2975
+ if (!tree.ok || !tree.snapshot)
2976
+ return fail(
2977
+ "terminal_uncertain",
2978
+ `Artifact tree authority could not be captured: ${tree.code ?? "unknown"}`,
2979
+ );
2980
+ cleanupTarget = {
2981
+ ...cleanupTarget,
2982
+ expectedArtifactsIdentity: {
2983
+ dev: stat.dev,
2984
+ ino: stat.ino,
2985
+ size: Number(stat.size),
2986
+ mtimeNs: stat.mtimeNs,
2987
+ sha256: "",
2988
+ },
2989
+ expectedArtifactsTree: tree.snapshot,
2990
+ };
2991
+ } catch (error) {
2992
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
2993
+ }
2994
+ }
2995
+ const preauthorizedCleanup: CleanupEvidence = {
2996
+ phase: cleanupTarget.artifactsRemoved ? "transcript" : "artifacts",
2997
+ sessionId: cleanupTarget.sessionId,
2998
+ sessionsRoot: cleanupTarget.sessionsRoot,
2999
+ transcriptPath: cleanupTarget.transcriptPath,
3000
+ cwd: cleanupTarget.cwd,
3001
+ ...(cleanupTarget.artifactsRemoved ? { artifactsRemoved: true } : {}),
3002
+ metadataRoot: validated.metadataRoot,
3003
+ transcriptIdentity: serializeCleanupIdentity(cleanupTarget.transcriptIdentity),
3004
+ ...(cleanupTarget.expectedArtifactsIdentity
3005
+ ? { artifactsIdentity: serializeCleanupIdentity(cleanupTarget.expectedArtifactsIdentity) }
3006
+ : {}),
3007
+ ...(cleanupTarget.expectedArtifactsIdentity && cleanupTarget.plannedArtifactsPath
3008
+ ? {
3009
+ artifactTree: {
3010
+ identity: serializeCleanupIdentity(cleanupTarget.expectedArtifactsIdentity),
3011
+ snapshot: cleanupTarget.expectedArtifactsTree!,
3012
+ plannedPath: cleanupTarget.plannedArtifactsPath,
3013
+ ...(cleanupTarget.detachedArtifactsPath
3014
+ ? { detachedPath: cleanupTarget.detachedArtifactsPath }
3015
+ : {}),
3016
+ },
3017
+ }
3018
+ : {}),
3019
+
3020
+ ...(cleanupTarget.plannedArtifactsPath ? { plannedArtifactsPath: cleanupTarget.plannedArtifactsPath } : {}),
3021
+ ...(cleanupTarget.plannedTranscriptPath ? { plannedTranscriptPath: cleanupTarget.plannedTranscriptPath } : {}),
3022
+ };
3023
+ await broker.ledger.transition(identity, "effect_started", {
3024
+ intendedSessionId: id,
3025
+ effectMarker: randomUUID(),
3026
+ response: fail(
3027
+ "cleanup_pending",
3028
+ "Saved session cleanup is preauthorized for durable reconciliation.",
3029
+ preauthorizedCleanup,
3030
+ ),
3031
+ });
3032
+ let deleted: VerifiedSessionDeleteResult;
1921
3033
  try {
1922
- deleted = await validated.storage.deleteSessionVerified(validated.target);
3034
+ deleted = await validated.storage.deleteSessionVerified(cleanupTarget);
1923
3035
  } catch (error) {
1924
3036
  if (error instanceof SessionDeleteVerificationError)
1925
3037
  return fail(
@@ -1931,27 +3043,95 @@ async function executeLifecycleResponse(
1931
3043
  `Unable to delete saved session artifacts: ${error instanceof Error ? error.message : String(error)}`,
1932
3044
  );
1933
3045
  }
3046
+ if (deleted.kind === "artifacts_removed") {
3047
+ const transcriptPhaseCleanup = {
3048
+ ...preauthorizedCleanup,
3049
+ phase: "transcript" as const,
3050
+ artifactsRemoved: true,
3051
+ ...(preauthorizedCleanup.artifactTree
3052
+ ? { artifactTree: { ...preauthorizedCleanup.artifactTree, completed: true as const } }
3053
+ : {}),
3054
+ };
3055
+
3056
+ await broker.ledger.transition(identity, "effect_started", {
3057
+ intendedSessionId: id,
3058
+ response: fail(
3059
+ "cleanup_pending",
3060
+ "Saved session artifacts were removed; transcript cleanup is preauthorized.",
3061
+ transcriptPhaseCleanup,
3062
+ ),
3063
+ });
3064
+ deleted = await validated.storage.deleteSessionVerified({
3065
+ ...cleanupTarget,
3066
+ expectedArtifactsIdentity: undefined,
3067
+ detachedArtifactsPath: undefined,
3068
+ artifactsRemoved: true,
3069
+ });
3070
+ }
1934
3071
  if (deleted.kind === "cleanup_pending")
1935
3072
  return fail(
1936
3073
  "cleanup_pending",
1937
3074
  `Saved session cleanup is pending in ${deleted.phase}: ${deleted.error.message}`,
1938
3075
  {
1939
3076
  phase: deleted.phase,
1940
- transcriptIdentity: {
1941
- dev: deleted.transcriptIdentity.dev.toString(),
1942
- ino: deleted.transcriptIdentity.ino.toString(),
1943
- },
3077
+ sessionId: validated.target.sessionId,
3078
+ sessionsRoot: validated.target.sessionsRoot,
3079
+ transcriptPath: validated.target.transcriptPath,
3080
+ cwd: validated.target.cwd,
3081
+ metadataRoot: validated.metadataRoot,
3082
+ transcriptIdentity: serializeCleanupIdentity(deleted.transcriptIdentity),
1944
3083
  ...(deleted.phase === "artifacts" && deleted.artifactsIdentity
3084
+ ? { artifactsIdentity: serializeCleanupIdentity(deleted.artifactsIdentity) }
3085
+ : {}),
3086
+ ...(deleted.phase === "artifacts" && deleted.artifactsIdentity && cleanupTarget.plannedArtifactsPath
1945
3087
  ? {
1946
- artifactsIdentity: {
1947
- dev: deleted.artifactsIdentity.dev.toString(),
1948
- ino: deleted.artifactsIdentity.ino.toString(),
3088
+ artifactTree: {
3089
+ identity: serializeCleanupIdentity(deleted.artifactsIdentity),
3090
+ snapshot: deleted.artifactsTree,
3091
+ plannedPath: cleanupTarget.plannedArtifactsPath,
3092
+ ...(deleted.detachedArtifactsPath ? { detachedPath: deleted.detachedArtifactsPath } : {}),
1949
3093
  },
1950
3094
  }
1951
3095
  : {}),
3096
+ ...(deleted.phase === "artifacts" ? { detachedArtifactsPath: deleted.detachedArtifactsPath } : {}),
3097
+ ...(deleted.phase === "transcript" && deleted.detachedTranscriptPath
3098
+ ? { detachedTranscriptPath: deleted.detachedTranscriptPath }
3099
+ : {}),
3100
+ ...(deleted.phase === "transcript" ? { artifactsRemoved: true } : {}),
3101
+ ...(deleted.phase === "transcript" &&
3102
+ cleanupTarget.plannedArtifactsPath &&
3103
+ cleanupTarget.expectedArtifactsIdentity
3104
+ ? {
3105
+ artifactTree: {
3106
+ identity: serializeCleanupIdentity(cleanupTarget.expectedArtifactsIdentity),
3107
+ snapshot: cleanupTarget.expectedArtifactsTree!,
3108
+ plannedPath: cleanupTarget.plannedArtifactsPath,
3109
+ completed: true as const,
3110
+ },
3111
+ }
3112
+ : {}),
3113
+ ...(cleanupTarget.plannedArtifactsPath
3114
+ ? { plannedArtifactsPath: cleanupTarget.plannedArtifactsPath }
3115
+ : {}),
3116
+ ...(cleanupTarget.plannedTranscriptPath
3117
+ ? { plannedTranscriptPath: cleanupTarget.plannedTranscriptPath }
3118
+ : {}),
1952
3119
  },
1953
3120
  );
1954
3121
 
3122
+ const completion = { ok: true, result: { sessionId: id } } as const;
3123
+ if (metadataCleanup.lifecycleFiles?.length) {
3124
+ await broker.ledger.transition(identity, "effect_started", {
3125
+ intendedSessionId: id,
3126
+ response: fail(
3127
+ "cleanup_pending",
3128
+ "Lifecycle metadata cleanup is preauthorized for durable reconciliation.",
3129
+ metadataCleanup,
3130
+ ),
3131
+ });
3132
+ const reconciled = await reconcileLifecycleCleanup(broker, identity, metadataCleanup, completion);
3133
+ if (!reconciled.ok) return reconciled;
3134
+ }
1955
3135
  if (record)
1956
3136
  await broker.index.append({
1957
3137
  type: "session_closed",
@@ -1960,18 +3140,7 @@ async function executeLifecycleResponse(
1960
3140
  endpointGeneration: record.endpointGeneration,
1961
3141
  pid: record.pid,
1962
3142
  });
1963
- try {
1964
- await fs.rm(lifecycleMarkerPath(validated.metadataRoot, id), { force: true });
1965
- } catch (error) {
1966
- return fail(
1967
- "cleanup_pending",
1968
- `Saved session was deleted but lifecycle metadata cleanup is pending: ${
1969
- error instanceof Error ? error.message : String(error)
1970
- }`,
1971
- { phase: "metadata" },
1972
- );
1973
- }
1974
- return { ok: true, result: { sessionId: id } };
3143
+ return completion;
1975
3144
  }
1976
3145
  return fail("invalid_input", "Unknown lifecycle operation.");
1977
3146
  }
@@ -2036,6 +3205,35 @@ async function exactCleanupProof(
2036
3205
  : undefined;
2037
3206
  }
2038
3207
 
3208
+ function validateLifecycleDeleteMetadataBinding(
3209
+ broker: Broker,
3210
+ operation: string,
3211
+ input: Input,
3212
+ identity: string,
3213
+ cleanup: CleanupEvidence,
3214
+ ): BrokerResponse | undefined {
3215
+ if (operation !== "session.delete")
3216
+ return fail("terminal_uncertain", "Lifecycle delete metadata cleanup is not authorized for this operation.");
3217
+ const requestedId = sessionId(input);
3218
+ const cwd = lifecycleCwd(input);
3219
+ const requestedRoot = stateRoot(input, cwd);
3220
+ if (
3221
+ !requestedId ||
3222
+ !isCanonicalSessionId(requestedId) ||
3223
+ !cwd ||
3224
+ !requestedRoot ||
3225
+ !hasDefaultStateRoot(cwd, requestedRoot) ||
3226
+ cleanup.sessionId !== requestedId ||
3227
+ !cleanup.metadataRoot ||
3228
+ path.resolve(cleanup.metadataRoot) !== requestedRoot
3229
+ )
3230
+ return fail("terminal_uncertain", "Lifecycle delete metadata cleanup does not match the normalized request.");
3231
+ const recordedRoot = broker.ledger.get(identity)?.effectIntent?.stateRoot;
3232
+ if (recordedRoot && path.resolve(recordedRoot) !== requestedRoot)
3233
+ return fail("terminal_uncertain", "Lifecycle delete metadata cleanup does not match the recorded workspace.");
3234
+ return undefined;
3235
+ }
3236
+
2039
3237
  export interface LifecycleExecutionOutcome {
2040
3238
  response: BrokerResponse;
2041
3239
  durableEffects?: LifecycleDurableEffectsReceipt;
@@ -2049,8 +3247,59 @@ export async function executeLifecycle(
2049
3247
  operation: string,
2050
3248
  input: Input,
2051
3249
  identity: string,
3250
+ cleanup?: CleanupEvidence,
2052
3251
  ): Promise<LifecycleExecutionOutcome> {
2053
- const response = await executeLifecycleResponse(broker, operation, input, identity);
3252
+ if (cleanup?.phase === "metadata") {
3253
+ if (operation !== "session.delete")
3254
+ return {
3255
+ response: fail(
3256
+ "terminal_uncertain",
3257
+ "Legacy metadata cleanup is not authorized for this lifecycle operation.",
3258
+ ),
3259
+ };
3260
+ const migrated = legacyMetadataCleanupPlan(cleanup);
3261
+ if (!migrated)
3262
+ return {
3263
+ response: fail(
3264
+ "terminal_uncertain",
3265
+ "Legacy metadata cleanup replay lacks immutable identity-bound intent.",
3266
+ ),
3267
+ };
3268
+ const binding = validateLifecycleDeleteMetadataBinding(broker, operation, input, identity, migrated);
3269
+ if (binding) return { response: binding };
3270
+ await broker.ledger.transition(identity, "effect_started", {
3271
+ response: fail(
3272
+ "cleanup_pending",
3273
+ "Legacy lifecycle metadata cleanup is preauthorized for durable reconciliation.",
3274
+ migrated,
3275
+ ),
3276
+ });
3277
+ return {
3278
+ response: await reconcileLifecycleCleanup(broker, identity, migrated, {
3279
+ ok: true,
3280
+ result: { sessionId: migrated.sessionId },
3281
+ }),
3282
+ };
3283
+ }
3284
+ if (cleanup?.phase === "lifecycle") {
3285
+ const shapeValidation = validateLifecycleCleanupShape(cleanup);
3286
+ if (shapeValidation) return { response: shapeValidation };
3287
+ if (cleanup.lifecycleDeleteMetadata === true || operation === "session.delete") {
3288
+ const binding = validateLifecycleDeleteMetadataBinding(broker, operation, input, identity, cleanup);
3289
+ if (binding) return { response: binding };
3290
+ }
3291
+ return {
3292
+ response: await reconcileLifecycleCleanup(
3293
+ broker,
3294
+ identity,
3295
+ cleanup,
3296
+ operation === "session.delete"
3297
+ ? { ok: true, result: { sessionId: cleanup.sessionId } }
3298
+ : fail("spawn_failed", "No ready SDK endpoint remains available."),
3299
+ ),
3300
+ };
3301
+ }
3302
+ const response = await executeLifecycleResponse(broker, operation, input, identity, cleanup);
2054
3303
  const entry = broker.ledger.get(identity);
2055
3304
  const priorDurableEffects = entry?.durableEffects;
2056
3305
  const evidenceCwd = entry?.effectIntent?.worktree?.worktreePath ?? lifecycleCwd(input);
@@ -2105,6 +3354,30 @@ export async function executeLifecycle(
2105
3354
  digest: createHash("sha256").update(canonicalJson(durableEffectsBody)).digest("hex"),
2106
3355
  }
2107
3356
  : undefined;
3357
+ const lifecycleCleanupResponse =
3358
+ evidence && root && entry?.intendedSessionId && expected && cleanupProof
3359
+ ? await (async () => {
3360
+ const cleanupIntent = lifecycleCleanupPlan(root, entry.intendedSessionId!, expected, evidence);
3361
+ await broker.ledger.transition(identity, "effect_started", {
3362
+ response: fail(
3363
+ "cleanup_pending",
3364
+ "Lifecycle failure cleanup is preauthorized for durable reconciliation.",
3365
+ cleanupIntent,
3366
+ ),
3367
+ });
3368
+ return reconcileLifecycleCleanup(broker, identity, cleanupIntent);
3369
+ })()
3370
+ : undefined;
3371
+ if (
3372
+ lifecycleCleanupResponse &&
3373
+ !lifecycleCleanupResponse.ok &&
3374
+ lifecycleCleanupResponse.error.code !== "spawn_failed"
3375
+ )
3376
+ return {
3377
+ response: lifecycleCleanupResponse,
3378
+ ...(durableEffects ? { durableEffects } : {}),
3379
+ ...(startupFailure ? { startupFailure } : {}),
3380
+ };
2108
3381
  const terminalResponse: BrokerResponse =
2109
3382
  !response.ok &&
2110
3383
  entry?.effectMarker &&
@@ -2137,21 +3410,5 @@ export async function executeLifecycle(
2137
3410
  response: terminalResponse,
2138
3411
  ...(durableEffects ? { durableEffects } : {}),
2139
3412
  ...(startupFailure ? { startupFailure } : {}),
2140
- ...(evidence && root && entry?.intendedSessionId && cleanupProof
2141
- ? {
2142
- deferredArtifactCleanup: async () => {
2143
- const current = await readLifecycleFailureArtifact(
2144
- lifecycleFailurePath(root, entry.intendedSessionId!, evidence.artifact.effectMarker),
2145
- evidence.artifact,
2146
- );
2147
- const marker = await readEffectMarker(lifecycleMarkerPath(root, entry.intendedSessionId!));
2148
- if (current?.digest !== evidence.digest || !marker || !sameEffectMarker(marker, evidence.artifact))
2149
- return;
2150
- await fs.rm(lifecycleFailurePath(root, entry.intendedSessionId!, evidence.artifact.effectMarker));
2151
- await fs.rm(lifecycleMarkerPath(root, entry.intendedSessionId!));
2152
- await syncDirectory(path.join(root, "sdk"));
2153
- },
2154
- }
2155
- : {}),
2156
3413
  };
2157
3414
  }