@sayknow-cli/coding-agent 0.4.7 → 0.5.1

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 +122 -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 +20 -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
@@ -18,7 +18,6 @@ import * as native from "@sayknow-cli/natives";
18
18
  import { getTerminalId } from "@sayknow-cli/tui";
19
19
  import {
20
20
  getBlobsDir,
21
- getAgentDir as getDefaultAgentDir,
22
21
  getProjectDir,
23
22
  getSessionsDir,
24
23
  getTerminalSessionsDir,
@@ -33,8 +32,8 @@ import {
33
32
  } from "@sayknow-cli/utils";
34
33
  import type { TtsrInjectionRecord } from "../export/ttsr";
35
34
  import type { ManagedLegacyLocalMigrationSource } from "../internal-urls/local-protocol";
35
+ import { assertSafePathComponent } from "../skc-runtime/session-layout";
36
36
  import { writeTextAtomic } from "../skc-runtime/state-writer";
37
-
38
37
  import * as git from "../utils/git";
39
38
  import { ArtifactManager } from "./artifacts";
40
39
  import {
@@ -58,20 +57,45 @@ import {
58
57
  canonicalizeTrustedPath,
59
58
  deleteManagedSessionCandidate,
60
59
  listManagedCandidates,
60
+ type ManagedCandidateWriteAuthority,
61
61
  type ManagedMigrationPolicy,
62
+ type ManagedOpenCandidateResult,
62
63
  type ManagedScope,
63
64
  managedDirectoryAuthorityForScope,
64
65
  managedRootForScope,
65
66
  openManagedCandidateForWrite,
66
67
  prepareManagedSessionScopeForWriteSync,
67
68
  resolveManagedScope,
69
+ resolveManagedScopeForWrite,
68
70
  } from "./internal/managed-session-scope";
69
71
  import {
72
+ assertManagedDirectoryRoot,
73
+ fsyncManagedArtifactTree,
70
74
  type ManagedDirectoryRoot,
71
75
  type ManagedFileSnapshot,
72
76
  ManagedSessionDescendantStore,
77
+ type ManagedSessionSecurityPolicy,
78
+ mayCleanManagedTreeStaging,
73
79
  retainManagedDirectoryAuthority,
74
80
  } from "./internal/managed-session-storage";
81
+ import { classifyNativePublishOutcome, formatNativePublishDiagnostic } from "./internal/native-publish-outcome";
82
+ import {
83
+ hasOnlyKeys as hasOnlyMemoryGuardKeys,
84
+ isMemoryGuardDecimalString,
85
+ isMemoryGuardRelativePath,
86
+ isMemoryGuardSha256Hex,
87
+ type MemoryGuardCheckpointBlobAuthorityV1,
88
+ type MemoryGuardCheckpointBlobManifestEntryV1,
89
+ type MemoryGuardCheckpointBlobManifestV1,
90
+ type MemoryGuardCreateCheckpointInput,
91
+ type MemoryGuardParticipantDescriptorV1,
92
+ type MemoryGuardParticipantIngressLease,
93
+ type MemoryGuardRestoreInput,
94
+ type MemoryGuardRestoreResult,
95
+ type MemoryGuardSessionManagerCheckpointV1,
96
+ memoryGuardCanonicalJson,
97
+ memoryGuardSha256Hex,
98
+ } from "./memory-guard-checkpoint-participant";
75
99
 
76
100
  import {
77
101
  type BashExecutionMessage,
@@ -127,6 +151,36 @@ export interface NewSessionOptions {
127
151
  drop?: boolean;
128
152
  }
129
153
 
154
+ /** Internal successor prepared without changing the manager's visible identity. */
155
+ export interface PreparedNewSession {
156
+ readonly sessionId: string;
157
+ readonly sessionFile: string | undefined;
158
+ readonly artifactsDir: string | null;
159
+ readonly managedLegacyLocalMigrationSource: ManagedLegacyLocalMigrationSource | null;
160
+ }
161
+
162
+ interface PreparedSessionIndex {
163
+ readonly byId: Map<string, SessionEntry>;
164
+ readonly labelsById: Map<string, string>;
165
+ readonly leafId: string | null;
166
+ readonly usageStatistics: UsageStatistics;
167
+ }
168
+
169
+ interface PreparedNewSessionState extends PreparedNewSession {
170
+ readonly header: SessionHeader;
171
+ readonly fileEntries: FileEntry[];
172
+ readonly sessionName: string | undefined;
173
+ readonly titleSource: "auto" | "user" | undefined;
174
+ residentFileEntries?: FileEntry[];
175
+ residentTextBlobStore?: BlobStore;
176
+ index?: PreparedSessionIndex;
177
+ flushed: boolean;
178
+ committed: boolean;
179
+ discarded: boolean;
180
+ persistenceWriter?: NdjsonFileWriter;
181
+ persistenceTempPath?: string;
182
+ }
183
+
130
184
  export interface SessionEntryBase {
131
185
  type: string;
132
186
  id: string;
@@ -443,6 +497,238 @@ export type DefaultModelSelectionStage = {
443
497
  readonly tempPath: string | undefined;
444
498
  readonly persistsToExistingFile: boolean;
445
499
  };
500
+ export interface SessionManagerRevisionSnapshot {
501
+ entry: number;
502
+ leaf: number;
503
+ headerExport: number;
504
+ label: number;
505
+ replayMetadata: number;
506
+ }
507
+
508
+ export interface SessionManagerCheckpointRevisionStrings {
509
+ entry: string;
510
+ leaf: string;
511
+ headerExport: string;
512
+ label: string;
513
+ replayMetadata: string;
514
+ }
515
+
516
+ function assertMemoryGuardSessionId(sessionId: string): void {
517
+ assertSafePathComponent(sessionId, "memory guard checkpoint session id");
518
+ }
519
+
520
+ function memoryGuardParticipantRoot(checkpointRoot: string, sessionId: string): string {
521
+ assertMemoryGuardSessionId(sessionId);
522
+ return path.join(checkpointRoot, "participants", sessionId);
523
+ }
524
+
525
+ function memoryGuardParticipantRelativePath(sessionId: string, suffix: string): string {
526
+ assertMemoryGuardSessionId(sessionId);
527
+ return `participants/${sessionId}/${suffix}`;
528
+ }
529
+
530
+ async function fsyncDirectoryPath(directoryPath: string): Promise<void> {
531
+ const directory = await fs.promises.open(directoryPath, "r");
532
+ try {
533
+ await directory.sync();
534
+ } finally {
535
+ await directory.close();
536
+ }
537
+ }
538
+
539
+ async function ensureOwnerOnlyDirectory(directoryPath: string): Promise<void> {
540
+ await fs.promises.mkdir(directoryPath, { recursive: true, mode: 0o700 });
541
+ await fs.promises.chmod(directoryPath, 0o700).catch(() => undefined);
542
+ await fsyncDirectoryPath(directoryPath);
543
+ }
544
+ interface CreatedDirectoryIdentity {
545
+ dev: bigint;
546
+ ino: bigint;
547
+ }
548
+
549
+ async function ensureOwnerOnlyDirectoryTracked(directoryPath: string): Promise<CreatedDirectoryIdentity | undefined> {
550
+ const createdPath = await fs.promises.mkdir(directoryPath, { recursive: true, mode: 0o700 });
551
+ await fs.promises.chmod(directoryPath, 0o700).catch(() => undefined);
552
+ await fsyncDirectoryPath(directoryPath);
553
+ if (createdPath === undefined) return undefined;
554
+ const stat = await fs.promises.lstat(directoryPath, { bigint: true });
555
+ return { dev: stat.dev, ino: stat.ino };
556
+ }
557
+
558
+ async function removeCreatedDirectoryIfEmpty(
559
+ directoryPath: string,
560
+ created: CreatedDirectoryIdentity | undefined,
561
+ ): Promise<void> {
562
+ if (!created) return;
563
+ const stat = await fs.promises.lstat(directoryPath, { bigint: true }).catch(() => undefined);
564
+ if (!stat || stat.dev !== created.dev || stat.ino !== created.ino) return;
565
+ await fs.promises.rmdir(directoryPath).catch(() => undefined);
566
+ await fsyncDirectoryPath(path.dirname(directoryPath)).catch(() => undefined);
567
+ }
568
+
569
+ async function writeOwnerOnlyFileNoReplace(filePath: string, content: Uint8Array | string): Promise<void> {
570
+ await ensureOwnerOnlyDirectory(path.dirname(filePath));
571
+ const handle = await fs.promises.open(filePath, "wx", 0o600);
572
+ try {
573
+ await handle.writeFile(content);
574
+ await handle.sync();
575
+ } finally {
576
+ await handle.close();
577
+ }
578
+ await fs.promises.chmod(filePath, 0o600).catch(() => undefined);
579
+ await fsyncDirectoryPath(path.dirname(filePath));
580
+ }
581
+
582
+ const MEMORY_GUARD_CHECKPOINT_FILE_MAX_BYTES = 64 * 1024 * 1024;
583
+ const MEMORY_GUARD_CHECKPOINT_BLOB_MAX_ENTRIES = 4096;
584
+ const MEMORY_GUARD_CHECKPOINT_BLOB_TOTAL_MAX_BYTES = 64 * 1024 * 1024;
585
+
586
+ function collectCheckpointBlobRefs(value: unknown, refs: Set<string> = new Set(), key?: string): Set<string> {
587
+ const addRef = (candidate: unknown): void => {
588
+ if (typeof candidate === "string" && parseBlobRef(candidate)) refs.add(candidate);
589
+ };
590
+ if (Array.isArray(value)) {
591
+ for (const item of value) collectCheckpointBlobRefs(item, refs, key);
592
+ return refs;
593
+ }
594
+ if (!value || typeof value !== "object") return refs;
595
+ if (isRecord(value) && value.kind === "cold_spill" && typeof value.ref === "string" && parseBlobRef(value.ref))
596
+ refs.add(value.ref);
597
+ if (isImageBlock(value) && key === TEXT_CONTENT_KEY) addRef(value.data);
598
+ if (hasImageUrl(value)) {
599
+ if (typeof value.image_url === "string") addRef(value.image_url);
600
+ else addRef(value.image_url.url);
601
+ }
602
+ for (const [childKey, item] of Object.entries(value as Record<string, unknown>)) {
603
+ if (childKey === "data" && key !== TEXT_CONTENT_KEY) addRef(item);
604
+ collectCheckpointBlobRefs(item, refs, childKey);
605
+ }
606
+ return refs;
607
+ }
608
+
609
+ function decodeCheckpointUtf8(data: Uint8Array): string | null {
610
+ try {
611
+ return new TextDecoder("utf-8", { fatal: true }).decode(data);
612
+ } catch {
613
+ return null;
614
+ }
615
+ }
616
+
617
+ function validateMemoryGuardBlobAuthority(value: unknown): value is MemoryGuardCheckpointBlobAuthorityV1 {
618
+ if (
619
+ !isRecord(value) ||
620
+ !hasOnlyMemoryGuardKeys(value, ["kind", "manifest_relative_path", "manifest_sha256", "root_relative_path"])
621
+ )
622
+ return false;
623
+ return (
624
+ value.kind === "checkpoint_blob_tree_v1" &&
625
+ isMemoryGuardRelativePath(value.manifest_relative_path) &&
626
+ isMemoryGuardSha256Hex(value.manifest_sha256) &&
627
+ isMemoryGuardRelativePath(value.root_relative_path)
628
+ );
629
+ }
630
+
631
+ function validateMemoryGuardTranscriptDescriptor(
632
+ value: unknown,
633
+ ): value is MemoryGuardSessionManagerCheckpointV1["transcript"] {
634
+ if (!isRecord(value) || !hasOnlyMemoryGuardKeys(value, ["bytes", "relative_path", "sha256"])) return false;
635
+ return (
636
+ isMemoryGuardDecimalString(value.bytes) &&
637
+ isMemoryGuardRelativePath(value.relative_path) &&
638
+ isMemoryGuardSha256Hex(value.sha256)
639
+ );
640
+ }
641
+
642
+ function validateMemoryGuardRevisions(value: unknown): value is SessionManagerCheckpointRevisionStrings {
643
+ if (!isRecord(value) || !hasOnlyMemoryGuardKeys(value, ["entry", "leaf", "headerExport", "label", "replayMetadata"]))
644
+ return false;
645
+ return (
646
+ isMemoryGuardDecimalString(value.entry) &&
647
+ isMemoryGuardDecimalString(value.leaf) &&
648
+ isMemoryGuardDecimalString(value.headerExport) &&
649
+ isMemoryGuardDecimalString(value.label) &&
650
+ isMemoryGuardDecimalString(value.replayMetadata)
651
+ );
652
+ }
653
+
654
+ function validateMemoryGuardCheckpoint(value: unknown): value is MemoryGuardSessionManagerCheckpointV1 {
655
+ if (
656
+ !isRecord(value) ||
657
+ !hasOnlyMemoryGuardKeys(value, [
658
+ "blob_authority",
659
+ "revisions",
660
+ "schema_version",
661
+ "session_id",
662
+ "session_name",
663
+ "transcript",
664
+ ])
665
+ )
666
+ return false;
667
+ return (
668
+ value.schema_version === 1 &&
669
+ typeof value.session_id === "string" &&
670
+ value.session_id.length > 0 &&
671
+ (value.session_name === null || typeof value.session_name === "string") &&
672
+ validateMemoryGuardBlobAuthority(value.blob_authority) &&
673
+ validateMemoryGuardRevisions(value.revisions) &&
674
+ validateMemoryGuardTranscriptDescriptor(value.transcript)
675
+ );
676
+ }
677
+
678
+ function validateMemoryGuardBlobManifestEntry(value: unknown): value is MemoryGuardCheckpointBlobManifestEntryV1 {
679
+ if (!isRecord(value) || !hasOnlyMemoryGuardKeys(value, ["bytes", "relative_path", "sha256"])) return false;
680
+ return (
681
+ isMemoryGuardDecimalString(value.bytes) &&
682
+ isMemoryGuardRelativePath(value.relative_path) &&
683
+ isMemoryGuardSha256Hex(value.sha256)
684
+ );
685
+ }
686
+
687
+ function validateMemoryGuardBlobManifest(value: unknown): value is MemoryGuardCheckpointBlobManifestV1 {
688
+ if (!isRecord(value) || !hasOnlyMemoryGuardKeys(value, ["entries", "schema_version"])) return false;
689
+ if (value.schema_version !== 1 || !Array.isArray(value.entries)) return false;
690
+ return value.entries.every(validateMemoryGuardBlobManifestEntry);
691
+ }
692
+
693
+ function memoryGuardParticipantMatchesCheckpoint(
694
+ participant: MemoryGuardParticipantDescriptorV1,
695
+ checkpoint: MemoryGuardSessionManagerCheckpointV1,
696
+ ): boolean {
697
+ return (
698
+ participant.session_id === checkpoint.session_id &&
699
+ participant.session_name === checkpoint.session_name &&
700
+ JSON.stringify(participant.checkpoint) === JSON.stringify(checkpoint.blob_authority) &&
701
+ JSON.stringify(participant.revisions) === JSON.stringify(checkpoint.revisions) &&
702
+ JSON.stringify(participant.transcript) === JSON.stringify(checkpoint.transcript)
703
+ );
704
+ }
705
+
706
+ function readCheckpointAuthorityFile(
707
+ authority: native.RecoveryFsRoot,
708
+ relativePath: string,
709
+ maxBytes: number,
710
+ ): Uint8Array | null {
711
+ const result = authority.readManaged(relativePath);
712
+ if (!result.ok || !result.data || result.data.byteLength > maxBytes) return null;
713
+ return result.data;
714
+ }
715
+
716
+ function toCanonicalRevisionString(name: keyof SessionManagerRevisionSnapshot, value: number): string {
717
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error(`invalid_session_manager_revision:${name}`);
718
+ return String(value);
719
+ }
720
+
721
+ export function toSessionManagerCheckpointRevisionStrings(
722
+ snapshot: SessionManagerRevisionSnapshot,
723
+ ): SessionManagerCheckpointRevisionStrings {
724
+ return {
725
+ entry: toCanonicalRevisionString("entry", snapshot.entry),
726
+ leaf: toCanonicalRevisionString("leaf", snapshot.leaf),
727
+ headerExport: toCanonicalRevisionString("headerExport", snapshot.headerExport),
728
+ label: toCanonicalRevisionString("label", snapshot.label),
729
+ replayMetadata: toCanonicalRevisionString("replayMetadata", snapshot.replayMetadata),
730
+ };
731
+ }
446
732
 
447
733
  export type DefaultModelSelectionPromotion =
448
734
  | { readonly kind: "promoted" }
@@ -540,6 +826,13 @@ export class SessionMigrationPolicyError extends Error {
540
826
  }
541
827
  }
542
828
 
829
+ export class SessionArtifactCapacityError extends Error {
830
+ constructor(message: string) {
831
+ super(message);
832
+ this.name = "SessionArtifactCapacityError";
833
+ }
834
+ }
835
+
543
836
  export type ResumeTailInspection = ResumeTailResumable | ResumeTailTerminal | ResumeTailError;
544
837
  export interface StrictSessionOpenSuccess {
545
838
  kind: "opened";
@@ -548,7 +841,8 @@ export interface StrictSessionOpenSuccess {
548
841
 
549
842
  export interface StrictSessionOpenFailure {
550
843
  kind: "error";
551
- reason: ResumeTailError["reason"] | "identity-mismatch" | "migration-required";
844
+ reason: ResumeTailError["reason"] | "identity-mismatch" | "migration-required" | "artifact_capacity_exceeded";
845
+ message?: string;
552
846
  }
553
847
 
554
848
  /** Exact transcript bytes and authority captured from one descriptor-bound read. */
@@ -1317,6 +1611,18 @@ function freezeInternalReadSnapshot<T>(value: T): T {
1317
1611
  return copy;
1318
1612
  }
1319
1613
 
1614
+ function managedScopeStartupError(
1615
+ action: "resolve" | "prepare",
1616
+ failure: Extract<ReturnType<typeof resolveManagedScopeForWrite>, { kind: "error" }>,
1617
+ ): Error {
1618
+ return new Error(`Could not ${action} managed session scope.`, {
1619
+ cause: {
1620
+ classification: failure.cause?.classification ?? failure.code,
1621
+ ...(failure.cause?.diagnostic === undefined ? {} : { diagnostic: failure.cause.diagnostic }),
1622
+ },
1623
+ });
1624
+ }
1625
+
1320
1626
  /** Resolve and prepare the default v2 session scope before any managed writer exists. */
1321
1627
  function computeDefaultSessionDir(
1322
1628
  cwd: string,
@@ -1324,10 +1630,13 @@ function computeDefaultSessionDir(
1324
1630
  sessionsRoot: string = getSessionsDir(),
1325
1631
  ): string {
1326
1632
  if (!(storage instanceof FileSessionStorage)) throw new SessionManagedStorageError();
1327
- const resolved = resolveManagedScope({ cwd, agentDir: path.resolve(sessionsRoot, ".."), sessionsRoot });
1328
- if (resolved.kind === "error") throw new Error(`Could not resolve managed session scope: ${resolved.message}`);
1329
- const prepared = prepareManagedSessionScopeForWriteSync(resolved.scope);
1330
- if (prepared.kind === "error") throw new Error(`Could not prepare managed session scope: ${prepared.message}`);
1633
+ const resolved = resolveManagedScopeForWrite({ cwd, agentDir: path.resolve(sessionsRoot, ".."), sessionsRoot });
1634
+ if (resolved.kind === "error") throw managedScopeStartupError("resolve", resolved);
1635
+ const prepared = prepareManagedSessionScopeForWriteSync(
1636
+ resolved.scope,
1637
+ process.platform === "win32" ? "windows-existing-verify-first" : "default",
1638
+ );
1639
+ if (prepared.kind === "error") throw managedScopeStartupError("prepare", prepared);
1331
1640
  return prepared.scope.directoryPath;
1332
1641
  }
1333
1642
 
@@ -1408,6 +1717,13 @@ function managedFileSnapshotEquals(left: ManagedFileSnapshot | null, right: Mana
1408
1717
  );
1409
1718
  }
1410
1719
  const trustedSessionDestinations = new WeakSet<SessionDestination>();
1720
+ const managedSecurityPolicies = new WeakMap<ManagedSessionSecurityContext, ManagedSessionSecurityPolicy>();
1721
+
1722
+ function managedSecurityPolicyForContext(securityContext: ManagedSessionSecurityContext): ManagedSessionSecurityPolicy {
1723
+ const policy = managedSecurityPolicies.get(securityContext);
1724
+ if (!policy) throw new Error("Managed session security policy unavailable");
1725
+ return policy;
1726
+ }
1411
1727
 
1412
1728
  function managedStoreFromContext(
1413
1729
  securityContext: ManagedSessionSecurityContext,
@@ -1419,6 +1735,7 @@ function managedStoreFromContext(
1419
1735
  securityContext.retainedAuthority
1420
1736
  ? { authority: securityContext.retainedAuthority, authorityBaseDir: directory }
1421
1737
  : undefined,
1738
+ managedSecurityPolicyForContext(securityContext),
1422
1739
  );
1423
1740
  }
1424
1741
 
@@ -1431,6 +1748,10 @@ function freezeManagedDestination(scope: ManagedScope): SessionDestination {
1431
1748
  rootAuthority,
1432
1749
  retainedAuthority: managedDirectoryAuthorityForScope(scope),
1433
1750
  });
1751
+ managedSecurityPolicies.set(
1752
+ securityContext,
1753
+ scope.platform === "win32" ? "windows-existing-verify-first" : "default",
1754
+ );
1434
1755
  const destination = Object.freeze({ kind: "managed" as const, directory: scope.directoryPath, securityContext });
1435
1756
  trustedSessionDestinations.add(destination);
1436
1757
  return destination;
@@ -1445,10 +1766,17 @@ function explicitDestination(directory: string): SessionDestination {
1445
1766
  function managedDestination(cwd: string, storage: SessionStorage, agentDir?: string): SessionDestination {
1446
1767
  if (!(storage instanceof FileSessionStorage)) throw new SessionManagedStorageError();
1447
1768
  const sessionsRoot = getSessionsDir(agentDir);
1448
- const resolved = resolveManagedScope({ cwd, agentDir: agentDir ?? path.resolve(sessionsRoot, ".."), sessionsRoot });
1449
- if (resolved.kind === "error") throw new Error(`Could not resolve managed session scope: ${resolved.message}`);
1450
- const prepared = prepareManagedSessionScopeForWriteSync(resolved.scope);
1451
- if (prepared.kind === "error") throw new Error(`Could not prepare managed session scope: ${prepared.message}`);
1769
+ const resolved = resolveManagedScopeForWrite({
1770
+ cwd,
1771
+ agentDir: agentDir ?? path.resolve(sessionsRoot, ".."),
1772
+ sessionsRoot,
1773
+ });
1774
+ if (resolved.kind === "error") throw managedScopeStartupError("resolve", resolved);
1775
+ const prepared = prepareManagedSessionScopeForWriteSync(
1776
+ resolved.scope,
1777
+ process.platform === "win32" ? "windows-existing-verify-first" : "default",
1778
+ );
1779
+ if (prepared.kind === "error") throw managedScopeStartupError("prepare", prepared);
1452
1780
  return freezeManagedDestination(prepared.scope);
1453
1781
  }
1454
1782
 
@@ -1814,12 +2142,46 @@ function inspectResumeSessionFile(
1814
2142
  mtimeNs: snapshot.mtimeNs,
1815
2143
  sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
1816
2144
  };
1817
- return { identity, content: Uint8Array.from(bytes), entries, context, migrationApplied };
2145
+ return { identity, content: bytes, entries, context, migrationApplied };
1818
2146
  } catch {
1819
2147
  return { kind: "error", reason: "malformed" };
1820
2148
  }
1821
2149
  }
1822
2150
 
2151
+ /** Revalidate previously parsed authority without another decode, parse, migration, or context build. */
2152
+ function revalidateResumeSessionIdentity(
2153
+ filePath: string,
2154
+ storage: SessionStorage,
2155
+ expected: ResumeSessionIdentity,
2156
+ ): StrictSessionOpenFailure | { kind: "valid" } {
2157
+ const canonicalPath = resolveEquivalentPath(path.resolve(filePath));
2158
+ let before: SessionStorageStat;
2159
+ let snapshot: SessionStorageSnapshot;
2160
+ let after: SessionStorageStat;
2161
+ try {
2162
+ before = storage.statSync(canonicalPath);
2163
+ if (!before.isFile || !storage.readSnapshotSync) return { kind: "error", reason: "read-failed" };
2164
+ snapshot = storage.readSnapshotSync(canonicalPath);
2165
+ after = storage.statSync(canonicalPath);
2166
+ } catch (error) {
2167
+ return resumeReadFailure(error, storage, canonicalPath);
2168
+ }
2169
+ if (!sameResumeStat(before, snapshot.stat) || !sameResumeStat(snapshot.stat, after)) {
2170
+ return { kind: "error", reason: "unstable" };
2171
+ }
2172
+ const observed: ResumeSessionIdentity = {
2173
+ canonicalPath,
2174
+ sessionId: expected.sessionId,
2175
+ dev: snapshot.stat.dev,
2176
+ ino: snapshot.stat.ino,
2177
+ size: snapshot.stat.size,
2178
+ mtimeMs: snapshot.stat.mtimeMs,
2179
+ mtimeNs: snapshot.stat.mtimeNs,
2180
+ sha256: crypto.createHash("sha256").update(snapshot.bytes).digest("hex"),
2181
+ };
2182
+ return sameResumeIdentity(expected, observed) ? { kind: "valid" } : { kind: "error", reason: "identity-mismatch" };
2183
+ }
2184
+
1823
2185
  /**
1824
2186
  * Convert legacy persisted blob references in loaded entries into resident sentinels.
1825
2187
  * Images then materialize lazily at provider/display/export chokepoints instead of
@@ -2035,41 +2397,90 @@ export async function recoverOrphanedBackups(sessionDir: string, storage: Sessio
2035
2397
  }
2036
2398
 
2037
2399
  /**
2038
- * Reads all session files from the directory and returns them sorted by mtime (newest first).
2039
- * Uses low-level file I/O to efficiently read only the first 4KB of each file
2040
- * to extract the JSON header and first user message without loading entire session logs into memory.
2400
+ * Returns session metadata sorted by mtime (newest first).
2401
+ *
2402
+ * Directory entries are cheap to stat, but opening every transcript before applying a
2403
+ * small caller limit makes welcome-screen startup scale with the entire session history.
2404
+ * Rank paths first, then read bounded batches until the requested number of valid
2405
+ * sessions has been found. Invalid or future-version files do not prevent older valid
2406
+ * sessions from filling the result.
2041
2407
  */
2042
- async function getSortedSessions(sessionDir: string, storage: SessionStorage): Promise<RecentSessionInfo[]> {
2408
+ async function getSortedSessions(
2409
+ sessionDir: string,
2410
+ storage: SessionStorage,
2411
+ limit?: number,
2412
+ ): Promise<RecentSessionInfo[]> {
2043
2413
  await recoverOrphanedBackups(sessionDir, storage);
2044
2414
  try {
2045
- const files: string[] = storage.listFilesSync(sessionDir, "*.jsonl");
2415
+ let candidates: Array<{ path: string; mtime: number }> | undefined;
2416
+ if (storage.listFilesByMtime) {
2417
+ try {
2418
+ candidates = (await storage.listFilesByMtime(sessionDir, "*.jsonl")).map(candidate => ({
2419
+ path: candidate.path,
2420
+ mtime: candidate.mtimeMs,
2421
+ }));
2422
+ } catch (error) {
2423
+ logger.warn("Native session mtime listing failed; using JavaScript fallback", {
2424
+ sessionDir,
2425
+ error: toError(error).message,
2426
+ });
2427
+ }
2428
+ } else {
2429
+ logger.debug("Native session mtime listing unavailable; using JavaScript fallback", { sessionDir });
2430
+ }
2431
+ candidates ??= storage.listFilesSync(sessionDir, "*.jsonl").flatMap(path => {
2432
+ try {
2433
+ return [{ path, mtime: storage.statSync(path).mtimeMs }];
2434
+ } catch {
2435
+ return [];
2436
+ }
2437
+ });
2438
+ candidates.sort((left, right) => {
2439
+ if (left.mtime !== right.mtime) return right.mtime - left.mtime;
2440
+ return left.path < right.path ? -1 : left.path > right.path ? 1 : 0;
2441
+ });
2442
+
2046
2443
  const sessions: RecentSessionInfo[] = [];
2047
- await Promise.all(
2048
- files.map(async (path: string) => {
2049
- try {
2050
- const buffer = Buffer.allocUnsafe(SESSION_LIST_PREFIX_BYTES);
2051
- const content = await readSessionListPrefix(path, storage, buffer);
2052
- const entries = parseJsonlLenient<Record<string, unknown>>(content);
2053
- if (entries.length === 0) return;
2054
- const header = entries[0] as Record<string, unknown>;
2055
- if (
2056
- header.type !== "session" ||
2057
- typeof header.id !== "string" ||
2058
- !isSupportedSessionVersion(header.version)
2059
- )
2060
- return;
2061
- if (typeof header.version === "number" && header.version >= 4) {
2062
- for (const patch of await readSessionListTrailingPatches(path, storage, buffer)) {
2063
- applySessionListHeaderPatch(header as unknown as SessionListHeader, patch);
2444
+ const batchSize = Math.max(32, limit ?? 0);
2445
+
2446
+ for (
2447
+ let offset = 0;
2448
+ offset < candidates.length && (limit === undefined || sessions.length < limit);
2449
+ offset += batchSize
2450
+ ) {
2451
+ const batch = candidates.slice(offset, offset + batchSize);
2452
+ const parsed = await Promise.all(
2453
+ batch.map(async candidate => {
2454
+ try {
2455
+ const buffer = Buffer.allocUnsafe(SESSION_LIST_PREFIX_BYTES);
2456
+ const content = await readSessionListPrefix(candidate.path, storage, buffer);
2457
+ const entries = parseJsonlLenient<Record<string, unknown>>(content);
2458
+ if (entries.length === 0) return undefined;
2459
+ const header = entries[0] as Record<string, unknown>;
2460
+ if (
2461
+ header.type !== "session" ||
2462
+ typeof header.id !== "string" ||
2463
+ !isSupportedSessionVersion(header.version)
2464
+ )
2465
+ return undefined;
2466
+ if (typeof header.version === "number" && header.version >= 4) {
2467
+ for (const patch of await readSessionListTrailingPatches(candidate.path, storage, buffer)) {
2468
+ applySessionListHeaderPatch(header as unknown as SessionListHeader, patch);
2469
+ }
2064
2470
  }
2471
+ const firstPrompt = header.title ? undefined : extractFirstUserPrompt(entries);
2472
+ return new RecentSessionInfo(candidate.path, candidate.mtime, header, firstPrompt);
2473
+ } catch {
2474
+ return undefined;
2065
2475
  }
2066
- const mtime = storage.statSync(path).mtimeMs;
2067
- const firstPrompt = header.title ? undefined : extractFirstUserPrompt(entries);
2068
- sessions.push(new RecentSessionInfo(path, mtime, header, firstPrompt));
2069
- } catch {}
2070
- }),
2071
- );
2072
- return sessions.sort((a, b) => b.mtime - a.mtime);
2476
+ }),
2477
+ );
2478
+ for (const session of parsed) {
2479
+ if (session) sessions.push(session);
2480
+ if (limit !== undefined && sessions.length >= limit) break;
2481
+ }
2482
+ }
2483
+ return sessions;
2073
2484
  } catch {
2074
2485
  return [];
2075
2486
  }
@@ -2080,7 +2491,7 @@ export async function findMostRecentSession(
2080
2491
  sessionDir: string,
2081
2492
  storage: SessionStorage = new FileSessionStorage(),
2082
2493
  ): Promise<string | null> {
2083
- const sessions = await getSortedSessions(sessionDir, storage);
2494
+ const sessions = await getSortedSessions(sessionDir, storage, 1);
2084
2495
  return sessions[0]?.path || null;
2085
2496
  }
2086
2497
 
@@ -2099,23 +2510,6 @@ function formatTimeAgo(date: Date): string {
2099
2510
  return date.toLocaleDateString();
2100
2511
  }
2101
2512
 
2102
- async function syncPathAndParent(filePath: string): Promise<void> {
2103
- const file = await fs.promises.open(filePath, "r");
2104
- try {
2105
- await file.sync();
2106
- } finally {
2107
- await file.close();
2108
- }
2109
- if (process.platform !== "win32") {
2110
- const parent = await fs.promises.open(path.dirname(filePath), "r");
2111
- try {
2112
- await parent.sync();
2113
- } finally {
2114
- await parent.close();
2115
- }
2116
- }
2117
- }
2118
-
2119
2513
  interface SessionMoveDirectoryHandle {
2120
2514
  sync(): Promise<void>;
2121
2515
  close(): Promise<void>;
@@ -2135,40 +2529,6 @@ export async function syncSessionMoveDirectory(
2135
2529
  }
2136
2530
  }
2137
2531
 
2138
- /** Durably flush every copied file and directory before an EXDEV source removal. */
2139
- async function syncCopiedTree(treePath: string): Promise<void> {
2140
- const stat = await fs.promises.lstat(treePath);
2141
- if (stat.isSymbolicLink()) throw new Error("Refusing to fsync a copied symbolic link during a cross-device move");
2142
- if (stat.isFile()) {
2143
- const file = await fs.promises.open(treePath, "r");
2144
- try {
2145
- await file.sync();
2146
- } finally {
2147
- await file.close();
2148
- }
2149
- return;
2150
- }
2151
- if (!stat.isDirectory()) throw new Error("Copied cross-device move contains an unsupported filesystem entry");
2152
- const directory = await fs.promises.opendir(treePath);
2153
- try {
2154
- for (;;) {
2155
- const entry = await directory.read();
2156
- if (entry === null) break;
2157
- await syncCopiedTree(path.join(treePath, entry.name));
2158
- }
2159
- } finally {
2160
- await directory.close();
2161
- }
2162
- if (process.platform !== "win32") {
2163
- const handle = await fs.promises.open(treePath, "r");
2164
- try {
2165
- await handle.sync();
2166
- } finally {
2167
- await handle.close();
2168
- }
2169
- }
2170
- }
2171
-
2172
2532
  type CrossDeviceTreeIdentity = string;
2173
2533
 
2174
2534
  /** Captures content plus topology; a later capture must match before source removal. */
@@ -2216,14 +2576,6 @@ async function captureCrossDeviceTreeIdentity(treePath: string): Promise<CrossDe
2216
2576
  return `directory:${before.dev}:${before.ino}:${before.size}:${before.mtimeNs}:[${children.join(",")}]`;
2217
2577
  }
2218
2578
 
2219
- async function removeCrossDeviceSourceIfUnchanged(source: string, expected: CrossDeviceTreeIdentity): Promise<void> {
2220
- if ((await captureCrossDeviceTreeIdentity(source)) !== expected)
2221
- throw new Error("Cross-device move source changed before removal");
2222
- const stat = await fs.promises.lstat(source);
2223
- if (stat.isDirectory()) await fs.promises.rm(source, { recursive: true, force: false });
2224
- else await fs.promises.unlink(source);
2225
- }
2226
-
2227
2579
  /**
2228
2580
  * An EXDEV retirement must be driven by a native descriptor/handle-bound
2229
2581
  * snapshot-copy-verify transaction. Do not fall back to pathname copy/remove:
@@ -2239,83 +2591,25 @@ class CrossDeviceMoveUnsupportedError extends Error {
2239
2591
  }
2240
2592
  }
2241
2593
 
2242
- class PathMoveCompletedWithSyncFailure extends Error {
2243
- constructor(readonly cause: unknown) {
2244
- super("Path move completed but final source-directory sync failed.", { cause });
2245
- }
2246
- }
2247
-
2248
2594
  async function movePathAcrossDevicesSafe(source: string, destination: string): Promise<void> {
2249
2595
  const sourceIdentity = await captureCrossDeviceTreeIdentity(source);
2250
- const renamed = native.renameNoReplacePath(source, destination);
2251
- if (renamed.ok) {
2252
- try {
2253
- if ((await captureCrossDeviceTreeIdentity(destination)) !== sourceIdentity)
2254
- throw new Error("Atomic session rename did not preserve the captured source identity");
2255
- await syncSessionMoveDirectory(path.dirname(destination));
2256
- if (path.dirname(source) !== path.dirname(destination)) await syncSessionMoveDirectory(path.dirname(source));
2257
- } catch (error) {
2258
- throw new PathMoveCompletedWithSyncFailure(error);
2259
- }
2596
+ const outcome = classifyNativePublishOutcome(native.renameNoReplacePath(source, destination));
2597
+ if (outcome.ok) {
2598
+ if ((await captureCrossDeviceTreeIdentity(destination)) !== sourceIdentity)
2599
+ throw new Error("Atomic session rename did not preserve the captured source identity");
2600
+ await syncSessionMoveDirectory(path.dirname(destination));
2601
+ if (path.dirname(source) !== path.dirname(destination)) await syncSessionMoveDirectory(path.dirname(source));
2260
2602
  return;
2261
2603
  }
2262
- if (renamed.code === "quarantine_collision") {
2604
+ if (outcome.reason === "destination_exists") {
2263
2605
  const error = new Error(`Session move destination already exists: ${destination}`) as NodeJS.ErrnoException;
2264
2606
  error.code = "EEXIST";
2265
2607
  throw error;
2266
2608
  }
2267
- if (renamed.code !== "atomic_unavailable")
2268
- throw new Error(`Atomic session rename failed: ${renamed.code ?? "unknown"}`);
2269
- const sourceStat = await fs.promises.lstat(source);
2270
- if (!sourceStat.isDirectory()) {
2271
- let destinationCreated = false;
2272
- let sourceRemoved = false;
2273
- try {
2274
- try {
2275
- await fs.promises.link(source, destination);
2276
- destinationCreated = true;
2277
- } catch (error) {
2278
- if (hasFsCode(error, "EXDEV")) throw new CrossDeviceMoveUnsupportedError(source, destination, error);
2279
- if (
2280
- !hasFsCode(error, "EPERM") &&
2281
- !hasFsCode(error, "EACCES") &&
2282
- !hasFsCode(error, "ENOTSUP") &&
2283
- !hasFsCode(error, "EOPNOTSUPP")
2284
- )
2285
- throw error;
2286
- await fs.promises.copyFile(source, destination, fs.constants.COPYFILE_EXCL);
2287
- destinationCreated = true;
2288
- }
2289
- await syncPathAndParent(destination);
2290
- await removeCrossDeviceSourceIfUnchanged(source, sourceIdentity);
2291
- sourceRemoved = true;
2292
- await syncSessionMoveDirectory(path.dirname(source));
2293
- return;
2294
- } catch (error) {
2295
- if (!sourceRemoved && destinationCreated) await fs.promises.unlink(destination).catch(() => undefined);
2296
- if (sourceRemoved && !(error instanceof PathMoveCompletedWithSyncFailure))
2297
- throw new PathMoveCompletedWithSyncFailure(error);
2298
- throw error;
2299
- }
2300
- }
2301
-
2302
- const destinationParent = await fs.promises.lstat(path.dirname(destination));
2303
- if (sourceStat.dev !== destinationParent.dev) throw new CrossDeviceMoveUnsupportedError(source, destination);
2304
-
2305
- try {
2306
- await fs.promises.cp(source, destination, { recursive: true, force: false, errorOnExist: true });
2307
- await syncCopiedTree(destination);
2308
- await removeCrossDeviceSourceIfUnchanged(source, sourceIdentity);
2309
- try {
2310
- await syncSessionMoveDirectory(path.dirname(source));
2311
- } catch (error) {
2312
- throw new PathMoveCompletedWithSyncFailure(error);
2313
- }
2314
- } catch (error) {
2315
- if (!(error instanceof PathMoveCompletedWithSyncFailure))
2316
- await fs.promises.rm(destination, { recursive: true, force: true }).catch(() => undefined);
2317
- throw error;
2318
- }
2609
+ if (outcome.reason === "atomic_unavailable")
2610
+ throw new CrossDeviceMoveUnsupportedError(source, destination, new Error(formatNativePublishDiagnostic(outcome)));
2611
+ const message = `Atomic session rename failed: ${outcome.code ?? outcome.reason}`;
2612
+ throw new Error(message, { cause: new Error(formatNativePublishDiagnostic(outcome)) });
2319
2613
  }
2320
2614
 
2321
2615
  const MAX_PERSIST_CHARS = 500_000;
@@ -3461,6 +3755,81 @@ class NdjsonFileWriter {
3461
3755
  }
3462
3756
  }
3463
3757
 
3758
+ const PROJECT_SESSION_SCAN_MAX_DIRECTORIES = 4096;
3759
+ const PROJECT_SESSION_SCAN_MAX_FILES = 1000;
3760
+
3761
+ function isProjectSessionTranscriptPath(projectSkcDir: string, filePath: string): boolean {
3762
+ const relative = path.relative(projectSkcDir, filePath);
3763
+ if (relative.startsWith("..") || path.isAbsolute(relative)) return false;
3764
+ const segments = relative.split(path.sep);
3765
+ if (segments.length === 1) return true;
3766
+ const parent = segments.at(-2);
3767
+ return parent === "agent-session" || segments.includes("sessions");
3768
+ }
3769
+
3770
+ /**
3771
+ * Discover resumable transcripts intentionally stored inside a project's `.skc`.
3772
+ * Runtime token/audit JSONL files are excluded by requiring a known transcript
3773
+ * container (`agent-session` or `sessions`).
3774
+ */
3775
+ function listProjectSessionTranscriptFiles(cwd: string): string[] {
3776
+ const projectSkcDir = path.join(path.resolve(cwd), ".skc");
3777
+ let rootStat: fs.Stats;
3778
+ try {
3779
+ rootStat = fs.lstatSync(projectSkcDir);
3780
+ } catch {
3781
+ return [];
3782
+ }
3783
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) return [];
3784
+
3785
+ const directories = [projectSkcDir];
3786
+ const files: string[] = [];
3787
+ let scannedDirectories = 0;
3788
+ while (directories.length > 0 && scannedDirectories < PROJECT_SESSION_SCAN_MAX_DIRECTORIES) {
3789
+ const directory = directories.pop()!;
3790
+ scannedDirectories++;
3791
+ let entries: fs.Dirent[];
3792
+ try {
3793
+ entries = fs.readdirSync(directory, { withFileTypes: true });
3794
+ } catch {
3795
+ continue;
3796
+ }
3797
+ for (const entry of entries) {
3798
+ if (entry.isSymbolicLink()) continue;
3799
+ const entryPath = path.join(directory, entry.name);
3800
+ if (entry.isDirectory()) {
3801
+ directories.push(entryPath);
3802
+ continue;
3803
+ }
3804
+ if (
3805
+ entry.isFile() &&
3806
+ !entry.name.startsWith(".") &&
3807
+ entry.name.endsWith(".jsonl") &&
3808
+ isProjectSessionTranscriptPath(projectSkcDir, entryPath)
3809
+ ) {
3810
+ files.push(entryPath);
3811
+ if (files.length >= PROJECT_SESSION_SCAN_MAX_FILES) return files;
3812
+ }
3813
+ }
3814
+ }
3815
+ return files;
3816
+ }
3817
+
3818
+ async function collectProjectSessions(cwd: string, storage: FileSessionStorage): Promise<SessionInfo[]> {
3819
+ return await collectSessionsFromFiles(listProjectSessionTranscriptFiles(cwd), storage);
3820
+ }
3821
+
3822
+ function mergeSessionInventories(...inventories: SessionInfo[][]): SessionInfo[] {
3823
+ const sessions = new Map<string, SessionInfo>();
3824
+ for (const inventory of inventories) {
3825
+ for (const session of inventory) {
3826
+ const current = sessions.get(session.id);
3827
+ if (!current || session.modified.getTime() > current.modified.getTime()) sessions.set(session.id, session);
3828
+ }
3829
+ }
3830
+ return [...sessions.values()].sort((left, right) => right.modified.getTime() - left.modified.getTime());
3831
+ }
3832
+
3464
3833
  const DEFAULT_WELCOME_RECENT_SESSION_LIMIT = 20;
3465
3834
 
3466
3835
  /** Get recent sessions for display in welcome screen */
@@ -3469,8 +3838,22 @@ export async function getRecentSessions(
3469
3838
  limit = DEFAULT_WELCOME_RECENT_SESSION_LIMIT,
3470
3839
  storage: SessionStorage = new FileSessionStorage(),
3471
3840
  ): Promise<RecentSessionInfo[]> {
3472
- const sessions = await getSortedSessions(sessionDir, storage);
3473
- return sessions.slice(0, limit);
3841
+ return getSortedSessions(sessionDir, storage, limit);
3842
+ }
3843
+
3844
+ export function getRecentSessionDisplay(
3845
+ sessions: readonly SessionInfo[],
3846
+ limit = DEFAULT_WELCOME_RECENT_SESSION_LIMIT,
3847
+ ): Array<{ name: string; timeAgo: string }> {
3848
+ return sessions.slice(0, limit).map(session => {
3849
+ const recent = new RecentSessionInfo(
3850
+ session.path,
3851
+ session.modified.getTime(),
3852
+ { title: session.title, timestamp: session.created.toISOString() },
3853
+ session.firstMessage,
3854
+ );
3855
+ return { name: recent.name, timeAgo: recent.timeAgo };
3856
+ });
3474
3857
  }
3475
3858
 
3476
3859
  /**
@@ -3501,6 +3884,64 @@ function getTaskToolUsage(details: unknown): Usage | undefined {
3501
3884
  return usage as Usage;
3502
3885
  }
3503
3886
 
3887
+ interface ValidatedUsageTotals {
3888
+ input: number;
3889
+ output: number;
3890
+ cacheRead: number;
3891
+ cacheWrite: number;
3892
+ premiumRequests: number;
3893
+ cost: number;
3894
+ }
3895
+
3896
+ /** Max malformed-usage entry ids sampled for a single resume-time report. */
3897
+ const MALFORMED_USAGE_SAMPLE_LIMIT = 8;
3898
+
3899
+ function isFiniteNonNegativeNumber(value: unknown): value is number {
3900
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
3901
+ }
3902
+
3903
+ /**
3904
+ * Validate an untrusted persisted `usage` record before it is aggregated into
3905
+ * session usage statistics. `parseSessionEntries` accepts any parseable JSON, so a
3906
+ * torn/corrupt record can carry a `usage` that is still valid JSON yet malformed:
3907
+ * absent (`{}` yields NaN), numeric strings (`"10"` coerces sums into strings), or
3908
+ * negative buckets that silently reduce totals. Returns the validated finite
3909
+ * non-negative totals, or null when any bucket or `cost.total` is malformed — the
3910
+ * caller then skips and reports that record rather than poisoning every
3911
+ * getUsageStatistics() consumer. Absent `premiumRequests`/`cost` default to 0
3912
+ * (backward-compatible with older transcripts); a present-but-invalid field is
3913
+ * rejected.
3914
+ */
3915
+ function validatePersistedUsageTotals(usage: unknown): ValidatedUsageTotals | null {
3916
+ if (typeof usage !== "object" || usage === null) return null;
3917
+ const record = usage as Record<string, unknown>;
3918
+ const input = record.input;
3919
+ const output = record.output;
3920
+ const cacheRead = record.cacheRead;
3921
+ const cacheWrite = record.cacheWrite;
3922
+ if (
3923
+ !isFiniteNonNegativeNumber(input) ||
3924
+ !isFiniteNonNegativeNumber(output) ||
3925
+ !isFiniteNonNegativeNumber(cacheRead) ||
3926
+ !isFiniteNonNegativeNumber(cacheWrite)
3927
+ )
3928
+ return null;
3929
+ // Default only on truly absent (=== undefined) properties; a present-but-null/invalid
3930
+ // premiumRequests is malformed, not zero.
3931
+ const premiumRequests = record.premiumRequests === undefined ? 0 : record.premiumRequests;
3932
+ if (!isFiniteNonNegativeNumber(premiumRequests)) return null;
3933
+ const rawCost = record.cost;
3934
+ let cost: unknown = 0;
3935
+ if (rawCost !== undefined) {
3936
+ // A present `cost` must be a non-array record carrying a finite non-negative `total`;
3937
+ // null, arrays, and an absent/invalid `total` are malformed — never silently zero.
3938
+ if (typeof rawCost !== "object" || rawCost === null || Array.isArray(rawCost)) return null;
3939
+ cost = (rawCost as Record<string, unknown>).total;
3940
+ }
3941
+ if (!isFiniteNonNegativeNumber(cost)) return null;
3942
+ return { input, output, cacheRead, cacheWrite, premiumRequests, cost };
3943
+ }
3944
+
3504
3945
  function extractTextFromContent(content: Message["content"]): string {
3505
3946
  if (typeof content === "string") return content;
3506
3947
  return content
@@ -3867,9 +4308,11 @@ export async function resolveResumableSession(
3867
4308
  cwd: string,
3868
4309
  sessionDir?: string,
3869
4310
  storage: SessionStorage = new FileSessionStorage(),
4311
+ managedAgentDir?: string,
3870
4312
  ): Promise<ResolvedSessionMatch | undefined> {
3871
- const localSessionDir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
3872
- const localSessions = await SessionManager.list(cwd, localSessionDir, storage);
4313
+ const localSessions = sessionDir
4314
+ ? await SessionManager.list(cwd, sessionDir, storage)
4315
+ : await SessionManager.listManagedForResumePickerReadOnly(cwd, managedAgentDir, storage);
3873
4316
  const localMatch = localSessions.find(session => sessionMatchesResumeArg(session, sessionArg));
3874
4317
  if (localMatch) {
3875
4318
  return { session: localMatch, scope: "local" };
@@ -3879,7 +4322,7 @@ export async function resolveResumableSession(
3879
4322
  return undefined;
3880
4323
  }
3881
4324
 
3882
- const globalSessions = await SessionManager.listAll(storage);
4325
+ const globalSessions = await SessionManager.listAll(storage, managedAgentDir);
3883
4326
  const globalMatch = globalSessions.find(session => sessionMatchesResumeArg(session, sessionArg));
3884
4327
  if (!globalMatch) {
3885
4328
  return undefined;
@@ -3903,6 +4346,7 @@ export class SessionManager {
3903
4346
  #sessionId: string = "";
3904
4347
  /** True once a lifecycle pre-allocated id has been adopted (consume-once). */
3905
4348
  #lifecycleIdAdopted: boolean = false;
4349
+ #preparedNewSessions = new Set<PreparedNewSessionState>();
3906
4350
  #sessionName: string | undefined;
3907
4351
  #titleSource: "auto" | "user" | undefined;
3908
4352
  #sessionFile: string | undefined;
@@ -3910,7 +4354,10 @@ export class SessionManager {
3910
4354
  #needsFullRewriteOnNextPersist: boolean = false;
3911
4355
  #ensuredOnDisk: boolean = false;
3912
4356
  #recoveryHydrationContext: RecoveryHydrationContext | undefined;
4357
+ #recoveryPromotionTranscriptPath: string | undefined;
4358
+ #memoryGuardParticipantIngressToken: symbol | undefined;
3913
4359
  #fileEntries: FileEntry[] = [];
4360
+ #pendingStrictAdoption: { canonicalPath: string; identity: ResumeSessionIdentity } | undefined;
3914
4361
  #byId: Map<string, SessionEntry> = new Map();
3915
4362
  #labelsById: Map<string, string> = new Map();
3916
4363
  #leafId: string | null = null;
@@ -3927,6 +4374,8 @@ export class SessionManager {
3927
4374
  #persistChain: Promise<void> = Promise.resolve();
3928
4375
  #persistError: Error | undefined;
3929
4376
  #persistErrorReported = false;
4377
+ /** Failed staged persistence retains its exact writer and temporary pathname for retryable cleanup. */
4378
+ #preparedNewSessionCleanupInProgress = false;
3930
4379
 
3931
4380
  #artifactManager: ArtifactManager | null = null;
3932
4381
  #artifactManagerSessionFile: string | null = null;
@@ -3941,7 +4390,8 @@ export class SessionManager {
3941
4390
  #inMemoryArtifactCounter = 0;
3942
4391
  readonly #blobStore: BlobStore;
3943
4392
  #residentTextBlobStore: BlobStore = new MemoryBlobStore();
3944
- readonly #residentImageBlobStore: BlobStore;
4393
+ #residentImageBlobStore: BlobStore;
4394
+ #memoryGuardCheckpointBlobs: Map<string, Buffer> | undefined;
3945
4395
  #entryRevision = 0;
3946
4396
  #leafRevision = 0;
3947
4397
  /** Export/header cache invalidation contract; consumers may arrive after the revision field. */
@@ -4014,6 +4464,9 @@ export class SessionManager {
4014
4464
  },
4015
4465
  };
4016
4466
  }
4467
+ #coldSpillReadStore(): BlobStore {
4468
+ return this.#memoryGuardCheckpointBlobs ? this.#residentImageBlobStore : this.#blobStore;
4469
+ }
4017
4470
 
4018
4471
  #residentCacheDir(sessionFile: string): string {
4019
4472
  const instance = ++residentCacheInstanceCounter;
@@ -4031,6 +4484,21 @@ export class SessionManager {
4031
4484
  this.#buildIndex();
4032
4485
  }
4033
4486
 
4487
+ #preparePreparedNewSessionForCommit(stage: PreparedNewSessionState): void {
4488
+ const residentTextBlobStore = new MemoryBlobStore();
4489
+ const residentFileEntries = stage.fileEntries.map(entry =>
4490
+ prepareEntryForResidentSync(entry, {
4491
+ textStore: residentTextBlobStore,
4492
+ imageStore: this.#residentImageBlobStore,
4493
+ sessionId: stage.sessionId,
4494
+ sessionFile: stage.sessionFile,
4495
+ }),
4496
+ );
4497
+ stage.residentTextBlobStore = residentTextBlobStore;
4498
+ stage.residentFileEntries = residentFileEntries;
4499
+ stage.index = this.#buildIndexForEntries(residentFileEntries, stage.sessionFile);
4500
+ }
4501
+
4034
4502
  #resetMaterializedCaches(): void {
4035
4503
  this.#materializedEntriesRevision = -1;
4036
4504
  this.#materializedEntriesCache = undefined;
@@ -4055,13 +4523,7 @@ export class SessionManager {
4055
4523
  * revision contract). Tests assert the invalidation mapping through this;
4056
4524
  * future export/label-view caches key off their respective domains.
4057
4525
  */
4058
- revisionSnapshot(): {
4059
- entry: number;
4060
- leaf: number;
4061
- headerExport: number;
4062
- label: number;
4063
- replayMetadata: number;
4064
- } {
4526
+ revisionSnapshot(): SessionManagerRevisionSnapshot {
4065
4527
  return {
4066
4528
  entry: this.#entryRevision,
4067
4529
  leaf: this.#leafRevision,
@@ -4157,15 +4619,16 @@ export class SessionManager {
4157
4619
  }
4158
4620
 
4159
4621
  async #hydrateExistingSession(sessionFile: string, entries: FileEntry[], migrationApplied: boolean): Promise<void> {
4160
- const ownedEntries = structuredClone(entries) as FileEntry[];
4161
- const header = ownedEntries[0] as SessionHeader;
4622
+ // Strict inspection creates these entries solely for this hydration path. Adopt that
4623
+ // final, validated representation instead of cloning the complete transcript again.
4624
+ const header = entries[0] as SessionHeader;
4162
4625
  this.#sessionFile = this.storage instanceof FileSessionStorage ? path.resolve(sessionFile) : sessionFile;
4163
4626
  this.#sessionId = header.id;
4164
4627
  this.#sessionName = header.title;
4165
4628
  this.#titleSource = header.titleSource;
4166
4629
  this.#needsFullRewriteOnNextPersist = migrationApplied;
4167
- await resolveBlobRefsInEntries(ownedEntries, this.#blobStore);
4168
- this.#fileEntries = ownedEntries;
4630
+ await resolveBlobRefsInEntries(entries, this.#blobStore);
4631
+ this.#fileEntries = entries;
4169
4632
  this.#resetResidentTextBlobStore();
4170
4633
  this.#fileEntries = this.#fileEntries.map(entry =>
4171
4634
  prepareEntryForResidentSync(entry, this.#residentBlobStores()),
@@ -4184,12 +4647,26 @@ export class SessionManager {
4184
4647
 
4185
4648
  /** Switch to a different session file (used for resume and branching) */
4186
4649
  async setSessionFile(sessionFile: string): Promise<void> {
4650
+ this.#assertRecoveryHydrationWritable();
4651
+ const resolvedSessionFile = this.storage instanceof FileSessionStorage ? path.resolve(sessionFile) : sessionFile;
4652
+ const strictAdoption = this.#pendingStrictAdoption;
4653
+ this.#pendingStrictAdoption = undefined;
4654
+ if (strictAdoption && resolvedSessionFile === strictAdoption.canonicalPath) {
4655
+ const inspected = inspectResumeSessionFile(resolvedSessionFile, this.storage);
4656
+ if ("kind" in inspected || !sameResumeIdentity(strictAdoption.identity, inspected.identity))
4657
+ throw new Error("Prepared managed session changed before strict adoption.");
4658
+ }
4187
4659
  await this.#closePersistWriter();
4188
4660
  this.#persistError = undefined;
4189
4661
  this.#persistErrorReported = false;
4190
- this.#sessionFile = this.storage instanceof FileSessionStorage ? path.resolve(sessionFile) : sessionFile;
4662
+ this.#sessionFile = resolvedSessionFile;
4191
4663
  writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
4192
4664
  this.#fileEntries = await loadEntriesFromFile(this.#sessionFile, this.storage);
4665
+ if (strictAdoption) {
4666
+ const inspected = inspectResumeSessionFile(this.#sessionFile, this.storage);
4667
+ if ("kind" in inspected || !sameResumeIdentity(strictAdoption.identity, inspected.identity))
4668
+ throw new Error("Prepared managed session changed during strict adoption.");
4669
+ }
4193
4670
  if (this.#fileEntries.length > 0) {
4194
4671
  const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
4195
4672
  this.#sessionId = header?.id ?? createSessionId();
@@ -4224,10 +4701,496 @@ export class SessionManager {
4224
4701
 
4225
4702
  /** Start a new session. Closes any existing writer first. */
4226
4703
  async newSession(options?: NewSessionOptions): Promise<string | undefined> {
4704
+ this.#assertRecoveryHydrationWritable();
4705
+ const prepared = await this.prepareNewSession(options);
4706
+ try {
4707
+ this.commitPreparedNewSession(prepared);
4708
+ } catch (error) {
4709
+ try {
4710
+ await this.discardPreparedNewSession(prepared);
4711
+ } catch (cleanupError) {
4712
+ throw new AggregateError(
4713
+ [error, cleanupError],
4714
+ "New session adoption and staged session cleanup both failed.",
4715
+ );
4716
+ }
4717
+ throw error;
4718
+ }
4719
+ return prepared.sessionFile;
4720
+ }
4721
+
4722
+ /**
4723
+ * Allocate a fresh successor without publishing it through the manager's public
4724
+ * getters. The returned authority is deliberately immutable so readiness work
4725
+ * can resolve local:// against the successor while the predecessor stays live.
4726
+ * @internal
4727
+ */
4728
+ async prepareNewSession(options?: NewSessionOptions): Promise<PreparedNewSession> {
4729
+ this.#assertRecoveryHydrationWritable();
4730
+ await this.#retryPreparedNewSessionCleanups();
4731
+ await this.#closePersistWriter();
4732
+ const preallocated = this.#lifecycleIdAdopted ? undefined : lifecyclePreallocatedSessionId();
4733
+ const sessionId = preallocated ?? createSessionId();
4734
+ const timestamp = new Date().toISOString();
4735
+ const sessionFile = this.persist
4736
+ ? path.join(this.getSessionDir(), `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`)
4737
+ : undefined;
4738
+ const header: SessionHeader = {
4739
+ type: "session",
4740
+ version: CURRENT_SESSION_VERSION,
4741
+ id: sessionId,
4742
+ timestamp,
4743
+ cwd: this.cwd,
4744
+ parentSession: options?.parentSession,
4745
+ };
4746
+ const stage: PreparedNewSessionState = {
4747
+ sessionId,
4748
+ sessionFile,
4749
+ artifactsDir: sessionFile ? sessionFile.slice(0, -6) : null,
4750
+ managedLegacyLocalMigrationSource: this.#managedLegacyLocalMigrationSourceFor(sessionFile),
4751
+ header,
4752
+ fileEntries: [header],
4753
+ sessionName: undefined,
4754
+ titleSource: undefined,
4755
+ flushed: false,
4756
+ committed: false,
4757
+ discarded: false,
4758
+ };
4759
+ this.#preparedNewSessions.add(stage);
4760
+ this.#freezePreparedNewSessionIdentity(stage);
4761
+ return stage;
4762
+ }
4763
+
4764
+ /** Append a model selection to an unpublished successor. @internal */
4765
+ appendPreparedModelChange(prepared: PreparedNewSession, model: string): string {
4766
+ const stage = this.#getPreparedNewSessionStage(prepared);
4767
+ const entry: ModelChangeEntry = {
4768
+ type: "model_change",
4769
+ id: this.#nextPreparedNewSessionEntryId(stage),
4770
+ parentId: this.#preparedNewSessionLeafId(stage),
4771
+ timestamp: new Date().toISOString(),
4772
+ model,
4773
+ };
4774
+ stage.fileEntries.push(entry);
4775
+ return entry.id;
4776
+ }
4777
+
4778
+ /** Append a thinking-level selection to an unpublished successor. @internal */
4779
+ appendPreparedThinkingLevelChange(prepared: PreparedNewSession, thinkingLevel?: string): string {
4780
+ const stage = this.#getPreparedNewSessionStage(prepared);
4781
+ const entry: ThinkingLevelChangeEntry = {
4782
+ type: "thinking_level_change",
4783
+ id: this.#nextPreparedNewSessionEntryId(stage),
4784
+ parentId: this.#preparedNewSessionLeafId(stage),
4785
+ timestamp: new Date().toISOString(),
4786
+ thinkingLevel: thinkingLevel ?? null,
4787
+ };
4788
+ stage.fileEntries.push(entry);
4789
+ return entry.id;
4790
+ }
4791
+
4792
+ /** Append a service-tier selection to an unpublished successor. @internal */
4793
+ appendPreparedServiceTierChange(prepared: PreparedNewSession, serviceTier: ServiceTier | null): string {
4794
+ const stage = this.#getPreparedNewSessionStage(prepared);
4795
+ const entry: ServiceTierChangeEntry = {
4796
+ type: "service_tier_change",
4797
+ id: this.#nextPreparedNewSessionEntryId(stage),
4798
+ parentId: this.#preparedNewSessionLeafId(stage),
4799
+ timestamp: new Date().toISOString(),
4800
+ serviceTier,
4801
+ };
4802
+ stage.fileEntries.push(entry);
4803
+ return entry.id;
4804
+ }
4805
+
4806
+ /** Append a displayable custom message to an unpublished successor. @internal */
4807
+ appendPreparedCustomMessageEntry<T = unknown>(
4808
+ prepared: PreparedNewSession,
4809
+ customType: string,
4810
+ content: string | (TextContent | ImageContent)[],
4811
+ display: boolean,
4812
+ details?: T,
4813
+ attribution: MessageAttribution = "agent",
4814
+ ): string {
4815
+ const stage = this.#getPreparedNewSessionStage(prepared);
4816
+ const entry: CustomMessageEntry<T> = {
4817
+ type: "custom_message",
4818
+ customType,
4819
+ content,
4820
+ display,
4821
+ details: stripInternalDetailsFields(details),
4822
+ attribution,
4823
+ id: this.#nextPreparedNewSessionEntryId(stage),
4824
+ parentId: this.#preparedNewSessionLeafId(stage),
4825
+ timestamp: new Date().toISOString(),
4826
+ };
4827
+ stage.fileEntries.push(entry);
4828
+ return entry.id;
4829
+ }
4830
+
4831
+ /** Persist an unpublished successor without adopting it. @internal */
4832
+ async ensurePreparedNewSessionOnDisk(prepared: PreparedNewSession): Promise<void> {
4833
+ const stage = this.#getPreparedNewSessionStage(prepared);
4834
+ if (!this.persist || !stage.sessionFile || stage.flushed) return;
4835
+ const entries = await Promise.all(
4836
+ stage.fileEntries.map(entry => prepareEntryForPersistence(entry, this.#blobStore)),
4837
+ );
4838
+ if (this.destination.kind === "managed") {
4839
+ const bytes = Buffer.from(`${entries.map(entry => JSON.stringify(entry)).join("\n")}\n`, "utf8");
4840
+ await this.#managedTranscriptStore(stage.sessionFile).replace(path.basename(stage.sessionFile), bytes);
4841
+ } else {
4842
+ const staleCleanupError = await this.#cleanupPreparedNewSessionPersistence(stage);
4843
+ if (staleCleanupError) throw staleCleanupError;
4844
+ const dir = path.resolve(stage.sessionFile, "..");
4845
+ const tempPath = path.join(dir, `.${path.basename(stage.sessionFile)}.${Snowflake.next()}.tmp`);
4846
+ const writer = new NdjsonFileWriter(this.storage, tempPath, { flags: "w" });
4847
+ stage.persistenceTempPath = tempPath;
4848
+ stage.persistenceWriter = writer;
4849
+ try {
4850
+ for (const entry of entries) await writer.write(entry);
4851
+ await writer.flush();
4852
+ await writer.fsync();
4853
+ await writer.close();
4854
+ stage.persistenceWriter = undefined;
4855
+ await this.#replaceSessionFile(tempPath, stage.sessionFile);
4856
+ stage.persistenceTempPath = undefined;
4857
+ } catch (error) {
4858
+ const cleanupError = await this.#cleanupPreparedNewSessionPersistence(stage);
4859
+ if (cleanupError)
4860
+ throw new AggregateError(
4861
+ [toError(error), cleanupError],
4862
+ "Prepared session persistence and temporary-file cleanup both failed.",
4863
+ );
4864
+ throw toError(error);
4865
+ }
4866
+ }
4867
+ stage.flushed = true;
4868
+ }
4869
+
4870
+ /** Build context from an unpublished successor without reading active manager state. @internal */
4871
+ buildPreparedNewSessionContext(prepared: PreparedNewSession): SessionContext {
4872
+ const stage = this.#getPreparedNewSessionStage(prepared);
4873
+ return buildSessionContext(
4874
+ stage.fileEntries.filter((entry): entry is SessionEntry => entry.type !== "session"),
4875
+ undefined,
4876
+ undefined,
4877
+ stage.sessionId,
4878
+ );
4879
+ }
4880
+
4881
+ #freezePreparedNewSessionIdentity(stage: PreparedNewSessionState): void {
4882
+ // Prevent identity ABA: callers must not rebind staged sessionId/file after prepare.
4883
+ Object.defineProperty(stage, "sessionId", {
4884
+ value: stage.sessionId,
4885
+ writable: false,
4886
+ enumerable: true,
4887
+ configurable: false,
4888
+ });
4889
+ Object.defineProperty(stage, "sessionFile", {
4890
+ value: stage.sessionFile,
4891
+ writable: false,
4892
+ enumerable: true,
4893
+ configurable: false,
4894
+ });
4895
+ Object.defineProperty(stage, "artifactsDir", {
4896
+ value: stage.artifactsDir,
4897
+ writable: false,
4898
+ enumerable: true,
4899
+ configurable: false,
4900
+ });
4901
+ Object.defineProperty(stage, "managedLegacyLocalMigrationSource", {
4902
+ value: stage.managedLegacyLocalMigrationSource,
4903
+ writable: false,
4904
+ enumerable: true,
4905
+ configurable: false,
4906
+ });
4907
+ }
4908
+
4909
+ #getPreparedNewSessionStage(prepared: PreparedNewSession): PreparedNewSessionState {
4910
+ const stage = prepared as PreparedNewSessionState;
4911
+ if (!this.#preparedNewSessions.has(stage) || stage.committed || stage.discarded)
4912
+ throw new Error("Prepared session is no longer available.");
4913
+ return stage;
4914
+ }
4915
+
4916
+ #preparedNewSessionLeafId(stage: PreparedNewSessionState): string | null {
4917
+ for (let index = stage.fileEntries.length - 1; index >= 0; index--) {
4918
+ const entry = stage.fileEntries[index];
4919
+ if (entry.type !== "session") return entry.id;
4920
+ }
4921
+ return null;
4922
+ }
4923
+
4924
+ #nextPreparedNewSessionEntryId(stage: PreparedNewSessionState): string {
4925
+ const ids = new Map<string, SessionEntry>();
4926
+ for (const entry of stage.fileEntries) {
4927
+ if (entry.type !== "session") ids.set(entry.id, entry);
4928
+ }
4929
+ return generateId(ids);
4930
+ }
4931
+
4932
+ /** Prepare a forked successor without publishing it through public manager state. @internal */
4933
+ async prepareFork(): Promise<PreparedNewSession | undefined> {
4934
+ await this.#retryPreparedNewSessionCleanups();
4935
+ if (!this.persist || !this.#sessionFile) return undefined;
4936
+ await this.#closePersistWriter();
4937
+ const sourceFile = this.#sessionFile;
4938
+ const sourceId = this.#sessionId;
4939
+ const timestamp = new Date().toISOString();
4940
+ const sessionId = createSessionId();
4941
+ const sessionFile = path.join(this.getSessionDir(), `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`);
4942
+ const header: SessionHeader = {
4943
+ type: "session",
4944
+ version: CURRENT_SESSION_VERSION,
4945
+ id: sessionId,
4946
+ title: this.#sessionName,
4947
+ titleSource: this.#titleSource,
4948
+ timestamp,
4949
+ cwd: this.cwd,
4950
+ parentSession: sourceId,
4951
+ };
4952
+ const entries: FileEntry[] = [
4953
+ header,
4954
+ ...materializeResidentEntriesForReadSync(this.#fileEntries, this.#residentBlobStores()).filter(
4955
+ (entry): entry is SessionEntry => entry.type !== "session",
4956
+ ),
4957
+ ];
4958
+ const stage: PreparedNewSessionState = {
4959
+ sessionId,
4960
+ sessionFile,
4961
+ artifactsDir: sessionFile.slice(0, -6),
4962
+ managedLegacyLocalMigrationSource: this.#managedLegacyLocalMigrationSourceFor(sessionFile),
4963
+ header,
4964
+ fileEntries: entries,
4965
+ sessionName: this.#sessionName,
4966
+ titleSource: this.#titleSource,
4967
+ flushed: true,
4968
+ committed: false,
4969
+ discarded: false,
4970
+ };
4971
+ this.#preparedNewSessions.add(stage);
4972
+ this.#freezePreparedNewSessionIdentity(stage);
4973
+ try {
4974
+ await this.copyArtifactsForFork(sourceFile, sessionFile);
4975
+ const content = `${entries.map(entry => JSON.stringify(prepareEntryForPersistenceSync(entry, this.#blobStore))).join("\n")}\n`;
4976
+ if (this.destination.kind === "managed") {
4977
+ this.#managedTranscriptStore(sessionFile).publishNoReplaceSync(
4978
+ path.basename(sessionFile),
4979
+ Buffer.from(content, "utf8"),
4980
+ );
4981
+ } else {
4982
+ this.storage.writeTextSync(sessionFile, content);
4983
+ }
4984
+ } catch (error) {
4985
+ try {
4986
+ await this.discardPreparedNewSession(stage);
4987
+ } catch (cleanupError) {
4988
+ throw new AggregateError(
4989
+ [toError(error), toError(cleanupError)],
4990
+ "Fork preparation and staged session cleanup both failed.",
4991
+ );
4992
+ }
4993
+ throw toError(error);
4994
+ }
4995
+ return stage;
4996
+ }
4997
+
4998
+ /** Prepare a path-only branch successor without publishing it through public manager state. @internal */
4999
+ async prepareBranchedSession(leafId: string): Promise<PreparedNewSession> {
5000
+ this.#assertRecoveryHydrationWritable();
5001
+ await this.#retryPreparedNewSessionCleanups();
5002
+ const branchPath = this.#getCanonicalBranchClones(leafId);
5003
+ if (branchPath.length === 0) throw new Error(`Entry ${leafId} not found`);
4227
5004
  await this.#closePersistWriter();
4228
- const sessionFile = this.#newSessionSync(options);
5005
+ const timestamp = new Date().toISOString();
5006
+ const sessionId = createSessionId();
5007
+ const sessionFile = this.persist
5008
+ ? path.join(this.getSessionDir(), `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`)
5009
+ : undefined;
5010
+ const header: SessionHeader = {
5011
+ type: "session",
5012
+ version: CURRENT_SESSION_VERSION,
5013
+ id: sessionId,
5014
+ timestamp,
5015
+ cwd: this.cwd,
5016
+ parentSession: this.persist ? this.#sessionFile : undefined,
5017
+ };
5018
+ const pathWithoutLabels = branchPath.filter(entry => entry.type !== "label");
5019
+ const pathEntryIds = new Set(pathWithoutLabels.map(entry => entry.id));
5020
+ const labelEntries: LabelEntry[] = [];
5021
+ let labelParentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id ?? null;
5022
+ for (const [targetId, label] of this.#labelsById) {
5023
+ if (!pathEntryIds.has(targetId)) continue;
5024
+ const labelEntry: LabelEntry = {
5025
+ type: "label",
5026
+ id: generateId({ has: id => pathEntryIds.has(id) }),
5027
+ parentId: labelParentId,
5028
+ timestamp: new Date().toISOString(),
5029
+ targetId,
5030
+ label,
5031
+ };
5032
+ pathEntryIds.add(labelEntry.id);
5033
+ labelEntries.push(labelEntry);
5034
+ labelParentId = labelEntry.id;
5035
+ }
5036
+ const entries: FileEntry[] = [
5037
+ header,
5038
+ ...materializeResidentEntriesForReadSync(pathWithoutLabels, this.#residentBlobStores()),
5039
+ ...labelEntries,
5040
+ ];
5041
+ const stage: PreparedNewSessionState = {
5042
+ sessionId,
5043
+ sessionFile,
5044
+ artifactsDir: sessionFile ? sessionFile.slice(0, -6) : null,
5045
+ managedLegacyLocalMigrationSource: this.#managedLegacyLocalMigrationSourceFor(sessionFile),
5046
+ header,
5047
+ fileEntries: entries,
5048
+ sessionName: undefined,
5049
+ titleSource: undefined,
5050
+ flushed: this.persist,
5051
+ committed: false,
5052
+ discarded: false,
5053
+ };
5054
+ this.#preparedNewSessions.add(stage);
5055
+ this.#freezePreparedNewSessionIdentity(stage);
5056
+ try {
5057
+ if (sessionFile) {
5058
+ const content = `${entries.map(entry => JSON.stringify(prepareEntryForPersistenceSync(entry, this.#blobStore))).join("\n")}\n`;
5059
+ if (this.destination.kind === "managed") {
5060
+ this.#managedTranscriptStore(sessionFile).publishNoReplaceSync(
5061
+ path.basename(sessionFile),
5062
+ Buffer.from(content, "utf8"),
5063
+ );
5064
+ } else {
5065
+ this.storage.writeTextSync(sessionFile, content);
5066
+ }
5067
+ }
5068
+ return stage;
5069
+ } catch (error) {
5070
+ try {
5071
+ await this.discardPreparedNewSession(stage);
5072
+ } catch (cleanupError) {
5073
+ throw new AggregateError(
5074
+ [error, cleanupError],
5075
+ "Branch preparation and staged session cleanup both failed.",
5076
+ );
5077
+ }
5078
+ throw error;
5079
+ }
5080
+ }
5081
+
5082
+ /** Publish a prepared successor synchronously after all readiness awaits succeed. @internal */
5083
+ commitPreparedNewSession(prepared: PreparedNewSession): void {
5084
+ this.#assertRecoveryHydrationWritable();
5085
+ const stage = prepared as PreparedNewSessionState;
5086
+ if (!this.#preparedNewSessions.has(stage) || stage.committed || stage.discarded) {
5087
+ throw new Error("Prepared session is no longer available.");
5088
+ }
5089
+ this.#preparePreparedNewSessionForCommit(stage);
5090
+ const residentFileEntries = stage.residentFileEntries;
5091
+ const residentTextBlobStore = stage.residentTextBlobStore;
5092
+ const index = stage.index;
5093
+ if (!residentFileEntries || !residentTextBlobStore || !index)
5094
+ throw new Error("Prepared session adoption is incomplete.");
5095
+ if (stage.sessionFile) writeTerminalBreadcrumb(this.cwd, stage.sessionFile);
5096
+
5097
+ stage.committed = true;
5098
+ this.#preparedNewSessions.delete(stage);
5099
+ if (!this.#lifecycleIdAdopted && stage.sessionId === lifecyclePreallocatedSessionId())
5100
+ this.#lifecycleIdAdopted = true;
5101
+ this.#persistChain = Promise.resolve();
5102
+ this.#persistError = undefined;
5103
+ this.#persistErrorReported = false;
5104
+ this.#sessionId = stage.sessionId;
5105
+ this.#sessionName = stage.sessionName;
5106
+ this.#titleSource = stage.titleSource;
5107
+ this.#sessionFile = stage.sessionFile;
5108
+ this.#fileEntries = residentFileEntries;
5109
+ this.#residentTextBlobStore = residentTextBlobStore;
5110
+ this.#byId = index.byId;
5111
+ this.#labelsById = index.labelsById;
5112
+ this.#leafId = index.leafId;
5113
+ this.#usageStatistics = index.usageStatistics;
5114
+ this.#flushed = stage.flushed;
5115
+ this.#needsFullRewriteOnNextPersist = false;
5116
+ this.#ensuredOnDisk = stage.flushed;
5117
+ this.#inMemoryArtifacts = null;
5118
+ this.#inMemoryArtifactCounter = 0;
5119
+ this.#artifactManager = null;
5120
+ this.#artifactManagerSessionFile = null;
5121
+ this.#adoptedArtifactManager = null;
4229
5122
  this.#bumpAllRevisions();
4230
- return sessionFile;
5123
+ }
5124
+
5125
+ /** Exact-discard only an uncommitted successor prepared by this manager. @internal */
5126
+ async discardPreparedNewSession(prepared: PreparedNewSession): Promise<void> {
5127
+ const stage = prepared as PreparedNewSessionState;
5128
+ if (!this.#preparedNewSessions.has(stage) || stage.committed || stage.discarded) return;
5129
+ const persistenceCleanupError = await this.#cleanupPreparedNewSessionPersistence(stage);
5130
+ let sessionCleanupError: Error | undefined;
5131
+ if (stage.sessionFile) {
5132
+ try {
5133
+ await this.discardUncommittedSession(stage.sessionFile);
5134
+ } catch (error) {
5135
+ sessionCleanupError = toError(error);
5136
+ }
5137
+ }
5138
+ if (persistenceCleanupError || sessionCleanupError) {
5139
+ const errors = [persistenceCleanupError, sessionCleanupError].filter(
5140
+ (error): error is Error => error !== undefined,
5141
+ );
5142
+ throw errors.length === 1 ? errors[0] : new AggregateError(errors, "Prepared session cleanup failed.");
5143
+ }
5144
+ this.#preparedNewSessions.delete(stage);
5145
+ stage.discarded = true;
5146
+ }
5147
+
5148
+ async #retryPreparedNewSessionCleanups(): Promise<void> {
5149
+ if (this.#preparedNewSessionCleanupInProgress) return;
5150
+ this.#preparedNewSessionCleanupInProgress = true;
5151
+ try {
5152
+ const errors: Error[] = [];
5153
+ for (const stage of [...this.#preparedNewSessions]) {
5154
+ if (stage.committed || stage.discarded) continue;
5155
+ try {
5156
+ await this.discardPreparedNewSession(stage);
5157
+ } catch (error) {
5158
+ errors.push(toError(error));
5159
+ }
5160
+ }
5161
+ if (errors.length > 0)
5162
+ throw errors.length === 1
5163
+ ? errors[0]
5164
+ : new AggregateError(errors, "Prepared session cleanup retry failed.");
5165
+ } finally {
5166
+ this.#preparedNewSessionCleanupInProgress = false;
5167
+ }
5168
+ }
5169
+
5170
+ async #cleanupPreparedNewSessionPersistence(stage: PreparedNewSessionState): Promise<Error | undefined> {
5171
+ const errors: Error[] = [];
5172
+ if (stage.persistenceWriter) {
5173
+ try {
5174
+ await stage.persistenceWriter.close();
5175
+ stage.persistenceWriter = undefined;
5176
+ } catch (error) {
5177
+ errors.push(toError(error));
5178
+ }
5179
+ }
5180
+ if (stage.persistenceTempPath && !stage.persistenceWriter) {
5181
+ try {
5182
+ await this.storage.unlink(stage.persistenceTempPath);
5183
+ stage.persistenceTempPath = undefined;
5184
+ } catch (error) {
5185
+ if (!isEnoent(error)) errors.push(toError(error));
5186
+ else stage.persistenceTempPath = undefined;
5187
+ }
5188
+ }
5189
+ return errors.length === 0
5190
+ ? undefined
5191
+ : errors.length === 1
5192
+ ? errors[0]
5193
+ : new AggregateError(errors, "Prepared session temporary-file cleanup failed.");
4231
5194
  }
4232
5195
 
4233
5196
  /** Tombstone and exact-delete managed transcripts, detaching the active transcript first. */
@@ -4255,7 +5218,8 @@ export class SessionManager {
4255
5218
  const candidate = listing.owned.find(candidate => path.resolve(candidate.path) === requestedPath);
4256
5219
  if (!candidate) throw new Error("Managed session deletion requires exact logical authorization.");
4257
5220
  const deleted = await deleteManagedSessionCandidate(resolved.scope, candidate);
4258
- if (deleted.kind === "error") throw new Error(`Could not delete managed session: ${deleted.message}`);
5221
+ if (deleted.kind !== "deleted" && deleted.kind !== "already_deleted")
5222
+ throw new Error(`Could not delete managed session: ${deleted.message}`);
4259
5223
  } else {
4260
5224
  await this.storage.deleteSessionWithArtifacts(sessionPath);
4261
5225
  }
@@ -4384,27 +5348,20 @@ export class SessionManager {
4384
5348
  throw new Error("managed_fork_transcript_changed");
4385
5349
  }
4386
5350
  } catch (error) {
4387
- let failure = error;
4388
- const failedSessionFile = this.#sessionFile;
4389
- if (this.destination.kind === "managed") {
5351
+ const failure = error;
5352
+ if (forkArtifactPublication) {
4390
5353
  try {
4391
- if (forkTranscriptStore && forkTranscriptPublication)
4392
- forkTranscriptStore.removeExpected(path.basename(failedSessionFile), forkTranscriptPublication);
4393
- if (forkArtifactPublication)
4394
- forkArtifactPublication.cleanupStore.removeTreeExpected(
4395
- forkArtifactPublication.cleanupRelativePath,
4396
- forkArtifactPublication.snapshot,
4397
- );
5354
+ forkArtifactPublication.cleanupStore.removeTreeExpected(
5355
+ forkArtifactPublication.cleanupRelativePath,
5356
+ forkArtifactPublication.snapshot,
5357
+ );
4398
5358
  } catch (cleanupError) {
4399
- failure = new Error(`Failed to clean up managed fork destination: ${toError(cleanupError).message}`, {
4400
- cause: toError(error),
4401
- });
4402
- }
4403
- } else {
4404
- try {
4405
- await this.storage.unlink(failedSessionFile);
4406
- } catch {
4407
- // Preserve ambiguous cleanup evidence while restoring the active manager.
5359
+ const cleanupMessage = toError(cleanupError).message;
5360
+ if (cleanupMessage !== "cleanup_pending") {
5361
+ throw new Error(`Failed to clean up fork artifacts: ${cleanupMessage}`, {
5362
+ cause: toError(failure),
5363
+ });
5364
+ }
4408
5365
  }
4409
5366
  }
4410
5367
  this.#sessionId = oldSessionId;
@@ -4483,8 +5440,7 @@ export class SessionManager {
4483
5440
  }
4484
5441
  if (JSON.stringify(parentStore.captureTree(sourceName)) !== JSON.stringify(sourceSnapshot))
4485
5442
  throw new Error("artifact_source_changed");
4486
- parentStore.moveTreeNoReplace(stagingName, destinationName, stagingSnapshot);
4487
- publishedSnapshot = parentStore.captureTree(destinationName);
5443
+ publishedSnapshot = parentStore.moveTreeNoReplace(stagingName, destinationName, stagingSnapshot);
4488
5444
  const comparable = (tree: native.NativeDirectoryTreeSnapshot) =>
4489
5445
  tree.entries.map(entry => ({
4490
5446
  relativePath: entry.relativePath,
@@ -4508,12 +5464,15 @@ export class SessionManager {
4508
5464
  };
4509
5465
  } catch (error) {
4510
5466
  try {
4511
- if (publishedSnapshot) parentStore.removeTreeExpected(destinationName, publishedSnapshot);
4512
- else if (stagingSnapshot) parentStore.removeTreeExpected(stagingName, stagingSnapshot);
5467
+ if (stagingSnapshot && mayCleanManagedTreeStaging(error))
5468
+ parentStore.removeTreeExpected(stagingName, stagingSnapshot);
4513
5469
  } catch (cleanupError) {
4514
- throw new Error(`Failed to clean up managed fork artifacts: ${toError(cleanupError).message}`, {
4515
- cause: toError(error),
4516
- });
5470
+ const cleanupMessage = toError(cleanupError).message;
5471
+ if (cleanupMessage !== "cleanup_pending") {
5472
+ throw new Error(`Failed to clean up managed fork artifacts: ${cleanupMessage}`, {
5473
+ cause: toError(error),
5474
+ });
5475
+ }
4517
5476
  }
4518
5477
  throw error;
4519
5478
  }
@@ -4531,19 +5490,15 @@ export class SessionManager {
4531
5490
  const previousSessionDir = this.sessionDir;
4532
5491
  const previousSessionFile = this.#sessionFile;
4533
5492
  const previousDestination = this.destination;
4534
- const previousArtifactDir = previousSessionFile?.slice(0, -6);
4535
5493
 
4536
- const nextDestination = (() => {
4537
- if (this.destination.kind === "managed") {
4538
- return managedDestination(resolvedCwd, this.storage, this.destination.securityContext.agentDir);
4539
- }
4540
- if (!(this.storage instanceof FileSessionStorage)) return this.destination;
4541
- const managedSessionsRoot = resolveManagedSessionRoot(this.sessionDir, this.cwd);
4542
- const directory = managedSessionsRoot
4543
- ? computeDefaultSessionDir(resolvedCwd, this.storage, managedSessionsRoot)
4544
- : computeDefaultSessionDir(resolvedCwd, this.storage);
4545
- return explicitDestination(directory);
4546
- })();
5494
+ const nextDestination =
5495
+ this.storage instanceof FileSessionStorage
5496
+ ? managedDestination(
5497
+ resolvedCwd,
5498
+ this.storage,
5499
+ this.destination.kind === "managed" ? this.destination.securityContext.agentDir : undefined,
5500
+ )
5501
+ : this.destination;
4547
5502
  const newSessionDir = nextDestination.directory;
4548
5503
  let hadSessionFile = false;
4549
5504
  const managedMove = this.destination.kind === "managed" && nextDestination.kind === "managed";
@@ -4646,15 +5601,8 @@ export class SessionManager {
4646
5601
  managedPublishedArtifacts,
4647
5602
  );
4648
5603
  }
4649
- if (managedTranscript)
5604
+ if (managedTranscript && !managedSourceStore.readExpected(path.basename(oldSessionFile)))
4650
5605
  await managedSourceStore.publishNoReplace(path.basename(oldSessionFile), managedTranscript.bytes);
4651
- if (managedPublishedArtifacts)
4652
- managedDestinationStore.removeTreeExpected(
4653
- path.basename(newArtifactDir),
4654
- managedPublishedArtifacts,
4655
- );
4656
- if (managedPublishedTranscript)
4657
- managedDestinationStore.removeExpected(path.basename(newSessionFile), managedPublishedTranscript);
4658
5606
  };
4659
5607
  managedTranscript = managedSourceStore.readExpected(path.basename(oldSessionFile));
4660
5608
  hadSessionFile = managedTranscript !== null;
@@ -4691,10 +5639,20 @@ export class SessionManager {
4691
5639
  if (!retainedTreeSnapshotEquals(terminalArtifacts, managedPublishedArtifacts))
4692
5640
  throw new Error("managed_move_destination_artifacts_changed");
4693
5641
  }
4694
- if (managedArtifacts)
4695
- managedSourceStore.removeTreeExpected(path.basename(oldArtifactDir), managedArtifacts);
4696
- if (managedTranscript)
4697
- managedSourceStore.removeExpected(path.basename(oldSessionFile), managedTranscript);
5642
+ if (managedArtifacts) {
5643
+ try {
5644
+ managedSourceStore.removeTreeExpected(path.basename(oldArtifactDir), managedArtifacts);
5645
+ } catch (cleanupError) {
5646
+ if (toError(cleanupError).message !== "cleanup_pending") throw cleanupError;
5647
+ }
5648
+ }
5649
+ if (managedTranscript) {
5650
+ try {
5651
+ managedSourceStore.removeExpected(path.basename(oldSessionFile), managedTranscript);
5652
+ } catch (cleanupError) {
5653
+ if (toError(cleanupError).message !== "cleanup_pending") throw cleanupError;
5654
+ }
5655
+ }
4698
5656
  } else {
4699
5657
  if (hadSessionFile && oldSessionFile !== newSessionFile) {
4700
5658
  await movePathAcrossDevicesSafe(oldSessionFile, newSessionFile);
@@ -4750,13 +5708,6 @@ export class SessionManager {
4750
5708
  !managedSourceStore.readExpected(path.basename(oldSessionFile))
4751
5709
  )
4752
5710
  await managedSourceStore.publishNoReplace(path.basename(oldSessionFile), managedTranscript.bytes);
4753
- if (managedPublishedArtifacts)
4754
- managedDestinationStore.removeTreeExpected(
4755
- path.basename(newArtifactDir),
4756
- managedPublishedArtifacts,
4757
- );
4758
- if (managedPublishedTranscript)
4759
- managedDestinationStore.removeExpected(path.basename(newSessionFile), managedPublishedTranscript);
4760
5711
  } catch (rollbackErr) {
4761
5712
  restoreResidentStateAndThrow(
4762
5713
  new Error(`Failed to rollback managed move: ${toError(rollbackErr).message}`, {
@@ -4777,7 +5728,8 @@ export class SessionManager {
4777
5728
  this.#bumpAllRevisions();
4778
5729
  }
4779
5730
 
4780
- // Update cwd and sessionDir after the physical move succeeds, but roll the move back if metadata persistence fails.
5731
+ // Update cwd and sessionDir after physical publication succeeds. Metadata failures restore the source
5732
+ // authority but deliberately retain any destination publication evidence rather than deleting it.
4781
5733
  this.cwd = resolvedCwd;
4782
5734
  this.sessionDir = newSessionDir;
4783
5735
  this.destination = nextDestination;
@@ -4798,60 +5750,37 @@ export class SessionManager {
4798
5750
  await this.#appendHeaderPatch({ cwd: resolvedCwd });
4799
5751
  }
4800
5752
  } catch (error) {
4801
- const failedSessionFile = this.#sessionFile;
4802
- const failedArtifactDir = failedSessionFile?.slice(0, -6);
4803
- try {
4804
- await this.#closePersistWriter().catch(() => {});
4805
- this.#persistChain = Promise.resolve();
4806
- this.#persistError = undefined;
4807
- this.#persistErrorReported = false;
4808
- if (rollbackManagedMove) {
4809
- await rollbackManagedMove();
4810
- this.#sessionFile = previousSessionFile;
4811
- this.cwd = previousCwd;
4812
- this.sessionDir = previousSessionDir;
4813
- this.destination = previousDestination;
4814
- this.#managedTranscriptStoreCache =
4815
- managedMove && managedSourceStore
4816
- ? { directory: path.resolve(previousSessionDir), store: managedSourceStore }
4817
- : null;
4818
- const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
4819
- if (header) applyHeaderPatch(header, { cwd: previousCwd });
4820
- this.#headerExportRevision++;
4821
- throw error;
4822
- }
4823
- if (
4824
- this.storage instanceof FileSessionStorage &&
4825
- failedArtifactDir &&
4826
- previousArtifactDir &&
4827
- failedArtifactDir !== previousArtifactDir &&
4828
- fs.existsSync(failedArtifactDir)
4829
- ) {
4830
- await movePathAcrossDevicesSafe(failedArtifactDir, previousArtifactDir);
4831
- }
4832
- if (
4833
- this.storage instanceof FileSessionStorage &&
4834
- failedSessionFile &&
4835
- previousSessionFile &&
4836
- failedSessionFile !== previousSessionFile &&
4837
- fs.existsSync(failedSessionFile)
4838
- ) {
4839
- await movePathAcrossDevicesSafe(failedSessionFile, previousSessionFile);
5753
+ await this.#closePersistWriter().catch(() => {});
5754
+ this.#persistChain = Promise.resolve();
5755
+ this.#persistError = undefined;
5756
+ this.#persistErrorReported = false;
5757
+ if (rollbackManagedMove) {
5758
+ try {
5759
+ await rollbackManagedMove();
5760
+ } catch (rollbackError) {
5761
+ if (toError(rollbackError).message !== "cleanup_pending") {
5762
+ throw new Error(`Failed to rollback managed move: ${toError(rollbackError).message}`, {
5763
+ cause: toError(error),
5764
+ });
5765
+ }
4840
5766
  }
4841
5767
  this.#sessionFile = previousSessionFile;
4842
5768
  this.cwd = previousCwd;
4843
5769
  this.sessionDir = previousSessionDir;
4844
5770
  this.destination = previousDestination;
4845
- this.#managedTranscriptStoreCache = null;
5771
+ this.#managedTranscriptStoreCache =
5772
+ managedMove && managedSourceStore
5773
+ ? { directory: path.resolve(previousSessionDir), store: managedSourceStore }
5774
+ : null;
4846
5775
  const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
4847
5776
  if (header) applyHeaderPatch(header, { cwd: previousCwd });
4848
5777
  this.#headerExportRevision++;
4849
- if (previousSessionFile && this.storage.existsSync(previousSessionFile)) await this.#rewriteFile();
4850
- } catch (rollbackError) {
4851
- throw new Error(`Failed to rollback session move: ${toError(rollbackError).message}`, {
4852
- cause: toError(error),
4853
- });
5778
+ throw error;
4854
5779
  }
5780
+ // The destination has already been published. Retain it rather than moving it
5781
+ // back over the source after a metadata failure.
5782
+ this.#managedTranscriptStoreCache = null;
5783
+ this.#headerExportRevision++;
4855
5784
  throw error;
4856
5785
  }
4857
5786
 
@@ -4907,43 +5836,129 @@ export class SessionManager {
4907
5836
  }
4908
5837
 
4909
5838
  #buildIndex(): void {
4910
- this.#byId.clear();
4911
- this.#labelsById.clear();
4912
- this.#leafId = null;
4913
- this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
4914
- for (const entry of this.#fileEntries) {
5839
+ const index = this.#buildIndexForEntries(this.#fileEntries, this.#sessionFile);
5840
+ this.#byId = index.byId;
5841
+ this.#labelsById = index.labelsById;
5842
+ this.#leafId = index.leafId;
5843
+ this.#usageStatistics = index.usageStatistics;
5844
+ }
5845
+
5846
+ #buildIndexForEntries(fileEntries: FileEntry[], sessionFile: string | undefined): PreparedSessionIndex {
5847
+ const byId = new Map<string, SessionEntry>();
5848
+ const labelsById = new Map<string, string>();
5849
+ let leafId: string | null = null;
5850
+ const usageStatistics: UsageStatistics = {
5851
+ input: 0,
5852
+ output: 0,
5853
+ cacheRead: 0,
5854
+ cacheWrite: 0,
5855
+ premiumRequests: 0,
5856
+ cost: 0,
5857
+ };
5858
+ const addUsage = (totals: ValidatedUsageTotals): boolean => {
5859
+ const next = {
5860
+ input: usageStatistics.input + totals.input,
5861
+ output: usageStatistics.output + totals.output,
5862
+ cacheRead: usageStatistics.cacheRead + totals.cacheRead,
5863
+ cacheWrite: usageStatistics.cacheWrite + totals.cacheWrite,
5864
+ premiumRequests: usageStatistics.premiumRequests + totals.premiumRequests,
5865
+ cost: usageStatistics.cost + totals.cost,
5866
+ };
5867
+ if (Object.values(next).some(value => !Number.isFinite(value))) return false;
5868
+ Object.assign(usageStatistics, next);
5869
+ return true;
5870
+ };
5871
+ const accumulateUsage = (entry: SessionEntry): boolean => {
5872
+ if (entry.type !== "message") return false;
5873
+ const usage =
5874
+ entry.message.role === "assistant"
5875
+ ? entry.message.usage
5876
+ : entry.message.role === "toolResult" && entry.message.toolName === "task"
5877
+ ? getTaskToolUsage(entry.message.details)
5878
+ : undefined;
5879
+ if (usage === undefined) return false;
5880
+ const totals = validatePersistedUsageTotals(usage);
5881
+ return !(totals && addUsage(totals));
5882
+ };
5883
+ let malformedUsageRecords = 0;
5884
+ const malformedUsageSample: string[] = [];
5885
+ for (const entry of fileEntries) {
4915
5886
  if (entry.type === "session") continue;
4916
- this.#byId.set(entry.id, entry);
4917
- this.#leafId = entry.id;
5887
+ byId.set(entry.id, entry);
5888
+ leafId = entry.id;
4918
5889
  if (entry.type === "label") {
4919
- if (entry.label) {
4920
- this.#labelsById.set(entry.targetId, entry.label);
4921
- } else {
4922
- this.#labelsById.delete(entry.targetId);
4923
- }
5890
+ if (entry.label) labelsById.set(entry.targetId, entry.label);
5891
+ else labelsById.delete(entry.targetId);
4924
5892
  }
4925
- if (entry.type === "message" && entry.message.role === "assistant") {
4926
- const usage = entry.message.usage;
4927
- this.#usageStatistics.input += usage.input;
4928
- this.#usageStatistics.output += usage.output;
4929
- this.#usageStatistics.cacheRead += usage.cacheRead;
4930
- this.#usageStatistics.cacheWrite += usage.cacheWrite;
4931
- this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
4932
- this.#usageStatistics.cost += usage.cost.total;
5893
+ if (accumulateUsage(entry)) {
5894
+ malformedUsageRecords++;
5895
+ if (malformedUsageSample.length < MALFORMED_USAGE_SAMPLE_LIMIT) malformedUsageSample.push(entry.id);
4933
5896
  }
5897
+ }
5898
+ if (malformedUsageRecords > 0) {
5899
+ logger.warn("Skipped malformed or overflowing persisted usage records during resume aggregation", {
5900
+ sessionFile,
5901
+ count: malformedUsageRecords,
5902
+ sampleEntryIds: malformedUsageSample,
5903
+ });
5904
+ }
5905
+ return { byId, labelsById, leafId, usageStatistics };
5906
+ }
4934
5907
 
4935
- if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "task") {
4936
- const usage = getTaskToolUsage(entry.message.details);
4937
- if (usage) {
4938
- this.#usageStatistics.input += usage.input;
4939
- this.#usageStatistics.output += usage.output;
4940
- this.#usageStatistics.cacheRead += usage.cacheRead;
4941
- this.#usageStatistics.cacheWrite += usage.cacheWrite;
4942
- this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
4943
- this.#usageStatistics.cost += usage.cost.total;
4944
- }
4945
- }
5908
+ /**
5909
+ * Commit a validated record's totals only if every cumulative sum stays finite.
5910
+ * Individually-finite buckets can still overflow to Infinity in aggregate (e.g. two
5911
+ * Number.MAX_VALUE records), which would poison getUsageStatistics(); such a record is
5912
+ * rejected atomically (no partial commit). Returns true when committed, false when
5913
+ * rejected for cumulative overflow.
5914
+ */
5915
+ #addValidatedUsage(totals: ValidatedUsageTotals): boolean {
5916
+ const next = {
5917
+ input: this.#usageStatistics.input + totals.input,
5918
+ output: this.#usageStatistics.output + totals.output,
5919
+ cacheRead: this.#usageStatistics.cacheRead + totals.cacheRead,
5920
+ cacheWrite: this.#usageStatistics.cacheWrite + totals.cacheWrite,
5921
+ premiumRequests: this.#usageStatistics.premiumRequests + totals.premiumRequests,
5922
+ cost: this.#usageStatistics.cost + totals.cost,
5923
+ };
5924
+ if (
5925
+ !Number.isFinite(next.input) ||
5926
+ !Number.isFinite(next.output) ||
5927
+ !Number.isFinite(next.cacheRead) ||
5928
+ !Number.isFinite(next.cacheWrite) ||
5929
+ !Number.isFinite(next.premiumRequests) ||
5930
+ !Number.isFinite(next.cost)
5931
+ )
5932
+ return false;
5933
+ this.#usageStatistics.input = next.input;
5934
+ this.#usageStatistics.output = next.output;
5935
+ this.#usageStatistics.cacheRead = next.cacheRead;
5936
+ this.#usageStatistics.cacheWrite = next.cacheWrite;
5937
+ this.#usageStatistics.premiumRequests = next.premiumRequests;
5938
+ this.#usageStatistics.cost = next.cost;
5939
+ return true;
5940
+ }
5941
+
5942
+ /**
5943
+ * Validate and accumulate one entry's persisted usage through the single shared,
5944
+ * overflow-guarded aggregation path used by both #buildIndex (resume) and #appendEntry
5945
+ * (runtime), covering the assistant and `task` tool-result shapes. Returns true when a
5946
+ * present usage record was skipped as malformed or overflowing (so the caller can report
5947
+ * it); false when the entry has no usage or was aggregated cleanly.
5948
+ */
5949
+ #accumulateEntryUsage(entry: SessionEntry): boolean {
5950
+ if (entry.type !== "message") return false;
5951
+ if (entry.message.role === "assistant") {
5952
+ const totals = validatePersistedUsageTotals(entry.message.usage);
5953
+ return !(totals && this.#addValidatedUsage(totals));
5954
+ }
5955
+ if (entry.message.role === "toolResult" && entry.message.toolName === "task") {
5956
+ const rawTaskUsage = getTaskToolUsage(entry.message.details);
5957
+ if (rawTaskUsage === undefined) return false;
5958
+ const totals = validatePersistedUsageTotals(rawTaskUsage);
5959
+ return !(totals && this.#addValidatedUsage(totals));
4946
5960
  }
5961
+ return false;
4947
5962
  }
4948
5963
 
4949
5964
  #recordPersistError(err: unknown): Error {
@@ -5433,6 +6448,15 @@ export class SessionManager {
5433
6448
 
5434
6449
  /** Close the persistent writer after flushing all pending data. */
5435
6450
  async close(): Promise<void> {
6451
+ // Drain any uncommitted prepared successors before releasing resources so
6452
+ // dispose/shutdown retains exact cleanup authority (#3138).
6453
+ try {
6454
+ await this.#retryPreparedNewSessionCleanups();
6455
+ } catch (error) {
6456
+ logger.warn("Prepared session cleanup during close failed; retained for retry", {
6457
+ error: toError(error).message,
6458
+ });
6459
+ }
5436
6460
  await this.#queuePersistTask(async () => {
5437
6461
  if (this.#persistWriter) {
5438
6462
  await this.#closePersistWriterInternal();
@@ -5457,6 +6481,14 @@ export class SessionManager {
5457
6481
  * writer closure before any destructive operation.
5458
6482
  */
5459
6483
  async closeStrict(): Promise<SessionManagerCloseOutcome> {
6484
+ // Drain staged successors on the strict ACP dispose path as well as best-effort close (#3138).
6485
+ try {
6486
+ await this.#retryPreparedNewSessionCleanups();
6487
+ } catch (error) {
6488
+ logger.warn("Prepared session cleanup during closeStrict failed; retained for retry", {
6489
+ error: toError(error).message,
6490
+ });
6491
+ }
5460
6492
  let outcome: SessionManagerCloseOutcome = { kind: "closed" };
5461
6493
  await this.#queuePersistTask(async () => {
5462
6494
  const writer = this.#persistWriter;
@@ -5523,6 +6555,17 @@ export class SessionManager {
5523
6555
  return this.sessionDir;
5524
6556
  }
5525
6557
 
6558
+ /** Lists picker candidates within this manager's captured destination authority. */
6559
+ async listForResumePickerReadOnly(): Promise<SessionInfo[]> {
6560
+ return this.destination.kind === "managed"
6561
+ ? await SessionManager.listManagedForResumePickerReadOnly(
6562
+ this.cwd,
6563
+ this.destination.securityContext.agentDir,
6564
+ this.storage,
6565
+ )
6566
+ : await SessionManager.listForResumePickerReadOnly(this.cwd, this.destination.directory, this.storage);
6567
+ }
6568
+
5526
6569
  getSessionId(): string {
5527
6570
  return this.#sessionId;
5528
6571
  }
@@ -5531,6 +6574,160 @@ export class SessionManager {
5531
6574
  return this.#sessionFile;
5532
6575
  }
5533
6576
 
6577
+ acquireMemoryGuardParticipantIngressLease(): MemoryGuardParticipantIngressLease {
6578
+ if (this.#memoryGuardParticipantIngressToken)
6579
+ throw new Error("memory_guard_participant_ingress_lease_already_held");
6580
+ const token = Symbol("memory_guard_participant_ingress_lease");
6581
+ this.#memoryGuardParticipantIngressToken = token;
6582
+ let released = false;
6583
+ return Object.freeze({
6584
+ token,
6585
+ release: () => {
6586
+ if (released) return;
6587
+ released = true;
6588
+ if (this.#memoryGuardParticipantIngressToken === token)
6589
+ this.#memoryGuardParticipantIngressToken = undefined;
6590
+ },
6591
+ });
6592
+ }
6593
+
6594
+ #assertMemoryGuardParticipantIngressLease(lease: MemoryGuardParticipantIngressLease): void {
6595
+ if (this.#memoryGuardParticipantIngressToken !== lease.token) {
6596
+ throw new Error("memory_guard_participant_ingress_lease_invalid");
6597
+ }
6598
+ }
6599
+
6600
+ #stageMemoryGuardCheckpointBlobs(blobs: Map<string, Buffer>): void {
6601
+ const stagedImageStore = new MemoryBlobStore();
6602
+ for (const blob of blobs.values()) {
6603
+ this.#residentTextBlobStore.putSync(blob);
6604
+ stagedImageStore.putSync(blob);
6605
+ }
6606
+ this.#residentImageBlobStore = stagedImageStore;
6607
+ this.#memoryGuardCheckpointBlobs = blobs;
6608
+ }
6609
+
6610
+ async createMemoryGuardCheckpoint(
6611
+ input: MemoryGuardCreateCheckpointInput,
6612
+ ): Promise<MemoryGuardSessionManagerCheckpointV1> {
6613
+ this.#assertMemoryGuardParticipantIngressLease(input.ingressLease);
6614
+ if (!this.#sessionFile) throw new Error("memory_guard_checkpoint_session_file_unavailable");
6615
+ await this.flush();
6616
+ const captured = SessionManager.captureTranscriptStrict(this.#sessionFile, this.storage);
6617
+ if (captured.kind !== "captured") {
6618
+ throw new Error(`memory_guard_checkpoint_capture_failed:${captured.reason}`);
6619
+ }
6620
+ if (captured.snapshot.content.byteLength > MEMORY_GUARD_CHECKPOINT_FILE_MAX_BYTES)
6621
+ throw new Error("memory_guard_checkpoint_transcript_capacity_exceeded");
6622
+ const transcriptText = decodeCheckpointUtf8(captured.snapshot.content);
6623
+ if (transcriptText === null) throw new Error("memory_guard_checkpoint_transcript_unreadable");
6624
+ const entries = parseSessionEntries(transcriptText);
6625
+ const sessionId = this.getSessionId();
6626
+ assertMemoryGuardSessionId(sessionId);
6627
+ const sessionName = this.getSessionName() ?? null;
6628
+ const revisions = this.revisionSnapshot();
6629
+ const participantRoot = memoryGuardParticipantRoot(input.checkpointRoot, sessionId);
6630
+ const transcriptRelativePath = memoryGuardParticipantRelativePath(sessionId, "transcript.jsonl");
6631
+ const blobRootRelativePath = memoryGuardParticipantRelativePath(sessionId, "blobs");
6632
+ const blobManifestRelativePath = memoryGuardParticipantRelativePath(sessionId, "blob-manifest.json");
6633
+ const refs = [...collectCheckpointBlobRefs(entries)].sort();
6634
+ if (refs.length > MEMORY_GUARD_CHECKPOINT_BLOB_MAX_ENTRIES)
6635
+ throw new Error("memory_guard_checkpoint_blob_count_exceeded");
6636
+ const blobManifestEntries: MemoryGuardCheckpointBlobManifestEntryV1[] = [];
6637
+ const blobWrites: Array<{ data: Buffer; relativePath: string }> = [];
6638
+ let aggregateBlobBytes = 0;
6639
+ for (const ref of refs) {
6640
+ const hash = parseBlobRef(ref);
6641
+ if (!hash) continue;
6642
+ const data =
6643
+ this.#residentTextBlobStore.getCheckedSync(hash) ??
6644
+ this.#residentImageBlobStore.getCheckedSync(hash) ??
6645
+ this.#blobStore.getCheckedSync(hash);
6646
+ if (!data) throw new Error(`memory_guard_checkpoint_blob_missing:${hash}`);
6647
+ if (data.byteLength > MEMORY_GUARD_CHECKPOINT_FILE_MAX_BYTES)
6648
+ throw new Error(`memory_guard_checkpoint_blob_capacity_exceeded:${hash}`);
6649
+ aggregateBlobBytes += data.byteLength;
6650
+ if (aggregateBlobBytes > MEMORY_GUARD_CHECKPOINT_BLOB_TOTAL_MAX_BYTES)
6651
+ throw new Error("memory_guard_checkpoint_blob_total_capacity_exceeded");
6652
+ const relativePath = hash;
6653
+ blobWrites.push({ data, relativePath });
6654
+ blobManifestEntries.push({
6655
+ bytes: String(data.byteLength),
6656
+ relative_path: relativePath,
6657
+ sha256: memoryGuardSha256Hex(data),
6658
+ });
6659
+ }
6660
+ const publishedCheckpointPaths: string[] = [];
6661
+ const cleanupPublishedCheckpointPaths = async (): Promise<void> => {
6662
+ for (const publishedPath of publishedCheckpointPaths.reverse())
6663
+ await fs.promises.rm(publishedPath, { force: true }).catch(() => undefined);
6664
+ await fs.promises.rmdir(path.join(input.checkpointRoot, blobRootRelativePath)).catch(() => undefined);
6665
+ await fs.promises.rmdir(participantRoot).catch(() => undefined);
6666
+ };
6667
+ const publishCheckpointFile = async (filePath: string, data: Uint8Array | string): Promise<void> => {
6668
+ try {
6669
+ await writeOwnerOnlyFileNoReplace(filePath, data);
6670
+ publishedCheckpointPaths.push(filePath);
6671
+ } catch (error) {
6672
+ await cleanupPublishedCheckpointPaths();
6673
+ throw error;
6674
+ }
6675
+ };
6676
+ await ensureOwnerOnlyDirectory(participantRoot);
6677
+ await publishCheckpointFile(path.join(input.checkpointRoot, transcriptRelativePath), captured.snapshot.content);
6678
+ for (const write of blobWrites)
6679
+ await publishCheckpointFile(
6680
+ path.join(input.checkpointRoot, blobRootRelativePath, write.relativePath),
6681
+ write.data,
6682
+ );
6683
+ blobManifestEntries.sort((left, right) => left.relative_path.localeCompare(right.relative_path));
6684
+ const blobManifest: MemoryGuardCheckpointBlobManifestV1 = {
6685
+ entries: blobManifestEntries,
6686
+ schema_version: 1,
6687
+ };
6688
+ const blobManifestText = memoryGuardCanonicalJson(blobManifest);
6689
+ await publishCheckpointFile(path.join(input.checkpointRoot, blobManifestRelativePath), blobManifestText);
6690
+ const checkpoint: MemoryGuardSessionManagerCheckpointV1 = {
6691
+ blob_authority: {
6692
+ kind: "checkpoint_blob_tree_v1",
6693
+ manifest_relative_path: blobManifestRelativePath,
6694
+ manifest_sha256: memoryGuardSha256Hex(blobManifestText),
6695
+ root_relative_path: blobRootRelativePath,
6696
+ },
6697
+ revisions: toSessionManagerCheckpointRevisionStrings(revisions),
6698
+ schema_version: 1,
6699
+ session_id: sessionId,
6700
+ session_name: sessionName,
6701
+ transcript: {
6702
+ bytes: String(captured.snapshot.content.byteLength),
6703
+ relative_path: transcriptRelativePath,
6704
+ sha256: captured.snapshot.identity.sha256,
6705
+ },
6706
+ };
6707
+ const checkpointPath = path.join(
6708
+ input.checkpointRoot,
6709
+ memoryGuardParticipantRelativePath(sessionId, "session-manager.json"),
6710
+ );
6711
+ await publishCheckpointFile(checkpointPath, memoryGuardCanonicalJson(checkpoint));
6712
+ const currentRevisions = this.revisionSnapshot();
6713
+ const recaptured = SessionManager.captureTranscriptStrict(this.#sessionFile, this.storage);
6714
+ if (
6715
+ this.getSessionId() !== sessionId ||
6716
+ (this.getSessionName() ?? null) !== sessionName ||
6717
+ Object.keys(revisions).some(
6718
+ key =>
6719
+ currentRevisions[key as keyof SessionManagerRevisionSnapshot] !==
6720
+ revisions[key as keyof SessionManagerRevisionSnapshot],
6721
+ ) ||
6722
+ recaptured.kind !== "captured" ||
6723
+ !sameResumeIdentity(captured.snapshot.identity, recaptured.snapshot.identity)
6724
+ ) {
6725
+ await cleanupPublishedCheckpointPaths();
6726
+ throw new Error("memory_guard_checkpoint_state_changed_during_capture");
6727
+ }
6728
+ return checkpoint;
6729
+ }
6730
+
5534
6731
  /**
5535
6732
  * Returns the session artifacts directory path (session file path without .jsonl).
5536
6733
  * Returns null when the session is not persisted to a file.
@@ -5550,15 +6747,28 @@ export class SessionManager {
5550
6747
 
5551
6748
  /** Supplies opaque retained authority for mandatory managed legacy local migration. */
5552
6749
  getManagedLegacyLocalMigrationSource(): ManagedLegacyLocalMigrationSource | null {
5553
- if (this.destination.kind !== "managed" || !this.#sessionFile || this.#adoptedArtifactManager) return null;
5554
- const store = this.#managedTranscriptStore();
6750
+ return this.#managedLegacyLocalMigrationSourceFor(this.#sessionFile);
6751
+ }
6752
+
6753
+ #managedLegacyLocalMigrationSourceFor(sessionFile: string | undefined): ManagedLegacyLocalMigrationSource | null {
6754
+ if (this.destination.kind !== "managed" || !sessionFile || this.#adoptedArtifactManager) return null;
6755
+ const store = this.#managedTranscriptStore(sessionFile);
6756
+ const legacyArtifactsRoot = path.basename(sessionFile.slice(0, -6));
6757
+ const legacyLocalRoot = path.posix.join(legacyArtifactsRoot, "local");
5555
6758
  return {
5556
6759
  capture: async () => {
5557
6760
  let snapshot: native.NativeDirectoryTreeSnapshot;
5558
6761
  try {
5559
- snapshot = store.captureTree("local");
6762
+ snapshot = store.captureTree(legacyLocalRoot);
5560
6763
  } catch (error) {
5561
6764
  if (error instanceof Error && error.message === "not_found") return null;
6765
+ if (error instanceof Error && error.message === "reparse_point") {
6766
+ try {
6767
+ store.captureTree(legacyArtifactsRoot);
6768
+ } catch (artifactsError) {
6769
+ if (artifactsError instanceof Error && artifactsError.message === "not_found") return null;
6770
+ }
6771
+ }
5562
6772
  throw error;
5563
6773
  }
5564
6774
  let totalBytes = 0;
@@ -5566,7 +6776,7 @@ export class SessionManager {
5566
6776
  if (totalBytes > 64 * 1024 * 1024) throw new Error("Legacy local:// migration exceeds the safe size limit");
5567
6777
  const entries = snapshot.entries.map(entry => {
5568
6778
  if (entry.kind === "directory") return { relativePath: entry.relativePath, kind: "directory" as const };
5569
- const captured = store.readExpected(path.posix.join("local", entry.relativePath));
6779
+ const captured = store.readExpected(path.posix.join(legacyLocalRoot, entry.relativePath));
5570
6780
  if (
5571
6781
  !captured ||
5572
6782
  captured.identity.dev.toString() !== entry.dev ||
@@ -5582,12 +6792,12 @@ export class SessionManager {
5582
6792
  sha256: entry.sha256,
5583
6793
  };
5584
6794
  });
5585
- const verified = store.captureTree("local");
6795
+ const verified = store.captureTree(legacyLocalRoot);
5586
6796
  if (JSON.stringify(verified) !== JSON.stringify(snapshot))
5587
6797
  throw new Error("Legacy local:// migration source changed during capture");
5588
6798
  return { snapshot, entries };
5589
6799
  },
5590
- retire: snapshot => store.removeTreeExpected("local", snapshot),
6800
+ retire: snapshot => store.removeTreeExpected(legacyLocalRoot, snapshot),
5591
6801
  };
5592
6802
  }
5593
6803
 
@@ -5779,7 +6989,11 @@ export class SessionManager {
5779
6989
  * @param source - "user" for explicit renames (/rename command, RPC); "auto" for generated titles.
5780
6990
  * Auto-generated titles are silently ignored when the user has already set a name.
5781
6991
  */
6992
+ #assertRecoveryHydrationWritable(): void {
6993
+ if (this.#recoveryHydrationContext) throw new Error("recovery_hydration_not_promoted");
6994
+ }
5782
6995
  async setSessionName(name: string, source: "auto" | "user" = "auto"): Promise<boolean> {
6996
+ this.#assertRecoveryHydrationWritable();
5783
6997
  // User-set names take permanent precedence over auto-generated ones.
5784
6998
  if (this.#titleSource === "user" && source === "auto") return false;
5785
6999
 
@@ -5905,6 +7119,7 @@ export class SessionManager {
5905
7119
  }
5906
7120
 
5907
7121
  #appendEntry(entry: SessionEntry): void {
7122
+ this.#assertRecoveryHydrationWritable();
5908
7123
  const normalizedEntry = normalizeSessionEntryForStorage(entry);
5909
7124
  const residentEntry = prepareEntryForResidentSync(normalizedEntry, this.#residentBlobStores()) as SessionEntry;
5910
7125
  this.#fileEntries.push(residentEntry);
@@ -5914,26 +7129,14 @@ export class SessionManager {
5914
7129
  this.#leafRevision++;
5915
7130
  if (entry.type === "label") this.#labelRevision++;
5916
7131
  this._persist(residentEntry);
5917
- if (entry.type === "message" && entry.message.role === "assistant") {
5918
- const usage = entry.message.usage;
5919
- this.#usageStatistics.input += usage.input;
5920
- this.#usageStatistics.output += usage.output;
5921
- this.#usageStatistics.cacheRead += usage.cacheRead;
5922
- this.#usageStatistics.cacheWrite += usage.cacheWrite;
5923
- this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
5924
- this.#usageStatistics.cost += usage.cost.total;
5925
- }
5926
-
5927
- if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "task") {
5928
- const usage = getTaskToolUsage(entry.message.details);
5929
- if (usage) {
5930
- this.#usageStatistics.input += usage.input;
5931
- this.#usageStatistics.output += usage.output;
5932
- this.#usageStatistics.cacheRead += usage.cacheRead;
5933
- this.#usageStatistics.cacheWrite += usage.cacheWrite;
5934
- this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
5935
- this.#usageStatistics.cost += usage.cost.total;
5936
- }
7132
+ // Same validated, overflow-guarded aggregation as the resume path (#buildIndex): a
7133
+ // malformed task-result or assistant usage shape reaching the append path must not
7134
+ // crash or poison getUsageStatistics() either.
7135
+ if (this.#accumulateEntryUsage(entry)) {
7136
+ logger.warn("Skipped malformed or overflowing usage on appended entry", {
7137
+ sessionFile: this.#sessionFile,
7138
+ entryId: entry.id,
7139
+ });
5937
7140
  }
5938
7141
  }
5939
7142
 
@@ -6135,6 +7338,7 @@ export class SessionManager {
6135
7338
  * the canonical store. This applies such mutations for real.
6136
7339
  */
6137
7340
  applyEntryMessageUpdates(entries: readonly SessionMessageEntry[]): void {
7341
+ this.#assertRecoveryHydrationWritable();
6138
7342
  for (const updated of entries) {
6139
7343
  const canonical = this.#byId.get(updated.id);
6140
7344
  if (canonical?.type !== "message") continue;
@@ -6151,6 +7355,7 @@ export class SessionManager {
6151
7355
 
6152
7356
  /** Write mutated custom-message entries back into the canonical entry store by id. */
6153
7357
  applyCustomMessageEntryUpdates(entries: readonly CustomMessageEntry[]): void {
7358
+ this.#assertRecoveryHydrationWritable();
6154
7359
  for (const updated of entries) {
6155
7360
  const canonical = this.#byId.get(updated.id);
6156
7361
  if (canonical?.type !== "custom_message") continue;
@@ -6170,6 +7375,7 @@ export class SessionManager {
6170
7375
  * Use sparingly (e.g., pruning old tool outputs).
6171
7376
  */
6172
7377
  async rewriteEntries(): Promise<void> {
7378
+ this.#assertRecoveryHydrationWritable();
6173
7379
  if (!this.persist || !this.#sessionFile) return;
6174
7380
  await this.#rewriteFile();
6175
7381
  }
@@ -6335,6 +7541,7 @@ export class SessionManager {
6335
7541
  }
6336
7542
 
6337
7543
  evictCompactedContent(firstKeptEntryId: string, compactionEntryId: string): EvictCompactedContentResult {
7544
+ this.#assertRecoveryHydrationWritable();
6338
7545
  const firstKept = this.#byId.get(firstKeptEntryId);
6339
7546
  const compaction = this.#byId.get(compactionEntryId);
6340
7547
  if (!firstKept) throw new Error(`Entry ${firstKeptEntryId} not found`);
@@ -6485,7 +7692,7 @@ export class SessionManager {
6485
7692
  ? cloneSessionEntry(
6486
7693
  rehydrateColdSpillEntry(
6487
7694
  materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), new Map()),
6488
- this.#blobStore,
7695
+ this.#coldSpillReadStore(),
6489
7696
  this.#residentBlobStoresForColdRehydrate(),
6490
7697
  ),
6491
7698
  )
@@ -6504,7 +7711,7 @@ export class SessionManager {
6504
7711
  cloneSessionEntry(
6505
7712
  rehydrateColdSpillEntry(
6506
7713
  materializeResidentEntryForReadSync(current, this.#residentBlobStores(), cache),
6507
- this.#blobStore,
7714
+ this.#coldSpillReadStore(),
6508
7715
  this.#residentBlobStoresForColdRehydrate(),
6509
7716
  ),
6510
7717
  ),
@@ -6546,7 +7753,7 @@ export class SessionManager {
6546
7753
  cloneSessionEntry(
6547
7754
  rehydrateColdSpillEntry(
6548
7755
  materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), cache),
6549
- this.#blobStore,
7756
+ this.#coldSpillReadStore(),
6550
7757
  this.#residentBlobStoresForColdRehydrate(),
6551
7758
  ),
6552
7759
  ),
@@ -6712,7 +7919,7 @@ export class SessionManager {
6712
7919
  const materialized = materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), new Map());
6713
7920
  const rehydrated = rehydrateColdSpillEntry(
6714
7921
  materialized,
6715
- this.#blobStore,
7922
+ this.#coldSpillReadStore(),
6716
7923
  this.#residentBlobStoresForColdRehydrate(),
6717
7924
  );
6718
7925
  if (rehydrated !== materialized) this.#coldSpillReadCount += this.#countColdSpillPayloads(entry);
@@ -6728,6 +7935,7 @@ export class SessionManager {
6728
7935
  }
6729
7936
  /** Strip stale OpenAI Responses assistant replay metadata from loaded in-memory entries without persisting it. */
6730
7937
  sanitizeLoadedOpenAIResponsesReplayMetadata(): boolean {
7938
+ this.#assertRecoveryHydrationWritable();
6731
7939
  return this.#sanitizeLoadedOpenAIResponsesReplayMetadata().length > 0;
6732
7940
  }
6733
7941
 
@@ -6883,6 +8091,7 @@ export class SessionManager {
6883
8091
  * are not modified or deleted.
6884
8092
  */
6885
8093
  branch(branchFromId: string): void {
8094
+ this.#assertRecoveryHydrationWritable();
6886
8095
  if (!this.#byId.has(branchFromId)) {
6887
8096
  throw new Error(`Entry ${branchFromId} not found`);
6888
8097
  }
@@ -6896,6 +8105,7 @@ export class SessionManager {
6896
8105
  * Use this when navigating to re-edit the first user message.
6897
8106
  */
6898
8107
  resetLeaf(): void {
8108
+ this.#assertRecoveryHydrationWritable();
6899
8109
  this.#leafId = null;
6900
8110
  this.#leafRevision++;
6901
8111
  }
@@ -6906,6 +8116,7 @@ export class SessionManager {
6906
8116
  * context from the abandoned conversation path.
6907
8117
  */
6908
8118
  branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromExtension?: boolean): string {
8119
+ this.#assertRecoveryHydrationWritable();
6909
8120
  if (branchFromId !== null && !this.#byId.has(branchFromId)) {
6910
8121
  throw new Error(`Entry ${branchFromId} not found`);
6911
8122
  }
@@ -6930,6 +8141,7 @@ export class SessionManager {
6930
8141
  * Returns the new session file path, or undefined if not persisting.
6931
8142
  */
6932
8143
  createBranchedSession(leafId: string): string | undefined {
8144
+ this.#assertRecoveryHydrationWritable();
6933
8145
  const previousSessionFile = this.#sessionFile;
6934
8146
  const branchPath = this.#getCanonicalBranchClones(leafId);
6935
8147
  if (branchPath.length === 0) {
@@ -7080,6 +8292,12 @@ export class SessionManager {
7080
8292
  authority instanceof ManagedSessionDescendantStore
7081
8293
  ? authority.retainAuthority()
7082
8294
  : retainManagedDirectoryAuthority(rootAuthority, directory);
8295
+ const policy =
8296
+ authority instanceof ManagedSessionDescendantStore
8297
+ ? authority.securityPolicy
8298
+ : process.platform === "win32"
8299
+ ? "windows-existing-verify-first"
8300
+ : "default";
7083
8301
  const securityContext = createManagedSessionSecurityContext({
7084
8302
  agentDir: rootAuthority.canonicalPath,
7085
8303
  sessionsRoot: rootAuthority.canonicalPath,
@@ -7087,6 +8305,7 @@ export class SessionManager {
7087
8305
  rootAuthority,
7088
8306
  retainedAuthority,
7089
8307
  });
8308
+ managedSecurityPolicies.set(securityContext, policy);
7090
8309
  const destination = Object.freeze({ kind: "managed" as const, directory, securityContext });
7091
8310
  trustedSessionDestinations.add(destination);
7092
8311
  return destination;
@@ -7115,20 +8334,61 @@ export class SessionManager {
7115
8334
  return manager;
7116
8335
  }
7117
8336
 
8337
+ /** Prepare a selected candidate using this manager's captured managed destination authority. */
8338
+ async prepareManagedCandidateForWrite(
8339
+ filePath: string,
8340
+ migrationPolicy: SessionDirectoryMigrationPolicy,
8341
+ expectedIdentity?: ResumeSessionIdentity,
8342
+ ): Promise<string> {
8343
+ return SessionManager.prepareManagedCandidateForWrite(
8344
+ filePath,
8345
+ migrationPolicy,
8346
+ this.destination,
8347
+ expectedIdentity,
8348
+ );
8349
+ }
8350
+
8351
+ /** Prepare a managed candidate and retain its exact post-preparation identity for the next adoption. */
8352
+ async prepareManagedCandidateForStrictAdoption(
8353
+ filePath: string,
8354
+ migrationPolicy: SessionDirectoryMigrationPolicy,
8355
+ expectedIdentity: ResumeSessionIdentity,
8356
+ ): Promise<string> {
8357
+ const preparedPath = await this.prepareManagedCandidateForWrite(filePath, migrationPolicy, expectedIdentity);
8358
+ const inspected = inspectResumeSessionFile(preparedPath, this.storage);
8359
+ if ("kind" in inspected) throw new Error(`Could not inspect prepared managed session: ${inspected.reason}`);
8360
+ this.#pendingStrictAdoption = { canonicalPath: inspected.identity.canonicalPath, identity: inspected.identity };
8361
+ return preparedPath;
8362
+ }
8363
+
7118
8364
  /** Resolve a default-managed candidate through binding validation and copy-retain migration before mutation. */
7119
8365
  static async prepareManagedCandidateForWrite(
7120
8366
  filePath: string,
7121
8367
  migrationPolicy: SessionDirectoryMigrationPolicy,
8368
+ destination: SessionDestination,
8369
+ expectedIdentity?: ResumeSessionIdentity,
7122
8370
  ): Promise<string> {
8371
+ if (!trustedSessionDestinations.has(destination) || destination.kind !== "managed")
8372
+ throw new Error("Managed candidate preparation requires a trusted managed destination authority");
7123
8373
  const storage = new FileSessionStorage();
8374
+ const managedDestinationStore = managedStoreFromContext(destination.securityContext, destination.directory);
8375
+ const assertManagedDestinationBound = (): void => {
8376
+ managedDestinationStore.assertBound();
8377
+ if (!destination.securityContext.retainedAuthority)
8378
+ assertManagedDirectoryRoot(destination.securityContext.rootAuthority);
8379
+ };
8380
+
8381
+ assertManagedDestinationBound();
7124
8382
  const inspected = inspectResumeSessionFile(filePath, storage);
7125
8383
  if ("kind" in inspected) {
7126
8384
  if (inspected.reason === "missing") return filePath;
7127
8385
  throw new Error(`Could not inspect managed session: ${inspected.reason}`);
7128
8386
  }
8387
+ if (expectedIdentity && !sameResumeIdentity(expectedIdentity, inspected.identity))
8388
+ throw new Error("Managed session changed before migration authority was adopted.");
7129
8389
  const header = inspected.entries.find(entry => entry.type === "session") as SessionHeader | undefined;
7130
8390
  if (!header?.cwd) return filePath;
7131
- const sessionsRoot = getSessionsDir();
8391
+ const sessionsRoot = destination.securityContext.sessionsRoot;
7132
8392
  const resolvedPath = path.resolve(filePath);
7133
8393
  const relativeToManagedRoot = path.relative(sessionsRoot, resolvedPath);
7134
8394
  const isManagedPath =
@@ -7136,14 +8396,17 @@ export class SessionManager {
7136
8396
  (!relativeToManagedRoot.startsWith("..") && !path.isAbsolute(relativeToManagedRoot));
7137
8397
 
7138
8398
  if (!isManagedPath) return filePath;
8399
+ assertManagedDestinationBound();
7139
8400
  const resolved = resolveManagedScope({
7140
8401
  cwd: header.cwd,
7141
- agentDir: path.resolve(sessionsRoot, ".."),
8402
+ agentDir: destination.securityContext.agentDir,
7142
8403
  sessionsRoot,
7143
8404
  });
7144
8405
  if (resolved.kind === "error") throw new Error(`Could not resolve managed session scope: ${resolved.message}`);
7145
8406
 
8407
+ assertManagedDestinationBound();
7146
8408
  const listing = listManagedCandidates(resolved.scope);
8409
+ assertManagedDestinationBound();
7147
8410
  if (listing.kind !== "complete") throw new Error(`Managed session scan failed: ${listing.message}`);
7148
8411
 
7149
8412
  const candidate = listing.owned.find(item => path.resolve(item.path) === resolvedPath);
@@ -7160,11 +8423,37 @@ export class SessionManager {
7160
8423
  candidate.identity.sha256 !== inspected.identity.sha256
7161
8424
  )
7162
8425
  throw new Error("Managed session changed before migration authority was adopted.");
7163
- const opened = await openManagedCandidateForWrite(resolved.scope, candidate, migrationPolicy);
8426
+ assertManagedDestinationBound();
8427
+ const authority: ManagedCandidateWriteAuthority = {
8428
+ rootAuthority: destination.securityContext.rootAuthority,
8429
+ ...(path.resolve(destination.directory) === path.resolve(resolved.scope.directoryPath)
8430
+ ? {
8431
+ retainedAuthority: destination.securityContext.retainedAuthority,
8432
+ retainedDirectory: destination.directory,
8433
+ }
8434
+ : {}),
8435
+ };
8436
+ let opened: ManagedOpenCandidateResult;
8437
+ try {
8438
+ opened = await openManagedCandidateForWrite(
8439
+ resolved.scope,
8440
+ candidate,
8441
+ expectedIdentity ?? inspected.identity,
8442
+ migrationPolicy,
8443
+ authority,
8444
+ );
8445
+ } catch (error) {
8446
+ if (error instanceof Error && error.message.startsWith("Managed root authority changed"))
8447
+ managedDestinationStore.assertBound();
8448
+ throw error;
8449
+ }
7164
8450
  if (opened.kind === "error") {
8451
+ managedDestinationStore.assertBound();
7165
8452
  if (opened.code === "legacy_migration_disabled") throw new SessionMigrationPolicyError();
8453
+ if (opened.code === "artifact_capacity_exceeded") throw new SessionArtifactCapacityError(opened.message);
7166
8454
  throw new Error(`Could not open managed session: ${opened.message}`);
7167
8455
  }
8456
+ assertManagedDestinationBound();
7168
8457
  return opened.path;
7169
8458
  }
7170
8459
 
@@ -7182,7 +8471,7 @@ export class SessionManager {
7182
8471
  const destination = destinationFor(cwd, destinationInput, storage);
7183
8472
  const managedSourcePath =
7184
8473
  destination.kind === "managed" && storage instanceof FileSessionStorage
7185
- ? await SessionManager.prepareManagedCandidateForWrite(sourcePath, migrationPolicy)
8474
+ ? await SessionManager.prepareManagedCandidateForWrite(sourcePath, migrationPolicy, destination)
7186
8475
  : sourcePath;
7187
8476
  const dir = destination.directory;
7188
8477
  const manager = new SessionManager(cwd, dir, true, storage, destination);
@@ -7246,10 +8535,9 @@ export class SessionManager {
7246
8535
  return manager;
7247
8536
  }
7248
8537
 
7249
- const managedPath = await SessionManager.prepareManagedCandidateForWrite(filePath, migrationPolicy);
7250
- const inspected = inspectResumeSessionFile(managedPath, storage);
8538
+ const inspected = inspectResumeSessionFile(filePath, storage);
7251
8539
  if ("kind" in inspected) {
7252
- if (inspected.reason === "missing" && path.resolve(managedPath) === path.resolve(filePath)) {
8540
+ if (inspected.reason === "missing") {
7253
8541
  const manager = new SessionManager(getProjectDir(), destination.directory, true, storage, destination);
7254
8542
  await manager.#initSessionFile(filePath);
7255
8543
  return manager;
@@ -7317,54 +8605,79 @@ export class SessionManager {
7317
8605
  return manager;
7318
8606
  }
7319
8607
  /**
7320
- * List sessions for the resume picker without recovery or other maintenance writes.
8608
+ * List default-managed sessions for the resume picker without recovery or other
8609
+ * maintenance writes. This is the only picker inventory that includes legacy
8610
+ * sibling directories.
8611
+ */
8612
+ static async listManagedForResumePickerReadOnly(
8613
+ cwd: string,
8614
+ managedAgentDir?: string,
8615
+ storage: SessionStorage = new FileSessionStorage(),
8616
+ ): Promise<SessionInfo[]> {
8617
+ if (!(storage instanceof FileSessionStorage)) return [];
8618
+ const sessionsRoot = getSessionsDir(managedAgentDir);
8619
+ const resolved = resolveManagedScope({
8620
+ cwd,
8621
+ agentDir: managedAgentDir ?? path.resolve(sessionsRoot, ".."),
8622
+ sessionsRoot,
8623
+ });
8624
+ if (resolved.kind === "error") return [];
8625
+ const listing = listManagedCandidates(resolved.scope);
8626
+ if (listing.kind === "error") return [];
8627
+ const managed = await collectSessionsFromFiles(
8628
+ listing.owned.map(candidate => candidate.path),
8629
+ storage,
8630
+ );
8631
+ return mergeSessionInventories(managed, await collectProjectSessions(cwd, storage));
8632
+ }
8633
+
8634
+ /**
8635
+ * List sessions from an explicitly supplied picker directory without recovery
8636
+ * or other maintenance writes. Unlike managed inventory, this never scans
8637
+ * legacy sibling directories.
7321
8638
  */
7322
8639
  static async listForResumePickerReadOnly(
7323
8640
  cwd: string,
7324
8641
  sessionDir?: string,
7325
8642
  storage: SessionStorage = new FileSessionStorage(),
7326
8643
  ): Promise<SessionInfo[]> {
7327
- if (!sessionDir && !(storage instanceof FileSessionStorage)) return [];
7328
- if (sessionDir && !resolveManagedSessionRoot(sessionDir, cwd)) {
7329
- try {
7330
- return await collectSessionsFromFiles(storage.listFilesSync(sessionDir, "*.jsonl"), storage);
7331
- } catch {
7332
- return [];
7333
- }
7334
- }
7335
- const sessionsRoot = sessionDir
7336
- ? (resolveManagedSessionRoot(sessionDir, cwd) ?? getSessionsDir())
7337
- : getSessionsDir();
7338
- const resolved = resolveManagedScope({ cwd, agentDir: path.resolve(sessionsRoot, ".."), sessionsRoot });
7339
- if (resolved.kind === "error") return [];
7340
- if (storage instanceof FileSessionStorage) {
7341
- const listing = listManagedCandidates(resolved.scope);
7342
- if (listing.kind === "error") return [];
7343
- return await collectSessionsFromFiles(
7344
- listing.owned.map(candidate => candidate.path),
7345
- storage,
7346
- );
7347
- }
7348
- const legacyDirectory = `--${resolved.scope.canonicalCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
7349
- const files = new Set<string>();
8644
+ if (!sessionDir) return await SessionManager.listManagedForResumePickerReadOnly(cwd, undefined, storage);
7350
8645
  try {
7351
- for (const directory of [resolved.scope.directoryPath, path.join(sessionsRoot, legacyDirectory)]) {
7352
- for (const file of storage.listFilesSync(directory, "*.jsonl")) files.add(file);
7353
- }
8646
+ return await collectSessionsFromFiles(storage.listFilesSync(sessionDir, "*.jsonl"), storage);
7354
8647
  } catch {
7355
8648
  return [];
7356
8649
  }
7357
- return (await collectSessionsFromFiles([...files], storage)).filter(
7358
- session => resolveEquivalentPath(session.cwd) === resolved.scope.canonicalCwd,
7359
- );
7360
8650
  }
7361
8651
 
7362
- /** Tombstone and exact-delete a default-managed candidate. */
8652
+ /** Delete an authorized managed or project-local picker candidate. */
7363
8653
  static async deleteManagedCandidate(sessionPath: string): Promise<void> {
7364
8654
  const storage = new FileSessionStorage();
7365
8655
  const entries = await loadEntriesFromFile(sessionPath, storage);
7366
8656
  const header = entries.find(entry => entry.type === "session") as SessionHeader | undefined;
7367
- if (!header?.cwd) throw new Error("Session has no valid managed workspace header.");
8657
+ if (!header?.cwd) throw new Error("Session has no valid workspace header.");
8658
+ const projectSkcDir = path.join(path.resolve(header.cwd), ".skc");
8659
+ if (isProjectSessionTranscriptPath(projectSkcDir, sessionPath)) {
8660
+ const relativePath = path.relative(projectSkcDir, path.resolve(sessionPath)).split(path.sep).join("/");
8661
+ const authority = native.openRecoveryFsRoot(projectSkcDir);
8662
+ try {
8663
+ const captured = authority.readManaged(relativePath);
8664
+ if (!captured.ok || !captured.identity || !captured.data || !captured.identity.sha256)
8665
+ throw new Error("Project session is no longer an authorized candidate.");
8666
+ const removed = authority.removeManaged(
8667
+ relativePath,
8668
+ captured.identity.dev,
8669
+ captured.identity.ino,
8670
+ captured.identity.size,
8671
+ captured.identity.mtimeNs,
8672
+ captured.identity.ctimeNs,
8673
+ captured.identity.sha256,
8674
+ );
8675
+ if (!removed.ok) throw new Error(removed.code ?? "Could not delete project session.");
8676
+ return;
8677
+ } finally {
8678
+ authority.close();
8679
+ }
8680
+ }
7368
8681
  const sessionsRoot = path.resolve(sessionPath, "../..");
7369
8682
  const resolved = resolveManagedScope({
7370
8683
  cwd: header.cwd,
@@ -7377,7 +8690,8 @@ export class SessionManager {
7377
8690
  const candidate = listing.owned.find(item => path.resolve(item.path) === path.resolve(sessionPath));
7378
8691
  if (!candidate) throw new Error("Session is not an authorized managed candidate.");
7379
8692
  const deleted = await deleteManagedSessionCandidate(resolved.scope, candidate);
7380
- if (deleted.kind === "error") throw new Error(`Could not delete managed session: ${deleted.message}`);
8693
+ if (deleted.kind !== "deleted" && deleted.kind !== "already_deleted")
8694
+ throw new Error(`Could not delete managed session: ${deleted.message}`);
7381
8695
  }
7382
8696
 
7383
8697
  /** Capture exact source content for a strict fork without granting write ownership. */
@@ -7437,16 +8751,22 @@ export class SessionManager {
7437
8751
  }
7438
8752
 
7439
8753
  const dir = destination.directory;
7440
- const removeSessionDirOnFailure =
8754
+ const privateStagingDir =
7441
8755
  destination.kind !== "managed" &&
7442
8756
  snapshot.storage instanceof FileSessionStorage &&
7443
- !snapshot.storage.existsSync(dir);
8757
+ !snapshot.storage.existsSync(dir)
8758
+ ? fs.mkdtempSync(path.join(path.dirname(dir), `.${path.basename(dir)}.fork-staging-`))
8759
+ : undefined;
8760
+ const forkDestination = privateStagingDir ? explicitDestination(privateStagingDir) : destination;
7444
8761
  let managedForkStore: ManagedSessionDescendantStore | undefined;
7445
8762
  let managedForkTranscript: ManagedFileSnapshot | null = null;
7446
8763
  let manager: SessionManager | undefined;
7447
8764
  let authorityFailure: StrictSessionOpenFailure | undefined;
8765
+ let privateStagingSnapshot: native.NativeDirectoryTreeSnapshot | undefined;
8766
+ let privateStagingPublished = false;
8767
+
7448
8768
  try {
7449
- manager = new SessionManager(cwd, dir, true, snapshot.storage, destination);
8769
+ manager = new SessionManager(cwd, privateStagingDir ?? dir, true, snapshot.storage, forkDestination);
7450
8770
  await resolveBlobRefsInEntries(forkEntries, manager.#blobStore);
7451
8771
  manager.#fileEntries = forkEntries;
7452
8772
  const sourceHeader = manager.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
@@ -7476,6 +8796,13 @@ export class SessionManager {
7476
8796
  throw new Error("Captured fork source authority changed before destination write.");
7477
8797
  }
7478
8798
  await manager.#rewriteFile();
8799
+ if (privateStagingDir) {
8800
+ const capturedStaging = native.snapshotDirectoryTree(privateStagingDir);
8801
+ if (!capturedStaging.ok || !capturedStaging.snapshot)
8802
+ throw new Error(capturedStaging.code ?? "fork_staging_snapshot_failed");
8803
+ privateStagingSnapshot = capturedStaging.snapshot;
8804
+ }
8805
+
7479
8806
  if (destination.kind === "managed" && manager.#sessionFile) {
7480
8807
  managedForkStore = manager.#managedTranscriptStore();
7481
8808
  managedForkTranscript = managedForkStore.readExpected(path.basename(manager.#sessionFile));
@@ -7495,66 +8822,57 @@ export class SessionManager {
7495
8822
  if (!managedFileSnapshotEquals(terminalTranscript, managedForkTranscript))
7496
8823
  throw new Error("managed_fork_transcript_changed");
7497
8824
  }
8825
+ if (privateStagingDir && manager.#sessionFile) {
8826
+ const materializedEntries = materializeResidentEntriesForReadSync(
8827
+ manager.#fileEntries,
8828
+ manager.#residentBlobStores(),
8829
+ );
8830
+ manager.#fileEntries = materializedEntries;
8831
+ manager.#disposeResidentTextBlobStore();
8832
+ const capturedStaging = native.snapshotDirectoryTree(privateStagingDir);
8833
+ if (!capturedStaging.ok || !capturedStaging.snapshot)
8834
+ throw new Error(capturedStaging.code ?? "fork_staging_snapshot_failed");
8835
+ privateStagingSnapshot = capturedStaging.snapshot;
8836
+ fsyncManagedArtifactTree(privateStagingDir);
8837
+ const outcome = classifyNativePublishOutcome(native.renameNoReplacePath(privateStagingDir, dir));
8838
+ if (!outcome.ok) throw new Error(outcome.code ?? "fork_destination_publish_failed");
8839
+ privateStagingPublished = true;
8840
+ const terminal = native.snapshotDirectoryTree(dir);
8841
+ if (
8842
+ !terminal.ok ||
8843
+ !terminal.snapshot ||
8844
+ !retainedTreeSnapshotEqualsAfterRename(terminal.snapshot, privateStagingSnapshot)
8845
+ )
8846
+ throw new Error("fork_destination_terminal_identity_changed");
8847
+ await syncSessionMoveDirectory(path.dirname(dir));
8848
+ const durableTerminal = native.snapshotDirectoryTree(dir);
8849
+ if (
8850
+ !durableTerminal.ok ||
8851
+ !durableTerminal.snapshot ||
8852
+ !retainedTreeSnapshotEqualsAfterRename(durableTerminal.snapshot, privateStagingSnapshot)
8853
+ )
8854
+ throw new Error("fork_destination_durability_identity_changed");
8855
+ manager.sessionDir = dir;
8856
+ manager.destination = destination;
8857
+ manager.#sessionFile = path.join(dir, path.basename(manager.#sessionFile));
8858
+ manager.#resetResidentTextBlobStore();
8859
+ manager.#reexternalizeFileEntriesForResidentStore();
8860
+ manager.#bumpAllRevisions();
8861
+ }
7498
8862
  if (manager.#sessionFile) writeTerminalBreadcrumb(manager.cwd, manager.#sessionFile);
7499
8863
  return { kind: "forked", manager };
7500
8864
  } catch (error) {
7501
8865
  if (manager) {
7502
- const sessionFile = manager.#sessionFile;
7503
8866
  const cleanupErrors: Error[] = [];
7504
8867
  try {
7505
8868
  await manager.close();
7506
8869
  } catch (cleanupError) {
7507
- cleanupErrors.push(toError(cleanupError));
7508
- }
7509
- if (sessionFile) {
7510
- if (destination.kind === "managed") {
7511
- try {
7512
- if (managedForkStore && managedForkTranscript)
7513
- managedForkStore.removeExpected(path.basename(sessionFile), managedForkTranscript);
7514
- } catch (cleanupError) {
7515
- cleanupErrors.push(toError(cleanupError));
7516
- }
7517
- } else {
7518
- try {
7519
- await manager.storage.unlink(sessionFile);
7520
- } catch (cleanupError) {
7521
- if (!isEnoent(cleanupError)) cleanupErrors.push(toError(cleanupError));
7522
- }
7523
- if (manager.storage instanceof FileSessionStorage) {
7524
- try {
7525
- await fs.promises.rm(sessionFile.slice(0, -6), { recursive: true, force: true });
7526
- } catch (cleanupError) {
7527
- cleanupErrors.push(toError(cleanupError));
7528
- }
7529
- try {
7530
- const sessionFileName = path.basename(sessionFile);
7531
- const transientPrefix = `.${sessionFileName}.`;
7532
- const backupPrefix = `${sessionFileName}.`;
7533
- for (const name of await fs.promises.readdir(path.dirname(sessionFile))) {
7534
- if (
7535
- (name.startsWith(transientPrefix) && name.endsWith(".tmp")) ||
7536
- (name.startsWith(backupPrefix) && name.endsWith(".bak"))
7537
- ) {
7538
- await fs.promises.rm(path.join(path.dirname(sessionFile), name), {
7539
- recursive: true,
7540
- force: true,
7541
- });
7542
- }
7543
- }
7544
- } catch (cleanupError) {
7545
- if (!isEnoent(cleanupError)) cleanupErrors.push(toError(cleanupError));
7546
- }
7547
- }
7548
- }
8870
+ if (toError(cleanupError).message !== "cleanup_pending") cleanupErrors.push(toError(cleanupError));
7549
8871
  }
7550
- if (removeSessionDirOnFailure) {
7551
- try {
7552
- await fs.promises.rmdir(dir);
7553
- } catch (cleanupError) {
7554
- const code = (cleanupError as NodeJS.ErrnoException).code;
7555
- if (code !== "ENOENT" && code !== "ENOTEMPTY" && code !== "EEXIST")
7556
- cleanupErrors.push(toError(cleanupError));
7557
- }
8872
+ if (!privateStagingPublished && privateStagingDir && privateStagingSnapshot) {
8873
+ const removed = native.exactRemoveDirectoryTree(privateStagingDir, privateStagingSnapshot);
8874
+ if (!removed.ok && removed.code !== "not_found" && removed.code !== "cleanup_pending")
8875
+ cleanupErrors.push(new Error(removed.code ?? "fork_staging_cleanup_failed"));
7558
8876
  }
7559
8877
  if (cleanupErrors.length > 0) {
7560
8878
  throw new Error(`Failed to clean up fork destination: ${cleanupErrors[0]!.message}`, {
@@ -7567,6 +8885,194 @@ export class SessionManager {
7567
8885
  }
7568
8886
  }
7569
8887
 
8888
+ static async restoreMemoryGuardCheckpoint(input: MemoryGuardRestoreInput): Promise<MemoryGuardRestoreResult> {
8889
+ try {
8890
+ assertMemoryGuardSessionId(input.checkpoint.session_id);
8891
+ } catch {
8892
+ return { kind: "blocked", reason: "checkpoint-mismatch" };
8893
+ }
8894
+ if (!validateMemoryGuardCheckpoint(input.checkpoint)) return { kind: "blocked", reason: "checkpoint-mismatch" };
8895
+ if (!memoryGuardParticipantMatchesCheckpoint(input.participant, input.checkpoint)) {
8896
+ return { kind: "blocked", reason: "participant-mismatch" };
8897
+ }
8898
+ const checkpointText = readCheckpointAuthorityFile(
8899
+ input.incidentAuthority,
8900
+ memoryGuardParticipantRelativePath(input.checkpoint.session_id, "session-manager.json"),
8901
+ 256 * 1024,
8902
+ );
8903
+ if (!checkpointText) return { kind: "blocked", reason: "checkpoint-mismatch" };
8904
+ const checkpointCanonical = memoryGuardCanonicalJson(input.checkpoint);
8905
+ if (decodeCheckpointUtf8(checkpointText) !== checkpointCanonical) {
8906
+ return { kind: "blocked", reason: "checkpoint-mismatch" };
8907
+ }
8908
+ const transcriptBytes = Number(input.checkpoint.transcript.bytes);
8909
+ if (!Number.isSafeInteger(transcriptBytes) || transcriptBytes < 0) {
8910
+ return { kind: "blocked", reason: "transcript-mismatch" };
8911
+ }
8912
+ const transcriptData = readCheckpointAuthorityFile(
8913
+ input.incidentAuthority,
8914
+ input.checkpoint.transcript.relative_path,
8915
+ transcriptBytes + 1,
8916
+ );
8917
+ if (!transcriptData || transcriptData.byteLength !== transcriptBytes) {
8918
+ return { kind: "blocked", reason: "transcript-mismatch" };
8919
+ }
8920
+ if (memoryGuardSha256Hex(transcriptData) !== input.checkpoint.transcript.sha256) {
8921
+ return { kind: "blocked", reason: "transcript-mismatch" };
8922
+ }
8923
+ const transcriptText = decodeCheckpointUtf8(transcriptData);
8924
+ if (transcriptText === null) return { kind: "blocked", reason: "transcript-mismatch" };
8925
+ let transcriptEntries: FileEntry[];
8926
+ try {
8927
+ transcriptEntries = parseSessionEntries(transcriptText);
8928
+ } catch {
8929
+ return { kind: "blocked", reason: "malformed" };
8930
+ }
8931
+ const transcriptHeader = transcriptEntries[0] as SessionHeader | undefined;
8932
+ if (
8933
+ transcriptHeader?.type !== "session" ||
8934
+ transcriptHeader.id !== input.checkpoint.session_id ||
8935
+ (transcriptHeader.title ?? null) !== input.checkpoint.session_name
8936
+ ) {
8937
+ return { kind: "blocked", reason: "transcript-mismatch" };
8938
+ }
8939
+ const manifestData = readCheckpointAuthorityFile(
8940
+ input.incidentAuthority,
8941
+ input.checkpoint.blob_authority.manifest_relative_path,
8942
+ 8 * 1024 * 1024,
8943
+ );
8944
+ if (!manifestData) return { kind: "blocked", reason: "blob-authority-mismatch" };
8945
+ if (memoryGuardSha256Hex(manifestData) !== input.checkpoint.blob_authority.manifest_sha256) {
8946
+ return { kind: "blocked", reason: "blob-authority-mismatch" };
8947
+ }
8948
+ const manifestText = decodeCheckpointUtf8(manifestData);
8949
+ if (manifestText === null) return { kind: "blocked", reason: "blob-manifest-mismatch" };
8950
+ let blobManifest: unknown;
8951
+ try {
8952
+ blobManifest = JSON.parse(manifestText);
8953
+ } catch {
8954
+ return { kind: "blocked", reason: "blob-manifest-mismatch" };
8955
+ }
8956
+ if (!validateMemoryGuardBlobManifest(blobManifest)) return { kind: "blocked", reason: "blob-manifest-mismatch" };
8957
+ if (memoryGuardCanonicalJson(blobManifest) !== manifestText)
8958
+ return { kind: "blocked", reason: "blob-manifest-mismatch" };
8959
+ if (blobManifest.entries.length > MEMORY_GUARD_CHECKPOINT_BLOB_MAX_ENTRIES)
8960
+ return { kind: "blocked", reason: "blob-manifest-mismatch" };
8961
+ let checkpointBlobBytes = 0;
8962
+ const seenBlobPaths = new Set<string>();
8963
+ const checkpointBlobs = new Map<string, Buffer>();
8964
+ for (const entry of blobManifest.entries) {
8965
+ if (
8966
+ entry.relative_path !== entry.sha256 ||
8967
+ seenBlobPaths.has(entry.relative_path) ||
8968
+ checkpointBlobs.has(entry.sha256)
8969
+ )
8970
+ return { kind: "blocked", reason: "blob-manifest-mismatch" };
8971
+ seenBlobPaths.add(entry.relative_path);
8972
+ const blobBytes = Number(entry.bytes);
8973
+ if (!Number.isSafeInteger(blobBytes) || blobBytes < 0)
8974
+ return { kind: "blocked", reason: "blob-manifest-mismatch" };
8975
+ checkpointBlobBytes += blobBytes;
8976
+ if (checkpointBlobBytes > MEMORY_GUARD_CHECKPOINT_BLOB_TOTAL_MAX_BYTES)
8977
+ return { kind: "blocked", reason: "blob-manifest-mismatch" };
8978
+ const blobPath = `${input.checkpoint.blob_authority.root_relative_path}/${entry.relative_path}`;
8979
+ const blobData = readCheckpointAuthorityFile(input.incidentAuthority, blobPath, blobBytes + 1);
8980
+ if (!blobData || blobData.byteLength !== blobBytes) return { kind: "blocked", reason: "blob-missing" };
8981
+ if (memoryGuardSha256Hex(blobData) !== entry.sha256) return { kind: "blocked", reason: "blob-hash-mismatch" };
8982
+ checkpointBlobs.set(entry.sha256, Buffer.from(blobData));
8983
+ }
8984
+ for (const ref of collectCheckpointBlobRefs(transcriptEntries)) {
8985
+ const hash = parseBlobRef(ref);
8986
+ if (!hash || !checkpointBlobs.has(hash)) return { kind: "blocked", reason: "blob-manifest-mismatch" };
8987
+ }
8988
+ const storage = new FileSessionStorage();
8989
+ let destination: SessionDestination;
8990
+ try {
8991
+ destination =
8992
+ input.destination === undefined
8993
+ ? explicitDestination(path.join(os.tmpdir(), `skc-memory-guard-${input.checkpoint.session_id}`))
8994
+ : destinationFor(getProjectDir(), input.destination, storage);
8995
+ } catch {
8996
+ return { kind: "blocked", reason: "destination-unavailable" };
8997
+ }
8998
+ const transcriptPath = path.join(
8999
+ destination.directory,
9000
+ `.${input.checkpoint.session_id}.memory-guard.${crypto.randomUUID()}.jsonl`,
9001
+ );
9002
+ const transcriptName = path.basename(transcriptPath);
9003
+ const managedStagingStore =
9004
+ destination.kind === "managed"
9005
+ ? managedStoreFromContext(destination.securityContext, destination.directory)
9006
+ : undefined;
9007
+ const cleanupTranscript = async (): Promise<void> => {
9008
+ if (managedStagingStore) {
9009
+ await managedStagingStore.remove(transcriptName);
9010
+ return;
9011
+ }
9012
+ await fs.promises.rm(transcriptPath, { force: true });
9013
+ };
9014
+ let opened: RecoveryHydrationOpenResult;
9015
+ let transcriptIdentity: ResumeSessionIdentity;
9016
+ let createdDestination: CreatedDirectoryIdentity | undefined;
9017
+ try {
9018
+ if (managedStagingStore) await managedStagingStore.publishNoReplace(transcriptName, transcriptData);
9019
+ else {
9020
+ createdDestination = await ensureOwnerOnlyDirectoryTracked(destination.directory);
9021
+ await writeOwnerOnlyFileNoReplace(transcriptPath, transcriptData);
9022
+ }
9023
+ const captured = SessionManager.captureTranscriptStrict(transcriptPath, storage);
9024
+ if (captured.kind !== "captured") {
9025
+ await cleanupTranscript();
9026
+ await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
9027
+ return { kind: "blocked", reason: captured.reason };
9028
+ }
9029
+ transcriptIdentity = captured.snapshot.identity;
9030
+ opened = await SessionManager.openExistingForRecoveryHydrationStrict(
9031
+ transcriptIdentity,
9032
+ input.destination,
9033
+ storage,
9034
+ );
9035
+ } catch {
9036
+ await cleanupTranscript();
9037
+ await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
9038
+ return { kind: "blocked", reason: "destination-unavailable" };
9039
+ }
9040
+ if (opened.kind === "error") {
9041
+ await cleanupTranscript();
9042
+ await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
9043
+ return { kind: "blocked", reason: opened.reason };
9044
+ }
9045
+ if (
9046
+ opened.manager.getSessionId() !== input.checkpoint.session_id ||
9047
+ (opened.manager.getSessionName() ?? null) !== input.checkpoint.session_name
9048
+ ) {
9049
+ await opened.manager.close();
9050
+ await cleanupTranscript();
9051
+ await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
9052
+ return { kind: "blocked", reason: "transcript-mismatch" };
9053
+ }
9054
+ opened.manager.#stageMemoryGuardCheckpointBlobs(checkpointBlobs);
9055
+ opened.manager.#recoveryPromotionTranscriptPath = path.join(
9056
+ destination.directory,
9057
+ `${new Date().toISOString().replace(/[:.]/g, "-")}_${input.checkpoint.session_id}.jsonl`,
9058
+ );
9059
+ let cleanupConsumed = false;
9060
+ const cleanup = async (): Promise<void> => {
9061
+ if (cleanupConsumed) return;
9062
+ await opened.manager.close();
9063
+ await cleanupTranscript();
9064
+ await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
9065
+ cleanupConsumed = true;
9066
+ };
9067
+ return {
9068
+ kind: "staged",
9069
+ manager: opened.manager,
9070
+ hydrationContext: opened.context,
9071
+ transcriptIdentity,
9072
+ cleanup,
9073
+ };
9074
+ }
9075
+
7570
9076
  /** Inspect a selected session without acquiring write-capable ownership. */
7571
9077
  static async inspectSessionTailReadOnly(
7572
9078
  filePath: string,
@@ -7635,8 +9141,31 @@ export class SessionManager {
7635
9141
  if ("kind" in inspected || !sameResumeIdentity(context.identity, inspected.identity)) {
7636
9142
  throw new Error("Recovery transcript authority changed before promotion.");
7637
9143
  }
9144
+ if (this.#memoryGuardCheckpointBlobs) {
9145
+ for (const blob of this.#memoryGuardCheckpointBlobs.values()) this.#blobStore.putImmutableSync(blob);
9146
+ this.#residentImageBlobStore = this.#blobStore;
9147
+ this.#memoryGuardCheckpointBlobs = undefined;
9148
+ }
7638
9149
  await this.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
7639
- writeTerminalBreadcrumb(this.cwd, context.identity.canonicalPath);
9150
+ const promotionPath = this.#recoveryPromotionTranscriptPath;
9151
+ if (!promotionPath) throw new Error("Recovery transcript promotion path is unavailable.");
9152
+ const promotedSource = inspectResumeSessionFile(context.identity.canonicalPath, this.storage);
9153
+ if ("kind" in promotedSource) throw new Error("Recovery transcript became unavailable before publication.");
9154
+ if (this.destination.kind === "managed") {
9155
+ const store = this.#managedTranscriptStore();
9156
+ const sourceName = path.basename(context.identity.canonicalPath);
9157
+ const sourceSnapshot = store.readExpected(sourceName);
9158
+ if (!sourceSnapshot) throw new Error("Recovery transcript authority changed before publication.");
9159
+ await store.publishNoReplace(path.basename(promotionPath), promotedSource.content);
9160
+ store.removeExpected(sourceName, sourceSnapshot);
9161
+ } else {
9162
+ await writeOwnerOnlyFileNoReplace(promotionPath, promotedSource.content);
9163
+ await fs.promises.rm(context.identity.canonicalPath, { force: true });
9164
+ await fsyncDirectoryPath(path.dirname(context.identity.canonicalPath));
9165
+ }
9166
+ this.#sessionFile = promotionPath;
9167
+ this.#recoveryPromotionTranscriptPath = undefined;
9168
+ writeTerminalBreadcrumb(this.cwd, promotionPath);
7640
9169
  this.#recoveryHydrationContext = undefined;
7641
9170
  }
7642
9171
 
@@ -7658,36 +9187,41 @@ export class SessionManager {
7658
9187
  let sessionPath = identity.canonicalPath;
7659
9188
  if (destination.kind === "managed" && storage instanceof FileSessionStorage) {
7660
9189
  try {
7661
- sessionPath = await SessionManager.prepareManagedCandidateForWrite(sessionPath, migrationPolicy);
9190
+ sessionPath = await SessionManager.prepareManagedCandidateForWrite(
9191
+ sessionPath,
9192
+ migrationPolicy,
9193
+ destination,
9194
+ identity,
9195
+ );
7662
9196
  } catch (error) {
7663
9197
  if (error instanceof SessionMigrationPolicyError)
7664
9198
  return { kind: "error", reason: "legacy_migration_disabled" };
9199
+ if (error instanceof SessionArtifactCapacityError)
9200
+ return { kind: "error", reason: "artifact_capacity_exceeded", message: error.message };
9201
+ if (
9202
+ error instanceof Error &&
9203
+ (error.message.includes("source_changed") || error.message.includes("changed before migration"))
9204
+ )
9205
+ return { kind: "error", reason: "identity-mismatch" };
7665
9206
  throw error;
7666
9207
  }
7667
9208
  }
7668
9209
  const inspected = inspectResumeSessionFile(sessionPath, storage);
7669
9210
  if ("kind" in inspected) return inspected;
7670
- if (
7671
- (sessionPath === identity.canonicalPath && !sameResumeIdentity(identity, inspected.identity)) ||
7672
- (sessionPath !== identity.canonicalPath &&
7673
- (identity.sessionId !== inspected.identity.sessionId || identity.sha256 !== inspected.identity.sha256))
7674
- ) {
9211
+ if (sessionPath === identity.canonicalPath && !sameResumeIdentity(identity, inspected.identity)) {
7675
9212
  return { kind: "error", reason: "identity-mismatch" };
7676
9213
  }
9214
+
7677
9215
  const entries = inspected.entries;
7678
9216
  const header = entries[0] as SessionHeader;
7679
9217
  const dir = destination.directory;
7680
9218
  const manager = new SessionManager(header.cwd || getProjectDir(), dir, true, storage, destination);
7681
9219
  await manager.#hydrateExistingSession(sessionPath, entries, inspected.migrationApplied);
7682
- const ownershipInspection = inspectResumeSessionFile(sessionPath, storage);
7683
- if ("kind" in ownershipInspection) {
9220
+ const ownershipInspection = revalidateResumeSessionIdentity(sessionPath, storage, inspected.identity);
9221
+ if (ownershipInspection.kind === "error") {
7684
9222
  await manager.close();
7685
9223
  return ownershipInspection;
7686
9224
  }
7687
- if (!sameResumeIdentity(inspected.identity, ownershipInspection.identity)) {
7688
- await manager.close();
7689
- return { kind: "error", reason: "identity-mismatch" };
7690
- }
7691
9225
  try {
7692
9226
  await manager.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
7693
9227
  writeTerminalBreadcrumb(manager.cwd, sessionPath);
@@ -7722,6 +9256,10 @@ export class SessionManager {
7722
9256
  );
7723
9257
  if (opened.kind === "error") {
7724
9258
  if (opened.reason === "legacy_migration_disabled") throw new SessionMigrationPolicyError();
9259
+ if (opened.reason === "artifact_capacity_exceeded")
9260
+ throw new SessionArtifactCapacityError(
9261
+ opened.message ?? "Session artifacts exceed the migration capacity.",
9262
+ );
7725
9263
  return undefined;
7726
9264
  }
7727
9265
  return opened.manager;
@@ -7731,9 +9269,20 @@ export class SessionManager {
7731
9269
  const managedCandidates =
7732
9270
  destination.kind === "explicit"
7733
9271
  ? []
7734
- : await SessionManager.listForResumePickerReadOnly(cwd, destination.directory, storage);
9272
+ : await SessionManager.listManagedForResumePickerReadOnly(
9273
+ cwd,
9274
+ destination.securityContext.agentDir,
9275
+ storage,
9276
+ );
9277
+
7735
9278
  const terminalSession = await readTerminalBreadcrumb(cwd);
7736
- if (terminalSession) {
9279
+ const terminalSessionIsInExplicitRoot = (() => {
9280
+ if (destination.kind === "managed" || !terminalSession) return destination.kind === "managed";
9281
+ const canonicalRoot = canonicalizeTrustedPath(destination.directory);
9282
+ const canonicalSessionPath = canonicalizeTrustedPath(terminalSession);
9283
+ return pathIsWithin(canonicalRoot, canonicalSessionPath);
9284
+ })();
9285
+ if (terminalSession && terminalSessionIsInExplicitRoot) {
7737
9286
  const opened = await openSelectedStrictly(terminalSession);
7738
9287
  if (opened) return opened;
7739
9288
  }
@@ -7771,7 +9320,7 @@ export class SessionManager {
7771
9320
  sessionDir?: string,
7772
9321
  storage: SessionStorage = new FileSessionStorage(),
7773
9322
  ): Promise<SessionInfo[]> {
7774
- if (!sessionDir) return await SessionManager.listForResumePickerReadOnly(cwd, undefined, storage);
9323
+ if (!sessionDir) return await SessionManager.listManagedForResumePickerReadOnly(cwd, undefined, storage);
7775
9324
  try {
7776
9325
  await recoverOrphanedBackups(sessionDir, storage);
7777
9326
  return await collectSessionsFromFiles(storage.listFilesSync(sessionDir, "*.jsonl"), storage);
@@ -7783,9 +9332,12 @@ export class SessionManager {
7783
9332
  /**
7784
9333
  * List all sessions across all project directories.
7785
9334
  */
7786
- static async listAll(storage: SessionStorage = new FileSessionStorage()): Promise<SessionInfo[]> {
9335
+ static async listAll(
9336
+ storage: SessionStorage = new FileSessionStorage(),
9337
+ managedAgentDir?: string,
9338
+ ): Promise<SessionInfo[]> {
7787
9339
  if (!(storage instanceof FileSessionStorage)) return [];
7788
- const sessionsRoot = path.join(getDefaultAgentDir(), "sessions");
9340
+ const sessionsRoot = getSessionsDir(managedAgentDir);
7789
9341
  try {
7790
9342
  const directories = await fs.promises.readdir(sessionsRoot, { withFileTypes: true });
7791
9343
  const seedFiles = directories
@@ -7821,6 +9373,19 @@ export class SessionManager {
7821
9373
  return [];
7822
9374
  }
7823
9375
  }
9376
+ /**
9377
+ * Strict inventory bound to this manager's captured session authority. Managed
9378
+ * managers include authorized legacy and current candidates; explicit managers
9379
+ * authorize only their exact directory.
9380
+ */
9381
+ inventorySessionsStrict(): StrictInventoryResult {
9382
+ return SessionManager.inventorySessionsStrict(this.cwd, {
9383
+ sessionDir: this.destination.kind === "explicit" ? this.destination.directory : undefined,
9384
+ storage: this.storage,
9385
+ destination: this.destination,
9386
+ });
9387
+ }
9388
+
7824
9389
  /**
7825
9390
  * Strict raw scoped inventory for ACP authorization. Enumerates the scoped
7826
9391
  * session directory without suppressing any root/scan/lstat/read/parse/stat/
@@ -7830,10 +9395,55 @@ export class SessionManager {
7830
9395
  */
7831
9396
  static inventorySessionsStrict(
7832
9397
  cwd: string,
7833
- options?: { sessionDir?: string; storage?: SessionStorage },
9398
+ options?: { sessionDir?: string; storage?: SessionStorage; destination?: SessionDestination },
7834
9399
  ): StrictInventoryResult {
7835
9400
  const storage = options?.storage ?? new FileSessionStorage();
7836
- const dir = options?.sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
9401
+ const destination = options?.destination;
9402
+ const dir =
9403
+ options?.sessionDir ?? destination?.directory ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
9404
+ if (destination?.kind === "managed") {
9405
+ if (!trustedSessionDestinations.has(destination) || !(storage instanceof FileSessionStorage)) {
9406
+ return {
9407
+ kind: "failure",
9408
+ failures: [{ kind: "scan", message: "Managed strict inventory authority is unavailable", path: dir }],
9409
+ };
9410
+ }
9411
+ try {
9412
+ const store = managedStoreFromContext(destination.securityContext, destination.directory);
9413
+ assertManagedDirectoryRoot(destination.securityContext.rootAuthority);
9414
+ store.assertBound();
9415
+ const resolved = resolveManagedScope({
9416
+ cwd,
9417
+ agentDir: destination.securityContext.agentDir,
9418
+ sessionsRoot: destination.securityContext.sessionsRoot,
9419
+ });
9420
+ if (resolved.kind === "error")
9421
+ return { kind: "failure", failures: [{ kind: "root", message: resolved.message, path: dir }] };
9422
+ const listing = listManagedCandidates(resolved.scope);
9423
+ if (listing.kind === "error")
9424
+ return { kind: "failure", failures: [{ kind: "scan", message: listing.message, path: dir }] };
9425
+ const failures: StrictInventoryFailure[] = [];
9426
+ const candidates: StrictInventoryCandidate[] = [];
9427
+ for (const managedCandidate of listing.owned) {
9428
+ const candidate = inventoryReadCandidate(
9429
+ storage,
9430
+ managedCandidate.path,
9431
+ cwd,
9432
+ path.dirname(managedCandidate.path),
9433
+ );
9434
+ if ("failures" in candidate) failures.push(...candidate.failures);
9435
+ else candidates.push(candidate.candidate);
9436
+ }
9437
+ assertManagedDirectoryRoot(destination.securityContext.rootAuthority);
9438
+ store.assertBound();
9439
+ return failures.length > 0 ? { kind: "failure", failures } : { kind: "complete", candidates };
9440
+ } catch {
9441
+ return {
9442
+ kind: "failure",
9443
+ failures: [{ kind: "root", message: "Managed strict inventory authority changed", path: dir }],
9444
+ };
9445
+ }
9446
+ }
7837
9447
  if (!storage.listFilesStrictSync) {
7838
9448
  return {
7839
9449
  kind: "failure",