@sayknow-cli/coding-agent 0.4.6 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (428) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/dist/types/capability/index.d.ts +10 -20
  3. package/dist/types/capability/types.d.ts +3 -0
  4. package/dist/types/cli/args.d.ts +0 -2
  5. package/dist/types/cli/read-cli.d.ts +1 -0
  6. package/dist/types/cli.d.ts +9 -1
  7. package/dist/types/commands/daemon.d.ts +2 -2
  8. package/dist/types/commands/deep-interview.d.ts +9 -0
  9. package/dist/types/commands/harness.d.ts +22 -0
  10. package/dist/types/commands/read.d.ts +6 -0
  11. package/dist/types/commands/sdk.d.ts +20 -2
  12. package/dist/types/config/keybindings.d.ts +34 -4
  13. package/dist/types/config/model-profile-contract.d.ts +40 -0
  14. package/dist/types/config/model-profiles.d.ts +2 -3
  15. package/dist/types/config/model-registry.d.ts +5 -0
  16. package/dist/types/config/provider-auth-health.d.ts +14 -0
  17. package/dist/types/config/provider-ranking.d.ts +59 -0
  18. package/dist/types/config/settings-schema.d.ts +156 -10
  19. package/dist/types/config/settings.d.ts +3 -0
  20. package/dist/types/edit/modes/patch.d.ts +13 -0
  21. package/dist/types/edit/path-mutation-lock.d.ts +15 -0
  22. package/dist/types/eval/py/executor.d.ts +1 -1
  23. package/dist/types/eval/py/runner-artifact.d.ts +8 -0
  24. package/dist/types/eval/py/tool-bridge.d.ts +1 -2
  25. package/dist/types/extensibility/extensions/types.d.ts +43 -2
  26. package/dist/types/extensibility/shared-events.d.ts +6 -0
  27. package/dist/types/internal-urls/local-root-gc.d.ts +2 -0
  28. package/dist/types/main.d.ts +33 -2
  29. package/dist/types/memories/index.d.ts +2 -0
  30. package/dist/types/modes/acp/acp-agent.d.ts +22 -9
  31. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  32. package/dist/types/modes/components/btw-panel.d.ts +1 -0
  33. package/dist/types/modes/components/irc-sidebar.d.ts +41 -0
  34. package/dist/types/modes/components/queue-pane.d.ts +8 -0
  35. package/dist/types/modes/components/queued-message-selector.d.ts +6 -0
  36. package/dist/types/modes/components/read-tool-group.d.ts +4 -4
  37. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
  38. package/dist/types/modes/components/session-observer-overlay.d.ts +4 -0
  39. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  40. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  41. package/dist/types/modes/components/tool-status-header.d.ts +4 -2
  42. package/dist/types/modes/components/welcome.d.ts +5 -0
  43. package/dist/types/modes/controllers/btw-controller.d.ts +5 -0
  44. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  45. package/dist/types/modes/controllers/event-controller.d.ts +3 -0
  46. package/dist/types/modes/interactive-mode.d.ts +13 -2
  47. package/dist/types/modes/irc-observation-ledger.d.ts +17 -1
  48. package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
  49. package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
  50. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
  51. package/dist/types/modes/types.d.ts +6 -0
  52. package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
  53. package/dist/types/modes/utils/ui-helpers.d.ts +16 -0
  54. package/dist/types/runtime/memory-domain.d.ts +9 -0
  55. package/dist/types/runtime/memory-guard-contract.d.ts +71 -0
  56. package/dist/types/runtime/memory-guard.d.ts +39 -0
  57. package/dist/types/runtime/memory-limit.d.ts +11 -0
  58. package/dist/types/runtime-mcp/content-limits.d.ts +7 -0
  59. package/dist/types/runtime-mcp/discoverable-tool-metadata.d.ts +1 -1
  60. package/dist/types/runtime-mcp/manager.d.ts +9 -1
  61. package/dist/types/runtime-mcp/oauth-flow.d.ts +0 -5
  62. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +14 -0
  63. package/dist/types/runtime-mcp/redaction.d.ts +2 -0
  64. package/dist/types/runtime-mcp/smithery-auth.d.ts +5 -0
  65. package/dist/types/sdk/acp/final-text.d.ts +19 -0
  66. package/dist/types/sdk/acp/mcp.d.ts +23 -0
  67. package/dist/types/sdk/broker/broker.d.ts +2 -0
  68. package/dist/types/sdk/broker/lifecycle.d.ts +13 -0
  69. package/dist/types/sdk/bus/chat-daemon-control.d.ts +9 -1
  70. package/dist/types/sdk/bus/config-commands.d.ts +2 -2
  71. package/dist/types/sdk/bus/config.d.ts +2 -0
  72. package/dist/types/sdk/bus/control-drain-lease.d.ts +52 -0
  73. package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
  74. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  75. package/dist/types/sdk/bus/index.d.ts +52 -4
  76. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +37 -0
  77. package/dist/types/sdk/bus/lifecycle-commands.d.ts +5 -4
  78. package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
  79. package/dist/types/sdk/bus/notification-service.d.ts +34 -0
  80. package/dist/types/sdk/bus/operator-runtime.d.ts +1 -0
  81. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +106 -0
  82. package/dist/types/sdk/bus/rate-limit-pool.d.ts +2 -0
  83. package/dist/types/sdk/bus/reconciliation-store.d.ts +65 -0
  84. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +33 -9
  85. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
  86. package/dist/types/sdk/bus/telegram-daemon.d.ts +286 -12
  87. package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
  88. package/dist/types/sdk/bus/topic-registry.d.ts +88 -2
  89. package/dist/types/sdk/client/discovery.d.ts +2 -0
  90. package/dist/types/sdk/client/liveness.d.ts +7 -0
  91. package/dist/types/sdk/host/control/operations.d.ts +3 -2
  92. package/dist/types/sdk/host/host.d.ts +3 -1
  93. package/dist/types/sdk/host/query/handlers.d.ts +14 -0
  94. package/dist/types/sdk/index.d.ts +2 -0
  95. package/dist/types/sdk/lifecycle-session.d.ts +10 -1
  96. package/dist/types/sdk/prompt-status.d.ts +88 -0
  97. package/dist/types/sdk/session-directory.d.ts +1 -1
  98. package/dist/types/sdk/session.d.ts +7 -0
  99. package/dist/types/sdk/startup-capability.d.ts +9 -0
  100. package/dist/types/sdk/transport/auth-preface.d.ts +9 -0
  101. package/dist/types/sdk/transport/index.d.ts +12 -0
  102. package/dist/types/sdk/transport/relay.d.ts +43 -0
  103. package/dist/types/sdk/transport/serve-cli.d.ts +4 -0
  104. package/dist/types/sdk/transport/socket.d.ts +7 -0
  105. package/dist/types/sdk/transport/stdio.d.ts +3 -0
  106. package/dist/types/session/agent-session.d.ts +83 -24
  107. package/dist/types/session/btw-contract.d.ts +16 -0
  108. package/dist/types/session/fallback-chain-controller.d.ts +18 -2
  109. package/dist/types/session/internal/managed-session-scope.d.ts +70 -10
  110. package/dist/types/session/internal/managed-session-storage.d.ts +34 -12
  111. package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
  112. package/dist/types/session/memory-guard-checkpoint-participant.d.ts +69 -0
  113. package/dist/types/session/session-manager.d.ts +89 -18
  114. package/dist/types/session/session-storage.d.ts +31 -0
  115. package/dist/types/session/streaming-output.d.ts +60 -5
  116. package/dist/types/setup/credential-import.d.ts +5 -1
  117. package/dist/types/setup/provider-onboarding.d.ts +2 -0
  118. package/dist/types/skc-runtime/deep-interview-runtime.d.ts +1 -1
  119. package/dist/types/skc-runtime/deep-interview-stage.d.ts +37 -0
  120. package/dist/types/skc-runtime/gc-runtime.d.ts +1 -1
  121. package/dist/types/skc-runtime/linux-proc.d.ts +22 -14
  122. package/dist/types/skc-runtime/memory-guard-owner-claims.d.ts +47 -0
  123. package/dist/types/skc-runtime/ralplan-runtime.d.ts +112 -1
  124. package/dist/types/skc-runtime/repository-binding.d.ts +63 -0
  125. package/dist/types/skc-runtime/state-runtime.d.ts +3 -1
  126. package/dist/types/skc-runtime/state-writer.d.ts +5 -1
  127. package/dist/types/skc-runtime/team-launch.d.ts +1 -1
  128. package/dist/types/skc-runtime/team-runtime.d.ts +9 -2
  129. package/dist/types/skc-runtime/team-store.d.ts +12 -0
  130. package/dist/types/skc-runtime/team-worker-memory-guard.d.ts +92 -0
  131. package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +7 -0
  132. package/dist/types/skc-runtime/ultragoal-guard.d.ts +6 -5
  133. package/dist/types/skc-runtime/ultragoal-receipt-freshness.d.ts +28 -0
  134. package/dist/types/skc-runtime/ultragoal-runtime.d.ts +43 -5
  135. package/dist/types/skill-state/workflow-hud.d.ts +3 -0
  136. package/dist/types/ssh/utils.d.ts +1 -0
  137. package/dist/types/task/discovery.d.ts +3 -1
  138. package/dist/types/task/executor.d.ts +6 -0
  139. package/dist/types/task/index.d.ts +3 -1
  140. package/dist/types/task/provider-retry-status.d.ts +13 -0
  141. package/dist/types/task/receipt.d.ts +2 -0
  142. package/dist/types/task/types.d.ts +133 -0
  143. package/dist/types/task/ultragoal-redteam-activation.d.ts +33 -0
  144. package/dist/types/tools/browser/launch.d.ts +7 -0
  145. package/dist/types/tools/fetch.d.ts +22 -9
  146. package/dist/types/tools/image-gen.d.ts +63 -1
  147. package/dist/types/tools/index.d.ts +7 -1
  148. package/dist/types/tools/output-meta.d.ts +21 -1
  149. package/dist/types/tools/read-internals.d.ts +12 -0
  150. package/dist/types/tools/read.d.ts +45 -1
  151. package/dist/types/tools/resource-gc.d.ts +23 -0
  152. package/dist/types/tools/skill-discovery.d.ts +7 -0
  153. package/dist/types/tools/sqlite-reader.d.ts +7 -0
  154. package/dist/types/tools/tool-result.d.ts +7 -1
  155. package/dist/types/utils/prompt-suggestion.d.ts +33 -0
  156. package/dist/types/utils/shell-snapshot.d.ts +12 -0
  157. package/dist/types/web/insane/bridge.d.ts +1 -0
  158. package/dist/types/web/insane/url-guard.d.ts +17 -0
  159. package/package.json +8 -12
  160. package/scripts/benchmark-sticky-viewport-pr1.ts +239 -0
  161. package/scripts/capture-platform-shortcut-labels-showcase.ts +342 -0
  162. package/scripts/capture-sticky-viewport-showcase.ts +292 -0
  163. package/scripts/compile-args.ts +0 -1
  164. package/scripts/dogfood-repository-binding.ts +209 -0
  165. package/scripts/generate-docs-index.ts +4 -1
  166. package/scripts/generate-sdk-operation-inventory.ts +9 -0
  167. package/scripts/run-test-manifest.ts +6 -4
  168. package/scripts/verify-skc-sdk-canonicalization.ts +122 -18
  169. package/scripts/verify-sticky-viewport-showcase.ts +611 -0
  170. package/src/capability/index.ts +58 -42
  171. package/src/capability/types.ts +3 -0
  172. package/src/cli/args.ts +16 -100
  173. package/src/cli/fast-help.ts +1 -1
  174. package/src/cli/mcp-cli.ts +4 -21
  175. package/src/cli/notify-cli.ts +6 -38
  176. package/src/cli/read-cli.ts +2 -1
  177. package/src/cli/setup-cli.ts +10 -4
  178. package/src/cli.ts +133 -11
  179. package/src/commands/daemon.ts +22 -5
  180. package/src/commands/deep-interview.ts +26 -1
  181. package/src/commands/harness.ts +35 -12
  182. package/src/commands/read.ts +11 -2
  183. package/src/commands/sdk.ts +134 -17
  184. package/src/commands/team.ts +1 -1
  185. package/src/commit/agentic/index.ts +3 -3
  186. package/src/commit/map-reduce/index.ts +2 -2
  187. package/src/config/keybindings.ts +158 -25
  188. package/src/config/model-profile-contract.ts +174 -0
  189. package/src/config/model-profiles.ts +51 -10
  190. package/src/config/model-registry.ts +24 -4
  191. package/src/config/model-resolver.ts +2 -2
  192. package/src/config/provider-auth-health.ts +42 -0
  193. package/src/config/provider-ranking.ts +119 -0
  194. package/src/config/settings-schema.ts +157 -10
  195. package/src/config/settings.ts +254 -108
  196. package/src/coordinator-mcp/model-preset.ts +21 -74
  197. package/src/defaults/skc/skills/deep-interview/SKILL.md +147 -24
  198. package/src/defaults/skc/skills/ralplan/SKILL.md +91 -34
  199. package/src/defaults/skc/skills/team/SKILL.md +1 -1
  200. package/src/defaults/skc/skills/ultragoal/SKILL.md +68 -17
  201. package/src/discovery/ssh.ts +12 -1
  202. package/src/edit/index.ts +3 -6
  203. package/src/edit/modes/patch.ts +38 -1
  204. package/src/edit/modes/replace.ts +42 -0
  205. package/src/edit/path-mutation-lock.ts +112 -0
  206. package/src/eval/js/tool-bridge.ts +1 -1
  207. package/src/eval/py/executor.ts +37 -13
  208. package/src/eval/py/kernel.ts +1 -21
  209. package/src/eval/py/prelude.py +2 -2
  210. package/src/eval/py/runner-artifact.ts +84 -0
  211. package/src/eval/py/tool-bridge.ts +34 -18
  212. package/src/extensibility/extensions/runner.ts +4 -0
  213. package/src/extensibility/extensions/types.ts +42 -4
  214. package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
  215. package/src/extensibility/shared-events.ts +5 -0
  216. package/src/extensibility/skc-plugins/runtime-adapters.ts +2 -1
  217. package/src/hooks/native-skill-hook.ts +17 -4
  218. package/src/internal-urls/docs-index.generated.ts +17 -16
  219. package/src/internal-urls/local-protocol.ts +87 -25
  220. package/src/internal-urls/local-root-gc.ts +127 -0
  221. package/src/internal-urls/mcp-protocol.ts +73 -11
  222. package/src/main.ts +120 -42
  223. package/src/memories/index.ts +10 -0
  224. package/src/modes/DESIGN.md +52 -0
  225. package/src/modes/acp/acp-agent.ts +647 -83
  226. package/src/modes/acp/acp-event-mapper.ts +106 -12
  227. package/src/modes/acp/acp-mode.ts +39 -3
  228. package/src/modes/action-registry.ts +1 -1
  229. package/src/modes/components/assistant-message.ts +123 -22
  230. package/src/modes/components/btw-panel.ts +82 -16
  231. package/src/modes/components/irc-sidebar.ts +176 -62
  232. package/src/modes/components/model-selector.ts +61 -4
  233. package/src/modes/components/notifications-settings-editor.ts +1 -1
  234. package/src/modes/components/oauth-selector.ts +42 -12
  235. package/src/modes/components/plan-preview-overlay.ts +1 -1
  236. package/src/modes/components/queue-pane.ts +55 -9
  237. package/src/modes/components/queued-message-selector.ts +53 -10
  238. package/src/modes/components/read-tool-group.ts +82 -14
  239. package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
  240. package/src/modes/components/sayknow-pet-widget.ts +27 -8
  241. package/src/modes/components/session-observer-overlay.ts +187 -32
  242. package/src/modes/components/settings-selector.ts +60 -18
  243. package/src/modes/components/tool-execution.ts +138 -27
  244. package/src/modes/components/tool-status-header.ts +26 -8
  245. package/src/modes/components/welcome.ts +50 -23
  246. package/src/modes/controllers/btw-controller.ts +119 -36
  247. package/src/modes/controllers/command-controller.ts +101 -54
  248. package/src/modes/controllers/event-controller.ts +119 -27
  249. package/src/modes/controllers/extension-ui-controller.ts +23 -12
  250. package/src/modes/controllers/input-controller.ts +100 -8
  251. package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
  252. package/src/modes/controllers/selector-controller.ts +4 -1
  253. package/src/modes/interactive-mode.ts +201 -60
  254. package/src/modes/irc-observation-ledger.ts +71 -15
  255. package/src/modes/prompt-action-autocomplete.ts +32 -13
  256. package/src/modes/prompt-suggestion-controller.ts +96 -0
  257. package/src/modes/runtime-init.ts +20 -2
  258. package/src/modes/shared/agent-wire/command-dispatch.ts +3 -3
  259. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
  260. package/src/modes/types.ts +6 -0
  261. package/src/modes/utils/hotkeys-markdown.ts +64 -36
  262. package/src/modes/utils/ui-helpers.ts +99 -4
  263. package/src/prompts/agent-fragments/restricted-bash.md +1 -1
  264. package/src/prompts/agents/architect.md +17 -3
  265. package/src/prompts/agents/critic.md +17 -2
  266. package/src/prompts/agents/executor.md +1 -1
  267. package/src/prompts/agents/planner.md +8 -1
  268. package/src/prompts/system/btw-user.md +3 -8
  269. package/src/prompts/system/prompt-suggestion-system.md +32 -0
  270. package/src/prompts/system/system-prompt.md +6 -3
  271. package/src/prompts/tools/cron.md +3 -1
  272. package/src/prompts/tools/read.md +4 -2
  273. package/src/prompts/tools/skill-discovery.md +1 -0
  274. package/src/runtime/memory-domain.ts +66 -0
  275. package/src/runtime/memory-guard-contract.ts +73 -0
  276. package/src/runtime/memory-guard.ts +292 -0
  277. package/src/runtime/memory-limit.ts +50 -0
  278. package/src/runtime-mcp/client.ts +41 -55
  279. package/src/runtime-mcp/content-limits.ts +71 -0
  280. package/src/runtime-mcp/discoverable-tool-metadata.ts +1 -1
  281. package/src/runtime-mcp/json-rpc.ts +28 -11
  282. package/src/runtime-mcp/manager.ts +77 -59
  283. package/src/runtime-mcp/oauth-flow.ts +0 -72
  284. package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
  285. package/src/runtime-mcp/redaction.ts +30 -0
  286. package/src/runtime-mcp/smithery-auth.ts +17 -2
  287. package/src/runtime-mcp/smithery-connect.ts +11 -1
  288. package/src/runtime-mcp/transports/http.ts +63 -22
  289. package/src/sdk/acp/adapter.ts +8 -1
  290. package/src/sdk/acp/final-text.ts +40 -0
  291. package/src/sdk/acp/mcp.ts +26 -0
  292. package/src/sdk/broker/broker.ts +34 -20
  293. package/src/sdk/broker/identity.ts +22 -12
  294. package/src/sdk/broker/lifecycle.ts +293 -43
  295. package/src/sdk/broker/session-index.ts +11 -1
  296. package/src/sdk/bus/chat-daemon-cli.ts +2 -2
  297. package/src/sdk/bus/chat-daemon-control.ts +22 -5
  298. package/src/sdk/bus/config-commands.ts +2 -2
  299. package/src/sdk/bus/config.ts +10 -2
  300. package/src/sdk/bus/control-drain-lease.ts +104 -0
  301. package/src/sdk/bus/conversation-store.ts +40 -6
  302. package/src/sdk/bus/daemon-paths.ts +2 -0
  303. package/src/sdk/bus/index.ts +793 -121
  304. package/src/sdk/bus/kind-aware-reconciliation.ts +242 -0
  305. package/src/sdk/bus/lifecycle-commands.ts +63 -10
  306. package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
  307. package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
  308. package/src/sdk/bus/notification-orchestration.ts +12 -0
  309. package/src/sdk/bus/notification-service.ts +87 -25
  310. package/src/sdk/bus/operator-runtime.ts +31 -5
  311. package/src/sdk/bus/prompt-reconciliation.ts +240 -0
  312. package/src/sdk/bus/rate-limit-pool.ts +8 -0
  313. package/src/sdk/bus/recent-activity.ts +90 -26
  314. package/src/sdk/bus/reconciliation-store.ts +224 -0
  315. package/src/sdk/bus/telegram-cli.ts +6 -13
  316. package/src/sdk/bus/telegram-daemon-cli.ts +8 -3
  317. package/src/sdk/bus/telegram-daemon-contract.ts +34 -9
  318. package/src/sdk/bus/telegram-daemon-control.ts +161 -40
  319. package/src/sdk/bus/telegram-daemon.ts +3649 -593
  320. package/src/sdk/bus/telegram-reference.ts +20 -2
  321. package/src/sdk/bus/topic-registry.ts +451 -12
  322. package/src/sdk/cli/session-cli.ts +15 -2
  323. package/src/sdk/client/discovery.ts +19 -3
  324. package/src/sdk/client/liveness.ts +49 -0
  325. package/src/sdk/host/control/dispatch.ts +8 -2
  326. package/src/sdk/host/control/operations.ts +3 -2
  327. package/src/sdk/host/host.ts +27 -9
  328. package/src/sdk/host/query/handlers.ts +125 -5
  329. package/src/sdk/index.ts +12 -0
  330. package/src/sdk/lifecycle-session.ts +19 -3
  331. package/src/sdk/mcp/server.ts +46 -18
  332. package/src/sdk/prompt-status.ts +97 -0
  333. package/src/sdk/protocol/operation-inventory.generated.json +145 -0
  334. package/src/sdk/protocol/operation-registry.ts +24 -4
  335. package/src/sdk/session-directory.ts +5 -1
  336. package/src/sdk/session.ts +69 -8
  337. package/src/sdk/startup-capability.ts +15 -1
  338. package/src/sdk/transport/auth-preface.ts +82 -0
  339. package/src/sdk/transport/index.ts +18 -0
  340. package/src/sdk/transport/relay.ts +252 -0
  341. package/src/sdk/transport/serve-cli.ts +105 -0
  342. package/src/sdk/transport/socket.ts +197 -0
  343. package/src/sdk/transport/stdio.ts +17 -0
  344. package/src/session/agent-session.ts +1112 -314
  345. package/src/session/blob-store.ts +25 -7
  346. package/src/session/btw-contract.ts +59 -0
  347. package/src/session/fallback-chain-controller.ts +47 -8
  348. package/src/session/internal/managed-session-scope.ts +1426 -259
  349. package/src/session/internal/managed-session-storage.ts +414 -238
  350. package/src/session/internal/native-publish-outcome.ts +271 -0
  351. package/src/session/memory-guard-checkpoint-participant.ts +131 -0
  352. package/src/session/session-manager.ts +2079 -469
  353. package/src/session/session-storage.ts +151 -11
  354. package/src/session/streaming-output.ts +262 -43
  355. package/src/setup/credential-import.ts +28 -3
  356. package/src/setup/provider-onboarding.ts +49 -5
  357. package/src/setup/provider-presets.json +2 -1
  358. package/src/skc-runtime/deep-interview-runtime.ts +303 -36
  359. package/src/skc-runtime/deep-interview-stage.ts +1016 -0
  360. package/src/skc-runtime/deep-interview-state.ts +8 -5
  361. package/src/skc-runtime/gc-render.ts +1 -0
  362. package/src/skc-runtime/gc-runtime.ts +11 -1
  363. package/src/skc-runtime/linux-proc.ts +70 -36
  364. package/src/skc-runtime/managed-owner-supervisor.ts +83 -4
  365. package/src/skc-runtime/memory-guard-owner-claims.ts +389 -0
  366. package/src/skc-runtime/ralplan-runtime.ts +1057 -75
  367. package/src/skc-runtime/repository-binding.ts +267 -0
  368. package/src/skc-runtime/session-state-sidecar.ts +25 -9
  369. package/src/skc-runtime/state-runtime.ts +50 -12
  370. package/src/skc-runtime/state-writer.ts +68 -56
  371. package/src/skc-runtime/team-launch.ts +34 -5
  372. package/src/skc-runtime/team-runtime.ts +41 -1
  373. package/src/skc-runtime/team-store.ts +32 -0
  374. package/src/skc-runtime/team-worker-memory-guard.ts +363 -0
  375. package/src/skc-runtime/team-workers.ts +1 -0
  376. package/src/skc-runtime/tmux-common.ts +13 -2
  377. package/src/skc-runtime/tmux-owner-isolation.ts +20 -1
  378. package/src/skc-runtime/tmux-sessions.ts +45 -6
  379. package/src/skc-runtime/ultragoal-guard.ts +93 -15
  380. package/src/skc-runtime/ultragoal-receipt-freshness.ts +123 -0
  381. package/src/skc-runtime/ultragoal-runtime.ts +469 -38
  382. package/src/skc-runtime/workflow-manifest.generated.json +195 -5
  383. package/src/skc-runtime/workflow-manifest.ts +67 -3
  384. package/src/skill-state/workflow-hud.ts +25 -2
  385. package/src/skill-state/workflow-mutation-guard.ts +104 -12
  386. package/src/slash-commands/builtin-registry.ts +1 -1
  387. package/src/ssh/utils.ts +14 -0
  388. package/src/task/discovery.ts +8 -2
  389. package/src/task/executor.ts +165 -47
  390. package/src/task/index.ts +123 -15
  391. package/src/task/provider-retry-status.ts +88 -0
  392. package/src/task/receipt.ts +3 -0
  393. package/src/task/render.ts +45 -26
  394. package/src/task/skc-command.ts +2 -2
  395. package/src/task/types.ts +55 -10
  396. package/src/task/ultragoal-redteam-activation.ts +75 -0
  397. package/src/tools/ask.ts +1 -1
  398. package/src/tools/ast-edit.ts +2 -2
  399. package/src/tools/bash-allowed-prefixes.ts +45 -1
  400. package/src/tools/bash-pty-selection.ts +2 -2
  401. package/src/tools/browser/launch.ts +41 -6
  402. package/src/tools/browser/screenshot-format.ts +6 -1
  403. package/src/tools/cron.ts +1 -1
  404. package/src/tools/fetch.ts +127 -154
  405. package/src/tools/image-gen.ts +476 -23
  406. package/src/tools/index.ts +7 -1
  407. package/src/tools/output-meta.ts +176 -9
  408. package/src/tools/path-utils.ts +5 -1
  409. package/src/tools/read-internals.ts +44 -0
  410. package/src/tools/read.ts +1544 -310
  411. package/src/tools/resource-gc.ts +665 -125
  412. package/src/tools/skill-discovery.ts +39 -3
  413. package/src/tools/sqlite-reader.ts +63 -5
  414. package/src/tools/subagent-render.ts +74 -16
  415. package/src/tools/subagent.ts +2 -0
  416. package/src/tools/tool-result.ts +14 -1
  417. package/src/utils/edit-mode.ts +2 -2
  418. package/src/utils/pasted-image-path.ts +15 -6
  419. package/src/utils/prompt-suggestion.ts +265 -0
  420. package/src/utils/shell-snapshot.ts +173 -63
  421. package/src/utils/sixel.ts +3 -3
  422. package/src/web/insane/bridge.ts +5 -1
  423. package/src/web/insane/url-guard.ts +140 -27
  424. package/src/web/scrapers/docs-rs.ts +30 -5
  425. package/src/web/scrapers/types.ts +19 -49
  426. package/src/web/scrapers/utils.ts +12 -26
  427. package/src/web/search/providers/codex.ts +1 -1
  428. package/src/workflow/workflow-intent-diff.ts +4 -1
@@ -3,28 +3,42 @@ import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import * as native from "@sayknow-cli/natives";
6
- import { canonicalExistingDirectoryIdentity, verifyOwnerOnlyPathSecurity } from "@sayknow-cli/natives";
7
- import { pathIsWithin } from "@sayknow-cli/utils";
6
+ import {
7
+ canonicalExistingDirectoryIdentity,
8
+ verifyOwnerOnlyPathSecurity,
9
+ verifyOwnerOnlyPathSecurityExpected,
10
+ } from "@sayknow-cli/natives";
11
+ import { logger, pathIsWithin } from "@sayknow-cli/utils";
8
12
  import type { ResumeSessionIdentity } from "../session-manager";
9
13
  import {
10
14
  FileSessionStorage,
11
15
  type NativeDirectoryTreeSnapshot,
12
16
  type SessionStorageFileIdentity,
17
+ type VerifiedSessionDeleteResult,
13
18
  } from "../session-storage";
14
19
  import {
15
20
  acquireManagedLock,
21
+ assertManagedDirectoryRoot,
16
22
  captureManagedFileNoFollow,
17
23
  captureManagedFilePrefixNoFollow,
18
24
  copyManagedFileNoReplace,
19
25
  ensureManagedDirectory,
20
26
  fsyncManagedArtifactTree,
27
+ MANAGED_ARTIFACT_COPY_BATCH_SIZE,
28
+ MANAGED_ARTIFACT_MAX_FILES,
29
+ MANAGED_ARTIFACT_MAX_TOTAL_BYTES,
30
+ type ManagedDirectoryRoot,
31
+ type ManagedFileSnapshot,
32
+ ManagedPublishError,
21
33
  ManagedSessionDescendantStore,
34
+ type ManagedSessionSecurityPolicy,
22
35
  type ManagedStorageLock,
23
- managedDirectoryRoot,
36
+ prepareManagedDirectoryRoot,
24
37
  publishManagedFileNoReplace,
25
38
  publishManagedTombstone,
26
39
  retainManagedDirectoryAuthority,
27
40
  validateManagedArtifactTree,
41
+ validateNativeSecurityResult,
28
42
  } from "./managed-session-storage";
29
43
 
30
44
  export const MANAGED_SESSION_LAYOUT_VERSION = 2 as const;
@@ -44,9 +58,52 @@ export interface ManagedScope {
44
58
  platform: "posix" | "win32";
45
59
  }
46
60
 
47
- const managedRoots = new WeakMap<ManagedScope, ReturnType<typeof managedDirectoryRoot>>();
61
+ /**
62
+ * Opaque managed writer authority captured by a trusted destination. The open
63
+ * transaction must use this authority rather than reacquiring its root from a
64
+ * pathname after resume inspection.
65
+ */
66
+ export interface ManagedCandidateWriteAuthority {
67
+ readonly rootAuthority: ManagedDirectoryRoot;
68
+ readonly retainedAuthority?: native.RecoveryFsRoot;
69
+ readonly retainedDirectory?: string;
70
+ }
71
+
72
+ const managedRoots = new WeakMap<ManagedScope, ReturnType<typeof prepareManagedDirectoryRoot>>();
48
73
  const managedDirectoryIdentities = new WeakMap<ManagedScope, { dev: bigint; ino: bigint }>();
49
74
  const managedDirectoryAuthorities = new WeakMap<ManagedScope, native.RecoveryFsRoot | undefined>();
75
+ const boundManagedWriteAuthorities = new WeakMap<ManagedScope, ManagedCandidateWriteAuthority>();
76
+
77
+ function bindManagedWriteAuthority(scope: ManagedScope, authority: ManagedCandidateWriteAuthority): void {
78
+ if (
79
+ authority.retainedAuthority &&
80
+ authority.retainedDirectory !== undefined &&
81
+ path.resolve(authority.retainedDirectory) === path.resolve(scope.directoryPath)
82
+ ) {
83
+ new ManagedSessionDescendantStore(authority.rootAuthority, scope.directoryPath, {
84
+ authority: authority.retainedAuthority,
85
+ authorityBaseDir: scope.directoryPath,
86
+ }).assertBound();
87
+ } else {
88
+ assertManagedDirectoryRoot(authority.rootAuthority);
89
+ }
90
+ managedRoots.set(scope, authority.rootAuthority);
91
+ boundManagedWriteAuthorities.set(scope, authority);
92
+ }
93
+
94
+ /** A prepared scope is a retained authority boundary, not a path that may be re-adopted. */
95
+ function assertRetainedManagedDirectoryIdentity(scope: ManagedScope): void {
96
+ const expected = managedDirectoryIdentities.get(scope);
97
+ if (!expected) return;
98
+ const current = fs.lstatSync(scope.directoryPath, { bigint: true });
99
+ if (
100
+ !current.isDirectory() ||
101
+ current.isSymbolicLink() ||
102
+ current.dev !== expected.dev ||
103
+ current.ino !== expected.ino
104
+ )
105
+ throw new Error("Managed session directory changed");
106
+ }
50
107
 
51
108
  export function managedDirectoryAuthorityForScope(scope: ManagedScope): native.RecoveryFsRoot | undefined {
52
109
  if (!managedDirectoryAuthorities.has(scope)) throw new Error("Managed session directory authority was not prepared");
@@ -61,24 +118,32 @@ export function managedDirectoryIdentityForScope(scope: ManagedScope): { dev: bi
61
118
 
62
119
  function configuredRootPath(scope: ManagedScope): string {
63
120
  let candidate = pathIsWithin(scope.agentDir, scope.sessionsRoot) ? scope.agentDir : path.dirname(scope.sessionsRoot);
121
+ const suffix: string[] = [];
64
122
  for (;;) {
65
123
  try {
66
124
  const stat = fs.lstatSync(candidate);
67
125
  if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`Unsafe configured root: ${candidate}`);
68
- return fs.realpathSync.native(candidate);
126
+ const canonical = fs.realpathSync.native(candidate);
127
+ return suffix.length === 0 ? canonical : path.join(canonical, ...suffix);
69
128
  } catch (error) {
70
129
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
71
130
  const parent = path.dirname(candidate);
72
131
  if (parent === candidate) throw new Error("Configured managed root is unavailable.");
132
+ suffix.unshift(path.basename(candidate));
73
133
  candidate = parent;
74
134
  }
75
135
  }
76
136
  }
77
137
 
78
- function scopeRoot(scope: ManagedScope) {
138
+ function scopeRoot(scope: ManagedScope, policy: ManagedSessionSecurityPolicy = "default") {
139
+ const bound = boundManagedWriteAuthorities.get(scope);
140
+ if (bound) {
141
+ bindManagedWriteAuthority(scope, bound);
142
+ return bound.rootAuthority;
143
+ }
79
144
  const retained = managedRoots.get(scope);
80
145
  if (retained) return retained;
81
- const root = managedDirectoryRoot(configuredRootPath(scope));
146
+ const root = prepareManagedDirectoryRoot(configuredRootPath(scope), policy);
82
147
  managedRoots.set(scope, root);
83
148
  return root;
84
149
  }
@@ -96,11 +161,20 @@ export type ManagedScopeErrorCode =
96
161
  | "network_unsupported"
97
162
  | "sessions_root_unavailable"
98
163
  | "binding_conflict"
99
- | "binding_invalid";
164
+ | "binding_invalid"
165
+ | "atomic_unavailable"
166
+ | "invalid_request"
167
+ | "durability_failed"
168
+ | "durability_not_provable";
100
169
 
101
170
  export type ManagedScopeResolution =
102
171
  | { kind: "resolved"; scope: ManagedScope }
103
- | { kind: "error"; code: ManagedScopeErrorCode; message: string };
172
+ | {
173
+ kind: "error";
174
+ code: ManagedScopeErrorCode;
175
+ message: string;
176
+ cause?: { readonly classification: string; readonly diagnostic?: string };
177
+ };
104
178
 
105
179
  export interface ManagedCandidate {
106
180
  sessionId: string;
@@ -128,7 +202,11 @@ export type ManagedOpenFailure =
128
202
  | "destination_conflict"
129
203
  | "source_changed"
130
204
  | "unsafe_artifacts"
205
+ | "artifact_capacity_exceeded"
131
206
  | "durability_failed"
207
+ | "atomic_unavailable"
208
+ | "invalid_request"
209
+ | "durability_not_provable"
132
210
  | "migration_retired"
133
211
  | "legacy_migration_disabled"
134
212
  | "managed_storage_unsupported";
@@ -140,6 +218,7 @@ export type ManagedOpenCandidateResult =
140
218
  export type ManagedDeleteCandidateResult =
141
219
  | { kind: "deleted"; tombstonePath: string }
142
220
  | { kind: "already_deleted"; tombstonePath: string }
221
+ | { kind: "cleanup_pending"; tombstonePath: string; phase: "artifacts" | "transcript"; message: string }
143
222
  | { kind: "error"; code: ManagedOpenFailure; message: string };
144
223
 
145
224
  type NativeIdentity =
@@ -155,8 +234,6 @@ type NativeIdentityFailureCode =
155
234
  | "identity_unavailable"
156
235
  | "io_error";
157
236
 
158
- type NativeSecurity = { ok: true } | { ok: false; code: string };
159
-
160
237
  interface Binding {
161
238
  schemaVersion: 1;
162
239
  layoutVersion: 2;
@@ -196,6 +273,22 @@ function identityFor(cwd: string): NativeIdentity {
196
273
  return canonicalExistingDirectoryIdentity(cwd) as NativeIdentity;
197
274
  }
198
275
 
276
+ function verifyExistingManagedScopeDirectory(pathname: string) {
277
+ if (process.platform !== "win32") return verifyOwnerOnlyPathSecurity(pathname, "directory");
278
+ const expected = fs.lstatSync(pathname, { bigint: true });
279
+ if (!expected.isDirectory() || expected.isSymbolicLink()) throw new Error("Unsafe managed directory");
280
+ const verified = verifyOwnerOnlyPathSecurityExpected(pathname, "directory", expected.dev, expected.ino);
281
+ const current = fs.lstatSync(pathname, { bigint: true });
282
+ if (
283
+ !current.isDirectory() ||
284
+ current.isSymbolicLink() ||
285
+ current.dev !== expected.dev ||
286
+ current.ino !== expected.ino
287
+ )
288
+ throw new Error("Managed session directory changed");
289
+ return verified;
290
+ }
291
+
199
292
  function canonicalExistingPathForIo(base: string, identity: CanonicalNativeIdentity): string {
200
293
  if (identity.platform !== "win32") return identity.canonicalPath;
201
294
  try {
@@ -317,11 +410,16 @@ function validateExistingBinding(scope: ManagedScope): ManagedScopeResolution |
317
410
  return validateBindingRaw(scope, raw);
318
411
  }
319
412
 
320
- export function resolveManagedScope(input: {
413
+ interface ManagedScopeInput {
321
414
  cwd: string;
322
415
  agentDir: string;
323
416
  sessionsRoot: string;
324
- }): ManagedScopeResolution {
417
+ }
418
+
419
+ function resolveManagedScopeInternal(
420
+ input: ManagedScopeInput,
421
+ allowRepairableAclFailure: boolean,
422
+ ): ManagedScopeResolution {
325
423
  const identity = identityFor(input.cwd);
326
424
  if (!identity.ok) return nativeFailure(identity.code);
327
425
  try {
@@ -379,13 +477,18 @@ export function resolveManagedScope(input: {
379
477
  if (!directory.isDirectory() || directory.isSymbolicLink()) {
380
478
  return { kind: "error", code: "binding_invalid", message: "The managed scope path is not a safe directory." };
381
479
  }
382
- const security = verifyOwnerOnlyPathSecurity(scope.directoryPath, "directory") as NativeSecurity;
383
- if (!security.ok)
480
+ const security = validateNativeSecurityResult(
481
+ verifyExistingManagedScopeDirectory(scope.directoryPath),
482
+ "verify",
483
+ "directory",
484
+ );
485
+ if (!security.ok && (!allowRepairableAclFailure || security.code !== "acl_verify_failed")) {
384
486
  return {
385
487
  kind: "error",
386
488
  code: "binding_invalid",
387
489
  message: "The managed scope security could not be verified.",
388
490
  };
491
+ }
389
492
  } catch (error) {
390
493
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
391
494
  return { kind: "error", code: "binding_invalid", message: "The managed scope path could not be inspected." };
@@ -394,6 +497,15 @@ export function resolveManagedScope(input: {
394
497
  return validateExistingBinding(scope) ?? { kind: "resolved", scope };
395
498
  }
396
499
 
500
+ export function resolveManagedScope(input: ManagedScopeInput): ManagedScopeResolution {
501
+ return resolveManagedScopeInternal(input, false);
502
+ }
503
+
504
+ /** Resolve a scope for a synchronous write without mutating an existing ACL mismatch. */
505
+ export function resolveManagedScopeForWrite(input: ManagedScopeInput): ManagedScopeResolution {
506
+ return resolveManagedScopeInternal(input, true);
507
+ }
508
+
397
509
  function legacyDirectoryNames(
398
510
  platform: ManagedScope["platform"],
399
511
  canonicalCwd: string,
@@ -461,6 +573,63 @@ function fsyncManagedParent(pathname: string): void {
461
573
  }
462
574
  }
463
575
 
576
+ export function canonicalBindingOpenFlags(platform: NodeJS.Platform = process.platform): number {
577
+ return (platform === "win32" ? fs.constants.O_RDWR : fs.constants.O_RDONLY) | fs.constants.O_NOFOLLOW;
578
+ }
579
+
580
+ export function fsyncCanonicalBinding(bindingPath: string, expected: string): void {
581
+ let captured: ManagedFileSnapshot;
582
+ try {
583
+ captured = captureManagedFileNoFollow(bindingPath);
584
+ } catch {
585
+ throw new Error("binding_invalid");
586
+ }
587
+ if (captured.bytes.toString("utf8") !== expected) throw new Error("binding_invalid");
588
+ let descriptor: number | undefined;
589
+ try {
590
+ // Bun on Windows rejects fsync on a read-only file descriptor with EPERM.
591
+ // The managed binding is owner-writable, so reopen it read/write only for the durability fence.
592
+ descriptor = fs.openSync(bindingPath, canonicalBindingOpenFlags());
593
+ const before = fs.fstatSync(descriptor, { bigint: true });
594
+ if (
595
+ before.dev !== captured.identity.dev ||
596
+ before.ino !== captured.identity.ino ||
597
+ before.size !== BigInt(captured.identity.size) ||
598
+ before.mtimeNs !== captured.identity.mtimeNs
599
+ )
600
+ throw new Error("binding_invalid");
601
+ fs.fsyncSync(descriptor);
602
+ const after = fs.fstatSync(descriptor, { bigint: true });
603
+ if (
604
+ after.dev !== captured.identity.dev ||
605
+ after.ino !== captured.identity.ino ||
606
+ after.size !== BigInt(captured.identity.size) ||
607
+ after.mtimeNs !== captured.identity.mtimeNs
608
+ )
609
+ throw new Error("binding_invalid");
610
+ } catch (error) {
611
+ if (error instanceof Error && error.message === "binding_invalid") throw error;
612
+ throw new Error("durability_failed", { cause: error });
613
+ } finally {
614
+ if (descriptor !== undefined) fs.closeSync(descriptor);
615
+ }
616
+ fsyncManagedParent(bindingPath);
617
+ let recaptured: ManagedFileSnapshot;
618
+ try {
619
+ recaptured = captureManagedFileNoFollow(bindingPath);
620
+ } catch {
621
+ throw new Error("binding_invalid");
622
+ }
623
+ if (
624
+ recaptured.bytes.toString("utf8") !== expected ||
625
+ recaptured.identity.dev !== captured.identity.dev ||
626
+ recaptured.identity.ino !== captured.identity.ino ||
627
+ recaptured.identity.size !== captured.identity.size ||
628
+ recaptured.identity.mtimeNs !== captured.identity.mtimeNs
629
+ )
630
+ throw new Error("binding_invalid");
631
+ }
632
+
464
633
  type CandidatePreflight =
465
634
  | { kind: "capture"; identity: { dev: bigint; ino: bigint; size: number; mtimeNs: bigint } }
466
635
  | {
@@ -594,46 +763,159 @@ function listDirectoryCandidates(
594
763
  });
595
764
  }
596
765
 
597
- export async function ensureManagedScope(scope: ManagedScope): Promise<ManagedScopeResolution> {
766
+ export async function ensureManagedScope(
767
+ scope: ManagedScope,
768
+ policy: ManagedSessionSecurityPolicy = "default",
769
+ ): Promise<ManagedScopeResolution> {
598
770
  try {
599
- const root = scopeRoot(scope);
600
- ensureManagedDirectory(scope.sessionsRoot, root);
601
- ensureManagedDirectory(scope.directoryPath, root);
771
+ assertRetainedManagedDirectoryIdentity(scope);
772
+ const root = scopeRoot(scope, policy);
773
+ ensureManagedDirectory(scope.sessionsRoot, root, policy);
774
+ ensureManagedDirectory(scope.directoryPath, root, policy);
602
775
  const bindingPath = path.join(scope.directoryPath, MANAGED_SESSION_BINDING_FILE);
603
776
  const binding = `${JSON.stringify(bindingFor(scope))}\n`;
777
+ let bindingCollision = false;
604
778
  try {
605
- await publishManagedFileNoReplace(bindingPath, new TextEncoder().encode(binding), undefined, root);
779
+ await publishManagedFileNoReplace(bindingPath, new TextEncoder().encode(binding), undefined, root, policy);
606
780
  } catch (error) {
607
781
  if ((error as Error).message !== "destination_conflict") throw error;
782
+ bindingCollision = true;
608
783
  }
609
- return validateExistingBinding(scope) ?? { kind: "resolved", scope };
784
+ const validated = validateExistingBinding(scope);
785
+ if (validated) return validated;
786
+ if (bindingCollision) fsyncCanonicalBinding(bindingPath, binding);
787
+
788
+ const preparedDirectory = fs.lstatSync(scope.directoryPath, { bigint: true });
789
+ if (!preparedDirectory.isDirectory() || preparedDirectory.isSymbolicLink())
790
+ throw new Error("Managed session directory changed");
791
+ managedDirectoryIdentities.set(scope, { dev: preparedDirectory.dev, ino: preparedDirectory.ino });
792
+ return { kind: "resolved", scope };
610
793
  } catch (error) {
794
+ const publication = error instanceof ManagedPublishError ? error : undefined;
795
+ const message =
796
+ publication?.classification ??
797
+ (error instanceof Error ? error.message : "The managed scope could not be initialized.");
798
+ const code =
799
+ message === "atomic_unavailable" ||
800
+ message === "invalid_request" ||
801
+ message === "durability_failed" ||
802
+ message === "durability_not_provable"
803
+ ? message
804
+ : "binding_invalid";
805
+
611
806
  return {
612
807
  kind: "error",
613
- code: "binding_invalid",
614
- message: error instanceof Error ? error.message : "The managed scope could not be initialized.",
808
+ code,
809
+ message,
810
+ ...(publication
811
+ ? { cause: { classification: publication.classification, diagnostic: publication.diagnostic } }
812
+ : { cause: { classification: code } }),
615
813
  };
616
814
  }
617
815
  }
618
816
 
817
+ /**
818
+ * Re-apply owner-only security to every descendant of a managed scope directory.
819
+ *
820
+ * A managed scope can accumulate group/other-readable descendants when a
821
+ * different code path writes into it without the secured managed-storage
822
+ * helpers — notably the resident-cache `EphemeralBlobStore` created on the
823
+ * explicit session path. The managed-tree snapshot fails closed on the first
824
+ * such descendant (`mode_mismatch`), which would otherwise abort launch with an
825
+ * uncaught exception. Re-securing the tree in place lets a drifted scope
826
+ * recover on the next launch instead of trapping the user behind a fatal error.
827
+ */
828
+ function reapplyOwnerOnlyManagedTree(directory: string): void {
829
+ let entries: fs.Dirent[];
830
+ try {
831
+ entries = fs.readdirSync(directory, { withFileTypes: true });
832
+ } catch {
833
+ return;
834
+ }
835
+ for (const entry of entries) {
836
+ const child = path.join(directory, entry.name);
837
+ let stat: fs.Stats;
838
+ try {
839
+ stat = fs.lstatSync(child);
840
+ } catch {
841
+ continue;
842
+ }
843
+ if (stat.isSymbolicLink()) continue;
844
+ if (stat.isDirectory()) {
845
+ reapplyOwnerOnlyManagedTree(child);
846
+ try {
847
+ native.applyOwnerOnlyPathSecurity(child, "directory");
848
+ } catch {
849
+ // Best-effort: the managed-tree snapshot re-verifies and reports genuine failures.
850
+ }
851
+ } else if (stat.isFile()) {
852
+ try {
853
+ native.applyOwnerOnlyPathSecurity(child, "file");
854
+ } catch {
855
+ // Best-effort, as above.
856
+ }
857
+ }
858
+ }
859
+ try {
860
+ native.applyOwnerOnlyPathSecurity(directory, "directory");
861
+ } catch {
862
+ // Best-effort, as above.
863
+ }
864
+ }
865
+
866
+ /**
867
+ * True when a managed setup error reflects a fixable owner-only *mode* drift
868
+ * (group/other permission bits) rather than an ownership or identity change.
869
+ * Only mode drift can be self-healed by re-applying owner-only permissions.
870
+ */
871
+ function isRecoverableOwnerOnlyModeDrift(error: unknown): boolean {
872
+ const message = error instanceof Error ? error.message : "";
873
+ return message === "mode_mismatch" || message.endsWith(": mode_mismatch");
874
+ }
875
+
619
876
  /** Synchronously create and validate the v2 binding before a default session writer exists. */
620
- export function prepareManagedSessionScopeForWriteSync(scope: ManagedScope): ManagedScopeResolution {
877
+ export function prepareManagedSessionScopeForWriteSync(
878
+ scope: ManagedScope,
879
+ policy: ManagedSessionSecurityPolicy = "default",
880
+ authority?: ManagedCandidateWriteAuthority,
881
+ ): ManagedScopeResolution {
621
882
  try {
622
- const root = scopeRoot(scope);
623
- ensureManagedDirectory(scope.sessionsRoot, root);
624
- ensureManagedDirectory(scope.directoryPath, root);
883
+ assertRetainedManagedDirectoryIdentity(scope);
884
+ const root = authority?.rootAuthority ?? scopeRoot(scope, policy);
885
+ if (authority) bindManagedWriteAuthority(scope, authority);
886
+ ensureManagedDirectory(scope.sessionsRoot, root, policy);
887
+ ensureManagedDirectory(scope.directoryPath, root, policy);
625
888
  const preparedDirectory = fs.lstatSync(scope.directoryPath, { bigint: true });
626
889
  if (!preparedDirectory.isDirectory() || preparedDirectory.isSymbolicLink())
627
890
  throw new Error("Managed session directory changed");
628
- const retainedAuthority = retainManagedDirectoryAuthority(root, scope.directoryPath, {
629
- dev: preparedDirectory.dev,
630
- ino: preparedDirectory.ino,
631
- });
632
- const store = new ManagedSessionDescendantStore(
633
- root,
634
- scope.directoryPath,
635
- retainedAuthority ? { authority: retainedAuthority, authorityBaseDir: scope.directoryPath } : undefined,
636
- );
891
+ const retainedAuthority =
892
+ authority?.retainedAuthority &&
893
+ authority.retainedDirectory !== undefined &&
894
+ path.resolve(authority.retainedDirectory) === path.resolve(scope.directoryPath)
895
+ ? authority.retainedAuthority
896
+ : retainManagedDirectoryAuthority(root, scope.directoryPath, {
897
+ dev: preparedDirectory.dev,
898
+ ino: preparedDirectory.ino,
899
+ });
900
+ const buildStore = () =>
901
+ new ManagedSessionDescendantStore(
902
+ root,
903
+ scope.directoryPath,
904
+ retainedAuthority ? { authority: retainedAuthority, authorityBaseDir: scope.directoryPath } : undefined,
905
+ policy,
906
+ );
907
+ let store: ManagedSessionDescendantStore;
908
+ try {
909
+ store = buildStore();
910
+ } catch (error) {
911
+ if (process.platform === "win32" && policy === "windows-existing-verify-first") throw error;
912
+ if (!isRecoverableOwnerOnlyModeDrift(error)) throw error;
913
+ // A prior writer left group/other-readable descendants under the scope
914
+ // (e.g. resident-cache blobs written on the explicit session path).
915
+ // Re-secure the tree in place and retry once before failing closed.
916
+ reapplyOwnerOnlyManagedTree(scope.directoryPath);
917
+ store = buildStore();
918
+ }
637
919
  const binding = new TextEncoder().encode(`${JSON.stringify(bindingFor(scope))}\n`);
638
920
  try {
639
921
  store.publishNoReplaceSync(MANAGED_SESSION_BINDING_FILE, binding);
@@ -655,16 +937,32 @@ export function prepareManagedSessionScopeForWriteSync(scope: ManagedScope): Man
655
937
  managedDirectoryIdentities.set(scope, { dev: preparedDirectory.dev, ino: preparedDirectory.ino });
656
938
  if (validated) return validated;
657
939
  const internal = managedInternalDirectory(scope);
658
- ensureManagedDirectory(internal, root);
659
- ensureManagedDirectory(path.join(internal, MANAGED_LOCKS_DIRECTORY), root);
660
- ensureManagedDirectory(path.join(internal, MANAGED_RECEIPTS_DIRECTORY), root);
661
- ensureManagedDirectory(path.join(internal, MANAGED_TOMBSTONES_DIRECTORY), root);
940
+ ensureManagedDirectory(internal, root, policy);
941
+ ensureManagedDirectory(path.join(internal, MANAGED_LOCKS_DIRECTORY), root, policy);
942
+ ensureManagedDirectory(path.join(internal, MANAGED_RECEIPTS_DIRECTORY), root, policy);
943
+ ensureManagedDirectory(path.join(internal, MANAGED_TOMBSTONES_DIRECTORY), root, policy);
662
944
  return { kind: "resolved", scope };
663
945
  } catch (error) {
946
+ const publication = error instanceof ManagedPublishError ? error : undefined;
947
+ const message =
948
+ publication?.classification ??
949
+ (error instanceof Error ? error.message : "Managed write protocol setup failed.");
950
+ const code =
951
+ message === "atomic_unavailable" ||
952
+ message === "invalid_request" ||
953
+ message === "durability_failed" ||
954
+ message === "durability_not_provable"
955
+ ? message
956
+ : "binding_invalid";
664
957
  return {
665
958
  kind: "error",
666
- code: "binding_invalid",
667
- message: error instanceof Error ? error.message : "Managed write protocol setup failed.",
959
+ code,
960
+ message,
961
+ ...(publication
962
+ ? { cause: { classification: publication.classification, diagnostic: publication.diagnostic } }
963
+ : code === "binding_invalid"
964
+ ? {}
965
+ : { cause: { classification: code } }),
668
966
  };
669
967
  }
670
968
  }
@@ -787,7 +1085,11 @@ function expectedFailure(error: unknown): ManagedOpenFailure {
787
1085
  message === "destination_conflict" ||
788
1086
  message === "source_changed" ||
789
1087
  message === "unsafe_artifacts" ||
1088
+ message === "artifact_capacity_exceeded" ||
790
1089
  message === "durability_failed" ||
1090
+ message === "atomic_unavailable" ||
1091
+ message === "invalid_request" ||
1092
+ message === "durability_not_provable" ||
791
1093
  message === "migration_retired"
792
1094
  ? message
793
1095
  : "managed_storage_unsupported";
@@ -804,10 +1106,33 @@ function sameCandidate(left: ManagedCandidate, right: ManagedCandidate): boolean
804
1106
  );
805
1107
  }
806
1108
 
1109
+ function matchesExpectedResumeIdentity(candidate: ManagedCandidate, expected: ResumeSessionIdentity): boolean {
1110
+ return (
1111
+ path.resolve(candidate.identity.canonicalPath) === path.resolve(expected.canonicalPath) &&
1112
+ candidate.identity.sessionId === expected.sessionId &&
1113
+ candidate.identity.dev === expected.dev &&
1114
+ candidate.identity.ino === expected.ino &&
1115
+ candidate.identity.size === expected.size &&
1116
+ candidate.identity.mtimeMs === expected.mtimeMs &&
1117
+ candidate.identity.mtimeNs === expected.mtimeNs &&
1118
+ candidate.identity.sha256 === expected.sha256
1119
+ );
1120
+ }
1121
+
1122
+ function revalidatePickerConsent(
1123
+ scope: ManagedScope,
1124
+ candidate: ManagedCandidate,
1125
+ expectedIdentity: ResumeSessionIdentity,
1126
+ ): ManagedCandidate {
1127
+ const current = validateCandidateForScope(scope, candidate);
1128
+ if (!current || !matchesExpectedResumeIdentity(current, expectedIdentity)) throw new Error("source_changed");
1129
+ return current;
1130
+ }
1131
+
807
1132
  function receiptPathFor(
808
1133
  scope: ManagedScope,
809
1134
  source: ManagedCandidate,
810
- state: "prepared" | "published" | "committed" = "committed",
1135
+ state: "prepared" | "detached" | "published" | "committed" = "committed",
811
1136
  ): string {
812
1137
  const suffix = state === "committed" ? "" : `.${state}`;
813
1138
  return path.join(
@@ -846,6 +1171,14 @@ function receiptMatches(
846
1171
  identity?: { dev?: unknown; ino?: unknown; size?: unknown; mtimeNs?: unknown };
847
1172
  };
848
1173
  artifactManifest?: unknown;
1174
+ sourceArtifactQuarantine?: { role?: unknown };
1175
+ sourceArtifactCleanup?: {
1176
+ state?: unknown;
1177
+ role?: unknown;
1178
+ retainedPath?: unknown;
1179
+ identity?: { dev?: unknown; ino?: unknown; size?: unknown; mtimeNs?: unknown };
1180
+ tree?: unknown;
1181
+ };
849
1182
  };
850
1183
  const exact = (
851
1184
  recorded: { dev?: unknown; ino?: unknown; size?: unknown; mtimeNs?: unknown } | undefined,
@@ -886,12 +1219,43 @@ function receiptMatches(
886
1219
  item.size >= 0
887
1220
  );
888
1221
  });
1222
+ const quarantine = record.sourceArtifactQuarantine;
1223
+ const cleanup = record.sourceArtifactCleanup;
1224
+ const cleanupIdentity = cleanup?.identity;
1225
+ const cleanupTree = artifactTreeSnapshot(cleanup?.tree);
1226
+ const validCleanup =
1227
+ cleanup === undefined ||
1228
+ (quarantine?.role === "detached_artifact_root" &&
1229
+ cleanup.state === "cleanup_pending" &&
1230
+ cleanup.role === "exchange_placeholder" &&
1231
+ typeof cleanup.retainedPath === "string" &&
1232
+ cleanupIdentity?.dev !== undefined &&
1233
+ cleanupIdentity.ino !== undefined &&
1234
+ cleanupIdentity.size !== undefined &&
1235
+ cleanupIdentity.mtimeNs !== undefined &&
1236
+ !!cleanupTree &&
1237
+ cleanupAuthorityMatches(
1238
+ {
1239
+ state: "cleanup_pending",
1240
+ role: "exchange_placeholder",
1241
+ retainedPath: cleanup.retainedPath,
1242
+ identity: {
1243
+ dev: BigInt(String(cleanupIdentity.dev)),
1244
+ ino: BigInt(String(cleanupIdentity.ino)),
1245
+ size: BigInt(String(cleanupIdentity.size)),
1246
+ mtimeNs: BigInt(String(cleanupIdentity.mtimeNs)),
1247
+ },
1248
+ tree: cleanupTree,
1249
+ },
1250
+ path.dirname(source.path),
1251
+ ));
889
1252
  return (
890
1253
  record.schemaVersion === 2 &&
891
1254
  record.state === "committed" &&
892
1255
  record.policy === "copy-retain" &&
893
1256
  record.scope === scopeDigest(scope.platform, scope.canonicalCwd) &&
894
1257
  validManifest &&
1258
+ validCleanup &&
895
1259
  manifestContains(destination.path, manifest as readonly ArtifactManifestEntry[]) &&
896
1260
  record.source?.path === source.path &&
897
1261
  record.source?.sessionId === source.sessionId &&
@@ -1034,6 +1398,65 @@ function retiredTargets(scope: ManagedScope, pathname: string): readonly Retired
1034
1398
  * a returned partial result appends `pending(N + 1)` with the observed detached
1035
1399
  * identity/path. Restart accepts only a contiguous, target-bound sequence.
1036
1400
  */
1401
+ type RetainedArtifactsRootReceipt = {
1402
+ path: string;
1403
+ identity: SessionStorageFileIdentity;
1404
+ tree: NativeDirectoryTreeSnapshot;
1405
+ };
1406
+
1407
+ function retainedArtifactsRootReceipt(receipt: CleanupReceipt): RetainedArtifactsRootReceipt | undefined {
1408
+ const pathname = deterministicRemovalRoot(receipt.plannedArtifactsPath);
1409
+ if (!fs.existsSync(pathname)) return undefined;
1410
+ if (!receipt.expectedArtifactsIdentity) throw new Error("durability_failed");
1411
+ const identity = artifactIdentityAt(pathname);
1412
+ if (!identity || !sameArtifactRootIdentity(identity, receipt.expectedArtifactsIdentity))
1413
+ throw new Error("durability_failed");
1414
+ const tree = snapshotArtifactTree(pathname);
1415
+ return { path: pathname, identity, tree };
1416
+ }
1417
+
1418
+ function retainedArtifactsRootMatches(record: Record<string, unknown>): boolean {
1419
+ if (record.retainedArtifactsRoot === undefined) return true;
1420
+ if (!record.retainedArtifactsRoot || typeof record.retainedArtifactsRoot !== "object")
1421
+ throw new Error("durability_failed");
1422
+ const retained = record.retainedArtifactsRoot as Record<string, unknown>;
1423
+ const identity = retained.identity;
1424
+ const tree = artifactTreeSnapshot(retained.tree);
1425
+ if (
1426
+ typeof record.plannedArtifactsPath !== "string" ||
1427
+ retained.path !== deterministicRemovalRoot(record.plannedArtifactsPath) ||
1428
+ !identity ||
1429
+ typeof identity !== "object" ||
1430
+ Array.isArray(identity) ||
1431
+ !tree
1432
+ )
1433
+ throw new Error("durability_failed");
1434
+ const artifactIdentity = identity as Record<string, unknown>;
1435
+ if (
1436
+ typeof artifactIdentity.dev !== "string" ||
1437
+ typeof artifactIdentity.ino !== "string" ||
1438
+ typeof artifactIdentity.size !== "number" ||
1439
+ typeof artifactIdentity.mtimeNs !== "string" ||
1440
+ typeof artifactIdentity.sha256 !== "string"
1441
+ )
1442
+ throw new Error("durability_failed");
1443
+ if (!fs.existsSync(retained.path)) return true;
1444
+ const observed = artifactIdentityAt(retained.path);
1445
+ if (
1446
+ !observed ||
1447
+ !sameArtifactRootIdentity(observed, {
1448
+ dev: BigInt(artifactIdentity.dev),
1449
+ ino: BigInt(artifactIdentity.ino),
1450
+ size: artifactIdentity.size,
1451
+ mtimeNs: BigInt(artifactIdentity.mtimeNs),
1452
+ sha256: artifactIdentity.sha256,
1453
+ }) ||
1454
+ JSON.stringify(snapshotArtifactTree(retained.path)) !== JSON.stringify(tree)
1455
+ )
1456
+ throw new Error("durability_failed");
1457
+ return true;
1458
+ }
1459
+
1037
1460
  type CleanupReceipt = {
1038
1461
  attempt: number;
1039
1462
  target: RetiredTarget;
@@ -1041,6 +1464,13 @@ type CleanupReceipt = {
1041
1464
  expectedArtifactsTree?: NativeDirectoryTreeSnapshot;
1042
1465
  detachedArtifactsPath?: string;
1043
1466
  detachedTranscriptPath?: string;
1467
+ retainedArtifactsSuccessorPath?: string;
1468
+ retainedArtifactsPlaceholderPath?: string;
1469
+ retainedArtifactsUnknownPath?: string;
1470
+ retainedTranscriptSuccessorPath?: string;
1471
+ retainedTranscriptPlaceholderPath?: string;
1472
+ retainedTranscriptUnknownPath?: string;
1473
+
1044
1474
  plannedArtifactsPath: string;
1045
1475
  plannedTranscriptPath: string;
1046
1476
  };
@@ -1074,6 +1504,24 @@ function cleanupReceipt(scope: ManagedScope, tombstone: string, receipt: Cleanup
1074
1504
  ...(receipt.expectedArtifactsTree ? { expectedArtifactsTree: receipt.expectedArtifactsTree } : {}),
1075
1505
  ...(receipt.detachedArtifactsPath ? { detachedArtifactsPath: receipt.detachedArtifactsPath } : {}),
1076
1506
  ...(receipt.detachedTranscriptPath ? { detachedTranscriptPath: receipt.detachedTranscriptPath } : {}),
1507
+ ...(receipt.retainedArtifactsSuccessorPath
1508
+ ? { retainedArtifactsSuccessorPath: receipt.retainedArtifactsSuccessorPath }
1509
+ : {}),
1510
+ ...(receipt.retainedArtifactsPlaceholderPath
1511
+ ? { retainedArtifactsPlaceholderPath: receipt.retainedArtifactsPlaceholderPath }
1512
+ : {}),
1513
+ ...(receipt.retainedArtifactsUnknownPath
1514
+ ? { retainedArtifactsUnknownPath: receipt.retainedArtifactsUnknownPath }
1515
+ : {}),
1516
+ ...(receipt.retainedTranscriptSuccessorPath
1517
+ ? { retainedTranscriptSuccessorPath: receipt.retainedTranscriptSuccessorPath }
1518
+ : {}),
1519
+ ...(receipt.retainedTranscriptPlaceholderPath
1520
+ ? { retainedTranscriptPlaceholderPath: receipt.retainedTranscriptPlaceholderPath }
1521
+ : {}),
1522
+ ...(receipt.retainedTranscriptUnknownPath
1523
+ ? { retainedTranscriptUnknownPath: receipt.retainedTranscriptUnknownPath }
1524
+ : {}),
1077
1525
  plannedArtifactsPath: receipt.plannedArtifactsPath,
1078
1526
  plannedTranscriptPath: receipt.plannedTranscriptPath,
1079
1527
  };
@@ -1094,6 +1542,7 @@ function cleanupArtifactsRemoved(
1094
1542
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1095
1543
  const record = value as Record<string, unknown>;
1096
1544
  const recorded = record.target as Record<string, unknown> | undefined;
1545
+ const identity = recorded?.identity as Record<string, unknown> | undefined;
1097
1546
  return (
1098
1547
  record.schemaVersion === 2 &&
1099
1548
  record.state === "artifacts_removed" &&
@@ -1102,9 +1551,17 @@ function cleanupArtifactsRemoved(
1102
1551
  record.attempt === attempt &&
1103
1552
  recorded?.path === target.path &&
1104
1553
  recorded.sessionId === target.sessionId &&
1105
- recorded.cwd === target.cwd
1554
+ recorded.cwd === target.cwd &&
1555
+ identity?.canonicalPath === target.identity.canonicalPath &&
1556
+ identity.dev === String(target.identity.dev) &&
1557
+ identity.ino === String(target.identity.ino) &&
1558
+ identity.size === target.identity.size &&
1559
+ identity.mtimeNs === String(target.identity.mtimeNs) &&
1560
+ identity.sha256 === target.identity.sha256 &&
1561
+ retainedArtifactsRootMatches(record)
1106
1562
  );
1107
- } catch {
1563
+ } catch (error) {
1564
+ if ((error as Error).message === "durability_failed") throw error;
1108
1565
  return false;
1109
1566
  }
1110
1567
  }
@@ -1115,9 +1572,14 @@ async function publishCleanupArtifactsRemoved(
1115
1572
  receipt: CleanupReceipt,
1116
1573
  lock: ManagedStorageLock,
1117
1574
  ): Promise<void> {
1575
+ const retainedArtifactsRoot = retainedArtifactsRootReceipt(receipt);
1118
1576
  await publishManagedTombstone(
1119
1577
  cleanupReceiptPath(tombstone, receipt.target, "artifacts_removed", receipt.attempt),
1120
- { ...cleanupReceipt(scope, tombstone, receipt), state: "artifacts_removed" },
1578
+ {
1579
+ ...cleanupReceipt(scope, tombstone, receipt),
1580
+ state: "artifacts_removed",
1581
+ ...(retainedArtifactsRoot ? { retainedArtifactsRoot } : {}),
1582
+ },
1121
1583
  lock.assertOwned,
1122
1584
  ).catch(error => {
1123
1585
  if ((error as Error).message !== "destination_conflict") throw error;
@@ -1134,6 +1596,14 @@ function isQuarantinePath(target: RetiredTarget, pathname: unknown): pathname is
1134
1596
  );
1135
1597
  }
1136
1598
 
1599
+ function isRetainedNativePath(target: RetiredTarget, pathname: unknown): pathname is string {
1600
+ return (
1601
+ typeof pathname === "string" &&
1602
+ path.dirname(pathname) === path.dirname(target.path) &&
1603
+ path.basename(pathname).startsWith(".skc-")
1604
+ );
1605
+ }
1606
+
1137
1607
  function deterministicRemovalRoot(plannedRoot: string): string {
1138
1608
  return `${plannedRoot}.removing`;
1139
1609
  }
@@ -1145,17 +1615,44 @@ function isAuthorizedArtifactRoot(target: RetiredTarget, plannedRoot: string, pa
1145
1615
  );
1146
1616
  }
1147
1617
 
1148
- function artifactRootsAbsent(
1149
- _scope: ManagedScope,
1150
- tombstone: string,
1151
- target: RetiredTarget,
1152
- pending: CleanupReceipt,
1153
- ): boolean {
1618
+ function transcriptRootMatchesTarget(pathname: string, target: RetiredTarget): boolean {
1619
+ try {
1620
+ const observed = captureManagedFileNoFollow(pathname);
1621
+ const digest = createHash("sha256").update(observed.bytes).digest("hex");
1622
+ return (
1623
+ observed.identity.dev === target.identity.dev &&
1624
+ observed.identity.ino === target.identity.ino &&
1625
+ observed.identity.size === target.identity.size &&
1626
+ observed.identity.mtimeNs === target.identity.mtimeNs &&
1627
+ digest === target.identity.sha256
1628
+ );
1629
+ } catch (error) {
1630
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
1631
+ throw new Error("durability_failed");
1632
+ }
1633
+ }
1634
+
1635
+ function cleanupRootsAbsent(tombstone: string, target: RetiredTarget, pending: CleanupReceipt): boolean {
1154
1636
  const prefix = `${path.basename(tombstone, ".json")}.${stableOperationName(target)}.cleanup-pending-`;
1637
+ const activeTranscriptRoots = new Set(
1638
+ [pending.plannedTranscriptPath, pending.detachedTranscriptPath].filter((pathname): pathname is string =>
1639
+ isQuarantinePath(target, pathname),
1640
+ ),
1641
+ );
1155
1642
  const roots = new Set<string>([
1643
+ target.path,
1644
+ target.path.slice(0, -6),
1156
1645
  pending.plannedArtifactsPath,
1157
1646
  deterministicRemovalRoot(pending.plannedArtifactsPath),
1158
1647
  ...(pending.detachedArtifactsPath ? [pending.detachedArtifactsPath] : []),
1648
+ ...[
1649
+ pending.retainedArtifactsSuccessorPath,
1650
+ pending.retainedArtifactsPlaceholderPath,
1651
+ pending.retainedArtifactsUnknownPath,
1652
+ pending.retainedTranscriptSuccessorPath,
1653
+ pending.retainedTranscriptPlaceholderPath,
1654
+ pending.retainedTranscriptUnknownPath,
1655
+ ].filter((pathname): pathname is string => isRetainedNativePath(target, pathname)),
1159
1656
  ]);
1160
1657
  for (const name of fs.readdirSync(path.dirname(tombstone))) {
1161
1658
  if (!name.startsWith(prefix) || !name.endsWith(".json")) continue;
@@ -1164,26 +1661,91 @@ function artifactRootsAbsent(
1164
1661
  ) as {
1165
1662
  plannedArtifactsPath?: unknown;
1166
1663
  detachedArtifactsPath?: unknown;
1664
+ plannedTranscriptPath?: unknown;
1665
+ detachedTranscriptPath?: unknown;
1666
+ retainedArtifactsSuccessorPath?: unknown;
1667
+ retainedArtifactsPlaceholderPath?: unknown;
1668
+ retainedArtifactsUnknownPath?: unknown;
1669
+ retainedTranscriptSuccessorPath?: unknown;
1670
+ retainedTranscriptPlaceholderPath?: unknown;
1671
+ retainedTranscriptUnknownPath?: unknown;
1167
1672
  };
1168
1673
  if (isQuarantinePath(target, record.plannedArtifactsPath)) {
1169
1674
  roots.add(record.plannedArtifactsPath);
1170
1675
  roots.add(deterministicRemovalRoot(record.plannedArtifactsPath));
1171
1676
  }
1172
1677
  if (isQuarantinePath(target, record.detachedArtifactsPath)) roots.add(record.detachedArtifactsPath);
1678
+ for (const pathname of [record.plannedTranscriptPath, record.detachedTranscriptPath]) {
1679
+ if (isQuarantinePath(target, pathname) && !activeTranscriptRoots.has(pathname)) roots.add(pathname);
1680
+ }
1681
+ for (const pathname of [
1682
+ record.retainedArtifactsSuccessorPath,
1683
+ record.retainedArtifactsPlaceholderPath,
1684
+ record.retainedArtifactsUnknownPath,
1685
+ record.retainedTranscriptSuccessorPath,
1686
+ record.retainedTranscriptPlaceholderPath,
1687
+ record.retainedTranscriptUnknownPath,
1688
+ ]) {
1689
+ if (isRetainedNativePath(target, pathname)) roots.add(pathname);
1690
+ }
1691
+ }
1692
+ for (const pathname of roots) {
1693
+ try {
1694
+ fs.lstatSync(pathname);
1695
+ throw new Error("durability_failed");
1696
+ } catch (error) {
1697
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
1698
+ throw error;
1699
+ }
1173
1700
  }
1174
- return [...roots].every(pathname => !fs.existsSync(pathname));
1701
+ for (const pathname of activeTranscriptRoots) {
1702
+ try {
1703
+ fs.lstatSync(pathname);
1704
+ } catch (error) {
1705
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
1706
+ throw error;
1707
+ }
1708
+ if (!transcriptRootMatchesTarget(pathname, target)) throw new Error("durability_failed");
1709
+ return false;
1710
+ }
1711
+ return true;
1175
1712
  }
1176
1713
 
1177
1714
  function sameArtifactRootIdentity(left: SessionStorageFileIdentity, right: SessionStorageFileIdentity): boolean {
1178
1715
  return left.dev === right.dev && left.ino === right.ino;
1179
1716
  }
1180
1717
 
1181
- /**
1182
- * A pending receipt is written before the native no-replace detach. If a crash
1183
- * happens after that detach, the next receipt may only inherit the exact
1184
- * planned quarantine pathname and identity from its predecessor.
1185
- */
1718
+ function sameArtifactTreeSnapshot(left: NativeDirectoryTreeSnapshot, right: NativeDirectoryTreeSnapshot): boolean {
1719
+ if (left.rootDev !== right.rootDev || left.rootIno !== right.rootIno || left.entries.length !== right.entries.length)
1720
+ return false;
1721
+ const entryKey = (entry: NativeDirectoryTreeSnapshot["entries"][number]): string =>
1722
+ JSON.stringify([entry.relativePath, entry.kind, entry.dev, entry.ino, entry.size, entry.mtimeNs, entry.sha256]);
1723
+ const leftEntries = left.entries.map(entryKey).sort();
1724
+ const rightEntries = right.entries.map(entryKey).sort();
1725
+ return leftEntries.every((entry, index) => entry === rightEntries[index]);
1726
+ }
1727
+
1728
+ function assertRetainedArtifactsAuthority(pending: CleanupReceipt): void {
1729
+ if (!pending.detachedArtifactsPath) return;
1730
+ if (!pending.expectedArtifactsIdentity || !pending.expectedArtifactsTree) throw new Error("durability_failed");
1731
+ try {
1732
+ fs.lstatSync(pending.detachedArtifactsPath);
1733
+ } catch (error) {
1734
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
1735
+ throw new Error("durability_failed");
1736
+ }
1737
+ const observed = artifactIdentityAt(pending.detachedArtifactsPath);
1738
+ if (
1739
+ !observed ||
1740
+ !sameArtifactRootIdentity(observed, pending.expectedArtifactsIdentity) ||
1741
+ !sameArtifactTreeSnapshot(snapshotArtifactTree(pending.detachedArtifactsPath), pending.expectedArtifactsTree)
1742
+ )
1743
+ throw new Error("binding_invalid");
1744
+ }
1745
+
1186
1746
  function probePlannedCleanupDetach(target: RetiredTarget, pending: CleanupReceipt): CleanupReceipt {
1747
+ assertRetainedArtifactsAuthority(pending);
1748
+
1187
1749
  let detachedArtifactsPath = pending.detachedArtifactsPath;
1188
1750
  let detachedTranscriptPath = pending.detachedTranscriptPath;
1189
1751
  for (const pathname of [pending.plannedArtifactsPath, deterministicRemovalRoot(pending.plannedArtifactsPath)]) {
@@ -1247,6 +1809,16 @@ function artifactTreeSnapshot(value: unknown): NativeDirectoryTreeSnapshot | und
1247
1809
  })
1248
1810
  )
1249
1811
  return undefined;
1812
+ const roots = snapshot.entries.filter(entry => {
1813
+ const item = entry as Record<string, unknown>;
1814
+ return (
1815
+ item.relativePath === "" &&
1816
+ item.kind === "directory" &&
1817
+ item.dev === snapshot.rootDev &&
1818
+ item.ino === snapshot.rootIno
1819
+ );
1820
+ });
1821
+ if (roots.length !== 1) return undefined;
1250
1822
  return snapshot as unknown as NativeDirectoryTreeSnapshot;
1251
1823
  }
1252
1824
 
@@ -1298,7 +1870,19 @@ function pendingCleanupReceipt(
1298
1870
  plannedPaths.has(record.plannedArtifactsPath as string) ||
1299
1871
  plannedPaths.has(record.plannedTranscriptPath as string) ||
1300
1872
  (record.detachedArtifactsPath !== undefined && !isQuarantinePath(target, record.detachedArtifactsPath)) ||
1301
- (record.detachedTranscriptPath !== undefined && !isQuarantinePath(target, record.detachedTranscriptPath))
1873
+ (record.detachedTranscriptPath !== undefined && !isQuarantinePath(target, record.detachedTranscriptPath)) ||
1874
+ (record.retainedArtifactsSuccessorPath !== undefined &&
1875
+ !isRetainedNativePath(target, record.retainedArtifactsSuccessorPath)) ||
1876
+ (record.retainedArtifactsPlaceholderPath !== undefined &&
1877
+ !isRetainedNativePath(target, record.retainedArtifactsPlaceholderPath)) ||
1878
+ (record.retainedArtifactsUnknownPath !== undefined &&
1879
+ !isRetainedNativePath(target, record.retainedArtifactsUnknownPath)) ||
1880
+ (record.retainedTranscriptSuccessorPath !== undefined &&
1881
+ !isRetainedNativePath(target, record.retainedTranscriptSuccessorPath)) ||
1882
+ (record.retainedTranscriptPlaceholderPath !== undefined &&
1883
+ !isRetainedNativePath(target, record.retainedTranscriptPlaceholderPath)) ||
1884
+ (record.retainedTranscriptUnknownPath !== undefined &&
1885
+ !isRetainedNativePath(target, record.retainedTranscriptUnknownPath))
1302
1886
  )
1303
1887
  throw new Error("durability_failed");
1304
1888
  const artifact = record.expectedArtifactsIdentity as Record<string, unknown> | undefined;
@@ -1321,7 +1905,7 @@ function pendingCleanupReceipt(
1321
1905
  const expectedArtifactsTree =
1322
1906
  record.expectedArtifactsTree === undefined ? undefined : artifactTreeSnapshot(record.expectedArtifactsTree);
1323
1907
  if (record.expectedArtifactsTree !== undefined && !expectedArtifactsTree) throw new Error("durability_failed");
1324
- if (record.detachedArtifactsPath !== undefined && !expectedArtifactsTree) throw new Error("durability_failed");
1908
+
1325
1909
  if (
1326
1910
  latest &&
1327
1911
  ((record.detachedArtifactsPath !== undefined &&
@@ -1340,6 +1924,12 @@ function pendingCleanupReceipt(
1340
1924
  expectedArtifactsTree,
1341
1925
  detachedArtifactsPath: record.detachedArtifactsPath as string | undefined,
1342
1926
  detachedTranscriptPath: record.detachedTranscriptPath as string | undefined,
1927
+ retainedArtifactsSuccessorPath: record.retainedArtifactsSuccessorPath as string | undefined,
1928
+ retainedArtifactsPlaceholderPath: record.retainedArtifactsPlaceholderPath as string | undefined,
1929
+ retainedArtifactsUnknownPath: record.retainedArtifactsUnknownPath as string | undefined,
1930
+ retainedTranscriptSuccessorPath: record.retainedTranscriptSuccessorPath as string | undefined,
1931
+ retainedTranscriptPlaceholderPath: record.retainedTranscriptPlaceholderPath as string | undefined,
1932
+ retainedTranscriptUnknownPath: record.retainedTranscriptUnknownPath as string | undefined,
1343
1933
  plannedArtifactsPath: record.plannedArtifactsPath as string,
1344
1934
  plannedTranscriptPath: record.plannedTranscriptPath as string,
1345
1935
  };
@@ -1389,7 +1979,13 @@ function nextCleanupReceipt(target: RetiredTarget, pending: CleanupReceipt | und
1389
1979
  expectedArtifactsIdentity,
1390
1980
  expectedArtifactsTree,
1391
1981
  detachedArtifactsPath: pending?.detachedArtifactsPath,
1982
+ retainedArtifactsSuccessorPath: pending?.retainedArtifactsSuccessorPath,
1983
+ retainedArtifactsPlaceholderPath: pending?.retainedArtifactsPlaceholderPath,
1984
+ retainedArtifactsUnknownPath: pending?.retainedArtifactsUnknownPath,
1392
1985
  detachedTranscriptPath: pending?.detachedTranscriptPath,
1986
+ retainedTranscriptSuccessorPath: pending?.retainedTranscriptSuccessorPath,
1987
+ retainedTranscriptPlaceholderPath: pending?.retainedTranscriptPlaceholderPath,
1988
+ retainedTranscriptUnknownPath: pending?.retainedTranscriptUnknownPath,
1393
1989
  plannedArtifactsPath: path.join(directory, `.skc-delete-${operation}-artifacts-${attempt}`),
1394
1990
  plannedTranscriptPath: path.join(directory, `.skc-delete-${operation}-transcript-${attempt}`),
1395
1991
  };
@@ -1402,6 +1998,36 @@ function requiresFreshCleanupPlan(pending: CleanupReceipt): boolean {
1402
1998
  );
1403
1999
  }
1404
2000
 
2001
+ /** Evidence-carrier for a verified `cleanup_pending` deletion, bound to the active plan. */
2002
+ function cleanupPendingEvidence(
2003
+ retry: CleanupReceipt,
2004
+ active: CleanupReceipt,
2005
+ deletion: Extract<VerifiedSessionDeleteResult, { kind: "cleanup_pending" }>,
2006
+ ): CleanupReceipt {
2007
+ return {
2008
+ ...retry,
2009
+ expectedArtifactsIdentity:
2010
+ deletion.phase === "artifacts" ? deletion.artifactsIdentity : active.expectedArtifactsIdentity,
2011
+ expectedArtifactsTree: deletion.phase === "artifacts" ? deletion.artifactsTree : active.expectedArtifactsTree,
2012
+ detachedArtifactsPath:
2013
+ deletion.phase === "artifacts" ? deletion.detachedArtifactsPath : active.detachedArtifactsPath,
2014
+ detachedTranscriptPath:
2015
+ deletion.phase === "transcript" ? deletion.detachedTranscriptPath : active.detachedTranscriptPath,
2016
+ retainedArtifactsSuccessorPath:
2017
+ deletion.phase === "artifacts" ? deletion.retainedSuccessorPath : active.retainedArtifactsSuccessorPath,
2018
+ retainedArtifactsPlaceholderPath:
2019
+ deletion.phase === "artifacts" ? deletion.retainedPlaceholderPath : active.retainedArtifactsPlaceholderPath,
2020
+ retainedArtifactsUnknownPath:
2021
+ deletion.phase === "artifacts" ? deletion.retainedUnknownPath : active.retainedArtifactsUnknownPath,
2022
+ retainedTranscriptSuccessorPath:
2023
+ deletion.phase === "transcript" ? deletion.retainedSuccessorPath : active.retainedTranscriptSuccessorPath,
2024
+ retainedTranscriptPlaceholderPath:
2025
+ deletion.phase === "transcript" ? deletion.retainedPlaceholderPath : active.retainedTranscriptPlaceholderPath,
2026
+ retainedTranscriptUnknownPath:
2027
+ deletion.phase === "transcript" ? deletion.retainedUnknownPath : active.retainedTranscriptUnknownPath,
2028
+ };
2029
+ }
2030
+
1405
2031
  async function publishCleanupPending(
1406
2032
  scope: ManagedScope,
1407
2033
  tombstone: string,
@@ -1653,11 +2279,19 @@ function planArtifactRootForMigration(sourceTranscript: string, operation: strin
1653
2279
  throw error;
1654
2280
  }
1655
2281
  if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("unsafe_artifacts");
2282
+ const tree = snapshotArtifactTree(originalPath);
2283
+ const root = tree.entries.find(entry => entry.relativePath === "" && entry.kind === "directory");
2284
+ if (!root) throw new Error("unsafe_artifacts");
1656
2285
  return {
1657
2286
  originalPath,
1658
2287
  detachedPath: path.join(path.dirname(originalPath), `.skc-migrate-${operation}-artifacts`),
1659
- identity: { dev: stat.dev, ino: stat.ino, size: stat.size, mtimeNs: stat.mtimeNs },
1660
- tree: snapshotArtifactTree(originalPath),
2288
+ identity: {
2289
+ dev: stat.dev,
2290
+ ino: stat.ino,
2291
+ size: process.platform === "win32" ? BigInt(root.size) : stat.size,
2292
+ mtimeNs: process.platform === "win32" ? BigInt(root.mtimeNs) : stat.mtimeNs,
2293
+ },
2294
+ tree,
1661
2295
  };
1662
2296
  }
1663
2297
 
@@ -1678,44 +2312,223 @@ function sameDirectoryObject(leftPath: string, rightPath: string): boolean {
1678
2312
  }
1679
2313
  }
1680
2314
 
1681
- function matchesDetachedArtifactRoot(pathname: string, plan: DetachedArtifactRoot): boolean {
2315
+ export function matchesMigrationArtifactRoot(
2316
+ pathname: string,
2317
+ identity: { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint },
2318
+ expectedTree: NativeDirectoryTreeSnapshot,
2319
+ platform: NodeJS.Platform = process.platform,
2320
+ ): boolean {
1682
2321
  try {
1683
2322
  const stat = fs.lstatSync(pathname, { bigint: true });
2323
+ if (!stat.isDirectory() || stat.isSymbolicLink() || stat.dev !== identity.dev || stat.ino !== identity.ino)
2324
+ return false;
2325
+ const observed = native.snapshotDirectoryTree(pathname);
2326
+ const expectedRoot = expectedTree.entries.find(entry => entry.relativePath === "" && entry.kind === "directory");
2327
+ const observedRoot = observed.snapshot?.entries.find(
2328
+ entry => entry.relativePath === "" && entry.kind === "directory",
2329
+ );
2330
+ if (
2331
+ !observed.ok ||
2332
+ !observed.snapshot ||
2333
+ !expectedRoot ||
2334
+ !observedRoot ||
2335
+ (platform === "win32" &&
2336
+ (observedRoot.dev !== identity.dev.toString() ||
2337
+ observedRoot.ino !== identity.ino.toString() ||
2338
+ BigInt(observedRoot.size) !== identity.size ||
2339
+ BigInt(observedRoot.mtimeNs) !== identity.mtimeNs)) ||
2340
+ (platform !== "win32" && (stat.size !== identity.size || stat.mtimeNs !== identity.mtimeNs))
2341
+ )
2342
+ return false;
2343
+ return sameArtifactTree(expectedTree, observed.snapshot, { platform, phase: "migration" });
2344
+ } catch {
2345
+ return false;
2346
+ }
2347
+ }
2348
+
2349
+ type ArtifactTreeComparisonPolicy = {
2350
+ platform?: NodeJS.Platform;
2351
+ phase?: "migration" | "strict";
2352
+ };
2353
+
2354
+ function sameArtifactTree(
2355
+ left: NativeDirectoryTreeSnapshot,
2356
+ right: NativeDirectoryTreeSnapshot,
2357
+ policy: ArtifactTreeComparisonPolicy = {},
2358
+ ): boolean {
2359
+ const { platform = process.platform, phase = "strict" } = policy;
2360
+ const entryKey = (entry: NativeDirectoryTreeSnapshot["entries"][number]): string => {
2361
+ // Detaching the root directory changes its ctime without changing the preserved artifact tree;
2362
+ // root identity and mtime are checked above. pi-iso also accepts Windows plain directories by
2363
+ // stable file id and kind only because NTFS can lazily update their metadata while enumeration
2364
+ // trails an open handle. All other platforms and nested directories compare every captured field.
2365
+ if (phase === "migration" && entry.kind === "directory" && (entry.relativePath === "" || platform === "win32"))
2366
+ return JSON.stringify([entry.relativePath, entry.kind, entry.dev, entry.ino]);
2367
+ return JSON.stringify([
2368
+ entry.relativePath,
2369
+ entry.kind,
2370
+ entry.dev,
2371
+ entry.ino,
2372
+ entry.size,
2373
+ entry.mtimeNs,
2374
+ entry.ctimeNs,
2375
+ entry.sha256,
2376
+ ]);
2377
+ };
2378
+ const leftEntries = left.entries.map(entryKey).sort();
2379
+ const rightEntries = right.entries.map(entryKey).sort();
2380
+ return (
2381
+ left.rootDev === right.rootDev &&
2382
+ left.rootIno === right.rootIno &&
2383
+ leftEntries.length === rightEntries.length &&
2384
+ leftEntries.every((entry, index) => entry === rightEntries[index])
2385
+ );
2386
+ }
2387
+
2388
+ function matchesDetachedArtifactRoot(pathname: string, plan: DetachedArtifactRoot): boolean {
2389
+ return (
2390
+ matchesMigrationArtifactRoot(pathname, plan.identity, plan.tree) &&
2391
+ sameDirectoryObject(path.dirname(pathname), path.dirname(plan.detachedPath))
2392
+ );
2393
+ }
2394
+
2395
+ type SourceArtifactCleanup = {
2396
+ state: "cleanup_pending";
2397
+ role: "exchange_placeholder";
2398
+ retainedPath: string;
2399
+ identity: DetachedArtifactRoot["identity"];
2400
+ tree: NativeDirectoryTreeSnapshot;
2401
+ };
2402
+
2403
+ export function cleanupAuthorityMatches(
2404
+ cleanup: SourceArtifactCleanup,
2405
+ parent: string,
2406
+ platform: NodeJS.Platform = process.platform,
2407
+ ): boolean {
2408
+ try {
2409
+ const stat = fs.lstatSync(cleanup.retainedPath, { bigint: true });
2410
+ if (
2411
+ path.dirname(cleanup.retainedPath) !== parent ||
2412
+ !stat.isDirectory() ||
2413
+ stat.isSymbolicLink() ||
2414
+ stat.dev !== cleanup.identity.dev ||
2415
+ stat.ino !== cleanup.identity.ino
2416
+ )
2417
+ return false;
2418
+ const snapshot = native.snapshotDirectoryTree(cleanup.retainedPath);
2419
+ const observedRoot = snapshot.snapshot?.entries.find(
2420
+ entry => entry.relativePath === "" && entry.kind === "directory",
2421
+ );
2422
+ if (
2423
+ !snapshot.ok ||
2424
+ !snapshot.snapshot ||
2425
+ !observedRoot ||
2426
+ (platform === "win32"
2427
+ ? observedRoot.dev !== cleanup.identity.dev.toString() ||
2428
+ observedRoot.ino !== cleanup.identity.ino.toString() ||
2429
+ BigInt(observedRoot.size) !== cleanup.identity.size ||
2430
+ BigInt(observedRoot.mtimeNs) !== cleanup.identity.mtimeNs
2431
+ : stat.size !== cleanup.identity.size || stat.mtimeNs !== cleanup.identity.mtimeNs)
2432
+ )
2433
+ return false;
1684
2434
  return (
1685
- stat.isDirectory() &&
1686
- !stat.isSymbolicLink() &&
1687
- stat.dev === plan.identity.dev &&
1688
- stat.ino === plan.identity.ino &&
1689
- stat.size === plan.identity.size &&
1690
- stat.mtimeNs === plan.identity.mtimeNs &&
1691
- sameDirectoryObject(path.dirname(pathname), path.dirname(plan.detachedPath))
2435
+ sameArtifactTree(snapshot.snapshot, cleanup.tree) &&
2436
+ cleanup.tree.entries.length === 1 &&
2437
+ cleanup.tree.entries[0]?.relativePath === "" &&
2438
+ cleanup.tree.entries[0]?.kind === "directory"
1692
2439
  );
1693
2440
  } catch {
1694
2441
  return false;
1695
2442
  }
1696
2443
  }
1697
2444
 
1698
- function detachArtifactRootForMigration(plan: DetachedArtifactRoot): DetachedArtifactRoot {
2445
+ export function detachArtifactRootForMigration(
2446
+ plan: DetachedArtifactRoot,
2447
+ platform: NodeJS.Platform = process.platform,
2448
+ ):
2449
+ | { detached: DetachedArtifactRoot; detachOutcome: "clean" }
2450
+ | { detached: DetachedArtifactRoot; detachOutcome: "cleanup_pending"; cleanup: SourceArtifactCleanup } {
1699
2451
  const result = native.exactUnlink(plan.originalPath, {
1700
2452
  ...plan.identity,
1701
2453
  directory: true,
1702
2454
  detachOnly: true,
1703
2455
  quarantineName: path.basename(plan.detachedPath),
1704
2456
  });
1705
- if (!result.ok || !result.detachedPath || !matchesDetachedArtifactRoot(result.detachedPath, plan))
2457
+ const cleanSuccess =
2458
+ result.ok && !result.retainedSuccessorPath && !result.retainedPlaceholderPath && !result.retainedUnknownPath;
2459
+ const cleanupPending =
2460
+ !result.ok &&
2461
+ result.code === "cleanup_pending" &&
2462
+ !!result.detachedPath &&
2463
+ !!result.retainedPlaceholderPath &&
2464
+ !result.retainedSuccessorPath &&
2465
+ !result.retainedUnknownPath;
2466
+ if (
2467
+ (!cleanSuccess && !cleanupPending) ||
2468
+ !result.detachedPath ||
2469
+ !matchesDetachedArtifactRoot(result.detachedPath, plan)
2470
+ )
2471
+ throw new Error("durability_failed");
2472
+
2473
+ const detachedPath =
2474
+ process.platform === "win32" ? fs.realpathSync.native(result.detachedPath) : result.detachedPath;
2475
+ if (!matchesDetachedArtifactRoot(detachedPath, plan)) throw new Error("durability_failed");
2476
+ const detached = { ...plan, detachedPath };
2477
+ if (!cleanupPending) return { detached, detachOutcome: "clean" };
2478
+ const placeholder = result.retainedPlaceholderPath!;
2479
+ const stat = fs.lstatSync(placeholder, { bigint: true });
2480
+ if (!stat.isDirectory() || stat.isSymbolicLink() || path.dirname(placeholder) !== path.dirname(plan.originalPath))
2481
+ throw new Error("durability_failed");
2482
+ const snapshot = native.snapshotDirectoryTree(placeholder);
2483
+ if (!snapshot.ok || !snapshot.snapshot) throw new Error("durability_failed");
2484
+ // Windows directory size/mtime authority is the native tree root, never Bun's
2485
+ // zero-valued directory lstat. Capturing Bun values here would guarantee a
2486
+ // mismatch against the native-authoritative check below.
2487
+ const placeholderRoot = snapshot.snapshot.entries.find(
2488
+ entry => entry.relativePath === "" && entry.kind === "directory",
2489
+ );
2490
+ if (!placeholderRoot) throw new Error("durability_failed");
2491
+ const cleanup: SourceArtifactCleanup = {
2492
+ state: "cleanup_pending",
2493
+ role: "exchange_placeholder",
2494
+ retainedPath: placeholder,
2495
+ identity: {
2496
+ dev: stat.dev,
2497
+ ino: stat.ino,
2498
+ size: platform === "win32" ? BigInt(placeholderRoot.size) : stat.size,
2499
+ mtimeNs: platform === "win32" ? BigInt(placeholderRoot.mtimeNs) : stat.mtimeNs,
2500
+ },
2501
+ tree: snapshot.snapshot,
2502
+ };
2503
+ if (!cleanupAuthorityMatches(cleanup, path.dirname(plan.originalPath), platform))
1706
2504
  throw new Error("durability_failed");
1707
- return { ...plan, detachedPath: result.detachedPath };
2505
+ return { detached, detachOutcome: "cleanup_pending", cleanup };
1708
2506
  }
1709
2507
 
1710
- function restorePreparedArtifactRoot(scope: ManagedScope, source: ManagedCandidate): void {
1711
- const receipt = receiptPathFor(scope, source, "prepared");
2508
+ export function restorePreparedArtifactRoot(
2509
+ scope: ManagedScope,
2510
+ source: ManagedCandidate,
2511
+ lock?: ManagedStorageLock,
2512
+ ): void {
2513
+ const preparedReceipt = receiptPathFor(scope, source, "prepared");
2514
+ const detachedReceipt = receiptPathFor(scope, source, "detached");
2515
+ const receipt = fs.existsSync(detachedReceipt) ? detachedReceipt : preparedReceipt;
1712
2516
  let record: {
1713
2517
  sourceArtifactQuarantine?: {
1714
2518
  path?: unknown;
1715
2519
  detachedPath?: unknown;
1716
2520
  identity?: Record<string, unknown>;
1717
2521
  tree?: unknown;
2522
+ role?: unknown;
1718
2523
  };
2524
+ sourceArtifactCleanup?: {
2525
+ state?: unknown;
2526
+ role?: unknown;
2527
+ retainedPath?: unknown;
2528
+ identity?: Record<string, unknown>;
2529
+ tree?: unknown;
2530
+ };
2531
+ detachOutcome?: unknown;
1719
2532
  };
1720
2533
  try {
1721
2534
  record = JSON.parse(captureManagedFileNoFollow(receipt).bytes.toString("utf8")) as typeof record;
@@ -1739,39 +2552,105 @@ function restorePreparedArtifactRoot(scope: ManagedScope, source: ManagedCandida
1739
2552
  typeof identity.mtimeNs !== "string"
1740
2553
  )
1741
2554
  throw new Error("durability_failed");
2555
+ const artifactIdentity = {
2556
+ dev: BigInt(identity.dev),
2557
+ ino: BigInt(identity.ino),
2558
+ size: BigInt(identity.size),
2559
+ mtimeNs: BigInt(identity.mtimeNs),
2560
+ };
1742
2561
  const expectedTree = artifactTreeSnapshot(quarantine.tree)!;
1743
2562
  const assertPreparedTree = (pathname: string): void => {
1744
- validateManagedArtifactTree(pathname);
1745
- const observed = native.snapshotDirectoryTree(pathname);
1746
- if (!observed.ok || !observed.snapshot || JSON.stringify(observed.snapshot) !== JSON.stringify(expectedTree))
2563
+ if (
2564
+ !matchesMigrationArtifactRoot(
2565
+ pathname,
2566
+ {
2567
+ ...artifactIdentity,
2568
+ },
2569
+ expectedTree,
2570
+ )
2571
+ )
1747
2572
  throw new Error("durability_failed");
1748
2573
  };
2574
+ if (receipt === detachedReceipt) {
2575
+ if (quarantine.role !== "detached_artifact_root") throw new Error("durability_failed");
2576
+ if (record.detachOutcome === "clean") {
2577
+ if (record.sourceArtifactCleanup !== undefined) throw new Error("durability_failed");
2578
+ const originalExists = fs.existsSync(quarantine.path);
2579
+ const detachedExists = fs.existsSync(quarantine.detachedPath);
2580
+ if (originalExists) {
2581
+ if (detachedExists) throw new Error("durability_failed");
2582
+ assertPreparedTree(quarantine.path);
2583
+ if (lock) {
2584
+ lock.assertOwned();
2585
+ fs.unlinkSync(receipt);
2586
+ fsyncManagedParent(receipt);
2587
+ }
2588
+ return;
2589
+ }
2590
+ } else if (record.detachOutcome === "cleanup_pending") {
2591
+ const cleanup = record.sourceArtifactCleanup;
2592
+ if (!cleanup) throw new Error("durability_failed");
2593
+ const cleanupIdentity = cleanup.identity;
2594
+ const cleanupTree = artifactTreeSnapshot(cleanup.tree);
2595
+ if (
2596
+ cleanup.state !== "cleanup_pending" ||
2597
+ cleanup.role !== "exchange_placeholder" ||
2598
+ typeof cleanup.retainedPath !== "string" ||
2599
+ !cleanupIdentity ||
2600
+ typeof cleanupIdentity.dev !== "string" ||
2601
+ typeof cleanupIdentity.ino !== "string" ||
2602
+ typeof cleanupIdentity.size !== "string" ||
2603
+ typeof cleanupIdentity.mtimeNs !== "string" ||
2604
+ !cleanupTree ||
2605
+ !cleanupAuthorityMatches(
2606
+ {
2607
+ state: "cleanup_pending",
2608
+ role: "exchange_placeholder",
2609
+ retainedPath: cleanup.retainedPath,
2610
+ identity: {
2611
+ dev: BigInt(cleanupIdentity.dev),
2612
+ ino: BigInt(cleanupIdentity.ino),
2613
+ size: BigInt(cleanupIdentity.size),
2614
+ mtimeNs: BigInt(cleanupIdentity.mtimeNs),
2615
+ },
2616
+ tree: cleanupTree,
2617
+ },
2618
+ path.dirname(source.path),
2619
+ )
2620
+ )
2621
+ throw new Error("durability_failed");
2622
+ } else {
2623
+ throw new Error("durability_failed");
2624
+ }
2625
+ }
2626
+
1749
2627
  if (fs.existsSync(quarantine.path)) {
1750
- const existing = fs.lstatSync(quarantine.path, { bigint: true });
1751
2628
  if (
1752
- existing.isSymbolicLink() ||
1753
- !existing.isDirectory() ||
1754
- existing.dev !== BigInt(identity.dev) ||
1755
- existing.ino !== BigInt(identity.ino) ||
1756
- existing.size !== BigInt(identity.size) ||
1757
- existing.mtimeNs !== BigInt(identity.mtimeNs)
1758
- )
1759
- throw new Error("durability_failed");
1760
- assertPreparedTree(quarantine.path);
2629
+ matchesMigrationArtifactRoot(
2630
+ quarantine.path,
2631
+ {
2632
+ ...artifactIdentity,
2633
+ },
2634
+ expectedTree,
2635
+ )
2636
+ ) {
2637
+ assertPreparedTree(quarantine.path);
2638
+ }
2639
+ // A changed source pathname is independent retained authority. Do not replace
2640
+ // it with the detached original; retain both roots for recovery.
1761
2641
  return;
1762
2642
  }
1763
2643
  assertPreparedTree(quarantine.detachedPath);
1764
2644
  const result = native.exactRestore(quarantine.detachedPath, quarantine.path, {
1765
- dev: BigInt(identity.dev),
1766
- ino: BigInt(identity.ino),
1767
- size: BigInt(identity.size),
1768
- mtimeNs: BigInt(identity.mtimeNs),
2645
+ ...artifactIdentity,
1769
2646
  directory: true,
1770
2647
  });
1771
2648
  if (!result.ok) throw new Error("durability_failed");
1772
2649
  }
1773
2650
 
1774
- function restoreDetachedArtifactRoot(detached: DetachedArtifactRoot): void {
2651
+ function restoreDetachedArtifactRoot(detached: DetachedArtifactRoot, cleanup?: SourceArtifactCleanup): void {
2652
+ if (cleanup && !cleanupAuthorityMatches(cleanup, path.dirname(detached.originalPath)))
2653
+ throw new Error("durability_failed");
1775
2654
  const result = native.exactRestore(detached.detachedPath, detached.originalPath, {
1776
2655
  ...detached.identity,
1777
2656
  directory: true,
@@ -1785,6 +2664,8 @@ async function copyArtifacts(
1785
2664
  destinationTranscript: string,
1786
2665
  manifest: readonly ArtifactManifestEntry[],
1787
2666
  lock: ManagedStorageLock,
2667
+ expectedCandidate: ManagedCandidate,
2668
+ expectedIdentity: ResumeSessionIdentity,
1788
2669
  sourceRootOverride?: string,
1789
2670
  ): Promise<void> {
1790
2671
  const root = scopeRoot(scope);
@@ -1792,33 +2673,41 @@ async function copyArtifacts(
1792
2673
  const sourceRoot = sourceRootOverride ?? sourceTranscript.slice(0, -6);
1793
2674
  if (!manifestMatches(sourceTranscript, manifest, sourceRoot)) throw new Error("source_changed");
1794
2675
  const destinationRoot = destinationTranscript.slice(0, -6);
1795
- for (const entry of manifest) {
2676
+ for (let start = 0; start < manifest.length; start += MANAGED_ARTIFACT_COPY_BATCH_SIZE) {
2677
+ const batch = manifest.slice(start, start + MANAGED_ARTIFACT_COPY_BATCH_SIZE);
2678
+ revalidatePickerConsent(scope, expectedCandidate, expectedIdentity);
1796
2679
  lock.assertOwned();
1797
- const source = path.join(sourceRoot, entry.path);
1798
- const destination = path.join(destinationRoot, entry.path);
1799
- if (entry.kind === "directory") {
1800
- if (entry.path === "") ensureManagedDirectory(destinationRoot, root);
1801
- else ensureManagedDirectory(destination, root);
1802
- continue;
1803
- }
1804
- const snapshot = captureManagedFileNoFollow(source);
1805
- if (
1806
- snapshot.bytes.byteLength !== entry.size ||
1807
- createHash("sha256").update(snapshot.bytes).digest("hex") !== entry.sha256
1808
- )
1809
- throw new Error("source_changed");
1810
- try {
1811
- await copyManagedFileNoReplace(source, destination, snapshot, root);
1812
- } catch (error) {
1813
- if ((error as Error).message !== "destination_conflict") throw error;
2680
+ for (const entry of batch) {
2681
+ const source = path.join(sourceRoot, entry.path);
2682
+ const destination = path.join(destinationRoot, entry.path);
2683
+ if (entry.kind === "directory") {
2684
+ if (entry.path === "") {
2685
+ ensureManagedDirectory(destinationRoot, root);
2686
+ } else {
2687
+ ensureManagedDirectory(destination, root);
2688
+ }
2689
+ continue;
2690
+ }
2691
+ const snapshot = captureManagedFileNoFollow(source);
2692
+ if (
2693
+ snapshot.bytes.byteLength !== entry.size ||
2694
+ createHash("sha256").update(snapshot.bytes).digest("hex") !== entry.sha256
2695
+ )
2696
+ throw new Error("source_changed");
2697
+ try {
2698
+ await copyManagedFileNoReplace(source, destination, snapshot, root);
2699
+ } catch (error) {
2700
+ if ((error as Error).message !== "destination_conflict") throw error;
2701
+ }
2702
+ lock.assertOwned();
2703
+ const copied = captureManagedFileNoFollow(destination);
2704
+ if (
2705
+ copied.bytes.byteLength !== entry.size ||
2706
+ createHash("sha256").update(copied.bytes).digest("hex") !== entry.sha256
2707
+ )
2708
+ throw new Error("durability_failed");
1814
2709
  }
1815
- lock.assertOwned();
1816
- const copied = captureManagedFileNoFollow(destination);
1817
- if (
1818
- copied.bytes.byteLength !== entry.size ||
1819
- createHash("sha256").update(copied.bytes).digest("hex") !== entry.sha256
1820
- )
1821
- throw new Error("durability_failed");
2710
+ if (start + batch.length < manifest.length) await new Promise<void>(resolve => setTimeout(resolve, 0));
1822
2711
  }
1823
2712
  if (!manifestMatches(sourceTranscript, manifest, sourceRoot) || !manifestMatches(destinationTranscript, manifest))
1824
2713
  throw new Error("durability_failed");
@@ -1827,7 +2716,7 @@ async function copyArtifacts(
1827
2716
  function migrationReceipt(
1828
2717
  scope: ManagedScope,
1829
2718
  lock: ManagedStorageLock,
1830
- state: "prepared" | "published" | "committed",
2719
+ state: "prepared" | "artifact_detached" | "published" | "committed",
1831
2720
  source: ManagedCandidate,
1832
2721
  destination: ManagedCandidate | { path: string; sessionId: string; cwd: string },
1833
2722
  manifest: readonly ArtifactManifestEntry[],
@@ -1836,7 +2725,10 @@ function migrationReceipt(
1836
2725
  detachedPath: string;
1837
2726
  identity: DetachedArtifactRoot["identity"];
1838
2727
  tree: NativeDirectoryTreeSnapshot;
2728
+ role?: "detached_artifact_root";
1839
2729
  },
2730
+ sourceArtifactCleanup?: SourceArtifactCleanup,
2731
+ detachOutcome?: "clean" | "cleanup_pending",
1840
2732
  ): Uint8Array {
1841
2733
  lock.assertOwned();
1842
2734
  const destinationRecord =
@@ -1854,16 +2746,27 @@ function migrationReceipt(
1854
2746
  header: { id: destination.sessionId, cwd: destination.cwd },
1855
2747
  };
1856
2748
  return new TextEncoder().encode(
1857
- `${JSON.stringify({ schemaVersion: 2, state, policy: "copy-retain", attemptId: lock.attemptId, scope: scopeDigest(scope.platform, scope.canonicalCwd), source: { path: source.path, sessionId: source.sessionId, header: { id: source.sessionId, cwd: source.cwd }, identity: source.identity, sha256: source.identity.sha256 }, destination: destinationRecord, artifactManifest: manifest, ...(sourceArtifactQuarantine ? { sourceArtifactQuarantine } : {}) }, (_key, value: unknown) => (typeof value === "bigint" ? value.toString() : value))}\n`,
2749
+ `${JSON.stringify({ schemaVersion: 2, state, policy: "copy-retain", attemptId: lock.attemptId, scope: scopeDigest(scope.platform, scope.canonicalCwd), source: { path: source.path, sessionId: source.sessionId, header: { id: source.sessionId, cwd: source.cwd }, identity: source.identity, sha256: source.identity.sha256 }, destination: destinationRecord, artifactManifest: manifest, ...(sourceArtifactQuarantine ? { sourceArtifactQuarantine } : {}), ...(sourceArtifactCleanup ? { sourceArtifactCleanup } : {}), ...(detachOutcome ? { detachOutcome } : {}) }, (_key, value: unknown) => (typeof value === "bigint" ? value.toString() : value))}\n`,
1858
2750
  );
1859
2751
  }
1860
2752
 
2753
+ function detachedReceiptMatches(receipt: string, expected: Uint8Array): boolean {
2754
+ try {
2755
+ return captureManagedFileNoFollow(receipt).bytes.equals(expected);
2756
+ } catch {
2757
+ return false;
2758
+ }
2759
+ }
2760
+
1861
2761
  async function removeStagedReceipts(scope: ManagedScope, candidate: ManagedCandidate): Promise<void> {
1862
- for (const state of ["prepared", "published"] as const) {
2762
+ for (const state of ["prepared", "detached", "published"] as const) {
1863
2763
  const pathname = receiptPathFor(scope, candidate, state);
1864
2764
  try {
1865
2765
  const stat = fs.lstatSync(pathname);
1866
- if (stat.isFile() || stat.isSymbolicLink()) await fs.promises.unlink(pathname);
2766
+ if (stat.isFile() || stat.isSymbolicLink()) {
2767
+ await fs.promises.unlink(pathname);
2768
+ fsyncManagedParent(pathname);
2769
+ }
1867
2770
  } catch (error) {
1868
2771
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
1869
2772
  }
@@ -1905,6 +2808,7 @@ function receiptPair(scope: ManagedScope, candidate: ManagedCandidate): ManagedC
1905
2808
  }
1906
2809
 
1907
2810
  function validateCandidateForScope(scope: ManagedScope, candidate: ManagedCandidate): ManagedCandidate | undefined {
2811
+ scopeRoot(scope);
1908
2812
  const inspected = inspectCandidate(candidate.path, candidate.provenance);
1909
2813
  if ("code" in inspected || !sameCandidate(inspected, candidate)) return undefined;
1910
2814
  const identity = identityFor(inspected.cwd);
@@ -1914,12 +2818,16 @@ function validateCandidateForScope(scope: ManagedScope, candidate: ManagedCandid
1914
2818
  }
1915
2819
 
1916
2820
  /** Resume tombstoned cleanup under its original operation lease without restoring retired candidates. */
1917
- export async function reconcileManagedTombstones(scope: ManagedScope): Promise<void> {
2821
+ export async function reconcileManagedTombstones(
2822
+ scope: ManagedScope,
2823
+ expectedCandidate?: ManagedCandidate,
2824
+ ): Promise<void> {
1918
2825
  const directory = path.join(managedInternalDirectory(scope), MANAGED_TOMBSTONES_DIRECTORY);
1919
2826
  for (const name of fs.readdirSync(directory)) {
1920
2827
  const tombstone = path.join(directory, name);
1921
2828
  const targets = retiredTargets(scope, tombstone);
1922
2829
  if (!targets) continue;
2830
+ if (targets.every(target => cleanupCompleted(scope, tombstone, target))) continue;
1923
2831
  let lock: ManagedStorageLock | undefined;
1924
2832
  try {
1925
2833
  lock = await acquireManagedLock(
@@ -1932,98 +2840,148 @@ export async function reconcileManagedTombstones(scope: ManagedScope): Promise<v
1932
2840
  for (const target of lockedTargets) {
1933
2841
  lock.assertOwned();
1934
2842
  if (cleanupCompleted(scope, tombstone, target)) continue;
1935
- const pending = pendingCleanupReceipt(scope, tombstone, target);
1936
- const observedPending = pending ? probePlannedCleanupDetach(target, pending) : undefined;
1937
2843
  try {
1938
- fs.lstatSync(target.path);
1939
- } catch (error) {
1940
- if ((error as NodeJS.ErrnoException).code === "ENOENT") {
1941
- if (!observedPending) continue;
1942
- if (
1943
- cleanupArtifactsRemoved(scope, tombstone, target, observedPending.attempt) &&
1944
- artifactRootsAbsent(scope, tombstone, target, observedPending) &&
1945
- !fs.existsSync(observedPending.plannedTranscriptPath)
1946
- ) {
1947
- fsyncManagedParent(target.path);
1948
- await publishCleanupCompleted(scope, tombstone, target, lock);
1949
- continue;
1950
- }
1951
- } else throw error;
1952
- }
1953
- const verified = observedPending ? target : validateCandidateForScope(scope, target);
1954
- if (!verified || !sameCandidate(verified, target)) throw new Error("source_changed");
1955
- const discoveredDetach =
1956
- !!observedPending &&
1957
- (observedPending.detachedArtifactsPath !== pending?.detachedArtifactsPath ||
1958
- observedPending.detachedTranscriptPath !== pending?.detachedTranscriptPath);
1959
- const active =
1960
- discoveredDetach || (observedPending && requiresFreshCleanupPlan(observedPending))
1961
- ? nextCleanupReceipt(target, observedPending)
1962
- : (observedPending ?? nextCleanupReceipt(target, undefined));
1963
- if (!observedPending || discoveredDetach || requiresFreshCleanupPlan(observedPending))
1964
- await publishCleanupPending(scope, tombstone, active, lock);
1965
- let deletion = await new FileSessionStorage().deleteSessionVerified({
1966
- sessionsRoot: scope.sessionsRoot,
1967
- transcriptPath: target.path,
1968
- sessionId: target.sessionId,
1969
- cwd: target.cwd,
1970
- transcriptIdentity: target.identity,
1971
- expectedArtifactsIdentity: active.expectedArtifactsIdentity,
1972
- expectedArtifactsTree: active.expectedArtifactsTree,
1973
- detachedArtifactsPath:
1974
- active.detachedArtifactsPath ??
1975
- observedPending?.detachedArtifactsPath ??
1976
- (fs.existsSync(active.plannedArtifactsPath) ? active.plannedArtifactsPath : undefined),
1977
- detachedTranscriptPath:
1978
- active.detachedTranscriptPath ??
1979
- observedPending?.detachedTranscriptPath ??
1980
- (pending && fs.existsSync(pending.plannedTranscriptPath)
1981
- ? pending.plannedTranscriptPath
1982
- : undefined) ??
1983
- (fs.existsSync(active.plannedTranscriptPath) ? active.plannedTranscriptPath : undefined),
1984
- plannedArtifactsPath: active.plannedArtifactsPath,
1985
- plannedTranscriptPath: active.plannedTranscriptPath,
1986
- ...(cleanupArtifactsRemoved(scope, tombstone, target, pending?.attempt ?? active.attempt)
1987
- ? { artifactsRemoved: true as const }
1988
- : {}),
1989
- });
1990
- if (deletion.kind === "artifacts_removed") {
1991
- await publishCleanupArtifactsRemoved(scope, tombstone, active, lock);
1992
- deletion = await new FileSessionStorage().deleteSessionVerified({
2844
+ const pending = pendingCleanupReceipt(scope, tombstone, target);
2845
+ const observedPending = pending ? probePlannedCleanupDetach(target, pending) : undefined;
2846
+ try {
2847
+ fs.lstatSync(target.path);
2848
+ } catch (error) {
2849
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
2850
+ if (!observedPending) continue;
2851
+ if (
2852
+ cleanupArtifactsRemoved(scope, tombstone, target, observedPending.attempt) &&
2853
+ cleanupRootsAbsent(tombstone, target, observedPending)
2854
+ ) {
2855
+ fsyncManagedParent(target.path);
2856
+ await publishCleanupCompleted(scope, tombstone, target, lock);
2857
+ continue;
2858
+ }
2859
+ } else throw error;
2860
+ }
2861
+ const verified = observedPending ? target : validateCandidateForScope(scope, target);
2862
+ if (!verified || !sameCandidate(verified, target)) throw new Error("source_changed");
2863
+ const discoveredDetach =
2864
+ !!observedPending &&
2865
+ (observedPending.detachedArtifactsPath !== pending?.detachedArtifactsPath ||
2866
+ observedPending.detachedTranscriptPath !== pending?.detachedTranscriptPath);
2867
+ const active =
2868
+ discoveredDetach || (observedPending && requiresFreshCleanupPlan(observedPending))
2869
+ ? nextCleanupReceipt(target, observedPending)
2870
+ : (observedPending ?? nextCleanupReceipt(target, undefined));
2871
+ if (!observedPending || discoveredDetach || requiresFreshCleanupPlan(observedPending))
2872
+ await publishCleanupPending(scope, tombstone, active, lock);
2873
+ let deletion = await new FileSessionStorage().deleteSessionVerified({
1993
2874
  sessionsRoot: scope.sessionsRoot,
1994
2875
  transcriptPath: target.path,
1995
2876
  sessionId: target.sessionId,
1996
2877
  cwd: target.cwd,
1997
2878
  transcriptIdentity: target.identity,
2879
+ expectedArtifactsIdentity: active.expectedArtifactsIdentity,
2880
+ expectedArtifactsTree: active.expectedArtifactsTree,
2881
+ detachedArtifactsPath:
2882
+ active.detachedArtifactsPath ??
2883
+ observedPending?.detachedArtifactsPath ??
2884
+ (fs.existsSync(active.plannedArtifactsPath) ? active.plannedArtifactsPath : undefined),
2885
+ detachedTranscriptPath:
2886
+ active.detachedTranscriptPath ??
2887
+ observedPending?.detachedTranscriptPath ??
2888
+ (pending && fs.existsSync(pending.plannedTranscriptPath)
2889
+ ? pending.plannedTranscriptPath
2890
+ : undefined) ??
2891
+ (fs.existsSync(active.plannedTranscriptPath) ? active.plannedTranscriptPath : undefined),
2892
+ retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
2893
+ retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
2894
+ retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
2895
+ retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
2896
+ retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
2897
+ retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
1998
2898
  plannedArtifactsPath: active.plannedArtifactsPath,
1999
2899
  plannedTranscriptPath: active.plannedTranscriptPath,
2000
- artifactsRemoved: true,
2900
+ ...(cleanupArtifactsRemoved(scope, tombstone, target, pending?.attempt ?? active.attempt)
2901
+ ? { artifactsRemoved: true as const }
2902
+ : {}),
2001
2903
  });
2002
- }
2003
- if (deletion.kind === "cleanup_pending") {
2004
- const retry = nextCleanupReceipt(target, active);
2005
- await publishCleanupPending(
2006
- scope,
2904
+ if (deletion.kind === "artifacts_removed") {
2905
+ await publishCleanupArtifactsRemoved(scope, tombstone, active, lock);
2906
+ deletion = await new FileSessionStorage().deleteSessionVerified({
2907
+ sessionsRoot: scope.sessionsRoot,
2908
+ transcriptPath: target.path,
2909
+ sessionId: target.sessionId,
2910
+ cwd: target.cwd,
2911
+ transcriptIdentity: target.identity,
2912
+ plannedArtifactsPath: active.plannedArtifactsPath,
2913
+ plannedTranscriptPath: active.plannedTranscriptPath,
2914
+ detachedTranscriptPath: active.detachedTranscriptPath ?? observedPending?.detachedTranscriptPath,
2915
+
2916
+ retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
2917
+ retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
2918
+ retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
2919
+ retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
2920
+ retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
2921
+ retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
2922
+ artifactsRemoved: true,
2923
+ });
2924
+ }
2925
+ if (deletion.kind === "cleanup_pending") {
2926
+ const retry = nextCleanupReceipt(target, active);
2927
+ await publishCleanupPending(
2928
+ scope,
2929
+ tombstone,
2930
+ {
2931
+ ...retry,
2932
+ expectedArtifactsIdentity:
2933
+ deletion.phase === "artifacts"
2934
+ ? deletion.artifactsIdentity
2935
+ : active.expectedArtifactsIdentity,
2936
+ expectedArtifactsTree:
2937
+ deletion.phase === "artifacts" ? deletion.artifactsTree : active.expectedArtifactsTree,
2938
+ detachedArtifactsPath:
2939
+ deletion.phase === "artifacts"
2940
+ ? deletion.detachedArtifactsPath
2941
+ : active.detachedArtifactsPath,
2942
+ detachedTranscriptPath:
2943
+ deletion.phase === "transcript"
2944
+ ? deletion.detachedTranscriptPath
2945
+ : active.detachedTranscriptPath,
2946
+ retainedArtifactsSuccessorPath:
2947
+ deletion.phase === "artifacts"
2948
+ ? deletion.retainedSuccessorPath
2949
+ : active.retainedArtifactsSuccessorPath,
2950
+ retainedArtifactsPlaceholderPath:
2951
+ deletion.phase === "artifacts"
2952
+ ? deletion.retainedPlaceholderPath
2953
+ : active.retainedArtifactsPlaceholderPath,
2954
+ retainedArtifactsUnknownPath:
2955
+ deletion.phase === "artifacts"
2956
+ ? deletion.retainedUnknownPath
2957
+ : active.retainedArtifactsUnknownPath,
2958
+ retainedTranscriptSuccessorPath:
2959
+ deletion.phase === "transcript"
2960
+ ? deletion.retainedSuccessorPath
2961
+ : active.retainedTranscriptSuccessorPath,
2962
+ retainedTranscriptPlaceholderPath:
2963
+ deletion.phase === "transcript"
2964
+ ? deletion.retainedPlaceholderPath
2965
+ : active.retainedTranscriptPlaceholderPath,
2966
+ retainedTranscriptUnknownPath:
2967
+ deletion.phase === "transcript"
2968
+ ? deletion.retainedUnknownPath
2969
+ : active.retainedTranscriptUnknownPath,
2970
+ },
2971
+ lock,
2972
+ );
2973
+ continue;
2974
+ }
2975
+ fsyncManagedParent(target.path);
2976
+ await publishCleanupCompleted(scope, tombstone, target, lock);
2977
+ } catch (error) {
2978
+ if (!expectedCandidate || target.sessionId === expectedCandidate.sessionId) throw error;
2979
+ logger.warn("Tombstone reconciliation failed for one target; will retry on a future scope open", {
2007
2980
  tombstone,
2008
- {
2009
- ...retry,
2010
- expectedArtifactsIdentity:
2011
- deletion.phase === "artifacts" ? deletion.artifactsIdentity : active.expectedArtifactsIdentity,
2012
- expectedArtifactsTree:
2013
- deletion.phase === "artifacts" ? deletion.artifactsTree : active.expectedArtifactsTree,
2014
- detachedArtifactsPath:
2015
- deletion.phase === "artifacts" ? deletion.detachedArtifactsPath : active.detachedArtifactsPath,
2016
- detachedTranscriptPath:
2017
- deletion.phase === "transcript"
2018
- ? deletion.detachedTranscriptPath
2019
- : active.detachedTranscriptPath,
2020
- },
2021
- lock,
2022
- );
2023
- throw new Error("durability_failed");
2981
+ sessionId: target.sessionId,
2982
+ error: String(error),
2983
+ });
2024
2984
  }
2025
- fsyncManagedParent(target.path);
2026
- await publishCleanupCompleted(scope, tombstone, target, lock);
2027
2985
  }
2028
2986
  } finally {
2029
2987
  if (lock) await lock.release().catch(() => undefined);
@@ -2032,24 +2990,45 @@ export async function reconcileManagedTombstones(scope: ManagedScope): Promise<v
2032
2990
  }
2033
2991
 
2034
2992
  /** Create the v2 binding and private write protocol directories before managed writes. */
2035
- export async function prepareManagedSessionScopeForWrite(scope: ManagedScope): Promise<ManagedScopeResolution> {
2036
- const prepared = await ensureManagedScope(scope);
2993
+ export async function prepareManagedSessionScopeForWrite(
2994
+ scope: ManagedScope,
2995
+ policy: ManagedSessionSecurityPolicy = "default",
2996
+ authority?: ManagedCandidateWriteAuthority,
2997
+ expectedCandidate?: ManagedCandidate,
2998
+ expectedIdentity?: ResumeSessionIdentity,
2999
+ ): Promise<ManagedScopeResolution> {
3000
+ if (expectedCandidate && expectedIdentity) revalidatePickerConsent(scope, expectedCandidate, expectedIdentity);
3001
+ if (authority) bindManagedWriteAuthority(scope, authority);
3002
+ const prepared = await ensureManagedScope(scope, policy);
2037
3003
  if (prepared.kind === "error") return prepared;
2038
3004
  try {
2039
3005
  const internal = managedInternalDirectory(scope);
2040
3006
  const root = scopeRoot(scope);
2041
- ensureManagedDirectory(internal, root);
2042
- ensureManagedDirectory(path.join(internal, MANAGED_LOCKS_DIRECTORY), root);
2043
- ensureManagedDirectory(path.join(internal, MANAGED_RECEIPTS_DIRECTORY), root);
2044
- ensureManagedDirectory(path.join(internal, MANAGED_TOMBSTONES_DIRECTORY), root);
2045
- await reconcileManagedTombstones(scope);
3007
+ ensureManagedDirectory(internal, root, policy);
3008
+ ensureManagedDirectory(path.join(internal, MANAGED_LOCKS_DIRECTORY), root, policy);
3009
+ ensureManagedDirectory(path.join(internal, MANAGED_RECEIPTS_DIRECTORY), root, policy);
3010
+ ensureManagedDirectory(path.join(internal, MANAGED_TOMBSTONES_DIRECTORY), root, policy);
3011
+ await reconcileManagedTombstones(scope, expectedCandidate);
2046
3012
  return { kind: "resolved", scope };
2047
3013
  } catch (error) {
2048
- if (error instanceof Error && error.message === "durability_failed") return { kind: "resolved", scope };
3014
+ const publication = error instanceof ManagedPublishError ? error : undefined;
3015
+ const message =
3016
+ publication?.classification ??
3017
+ (error instanceof Error ? error.message : "Managed write protocol setup failed.");
3018
+ const code =
3019
+ message === "atomic_unavailable" ||
3020
+ message === "invalid_request" ||
3021
+ message === "durability_failed" ||
3022
+ message === "durability_not_provable"
3023
+ ? message
3024
+ : "binding_invalid";
2049
3025
  return {
2050
3026
  kind: "error",
2051
- code: "binding_invalid",
2052
- message: error instanceof Error ? error.message : "Managed write protocol setup failed.",
3027
+ code,
3028
+ message,
3029
+ ...(publication
3030
+ ? { cause: { classification: publication.classification, diagnostic: publication.diagnostic } }
3031
+ : { cause: { classification: code } }),
2053
3032
  };
2054
3033
  }
2055
3034
  }
@@ -2061,28 +3040,45 @@ export async function prepareManagedSessionScopeForWrite(scope: ManagedScope): P
2061
3040
  export async function openManagedCandidateForWrite(
2062
3041
  scope: ManagedScope,
2063
3042
  candidate: ManagedCandidate,
2064
- migrationPolicy: ManagedMigrationPolicy = "copy-retain",
3043
+ expectedIdentityOrMigrationPolicy: ResumeSessionIdentity | ManagedMigrationPolicy = "copy-retain",
3044
+ migrationPolicy: ManagedMigrationPolicy = typeof expectedIdentityOrMigrationPolicy === "string"
3045
+ ? expectedIdentityOrMigrationPolicy
3046
+ : "copy-retain",
3047
+ authority?: ManagedCandidateWriteAuthority,
2065
3048
  ): Promise<ManagedOpenCandidateResult> {
3049
+ const expectedIdentity =
3050
+ typeof expectedIdentityOrMigrationPolicy === "string" ? candidate.identity : expectedIdentityOrMigrationPolicy;
2066
3051
  if (migrationPolicy === "disabled" && candidate.provenance === "legacy")
2067
3052
  return {
2068
3053
  kind: "error",
2069
3054
  code: "legacy_migration_disabled",
2070
3055
  message: "Legacy session migration is disabled for this workspace.",
2071
3056
  };
2072
- const prepared = await prepareManagedSessionScopeForWrite(scope);
2073
- if (prepared.kind === "error")
2074
- return {
2075
- kind: "error",
2076
- code: prepared.code === "binding_conflict" ? "binding_conflict" : "binding_invalid",
2077
- message: prepared.message,
2078
- };
2079
- const current = validateCandidateForScope(scope, candidate);
2080
- if (!current)
3057
+ let prepared: ManagedScopeResolution;
3058
+ let current: ManagedCandidate;
3059
+ try {
3060
+ prepared = await prepareManagedSessionScopeForWrite(
3061
+ scope,
3062
+ scope.platform === "win32" ? "windows-existing-verify-first" : "default",
3063
+ authority,
3064
+ candidate,
3065
+ expectedIdentity,
3066
+ );
3067
+ if (prepared.kind === "error")
3068
+ return {
3069
+ kind: "error",
3070
+ code: expectedFailure(new Error(prepared.code)),
3071
+
3072
+ message: prepared.message,
3073
+ };
3074
+ current = revalidatePickerConsent(scope, candidate, expectedIdentity);
3075
+ } catch (error) {
2081
3076
  return {
2082
3077
  kind: "error",
2083
- code: "source_changed",
2084
- message: "The managed candidate changed before it could be opened.",
3078
+ code: expectedFailure(error),
3079
+ message: error instanceof Error ? error.message : "Managed migration failed.",
2085
3080
  };
3081
+ }
2086
3082
  if (isRetired(scope, current))
2087
3083
  return { kind: "error", code: "migration_retired", message: "The managed session has been retired." };
2088
3084
  if (current.provenance === "v2") return { kind: "opened", path: current.path, candidate: current, migrated: false };
@@ -2091,11 +3087,15 @@ export async function openManagedCandidateForWrite(
2091
3087
  const internal = managedInternalDirectory(scope);
2092
3088
  let lock: ManagedStorageLock | undefined;
2093
3089
  let detachedArtifacts: DetachedArtifactRoot | undefined;
3090
+ let sourceArtifactCleanup: SourceArtifactCleanup | undefined;
3091
+
2094
3092
  try {
3093
+ revalidatePickerConsent(scope, current, expectedIdentity);
2095
3094
  lock = await acquireManagedLock(path.join(internal, MANAGED_LOCKS_DIRECTORY), operation, scopeRoot(scope));
2096
- const afterLock = validateCandidateForScope(scope, current);
2097
- if (!afterLock)
2098
- return { kind: "error", code: "source_changed", message: "The legacy candidate changed during migration." };
3095
+ const heldLock = lock;
3096
+
3097
+ const afterLock = revalidatePickerConsent(scope, current, expectedIdentity);
3098
+
2099
3099
  const listing = listManagedCandidates(scope);
2100
3100
  if (listing.kind === "error") return { kind: "error", code: "binding_invalid", message: listing.message };
2101
3101
  const destination = path.join(scope.directoryPath, path.basename(afterLock.path));
@@ -2107,11 +3107,29 @@ export async function openManagedCandidateForWrite(
2107
3107
  code: "destination_conflict",
2108
3108
  message: "A distinct v2 transcript already owns this session id.",
2109
3109
  };
2110
- restorePreparedArtifactRoot(scope, afterLock);
2111
- const sourceSnapshot = captureManagedFileNoFollow(afterLock.path);
3110
+
3111
+ scopeRoot(scope);
3112
+ revalidatePickerConsent(scope, afterLock, expectedIdentity);
3113
+ restorePreparedArtifactRoot(scope, afterLock, heldLock);
3114
+ scopeRoot(scope);
3115
+
3116
+ let sourceSnapshot = captureManagedFileNoFollow(afterLock.path);
3117
+ if (
3118
+ sourceSnapshot.identity.dev !== afterLock.identity.dev ||
3119
+ sourceSnapshot.identity.ino !== afterLock.identity.ino ||
3120
+ sourceSnapshot.identity.size !== afterLock.identity.size ||
3121
+ sourceSnapshot.identity.mtimeNs !== afterLock.identity.mtimeNs ||
3122
+ sourceSnapshot.identity.sha256 !== afterLock.identity.sha256 ||
3123
+ sourceSnapshot.bytes.byteLength !== afterLock.identity.size
3124
+ )
3125
+ throw new Error("source_changed");
2112
3126
  let manifest: readonly ArtifactManifestEntry[] = [];
2113
3127
  const artifactPlan = planArtifactRootForMigration(afterLock.path, operation);
2114
3128
  const intendedDestination = { path: destination, sessionId: afterLock.sessionId, cwd: afterLock.cwd };
3129
+ const assertPublicationConsent = (): void => {
3130
+ heldLock.assertOwned();
3131
+ revalidatePickerConsent(scope, afterLock, expectedIdentity);
3132
+ };
2115
3133
  if (existing && existing.identity.sha256 !== afterLock.identity.sha256) {
2116
3134
  return {
2117
3135
  kind: "error",
@@ -2126,8 +3144,10 @@ export async function openManagedCandidateForWrite(
2126
3144
  message: "The migration destination already exists without validated ownership.",
2127
3145
  };
2128
3146
  }
3147
+
2129
3148
  const preparedReceipt = receiptPathFor(scope, afterLock, "prepared");
2130
3149
  try {
3150
+ revalidatePickerConsent(scope, afterLock, expectedIdentity);
2131
3151
  lock.assertOwned();
2132
3152
  await publishManagedFileNoReplace(
2133
3153
  preparedReceipt,
@@ -2147,27 +3167,97 @@ export async function openManagedCandidateForWrite(
2147
3167
  }
2148
3168
  : undefined,
2149
3169
  ),
2150
- lock.assertOwned,
3170
+ assertPublicationConsent,
3171
+ scopeRoot(scope),
2151
3172
  );
2152
3173
  } catch (error) {
2153
3174
  if ((error as Error).message !== "destination_conflict") throw error;
2154
3175
  }
3176
+
3177
+ revalidatePickerConsent(scope, afterLock, expectedIdentity);
2155
3178
  lock.assertOwned();
2156
3179
  if (!preparedReceiptMatches(preparedReceipt, scope, afterLock, intendedDestination, artifactPlan))
2157
3180
  throw new Error("durability_failed");
2158
3181
  if (artifactPlan && fs.existsSync(artifactPlan.detachedPath)) throw new Error("destination_conflict");
2159
- detachedArtifacts = artifactPlan ? detachArtifactRootForMigration(artifactPlan) : undefined;
3182
+
3183
+ revalidatePickerConsent(scope, afterLock, expectedIdentity);
3184
+
3185
+ scopeRoot(scope);
3186
+
3187
+ if (artifactPlan) {
3188
+ const detached = detachArtifactRootForMigration(artifactPlan);
3189
+ detachedArtifacts = detached.detached;
3190
+ sourceArtifactCleanup = detached.detachOutcome === "cleanup_pending" ? detached.cleanup : undefined;
3191
+ const detachedReceipt = receiptPathFor(scope, afterLock, "detached");
3192
+ const detachedRecord = migrationReceipt(
3193
+ scope,
3194
+ lock,
3195
+ "artifact_detached",
3196
+ afterLock,
3197
+ intendedDestination,
3198
+ [],
3199
+ {
3200
+ path: detachedArtifacts.originalPath,
3201
+ detachedPath: detachedArtifacts.detachedPath,
3202
+ identity: detachedArtifacts.identity,
3203
+ tree: detachedArtifacts.tree,
3204
+ role: "detached_artifact_root",
3205
+ },
3206
+ sourceArtifactCleanup,
3207
+ detached.detachOutcome,
3208
+ );
3209
+ try {
3210
+ await publishManagedFileNoReplace(
3211
+ detachedReceipt,
3212
+ detachedRecord,
3213
+ assertPublicationConsent,
3214
+ scopeRoot(scope),
3215
+ );
3216
+ } catch (error) {
3217
+ if ((error as Error).message !== "destination_conflict") throw error;
3218
+ }
3219
+ if (!detachedReceiptMatches(detachedReceipt, detachedRecord)) throw new Error("durability_failed");
3220
+ }
2160
3221
  manifest = detachedArtifacts ? artifactManifestFromSnapshot(detachedArtifacts.tree) : [];
2161
- await copyArtifacts(scope, afterLock.path, destination, manifest, lock, detachedArtifacts?.detachedPath);
3222
+
3223
+ await copyArtifacts(
3224
+ scope,
3225
+ afterLock.path,
3226
+ destination,
3227
+ manifest,
3228
+ lock,
3229
+ afterLock,
3230
+ expectedIdentity,
3231
+ detachedArtifacts?.detachedPath,
3232
+ );
3233
+
3234
+ if (!existing) {
3235
+ revalidatePickerConsent(scope, afterLock, expectedIdentity);
3236
+ lock.assertOwned();
3237
+ sourceSnapshot = captureManagedFileNoFollow(afterLock.path);
3238
+ if (
3239
+ sourceSnapshot.identity.dev !== afterLock.identity.dev ||
3240
+ sourceSnapshot.identity.ino !== afterLock.identity.ino ||
3241
+ sourceSnapshot.identity.size !== afterLock.identity.size ||
3242
+ sourceSnapshot.identity.mtimeNs !== afterLock.identity.mtimeNs ||
3243
+ sourceSnapshot.identity.sha256 !== afterLock.identity.sha256 ||
3244
+ sourceSnapshot.bytes.byteLength !== afterLock.identity.size
3245
+ )
3246
+ throw new Error("source_changed");
3247
+ }
3248
+
2162
3249
  if (!existing) {
2163
3250
  try {
3251
+ revalidatePickerConsent(scope, afterLock, expectedIdentity);
2164
3252
  lock.assertOwned();
2165
3253
  await copyManagedFileNoReplace(afterLock.path, destination, sourceSnapshot, scopeRoot(scope));
2166
3254
  } catch (error) {
2167
3255
  if ((error as Error).message !== "destination_conflict") throw error;
2168
3256
  }
2169
3257
  }
3258
+
2170
3259
  // Artifact files, directories, and the transcript must be durable before a receipt can grant shadow authority.
3260
+ scopeRoot(scope);
2171
3261
  if (manifest.length > 0) fsyncManagedArtifactTree(destination.slice(0, -6));
2172
3262
  lock.assertOwned();
2173
3263
  const migrated = inspectCandidate(destination, "v2");
@@ -2179,10 +3269,14 @@ export async function openManagedCandidateForWrite(
2179
3269
  !manifestMatches(destination, manifest)
2180
3270
  )
2181
3271
  throw new Error("durability_failed");
3272
+
2182
3273
  if (detachedArtifacts) {
2183
- restoreDetachedArtifactRoot(detachedArtifacts);
3274
+ revalidatePickerConsent(scope, afterLock, expectedIdentity);
3275
+ scopeRoot(scope);
3276
+ restoreDetachedArtifactRoot(detachedArtifacts, sourceArtifactCleanup);
2184
3277
  detachedArtifacts = undefined;
2185
3278
  }
3279
+
2186
3280
  const latest = validateCandidateForScope(scope, afterLock);
2187
3281
  if (!latest || !sameCandidate(latest, afterLock) || !manifestMatches(afterLock.path, manifest))
2188
3282
  return {
@@ -2190,19 +3284,40 @@ export async function openManagedCandidateForWrite(
2190
3284
  code: "source_changed",
2191
3285
  message: "The legacy candidate or its artifacts changed before migration commit.",
2192
3286
  };
3287
+
2193
3288
  for (const state of ["published", "committed"] as const) {
2194
3289
  const receipt = receiptPathFor(scope, afterLock, state);
2195
3290
  try {
3291
+ revalidatePickerConsent(scope, afterLock, expectedIdentity);
2196
3292
  lock.assertOwned();
2197
3293
  await publishManagedFileNoReplace(
2198
3294
  receipt,
2199
- migrationReceipt(scope, lock, state, afterLock, migrated, manifest),
2200
- lock.assertOwned,
3295
+ migrationReceipt(
3296
+ scope,
3297
+ lock,
3298
+ state,
3299
+ afterLock,
3300
+ migrated,
3301
+ manifest,
3302
+ artifactPlan
3303
+ ? {
3304
+ path: artifactPlan.originalPath,
3305
+ detachedPath: artifactPlan.detachedPath,
3306
+ identity: artifactPlan.identity,
3307
+ tree: artifactPlan.tree,
3308
+ role: "detached_artifact_root",
3309
+ }
3310
+ : undefined,
3311
+ sourceArtifactCleanup,
3312
+ ),
3313
+ assertPublicationConsent,
3314
+ scopeRoot(scope),
2201
3315
  );
2202
3316
  } catch (error) {
2203
3317
  if ((error as Error).message !== "destination_conflict") throw error;
2204
3318
  }
2205
3319
  }
3320
+
2206
3321
  const receipt = receiptPathFor(scope, afterLock);
2207
3322
  lock.assertOwned();
2208
3323
  if (!receiptMatches(receipt, afterLock, migrated, scope))
@@ -2211,6 +3326,9 @@ export async function openManagedCandidateForWrite(
2211
3326
  code: "durability_failed",
2212
3327
  message: "The migration receipt does not bind the copied v2 transcript and artifacts.",
2213
3328
  };
3329
+
3330
+ revalidatePickerConsent(scope, afterLock, expectedIdentity);
3331
+ scopeRoot(scope);
2214
3332
  await removeStagedReceipts(scope, afterLock);
2215
3333
  return {
2216
3334
  kind: "opened",
@@ -2220,7 +3338,7 @@ export async function openManagedCandidateForWrite(
2220
3338
  };
2221
3339
  } catch (error) {
2222
3340
  try {
2223
- if (detachedArtifacts) restoreDetachedArtifactRoot(detachedArtifacts);
3341
+ if (detachedArtifacts) restoreDetachedArtifactRoot(detachedArtifacts, sourceArtifactCleanup);
2224
3342
  } catch {
2225
3343
  return {
2226
3344
  kind: "error",
@@ -2229,7 +3347,16 @@ export async function openManagedCandidateForWrite(
2229
3347
  };
2230
3348
  }
2231
3349
  const code = expectedFailure(error);
2232
- return { kind: "error", code, message: error instanceof Error ? error.message : "Managed migration failed." };
3350
+ return {
3351
+ kind: "error",
3352
+ code,
3353
+ message:
3354
+ code === "artifact_capacity_exceeded"
3355
+ ? `Legacy session artifacts exceed the migration capacity (${MANAGED_ARTIFACT_MAX_FILES.toLocaleString()} files or ${MANAGED_ARTIFACT_MAX_TOTAL_BYTES / 1024 / 1024} MiB).`
3356
+ : error instanceof Error
3357
+ ? error.message
3358
+ : "Managed migration failed.",
3359
+ };
2233
3360
  } finally {
2234
3361
  if (lock) await lock.release().catch(() => undefined);
2235
3362
  }
@@ -2244,7 +3371,7 @@ export async function deleteManagedSessionCandidate(
2244
3371
  if (prepared.kind === "error")
2245
3372
  return {
2246
3373
  kind: "error",
2247
- code: prepared.code === "binding_conflict" ? "binding_conflict" : "binding_invalid",
3374
+ code: expectedFailure(new Error(prepared.code)),
2248
3375
  message: prepared.message,
2249
3376
  };
2250
3377
  const current = validateCandidateForScope(scope, candidate);
@@ -2304,8 +3431,7 @@ export async function deleteManagedSessionCandidate(
2304
3431
  if (!observedPending) continue;
2305
3432
  if (
2306
3433
  cleanupArtifactsRemoved(scope, tombstone, target, observedPending.attempt) &&
2307
- artifactRootsAbsent(scope, tombstone, target, observedPending) &&
2308
- !fs.existsSync(observedPending.plannedTranscriptPath)
3434
+ cleanupRootsAbsent(tombstone, target, observedPending)
2309
3435
  ) {
2310
3436
  fsyncManagedParent(target.path);
2311
3437
  await publishCleanupCompleted(scope, tombstone, target, lock);
@@ -2340,6 +3466,12 @@ export async function deleteManagedSessionCandidate(
2340
3466
  active.detachedTranscriptPath ??
2341
3467
  observedPending?.detachedTranscriptPath ??
2342
3468
  (pending && fs.existsSync(pending.plannedTranscriptPath) ? pending.plannedTranscriptPath : undefined),
3469
+ retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
3470
+ retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
3471
+ retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
3472
+ retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
3473
+ retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
3474
+ retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
2343
3475
  plannedArtifactsPath: active.plannedArtifactsPath,
2344
3476
  plannedTranscriptPath: active.plannedTranscriptPath,
2345
3477
  ...(cleanupArtifactsRemoved(scope, tombstone, target, pending?.attempt ?? active.attempt)
@@ -2356,6 +3488,13 @@ export async function deleteManagedSessionCandidate(
2356
3488
  transcriptIdentity: target.identity,
2357
3489
  plannedArtifactsPath: active.plannedArtifactsPath,
2358
3490
  plannedTranscriptPath: active.plannedTranscriptPath,
3491
+ detachedTranscriptPath: active.detachedTranscriptPath ?? observedPending?.detachedTranscriptPath,
3492
+ retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
3493
+ retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
3494
+ retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
3495
+ retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
3496
+ retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
3497
+ retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
2359
3498
  artifactsRemoved: true,
2360
3499
  });
2361
3500
  }
@@ -2371,23 +3510,51 @@ export async function deleteManagedSessionCandidate(
2371
3510
  )
2372
3511
  throw new Error("durability_failed");
2373
3512
  const retry = nextCleanupReceipt(target, active);
2374
- await publishCleanupPending(
2375
- scope,
2376
- tombstone,
2377
- {
2378
- ...retry,
2379
- expectedArtifactsIdentity:
2380
- deletion.phase === "artifacts" ? deletion.artifactsIdentity : active.expectedArtifactsIdentity,
2381
- expectedArtifactsTree:
2382
- deletion.phase === "artifacts" ? deletion.artifactsTree : active.expectedArtifactsTree,
2383
- detachedArtifactsPath:
2384
- deletion.phase === "artifacts" ? deletion.detachedArtifactsPath : active.detachedArtifactsPath,
2385
- detachedTranscriptPath:
2386
- deletion.phase === "transcript" ? deletion.detachedTranscriptPath : active.detachedTranscriptPath,
2387
- },
2388
- lock,
2389
- );
2390
- throw new Error("durability_failed");
3513
+ await publishCleanupPending(scope, tombstone, cleanupPendingEvidence(retry, active, deletion), lock);
3514
+ if (deletion.phase === "artifacts") {
3515
+ // A typed retained artifact root proves durable canonical absence; advance
3516
+ // to the transcript phase instead of stalling on the retained evidence.
3517
+ await publishCleanupArtifactsRemoved(scope, tombstone, active, lock);
3518
+ deletion = await new FileSessionStorage().deleteSessionVerified({
3519
+ sessionsRoot: scope.sessionsRoot,
3520
+ transcriptPath: target.path,
3521
+ sessionId: target.sessionId,
3522
+ cwd: target.cwd,
3523
+ transcriptIdentity: target.identity,
3524
+ plannedArtifactsPath: active.plannedArtifactsPath,
3525
+ plannedTranscriptPath: active.plannedTranscriptPath,
3526
+ detachedTranscriptPath: active.detachedTranscriptPath ?? observedPending?.detachedTranscriptPath,
3527
+ retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
3528
+ retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
3529
+ retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
3530
+ retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
3531
+ retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
3532
+ retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
3533
+ artifactsRemoved: true,
3534
+ });
3535
+ if (deletion.kind === "cleanup_pending") {
3536
+ const followup = nextCleanupReceipt(target, active);
3537
+ await publishCleanupPending(
3538
+ scope,
3539
+ tombstone,
3540
+ cleanupPendingEvidence(followup, active, deletion),
3541
+ lock,
3542
+ );
3543
+ if (
3544
+ deletion.phase !== "transcript" ||
3545
+ deletion.detachedTranscriptPath !== active.plannedTranscriptPath
3546
+ )
3547
+ return {
3548
+ kind: "cleanup_pending",
3549
+ tombstonePath: tombstone,
3550
+ phase: deletion.phase,
3551
+ message:
3552
+ "Exact cleanup remains pending because descriptor-bound final deletion is unavailable.",
3553
+ };
3554
+ }
3555
+ }
3556
+ // A typed retained transcript quarantine is durable canonical-absence
3557
+ // evidence — never a terminal byte-deletion claim; complete the receipt.
2391
3558
  }
2392
3559
  fsyncManagedParent(target.path);
2393
3560
  await publishCleanupCompleted(scope, tombstone, target, lock);