@gajae-code/coding-agent 0.11.11 → 0.12.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 (344) hide show
  1. package/CHANGELOG.md +131 -4
  2. package/dist/types/async/job-manager.d.ts +17 -3
  3. package/dist/types/cli/read-cli.d.ts +1 -0
  4. package/dist/types/commands/deep-interview.d.ts +9 -0
  5. package/dist/types/commands/harness.d.ts +22 -0
  6. package/dist/types/commands/read.d.ts +6 -0
  7. package/dist/types/config/model-registry.d.ts +4 -0
  8. package/dist/types/config/provider-auth-health.d.ts +14 -0
  9. package/dist/types/config/provider-ranking.d.ts +59 -0
  10. package/dist/types/config/settings-schema.d.ts +83 -1
  11. package/dist/types/config/settings.d.ts +2 -0
  12. package/dist/types/exa/mcp-client.d.ts +10 -1
  13. package/dist/types/exec/bash-executor.d.ts +45 -12
  14. package/dist/types/extensibility/extensions/types.d.ts +38 -4
  15. package/dist/types/extensibility/gjc-plugins/index.d.ts +12 -2
  16. package/dist/types/extensibility/gjc-plugins/installer.d.ts +63 -7
  17. package/dist/types/extensibility/gjc-plugins/lifecycle-reconciliation.d.ts +55 -0
  18. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +64 -0
  19. package/dist/types/extensibility/gjc-plugins/runtime-quarantine.d.ts +67 -0
  20. package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +3 -1
  21. package/dist/types/extensibility/gjc-plugins/types.d.ts +124 -0
  22. package/dist/types/extensibility/shared-events.d.ts +6 -0
  23. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +5 -12
  24. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +17 -32
  25. package/dist/types/gjc-runtime/deep-interview-stage.d.ts +37 -0
  26. package/dist/types/gjc-runtime/deep-interview-state.d.ts +4 -135
  27. package/dist/types/gjc-runtime/gc-runtime.d.ts +1 -1
  28. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  29. package/dist/types/gjc-runtime/memory-guard-owner-claims.d.ts +47 -0
  30. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +70 -1
  31. package/dist/types/gjc-runtime/state-writer.d.ts +5 -46
  32. package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
  33. package/dist/types/gjc-runtime/team-runtime.d.ts +27 -1
  34. package/dist/types/gjc-runtime/team-store.d.ts +12 -0
  35. package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +43 -0
  36. package/dist/types/gjc-runtime/team-worker-memory-guard.d.ts +92 -0
  37. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +17 -0
  38. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +8 -0
  39. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +31 -80
  40. package/dist/types/gjc-runtime/workflow-manifest.d.ts +3 -5
  41. package/dist/types/internal-urls/local-root-gc.d.ts +2 -0
  42. package/dist/types/main.d.ts +1 -1
  43. package/dist/types/memories/index.d.ts +2 -0
  44. package/dist/types/modes/acp/acp-agent.d.ts +10 -19
  45. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  46. package/dist/types/modes/components/gjc-bundle-settings.d.ts +34 -0
  47. package/dist/types/modes/components/irc-sidebar.d.ts +41 -0
  48. package/dist/types/modes/components/notifications-settings-editor.d.ts +1 -0
  49. package/dist/types/modes/components/read-tool-group.d.ts +4 -4
  50. package/dist/types/modes/components/session-observer-overlay.d.ts +22 -0
  51. package/dist/types/modes/components/settings-selector.d.ts +11 -0
  52. package/dist/types/modes/components/status-line/gh.d.ts +5 -0
  53. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  54. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +18 -1
  55. package/dist/types/modes/controllers/event-controller.d.ts +3 -0
  56. package/dist/types/modes/interactive-mode.d.ts +2 -0
  57. package/dist/types/modes/irc-observation-ledger.d.ts +17 -1
  58. package/dist/types/modes/types.d.ts +2 -0
  59. package/dist/types/modes/utils/ui-helpers.d.ts +16 -0
  60. package/dist/types/reminders/star-reminder.d.ts +3 -13
  61. package/dist/types/runtime-mcp/manager.d.ts +1 -0
  62. package/dist/types/runtime-mcp/smithery-auth.d.ts +5 -0
  63. package/dist/types/sdk/acp/adapter.d.ts +3 -1
  64. package/dist/types/sdk/broker/lifecycle.d.ts +5 -0
  65. package/dist/types/sdk/broker/transport.d.ts +2 -0
  66. package/dist/types/sdk/bus/attachment-registry.d.ts +1 -0
  67. package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -1
  68. package/dist/types/sdk/bus/config.d.ts +6 -2
  69. package/dist/types/sdk/bus/control-drain-lease.d.ts +52 -0
  70. package/dist/types/sdk/bus/index.d.ts +44 -1
  71. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +44 -0
  72. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
  73. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -1
  74. package/dist/types/sdk/bus/reconciliation-store.d.ts +69 -0
  75. package/dist/types/sdk/bus/rich-draft.d.ts +1 -0
  76. package/dist/types/sdk/bus/rich-render.d.ts +2 -0
  77. package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +165 -0
  78. package/dist/types/sdk/bus/telegram-cli.d.ts +12 -0
  79. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +11 -4
  80. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -1
  81. package/dist/types/sdk/bus/telegram-reference.d.ts +11 -1
  82. package/dist/types/sdk/bus/threaded-render.d.ts +3 -2
  83. package/dist/types/sdk/bus/topic-registry.d.ts +11 -1
  84. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  85. package/dist/types/sdk/host/host.d.ts +2 -0
  86. package/dist/types/sdk/host/query/handlers.d.ts +5 -0
  87. package/dist/types/sdk/host/query/revision-store.d.ts +2 -0
  88. package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
  89. package/dist/types/sdk/prompt-status.d.ts +23 -3
  90. package/dist/types/sdk/session.d.ts +6 -0
  91. package/dist/types/session/agent-session.d.ts +82 -12
  92. package/dist/types/session/artifacts.d.ts +8 -0
  93. package/dist/types/session/blob-store.d.ts +57 -4
  94. package/dist/types/session/internal/managed-session-storage.d.ts +8 -1
  95. package/dist/types/session/memory-guard-checkpoint-participant.d.ts +69 -0
  96. package/dist/types/session/session-manager.d.ts +48 -19
  97. package/dist/types/session/session-storage.d.ts +4 -0
  98. package/dist/types/session/streaming-output.d.ts +96 -12
  99. package/dist/types/setup/credential-import.d.ts +12 -0
  100. package/dist/types/skill-state/active-state.d.ts +12 -4
  101. package/dist/types/skill-state/workflow-hud.d.ts +9 -0
  102. package/dist/types/task/executor.d.ts +3 -2
  103. package/dist/types/task/index.d.ts +3 -1
  104. package/dist/types/task/provider-retry-status.d.ts +8 -0
  105. package/dist/types/task/receipt.d.ts +7 -1
  106. package/dist/types/task/types.d.ts +23 -0
  107. package/dist/types/tools/ask-answer-registry.d.ts +2 -2
  108. package/dist/types/tools/bash-interactive.d.ts +4 -1
  109. package/dist/types/tools/bash.d.ts +3 -1
  110. package/dist/types/tools/browser/launch.d.ts +7 -0
  111. package/dist/types/tools/fetch.d.ts +17 -1
  112. package/dist/types/tools/image-gen.d.ts +4 -0
  113. package/dist/types/tools/index.d.ts +4 -0
  114. package/dist/types/tools/output-meta.d.ts +44 -3
  115. package/dist/types/tools/path-utils.d.ts +1 -0
  116. package/dist/types/tools/read-internals.d.ts +12 -0
  117. package/dist/types/tools/read.d.ts +45 -7
  118. package/dist/types/tools/resource-gc.d.ts +4 -0
  119. package/dist/types/tools/review.d.ts +5 -7
  120. package/dist/types/tools/subagent.d.ts +2 -0
  121. package/dist/types/tools/tool-result.d.ts +7 -1
  122. package/dist/types/utils/gh.d.ts +13 -0
  123. package/dist/types/utils/image-resize.d.ts +1 -0
  124. package/dist/types/web/search/providers/kimi.d.ts +2 -0
  125. package/dist/types/web/search/providers/searxng.d.ts +7 -0
  126. package/dist/types/web/search/providers/xai.d.ts +2 -0
  127. package/package.json +11 -13
  128. package/scripts/acp-conformance-agent.ts +218 -0
  129. package/scripts/benchmark-sticky-viewport-pr1.ts +239 -0
  130. package/scripts/capture-gjc-bundle-settings.ts +306 -0
  131. package/scripts/capture-sticky-viewport-showcase.ts +292 -0
  132. package/scripts/generate-sdk-operation-inventory.ts +5 -0
  133. package/scripts/resident-memory-bench.ts +1341 -0
  134. package/scripts/run-acp-conformance.ts +285 -0
  135. package/scripts/verify-gjc-sdk-canonicalization.ts +122 -18
  136. package/scripts/verify-sticky-viewport-showcase.ts +611 -0
  137. package/src/async/job-manager.ts +78 -20
  138. package/src/capability/index.ts +8 -0
  139. package/src/cli/fast-help.ts +1 -1
  140. package/src/cli/plugin-cli.ts +176 -18
  141. package/src/cli/read-cli.ts +2 -1
  142. package/src/cli.ts +1 -0
  143. package/src/commands/deep-interview.ts +29 -9
  144. package/src/commands/harness.ts +35 -12
  145. package/src/commands/read.ts +11 -2
  146. package/src/commands/team.ts +20 -2
  147. package/src/config/file-lock.ts +11 -2
  148. package/src/config/model-registry.ts +21 -2
  149. package/src/config/provider-auth-health.ts +42 -0
  150. package/src/config/provider-ranking.ts +119 -0
  151. package/src/config/settings-schema.ts +74 -1
  152. package/src/config/settings.ts +246 -108
  153. package/src/coordinator-mcp/question-gate-codec.ts +1 -1
  154. package/src/coordinator-mcp/server.ts +5 -12
  155. package/src/defaults/gjc/skills/deep-interview/SKILL.md +230 -82
  156. package/src/defaults/gjc/skills/ralplan/SKILL.md +77 -41
  157. package/src/defaults/gjc/skills/team/SKILL.md +3 -1
  158. package/src/defaults/gjc/skills/ultragoal/SKILL.md +62 -39
  159. package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +12 -0
  160. package/src/defaults/gjc-defaults.ts +4 -4
  161. package/src/eval/py/executor.ts +7 -2
  162. package/src/exa/mcp-client.ts +12 -3
  163. package/src/exec/bash-executor.ts +144 -27
  164. package/src/extensibility/extensions/runner.ts +15 -0
  165. package/src/extensibility/extensions/types.ts +46 -5
  166. package/src/extensibility/gjc-plugins/constrained-hooks.ts +8 -2
  167. package/src/extensibility/gjc-plugins/index.ts +19 -2
  168. package/src/extensibility/gjc-plugins/installer.ts +189 -50
  169. package/src/extensibility/gjc-plugins/lifecycle-reconciliation.ts +199 -0
  170. package/src/extensibility/gjc-plugins/lifecycle.ts +626 -0
  171. package/src/extensibility/gjc-plugins/observability.ts +3 -3
  172. package/src/extensibility/gjc-plugins/prompt-appendix.ts +5 -1
  173. package/src/extensibility/gjc-plugins/runtime-adapters.ts +12 -2
  174. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +121 -0
  175. package/src/extensibility/gjc-plugins/schema.ts +84 -12
  176. package/src/extensibility/gjc-plugins/session-validation.ts +9 -3
  177. package/src/extensibility/gjc-plugins/types.ts +141 -0
  178. package/src/extensibility/gjc-plugins/validation.ts +9 -0
  179. package/src/extensibility/shared-events.ts +5 -0
  180. package/src/gjc-runtime/deep-interview-ambiguity.ts +47 -82
  181. package/src/gjc-runtime/deep-interview-recorder.ts +227 -310
  182. package/src/gjc-runtime/deep-interview-runtime.ts +109 -270
  183. package/src/gjc-runtime/deep-interview-stage.ts +1016 -0
  184. package/src/gjc-runtime/deep-interview-state.ts +12 -943
  185. package/src/gjc-runtime/gc-render.ts +1 -0
  186. package/src/gjc-runtime/gc-runtime.ts +11 -1
  187. package/src/gjc-runtime/launch-tmux.ts +25 -6
  188. package/src/gjc-runtime/managed-owner-admission.ts +1 -1
  189. package/src/gjc-runtime/memory-guard-owner-claims.ts +389 -0
  190. package/src/gjc-runtime/ralplan-runtime.ts +1149 -98
  191. package/src/gjc-runtime/state-runtime.ts +2 -1
  192. package/src/gjc-runtime/state-writer.ts +70 -222
  193. package/src/gjc-runtime/team-launch.ts +42 -5
  194. package/src/gjc-runtime/team-runtime.ts +1085 -40
  195. package/src/gjc-runtime/team-store.ts +32 -0
  196. package/src/gjc-runtime/team-worker-heartbeat.ts +149 -0
  197. package/src/gjc-runtime/team-worker-memory-guard.ts +363 -0
  198. package/src/gjc-runtime/team-workers.ts +1 -0
  199. package/src/gjc-runtime/tmux-owner-isolation.ts +28 -0
  200. package/src/gjc-runtime/tmux-sessions.ts +58 -13
  201. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +20 -0
  202. package/src/gjc-runtime/ultragoal-runtime.ts +414 -618
  203. package/src/gjc-runtime/workflow-manifest.generated.json +105 -449
  204. package/src/gjc-runtime/workflow-manifest.ts +38 -247
  205. package/src/hooks/native-skill-hook.ts +17 -4
  206. package/src/hooks/skill-state.ts +1 -23
  207. package/src/internal-urls/docs-index.generated.ts +21 -22
  208. package/src/internal-urls/local-root-gc.ts +127 -0
  209. package/src/main.ts +9 -5
  210. package/src/memories/index.ts +10 -0
  211. package/src/modes/DESIGN.md +98 -0
  212. package/src/modes/acp/acp-agent.ts +339 -191
  213. package/src/modes/acp/acp-event-mapper.ts +14 -7
  214. package/src/modes/acp/acp-mode.ts +39 -3
  215. package/src/modes/components/assistant-message.ts +123 -22
  216. package/src/modes/components/gjc-bundle-settings.ts +508 -0
  217. package/src/modes/components/irc-sidebar.ts +176 -62
  218. package/src/modes/components/model-selector.ts +61 -4
  219. package/src/modes/components/notifications-settings-editor.ts +12 -1
  220. package/src/modes/components/oauth-selector.ts +96 -28
  221. package/src/modes/components/read-tool-group.ts +82 -14
  222. package/src/modes/components/session-observer-overlay.ts +457 -83
  223. package/src/modes/components/settings-selector.ts +116 -23
  224. package/src/modes/components/status-line/gh.ts +16 -0
  225. package/src/modes/components/tool-execution.ts +138 -27
  226. package/src/modes/components/tool-status-header.ts +3 -14
  227. package/src/modes/components/transcript-viewer-overlay.ts +99 -24
  228. package/src/modes/controllers/command-controller.ts +4 -0
  229. package/src/modes/controllers/event-controller.ts +109 -27
  230. package/src/modes/controllers/extension-ui-controller.ts +26 -2
  231. package/src/modes/controllers/input-controller.ts +18 -3
  232. package/src/modes/controllers/selector-controller.ts +127 -36
  233. package/src/modes/interactive-mode.ts +62 -3
  234. package/src/modes/irc-observation-ledger.ts +71 -15
  235. package/src/modes/runtime-init.ts +22 -2
  236. package/src/modes/types.ts +2 -0
  237. package/src/modes/utils/ui-helpers.ts +94 -1
  238. package/src/prompts/agent-fragments/ralplan-persistence.md +4 -3
  239. package/src/prompts/agents/architect.md +9 -1
  240. package/src/prompts/agents/critic.md +9 -0
  241. package/src/prompts/system/eager-todo.md +8 -4
  242. package/src/prompts/system/system-prompt.md +11 -5
  243. package/src/prompts/tools/bash.md +5 -5
  244. package/src/prompts/tools/read.md +4 -2
  245. package/src/prompts/tools/search-tool-bm25.md +5 -0
  246. package/src/prompts/tools/task.md +1 -1
  247. package/src/reminders/star-reminder.ts +4 -42
  248. package/src/runtime-mcp/manager.ts +19 -4
  249. package/src/runtime-mcp/smithery-auth.ts +17 -2
  250. package/src/runtime-mcp/smithery-connect.ts +11 -1
  251. package/src/sdk/acp/adapter.ts +22 -6
  252. package/src/sdk/broker/lifecycle.ts +25 -6
  253. package/src/sdk/broker/transport.ts +35 -3
  254. package/src/sdk/bus/attachment-registry.ts +5 -1
  255. package/src/sdk/bus/chat-daemon-cli.ts +4 -39
  256. package/src/sdk/bus/chat-daemon-control.ts +6 -3
  257. package/src/sdk/bus/config.ts +18 -2
  258. package/src/sdk/bus/control-drain-lease.ts +104 -0
  259. package/src/sdk/bus/index.ts +984 -146
  260. package/src/sdk/bus/kind-aware-reconciliation.ts +317 -0
  261. package/src/sdk/bus/lifecycle-commands.ts +4 -1
  262. package/src/sdk/bus/lifecycle-control-runtime.ts +403 -88
  263. package/src/sdk/bus/prompt-reconciliation.ts +2 -1
  264. package/src/sdk/bus/reconciliation-store.ts +303 -0
  265. package/src/sdk/bus/rich-draft.ts +1 -1
  266. package/src/sdk/bus/rich-render.ts +2 -2
  267. package/src/sdk/bus/telegram-adoption-intent.ts +574 -0
  268. package/src/sdk/bus/telegram-cli.ts +33 -8
  269. package/src/sdk/bus/telegram-daemon-cli.ts +1 -0
  270. package/src/sdk/bus/telegram-daemon-contract.ts +11 -4
  271. package/src/sdk/bus/telegram-daemon.ts +916 -23
  272. package/src/sdk/bus/telegram-reference.ts +40 -3
  273. package/src/sdk/bus/threaded-render.ts +48 -3
  274. package/src/sdk/bus/topic-registry.ts +20 -0
  275. package/src/sdk/host/control/dispatch.ts +5 -1
  276. package/src/sdk/host/control/operations.ts +1 -1
  277. package/src/sdk/host/host.ts +23 -1
  278. package/src/sdk/host/query/handlers.ts +55 -0
  279. package/src/sdk/host/query/revision-store.ts +36 -2
  280. package/src/sdk/host/reverse-leases.ts +44 -7
  281. package/src/sdk/mcp/server.ts +46 -18
  282. package/src/sdk/prompt-status.ts +16 -3
  283. package/src/sdk/protocol/operation-inventory.generated.json +51 -0
  284. package/src/sdk/protocol/operation-registry.ts +7 -2
  285. package/src/sdk/session.ts +101 -10
  286. package/src/session/agent-session.ts +1286 -220
  287. package/src/session/artifacts.ts +22 -0
  288. package/src/session/auth-broker-config.ts +10 -3
  289. package/src/session/blob-store.ts +915 -24
  290. package/src/session/internal/managed-session-scope.ts +44 -6
  291. package/src/session/internal/managed-session-storage.ts +48 -6
  292. package/src/session/memory-guard-checkpoint-participant.ts +131 -0
  293. package/src/session/session-manager.ts +2364 -438
  294. package/src/session/session-storage.ts +9 -1
  295. package/src/session/streaming-output.ts +500 -111
  296. package/src/setup/credential-import.ts +75 -13
  297. package/src/setup/provider-onboarding.ts +19 -4
  298. package/src/skill-state/active-state.ts +32 -10
  299. package/src/skill-state/workflow-hud.ts +42 -2
  300. package/src/skill-state/workflow-mutation-guard.ts +560 -8
  301. package/src/task/executor.ts +122 -39
  302. package/src/task/index.ts +23 -14
  303. package/src/task/provider-retry-status.ts +16 -0
  304. package/src/task/receipt.ts +23 -7
  305. package/src/task/render.ts +6 -2
  306. package/src/task/types.ts +108 -0
  307. package/src/tools/ask-answer-registry.ts +12 -6
  308. package/src/tools/ask.ts +169 -118
  309. package/src/tools/ast-edit.ts +1 -0
  310. package/src/tools/ast-grep.ts +1 -0
  311. package/src/tools/bash-interactive.ts +8 -3
  312. package/src/tools/bash.ts +503 -60
  313. package/src/tools/browser/launch.ts +41 -6
  314. package/src/tools/fetch.ts +114 -23
  315. package/src/tools/find.ts +1 -0
  316. package/src/tools/image-gen.ts +29 -5
  317. package/src/tools/index.ts +4 -0
  318. package/src/tools/output-meta.ts +281 -20
  319. package/src/tools/path-utils.ts +8 -3
  320. package/src/tools/read-internals.ts +44 -0
  321. package/src/tools/read.ts +1560 -310
  322. package/src/tools/resource-gc.ts +182 -18
  323. package/src/tools/review.ts +5 -7
  324. package/src/tools/search.ts +1 -0
  325. package/src/tools/subagent-render.ts +6 -2
  326. package/src/tools/subagent.ts +6 -0
  327. package/src/tools/telegram-send.ts +6 -3
  328. package/src/tools/tool-result.ts +14 -1
  329. package/src/utils/gh.ts +39 -0
  330. package/src/utils/image-resize.ts +3 -0
  331. package/src/web/search/providers/anthropic.ts +4 -4
  332. package/src/web/search/providers/codex.ts +1 -1
  333. package/src/web/search/providers/kimi.ts +15 -5
  334. package/src/web/search/providers/searxng.ts +47 -4
  335. package/src/web/search/providers/xai.ts +7 -2
  336. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +0 -8
  337. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +0 -41
  338. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +0 -17
  339. package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +0 -23
  340. package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +0 -30
  341. package/src/gjc-runtime/deep-interview-draft.ts +0 -843
  342. package/src/gjc-runtime/deep-interview-payload.ts +0 -262
  343. package/src/gjc-runtime/deep-interview-repair.ts +0 -1674
  344. package/src/gjc-runtime/ultragoal-pipeline.ts +0 -306
@@ -17,8 +17,10 @@ import type {
17
17
  import * as native from "@gajae-code/natives";
18
18
  import { getTerminalId } from "@gajae-code/tui";
19
19
  import {
20
+ getAgentDir,
20
21
  getBlobsDir,
21
22
  getProjectDir,
23
+ getResidentCacheRootDir,
22
24
  getSessionsDir,
23
25
  getTerminalSessionsDir,
24
26
  hasFsCode,
@@ -31,9 +33,9 @@ import {
31
33
  toError,
32
34
  } from "@gajae-code/utils";
33
35
  import type { TtsrInjectionRecord } from "../export/ttsr";
36
+ import { assertSafePathComponent } from "../gjc-runtime/session-layout";
34
37
  import { writeTextAtomic } from "../gjc-runtime/state-writer";
35
38
  import type { ManagedLegacyLocalMigrationSource } from "../internal-urls/local-protocol";
36
-
37
39
  import * as git from "../utils/git";
38
40
  import { ArtifactManager } from "./artifacts";
39
41
  import {
@@ -47,8 +49,10 @@ import {
47
49
  isBlobRef,
48
50
  isImageDataUrl,
49
51
  MemoryBlobStore,
52
+ openVerifiedResidentCacheInstanceDir,
50
53
  parseBlobRef,
51
54
  ResidentBlobMissingError,
55
+ ResidentCacheTrustError,
52
56
  resolveResidentImageDataSync,
53
57
  resolveResidentImageDataUrlSync,
54
58
  resolveTextBlobSync,
@@ -70,6 +74,7 @@ import {
70
74
  } from "./internal/managed-session-scope";
71
75
  import {
72
76
  assertManagedDirectoryRoot,
77
+ captureManagedFileNoFollow,
73
78
  fsyncManagedArtifactTree,
74
79
  type ManagedDirectoryRoot,
75
80
  type ManagedFileSnapshot,
@@ -78,7 +83,25 @@ import {
78
83
  mayCleanManagedTreeStaging,
79
84
  retainManagedDirectoryAuthority,
80
85
  } from "./internal/managed-session-storage";
86
+
81
87
  import { classifyNativePublishOutcome, formatNativePublishDiagnostic } from "./internal/native-publish-outcome";
88
+ import {
89
+ hasOnlyKeys as hasOnlyMemoryGuardKeys,
90
+ isMemoryGuardDecimalString,
91
+ isMemoryGuardRelativePath,
92
+ isMemoryGuardSha256Hex,
93
+ type MemoryGuardCheckpointBlobAuthorityV1,
94
+ type MemoryGuardCheckpointBlobManifestEntryV1,
95
+ type MemoryGuardCheckpointBlobManifestV1,
96
+ type MemoryGuardCreateCheckpointInput,
97
+ type MemoryGuardParticipantDescriptorV1,
98
+ type MemoryGuardParticipantIngressLease,
99
+ type MemoryGuardRestoreInput,
100
+ type MemoryGuardRestoreResult,
101
+ type MemoryGuardSessionManagerCheckpointV1,
102
+ memoryGuardCanonicalJson,
103
+ memoryGuardSha256Hex,
104
+ } from "./memory-guard-checkpoint-participant";
82
105
 
83
106
  import {
84
107
  type BashExecutionMessage,
@@ -164,6 +187,107 @@ interface PreparedNewSessionState extends PreparedNewSession {
164
187
  persistenceTempPath?: string;
165
188
  }
166
189
 
190
+ type ResidentTransitionFailurePolicy = "install-staged" | "memory-fallback" | "retain-and-throw" | "memory-only";
191
+ type ResidentBlobMissingPolicy = "throw" | "placeholder";
192
+
193
+ type ResidentTransitionSource =
194
+ | {
195
+ mode: "materialize";
196
+ sourceEntries: FileEntry[];
197
+ sourceStores: {
198
+ textStore: BlobStore | null;
199
+ imageStore: BlobStore;
200
+ textFallback?: (hash: string) => Buffer | null;
201
+ };
202
+ missingPolicy?: ResidentBlobMissingPolicy;
203
+ }
204
+ | {
205
+ mode: "adopt-staged";
206
+ stagedEntries: FileEntry[];
207
+ stagedStore: BlobStore;
208
+ stagedIndex?: PreparedSessionIndex;
209
+ };
210
+
211
+ interface ResidentTransitionInput {
212
+ readonly target: { sessionId: string; sessionFile: string };
213
+ readonly primary: ResidentTransitionSource;
214
+ /** A read-only load may demote an unavailable disposable resident cache to memory. */
215
+ readonly allowUnwritableResidentCacheFallback?: boolean;
216
+
217
+ readonly fallback?: Extract<ResidentTransitionSource, { mode: "adopt-staged" }>;
218
+ }
219
+
220
+ class PreparedResidentStoreTransition {
221
+ #active = true;
222
+
223
+ constructor(
224
+ readonly entries: FileEntry[],
225
+ readonly store: BlobStore,
226
+ readonly index: PreparedSessionIndex,
227
+ private readonly ownsStore: boolean,
228
+ ) {}
229
+
230
+ dispose(): void {
231
+ if (!this.#active) return;
232
+ this.#active = false;
233
+ if (!this.ownsStore || !(this.store instanceof EphemeralBlobStore)) return;
234
+ try {
235
+ this.store.dispose();
236
+ } catch (error) {
237
+ logger.warn("Failed to dispose an uncommitted resident cache candidate", {
238
+ error: toError(error).message,
239
+ });
240
+ }
241
+ }
242
+
243
+ adopt(): void {
244
+ this.#active = false;
245
+ }
246
+ }
247
+
248
+ interface FreshSessionState {
249
+ readonly sessionId: string;
250
+ readonly sessionFile: string | undefined;
251
+ readonly header: SessionHeader;
252
+ readonly adoptsLifecycleId: boolean;
253
+ }
254
+
255
+ type ResidentCacheDegradedStore = BlobStore & { degradedReason?: string };
256
+
257
+ const RESIDENT_CACHE_WRITE_FAILURE_REASONS = new Set([
258
+ "root_create_failed",
259
+ "instance_create_failed",
260
+ "owner_write_failed",
261
+ "owner_close_failed",
262
+ "blob_create_failed",
263
+ "blob_write_failed",
264
+ "blob_close_failed",
265
+ ]);
266
+ const RESIDENT_CACHE_UNWRITABLE_CODES = ["EACCES", "EPERM", "EROFS"] as const;
267
+
268
+ function hasUnwritableResidentCacheFsCause(error: unknown): boolean {
269
+ const seen = new Set<unknown>();
270
+ let current: unknown = error;
271
+ while (current !== undefined && !seen.has(current)) {
272
+ seen.add(current);
273
+ if (RESIDENT_CACHE_UNWRITABLE_CODES.some(code => hasFsCode(current, code))) return true;
274
+ if (typeof current !== "object" || current === null || !("cause" in current)) return false;
275
+ current = current.cause;
276
+ }
277
+ return false;
278
+ }
279
+
280
+ function isResidentCacheProvisioningFailure(error: unknown): boolean {
281
+ return error instanceof ResidentCacheTrustError || hasUnwritableResidentCacheFsCause(error);
282
+ }
283
+
284
+ function isUnwritableResidentCacheFailure(error: unknown): boolean {
285
+ return (
286
+ hasUnwritableResidentCacheFsCause(error) &&
287
+ (!(error instanceof ResidentCacheTrustError) || RESIDENT_CACHE_WRITE_FAILURE_REASONS.has(error.reason))
288
+ );
289
+ }
290
+
167
291
  export interface SessionEntryBase {
168
292
  type: string;
169
293
  id: string;
@@ -205,11 +329,16 @@ export interface SessionManagerObservabilityStats {
205
329
  coldSpillReadCount: number;
206
330
  residentTextReadCount: number;
207
331
  residentImageReadCount: number;
332
+ residentCacheAdoptFallbackCount: number;
333
+ residentCacheTrustRejectCount: number;
334
+ residentCacheWin32FallbackCount: number;
335
+ residentCacheDegradedReason?: string;
208
336
  publicMaterializerCallCount: number;
209
337
  getEntryMaterializerCallCount: number;
210
338
  getBranchMaterializerCallCount: number;
211
339
  getEntriesMaterializerCallCount: number;
212
340
  materializedEntriesCachePopulateCount: number;
341
+ materializedCacheDemotedCount: number;
213
342
  pathOnlyContextBuildCount: number;
214
343
  }
215
344
 
@@ -480,6 +609,238 @@ export type DefaultModelSelectionStage = {
480
609
  readonly tempPath: string | undefined;
481
610
  readonly persistsToExistingFile: boolean;
482
611
  };
612
+ export interface SessionManagerRevisionSnapshot {
613
+ entry: number;
614
+ leaf: number;
615
+ headerExport: number;
616
+ label: number;
617
+ replayMetadata: number;
618
+ }
619
+
620
+ export interface SessionManagerCheckpointRevisionStrings {
621
+ entry: string;
622
+ leaf: string;
623
+ headerExport: string;
624
+ label: string;
625
+ replayMetadata: string;
626
+ }
627
+
628
+ function assertMemoryGuardSessionId(sessionId: string): void {
629
+ assertSafePathComponent(sessionId, "memory guard checkpoint session id");
630
+ }
631
+
632
+ function memoryGuardParticipantRoot(checkpointRoot: string, sessionId: string): string {
633
+ assertMemoryGuardSessionId(sessionId);
634
+ return path.join(checkpointRoot, "participants", sessionId);
635
+ }
636
+
637
+ function memoryGuardParticipantRelativePath(sessionId: string, suffix: string): string {
638
+ assertMemoryGuardSessionId(sessionId);
639
+ return `participants/${sessionId}/${suffix}`;
640
+ }
641
+
642
+ async function fsyncDirectoryPath(directoryPath: string): Promise<void> {
643
+ const directory = await fs.promises.open(directoryPath, "r");
644
+ try {
645
+ await directory.sync();
646
+ } finally {
647
+ await directory.close();
648
+ }
649
+ }
650
+
651
+ async function ensureOwnerOnlyDirectory(directoryPath: string): Promise<void> {
652
+ await fs.promises.mkdir(directoryPath, { recursive: true, mode: 0o700 });
653
+ await fs.promises.chmod(directoryPath, 0o700).catch(() => undefined);
654
+ await fsyncDirectoryPath(directoryPath);
655
+ }
656
+ interface CreatedDirectoryIdentity {
657
+ dev: bigint;
658
+ ino: bigint;
659
+ }
660
+
661
+ async function ensureOwnerOnlyDirectoryTracked(directoryPath: string): Promise<CreatedDirectoryIdentity | undefined> {
662
+ const createdPath = await fs.promises.mkdir(directoryPath, { recursive: true, mode: 0o700 });
663
+ await fs.promises.chmod(directoryPath, 0o700).catch(() => undefined);
664
+ await fsyncDirectoryPath(directoryPath);
665
+ if (createdPath === undefined) return undefined;
666
+ const stat = await fs.promises.lstat(directoryPath, { bigint: true });
667
+ return { dev: stat.dev, ino: stat.ino };
668
+ }
669
+
670
+ async function removeCreatedDirectoryIfEmpty(
671
+ directoryPath: string,
672
+ created: CreatedDirectoryIdentity | undefined,
673
+ ): Promise<void> {
674
+ if (!created) return;
675
+ const stat = await fs.promises.lstat(directoryPath, { bigint: true }).catch(() => undefined);
676
+ if (!stat || stat.dev !== created.dev || stat.ino !== created.ino) return;
677
+ await fs.promises.rmdir(directoryPath).catch(() => undefined);
678
+ await fsyncDirectoryPath(path.dirname(directoryPath)).catch(() => undefined);
679
+ }
680
+
681
+ async function writeOwnerOnlyFileNoReplace(filePath: string, content: Uint8Array | string): Promise<void> {
682
+ await ensureOwnerOnlyDirectory(path.dirname(filePath));
683
+ const handle = await fs.promises.open(filePath, "wx", 0o600);
684
+ try {
685
+ await handle.writeFile(content);
686
+ await handle.sync();
687
+ } finally {
688
+ await handle.close();
689
+ }
690
+ await fs.promises.chmod(filePath, 0o600).catch(() => undefined);
691
+ await fsyncDirectoryPath(path.dirname(filePath));
692
+ }
693
+
694
+ const MEMORY_GUARD_CHECKPOINT_FILE_MAX_BYTES = 64 * 1024 * 1024;
695
+ const MEMORY_GUARD_CHECKPOINT_BLOB_MAX_ENTRIES = 4096;
696
+ const MEMORY_GUARD_CHECKPOINT_BLOB_TOTAL_MAX_BYTES = 64 * 1024 * 1024;
697
+
698
+ function collectCheckpointBlobRefs(value: unknown, refs: Set<string> = new Set(), key?: string): Set<string> {
699
+ const addRef = (candidate: unknown): void => {
700
+ if (typeof candidate === "string" && parseBlobRef(candidate)) refs.add(candidate);
701
+ };
702
+ if (Array.isArray(value)) {
703
+ for (const item of value) collectCheckpointBlobRefs(item, refs, key);
704
+ return refs;
705
+ }
706
+ if (!value || typeof value !== "object") return refs;
707
+ if (isRecord(value) && value.kind === "cold_spill" && typeof value.ref === "string" && parseBlobRef(value.ref))
708
+ refs.add(value.ref);
709
+ if (isImageBlock(value) && key === TEXT_CONTENT_KEY) addRef(value.data);
710
+ if (hasImageUrl(value)) {
711
+ if (typeof value.image_url === "string") addRef(value.image_url);
712
+ else addRef(value.image_url.url);
713
+ }
714
+ for (const [childKey, item] of Object.entries(value as Record<string, unknown>)) {
715
+ if (childKey === "data" && key !== TEXT_CONTENT_KEY) addRef(item);
716
+ collectCheckpointBlobRefs(item, refs, childKey);
717
+ }
718
+ return refs;
719
+ }
720
+
721
+ function decodeCheckpointUtf8(data: Uint8Array): string | null {
722
+ try {
723
+ return new TextDecoder("utf-8", { fatal: true }).decode(data);
724
+ } catch {
725
+ return null;
726
+ }
727
+ }
728
+
729
+ function validateMemoryGuardBlobAuthority(value: unknown): value is MemoryGuardCheckpointBlobAuthorityV1 {
730
+ if (
731
+ !isRecord(value) ||
732
+ !hasOnlyMemoryGuardKeys(value, ["kind", "manifest_relative_path", "manifest_sha256", "root_relative_path"])
733
+ )
734
+ return false;
735
+ return (
736
+ value.kind === "checkpoint_blob_tree_v1" &&
737
+ isMemoryGuardRelativePath(value.manifest_relative_path) &&
738
+ isMemoryGuardSha256Hex(value.manifest_sha256) &&
739
+ isMemoryGuardRelativePath(value.root_relative_path)
740
+ );
741
+ }
742
+
743
+ function validateMemoryGuardTranscriptDescriptor(
744
+ value: unknown,
745
+ ): value is MemoryGuardSessionManagerCheckpointV1["transcript"] {
746
+ if (!isRecord(value) || !hasOnlyMemoryGuardKeys(value, ["bytes", "relative_path", "sha256"])) return false;
747
+ return (
748
+ isMemoryGuardDecimalString(value.bytes) &&
749
+ isMemoryGuardRelativePath(value.relative_path) &&
750
+ isMemoryGuardSha256Hex(value.sha256)
751
+ );
752
+ }
753
+
754
+ function validateMemoryGuardRevisions(value: unknown): value is SessionManagerCheckpointRevisionStrings {
755
+ if (!isRecord(value) || !hasOnlyMemoryGuardKeys(value, ["entry", "leaf", "headerExport", "label", "replayMetadata"]))
756
+ return false;
757
+ return (
758
+ isMemoryGuardDecimalString(value.entry) &&
759
+ isMemoryGuardDecimalString(value.leaf) &&
760
+ isMemoryGuardDecimalString(value.headerExport) &&
761
+ isMemoryGuardDecimalString(value.label) &&
762
+ isMemoryGuardDecimalString(value.replayMetadata)
763
+ );
764
+ }
765
+
766
+ function validateMemoryGuardCheckpoint(value: unknown): value is MemoryGuardSessionManagerCheckpointV1 {
767
+ if (
768
+ !isRecord(value) ||
769
+ !hasOnlyMemoryGuardKeys(value, [
770
+ "blob_authority",
771
+ "revisions",
772
+ "schema_version",
773
+ "session_id",
774
+ "session_name",
775
+ "transcript",
776
+ ])
777
+ )
778
+ return false;
779
+ return (
780
+ value.schema_version === 1 &&
781
+ typeof value.session_id === "string" &&
782
+ value.session_id.length > 0 &&
783
+ (value.session_name === null || typeof value.session_name === "string") &&
784
+ validateMemoryGuardBlobAuthority(value.blob_authority) &&
785
+ validateMemoryGuardRevisions(value.revisions) &&
786
+ validateMemoryGuardTranscriptDescriptor(value.transcript)
787
+ );
788
+ }
789
+
790
+ function validateMemoryGuardBlobManifestEntry(value: unknown): value is MemoryGuardCheckpointBlobManifestEntryV1 {
791
+ if (!isRecord(value) || !hasOnlyMemoryGuardKeys(value, ["bytes", "relative_path", "sha256"])) return false;
792
+ return (
793
+ isMemoryGuardDecimalString(value.bytes) &&
794
+ isMemoryGuardRelativePath(value.relative_path) &&
795
+ isMemoryGuardSha256Hex(value.sha256)
796
+ );
797
+ }
798
+
799
+ function validateMemoryGuardBlobManifest(value: unknown): value is MemoryGuardCheckpointBlobManifestV1 {
800
+ if (!isRecord(value) || !hasOnlyMemoryGuardKeys(value, ["entries", "schema_version"])) return false;
801
+ if (value.schema_version !== 1 || !Array.isArray(value.entries)) return false;
802
+ return value.entries.every(validateMemoryGuardBlobManifestEntry);
803
+ }
804
+
805
+ function memoryGuardParticipantMatchesCheckpoint(
806
+ participant: MemoryGuardParticipantDescriptorV1,
807
+ checkpoint: MemoryGuardSessionManagerCheckpointV1,
808
+ ): boolean {
809
+ return (
810
+ participant.session_id === checkpoint.session_id &&
811
+ participant.session_name === checkpoint.session_name &&
812
+ JSON.stringify(participant.checkpoint) === JSON.stringify(checkpoint.blob_authority) &&
813
+ JSON.stringify(participant.revisions) === JSON.stringify(checkpoint.revisions) &&
814
+ JSON.stringify(participant.transcript) === JSON.stringify(checkpoint.transcript)
815
+ );
816
+ }
817
+
818
+ function readCheckpointAuthorityFile(
819
+ authority: native.RecoveryFsRoot,
820
+ relativePath: string,
821
+ maxBytes: number,
822
+ ): Uint8Array | null {
823
+ const result = authority.readManaged(relativePath);
824
+ if (!result.ok || !result.data || result.data.byteLength > maxBytes) return null;
825
+ return result.data;
826
+ }
827
+
828
+ function toCanonicalRevisionString(name: keyof SessionManagerRevisionSnapshot, value: number): string {
829
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error(`invalid_session_manager_revision:${name}`);
830
+ return String(value);
831
+ }
832
+
833
+ export function toSessionManagerCheckpointRevisionStrings(
834
+ snapshot: SessionManagerRevisionSnapshot,
835
+ ): SessionManagerCheckpointRevisionStrings {
836
+ return {
837
+ entry: toCanonicalRevisionString("entry", snapshot.entry),
838
+ leaf: toCanonicalRevisionString("leaf", snapshot.leaf),
839
+ headerExport: toCanonicalRevisionString("headerExport", snapshot.headerExport),
840
+ label: toCanonicalRevisionString("label", snapshot.label),
841
+ replayMetadata: toCanonicalRevisionString("replayMetadata", snapshot.replayMetadata),
842
+ };
843
+ }
483
844
 
484
845
  export type DefaultModelSelectionPromotion =
485
846
  | { readonly kind: "promoted" }
@@ -1366,10 +1727,17 @@ function managedScopeStartupError(
1366
1727
  action: "resolve" | "prepare",
1367
1728
  failure: Extract<ReturnType<typeof resolveManagedScopeForWrite>, { kind: "error" }>,
1368
1729
  ): Error {
1369
- return new Error(`Could not ${action} managed session scope.`, {
1730
+ const classification = failure.cause?.classification ?? failure.code;
1731
+ const diagnostic = failure.cause?.diagnostic;
1732
+ const detail = diagnostic === undefined ? classification : `${classification}: ${diagnostic}`;
1733
+ const message =
1734
+ action === "prepare"
1735
+ ? `Could not prepare managed session scope (${detail}).`
1736
+ : "Could not resolve managed session scope.";
1737
+ return new Error(message, {
1370
1738
  cause: {
1371
- classification: failure.cause?.classification ?? failure.code,
1372
- ...(failure.cause?.diagnostic === undefined ? {} : { diagnostic: failure.cause.diagnostic }),
1739
+ classification,
1740
+ ...(diagnostic === undefined ? {} : { diagnostic }),
1373
1741
  },
1374
1742
  });
1375
1743
  }
@@ -1397,11 +1765,54 @@ export type SessionDestination =
1397
1765
  | { readonly kind: "explicit"; readonly directory: string };
1398
1766
  export type SessionDestinationInput = string | SessionDestination | undefined;
1399
1767
 
1400
- interface ForkArtifactPublication {
1401
- readonly snapshot: native.NativeDirectoryTreeSnapshot;
1402
- readonly store: ManagedSessionDescendantStore;
1403
- readonly cleanupStore: ManagedSessionDescendantStore;
1404
- readonly cleanupRelativePath: string;
1768
+ type ForkArtifactPublication =
1769
+ | {
1770
+ readonly kind: "managed";
1771
+ readonly snapshot: native.NativeDirectoryTreeSnapshot;
1772
+ readonly store: ManagedSessionDescendantStore;
1773
+ readonly cleanupStore: ManagedSessionDescendantStore;
1774
+ readonly cleanupRelativePath: string;
1775
+ }
1776
+ | {
1777
+ readonly kind: "explicit";
1778
+ readonly artifactsDir: string;
1779
+ readonly snapshot: native.NativeDirectoryTreeSnapshot;
1780
+ };
1781
+
1782
+ type ForkTranscriptPublication =
1783
+ | {
1784
+ readonly kind: "managed";
1785
+ readonly store: ManagedSessionDescendantStore;
1786
+ readonly relativePath: string;
1787
+ /** Bytes successfully installed by no-replace publication. */
1788
+ readonly publishedBytes: Buffer;
1789
+ /** Absent only while post-publication identity capture is failing. */
1790
+ readonly snapshot?: ManagedFileSnapshot;
1791
+ }
1792
+ | {
1793
+ readonly kind: "explicit-file";
1794
+ readonly sessionFile: string;
1795
+ /** Bytes successfully installed by no-replace publication. */
1796
+ readonly publishedBytes: Buffer;
1797
+ /** Absent only while post-publication identity capture is failing. */
1798
+ readonly snapshot?: ManagedFileSnapshot;
1799
+ }
1800
+ | {
1801
+ readonly kind: "explicit-storage";
1802
+ readonly sessionFile: string;
1803
+ /** Bytes successfully installed by transcript publication. */
1804
+ readonly publishedBytes: Buffer;
1805
+ /** Absent only while post-publication identity capture is failing. */
1806
+ readonly snapshot?: SessionStorageSnapshot;
1807
+ };
1808
+
1809
+ function pruneResidentCacheEntries(snapshot: native.NativeDirectoryTreeSnapshot): native.NativeDirectoryTreeSnapshot {
1810
+ return {
1811
+ ...snapshot,
1812
+ entries: snapshot.entries.filter(
1813
+ entry => entry.relativePath !== "resident-cache" && !entry.relativePath.startsWith("resident-cache/"),
1814
+ ),
1815
+ };
1405
1816
  }
1406
1817
 
1407
1818
  function retainedTreeSnapshotEquals(
@@ -1454,6 +1865,51 @@ function retainedTreeSnapshotEqualsAfterRename(
1454
1865
  );
1455
1866
  }
1456
1867
 
1868
+ /**
1869
+ * True when a cleanup error only reports the authorized POSIX quarantine.
1870
+ *
1871
+ * `exact_remove_directory_tree` and `removeManagedTree` cannot bind the final
1872
+ * unlink to the verified root descriptor on POSIX, so they detach the tree to a
1873
+ * no-replace `<name>.removing` name and report `cleanup_pending`. No live
1874
+ * artifact survives that outcome, so it is a SUCCESSFUL cleanup and must never
1875
+ * supersede the primary failure that triggered it.
1876
+ */
1877
+ function isAuthorizedPendingCleanup(cleanupError: Error): boolean {
1878
+ return cleanupError.message === "cleanup_pending";
1879
+ }
1880
+
1881
+ /**
1882
+ * Remove a fork-staging root this process created, re-proving ownership first.
1883
+ * Returns undefined when the root is gone or authorized-pending; otherwise returns
1884
+ * a stable cleanup code the caller wraps with the original failure as `cause`.
1885
+ */
1886
+ function removeOwnedForkStaging(stagingDir: string, ownedRoot: native.NativeDirectoryTreeSnapshot): string | undefined {
1887
+ let current: native.NativeDirectoryTreeResult;
1888
+ try {
1889
+ current = native.snapshotDirectoryTree(stagingDir);
1890
+ } catch (snapshotError) {
1891
+ return `staging_snapshot_threw:${toError(snapshotError).message}`;
1892
+ }
1893
+ if (!current.ok || !current.snapshot) {
1894
+ // Only VERIFIED ABSENCE means there is nothing to clean.
1895
+ return current.code === "not_found" ? undefined : (current.code ?? "staging_snapshot_failed");
1896
+ }
1897
+ // Rule R: identity, not pathname, authorizes the delete. Entries may be enumerated
1898
+ // now because they live inside a root we created; the ROOT must be the one we made.
1899
+ if (current.snapshot.rootDev !== ownedRoot.rootDev || current.snapshot.rootIno !== ownedRoot.rootIno)
1900
+ return "staging_identity_mismatch";
1901
+ const removed = native.exactRemoveDirectoryTree(stagingDir, current.snapshot);
1902
+ if (removed.ok) return undefined;
1903
+ if (removed.code === "not_found") return undefined;
1904
+ const cleanupPending =
1905
+ removed.code === "cleanup_pending" &&
1906
+ (removed.detachedPath ??
1907
+ removed.retainedSuccessorPath ??
1908
+ removed.retainedPlaceholderPath ??
1909
+ removed.retainedUnknownPath) !== undefined;
1910
+ return cleanupPending ? undefined : (removed.code ?? "staging_remove_failed");
1911
+ }
1912
+
1457
1913
  function managedFileSnapshotEquals(left: ManagedFileSnapshot | null, right: ManagedFileSnapshot | null): boolean {
1458
1914
  return (
1459
1915
  left !== null &&
@@ -1468,6 +1924,7 @@ function managedFileSnapshotEquals(left: ManagedFileSnapshot | null, right: Mana
1468
1924
  );
1469
1925
  }
1470
1926
  const trustedSessionDestinations = new WeakSet<SessionDestination>();
1927
+ const explicitProfileAgentDirs = new WeakMap<SessionDestination, string>();
1471
1928
  const managedSecurityPolicies = new WeakMap<ManagedSessionSecurityContext, ManagedSessionSecurityPolicy>();
1472
1929
 
1473
1930
  function managedSecurityPolicyForContext(securityContext: ManagedSessionSecurityContext): ManagedSessionSecurityPolicy {
@@ -1480,20 +1937,26 @@ function managedStoreFromContext(
1480
1937
  securityContext: ManagedSessionSecurityContext,
1481
1938
  directory: string,
1482
1939
  ): ManagedSessionDescendantStore {
1940
+ // A retained fd authority is bound to the exact directory it was retained for.
1941
+ // For any other directory the store must re-derive its own authority from the
1942
+ // shared root instead of inheriting a binding that describes a different inode.
1943
+ const ownsRetainedAuthority = path.resolve(directory) === path.resolve(securityContext.sessionDir);
1483
1944
  return new ManagedSessionDescendantStore(
1484
1945
  securityContext.rootAuthority,
1485
1946
  directory,
1486
- securityContext.retainedAuthority
1947
+ securityContext.retainedAuthority && ownsRetainedAuthority
1487
1948
  ? { authority: securityContext.retainedAuthority, authorityBaseDir: directory }
1488
1949
  : undefined,
1489
1950
  managedSecurityPolicyForContext(securityContext),
1951
+ securityContext.profileAgentDir,
1490
1952
  );
1491
1953
  }
1492
1954
 
1493
- function freezeManagedDestination(scope: ManagedScope): SessionDestination {
1955
+ function freezeManagedDestination(scope: ManagedScope, profileAgentDir: string): SessionDestination {
1494
1956
  const rootAuthority = managedRootForScope(scope);
1495
1957
  const securityContext = createManagedSessionSecurityContext({
1496
1958
  agentDir: scope.agentDir,
1959
+ profileAgentDir,
1497
1960
  sessionsRoot: scope.sessionsRoot,
1498
1961
  sessionDir: scope.directoryPath,
1499
1962
  rootAuthority,
@@ -1511,6 +1974,7 @@ function freezeManagedDestination(scope: ManagedScope): SessionDestination {
1511
1974
  function explicitDestination(directory: string): SessionDestination {
1512
1975
  const destination = Object.freeze({ kind: "explicit" as const, directory });
1513
1976
  trustedSessionDestinations.add(destination);
1977
+ explicitProfileAgentDirs.set(destination, getAgentDir());
1514
1978
  return destination;
1515
1979
  }
1516
1980
 
@@ -1528,7 +1992,7 @@ function managedDestination(cwd: string, storage: SessionStorage, agentDir?: str
1528
1992
  process.platform === "win32" ? "windows-existing-verify-first" : "default",
1529
1993
  );
1530
1994
  if (prepared.kind === "error") throw managedScopeStartupError("prepare", prepared);
1531
- return freezeManagedDestination(prepared.scope);
1995
+ return freezeManagedDestination(prepared.scope, agentDir ?? getAgentDir());
1532
1996
  }
1533
1997
 
1534
1998
  function destinationFor(
@@ -1702,6 +2166,10 @@ function sameResumeStat(left: SessionStorageStat, right: SessionStorageStat): bo
1702
2166
  );
1703
2167
  }
1704
2168
 
2169
+ function sameSessionStorageSnapshot(left: SessionStorageSnapshot, right: SessionStorageSnapshot): boolean {
2170
+ return sameResumeStat(left.stat, right.stat) && Buffer.from(left.bytes).equals(Buffer.from(right.bytes));
2171
+ }
2172
+
1705
2173
  interface ResumeInspectionSnapshot {
1706
2174
  identity: ResumeSessionIdentity;
1707
2175
  content: Uint8Array;
@@ -2431,6 +2899,48 @@ function collectResidentImageRefs(value: unknown, refs: Set<string>, seen = new
2431
2899
  for (const child of Object.values(value)) collectResidentImageRefs(child, refs, seen);
2432
2900
  }
2433
2901
 
2902
+ function collectResidentTextBlobHashes(value: unknown, hashes: Set<string>, seen = new WeakSet<object>()): void {
2903
+ if (value === null || value === undefined || typeof value !== "object") return;
2904
+ if (isResidentBlobSentinel(value)) {
2905
+ if (value.kind === "text") {
2906
+ const hash = parseBlobRef(value.ref);
2907
+ if (hash) hashes.add(hash);
2908
+ }
2909
+ return;
2910
+ }
2911
+ if (seen.has(value)) return;
2912
+ seen.add(value);
2913
+ if (Array.isArray(value)) {
2914
+ for (const item of value) collectResidentTextBlobHashes(item, hashes, seen);
2915
+ return;
2916
+ }
2917
+ for (const child of Object.values(value)) collectResidentTextBlobHashes(child, hashes, seen);
2918
+ }
2919
+
2920
+ function recoverPersistedResidentTextBuffers(
2921
+ value: unknown,
2922
+ requested: ReadonlySet<string>,
2923
+ recovered: Map<string, Buffer>,
2924
+ key?: string,
2925
+ seen = new WeakSet<object>(),
2926
+ ): void {
2927
+ if (typeof value === "string") {
2928
+ if (value.length < BLOB_EXTERNALIZE_THRESHOLD || !shouldExternalizeResidentString(key)) return;
2929
+ const hash = crypto.createHash("sha256").update(value, "utf8").digest("hex");
2930
+ if (requested.has(hash)) recovered.set(hash, Buffer.from(value, "utf8"));
2931
+ return;
2932
+ }
2933
+ if (value === null || value === undefined || typeof value !== "object" || seen.has(value)) return;
2934
+ seen.add(value);
2935
+ if (Array.isArray(value)) {
2936
+ for (const item of value) recoverPersistedResidentTextBuffers(item, requested, recovered, key, seen);
2937
+ return;
2938
+ }
2939
+ for (const [childKey, child] of Object.entries(value)) {
2940
+ recoverPersistedResidentTextBuffers(child, requested, recovered, childKey, seen);
2941
+ }
2942
+ }
2943
+
2434
2944
  /**
2435
2945
  * Recursively truncate large strings in an object for session persistence.
2436
2946
  * - Truncates any oversized string fields (key-agnostic)
@@ -2527,13 +3037,13 @@ function shouldExternalizeResidentString(key: string | undefined): boolean {
2527
3037
  interface ResidentBlobStores {
2528
3038
  textStore: BlobStore;
2529
3039
  imageStore: BlobStore;
3040
+ /** Trusted alternate bytes used only while demoting an invalidated resident store. */
3041
+ textFallback?: (hash: string) => Buffer | null;
2530
3042
  sessionId?: string;
2531
3043
  sessionFile?: string;
2532
3044
  onResidentBlobRead?: (kind: ResidentBlobKind) => void;
2533
3045
  }
2534
3046
 
2535
- type ResidentBlobMissingPolicy = "throw" | "placeholder";
2536
-
2537
3047
  function residentBlobMissingPlaceholder(error: ResidentBlobMissingError): string {
2538
3048
  return `[Session resident ${error.kind} blob missing: sha256:${error.hash}; original content unavailable]`;
2539
3049
  }
@@ -2544,7 +3054,7 @@ function externalizeResidentValueSync(obj: unknown, stores: ResidentBlobStores,
2544
3054
  if (key === "image_url" && isImageDataUrl(obj) && obj.length >= BLOB_EXTERNALIZE_THRESHOLD)
2545
3055
  return residentBlobSentinel("imageUrl", externalizeImageDataUrlSync(stores.imageStore, obj));
2546
3056
  if (shouldExternalizeResidentString(key) && obj.length >= BLOB_EXTERNALIZE_THRESHOLD)
2547
- return residentBlobSentinel("text", stores.textStore.putSync(Buffer.from(obj, "utf8")).ref);
3057
+ return residentBlobSentinel("text", stores.textStore.putOwnedSync(Buffer.from(obj, "utf8")).ref);
2548
3058
  return obj;
2549
3059
  }
2550
3060
  if (Array.isArray(obj)) {
@@ -2603,12 +3113,16 @@ function materializeResidentValueSync(
2603
3113
  if (cached !== undefined) return cached;
2604
3114
  let resolved: string;
2605
3115
  try {
2606
- resolved =
2607
- obj.kind === "imageUrl"
2608
- ? resolveResidentImageDataUrlSync(stores.imageStore, obj.ref, stores)
2609
- : obj.kind === "imageData"
2610
- ? resolveResidentImageDataSync(stores.imageStore, obj.ref, stores)
2611
- : resolveTextBlobSync(stores.textStore, obj.ref, stores);
3116
+ if (obj.kind === "text") {
3117
+ const hash = parseBlobRef(obj.ref);
3118
+ const buffered = hash ? stores.textFallback?.(hash) : null;
3119
+ resolved = buffered ? buffered.toString("utf8") : resolveTextBlobSync(stores.textStore, obj.ref, stores);
3120
+ } else {
3121
+ resolved =
3122
+ obj.kind === "imageUrl"
3123
+ ? resolveResidentImageDataUrlSync(stores.imageStore, obj.ref, stores)
3124
+ : resolveResidentImageDataSync(stores.imageStore, obj.ref, stores);
3125
+ }
2612
3126
  } catch (err) {
2613
3127
  if (missingPolicy === "placeholder" && err instanceof ResidentBlobMissingError) {
2614
3128
  resolved = residentBlobMissingPlaceholder(err);
@@ -2616,6 +3130,7 @@ function materializeResidentValueSync(
2616
3130
  throw err;
2617
3131
  }
2618
3132
  }
3133
+
2619
3134
  cache.set(cacheKey, resolved);
2620
3135
  stores.onResidentBlobRead?.(obj.kind);
2621
3136
  return resolved;
@@ -2659,6 +3174,35 @@ function materializeResidentEntriesSync<T extends FileEntry | SessionEntry>(
2659
3174
  return entries.map(entry => materializeResidentEntrySync(entry, stores, cache, missingPolicy));
2660
3175
  }
2661
3176
 
3177
+ /** Verify staged references without materializing their full strings before commit. */
3178
+ function assertResidentReferencesResolvableSync(entries: readonly FileEntry[], stores: ResidentBlobStores): void {
3179
+ const seen = new WeakSet<object>();
3180
+ const resolved = new Set<string>();
3181
+ const visit = (value: unknown): void => {
3182
+ if (value === null || value === undefined || typeof value !== "object") return;
3183
+ if (isResidentBlobSentinel(value)) {
3184
+ const key = `${value.kind}:${value.ref}`;
3185
+ if (resolved.has(key)) return;
3186
+ resolved.add(key);
3187
+ const hash = parseBlobRef(value.ref);
3188
+ if (hash === null) throw new Error("Staged resident entry has an invalid blob reference.");
3189
+ const store = value.kind === "text" ? stores.textStore : stores.imageStore;
3190
+ if (store.getSync(hash) === null) {
3191
+ throw new ResidentBlobMissingError(hash, value.kind, stores.sessionId, stores.sessionFile);
3192
+ }
3193
+ return;
3194
+ }
3195
+ if (ArrayBuffer.isView(value) || seen.has(value)) return;
3196
+ seen.add(value);
3197
+ if (Array.isArray(value)) {
3198
+ for (const item of value) visit(item);
3199
+ return;
3200
+ }
3201
+ for (const item of Object.values(value)) visit(item);
3202
+ };
3203
+ for (const entry of entries) visit(entry);
3204
+ }
3205
+
2662
3206
  function materializeResidentEntryForReadSync<T extends FileEntry | SessionEntry>(
2663
3207
  entry: T,
2664
3208
  stores: ResidentBlobStores,
@@ -2667,6 +3211,63 @@ function materializeResidentEntryForReadSync<T extends FileEntry | SessionEntry>
2667
3211
  return materializeResidentEntrySync(entry, stores, cache, "placeholder");
2668
3212
  }
2669
3213
 
3214
+ /**
3215
+ * Approximate the UTF-8 footprint of a JSON-like snapshot without allocating a
3216
+ * serialized copy. The cap check can stop as soon as it is known to exceed it.
3217
+ */
3218
+ function jsonLikeValueExceedsCacheLimit(value: unknown, limit: number): boolean {
3219
+ let bytes = 0;
3220
+ const seen = new WeakSet<object>();
3221
+ const add = (amount: number): void => {
3222
+ bytes += amount;
3223
+ };
3224
+ const visit = (value: unknown): void => {
3225
+ if (bytes > limit || value === null || value === undefined) return;
3226
+ if (typeof value === "string") {
3227
+ add(Buffer.byteLength(value, "utf8"));
3228
+ return;
3229
+ }
3230
+ if (typeof value === "number" || typeof value === "bigint") {
3231
+ add(8);
3232
+ return;
3233
+ }
3234
+ if (typeof value === "boolean") {
3235
+ add(4);
3236
+ return;
3237
+ }
3238
+ if (typeof value !== "object" || seen.has(value)) return;
3239
+ seen.add(value);
3240
+ if (ArrayBuffer.isView(value)) {
3241
+ add(value.byteLength);
3242
+ return;
3243
+ }
3244
+ if (value instanceof ArrayBuffer) {
3245
+ add(value.byteLength);
3246
+ return;
3247
+ }
3248
+ if (Array.isArray(value)) {
3249
+ add(8);
3250
+ for (const item of value) visit(item);
3251
+ return;
3252
+ }
3253
+ add(16);
3254
+ for (const [key, child] of Object.entries(value)) {
3255
+ add(Buffer.byteLength(key, "utf8"));
3256
+ visit(child);
3257
+ }
3258
+ };
3259
+ visit(value);
3260
+ return bytes > limit;
3261
+ }
3262
+
3263
+ type MaterializedCacheReference<T extends object> = T | WeakRef<T>;
3264
+
3265
+ function dereferenceMaterializedCache<T extends object>(
3266
+ cache: MaterializedCacheReference<T> | undefined,
3267
+ ): T | undefined {
3268
+ return cache instanceof WeakRef ? cache.deref() : cache;
3269
+ }
3270
+
2670
3271
  function materializeResidentEntriesForReadSync<T extends FileEntry | SessionEntry>(
2671
3272
  entries: T[],
2672
3273
  stores: ResidentBlobStores,
@@ -3506,6 +4107,81 @@ class NdjsonFileWriter {
3506
4107
  }
3507
4108
  }
3508
4109
 
4110
+ const PROJECT_SESSION_SCAN_MAX_DIRECTORIES = 4096;
4111
+ const PROJECT_SESSION_SCAN_MAX_FILES = 1000;
4112
+
4113
+ function isProjectSessionTranscriptPath(projectGjcDir: string, filePath: string): boolean {
4114
+ const relative = path.relative(projectGjcDir, filePath);
4115
+ if (relative.startsWith("..") || path.isAbsolute(relative)) return false;
4116
+ const segments = relative.split(path.sep);
4117
+ if (segments.length === 1) return true;
4118
+ const parent = segments.at(-2);
4119
+ return parent === "agent-session" || segments.includes("sessions");
4120
+ }
4121
+
4122
+ /**
4123
+ * Discover resumable transcripts intentionally stored inside a project's `.gjc`.
4124
+ * Runtime token/audit JSONL files are excluded by requiring a known transcript
4125
+ * container (`agent-session` or `sessions`).
4126
+ */
4127
+ function listProjectSessionTranscriptFiles(cwd: string): string[] {
4128
+ const projectGjcDir = path.join(path.resolve(cwd), ".gjc");
4129
+ let rootStat: fs.Stats;
4130
+ try {
4131
+ rootStat = fs.lstatSync(projectGjcDir);
4132
+ } catch {
4133
+ return [];
4134
+ }
4135
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) return [];
4136
+
4137
+ const directories = [projectGjcDir];
4138
+ const files: string[] = [];
4139
+ let scannedDirectories = 0;
4140
+ while (directories.length > 0 && scannedDirectories < PROJECT_SESSION_SCAN_MAX_DIRECTORIES) {
4141
+ const directory = directories.pop()!;
4142
+ scannedDirectories++;
4143
+ let entries: fs.Dirent[];
4144
+ try {
4145
+ entries = fs.readdirSync(directory, { withFileTypes: true });
4146
+ } catch {
4147
+ continue;
4148
+ }
4149
+ for (const entry of entries) {
4150
+ if (entry.isSymbolicLink()) continue;
4151
+ const entryPath = path.join(directory, entry.name);
4152
+ if (entry.isDirectory()) {
4153
+ directories.push(entryPath);
4154
+ continue;
4155
+ }
4156
+ if (
4157
+ entry.isFile() &&
4158
+ !entry.name.startsWith(".") &&
4159
+ entry.name.endsWith(".jsonl") &&
4160
+ isProjectSessionTranscriptPath(projectGjcDir, entryPath)
4161
+ ) {
4162
+ files.push(entryPath);
4163
+ if (files.length >= PROJECT_SESSION_SCAN_MAX_FILES) return files;
4164
+ }
4165
+ }
4166
+ }
4167
+ return files;
4168
+ }
4169
+
4170
+ async function collectProjectSessions(cwd: string, storage: FileSessionStorage): Promise<SessionInfo[]> {
4171
+ return await collectSessionsFromFiles(listProjectSessionTranscriptFiles(cwd), storage);
4172
+ }
4173
+
4174
+ function mergeSessionInventories(...inventories: SessionInfo[][]): SessionInfo[] {
4175
+ const sessions = new Map<string, SessionInfo>();
4176
+ for (const inventory of inventories) {
4177
+ for (const session of inventory) {
4178
+ const current = sessions.get(session.id);
4179
+ if (!current || session.modified.getTime() > current.modified.getTime()) sessions.set(session.id, session);
4180
+ }
4181
+ }
4182
+ return [...sessions.values()].sort((left, right) => right.modified.getTime() - left.modified.getTime());
4183
+ }
4184
+
3509
4185
  const DEFAULT_WELCOME_RECENT_SESSION_LIMIT = 20;
3510
4186
 
3511
4187
  /** Get recent sessions for display in welcome screen */
@@ -3517,6 +4193,21 @@ export async function getRecentSessions(
3517
4193
  return getSortedSessions(sessionDir, storage, limit);
3518
4194
  }
3519
4195
 
4196
+ export function getRecentSessionDisplay(
4197
+ sessions: readonly SessionInfo[],
4198
+ limit = DEFAULT_WELCOME_RECENT_SESSION_LIMIT,
4199
+ ): Array<{ name: string; timeAgo: string }> {
4200
+ return sessions.slice(0, limit).map(session => {
4201
+ const recent = new RecentSessionInfo(
4202
+ session.path,
4203
+ session.modified.getTime(),
4204
+ { title: session.title, timestamp: session.created.toISOString() },
4205
+ session.firstMessage,
4206
+ );
4207
+ return { name: recent.name, timeAgo: recent.timeAgo };
4208
+ });
4209
+ }
4210
+
3520
4211
  /**
3521
4212
  * Manages conversation sessions as append-only trees stored in JSONL files.
3522
4213
  *
@@ -3623,7 +4314,6 @@ const SESSION_NAME_MAX_CHARS = 1_000;
3623
4314
  const SESSION_LIST_PARALLEL_THRESHOLD = 64;
3624
4315
  const SESSION_LIST_MAX_WORKERS = 16;
3625
4316
  const sessionListPrefixDecoder = new TextDecoder("utf-8", { fatal: false });
3626
- let residentCacheInstanceCounter = 0;
3627
4317
 
3628
4318
  async function readSessionListPrefix(file: string, storage: SessionStorage, buffer: Buffer): Promise<string> {
3629
4319
  if (!(storage instanceof FileSessionStorage)) {
@@ -4003,6 +4693,34 @@ interface SessionManagerStateSnapshot {
4003
4693
  materializedFileEntries: FileEntry[];
4004
4694
  }
4005
4695
 
4696
+ /** Benchmark-derived cap for strong materialized session snapshots. */
4697
+ export const MATERIALIZED_CACHE_MAX_BYTES = 32 * 1024 * 1024;
4698
+
4699
+ /** Test-only cache retention and transition seams; these are intentionally not user settings. */
4700
+ export const SessionManagerTestHooks: {
4701
+ materializedCacheMaxBytesOverride?: number;
4702
+ beforeResidentTransitionIndexBuild?: () => void;
4703
+ } = {};
4704
+
4705
+ function materializedCacheMaxBytes(): number {
4706
+ const override = SessionManagerTestHooks.materializedCacheMaxBytesOverride;
4707
+ if (override === undefined) return MATERIALIZED_CACHE_MAX_BYTES;
4708
+ if (!Number.isSafeInteger(override) || override < 0)
4709
+ throw new RangeError("materializedCacheMaxBytesOverride must be a non-negative safe integer.");
4710
+ return override;
4711
+ }
4712
+
4713
+ type ManagedDestinationTransition = {
4714
+ readonly directory: string;
4715
+ readonly destination: SessionDestination;
4716
+ readonly store: ManagedSessionDescendantStore;
4717
+ adopt(): void;
4718
+ /** Release the superseded authority once rollback is no longer possible. */
4719
+ settle(): void;
4720
+ rollback(): void;
4721
+ dispose(): void;
4722
+ };
4723
+
4006
4724
  export class SessionManager {
4007
4725
  #sessionId: string = "";
4008
4726
  /** True once a lifecycle pre-allocated id has been adopted (consume-once). */
@@ -4015,6 +4733,8 @@ export class SessionManager {
4015
4733
  #needsFullRewriteOnNextPersist: boolean = false;
4016
4734
  #ensuredOnDisk: boolean = false;
4017
4735
  #recoveryHydrationContext: RecoveryHydrationContext | undefined;
4736
+ #recoveryPromotionTranscriptPath: string | undefined;
4737
+ #memoryGuardParticipantIngressToken: symbol | undefined;
4018
4738
  #fileEntries: FileEntry[] = [];
4019
4739
  #pendingStrictAdoption: { canonicalPath: string; identity: ResumeSessionIdentity } | undefined;
4020
4740
  #byId: Map<string, SessionEntry> = new Map();
@@ -4039,6 +4759,7 @@ export class SessionManager {
4039
4759
  #artifactManager: ArtifactManager | null = null;
4040
4760
  #artifactManagerSessionFile: string | null = null;
4041
4761
  #managedTranscriptStoreCache: { directory: string; store: ManagedSessionDescendantStore } | null = null;
4762
+ #ownedManagedAuthority: ManagedSessionDescendantStore | undefined;
4042
4763
  // When set, take precedence over the lazily-derived per-session manager.
4043
4764
  // Subagents adopt the parent's manager so artifact IDs are unique across the
4044
4765
  // whole agent tree and all files land in the parent's artifacts dir.
@@ -4049,7 +4770,8 @@ export class SessionManager {
4049
4770
  #inMemoryArtifactCounter = 0;
4050
4771
  readonly #blobStore: BlobStore;
4051
4772
  #residentTextBlobStore: BlobStore = new MemoryBlobStore();
4052
- readonly #residentImageBlobStore: BlobStore;
4773
+ #residentImageBlobStore: BlobStore;
4774
+ #memoryGuardCheckpointBlobs: Map<string, Buffer> | undefined;
4053
4775
  #entryRevision = 0;
4054
4776
  #leafRevision = 0;
4055
4777
  /** Export/header cache invalidation contract; consumers may arrive after the revision field. */
@@ -4058,8 +4780,9 @@ export class SessionManager {
4058
4780
  #labelRevision = 0;
4059
4781
  #replayMetadataRevision = 0;
4060
4782
  #materializedEntriesRevision = -1;
4061
- #materializedEntriesCache: SessionEntry[] | undefined;
4062
- #sessionContextCache: SessionContext | undefined;
4783
+ #materializedEntriesCache: MaterializedCacheReference<SessionEntry[]> | undefined;
4784
+ #sessionContextCache: MaterializedCacheReference<SessionContext> | undefined;
4785
+ #materializedCachesWeaklyHeld = false;
4063
4786
  #sessionContextEntryRevision = -1;
4064
4787
  #sessionContextLeafRevision = -1;
4065
4788
  #sessionContextReplayMetadataRevision = -1;
@@ -4067,11 +4790,15 @@ export class SessionManager {
4067
4790
  #coldSpillReadCount = 0;
4068
4791
  #residentTextReadCount = 0;
4069
4792
  #residentImageReadCount = 0;
4793
+ #residentCacheAdoptFallbackCount = 0;
4794
+ #residentCacheTrustRejectCount = 0;
4795
+ #residentCacheWin32FallbackCount = 0;
4070
4796
  #publicMaterializerCallCount = 0;
4071
4797
  #getEntryMaterializerCallCount = 0;
4072
4798
  #getBranchMaterializerCallCount = 0;
4073
4799
  #getEntriesMaterializerCallCount = 0;
4074
4800
  #materializedEntriesCachePopulateCount = 0;
4801
+ #materializedCacheDemotedCount = 0;
4075
4802
  #pathOnlyContextBuildCount = 0;
4076
4803
  #internalReadAccess: SessionManagerReadAccess = {
4077
4804
  getEntries: () => freezeInternalReadSnapshot(this.#getMaterializedEntriesInternal()),
@@ -4110,37 +4837,202 @@ export class SessionManager {
4110
4837
  };
4111
4838
  }
4112
4839
 
4113
- #residentBlobStoresForColdRehydrate(): ResidentBlobStores {
4114
- return {
4115
- ...this.#residentBlobStores(),
4116
- onResidentBlobRead: kind => {
4117
- if (kind === "text") {
4118
- this.#residentTextReadCount++;
4119
- } else {
4120
- this.#residentImageReadCount++;
4121
- }
4840
+ /**
4841
+ * Build a one-shot fallback from the materialized transcript. Resident sentinels
4842
+ * are expanded before persistence, so a persisted value is accepted only when its
4843
+ * hash matches a currently referenced resident text blob.
4844
+ */
4845
+ #createPersistedResidentTextFallback(): ((hash: string) => Buffer | null) | undefined {
4846
+ if (!this.#sessionFile || !(this.storage instanceof FileSessionStorage)) return undefined;
4847
+ const requested = new Set<string>();
4848
+ for (const entry of this.#fileEntries) collectResidentTextBlobHashes(entry, requested);
4849
+ if (requested.size === 0) return undefined;
4850
+
4851
+ let recovered: Map<string, Buffer> | undefined;
4852
+ return hash => {
4853
+ if (!requested.has(hash)) return null;
4854
+ if (!recovered) {
4855
+ recovered = new Map<string, Buffer>();
4856
+ try {
4857
+ const bytes =
4858
+ this.destination.kind === "managed"
4859
+ ? this.#managedTranscriptStore().readExpected(path.basename(this.#sessionFile!))?.bytes
4860
+ : captureManagedFileNoFollow(this.#sessionFile!).bytes;
4861
+ if (!bytes) return null;
4862
+ const content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
4863
+ for (const entry of parseSessionEntries(content)) {
4864
+ recoverPersistedResidentTextBuffers(entry, requested, recovered);
4865
+ }
4866
+ } catch {
4867
+ // The normal source lookup reports a missing resident blob if the canonical
4868
+ // transcript cannot be captured safely or does not contain the full value.
4869
+ }
4870
+ }
4871
+ return recovered.get(hash) ?? null;
4872
+ };
4873
+ }
4874
+
4875
+ #residentBlobStoresForColdRehydrate(): ResidentBlobStores {
4876
+ return {
4877
+ ...this.#residentBlobStores(),
4878
+ onResidentBlobRead: kind => {
4879
+ if (kind === "text") {
4880
+ this.#residentTextReadCount++;
4881
+ } else {
4882
+ this.#residentImageReadCount++;
4883
+ }
4122
4884
  },
4123
4885
  };
4124
4886
  }
4887
+ #coldSpillReadStore(): BlobStore {
4888
+ return this.#memoryGuardCheckpointBlobs ? this.#residentImageBlobStore : this.#blobStore;
4889
+ }
4125
4890
 
4126
- #residentCacheDir(sessionFile: string): string {
4127
- const instance = ++residentCacheInstanceCounter;
4128
- return path.join(
4129
- sessionFile.slice(0, -6),
4130
- "resident-cache",
4131
- `${this.#sessionId || "pending"}-${process.pid}-${instance}`,
4132
- );
4891
+ #residentCacheProfileAgentDir(): string {
4892
+ return this.destination.kind === "managed"
4893
+ ? this.destination.securityContext.profileAgentDir
4894
+ : (explicitProfileAgentDirs.get(this.destination) ?? getAgentDir());
4133
4895
  }
4134
4896
 
4135
- #reexternalizeFileEntriesForResidentStore(): void {
4136
- this.#fileEntries = this.#fileEntries.map(entry =>
4137
- prepareEntryForResidentSync(entry, this.#residentBlobStores()),
4897
+ #newCanonicalResidentTextStore(): MemoryBlobStore {
4898
+ return new MemoryBlobStore({ ownership: "canonical" });
4899
+ }
4900
+
4901
+ #newResidentTextStoreCandidate(target: ResidentTransitionInput["target"]): { store: BlobStore; ownsStore: boolean } {
4902
+ if (!this.persist || !target.sessionFile || !(this.storage instanceof FileSessionStorage)) {
4903
+ return { store: this.#newCanonicalResidentTextStore(), ownsStore: false };
4904
+ }
4905
+ if (process.platform === "win32") {
4906
+ this.#residentCacheWin32FallbackCount++;
4907
+ return { store: this.#newCanonicalResidentTextStore(), ownsStore: false };
4908
+ }
4909
+ const instanceDir = openVerifiedResidentCacheInstanceDir(
4910
+ getResidentCacheRootDir(this.#residentCacheProfileAgentDir()),
4911
+ );
4912
+ return { store: EphemeralBlobStore.adoptVerifiedDir(instanceDir), ownsStore: true };
4913
+ }
4914
+
4915
+ #preparedResidentTransitionFromSource(
4916
+ source: ResidentTransitionSource,
4917
+ target: ResidentTransitionInput["target"],
4918
+ store: BlobStore,
4919
+ ownsStore: boolean,
4920
+ ): PreparedResidentStoreTransition {
4921
+ if (source.mode === "adopt-staged") {
4922
+ assertResidentReferencesResolvableSync(source.stagedEntries, {
4923
+ textStore: source.stagedStore,
4924
+ imageStore: this.#residentImageBlobStore,
4925
+ sessionId: target.sessionId,
4926
+ sessionFile: target.sessionFile || undefined,
4927
+ });
4928
+ return new PreparedResidentStoreTransition(
4929
+ source.stagedEntries,
4930
+ source.stagedStore,
4931
+ source.stagedIndex ?? this.#buildIndexForEntries(source.stagedEntries, target.sessionFile),
4932
+ false,
4933
+ );
4934
+ }
4935
+ const sourceTextStore = source.sourceStores.textStore ?? new MemoryBlobStore();
4936
+ const materializedEntries = materializeResidentEntriesSync(
4937
+ source.sourceEntries,
4938
+ {
4939
+ textStore: sourceTextStore,
4940
+ imageStore: source.sourceStores.imageStore,
4941
+ textFallback: source.sourceStores.textFallback,
4942
+ },
4943
+
4944
+ source.missingPolicy,
4945
+ );
4946
+ const entries = materializedEntries.map(entry =>
4947
+ prepareEntryForResidentSync(entry, {
4948
+ textStore: store,
4949
+ imageStore: source.sourceStores.imageStore,
4950
+ sessionId: target.sessionId,
4951
+ sessionFile: target.sessionFile || undefined,
4952
+ }),
4953
+ );
4954
+ SessionManagerTestHooks.beforeResidentTransitionIndexBuild?.();
4955
+ return new PreparedResidentStoreTransition(
4956
+ entries,
4957
+ store,
4958
+ this.#buildIndexForEntries(entries, target.sessionFile || undefined),
4959
+ ownsStore,
4138
4960
  );
4139
- this.#buildIndex();
4961
+ }
4962
+
4963
+ #prepareResidentTextStoreTransition(
4964
+ input: ResidentTransitionInput,
4965
+ policy: ResidentTransitionFailurePolicy,
4966
+ ): PreparedResidentStoreTransition {
4967
+ if (policy === "install-staged" && !input.fallback)
4968
+ throw new Error("install-staged resident transition requires a staged fallback.");
4969
+ if (input.primary.mode === "adopt-staged") {
4970
+ return this.#preparedResidentTransitionFromSource(
4971
+ input.primary,
4972
+ input.target,
4973
+ input.primary.stagedStore,
4974
+ false,
4975
+ );
4976
+ }
4977
+ if (policy === "memory-only") {
4978
+ return this.#preparedResidentTransitionFromSource(
4979
+ input.primary,
4980
+ input.target,
4981
+ this.#newCanonicalResidentTextStore(),
4982
+ false,
4983
+ );
4984
+ }
4985
+ let candidate: { store: BlobStore; ownsStore: boolean } | undefined;
4986
+ try {
4987
+ candidate = this.#newResidentTextStoreCandidate(input.target);
4988
+ return this.#preparedResidentTransitionFromSource(
4989
+ input.primary,
4990
+ input.target,
4991
+ candidate.store,
4992
+ candidate.ownsStore,
4993
+ );
4994
+ } catch (error) {
4995
+ if (candidate?.ownsStore && candidate.store instanceof EphemeralBlobStore) {
4996
+ try {
4997
+ candidate.store.dispose();
4998
+ } catch (disposeError) {
4999
+ logger.warn("Failed to dispose a rejected resident cache candidate", {
5000
+ error: toError(disposeError).message,
5001
+ });
5002
+ }
5003
+ }
5004
+ if (error instanceof ResidentCacheTrustError) this.#residentCacheTrustRejectCount++;
5005
+ if (input.allowUnwritableResidentCacheFallback && isUnwritableResidentCacheFailure(error)) {
5006
+ this.#residentCacheAdoptFallbackCount++;
5007
+ return this.#preparedResidentTransitionFromSource(
5008
+ input.primary,
5009
+ input.target,
5010
+ this.#newCanonicalResidentTextStore(),
5011
+ false,
5012
+ );
5013
+ }
5014
+ if (policy === "retain-and-throw" || !isResidentCacheProvisioningFailure(error)) throw error;
5015
+ if (policy === "install-staged") {
5016
+ const fallback = input.fallback;
5017
+ if (!fallback) throw new Error("install-staged resident transition requires a staged fallback.");
5018
+ this.#residentCacheAdoptFallbackCount++;
5019
+ return this.#preparedResidentTransitionFromSource(fallback, input.target, fallback.stagedStore, false);
5020
+ }
5021
+ if (policy === "memory-fallback") {
5022
+ this.#residentCacheAdoptFallbackCount++;
5023
+ return this.#preparedResidentTransitionFromSource(
5024
+ input.primary,
5025
+ input.target,
5026
+ this.#newCanonicalResidentTextStore(),
5027
+ false,
5028
+ );
5029
+ }
5030
+ throw error;
5031
+ }
4140
5032
  }
4141
5033
 
4142
5034
  #preparePreparedNewSessionForCommit(stage: PreparedNewSessionState): void {
4143
- const residentTextBlobStore = new MemoryBlobStore();
5035
+ const residentTextBlobStore = this.#newCanonicalResidentTextStore();
4144
5036
  const residentFileEntries = stage.fileEntries.map(entry =>
4145
5037
  prepareEntryForResidentSync(entry, {
4146
5038
  textStore: residentTextBlobStore,
@@ -4157,6 +5049,21 @@ export class SessionManager {
4157
5049
  #resetMaterializedCaches(): void {
4158
5050
  this.#materializedEntriesRevision = -1;
4159
5051
  this.#materializedEntriesCache = undefined;
5052
+ this.#materializedCachesWeaklyHeld = false;
5053
+ this.#sessionContextCache = undefined;
5054
+ this.#sessionContextEntryRevision = -1;
5055
+ this.#sessionContextLeafRevision = -1;
5056
+ this.#sessionContextReplayMetadataRevision = -1;
5057
+ }
5058
+
5059
+ #holdMaterializedCachesWeakly(): void {
5060
+ if (this.#materializedCachesWeaklyHeld) return;
5061
+ this.#materializedCachesWeaklyHeld = true;
5062
+ this.#materializedCacheDemotedCount++;
5063
+ const entries = dereferenceMaterializedCache(this.#materializedEntriesCache);
5064
+ if (entries) this.#materializedEntriesCache = new WeakRef(entries);
5065
+ const context = dereferenceMaterializedCache(this.#sessionContextCache);
5066
+ if (context) this.#sessionContextCache = new WeakRef(context);
4160
5067
  }
4161
5068
 
4162
5069
  #bumpEntryRevision(): void {
@@ -4178,13 +5085,7 @@ export class SessionManager {
4178
5085
  * revision contract). Tests assert the invalidation mapping through this;
4179
5086
  * future export/label-view caches key off their respective domains.
4180
5087
  */
4181
- revisionSnapshot(): {
4182
- entry: number;
4183
- leaf: number;
4184
- headerExport: number;
4185
- label: number;
4186
- replayMetadata: number;
4187
- } {
5088
+ revisionSnapshot(): SessionManagerRevisionSnapshot {
4188
5089
  return {
4189
5090
  entry: this.#entryRevision,
4190
5091
  leaf: this.#leafRevision,
@@ -4194,26 +5095,170 @@ export class SessionManager {
4194
5095
  };
4195
5096
  }
4196
5097
 
4197
- #disposeResidentTextBlobStore(): void {
4198
- if (this.#residentTextBlobStore instanceof EphemeralBlobStore) {
4199
- this.#residentTextBlobStore.dispose();
5098
+ #disposeResidentTextStore(store: BlobStore): void {
5099
+ if (!(store instanceof EphemeralBlobStore)) return;
5100
+ try {
5101
+ store.dispose();
5102
+ } catch (error) {
5103
+ logger.warn("Failed to dispose a replaced resident cache store", {
5104
+ error: toError(error).message,
5105
+ });
5106
+ }
5107
+ }
5108
+
5109
+ async #cleanupForkTranscriptPublication(publication: ForkTranscriptPublication): Promise<void> {
5110
+ if (publication.kind === "managed") {
5111
+ const snapshot = publication.snapshot ?? publication.store.readExpected(publication.relativePath);
5112
+ if (!snapshot) return;
5113
+ if (!snapshot.bytes.equals(publication.publishedBytes)) throw new Error("fork_transcript_changed");
5114
+ publication.store.removeExpected(publication.relativePath, snapshot);
5115
+ return;
5116
+ }
5117
+ if (publication.kind === "explicit-storage") {
5118
+ if (publication.snapshot) {
5119
+ const current = this.storage.readSnapshotSync?.(publication.sessionFile);
5120
+ if (
5121
+ !current ||
5122
+ !sameSessionStorageSnapshot(current, publication.snapshot) ||
5123
+ !Buffer.from(current.bytes).equals(publication.publishedBytes)
5124
+ ) {
5125
+ throw new Error("fork_transcript_changed");
5126
+ }
5127
+ } else {
5128
+ if (!this.storage.existsSync(publication.sessionFile)) return;
5129
+ const current = Buffer.from(this.storage.readTextSync(publication.sessionFile), "utf8");
5130
+ if (!current.equals(publication.publishedBytes)) throw new Error("fork_transcript_changed");
5131
+ }
5132
+ await this.storage.unlink(publication.sessionFile);
5133
+ return;
5134
+ }
5135
+ let snapshot = publication.snapshot;
5136
+ if (!snapshot) {
5137
+ try {
5138
+ snapshot = captureManagedFileNoFollow(publication.sessionFile);
5139
+ } catch (error) {
5140
+ if (isEnoent(error)) return;
5141
+ throw error;
5142
+ }
5143
+ }
5144
+ if (!snapshot.bytes.equals(publication.publishedBytes)) throw new Error("fork_transcript_changed");
5145
+ const removed = native.exactUnlink(publication.sessionFile, {
5146
+ dev: snapshot.identity.dev,
5147
+ ino: snapshot.identity.ino,
5148
+ size: BigInt(snapshot.identity.size),
5149
+ mtimeNs: snapshot.identity.mtimeNs,
5150
+ sha256: snapshot.identity.sha256,
5151
+ quarantineName: `.gjc-fork-${process.pid}-${crypto.randomUUID()}`,
5152
+ });
5153
+ if (
5154
+ !removed.ok &&
5155
+ !(
5156
+ removed.code === "cleanup_pending" &&
5157
+ (removed.detachedPath ??
5158
+ removed.retainedSuccessorPath ??
5159
+ removed.retainedPlaceholderPath ??
5160
+ removed.retainedUnknownPath) !== undefined
5161
+ )
5162
+ ) {
5163
+ throw new Error(removed.code ?? "fork_transcript_cleanup_failed");
4200
5164
  }
4201
- this.#residentTextBlobStore = new MemoryBlobStore();
4202
- this.#resetMaterializedCaches();
4203
5165
  }
4204
5166
 
4205
- #resetResidentTextBlobStore(): void {
4206
- this.#disposeResidentTextBlobStore();
5167
+ #cleanupForkArtifactPublication(publication: ForkArtifactPublication): void {
5168
+ if (publication.kind === "managed") {
5169
+ publication.cleanupStore.removeTreeExpected(publication.cleanupRelativePath, publication.snapshot);
5170
+ return;
5171
+ }
5172
+ const removed = native.exactRemoveDirectoryTree(publication.artifactsDir, publication.snapshot);
4207
5173
  if (
4208
- this.destination.kind === "explicit" &&
4209
- this.persist &&
4210
- this.#sessionFile &&
4211
- this.storage instanceof FileSessionStorage
5174
+ !removed.ok &&
5175
+ !(
5176
+ removed.code === "cleanup_pending" &&
5177
+ (removed.detachedPath ??
5178
+ removed.retainedSuccessorPath ??
5179
+ removed.retainedPlaceholderPath ??
5180
+ removed.retainedUnknownPath) !== undefined
5181
+ )
4212
5182
  ) {
4213
- this.#residentTextBlobStore = new EphemeralBlobStore(this.#residentCacheDir(this.#sessionFile));
5183
+ throw new Error(removed.code ?? "fork_artifact_cleanup_failed");
5184
+ }
5185
+ }
5186
+
5187
+ #commitResidentTextStoreTransition(prepared: PreparedResidentStoreTransition): void {
5188
+ const predecessor = this.#residentTextBlobStore;
5189
+ this.#fileEntries = prepared.entries;
5190
+ this.#residentTextBlobStore = prepared.store;
5191
+ this.#byId = prepared.index.byId;
5192
+ this.#labelsById = prepared.index.labelsById;
5193
+ this.#leafId = prepared.index.leafId;
5194
+ this.#usageStatistics = prepared.index.usageStatistics;
5195
+ this.#bumpAllRevisions();
5196
+ prepared.adopt();
5197
+ if (predecessor !== prepared.store) this.#disposeResidentTextStore(predecessor);
5198
+ }
5199
+
5200
+ #releaseResidentTextStore(): void {
5201
+ const predecessor = this.#residentTextBlobStore;
5202
+ this.#residentTextBlobStore = new MemoryBlobStore();
5203
+ this.#resetMaterializedCaches();
5204
+ this.#disposeResidentTextStore(predecessor);
5205
+ }
5206
+
5207
+ #demoteResidentTextStoreAfterTrustReject(error: ResidentCacheTrustError): void {
5208
+ const predecessor = this.#residentTextBlobStore;
5209
+ (predecessor as ResidentCacheDegradedStore).degradedReason = error.reason;
5210
+ this.#residentCacheTrustRejectCount++;
5211
+ logger.warn("Resident cache trust rejection; demoting resident text store", {
5212
+ sessionId: this.#sessionId,
5213
+ sessionFile: this.#sessionFile,
5214
+ reason: error.reason,
5215
+ });
5216
+ const persistedTextFallback =
5217
+ predecessor instanceof EphemeralBlobStore ? this.#createPersistedResidentTextFallback() : undefined;
5218
+ const prepared = this.#prepareResidentTextStoreTransition(
5219
+ {
5220
+ target: { sessionId: this.#sessionId || "pending", sessionFile: this.#sessionFile ?? "" },
5221
+ primary: {
5222
+ mode: "materialize",
5223
+ sourceEntries: this.#fileEntries,
5224
+ sourceStores: {
5225
+ textStore: predecessor,
5226
+ imageStore: this.#residentImageBlobStore,
5227
+ // Read buffered bytes before EphemeralBlobStore revalidates a root that may have been swapped.
5228
+ // Persisted JSONL is a canonical fallback for resident entries evicted from the LRU.
5229
+ textFallback:
5230
+ predecessor instanceof EphemeralBlobStore
5231
+ ? hash => predecessor.getBufferedSync(hash) ?? persistedTextFallback?.(hash) ?? null
5232
+ : undefined,
5233
+ },
5234
+ },
5235
+ },
5236
+ "memory-only",
5237
+ );
5238
+ (prepared.store as ResidentCacheDegradedStore).degradedReason = error.reason;
5239
+ this.#commitResidentTextStoreTransition(prepared);
5240
+ }
5241
+
5242
+ #retryResidentPutAfterTrustReject<T>(operation: () => T): T {
5243
+ try {
5244
+ return operation();
5245
+ } catch (error) {
5246
+ if (!(error instanceof ResidentCacheTrustError)) throw error;
5247
+ this.#demoteResidentTextStoreAfterTrustReject(error);
5248
+ return operation();
4214
5249
  }
4215
5250
  }
4216
5251
 
5252
+ #prepareEntryForCurrentResidentStore(entry: FileEntry): FileEntry {
5253
+ return this.#retryResidentPutAfterTrustReject(() =>
5254
+ prepareEntryForResidentSync(entry, this.#residentBlobStores()),
5255
+ );
5256
+ }
5257
+
5258
+ #putResidentTextBlobSync(data: Buffer): BlobPutResult {
5259
+ return this.#retryResidentPutAfterTrustReject(() => this.#residentTextBlobStore.putSync(data));
5260
+ }
5261
+
4217
5262
  /** Puts a binary blob into the blob store and returns the blob reference */
4218
5263
  async putBlob(data: Buffer): Promise<BlobPutResult> {
4219
5264
  return this.#blobStore.put(data);
@@ -4243,6 +5288,17 @@ export class SessionManager {
4243
5288
 
4244
5289
  restoreState(snapshot: SessionManagerStateSnapshot): void {
4245
5290
  const restoredFileEntries = [...snapshot.materializedFileEntries];
5291
+ const prepared = this.#prepareResidentTextStoreTransition(
5292
+ {
5293
+ target: { sessionId: snapshot.sessionId, sessionFile: snapshot.sessionFile ?? "" },
5294
+ primary: {
5295
+ mode: "materialize",
5296
+ sourceEntries: restoredFileEntries,
5297
+ sourceStores: { textStore: null, imageStore: this.#residentImageBlobStore },
5298
+ },
5299
+ },
5300
+ "retain-and-throw",
5301
+ );
4246
5302
  const retainsPersistWriter =
4247
5303
  this.#persistWriter?.isOpen() === true &&
4248
5304
  this.#sessionId === snapshot.sessionId &&
@@ -4255,7 +5311,6 @@ export class SessionManager {
4255
5311
  this.#flushed = snapshot.flushed;
4256
5312
  this.#ensuredOnDisk = snapshot.ensuredOnDisk;
4257
5313
  this.#needsFullRewriteOnNextPersist = snapshot.needsFullRewriteOnNextPersist;
4258
- this.#fileEntries = restoredFileEntries;
4259
5314
  if (!retainsPersistWriter) {
4260
5315
  this.#persistWriter = undefined;
4261
5316
  this.#persistWriterPath = undefined;
@@ -4266,101 +5321,312 @@ export class SessionManager {
4266
5321
  this.#artifactManager = null;
4267
5322
  this.#artifactManagerSessionFile = null;
4268
5323
  this.#adoptedArtifactManager = null;
4269
- this.#resetResidentTextBlobStore();
4270
- this.#reexternalizeFileEntriesForResidentStore();
4271
- this.#bumpAllRevisions();
4272
- if (this.#sessionFile) {
4273
- writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
4274
- }
5324
+ this.#commitResidentTextStoreTransition(prepared);
5325
+ if (this.#sessionFile) writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
4275
5326
  }
4276
5327
 
4277
- /** Initialize with a specific session file (used by factory methods) */
5328
+ #freshSessionState(options?: NewSessionOptions, sessionFileOverride?: string): FreshSessionState {
5329
+ const preallocated = this.#lifecycleIdAdopted ? undefined : lifecyclePreallocatedSessionId();
5330
+ const sessionId = preallocated ?? createSessionId();
5331
+ const timestamp = new Date().toISOString();
5332
+ const sessionFile =
5333
+ sessionFileOverride ??
5334
+ (this.persist
5335
+ ? path.join(this.getSessionDir(), `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`)
5336
+ : undefined);
5337
+ return {
5338
+ sessionId,
5339
+ sessionFile,
5340
+ header: {
5341
+ type: "session",
5342
+ version: CURRENT_SESSION_VERSION,
5343
+ id: sessionId,
5344
+ timestamp,
5345
+ cwd: this.cwd,
5346
+ parentSession: options?.parentSession,
5347
+ },
5348
+ adoptsLifecycleId: preallocated !== undefined,
5349
+ };
5350
+ }
5351
+
5352
+ #applyFreshSessionMetadata(state: FreshSessionState): void {
5353
+ if (state.adoptsLifecycleId) this.#lifecycleIdAdopted = true;
5354
+ this.#persistChain = Promise.resolve();
5355
+ this.#persistError = undefined;
5356
+ this.#persistErrorReported = false;
5357
+ this.#sessionId = state.sessionId;
5358
+ this.#sessionName = state.header.title;
5359
+ this.#titleSource = state.header.titleSource;
5360
+ this.#sessionFile = state.sessionFile;
5361
+ this.#flushed = false;
5362
+ this.#needsFullRewriteOnNextPersist = false;
5363
+ this.#ensuredOnDisk = false;
5364
+ this.#inMemoryArtifacts = null;
5365
+ this.#inMemoryArtifactCounter = 0;
5366
+ this.#artifactManager = null;
5367
+ this.#artifactManagerSessionFile = null;
5368
+ this.#adoptedArtifactManager = null;
5369
+ }
5370
+
5371
+ #prepareFreshSessionTransition(
5372
+ state: FreshSessionState,
5373
+ policy: Exclude<ResidentTransitionFailurePolicy, "install-staged">,
5374
+ ): PreparedResidentStoreTransition {
5375
+ return this.#prepareResidentTextStoreTransition(
5376
+ {
5377
+ target: { sessionId: state.sessionId, sessionFile: state.sessionFile ?? "" },
5378
+ primary: {
5379
+ mode: "materialize",
5380
+ sourceEntries: [state.header],
5381
+ sourceStores: { textStore: null, imageStore: this.#residentImageBlobStore },
5382
+ },
5383
+ },
5384
+ policy,
5385
+ );
5386
+ }
5387
+
5388
+ /** Initialize with a specific session file (used by factory methods). */
4278
5389
  async #initSessionFile(sessionFile: string): Promise<void> {
4279
- await this.setSessionFile(sessionFile);
5390
+ const resolvedSessionFile = this.storage instanceof FileSessionStorage ? path.resolve(sessionFile) : sessionFile;
5391
+ const entries = await loadEntriesFromFile(resolvedSessionFile, this.storage);
5392
+ if (entries.length === 0) {
5393
+ const fresh = this.#freshSessionState(undefined, resolvedSessionFile);
5394
+ const prepared = this.#prepareFreshSessionTransition(fresh, "memory-fallback");
5395
+ this.#applyFreshSessionMetadata(fresh);
5396
+ this.#commitResidentTextStoreTransition(prepared);
5397
+ writeTerminalBreadcrumb(this.cwd, resolvedSessionFile);
5398
+ await this.#rewriteFile();
5399
+ this.#flushed = true;
5400
+ this.#ensuredOnDisk = true;
5401
+ return;
5402
+ }
5403
+ const header = entries.find(entry => entry.type === "session") as SessionHeader | undefined;
5404
+ const sessionId = header?.id ?? createSessionId();
5405
+ const migrationApplied = migrateToCurrentVersion(entries);
5406
+ await resolveBlobRefsInEntries(entries, this.#blobStore);
5407
+ const prepared = this.#prepareResidentTextStoreTransition(
5408
+ {
5409
+ target: { sessionId, sessionFile: resolvedSessionFile },
5410
+ primary: {
5411
+ mode: "materialize",
5412
+ sourceEntries: entries,
5413
+ sourceStores: { textStore: null, imageStore: this.#residentImageBlobStore },
5414
+ missingPolicy: "placeholder",
5415
+ },
5416
+ },
5417
+ "memory-fallback",
5418
+ );
5419
+ this.#sessionFile = resolvedSessionFile;
5420
+ this.#sessionId = sessionId;
5421
+ this.#sessionName = header?.title;
5422
+ this.#titleSource = header?.titleSource;
5423
+ this.#needsFullRewriteOnNextPersist = migrationApplied;
5424
+ this.#commitResidentTextStoreTransition(prepared);
5425
+ writeTerminalBreadcrumb(this.cwd, resolvedSessionFile);
5426
+ this.#flushed = true;
5427
+ this.#ensuredOnDisk = true;
5428
+ await this.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
4280
5429
  }
4281
5430
 
4282
- async #hydrateExistingSession(sessionFile: string, entries: FileEntry[], migrationApplied: boolean): Promise<void> {
5431
+ async #hydrateExistingSession(
5432
+ sessionFile: string,
5433
+ entries: FileEntry[],
5434
+ migrationApplied: boolean,
5435
+ policy: Exclude<ResidentTransitionFailurePolicy, "install-staged"> = "memory-fallback",
5436
+ ): Promise<void> {
4283
5437
  // Strict inspection creates these entries solely for this hydration path. Adopt that
4284
5438
  // final, validated representation instead of cloning the complete transcript again.
4285
5439
  const header = entries[0] as SessionHeader;
4286
- this.#sessionFile = this.storage instanceof FileSessionStorage ? path.resolve(sessionFile) : sessionFile;
5440
+ const resolvedSessionFile = this.storage instanceof FileSessionStorage ? path.resolve(sessionFile) : sessionFile;
5441
+ await resolveBlobRefsInEntries(entries, this.#blobStore);
5442
+ const prepared = this.#prepareResidentTextStoreTransition(
5443
+ {
5444
+ target: { sessionId: header.id, sessionFile: resolvedSessionFile },
5445
+ primary: {
5446
+ mode: "materialize",
5447
+ sourceEntries: entries,
5448
+ sourceStores: { textStore: null, imageStore: this.#residentImageBlobStore },
5449
+ missingPolicy: "placeholder",
5450
+ },
5451
+ },
5452
+ policy,
5453
+ );
5454
+ this.#sessionFile = resolvedSessionFile;
4287
5455
  this.#sessionId = header.id;
4288
5456
  this.#sessionName = header.title;
4289
5457
  this.#titleSource = header.titleSource;
4290
5458
  this.#needsFullRewriteOnNextPersist = migrationApplied;
4291
- await resolveBlobRefsInEntries(entries, this.#blobStore);
4292
- this.#fileEntries = entries;
4293
- this.#resetResidentTextBlobStore();
4294
- this.#fileEntries = this.#fileEntries.map(entry =>
4295
- prepareEntryForResidentSync(entry, this.#residentBlobStores()),
4296
- );
4297
- this.#buildIndex();
4298
- this.#bumpAllRevisions();
5459
+ this.#commitResidentTextStoreTransition(prepared);
4299
5460
  this.#flushed = true;
4300
5461
  this.#ensuredOnDisk = true;
4301
5462
  }
4302
5463
 
4303
- /** Initialize with a new session (used by factory methods) */
5464
+ /** Initialize with a new session (used by factory methods). */
4304
5465
  #initNewSession(): void {
4305
5466
  this.#newSessionSync();
4306
- this.#bumpAllRevisions();
4307
5467
  }
4308
5468
 
4309
- /** Switch to a different session file (used for resume and branching) */
5469
+ /** Switch to a different session file (used for resume and branching). */
4310
5470
  async setSessionFile(sessionFile: string): Promise<void> {
5471
+ this.#assertRecoveryHydrationWritable();
4311
5472
  const resolvedSessionFile = this.storage instanceof FileSessionStorage ? path.resolve(sessionFile) : sessionFile;
4312
5473
  const strictAdoption = this.#pendingStrictAdoption;
4313
- this.#pendingStrictAdoption = undefined;
4314
- if (strictAdoption && resolvedSessionFile === strictAdoption.canonicalPath) {
4315
- const inspected = inspectResumeSessionFile(resolvedSessionFile, this.storage);
4316
- if ("kind" in inspected || !sameResumeIdentity(strictAdoption.identity, inspected.identity))
4317
- throw new Error("Prepared managed session changed before strict adoption.");
5474
+ let managedTransition: ManagedDestinationTransition | undefined;
5475
+ if (this.destination.kind === "managed") {
5476
+ const candidateDirectory = path.resolve(path.dirname(resolvedSessionFile));
5477
+ if (candidateDirectory === path.resolve(this.destination.directory)) {
5478
+ const candidateStore = this.#managedTranscriptStore(resolvedSessionFile);
5479
+ candidateStore.verifyRootSecurity();
5480
+ candidateStore.assertBound();
5481
+ } else {
5482
+ managedTransition = this.#prepareManagedDestinationTransition(candidateDirectory);
5483
+ }
4318
5484
  }
4319
- await this.#closePersistWriter();
4320
- this.#persistError = undefined;
4321
- this.#persistErrorReported = false;
4322
- this.#sessionFile = resolvedSessionFile;
4323
- writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
4324
- this.#fileEntries = await loadEntriesFromFile(this.#sessionFile, this.storage);
4325
- if (strictAdoption) {
4326
- const inspected = inspectResumeSessionFile(this.#sessionFile, this.storage);
4327
- if ("kind" in inspected || !sameResumeIdentity(strictAdoption.identity, inspected.identity))
4328
- throw new Error("Prepared managed session changed during strict adoption.");
4329
- }
4330
- if (this.#fileEntries.length > 0) {
4331
- const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
4332
- this.#sessionId = header?.id ?? createSessionId();
4333
- this.#sessionName = header?.title;
4334
- this.#titleSource = header?.titleSource;
4335
-
4336
- this.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(this.#fileEntries);
4337
- await resolveBlobRefsInEntries(this.#fileEntries, this.#blobStore);
4338
- this.#resetResidentTextBlobStore();
4339
-
4340
- this.#fileEntries = this.#fileEntries.map(entry =>
4341
- prepareEntryForResidentSync(entry, this.#residentBlobStores()),
4342
- );
4343
-
4344
- this.#buildIndex();
4345
- this.#bumpAllRevisions();
4346
- this.#flushed = true;
4347
- this.#ensuredOnDisk = true;
4348
- await this.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
4349
- } else {
4350
- const explicitPath = this.#sessionFile;
4351
- this.#newSessionSync();
4352
- this.#sessionFile = explicitPath; // preserve explicit path from --session flag
4353
- this.#resetResidentTextBlobStore();
5485
+ try {
5486
+ if (strictAdoption && resolvedSessionFile === strictAdoption.canonicalPath) {
5487
+ const inspected = inspectResumeSessionFile(resolvedSessionFile, this.storage);
5488
+ if ("kind" in inspected || !sameResumeIdentity(strictAdoption.identity, inspected.identity))
5489
+ throw new Error("Prepared managed session changed before strict adoption.");
5490
+ }
5491
+ let entries: FileEntry[];
5492
+ let candidateMigrationApplied = false;
5493
+ if (this.storage.existsSync(resolvedSessionFile)) {
5494
+ const inspected = inspectResumeSessionFile(resolvedSessionFile, this.storage);
5495
+ if ("kind" in inspected) throw new Error(`Could not switch session: ${inspected.reason}`);
5496
+ entries = inspected.entries;
5497
+ candidateMigrationApplied = inspected.migrationApplied;
5498
+ } else {
5499
+ entries = await loadEntriesFromFile(resolvedSessionFile, this.storage);
5500
+ }
5501
+ if (strictAdoption) {
5502
+ const inspected = inspectResumeSessionFile(resolvedSessionFile, this.storage);
5503
+ if ("kind" in inspected || !sameResumeIdentity(strictAdoption.identity, inspected.identity))
5504
+ throw new Error("Prepared managed session changed during strict adoption.");
5505
+ }
5506
+ if (entries.length > 0) {
5507
+ const header = entries.find(entry => entry.type === "session") as SessionHeader | undefined;
5508
+ const sessionId = header?.id ?? createSessionId();
5509
+ const migrationApplied = candidateMigrationApplied || migrateToCurrentVersion(entries);
5510
+ await resolveBlobRefsInEntries(entries, this.#blobStore);
5511
+ const prepared = this.#prepareResidentTextStoreTransition(
5512
+ {
5513
+ target: { sessionId, sessionFile: resolvedSessionFile },
5514
+ allowUnwritableResidentCacheFallback: true,
5515
+ primary: {
5516
+ mode: "materialize",
5517
+ sourceEntries: entries,
5518
+ sourceStores: { textStore: null, imageStore: this.#residentImageBlobStore },
5519
+ },
5520
+ },
5521
+ "retain-and-throw",
5522
+ );
5523
+ try {
5524
+ await this.#closePersistWriter();
5525
+ } catch (error) {
5526
+ prepared.dispose();
5527
+ throw error;
5528
+ }
5529
+ const previous = {
5530
+ sessionId: this.#sessionId,
5531
+ sessionName: this.#sessionName,
5532
+ titleSource: this.#titleSource,
5533
+ sessionFile: this.#sessionFile,
5534
+ needsFullRewriteOnNextPersist: this.#needsFullRewriteOnNextPersist,
5535
+ };
5536
+ this.#persistError = undefined;
5537
+ this.#persistErrorReported = false;
5538
+ this.#sessionFile = resolvedSessionFile;
5539
+ this.#sessionId = sessionId;
5540
+ this.#sessionName = header?.title;
5541
+ this.#titleSource = header?.titleSource;
5542
+ this.#needsFullRewriteOnNextPersist = migrationApplied;
5543
+ try {
5544
+ managedTransition?.adopt();
5545
+ writeTerminalBreadcrumb(this.cwd, resolvedSessionFile);
5546
+ this.#commitResidentTextStoreTransition(prepared);
5547
+ } catch (error) {
5548
+ managedTransition?.rollback();
5549
+ this.#sessionId = previous.sessionId;
5550
+ this.#sessionName = previous.sessionName;
5551
+ this.#titleSource = previous.titleSource;
5552
+ this.#sessionFile = previous.sessionFile;
5553
+ this.#needsFullRewriteOnNextPersist = previous.needsFullRewriteOnNextPersist;
5554
+ prepared.dispose();
5555
+ throw error;
5556
+ }
5557
+ managedTransition?.settle();
5558
+ this.#pendingStrictAdoption = undefined;
5559
+ this.#flushed = true;
5560
+ this.#ensuredOnDisk = true;
5561
+ await this.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
5562
+ return;
5563
+ }
5564
+ const fresh = this.#freshSessionState(undefined, resolvedSessionFile);
5565
+ const prepared = this.#prepareFreshSessionTransition(fresh, "retain-and-throw");
5566
+ try {
5567
+ await this.#closePersistWriter();
5568
+ } catch (error) {
5569
+ prepared.dispose();
5570
+ throw error;
5571
+ }
5572
+ const previous = {
5573
+ lifecycleIdAdopted: this.#lifecycleIdAdopted,
5574
+ persistChain: this.#persistChain,
5575
+ persistError: this.#persistError,
5576
+ persistErrorReported: this.#persistErrorReported,
5577
+ sessionId: this.#sessionId,
5578
+ sessionName: this.#sessionName,
5579
+ titleSource: this.#titleSource,
5580
+ sessionFile: this.#sessionFile,
5581
+ flushed: this.#flushed,
5582
+ needsFullRewriteOnNextPersist: this.#needsFullRewriteOnNextPersist,
5583
+ ensuredOnDisk: this.#ensuredOnDisk,
5584
+ inMemoryArtifacts: this.#inMemoryArtifacts,
5585
+ inMemoryArtifactCounter: this.#inMemoryArtifactCounter,
5586
+ artifactManager: this.#artifactManager,
5587
+ artifactManagerSessionFile: this.#artifactManagerSessionFile,
5588
+ adoptedArtifactManager: this.#adoptedArtifactManager,
5589
+ };
5590
+ this.#applyFreshSessionMetadata(fresh);
5591
+ try {
5592
+ managedTransition?.adopt();
5593
+ writeTerminalBreadcrumb(this.cwd, resolvedSessionFile);
5594
+ this.#commitResidentTextStoreTransition(prepared);
5595
+ } catch (error) {
5596
+ managedTransition?.rollback();
5597
+ this.#lifecycleIdAdopted = previous.lifecycleIdAdopted;
5598
+ this.#persistChain = previous.persistChain;
5599
+ this.#persistError = previous.persistError;
5600
+ this.#persistErrorReported = previous.persistErrorReported;
5601
+ this.#sessionId = previous.sessionId;
5602
+ this.#sessionName = previous.sessionName;
5603
+ this.#titleSource = previous.titleSource;
5604
+ this.#sessionFile = previous.sessionFile;
5605
+ this.#flushed = previous.flushed;
5606
+ this.#needsFullRewriteOnNextPersist = previous.needsFullRewriteOnNextPersist;
5607
+ this.#ensuredOnDisk = previous.ensuredOnDisk;
5608
+ this.#inMemoryArtifacts = previous.inMemoryArtifacts;
5609
+ this.#inMemoryArtifactCounter = previous.inMemoryArtifactCounter;
5610
+ this.#artifactManager = previous.artifactManager;
5611
+ this.#artifactManagerSessionFile = previous.artifactManagerSessionFile;
5612
+ this.#adoptedArtifactManager = previous.adoptedArtifactManager;
5613
+ prepared.dispose();
5614
+ throw error;
5615
+ }
5616
+ managedTransition?.settle();
5617
+ this.#pendingStrictAdoption = undefined;
4354
5618
  await this.#rewriteFile();
4355
5619
  this.#flushed = true;
4356
5620
  this.#ensuredOnDisk = true;
4357
- this.#bumpAllRevisions();
4358
- return;
5621
+ } catch (error) {
5622
+ managedTransition?.dispose();
5623
+ throw error;
4359
5624
  }
4360
5625
  }
4361
5626
 
4362
5627
  /** Start a new session. Closes any existing writer first. */
4363
5628
  async newSession(options?: NewSessionOptions): Promise<string | undefined> {
5629
+ this.#assertRecoveryHydrationWritable();
4364
5630
  const prepared = await this.prepareNewSession(options);
4365
5631
  try {
4366
5632
  this.commitPreparedNewSession(prepared);
@@ -4385,6 +5651,7 @@ export class SessionManager {
4385
5651
  * @internal
4386
5652
  */
4387
5653
  async prepareNewSession(options?: NewSessionOptions): Promise<PreparedNewSession> {
5654
+ this.#assertRecoveryHydrationWritable();
4388
5655
  await this.#retryPreparedNewSessionCleanups();
4389
5656
  await this.#closePersistWriter();
4390
5657
  const preallocated = this.#lifecycleIdAdopted ? undefined : lifecyclePreallocatedSessionId();
@@ -4655,6 +5922,7 @@ export class SessionManager {
4655
5922
 
4656
5923
  /** Prepare a path-only branch successor without publishing it through public manager state. @internal */
4657
5924
  async prepareBranchedSession(leafId: string): Promise<PreparedNewSession> {
5925
+ this.#assertRecoveryHydrationWritable();
4658
5926
  await this.#retryPreparedNewSessionCleanups();
4659
5927
  const branchPath = this.#getCanonicalBranchClones(leafId);
4660
5928
  if (branchPath.length === 0) throw new Error(`Entry ${leafId} not found`);
@@ -4738,6 +6006,7 @@ export class SessionManager {
4738
6006
 
4739
6007
  /** Publish a prepared successor synchronously after all readiness awaits succeed. @internal */
4740
6008
  commitPreparedNewSession(prepared: PreparedNewSession): void {
6009
+ this.#assertRecoveryHydrationWritable();
4741
6010
  const stage = prepared as PreparedNewSessionState;
4742
6011
  if (!this.#preparedNewSessions.has(stage) || stage.committed || stage.discarded) {
4743
6012
  throw new Error("Prepared session is no longer available.");
@@ -4748,10 +6017,29 @@ export class SessionManager {
4748
6017
  const index = stage.index;
4749
6018
  if (!residentFileEntries || !residentTextBlobStore || !index)
4750
6019
  throw new Error("Prepared session adoption is incomplete.");
4751
- if (stage.sessionFile) writeTerminalBreadcrumb(this.cwd, stage.sessionFile);
4752
-
4753
- stage.committed = true;
4754
- this.#preparedNewSessions.delete(stage);
6020
+ const transition = this.#prepareResidentTextStoreTransition(
6021
+ {
6022
+ target: { sessionId: stage.sessionId, sessionFile: stage.sessionFile ?? "" },
6023
+ primary: {
6024
+ mode: "materialize",
6025
+ sourceEntries: stage.fileEntries,
6026
+ sourceStores: { textStore: residentTextBlobStore, imageStore: this.#residentImageBlobStore },
6027
+ },
6028
+ fallback: {
6029
+ mode: "adopt-staged",
6030
+ stagedEntries: residentFileEntries,
6031
+ stagedStore: residentTextBlobStore,
6032
+ stagedIndex: index,
6033
+ },
6034
+ },
6035
+ "install-staged",
6036
+ );
6037
+ try {
6038
+ if (stage.sessionFile) writeTerminalBreadcrumb(this.cwd, stage.sessionFile);
6039
+ } catch (error) {
6040
+ transition.dispose();
6041
+ throw error;
6042
+ }
4755
6043
  if (!this.#lifecycleIdAdopted && stage.sessionId === lifecyclePreallocatedSessionId())
4756
6044
  this.#lifecycleIdAdopted = true;
4757
6045
  this.#persistChain = Promise.resolve();
@@ -4761,12 +6049,6 @@ export class SessionManager {
4761
6049
  this.#sessionName = stage.sessionName;
4762
6050
  this.#titleSource = stage.titleSource;
4763
6051
  this.#sessionFile = stage.sessionFile;
4764
- this.#fileEntries = residentFileEntries;
4765
- this.#residentTextBlobStore = residentTextBlobStore;
4766
- this.#byId = index.byId;
4767
- this.#labelsById = index.labelsById;
4768
- this.#leafId = index.leafId;
4769
- this.#usageStatistics = index.usageStatistics;
4770
6052
  this.#flushed = stage.flushed;
4771
6053
  this.#needsFullRewriteOnNextPersist = false;
4772
6054
  this.#ensuredOnDisk = stage.flushed;
@@ -4775,7 +6057,9 @@ export class SessionManager {
4775
6057
  this.#artifactManager = null;
4776
6058
  this.#artifactManagerSessionFile = null;
4777
6059
  this.#adoptedArtifactManager = null;
4778
- this.#bumpAllRevisions();
6060
+ this.#commitResidentTextStoreTransition(transition);
6061
+ stage.committed = true;
6062
+ this.#preparedNewSessions.delete(stage);
4779
6063
  }
4780
6064
 
4781
6065
  /** Exact-discard only an uncommitted successor prepared by this manager. @internal */
@@ -4890,9 +6174,9 @@ export class SessionManager {
4890
6174
  * managed logical authorization. This is only for discarding an UNCOMMITTED
4891
6175
  * successor that a transaction (e.g. handoff) created via `newSession()` but
4892
6176
  * never durably authorized — such a session has no managed candidate listing
4893
- * yet, so `dropSession` would refuse it and leak its resident-cache/artifact
4894
- * root. Bounded to this manager's configured session root and refuses to touch
4895
- * the active session; tolerates missing files.
6177
+ * yet, so `dropSession` would refuse it and leak its artifact root. Bounded to
6178
+ * this manager's configured session root and refuses to touch the active session;
6179
+ * tolerates missing files.
4896
6180
  */
4897
6181
  async discardUncommittedSession(sessionPath: string): Promise<void> {
4898
6182
  // Close the writer FIRST so the canonical checks and the delete run with no
@@ -4931,110 +6215,154 @@ export class SessionManager {
4931
6215
  * @returns { oldSessionFile, newSessionFile } or undefined if not persisting
4932
6216
  */
4933
6217
  async fork(): Promise<{ oldSessionFile: string; newSessionFile: string } | undefined> {
4934
- if (!this.persist || !this.#sessionFile) {
4935
- return undefined;
4936
- }
6218
+ if (!this.persist || !this.#sessionFile) return undefined;
4937
6219
 
4938
6220
  const oldSessionFile = this.#sessionFile;
4939
6221
  const oldSessionId = this.#sessionId;
4940
6222
  const materializedEntries = materializeResidentEntriesForReadSync(this.#fileEntries, this.#residentBlobStores());
4941
- const oldFileEntries = this.#fileEntries;
4942
- const oldSessionName = this.#sessionName;
4943
- const oldTitleSource = this.#titleSource;
4944
-
4945
- // Close the current writer
4946
- await this.#closePersistWriter();
4947
- this.#persistChain = Promise.resolve();
4948
- this.#persistError = undefined;
4949
- this.#persistErrorReported = false;
4950
-
4951
- // Create new session ID and header
4952
- this.#sessionId = createSessionId();
4953
6223
  const timestamp = new Date().toISOString();
4954
- const fileTimestamp = timestamp.replace(/[:.]/g, "-");
4955
- this.#sessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${this.#sessionId}.jsonl`);
4956
-
4957
- // Update the header with new ID but keep all entries
4958
- const oldHeader = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
6224
+ const newSessionId = createSessionId();
6225
+ const newSessionFile = path.join(
6226
+ this.getSessionDir(),
6227
+ `${timestamp.replace(/[:.]/g, "-")}_${newSessionId}.jsonl`,
6228
+ );
6229
+ const oldHeader = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
4959
6230
  const newHeader: SessionHeader = {
4960
6231
  type: "session",
4961
6232
  version: CURRENT_SESSION_VERSION,
4962
- id: this.#sessionId,
6233
+ id: newSessionId,
4963
6234
  title: oldHeader?.title ?? this.#sessionName,
4964
6235
  titleSource: oldHeader?.titleSource ?? this.#titleSource,
4965
6236
  timestamp,
4966
6237
  cwd: this.cwd,
4967
6238
  parentSession: oldSessionId,
4968
6239
  };
4969
- this.#sessionName = newHeader.title;
4970
- this.#titleSource = newHeader.titleSource;
4971
-
4972
- // Replace the header in fileEntries
4973
- const entries = materializedEntries.filter((e): e is SessionEntry => e.type !== "session");
4974
- this.#fileEntries = [newHeader, ...entries];
4975
- this.#resetResidentTextBlobStore();
4976
- this.#reexternalizeFileEntriesForResidentStore();
4977
- this.#bumpAllRevisions();
6240
+ const entries: FileEntry[] = [
6241
+ newHeader,
6242
+ ...materializedEntries.filter((entry): entry is SessionEntry => entry.type !== "session"),
6243
+ ];
4978
6244
 
4979
- // Write the new session file, import artifacts, and adopt only after the
4980
- // retained destination manifest is complete.
4981
- this.#flushed = false;
6245
+ await this.#closePersistWriter();
6246
+ this.#persistChain = Promise.resolve();
6247
+ this.#persistError = undefined;
6248
+ this.#persistErrorReported = false;
4982
6249
  let forkArtifactPublication: ForkArtifactPublication | undefined;
4983
- let forkTranscriptPublication: ManagedFileSnapshot | null = null;
4984
- let forkTranscriptStore: ManagedSessionDescendantStore | undefined;
6250
+ let forkTranscriptPublication: ForkTranscriptPublication | undefined;
6251
+ let transition: PreparedResidentStoreTransition | undefined;
4985
6252
  try {
4986
- forkArtifactPublication = await this.copyArtifactsForFork(oldSessionFile, this.#sessionFile);
4987
- await this.#rewriteFile();
6253
+ forkArtifactPublication = await this.copyArtifactsForFork(oldSessionFile, newSessionFile);
6254
+ const content = `${entries.map(entry => JSON.stringify(prepareEntryForPersistenceSync(entry, this.#blobStore))).join("\n")}\n`;
6255
+ const contentBytes = Buffer.from(content, "utf8");
4988
6256
  if (this.destination.kind === "managed") {
4989
- forkTranscriptStore = this.#managedTranscriptStore();
4990
- forkTranscriptPublication = forkTranscriptStore.readExpected(path.basename(this.#sessionFile));
4991
- if (!forkTranscriptPublication) throw new Error("managed_fork_transcript_publish_missing");
6257
+ const store = this.#managedTranscriptStore(newSessionFile);
6258
+ const relativePath = path.basename(newSessionFile);
6259
+ await store.publishNoReplace(relativePath, contentBytes);
6260
+ const publication = { kind: "managed" as const, store, relativePath, publishedBytes: contentBytes };
6261
+ forkTranscriptPublication = publication;
6262
+ const snapshot = store.readExpected(relativePath);
6263
+ if (!snapshot) throw new Error("managed_fork_transcript_publish_missing");
6264
+ forkTranscriptPublication = { ...publication, snapshot };
6265
+ } else if (this.storage instanceof FileSessionStorage) {
6266
+ await writeOwnerOnlyFileNoReplace(newSessionFile, content);
6267
+ const publication = {
6268
+ kind: "explicit-file" as const,
6269
+ sessionFile: newSessionFile,
6270
+ publishedBytes: contentBytes,
6271
+ };
6272
+ forkTranscriptPublication = publication;
6273
+ forkTranscriptPublication = { ...publication, snapshot: captureManagedFileNoFollow(newSessionFile) };
6274
+ } else {
6275
+ if (this.storage.existsSync(newSessionFile)) throw new Error("destination_conflict");
6276
+ await this.storage.writeText(newSessionFile, content);
6277
+ const publication = {
6278
+ kind: "explicit-storage" as const,
6279
+ sessionFile: newSessionFile,
6280
+ publishedBytes: contentBytes,
6281
+ };
6282
+ forkTranscriptPublication = publication;
6283
+ const snapshot = this.storage.readSnapshotSync?.(newSessionFile);
6284
+ if (!snapshot) throw new Error("fork_transcript_identity_unavailable");
6285
+ forkTranscriptPublication = { ...publication, snapshot };
4992
6286
  }
4993
- forkArtifactPublication?.store.verifyRootSecurity();
4994
- forkArtifactPublication?.store.assertBound();
4995
- if (forkArtifactPublication) {
6287
+ if (forkArtifactPublication?.kind === "managed") {
6288
+ forkArtifactPublication.store.verifyRootSecurity();
6289
+ forkArtifactPublication.store.assertBound();
4996
6290
  const adoptedSnapshot = forkArtifactPublication.store.fsyncTree();
4997
- if (!retainedTreeSnapshotEquals(adoptedSnapshot, forkArtifactPublication.snapshot)) {
6291
+ if (!retainedTreeSnapshotEquals(adoptedSnapshot, forkArtifactPublication.snapshot))
6292
+ throw new Error("artifact_destination_changed_during_transcript_publication");
6293
+ } else if (forkArtifactPublication) {
6294
+ const terminalArtifacts = native.snapshotDirectoryTree(forkArtifactPublication.artifactsDir);
6295
+ if (
6296
+ !terminalArtifacts.ok ||
6297
+ !terminalArtifacts.snapshot ||
6298
+ !retainedTreeSnapshotEquals(terminalArtifacts.snapshot, forkArtifactPublication.snapshot)
6299
+ ) {
4998
6300
  throw new Error("artifact_destination_changed_during_transcript_publication");
4999
6301
  }
5000
6302
  }
5001
- if (forkTranscriptStore && forkTranscriptPublication) {
5002
- const terminalTranscript = forkTranscriptStore.readExpected(path.basename(this.#sessionFile));
5003
- if (!managedFileSnapshotEquals(terminalTranscript, forkTranscriptPublication))
6303
+
6304
+ if (forkTranscriptPublication?.kind === "managed") {
6305
+ const snapshot = forkTranscriptPublication.snapshot;
6306
+ const terminalTranscript = forkTranscriptPublication.store.readExpected(
6307
+ forkTranscriptPublication.relativePath,
6308
+ );
6309
+ if (
6310
+ !snapshot?.bytes.equals(forkTranscriptPublication.publishedBytes) ||
6311
+ !managedFileSnapshotEquals(terminalTranscript, snapshot ?? null)
6312
+ ) {
5004
6313
  throw new Error("managed_fork_transcript_changed");
6314
+ }
5005
6315
  }
6316
+ transition = this.#prepareResidentTextStoreTransition(
6317
+ {
6318
+ target: { sessionId: newSessionId, sessionFile: newSessionFile },
6319
+ primary: {
6320
+ mode: "materialize",
6321
+ sourceEntries: entries,
6322
+ sourceStores: { textStore: null, imageStore: this.#residentImageBlobStore },
6323
+ },
6324
+ },
6325
+ "retain-and-throw",
6326
+ );
5006
6327
  } catch (error) {
5007
- const failure = error;
6328
+ transition?.dispose();
6329
+ const failure = toError(error);
6330
+ const cleanupErrors: Error[] = [];
6331
+ if (forkTranscriptPublication) {
6332
+ try {
6333
+ await this.#cleanupForkTranscriptPublication(forkTranscriptPublication);
6334
+ } catch (cleanupError) {
6335
+ cleanupErrors.push(toError(cleanupError));
6336
+ }
6337
+ }
5008
6338
  if (forkArtifactPublication) {
5009
6339
  try {
5010
- forkArtifactPublication.cleanupStore.removeTreeExpected(
5011
- forkArtifactPublication.cleanupRelativePath,
5012
- forkArtifactPublication.snapshot,
5013
- );
6340
+ this.#cleanupForkArtifactPublication(forkArtifactPublication);
5014
6341
  } catch (cleanupError) {
5015
- const cleanupMessage = toError(cleanupError).message;
5016
- if (cleanupMessage !== "cleanup_pending") {
5017
- throw new Error(`Failed to clean up fork artifacts: ${cleanupMessage}`, {
5018
- cause: toError(failure),
5019
- });
5020
- }
6342
+ cleanupErrors.push(toError(cleanupError));
5021
6343
  }
5022
6344
  }
5023
- this.#sessionId = oldSessionId;
5024
- this.#sessionFile = oldSessionFile;
5025
- this.#fileEntries = oldFileEntries;
5026
- this.#sessionName = oldSessionName;
5027
- this.#titleSource = oldTitleSource;
5028
- this.#persistChain = Promise.resolve();
5029
- this.#persistError = undefined;
5030
- this.#persistErrorReported = false;
5031
- this.#resetResidentTextBlobStore();
5032
- this.#reexternalizeFileEntriesForResidentStore();
5033
- this.#bumpAllRevisions();
6345
+ // A POSIX quarantine (`cleanup_pending` with a retained recovery path) IS a
6346
+ // successful cleanup: the tree was detached to `<name>.removing` and no live
6347
+ // artifact survives. Only an independently real cleanup failure may supersede
6348
+ // the primary error; otherwise the original failure must reach the caller.
6349
+ const realCleanupErrors = cleanupErrors.filter(cleanupError => !isAuthorizedPendingCleanup(cleanupError));
6350
+ if (realCleanupErrors.length > 0) {
6351
+ throw new Error(`Failed to clean up fork publication: ${realCleanupErrors[0]!.message}`, {
6352
+ cause: failure,
6353
+ });
6354
+ }
5034
6355
  throw failure;
5035
6356
  }
5036
-
5037
- return { oldSessionFile, newSessionFile: this.#sessionFile };
6357
+ this.#sessionId = newSessionId;
6358
+ this.#sessionFile = newSessionFile;
6359
+ this.#sessionName = newHeader.title;
6360
+ this.#titleSource = newHeader.titleSource;
6361
+ this.#flushed = true;
6362
+ this.#needsFullRewriteOnNextPersist = false;
6363
+ this.#ensuredOnDisk = true;
6364
+ this.#commitResidentTextStoreTransition(transition);
6365
+ return { oldSessionFile, newSessionFile };
5038
6366
  }
5039
6367
 
5040
6368
  async copyArtifactsForFork(
@@ -5044,15 +6372,98 @@ export class SessionManager {
5044
6372
  const sourceDir = oldSessionFile.slice(0, -6);
5045
6373
  const finalDestinationDir = newSessionFile.slice(0, -6);
5046
6374
  if (this.destination.kind !== "managed") {
6375
+ let sourceSnapshot: native.NativeDirectoryTreeSnapshot;
5047
6376
  try {
5048
6377
  const sourceStat = fs.lstatSync(sourceDir);
5049
6378
  if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) throw new Error("unsafe_artifacts");
6379
+ const captured = native.snapshotDirectoryTree(sourceDir);
6380
+ if (!captured.ok || !captured.snapshot) throw new Error(captured.code ?? "unsafe_artifacts");
6381
+ sourceSnapshot = captured.snapshot;
5050
6382
  } catch (error) {
5051
6383
  if (isEnoent(error)) return;
5052
6384
  throw error;
5053
6385
  }
5054
- await fs.promises.cp(sourceDir, finalDestinationDir, { recursive: true });
5055
- return undefined;
6386
+ try {
6387
+ fs.lstatSync(finalDestinationDir);
6388
+ throw new Error("destination_conflict");
6389
+ } catch (error) {
6390
+ if (error instanceof Error && error.message === "destination_conflict") throw error;
6391
+ if (!isEnoent(error)) throw error;
6392
+ }
6393
+ const expectedManifest = pruneResidentCacheEntries(sourceSnapshot);
6394
+ const parentDir = path.dirname(finalDestinationDir);
6395
+ const stagingDir = path.join(
6396
+ parentDir,
6397
+ `.${path.basename(finalDestinationDir)}.${crypto.randomUUID()}.fork-staging`,
6398
+ );
6399
+ // Establish deletion authority BEFORE any content exists: we create this root
6400
+ // ourselves under an unguessable name and capture its identity while empty.
6401
+ // Only a root whose identity still matches this capture may ever be removed.
6402
+ fs.mkdirSync(stagingDir, { mode: 0o700 });
6403
+ const ownedStaging = native.snapshotDirectoryTree(stagingDir);
6404
+ if (!ownedStaging.ok || !ownedStaging.snapshot) {
6405
+ throw new Error(ownedStaging.code ?? "artifact_staging_snapshot_failed");
6406
+ }
6407
+ const ownedStagingRoot = ownedStaging.snapshot;
6408
+ let published = false;
6409
+ try {
6410
+ await fs.promises.cp(sourceDir, stagingDir, {
6411
+ recursive: true,
6412
+ filter: source => {
6413
+ const relativePath = path.relative(sourceDir, source);
6414
+ return relativePath !== "resident-cache" && !relativePath.startsWith(`resident-cache${path.sep}`);
6415
+ },
6416
+ });
6417
+ const capturedStaging = native.snapshotDirectoryTree(stagingDir);
6418
+ if (!capturedStaging.ok || !capturedStaging.snapshot)
6419
+ throw new Error(capturedStaging.code ?? "artifact_destination_snapshot_failed");
6420
+ const stagedSnapshot = capturedStaging.snapshot;
6421
+ if (
6422
+ stagedSnapshot.rootDev !== ownedStagingRoot.rootDev ||
6423
+ stagedSnapshot.rootIno !== ownedStagingRoot.rootIno
6424
+ )
6425
+ throw new Error("artifact_staging_identity_changed");
6426
+ const terminalSource = native.snapshotDirectoryTree(sourceDir);
6427
+ if (!terminalSource.ok || !terminalSource.snapshot)
6428
+ throw new Error(terminalSource.code ?? "artifact_source_changed");
6429
+ if (JSON.stringify(terminalSource.snapshot) !== JSON.stringify(sourceSnapshot))
6430
+ throw new Error("artifact_source_changed");
6431
+ const comparable = (tree: native.NativeDirectoryTreeSnapshot) =>
6432
+ tree.entries.map(entry => ({
6433
+ relativePath: entry.relativePath,
6434
+ kind: entry.kind,
6435
+ size: entry.kind === "file" ? entry.size : undefined,
6436
+ sha256: entry.sha256,
6437
+ }));
6438
+ if (JSON.stringify(comparable(stagedSnapshot)) !== JSON.stringify(comparable(expectedManifest)))
6439
+ throw new Error("artifact_destination_terminal_mismatch");
6440
+ // No-replace publication: a directory that appeared at the final name after the
6441
+ // preflight is never replaced and never touched.
6442
+ const outcome = classifyNativePublishOutcome(native.renameNoReplacePath(stagingDir, finalDestinationDir));
6443
+ if (!outcome.ok) {
6444
+ if (outcome.reason === "destination_exists") throw new Error("destination_conflict");
6445
+ throw new Error(outcome.code ?? "artifact_destination_publish_failed");
6446
+ }
6447
+ published = true;
6448
+ const terminal = native.snapshotDirectoryTree(finalDestinationDir);
6449
+ if (
6450
+ !terminal.ok ||
6451
+ !terminal.snapshot ||
6452
+ !retainedTreeSnapshotEqualsAfterRename(terminal.snapshot, stagedSnapshot)
6453
+ )
6454
+ throw new Error("artifact_destination_terminal_mismatch");
6455
+ return { kind: "explicit", artifactsDir: finalDestinationDir, snapshot: terminal.snapshot };
6456
+ } catch (error) {
6457
+ if (!published) {
6458
+ const cleanupError = removeOwnedForkStaging(stagingDir, ownedStagingRoot);
6459
+ if (cleanupError) {
6460
+ throw new Error(`Failed to clean up explicit fork artifacts: ${cleanupError}`, {
6461
+ cause: toError(error),
6462
+ });
6463
+ }
6464
+ }
6465
+ throw error;
6466
+ }
5056
6467
  }
5057
6468
 
5058
6469
  const sourceName = path.basename(sourceDir);
@@ -5066,12 +6477,13 @@ export class SessionManager {
5066
6477
  if (error instanceof Error && error.message === "not_found") return;
5067
6478
  throw error;
5068
6479
  }
6480
+ const expectedManifest = pruneResidentCacheEntries(sourceSnapshot);
5069
6481
  let stagingSnapshot: native.NativeDirectoryTreeSnapshot | undefined;
5070
6482
  let publishedSnapshot: native.NativeDirectoryTreeSnapshot | undefined;
5071
6483
  try {
5072
6484
  parentStore.ensureDirectory(stagingName);
5073
6485
  stagingSnapshot = parentStore.captureTree(stagingName);
5074
- for (const entry of sourceSnapshot.entries) {
6486
+ for (const entry of expectedManifest.entries) {
5075
6487
  if (entry.relativePath === "") continue;
5076
6488
  const destinationRelativePath = path.posix.join(stagingName, entry.relativePath);
5077
6489
  if (entry.kind === "directory") {
@@ -5104,7 +6516,7 @@ export class SessionManager {
5104
6516
  size: entry.kind === "file" ? entry.size : undefined,
5105
6517
  sha256: entry.sha256,
5106
6518
  }));
5107
- if (JSON.stringify(comparable(publishedSnapshot)) !== JSON.stringify(comparable(sourceSnapshot)))
6519
+ if (JSON.stringify(comparable(publishedSnapshot)) !== JSON.stringify(comparable(expectedManifest)))
5108
6520
  throw new Error("artifact_destination_terminal_mismatch");
5109
6521
  const publishedStore = parentStore.deriveSubtree(destinationName);
5110
6522
  publishedStore.verifyRootSecurity();
@@ -5113,6 +6525,7 @@ export class SessionManager {
5113
6525
  throw new Error("artifact_destination_retained_identity_changed");
5114
6526
  publishedSnapshot = retainedPublishedSnapshot;
5115
6527
  return {
6528
+ kind: "managed",
5116
6529
  snapshot: publishedSnapshot,
5117
6530
  store: publishedStore,
5118
6531
  cleanupStore: parentStore,
@@ -5152,7 +6565,7 @@ export class SessionManager {
5152
6565
  ? managedDestination(
5153
6566
  resolvedCwd,
5154
6567
  this.storage,
5155
- this.destination.kind === "managed" ? this.destination.securityContext.agentDir : undefined,
6568
+ this.destination.kind === "managed" ? this.destination.securityContext.profileAgentDir : undefined,
5156
6569
  )
5157
6570
  : this.destination;
5158
6571
  const newSessionDir = nextDestination.directory;
@@ -5161,6 +6574,7 @@ export class SessionManager {
5161
6574
  let managedSourceStore: ManagedSessionDescendantStore | undefined;
5162
6575
  let managedDestinationStore: ManagedSessionDescendantStore | undefined;
5163
6576
  let rollbackManagedMove: (() => Promise<void>) | undefined;
6577
+ let residentTransition: PreparedResidentStoreTransition | undefined;
5164
6578
 
5165
6579
  if (this.persist && this.#sessionFile) {
5166
6580
  // Close the persist writer before moving files
@@ -5184,20 +6598,22 @@ export class SessionManager {
5184
6598
  this.#fileEntries,
5185
6599
  this.#residentBlobStores(),
5186
6600
  );
5187
- const restoreResidentStateAfterFailure = (): void => {
5188
- this.#fileEntries = materializedEntries;
5189
- this.#resetResidentTextBlobStore();
5190
- this.#reexternalizeFileEntriesForResidentStore();
5191
- this.#bumpAllRevisions();
5192
- };
5193
- const restoreResidentStateAndThrow = (error: unknown): never => {
5194
- try {
5195
- restoreResidentStateAfterFailure();
5196
- } catch (restoreErr) {
5197
- throw new Error(
5198
- `Failed to restore live session resident state after move failure: ${toError(restoreErr).message}; original error: ${toError(error).message}`,
5199
- );
5200
- }
6601
+ const transitionEntries: FileEntry[] = materializedEntries.map(entry =>
6602
+ entry.type === "session" ? { ...entry, cwd: resolvedCwd } : entry,
6603
+ );
6604
+ residentTransition = this.#prepareResidentTextStoreTransition(
6605
+ {
6606
+ target: { sessionId: this.#sessionId, sessionFile: newSessionFile },
6607
+ primary: {
6608
+ mode: "materialize",
6609
+ sourceEntries: transitionEntries,
6610
+ sourceStores: { textStore: null, imageStore: this.#residentImageBlobStore },
6611
+ },
6612
+ },
6613
+ "retain-and-throw",
6614
+ );
6615
+ const discardResidentTransitionAndThrow = (error: unknown): never => {
6616
+ residentTransition?.dispose();
5201
6617
  throw error;
5202
6618
  };
5203
6619
  const copyManagedTree = async (
@@ -5238,7 +6654,6 @@ export class SessionManager {
5238
6654
  throw new Error("artifact_destination_mismatch");
5239
6655
  destination.fsyncTree();
5240
6656
  };
5241
- this.#disposeResidentTextBlobStore();
5242
6657
  try {
5243
6658
  if (managedMove) {
5244
6659
  if (previousDestination.kind !== "managed" || nextDestination.kind !== "managed")
@@ -5365,7 +6780,7 @@ export class SessionManager {
5365
6780
  )
5366
6781
  await managedSourceStore.publishNoReplace(path.basename(oldSessionFile), managedTranscript.bytes);
5367
6782
  } catch (rollbackErr) {
5368
- restoreResidentStateAndThrow(
6783
+ discardResidentTransitionAndThrow(
5369
6784
  new Error(`Failed to rollback managed move: ${toError(rollbackErr).message}`, {
5370
6785
  cause: toError(err),
5371
6786
  }),
@@ -5375,13 +6790,9 @@ export class SessionManager {
5375
6790
  if (movedArtifactDir) await movePathAcrossDevicesSafe(newArtifactDir, oldArtifactDir);
5376
6791
  if (movedSessionFile) await movePathAcrossDevicesSafe(newSessionFile, oldSessionFile);
5377
6792
  }
5378
- restoreResidentStateAndThrow(err);
6793
+ discardResidentTransitionAndThrow(err);
5379
6794
  }
5380
6795
  this.#sessionFile = newSessionFile;
5381
- this.#fileEntries = materializedEntries;
5382
- this.#resetResidentTextBlobStore();
5383
- this.#reexternalizeFileEntriesForResidentStore();
5384
- this.#bumpAllRevisions();
5385
6796
  }
5386
6797
 
5387
6798
  // Update cwd and sessionDir after physical publication succeeds. Metadata failures restore the source
@@ -5406,6 +6817,7 @@ export class SessionManager {
5406
6817
  await this.#appendHeaderPatch({ cwd: resolvedCwd });
5407
6818
  }
5408
6819
  } catch (error) {
6820
+ residentTransition?.dispose();
5409
6821
  await this.#closePersistWriter().catch(() => {});
5410
6822
  this.#persistChain = Promise.resolve();
5411
6823
  this.#persistError = undefined;
@@ -5439,6 +6851,7 @@ export class SessionManager {
5439
6851
  this.#headerExportRevision++;
5440
6852
  throw error;
5441
6853
  }
6854
+ if (residentTransition) this.#commitResidentTextStoreTransition(residentTransition);
5442
6855
 
5443
6856
  // Update terminal breadcrumb only after the durable cwd transition succeeds.
5444
6857
  if (this.#sessionFile) {
@@ -5446,57 +6859,14 @@ export class SessionManager {
5446
6859
  }
5447
6860
  }
5448
6861
 
5449
- /** Sync version for initial creation (no existing writer to close) */
6862
+ /** Sync version for initial creation (no existing writer to close). */
5450
6863
  #newSessionSync(options?: NewSessionOptions, writeBreadcrumb = true): string | undefined {
5451
- this.#persistChain = Promise.resolve();
5452
- this.#persistError = undefined;
5453
- this.#persistErrorReported = false;
5454
- // Adopt a lifecycle pre-allocated id exactly once (the initial session of a
5455
- // /session_create child); later new-session paths (/new, fork, branch) get
5456
- // fresh ids so they cannot reuse the original GJC_SESSION_ID.
5457
- const preallocated = this.#lifecycleIdAdopted ? undefined : lifecyclePreallocatedSessionId();
5458
- if (preallocated) this.#lifecycleIdAdopted = true;
5459
- this.#sessionId = preallocated ?? createSessionId();
5460
- this.#sessionName = undefined;
5461
- this.#titleSource = undefined;
5462
- const timestamp = new Date().toISOString();
5463
- const header: SessionHeader = {
5464
- type: "session",
5465
- version: CURRENT_SESSION_VERSION,
5466
- id: this.#sessionId,
5467
- timestamp,
5468
- cwd: this.cwd,
5469
- parentSession: options?.parentSession,
5470
- };
5471
- this.#fileEntries = [header];
5472
- this.#byId.clear();
5473
- this.#labelsById.clear();
5474
- this.#leafId = null;
5475
- this.#flushed = false;
5476
- this.#needsFullRewriteOnNextPersist = false;
5477
- this.#ensuredOnDisk = false;
5478
- this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
5479
- this.#inMemoryArtifacts = null;
5480
- this.#inMemoryArtifactCounter = 0;
5481
- this.#artifactManager = null;
5482
- this.#artifactManagerSessionFile = null;
5483
- this.#adoptedArtifactManager = null;
5484
-
5485
- if (this.persist) {
5486
- const fileTimestamp = timestamp.replace(/[:.]/g, "-");
5487
- this.#sessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${this.#sessionId}.jsonl`);
5488
- if (writeBreadcrumb) writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
5489
- }
5490
- this.#resetResidentTextBlobStore();
5491
- return this.#sessionFile;
5492
- }
5493
-
5494
- #buildIndex(): void {
5495
- const index = this.#buildIndexForEntries(this.#fileEntries, this.#sessionFile);
5496
- this.#byId = index.byId;
5497
- this.#labelsById = index.labelsById;
5498
- this.#leafId = index.leafId;
5499
- this.#usageStatistics = index.usageStatistics;
6864
+ const fresh = this.#freshSessionState(options);
6865
+ const prepared = this.#prepareFreshSessionTransition(fresh, "memory-fallback");
6866
+ this.#applyFreshSessionMetadata(fresh);
6867
+ this.#commitResidentTextStoreTransition(prepared);
6868
+ if (writeBreadcrumb && fresh.sessionFile) writeTerminalBreadcrumb(this.cwd, fresh.sessionFile);
6869
+ return fresh.sessionFile;
5500
6870
  }
5501
6871
 
5502
6872
  #buildIndexForEntries(fileEntries: FileEntry[], sessionFile: string | undefined): PreparedSessionIndex {
@@ -5812,6 +7182,82 @@ export class SessionManager {
5812
7182
  }
5813
7183
  }
5814
7184
 
7185
+ /**
7186
+ * Verify a foreign managed candidate directory under this manager's own root and
7187
+ * prepare an owned destination rebind for it. Returns undefined when the candidate
7188
+ * is this manager's current managed directory (nothing to rebind).
7189
+ */
7190
+ #prepareManagedDestinationTransition(candidateDirectory: string): ManagedDestinationTransition | undefined {
7191
+ if (this.destination.kind !== "managed") throw new Error("Managed transcript authority is unavailable");
7192
+ const current = this.destination;
7193
+ if (path.resolve(candidateDirectory) === path.resolve(current.directory)) return undefined;
7194
+ // Independent verification: the non-retained constructor applies managedRelativePath
7195
+ // containment, assertManagedDirectoryRoot, full-chain ensureManagedDirectory owner-only
7196
+ // ACL verification, and a fresh fd identity binding on linux.
7197
+ const store = managedStoreFromContext(current.securityContext, candidateDirectory);
7198
+ let adopted = false;
7199
+ try {
7200
+ store.verifyRootSecurity();
7201
+ store.assertBound();
7202
+ } catch (error) {
7203
+ store.close();
7204
+ throw error;
7205
+ }
7206
+ const securityContext = createManagedSessionSecurityContext({
7207
+ agentDir: current.securityContext.agentDir,
7208
+ profileAgentDir: current.securityContext.profileAgentDir,
7209
+ sessionsRoot: current.securityContext.sessionsRoot,
7210
+ sessionDir: candidateDirectory,
7211
+ rootAuthority: current.securityContext.rootAuthority,
7212
+ // The candidate store owns its own authority; the context must not hand out a
7213
+ // borrowed retained fd for a directory it was not retained for.
7214
+ retainedAuthority: undefined,
7215
+ });
7216
+ managedSecurityPolicies.set(securityContext, managedSecurityPolicyForContext(current.securityContext));
7217
+ const destination = Object.freeze({ kind: "managed" as const, directory: candidateDirectory, securityContext });
7218
+ trustedSessionDestinations.add(destination);
7219
+ const previousDestination = current;
7220
+ const previousCache = this.#managedTranscriptStoreCache;
7221
+ const previousOwned = this.#ownedManagedAuthority;
7222
+ return {
7223
+ directory: candidateDirectory,
7224
+ destination,
7225
+ store,
7226
+ adopt: () => {
7227
+ adopted = true;
7228
+ this.destination = destination;
7229
+ this.#managedTranscriptStoreCache = { directory: path.resolve(candidateDirectory), store };
7230
+ this.#ownedManagedAuthority = store;
7231
+ // A superseded authority is NOT closed here: rollback() may still have to
7232
+ // restore it. Closing at adoption time would hand a closed store back to
7233
+ // the manager when a later cross-workspace switch fails after adoption.
7234
+ },
7235
+ settle: () => {
7236
+ if (!adopted) return;
7237
+ // The transition can no longer be rolled back, so the superseded authority
7238
+ // is now unreachable. Release it to keep exactly one owned fd per manager.
7239
+ if (previousOwned && previousOwned !== store) previousOwned.close();
7240
+ },
7241
+ rollback: () => {
7242
+ if (!adopted) return;
7243
+ adopted = false;
7244
+ this.destination = previousDestination;
7245
+ this.#managedTranscriptStoreCache = previousCache;
7246
+ this.#ownedManagedAuthority = previousOwned;
7247
+ store.close();
7248
+ },
7249
+ dispose: () => {
7250
+ if (adopted) return;
7251
+ store.close();
7252
+ },
7253
+ };
7254
+ }
7255
+
7256
+ #releaseOwnedManagedAuthority(): void {
7257
+ this.#ownedManagedAuthority?.close();
7258
+ this.#ownedManagedAuthority = undefined;
7259
+ }
7260
+
5815
7261
  #managedTranscriptStore(sessionFile = this.#sessionFile): ManagedSessionDescendantStore {
5816
7262
  if (this.destination.kind !== "managed" || !sessionFile) {
5817
7263
  throw new Error("Managed transcript authority is unavailable");
@@ -6033,8 +7479,27 @@ export class SessionManager {
6033
7479
  ) {
6034
7480
  return { kind: "not_promoted" };
6035
7481
  }
7482
+ let transition: PreparedResidentStoreTransition;
7483
+ try {
7484
+ transition = this.#prepareResidentTextStoreTransition(
7485
+ {
7486
+ target: { sessionId: this.#sessionId, sessionFile: this.#sessionFile ?? "" },
7487
+ primary: {
7488
+ mode: "materialize",
7489
+ sourceEntries: [...stage.entries],
7490
+ sourceStores: { textStore: this.#residentTextBlobStore, imageStore: this.#residentImageBlobStore },
7491
+ },
7492
+ },
7493
+ "retain-and-throw",
7494
+ );
7495
+ } catch (error) {
7496
+ return { kind: "unknown", error: toError(error) };
7497
+ }
6036
7498
  if (stage.persistsToExistingFile) {
6037
- if (!this.#sessionFile) return { kind: "unknown", error: new Error("Missing staged session replacement") };
7499
+ if (!this.#sessionFile) {
7500
+ transition.dispose();
7501
+ return { kind: "unknown", error: new Error("Missing staged session replacement") };
7502
+ }
6038
7503
  if (this.destination.kind === "managed") {
6039
7504
  try {
6040
7505
  const persistedEntries = materializeResidentEntriesForPersistenceSync(
@@ -6043,17 +7508,23 @@ export class SessionManager {
6043
7508
  ).map(entry => prepareEntryForPersistenceSync(entry, this.#blobStore));
6044
7509
  this.#writeEntriesAtomicallySync(persistedEntries);
6045
7510
  } catch (error) {
7511
+ transition.dispose();
6046
7512
  return { kind: "unknown", error: toError(error) };
6047
7513
  }
6048
7514
  } else {
6049
- if (!stage.tempPath) return { kind: "unknown", error: new Error("Missing staged session replacement") };
7515
+ if (!stage.tempPath) {
7516
+ transition.dispose();
7517
+ return { kind: "unknown", error: new Error("Missing staged session replacement") };
7518
+ }
6050
7519
  try {
6051
7520
  this.#closePersistWriterInternalSync();
6052
7521
  const replacement = this.#replaceSessionFileSync(stage.tempPath, this.#sessionFile);
6053
7522
  if (replacement.kind === "restored_previous") {
7523
+ transition.dispose();
6054
7524
  return { kind: "not_promoted", error: replacement.error };
6055
7525
  }
6056
7526
  } catch (error) {
7527
+ transition.dispose();
6057
7528
  if (this.#persistWriter?.getCloseState() === "close_failed_retryable") {
6058
7529
  return { kind: "not_promoted", error: new Error("Session replacement could not be completed.") };
6059
7530
  }
@@ -6061,13 +7532,10 @@ export class SessionManager {
6061
7532
  }
6062
7533
  }
6063
7534
  }
6064
- this.#fileEntries = [...stage.entries];
6065
- this.#resetResidentTextBlobStore();
6066
- this.#reexternalizeFileEntriesForResidentStore();
6067
7535
  this.#needsFullRewriteOnNextPersist = false;
6068
7536
  this.#flushed = stage.persistsToExistingFile;
6069
7537
  this.#ensuredOnDisk = stage.persistsToExistingFile;
6070
- this.#bumpAllRevisions();
7538
+ this.#commitResidentTextStoreTransition(transition);
6071
7539
  return { kind: "promoted" };
6072
7540
  }
6073
7541
 
@@ -6119,7 +7587,8 @@ export class SessionManager {
6119
7587
  this.#flushed = true;
6120
7588
  }
6121
7589
  });
6122
- this.#disposeResidentTextBlobStore();
7590
+ this.#releaseResidentTextStore();
7591
+ this.#releaseOwnedManagedAuthority();
6123
7592
  if (this.#persistError) throw this.#persistError;
6124
7593
  }
6125
7594
  /** Flush while open, then strictly close; retryable close skips the invalid second flush. */
@@ -6176,7 +7645,7 @@ export class SessionManager {
6176
7645
  // Only tear down the resident blob store on a terminal outcome; a retryable
6177
7646
  // close leaves the session live for a genuine retry.
6178
7647
  if (!this.#persistWriter) {
6179
- this.#disposeResidentTextBlobStore();
7648
+ this.#releaseResidentTextStore();
6180
7649
  }
6181
7650
  return outcome;
6182
7651
  }
@@ -6230,6 +7699,160 @@ export class SessionManager {
6230
7699
  return this.#sessionFile;
6231
7700
  }
6232
7701
 
7702
+ acquireMemoryGuardParticipantIngressLease(): MemoryGuardParticipantIngressLease {
7703
+ if (this.#memoryGuardParticipantIngressToken)
7704
+ throw new Error("memory_guard_participant_ingress_lease_already_held");
7705
+ const token = Symbol("memory_guard_participant_ingress_lease");
7706
+ this.#memoryGuardParticipantIngressToken = token;
7707
+ let released = false;
7708
+ return Object.freeze({
7709
+ token,
7710
+ release: () => {
7711
+ if (released) return;
7712
+ released = true;
7713
+ if (this.#memoryGuardParticipantIngressToken === token)
7714
+ this.#memoryGuardParticipantIngressToken = undefined;
7715
+ },
7716
+ });
7717
+ }
7718
+
7719
+ #assertMemoryGuardParticipantIngressLease(lease: MemoryGuardParticipantIngressLease): void {
7720
+ if (this.#memoryGuardParticipantIngressToken !== lease.token) {
7721
+ throw new Error("memory_guard_participant_ingress_lease_invalid");
7722
+ }
7723
+ }
7724
+
7725
+ #stageMemoryGuardCheckpointBlobs(blobs: Map<string, Buffer>): void {
7726
+ const stagedImageStore = new MemoryBlobStore();
7727
+ for (const blob of blobs.values()) {
7728
+ this.#putResidentTextBlobSync(blob);
7729
+ stagedImageStore.putSync(blob);
7730
+ }
7731
+ this.#residentImageBlobStore = stagedImageStore;
7732
+ this.#memoryGuardCheckpointBlobs = blobs;
7733
+ }
7734
+
7735
+ async createMemoryGuardCheckpoint(
7736
+ input: MemoryGuardCreateCheckpointInput,
7737
+ ): Promise<MemoryGuardSessionManagerCheckpointV1> {
7738
+ this.#assertMemoryGuardParticipantIngressLease(input.ingressLease);
7739
+ if (!this.#sessionFile) throw new Error("memory_guard_checkpoint_session_file_unavailable");
7740
+ await this.flush();
7741
+ const captured = SessionManager.captureTranscriptStrict(this.#sessionFile, this.storage);
7742
+ if (captured.kind !== "captured") {
7743
+ throw new Error(`memory_guard_checkpoint_capture_failed:${captured.reason}`);
7744
+ }
7745
+ if (captured.snapshot.content.byteLength > MEMORY_GUARD_CHECKPOINT_FILE_MAX_BYTES)
7746
+ throw new Error("memory_guard_checkpoint_transcript_capacity_exceeded");
7747
+ const transcriptText = decodeCheckpointUtf8(captured.snapshot.content);
7748
+ if (transcriptText === null) throw new Error("memory_guard_checkpoint_transcript_unreadable");
7749
+ const entries = parseSessionEntries(transcriptText);
7750
+ const sessionId = this.getSessionId();
7751
+ assertMemoryGuardSessionId(sessionId);
7752
+ const sessionName = this.getSessionName() ?? null;
7753
+ const revisions = this.revisionSnapshot();
7754
+ const participantRoot = memoryGuardParticipantRoot(input.checkpointRoot, sessionId);
7755
+ const transcriptRelativePath = memoryGuardParticipantRelativePath(sessionId, "transcript.jsonl");
7756
+ const blobRootRelativePath = memoryGuardParticipantRelativePath(sessionId, "blobs");
7757
+ const blobManifestRelativePath = memoryGuardParticipantRelativePath(sessionId, "blob-manifest.json");
7758
+ const refs = [...collectCheckpointBlobRefs(entries)].sort();
7759
+ if (refs.length > MEMORY_GUARD_CHECKPOINT_BLOB_MAX_ENTRIES)
7760
+ throw new Error("memory_guard_checkpoint_blob_count_exceeded");
7761
+ const blobManifestEntries: MemoryGuardCheckpointBlobManifestEntryV1[] = [];
7762
+ const blobWrites: Array<{ data: Buffer; relativePath: string }> = [];
7763
+ let aggregateBlobBytes = 0;
7764
+ for (const ref of refs) {
7765
+ const hash = parseBlobRef(ref);
7766
+ if (!hash) continue;
7767
+ const data =
7768
+ this.#residentTextBlobStore.getCheckedSync(hash) ??
7769
+ this.#residentImageBlobStore.getCheckedSync(hash) ??
7770
+ this.#blobStore.getCheckedSync(hash);
7771
+ if (!data) throw new Error(`memory_guard_checkpoint_blob_missing:${hash}`);
7772
+ if (data.byteLength > MEMORY_GUARD_CHECKPOINT_FILE_MAX_BYTES)
7773
+ throw new Error(`memory_guard_checkpoint_blob_capacity_exceeded:${hash}`);
7774
+ aggregateBlobBytes += data.byteLength;
7775
+ if (aggregateBlobBytes > MEMORY_GUARD_CHECKPOINT_BLOB_TOTAL_MAX_BYTES)
7776
+ throw new Error("memory_guard_checkpoint_blob_total_capacity_exceeded");
7777
+ const relativePath = hash;
7778
+ blobWrites.push({ data, relativePath });
7779
+ blobManifestEntries.push({
7780
+ bytes: String(data.byteLength),
7781
+ relative_path: relativePath,
7782
+ sha256: memoryGuardSha256Hex(data),
7783
+ });
7784
+ }
7785
+ const publishedCheckpointPaths: string[] = [];
7786
+ const cleanupPublishedCheckpointPaths = async (): Promise<void> => {
7787
+ for (const publishedPath of publishedCheckpointPaths.reverse())
7788
+ await fs.promises.rm(publishedPath, { force: true }).catch(() => undefined);
7789
+ await fs.promises.rmdir(path.join(input.checkpointRoot, blobRootRelativePath)).catch(() => undefined);
7790
+ await fs.promises.rmdir(participantRoot).catch(() => undefined);
7791
+ };
7792
+ const publishCheckpointFile = async (filePath: string, data: Uint8Array | string): Promise<void> => {
7793
+ try {
7794
+ await writeOwnerOnlyFileNoReplace(filePath, data);
7795
+ publishedCheckpointPaths.push(filePath);
7796
+ } catch (error) {
7797
+ await cleanupPublishedCheckpointPaths();
7798
+ throw error;
7799
+ }
7800
+ };
7801
+ await ensureOwnerOnlyDirectory(participantRoot);
7802
+ await publishCheckpointFile(path.join(input.checkpointRoot, transcriptRelativePath), captured.snapshot.content);
7803
+ for (const write of blobWrites)
7804
+ await publishCheckpointFile(
7805
+ path.join(input.checkpointRoot, blobRootRelativePath, write.relativePath),
7806
+ write.data,
7807
+ );
7808
+ blobManifestEntries.sort((left, right) => left.relative_path.localeCompare(right.relative_path));
7809
+ const blobManifest: MemoryGuardCheckpointBlobManifestV1 = {
7810
+ entries: blobManifestEntries,
7811
+ schema_version: 1,
7812
+ };
7813
+ const blobManifestText = memoryGuardCanonicalJson(blobManifest);
7814
+ await publishCheckpointFile(path.join(input.checkpointRoot, blobManifestRelativePath), blobManifestText);
7815
+ const checkpoint: MemoryGuardSessionManagerCheckpointV1 = {
7816
+ blob_authority: {
7817
+ kind: "checkpoint_blob_tree_v1",
7818
+ manifest_relative_path: blobManifestRelativePath,
7819
+ manifest_sha256: memoryGuardSha256Hex(blobManifestText),
7820
+ root_relative_path: blobRootRelativePath,
7821
+ },
7822
+ revisions: toSessionManagerCheckpointRevisionStrings(revisions),
7823
+ schema_version: 1,
7824
+ session_id: sessionId,
7825
+ session_name: sessionName,
7826
+ transcript: {
7827
+ bytes: String(captured.snapshot.content.byteLength),
7828
+ relative_path: transcriptRelativePath,
7829
+ sha256: captured.snapshot.identity.sha256,
7830
+ },
7831
+ };
7832
+ const checkpointPath = path.join(
7833
+ input.checkpointRoot,
7834
+ memoryGuardParticipantRelativePath(sessionId, "session-manager.json"),
7835
+ );
7836
+ await publishCheckpointFile(checkpointPath, memoryGuardCanonicalJson(checkpoint));
7837
+ const currentRevisions = this.revisionSnapshot();
7838
+ const recaptured = SessionManager.captureTranscriptStrict(this.#sessionFile, this.storage);
7839
+ if (
7840
+ this.getSessionId() !== sessionId ||
7841
+ (this.getSessionName() ?? null) !== sessionName ||
7842
+ Object.keys(revisions).some(
7843
+ key =>
7844
+ currentRevisions[key as keyof SessionManagerRevisionSnapshot] !==
7845
+ revisions[key as keyof SessionManagerRevisionSnapshot],
7846
+ ) ||
7847
+ recaptured.kind !== "captured" ||
7848
+ !sameResumeIdentity(captured.snapshot.identity, recaptured.snapshot.identity)
7849
+ ) {
7850
+ await cleanupPublishedCheckpointPaths();
7851
+ throw new Error("memory_guard_checkpoint_state_changed_during_capture");
7852
+ }
7853
+ return checkpoint;
7854
+ }
7855
+
6233
7856
  /**
6234
7857
  * Returns the session artifacts directory path (session file path without .jsonl).
6235
7858
  * Returns null when the session is not persisted to a file.
@@ -6491,7 +8114,11 @@ export class SessionManager {
6491
8114
  * @param source - "user" for explicit renames (/rename command, RPC); "auto" for generated titles.
6492
8115
  * Auto-generated titles are silently ignored when the user has already set a name.
6493
8116
  */
8117
+ #assertRecoveryHydrationWritable(): void {
8118
+ if (this.#recoveryHydrationContext) throw new Error("recovery_hydration_not_promoted");
8119
+ }
6494
8120
  async setSessionName(name: string, source: "auto" | "user" = "auto"): Promise<boolean> {
8121
+ this.#assertRecoveryHydrationWritable();
6495
8122
  // User-set names take permanent precedence over auto-generated ones.
6496
8123
  if (this.#titleSource === "user" && source === "auto") return false;
6497
8124
 
@@ -6617,8 +8244,9 @@ export class SessionManager {
6617
8244
  }
6618
8245
 
6619
8246
  #appendEntry(entry: SessionEntry): void {
8247
+ this.#assertRecoveryHydrationWritable();
6620
8248
  const normalizedEntry = normalizeSessionEntryForStorage(entry);
6621
- const residentEntry = prepareEntryForResidentSync(normalizedEntry, this.#residentBlobStores()) as SessionEntry;
8249
+ const residentEntry = this.#prepareEntryForCurrentResidentStore(normalizedEntry) as SessionEntry;
6622
8250
  this.#fileEntries.push(residentEntry);
6623
8251
  this.#byId.set(residentEntry.id, residentEntry);
6624
8252
  this.#leafId = residentEntry.id;
@@ -6835,13 +8463,14 @@ export class SessionManager {
6835
8463
  * the canonical store. This applies such mutations for real.
6836
8464
  */
6837
8465
  applyEntryMessageUpdates(entries: readonly SessionMessageEntry[]): void {
8466
+ this.#assertRecoveryHydrationWritable();
6838
8467
  for (const updated of entries) {
6839
8468
  const canonical = this.#byId.get(updated.id);
6840
8469
  if (canonical?.type !== "message") continue;
6841
- const residentEntry = prepareEntryForResidentSync(
6842
- { ...canonical, message: updated.message },
6843
- this.#residentBlobStores(),
6844
- ) as SessionMessageEntry;
8470
+ const residentEntry = this.#prepareEntryForCurrentResidentStore({
8471
+ ...canonical,
8472
+ message: updated.message,
8473
+ }) as SessionMessageEntry;
6845
8474
  canonical.message = residentEntry.message;
6846
8475
  }
6847
8476
  this.#needsFullRewriteOnNextPersist = true;
@@ -6851,6 +8480,7 @@ export class SessionManager {
6851
8480
 
6852
8481
  /** Write mutated custom-message entries back into the canonical entry store by id. */
6853
8482
  applyCustomMessageEntryUpdates(entries: readonly CustomMessageEntry[]): void {
8483
+ this.#assertRecoveryHydrationWritable();
6854
8484
  for (const updated of entries) {
6855
8485
  const canonical = this.#byId.get(updated.id);
6856
8486
  if (canonical?.type !== "custom_message") continue;
@@ -6870,6 +8500,7 @@ export class SessionManager {
6870
8500
  * Use sparingly (e.g., pruning old tool outputs).
6871
8501
  */
6872
8502
  async rewriteEntries(): Promise<void> {
8503
+ this.#assertRecoveryHydrationWritable();
6873
8504
  if (!this.persist || !this.#sessionFile) return;
6874
8505
  await this.#rewriteFile();
6875
8506
  }
@@ -7035,6 +8666,7 @@ export class SessionManager {
7035
8666
  }
7036
8667
 
7037
8668
  evictCompactedContent(firstKeptEntryId: string, compactionEntryId: string): EvictCompactedContentResult {
8669
+ this.#assertRecoveryHydrationWritable();
7038
8670
  const firstKept = this.#byId.get(firstKeptEntryId);
7039
8671
  const compaction = this.#byId.get(compactionEntryId);
7040
8672
  if (!firstKept) throw new Error(`Entry ${firstKeptEntryId} not found`);
@@ -7156,11 +8788,16 @@ export class SessionManager {
7156
8788
  coldSpillReadCount: this.#coldSpillReadCount,
7157
8789
  residentTextReadCount: this.#residentTextReadCount,
7158
8790
  residentImageReadCount: this.#residentImageReadCount,
8791
+ residentCacheAdoptFallbackCount: this.#residentCacheAdoptFallbackCount,
8792
+ residentCacheTrustRejectCount: this.#residentCacheTrustRejectCount,
8793
+ residentCacheWin32FallbackCount: this.#residentCacheWin32FallbackCount,
8794
+ residentCacheDegradedReason: (this.#residentTextBlobStore as ResidentCacheDegradedStore).degradedReason,
7159
8795
  publicMaterializerCallCount: this.#publicMaterializerCallCount,
7160
8796
  getEntryMaterializerCallCount: this.#getEntryMaterializerCallCount,
7161
8797
  getBranchMaterializerCallCount: this.#getBranchMaterializerCallCount,
7162
8798
  getEntriesMaterializerCallCount: this.#getEntriesMaterializerCallCount,
7163
8799
  materializedEntriesCachePopulateCount: this.#materializedEntriesCachePopulateCount,
8800
+ materializedCacheDemotedCount: this.#materializedCacheDemotedCount,
7164
8801
  pathOnlyContextBuildCount: this.#pathOnlyContextBuildCount,
7165
8802
  };
7166
8803
  }
@@ -7185,7 +8822,7 @@ export class SessionManager {
7185
8822
  ? cloneSessionEntry(
7186
8823
  rehydrateColdSpillEntry(
7187
8824
  materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), new Map()),
7188
- this.#blobStore,
8825
+ this.#coldSpillReadStore(),
7189
8826
  this.#residentBlobStoresForColdRehydrate(),
7190
8827
  ),
7191
8828
  )
@@ -7204,7 +8841,7 @@ export class SessionManager {
7204
8841
  cloneSessionEntry(
7205
8842
  rehydrateColdSpillEntry(
7206
8843
  materializeResidentEntryForReadSync(current, this.#residentBlobStores(), cache),
7207
- this.#blobStore,
8844
+ this.#coldSpillReadStore(),
7208
8845
  this.#residentBlobStoresForColdRehydrate(),
7209
8846
  ),
7210
8847
  ),
@@ -7246,7 +8883,7 @@ export class SessionManager {
7246
8883
  cloneSessionEntry(
7247
8884
  rehydrateColdSpillEntry(
7248
8885
  materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), cache),
7249
- this.#blobStore,
8886
+ this.#coldSpillReadStore(),
7250
8887
  this.#residentBlobStoresForColdRehydrate(),
7251
8888
  ),
7252
8889
  ),
@@ -7347,10 +8984,11 @@ export class SessionManager {
7347
8984
 
7348
8985
  /**
7349
8986
  * Return the revision-keyed context cache for internal read-only consumers.
7350
- * The strong reference avoids GC-driven rebuilds during a session.
8987
+ * Normal snapshots remain strongly held; snapshots over the materialized cache
8988
+ * cap are weakly held so a GC cycle can reclaim them between reads.
7351
8989
  */
7352
8990
  #getSessionContextForRead(): Readonly<SessionContext> {
7353
- const cached = this.#sessionContextCache;
8991
+ const cached = dereferenceMaterializedCache(this.#sessionContextCache);
7354
8992
  if (
7355
8993
  cached &&
7356
8994
  this.#sessionContextEntryRevision === this.#entryRevision &&
@@ -7360,18 +8998,22 @@ export class SessionManager {
7360
8998
  return cached;
7361
8999
  }
7362
9000
  this.#pathOnlyContextBuildCount++;
7363
- const context = buildSessionContext(
9001
+ const builtContext = buildSessionContext(
7364
9002
  this.#getActivePathEntriesForProviderContext(),
7365
9003
  this.#leafId,
7366
9004
  undefined,
7367
9005
  this.#sessionId,
7368
9006
  );
7369
- this.#sessionContextCache = freezeInternalReadSnapshot(context);
7370
- transferSessionMessageIdentity(context.messages, this.#sessionContextCache.messages);
9007
+ if (jsonLikeValueExceedsCacheLimit(builtContext, materializedCacheMaxBytes())) {
9008
+ this.#holdMaterializedCachesWeakly();
9009
+ }
9010
+ const context = freezeInternalReadSnapshot(builtContext);
9011
+ this.#sessionContextCache = this.#materializedCachesWeaklyHeld ? new WeakRef(context) : context;
9012
+ transferSessionMessageIdentity(builtContext.messages, context.messages);
7371
9013
  this.#sessionContextEntryRevision = this.#entryRevision;
7372
9014
  this.#sessionContextLeafRevision = this.#leafRevision;
7373
9015
  this.#sessionContextReplayMetadataRevision = this.#replayMetadataRevision;
7374
- return this.#sessionContextCache;
9016
+ return context;
7375
9017
  }
7376
9018
 
7377
9019
  #getActivePathEntriesForProviderContext(fromId?: string | null): SessionEntry[] {
@@ -7412,7 +9054,7 @@ export class SessionManager {
7412
9054
  const materialized = materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), new Map());
7413
9055
  const rehydrated = rehydrateColdSpillEntry(
7414
9056
  materialized,
7415
- this.#blobStore,
9057
+ this.#coldSpillReadStore(),
7416
9058
  this.#residentBlobStoresForColdRehydrate(),
7417
9059
  );
7418
9060
  if (rehydrated !== materialized) this.#coldSpillReadCount += this.#countColdSpillPayloads(entry);
@@ -7428,6 +9070,7 @@ export class SessionManager {
7428
9070
  }
7429
9071
  /** Strip stale OpenAI Responses assistant replay metadata from loaded in-memory entries without persisting it. */
7430
9072
  sanitizeLoadedOpenAIResponsesReplayMetadata(): boolean {
9073
+ this.#assertRecoveryHydrationWritable();
7431
9074
  return this.#sanitizeLoadedOpenAIResponsesReplayMetadata().length > 0;
7432
9075
  }
7433
9076
 
@@ -7467,17 +9110,21 @@ export class SessionManager {
7467
9110
  * Internal materialized entry cache. Public getters clone this snapshot before returning it.
7468
9111
  */
7469
9112
  #getMaterializedEntriesInternal(): SessionEntry[] {
7470
- if (this.#materializedEntriesRevision === this.#entryRevision && this.#materializedEntriesCache) {
7471
- return this.#materializedEntriesCache;
7472
- }
9113
+ const cached = dereferenceMaterializedCache(this.#materializedEntriesCache);
9114
+ if (this.#materializedEntriesRevision === this.#entryRevision && cached) return cached;
7473
9115
  this.#materializedEntriesCachePopulateCount++;
7474
9116
  const resolvedTextBlobCache = new Map<string, string>();
7475
9117
  const sourceEntries = this.#fileEntries.filter((e): e is SessionEntry => e.type !== "session");
7476
9118
  const materializedEntries = sourceEntries.map(entry =>
7477
9119
  materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), resolvedTextBlobCache),
7478
9120
  );
9121
+ if (jsonLikeValueExceedsCacheLimit(materializedEntries, materializedCacheMaxBytes())) {
9122
+ this.#holdMaterializedCachesWeakly();
9123
+ }
7479
9124
  if (!sourceEntries.some(entry => containsResidentImageSentinel(entry))) {
7480
- this.#materializedEntriesCache = materializedEntries;
9125
+ this.#materializedEntriesCache = this.#materializedCachesWeaklyHeld
9126
+ ? new WeakRef(materializedEntries)
9127
+ : materializedEntries;
7481
9128
  this.#materializedEntriesRevision = this.#entryRevision;
7482
9129
  }
7483
9130
  return materializedEntries;
@@ -7583,6 +9230,7 @@ export class SessionManager {
7583
9230
  * are not modified or deleted.
7584
9231
  */
7585
9232
  branch(branchFromId: string): void {
9233
+ this.#assertRecoveryHydrationWritable();
7586
9234
  if (!this.#byId.has(branchFromId)) {
7587
9235
  throw new Error(`Entry ${branchFromId} not found`);
7588
9236
  }
@@ -7596,6 +9244,7 @@ export class SessionManager {
7596
9244
  * Use this when navigating to re-edit the first user message.
7597
9245
  */
7598
9246
  resetLeaf(): void {
9247
+ this.#assertRecoveryHydrationWritable();
7599
9248
  this.#leafId = null;
7600
9249
  this.#leafRevision++;
7601
9250
  }
@@ -7606,6 +9255,7 @@ export class SessionManager {
7606
9255
  * context from the abandoned conversation path.
7607
9256
  */
7608
9257
  branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromExtension?: boolean): string {
9258
+ this.#assertRecoveryHydrationWritable();
7609
9259
  if (branchFromId !== null && !this.#byId.has(branchFromId)) {
7610
9260
  throw new Error(`Entry ${branchFromId} not found`);
7611
9261
  }
@@ -7630,6 +9280,7 @@ export class SessionManager {
7630
9280
  * Returns the new session file path, or undefined if not persisting.
7631
9281
  */
7632
9282
  createBranchedSession(leafId: string): string | undefined {
9283
+ this.#assertRecoveryHydrationWritable();
7633
9284
  const previousSessionFile = this.#sessionFile;
7634
9285
  const branchPath = this.#getCanonicalBranchClones(leafId);
7635
9286
  if (branchPath.length === 0) {
@@ -7689,27 +9340,35 @@ export class SessionManager {
7689
9340
  labelEntries.push(labelEntry);
7690
9341
  parentId = labelEntry.id;
7691
9342
  }
7692
- if (this.destination.kind === "managed") {
7693
- this.#managedTranscriptStore(newSessionFile).publishNoReplaceSync(
7694
- path.basename(newSessionFile),
7695
- Buffer.from(`${lines.join("\n")}\n`, "utf8"),
7696
- );
7697
- } else {
7698
- this.storage.writeTextSync(newSessionFile, `${lines.join("\n")}\n`);
9343
+ const transitionEntries: FileEntry[] = [header, ...materializedPathWithoutLabels, ...labelEntries];
9344
+ const transition = this.#prepareResidentTextStoreTransition(
9345
+ {
9346
+ target: { sessionId: newSessionId, sessionFile: newSessionFile },
9347
+ primary: {
9348
+ mode: "materialize",
9349
+ sourceEntries: transitionEntries,
9350
+ sourceStores: { textStore: null, imageStore: this.#residentImageBlobStore },
9351
+ },
9352
+ },
9353
+ "retain-and-throw",
9354
+ );
9355
+ try {
9356
+ if (this.destination.kind === "managed") {
9357
+ this.#managedTranscriptStore(newSessionFile).publishNoReplaceSync(
9358
+ path.basename(newSessionFile),
9359
+ Buffer.from(`${lines.join("\n")}\n`, "utf8"),
9360
+ );
9361
+ } else {
9362
+ this.storage.writeTextSync(newSessionFile, `${lines.join("\n")}\n`);
9363
+ }
9364
+ } catch (error) {
9365
+ transition.dispose();
9366
+ throw error;
7699
9367
  }
7700
9368
  this.#sessionId = newSessionId;
7701
9369
  this.#sessionFile = newSessionFile;
7702
- this.#resetResidentTextBlobStore();
7703
- this.#fileEntries = [
7704
- header,
7705
- ...materializedPathWithoutLabels.map(
7706
- entry => prepareEntryForResidentSync(entry, this.#residentBlobStores()) as SessionEntry,
7707
- ),
7708
- ...labelEntries,
7709
- ];
7710
9370
  this.#flushed = true;
7711
- this.#buildIndex();
7712
- this.#bumpAllRevisions();
9371
+ this.#commitResidentTextStoreTransition(transition);
7713
9372
  return newSessionFile;
7714
9373
  }
7715
9374
 
@@ -7728,17 +9387,20 @@ export class SessionManager {
7728
9387
  labelEntries.push(labelEntry);
7729
9388
  parentId = labelEntry.id;
7730
9389
  }
9390
+ const transitionEntries: FileEntry[] = [header, ...materializedPathWithoutLabels, ...labelEntries];
9391
+ const transition = this.#prepareResidentTextStoreTransition(
9392
+ {
9393
+ target: { sessionId: newSessionId, sessionFile: newSessionFile },
9394
+ primary: {
9395
+ mode: "materialize",
9396
+ sourceEntries: transitionEntries,
9397
+ sourceStores: { textStore: null, imageStore: this.#residentImageBlobStore },
9398
+ },
9399
+ },
9400
+ "retain-and-throw",
9401
+ );
7731
9402
  this.#sessionId = newSessionId;
7732
- this.#resetResidentTextBlobStore();
7733
- this.#fileEntries = [
7734
- header,
7735
- ...materializedPathWithoutLabels.map(
7736
- entry => prepareEntryForResidentSync(entry, this.#residentBlobStores()) as SessionEntry,
7737
- ),
7738
- ...labelEntries,
7739
- ];
7740
- this.#buildIndex();
7741
- this.#bumpAllRevisions();
9403
+ this.#commitResidentTextStoreTransition(transition);
7742
9404
  return undefined;
7743
9405
  }
7744
9406
 
@@ -7786,8 +9448,11 @@ export class SessionManager {
7786
9448
  : process.platform === "win32"
7787
9449
  ? "windows-existing-verify-first"
7788
9450
  : "default";
9451
+ const profileAgentDir =
9452
+ authority instanceof ManagedSessionDescendantStore ? authority.profileAgentDir : getAgentDir();
7789
9453
  const securityContext = createManagedSessionSecurityContext({
7790
9454
  agentDir: rootAuthority.canonicalPath,
9455
+ profileAgentDir,
7791
9456
  sessionsRoot: rootAuthority.canonicalPath,
7792
9457
  sessionDir: directory,
7793
9458
  rootAuthority,
@@ -7966,25 +9631,26 @@ export class SessionManager {
7966
9631
  const forkEntries = structuredClone(await loadEntriesFromFile(managedSourcePath, storage)) as FileEntry[];
7967
9632
  migrateToCurrentVersion(forkEntries);
7968
9633
  await resolveBlobRefsInEntries(forkEntries, manager.#blobStore);
7969
- manager.#fileEntries = forkEntries;
7970
- const sourceHeader = manager.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
7971
- const historyEntries = manager.#fileEntries.filter(entry => entry.type !== "session") as SessionEntry[];
7972
- manager.#newSessionSync({ parentSession: sourceHeader?.id });
7973
- manager.#resetResidentTextBlobStore();
7974
- const newHeader = manager.#fileEntries[0] as SessionHeader;
7975
- newHeader.title = sourceHeader?.title;
7976
- newHeader.titleSource = sourceHeader?.titleSource;
7977
- manager.#fileEntries = [
7978
- newHeader,
7979
- ...historyEntries.map(
7980
- entry => prepareEntryForResidentSync(entry, manager.#residentBlobStores()) as SessionEntry,
7981
- ),
7982
- ];
7983
- manager.#sessionName = newHeader.title;
7984
- manager.#titleSource = newHeader.titleSource;
9634
+ const sourceHeader = forkEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
9635
+ const historyEntries = forkEntries.filter(entry => entry.type !== "session") as SessionEntry[];
9636
+ const fresh = manager.#freshSessionState({ parentSession: sourceHeader?.id });
9637
+ fresh.header.title = sourceHeader?.title;
9638
+ fresh.header.titleSource = sourceHeader?.titleSource;
9639
+ const transition = manager.#prepareResidentTextStoreTransition(
9640
+ {
9641
+ target: { sessionId: fresh.sessionId, sessionFile: fresh.sessionFile ?? "" },
9642
+ primary: {
9643
+ mode: "materialize",
9644
+ sourceEntries: [fresh.header, ...historyEntries],
9645
+ sourceStores: { textStore: null, imageStore: manager.#residentImageBlobStore },
9646
+ },
9647
+ },
9648
+ "memory-fallback",
9649
+ );
9650
+ manager.#applyFreshSessionMetadata(fresh);
9651
+ manager.#commitResidentTextStoreTransition(transition);
9652
+ if (fresh.sessionFile) writeTerminalBreadcrumb(manager.cwd, fresh.sessionFile);
7985
9653
  manager.sanitizeLoadedOpenAIResponsesReplayMetadata();
7986
- manager.#buildIndex();
7987
- manager.#bumpAllRevisions();
7988
9654
  await manager.#rewriteFile();
7989
9655
  return manager;
7990
9656
  }
@@ -8062,32 +9728,20 @@ export class SessionManager {
8062
9728
  storage,
8063
9729
  destination,
8064
9730
  );
8065
- manager.#sessionFile = resolved;
8066
- writeTerminalBreadcrumb(manager.cwd, resolved);
8067
- manager.#fileEntries = entries;
8068
9731
  if (entries.length > 0) {
8069
- manager.#sessionId = header?.id ?? createSessionId();
8070
- manager.#sessionName = header?.title;
8071
- manager.#titleSource = header?.titleSource;
8072
- manager.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(manager.#fileEntries);
8073
- await resolveBlobRefsInEntries(manager.#fileEntries, manager.#blobStore);
8074
- manager.#resetResidentTextBlobStore();
8075
- manager.#fileEntries = manager.#fileEntries.map(entry =>
8076
- prepareEntryForResidentSync(entry, manager.#residentBlobStores()),
8077
- );
8078
- manager.#buildIndex();
8079
- manager.#bumpAllRevisions();
8080
- manager.#flushed = true;
8081
- manager.#ensuredOnDisk = true;
9732
+ const migrationApplied = migrateToCurrentVersion(entries);
9733
+ await manager.#hydrateExistingSession(resolved, entries, migrationApplied, "memory-fallback");
9734
+ writeTerminalBreadcrumb(manager.cwd, resolved);
8082
9735
  await manager.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
8083
9736
  } else {
8084
- manager.#newSessionSync();
8085
- manager.#sessionFile = resolved;
8086
- manager.#resetResidentTextBlobStore();
9737
+ const fresh = manager.#freshSessionState(undefined, resolved);
9738
+ const transition = manager.#prepareFreshSessionTransition(fresh, "memory-fallback");
9739
+ manager.#applyFreshSessionMetadata(fresh);
9740
+ manager.#commitResidentTextStoreTransition(transition);
9741
+ writeTerminalBreadcrumb(manager.cwd, resolved);
8087
9742
  await manager.#rewriteFile();
8088
9743
  manager.#flushed = true;
8089
9744
  manager.#ensuredOnDisk = true;
8090
- manager.#bumpAllRevisions();
8091
9745
  }
8092
9746
  store.assertBound();
8093
9747
  return manager;
@@ -8112,10 +9766,11 @@ export class SessionManager {
8112
9766
  if (resolved.kind === "error") return [];
8113
9767
  const listing = listManagedCandidates(resolved.scope);
8114
9768
  if (listing.kind === "error") return [];
8115
- return await collectSessionsFromFiles(
9769
+ const managed = await collectSessionsFromFiles(
8116
9770
  listing.owned.map(candidate => candidate.path),
8117
9771
  storage,
8118
9772
  );
9773
+ return mergeSessionInventories(managed, await collectProjectSessions(cwd, storage));
8119
9774
  }
8120
9775
 
8121
9776
  /**
@@ -8136,12 +9791,35 @@ export class SessionManager {
8136
9791
  }
8137
9792
  }
8138
9793
 
8139
- /** Tombstone and exact-delete a default-managed candidate. */
9794
+ /** Delete an authorized managed or project-local picker candidate. */
8140
9795
  static async deleteManagedCandidate(sessionPath: string): Promise<void> {
8141
9796
  const storage = new FileSessionStorage();
8142
9797
  const entries = await loadEntriesFromFile(sessionPath, storage);
8143
9798
  const header = entries.find(entry => entry.type === "session") as SessionHeader | undefined;
8144
- if (!header?.cwd) throw new Error("Session has no valid managed workspace header.");
9799
+ if (!header?.cwd) throw new Error("Session has no valid workspace header.");
9800
+ const projectGjcDir = path.join(path.resolve(header.cwd), ".gjc");
9801
+ if (isProjectSessionTranscriptPath(projectGjcDir, sessionPath)) {
9802
+ const relativePath = path.relative(projectGjcDir, path.resolve(sessionPath)).split(path.sep).join("/");
9803
+ const authority = native.openRecoveryFsRoot(projectGjcDir);
9804
+ try {
9805
+ const captured = authority.readManaged(relativePath);
9806
+ if (!captured.ok || !captured.identity || !captured.data || !captured.identity.sha256)
9807
+ throw new Error("Project session is no longer an authorized candidate.");
9808
+ const removed = authority.removeManaged(
9809
+ relativePath,
9810
+ captured.identity.dev,
9811
+ captured.identity.ino,
9812
+ captured.identity.size,
9813
+ captured.identity.mtimeNs,
9814
+ captured.identity.ctimeNs,
9815
+ captured.identity.sha256,
9816
+ );
9817
+ if (!removed.ok) throw new Error(removed.code ?? "Could not delete project session.");
9818
+ return;
9819
+ } finally {
9820
+ authority.close();
9821
+ }
9822
+ }
8145
9823
  const sessionsRoot = path.resolve(sessionPath, "../..");
8146
9824
  const resolved = resolveManagedScope({
8147
9825
  cwd: header.cwd,
@@ -8232,24 +9910,25 @@ export class SessionManager {
8232
9910
  try {
8233
9911
  manager = new SessionManager(cwd, privateStagingDir ?? dir, true, snapshot.storage, forkDestination);
8234
9912
  await resolveBlobRefsInEntries(forkEntries, manager.#blobStore);
8235
- manager.#fileEntries = forkEntries;
8236
- const sourceHeader = manager.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
8237
- const historyEntries = manager.#fileEntries.filter(entry => entry.type !== "session") as SessionEntry[];
8238
- manager.#newSessionSync({ parentSession: sourceHeader?.id }, false);
8239
- manager.#resetResidentTextBlobStore();
8240
- const newHeader = manager.#fileEntries[0] as SessionHeader;
8241
- newHeader.title = sourceHeader?.title;
8242
- newHeader.titleSource = sourceHeader?.titleSource;
8243
- const residentBlobStores = manager.#residentBlobStores();
8244
- manager.#fileEntries = [
8245
- newHeader,
8246
- ...historyEntries.map(entry => prepareEntryForResidentSync(entry, residentBlobStores) as SessionEntry),
8247
- ];
8248
- manager.#sessionName = newHeader.title;
8249
- manager.#titleSource = newHeader.titleSource;
9913
+ const sourceHeader = forkEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
9914
+ const historyEntries = forkEntries.filter(entry => entry.type !== "session") as SessionEntry[];
9915
+ const fresh = manager.#freshSessionState({ parentSession: sourceHeader?.id });
9916
+ fresh.header.title = sourceHeader?.title;
9917
+ fresh.header.titleSource = sourceHeader?.titleSource;
9918
+ const transition = manager.#prepareResidentTextStoreTransition(
9919
+ {
9920
+ target: { sessionId: fresh.sessionId, sessionFile: fresh.sessionFile ?? "" },
9921
+ primary: {
9922
+ mode: "materialize",
9923
+ sourceEntries: [fresh.header, ...historyEntries],
9924
+ sourceStores: { textStore: null, imageStore: manager.#residentImageBlobStore },
9925
+ },
9926
+ },
9927
+ "memory-fallback",
9928
+ );
9929
+ manager.#applyFreshSessionMetadata(fresh);
9930
+ manager.#commitResidentTextStoreTransition(transition);
8250
9931
  manager.sanitizeLoadedOpenAIResponsesReplayMetadata();
8251
- manager.#buildIndex();
8252
- manager.#bumpAllRevisions();
8253
9932
  const beforeWrite = inspectResumeSessionFile(snapshot.sourcePath, snapshot.storage);
8254
9933
  if ("kind" in beforeWrite) {
8255
9934
  authorityFailure = beforeWrite;
@@ -8287,41 +9966,57 @@ export class SessionManager {
8287
9966
  throw new Error("managed_fork_transcript_changed");
8288
9967
  }
8289
9968
  if (privateStagingDir && manager.#sessionFile) {
8290
- const materializedEntries = materializeResidentEntriesForReadSync(
8291
- manager.#fileEntries,
8292
- manager.#residentBlobStores(),
9969
+ const stagedSessionFile = manager.#sessionFile;
9970
+ const finalSessionFile = path.join(dir, path.basename(stagedSessionFile));
9971
+ const finalTransition = manager.#prepareResidentTextStoreTransition(
9972
+ {
9973
+ target: { sessionId: manager.#sessionId, sessionFile: finalSessionFile },
9974
+ primary: {
9975
+ mode: "materialize",
9976
+ sourceEntries: manager.#fileEntries,
9977
+ sourceStores: {
9978
+ textStore: manager.#residentTextBlobStore,
9979
+ imageStore: manager.#residentImageBlobStore,
9980
+ },
9981
+ },
9982
+ },
9983
+ "retain-and-throw",
8293
9984
  );
8294
- manager.#fileEntries = materializedEntries;
8295
- manager.#disposeResidentTextBlobStore();
8296
- const capturedStaging = native.snapshotDirectoryTree(privateStagingDir);
8297
- if (!capturedStaging.ok || !capturedStaging.snapshot)
8298
- throw new Error(capturedStaging.code ?? "fork_staging_snapshot_failed");
8299
- privateStagingSnapshot = capturedStaging.snapshot;
8300
- fsyncManagedArtifactTree(privateStagingDir);
8301
- const outcome = classifyNativePublishOutcome(native.renameNoReplacePath(privateStagingDir, dir));
8302
- if (!outcome.ok) throw new Error(outcome.code ?? "fork_destination_publish_failed");
8303
- privateStagingPublished = true;
8304
- const terminal = native.snapshotDirectoryTree(dir);
8305
- if (
8306
- !terminal.ok ||
8307
- !terminal.snapshot ||
8308
- !retainedTreeSnapshotEqualsAfterRename(terminal.snapshot, privateStagingSnapshot)
8309
- )
8310
- throw new Error("fork_destination_terminal_identity_changed");
8311
- await syncSessionMoveDirectory(path.dirname(dir));
8312
- const durableTerminal = native.snapshotDirectoryTree(dir);
8313
- if (
8314
- !durableTerminal.ok ||
8315
- !durableTerminal.snapshot ||
8316
- !retainedTreeSnapshotEqualsAfterRename(durableTerminal.snapshot, privateStagingSnapshot)
8317
- )
8318
- throw new Error("fork_destination_durability_identity_changed");
8319
- manager.sessionDir = dir;
8320
- manager.destination = destination;
8321
- manager.#sessionFile = path.join(dir, path.basename(manager.#sessionFile));
8322
- manager.#resetResidentTextBlobStore();
8323
- manager.#reexternalizeFileEntriesForResidentStore();
8324
- manager.#bumpAllRevisions();
9985
+ // The staged store is no longer needed once the final candidate has copied its entries.
9986
+ // Dispose it before the staging tree becomes published evidence.
9987
+ manager.#disposeResidentTextStore(manager.#residentTextBlobStore);
9988
+ try {
9989
+ const capturedStaging = native.snapshotDirectoryTree(privateStagingDir);
9990
+ if (!capturedStaging.ok || !capturedStaging.snapshot)
9991
+ throw new Error(capturedStaging.code ?? "fork_staging_snapshot_failed");
9992
+ privateStagingSnapshot = capturedStaging.snapshot;
9993
+ fsyncManagedArtifactTree(privateStagingDir);
9994
+ const outcome = classifyNativePublishOutcome(native.renameNoReplacePath(privateStagingDir, dir));
9995
+ if (!outcome.ok) throw new Error(outcome.code ?? "fork_destination_publish_failed");
9996
+ privateStagingPublished = true;
9997
+ const terminal = native.snapshotDirectoryTree(dir);
9998
+ if (
9999
+ !terminal.ok ||
10000
+ !terminal.snapshot ||
10001
+ !retainedTreeSnapshotEqualsAfterRename(terminal.snapshot, privateStagingSnapshot)
10002
+ )
10003
+ throw new Error("fork_destination_terminal_identity_changed");
10004
+ await syncSessionMoveDirectory(path.dirname(dir));
10005
+ const durableTerminal = native.snapshotDirectoryTree(dir);
10006
+ if (
10007
+ !durableTerminal.ok ||
10008
+ !durableTerminal.snapshot ||
10009
+ !retainedTreeSnapshotEqualsAfterRename(durableTerminal.snapshot, privateStagingSnapshot)
10010
+ )
10011
+ throw new Error("fork_destination_durability_identity_changed");
10012
+ manager.sessionDir = dir;
10013
+ manager.destination = destination;
10014
+ manager.#sessionFile = finalSessionFile;
10015
+ manager.#commitResidentTextStoreTransition(finalTransition);
10016
+ } catch (error) {
10017
+ finalTransition.dispose();
10018
+ throw error;
10019
+ }
8325
10020
  }
8326
10021
  if (manager.#sessionFile) writeTerminalBreadcrumb(manager.cwd, manager.#sessionFile);
8327
10022
  return { kind: "forked", manager };
@@ -8349,6 +10044,194 @@ export class SessionManager {
8349
10044
  }
8350
10045
  }
8351
10046
 
10047
+ static async restoreMemoryGuardCheckpoint(input: MemoryGuardRestoreInput): Promise<MemoryGuardRestoreResult> {
10048
+ try {
10049
+ assertMemoryGuardSessionId(input.checkpoint.session_id);
10050
+ } catch {
10051
+ return { kind: "blocked", reason: "checkpoint-mismatch" };
10052
+ }
10053
+ if (!validateMemoryGuardCheckpoint(input.checkpoint)) return { kind: "blocked", reason: "checkpoint-mismatch" };
10054
+ if (!memoryGuardParticipantMatchesCheckpoint(input.participant, input.checkpoint)) {
10055
+ return { kind: "blocked", reason: "participant-mismatch" };
10056
+ }
10057
+ const checkpointText = readCheckpointAuthorityFile(
10058
+ input.incidentAuthority,
10059
+ memoryGuardParticipantRelativePath(input.checkpoint.session_id, "session-manager.json"),
10060
+ 256 * 1024,
10061
+ );
10062
+ if (!checkpointText) return { kind: "blocked", reason: "checkpoint-mismatch" };
10063
+ const checkpointCanonical = memoryGuardCanonicalJson(input.checkpoint);
10064
+ if (decodeCheckpointUtf8(checkpointText) !== checkpointCanonical) {
10065
+ return { kind: "blocked", reason: "checkpoint-mismatch" };
10066
+ }
10067
+ const transcriptBytes = Number(input.checkpoint.transcript.bytes);
10068
+ if (!Number.isSafeInteger(transcriptBytes) || transcriptBytes < 0) {
10069
+ return { kind: "blocked", reason: "transcript-mismatch" };
10070
+ }
10071
+ const transcriptData = readCheckpointAuthorityFile(
10072
+ input.incidentAuthority,
10073
+ input.checkpoint.transcript.relative_path,
10074
+ transcriptBytes + 1,
10075
+ );
10076
+ if (!transcriptData || transcriptData.byteLength !== transcriptBytes) {
10077
+ return { kind: "blocked", reason: "transcript-mismatch" };
10078
+ }
10079
+ if (memoryGuardSha256Hex(transcriptData) !== input.checkpoint.transcript.sha256) {
10080
+ return { kind: "blocked", reason: "transcript-mismatch" };
10081
+ }
10082
+ const transcriptText = decodeCheckpointUtf8(transcriptData);
10083
+ if (transcriptText === null) return { kind: "blocked", reason: "transcript-mismatch" };
10084
+ let transcriptEntries: FileEntry[];
10085
+ try {
10086
+ transcriptEntries = parseSessionEntries(transcriptText);
10087
+ } catch {
10088
+ return { kind: "blocked", reason: "malformed" };
10089
+ }
10090
+ const transcriptHeader = transcriptEntries[0] as SessionHeader | undefined;
10091
+ if (
10092
+ transcriptHeader?.type !== "session" ||
10093
+ transcriptHeader.id !== input.checkpoint.session_id ||
10094
+ (transcriptHeader.title ?? null) !== input.checkpoint.session_name
10095
+ ) {
10096
+ return { kind: "blocked", reason: "transcript-mismatch" };
10097
+ }
10098
+ const manifestData = readCheckpointAuthorityFile(
10099
+ input.incidentAuthority,
10100
+ input.checkpoint.blob_authority.manifest_relative_path,
10101
+ 8 * 1024 * 1024,
10102
+ );
10103
+ if (!manifestData) return { kind: "blocked", reason: "blob-authority-mismatch" };
10104
+ if (memoryGuardSha256Hex(manifestData) !== input.checkpoint.blob_authority.manifest_sha256) {
10105
+ return { kind: "blocked", reason: "blob-authority-mismatch" };
10106
+ }
10107
+ const manifestText = decodeCheckpointUtf8(manifestData);
10108
+ if (manifestText === null) return { kind: "blocked", reason: "blob-manifest-mismatch" };
10109
+ let blobManifest: unknown;
10110
+ try {
10111
+ blobManifest = JSON.parse(manifestText);
10112
+ } catch {
10113
+ return { kind: "blocked", reason: "blob-manifest-mismatch" };
10114
+ }
10115
+ if (!validateMemoryGuardBlobManifest(blobManifest)) return { kind: "blocked", reason: "blob-manifest-mismatch" };
10116
+ if (memoryGuardCanonicalJson(blobManifest) !== manifestText)
10117
+ return { kind: "blocked", reason: "blob-manifest-mismatch" };
10118
+ if (blobManifest.entries.length > MEMORY_GUARD_CHECKPOINT_BLOB_MAX_ENTRIES)
10119
+ return { kind: "blocked", reason: "blob-manifest-mismatch" };
10120
+ let checkpointBlobBytes = 0;
10121
+ const seenBlobPaths = new Set<string>();
10122
+ const checkpointBlobs = new Map<string, Buffer>();
10123
+ for (const entry of blobManifest.entries) {
10124
+ if (
10125
+ entry.relative_path !== entry.sha256 ||
10126
+ seenBlobPaths.has(entry.relative_path) ||
10127
+ checkpointBlobs.has(entry.sha256)
10128
+ )
10129
+ return { kind: "blocked", reason: "blob-manifest-mismatch" };
10130
+ seenBlobPaths.add(entry.relative_path);
10131
+ const blobBytes = Number(entry.bytes);
10132
+ if (!Number.isSafeInteger(blobBytes) || blobBytes < 0)
10133
+ return { kind: "blocked", reason: "blob-manifest-mismatch" };
10134
+ checkpointBlobBytes += blobBytes;
10135
+ if (checkpointBlobBytes > MEMORY_GUARD_CHECKPOINT_BLOB_TOTAL_MAX_BYTES)
10136
+ return { kind: "blocked", reason: "blob-manifest-mismatch" };
10137
+ const blobPath = `${input.checkpoint.blob_authority.root_relative_path}/${entry.relative_path}`;
10138
+ const blobData = readCheckpointAuthorityFile(input.incidentAuthority, blobPath, blobBytes + 1);
10139
+ if (!blobData || blobData.byteLength !== blobBytes) return { kind: "blocked", reason: "blob-missing" };
10140
+ if (memoryGuardSha256Hex(blobData) !== entry.sha256) return { kind: "blocked", reason: "blob-hash-mismatch" };
10141
+ checkpointBlobs.set(entry.sha256, Buffer.from(blobData));
10142
+ }
10143
+ for (const ref of collectCheckpointBlobRefs(transcriptEntries)) {
10144
+ const hash = parseBlobRef(ref);
10145
+ if (!hash || !checkpointBlobs.has(hash)) return { kind: "blocked", reason: "blob-manifest-mismatch" };
10146
+ }
10147
+ const storage = new FileSessionStorage();
10148
+ let destination: SessionDestination;
10149
+ try {
10150
+ destination =
10151
+ input.destination === undefined
10152
+ ? explicitDestination(path.join(os.tmpdir(), `gjc-memory-guard-${input.checkpoint.session_id}`))
10153
+ : destinationFor(getProjectDir(), input.destination, storage);
10154
+ } catch {
10155
+ return { kind: "blocked", reason: "destination-unavailable" };
10156
+ }
10157
+ const transcriptPath = path.join(
10158
+ destination.directory,
10159
+ `.${input.checkpoint.session_id}.memory-guard.${crypto.randomUUID()}.jsonl`,
10160
+ );
10161
+ const transcriptName = path.basename(transcriptPath);
10162
+ const managedStagingStore =
10163
+ destination.kind === "managed"
10164
+ ? managedStoreFromContext(destination.securityContext, destination.directory)
10165
+ : undefined;
10166
+ const cleanupTranscript = async (): Promise<void> => {
10167
+ if (managedStagingStore) {
10168
+ await managedStagingStore.remove(transcriptName);
10169
+ return;
10170
+ }
10171
+ await fs.promises.rm(transcriptPath, { force: true });
10172
+ };
10173
+ let opened: RecoveryHydrationOpenResult;
10174
+ let transcriptIdentity: ResumeSessionIdentity;
10175
+ let createdDestination: CreatedDirectoryIdentity | undefined;
10176
+ try {
10177
+ if (managedStagingStore) await managedStagingStore.publishNoReplace(transcriptName, transcriptData);
10178
+ else {
10179
+ createdDestination = await ensureOwnerOnlyDirectoryTracked(destination.directory);
10180
+ await writeOwnerOnlyFileNoReplace(transcriptPath, transcriptData);
10181
+ }
10182
+ const captured = SessionManager.captureTranscriptStrict(transcriptPath, storage);
10183
+ if (captured.kind !== "captured") {
10184
+ await cleanupTranscript();
10185
+ await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
10186
+ return { kind: "blocked", reason: captured.reason };
10187
+ }
10188
+ transcriptIdentity = captured.snapshot.identity;
10189
+ opened = await SessionManager.openExistingForRecoveryHydrationStrict(
10190
+ transcriptIdentity,
10191
+ input.destination,
10192
+ storage,
10193
+ );
10194
+ } catch {
10195
+ await cleanupTranscript();
10196
+ await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
10197
+ return { kind: "blocked", reason: "destination-unavailable" };
10198
+ }
10199
+ if (opened.kind === "error") {
10200
+ await cleanupTranscript();
10201
+ await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
10202
+ return { kind: "blocked", reason: opened.reason };
10203
+ }
10204
+ if (
10205
+ opened.manager.getSessionId() !== input.checkpoint.session_id ||
10206
+ (opened.manager.getSessionName() ?? null) !== input.checkpoint.session_name
10207
+ ) {
10208
+ await opened.manager.close();
10209
+ await cleanupTranscript();
10210
+ await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
10211
+ return { kind: "blocked", reason: "transcript-mismatch" };
10212
+ }
10213
+ opened.manager.#stageMemoryGuardCheckpointBlobs(checkpointBlobs);
10214
+ opened.manager.#recoveryPromotionTranscriptPath = path.join(
10215
+ destination.directory,
10216
+ `${new Date().toISOString().replace(/[:.]/g, "-")}_${input.checkpoint.session_id}.jsonl`,
10217
+ );
10218
+ let cleanupConsumed = false;
10219
+ const cleanup = async (): Promise<void> => {
10220
+ if (cleanupConsumed) return;
10221
+ await opened.manager.close();
10222
+ await cleanupTranscript();
10223
+ await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
10224
+ cleanupConsumed = true;
10225
+ };
10226
+ return {
10227
+ kind: "staged",
10228
+ manager: opened.manager,
10229
+ hydrationContext: opened.context,
10230
+ transcriptIdentity,
10231
+ cleanup,
10232
+ };
10233
+ }
10234
+
8352
10235
  /** Inspect a selected session without acquiring write-capable ownership. */
8353
10236
  static async inspectSessionTailReadOnly(
8354
10237
  filePath: string,
@@ -8389,7 +10272,7 @@ export class SessionManager {
8389
10272
  storage,
8390
10273
  destination,
8391
10274
  );
8392
- await manager.#hydrateExistingSession(identity.canonicalPath, inspected.entries, false);
10275
+ await manager.#hydrateExistingSession(identity.canonicalPath, inspected.entries, false, "memory-only");
8393
10276
  const revalidated = inspectResumeSessionFile(identity.canonicalPath, storage);
8394
10277
  if ("kind" in revalidated || !sameResumeIdentity(identity, revalidated.identity)) {
8395
10278
  await manager.close();
@@ -8417,8 +10300,51 @@ export class SessionManager {
8417
10300
  if ("kind" in inspected || !sameResumeIdentity(context.identity, inspected.identity)) {
8418
10301
  throw new Error("Recovery transcript authority changed before promotion.");
8419
10302
  }
10303
+ if (this.#memoryGuardCheckpointBlobs) {
10304
+ for (const blob of this.#memoryGuardCheckpointBlobs.values()) this.#blobStore.putImmutableSync(blob);
10305
+ this.#residentImageBlobStore = this.#blobStore;
10306
+ this.#memoryGuardCheckpointBlobs = undefined;
10307
+ }
8420
10308
  await this.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
8421
- writeTerminalBreadcrumb(this.cwd, context.identity.canonicalPath);
10309
+ const promotionPath = this.#recoveryPromotionTranscriptPath;
10310
+ if (!promotionPath) throw new Error("Recovery transcript promotion path is unavailable.");
10311
+ const promotedSource = inspectResumeSessionFile(context.identity.canonicalPath, this.storage);
10312
+ if ("kind" in promotedSource) throw new Error("Recovery transcript became unavailable before publication.");
10313
+ const transition = this.#prepareResidentTextStoreTransition(
10314
+ {
10315
+ target: { sessionId: this.#sessionId, sessionFile: promotionPath },
10316
+ primary: {
10317
+ mode: "materialize",
10318
+ sourceEntries: this.#fileEntries,
10319
+ sourceStores: {
10320
+ textStore: this.#residentTextBlobStore,
10321
+ imageStore: this.#residentImageBlobStore,
10322
+ },
10323
+ },
10324
+ },
10325
+ "retain-and-throw",
10326
+ );
10327
+ try {
10328
+ if (this.destination.kind === "managed") {
10329
+ const store = this.#managedTranscriptStore();
10330
+ const sourceName = path.basename(context.identity.canonicalPath);
10331
+ const sourceSnapshot = store.readExpected(sourceName);
10332
+ if (!sourceSnapshot) throw new Error("Recovery transcript authority changed before publication.");
10333
+ await store.publishNoReplace(path.basename(promotionPath), promotedSource.content);
10334
+ store.removeExpected(sourceName, sourceSnapshot);
10335
+ } else {
10336
+ await writeOwnerOnlyFileNoReplace(promotionPath, promotedSource.content);
10337
+ await fs.promises.rm(context.identity.canonicalPath, { force: true });
10338
+ await fsyncDirectoryPath(path.dirname(context.identity.canonicalPath));
10339
+ }
10340
+ } catch (error) {
10341
+ transition.dispose();
10342
+ throw error;
10343
+ }
10344
+ this.#sessionFile = promotionPath;
10345
+ this.#recoveryPromotionTranscriptPath = undefined;
10346
+ this.#commitResidentTextStoreTransition(transition);
10347
+ writeTerminalBreadcrumb(this.cwd, promotionPath);
8422
10348
  this.#recoveryHydrationContext = undefined;
8423
10349
  }
8424
10350