@gajae-code/coding-agent 0.12.0 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (247) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/dist/types/async/job-manager.d.ts +17 -3
  3. package/dist/types/config/settings-schema.d.ts +56 -2
  4. package/dist/types/exa/mcp-client.d.ts +10 -1
  5. package/dist/types/exec/bash-executor.d.ts +45 -12
  6. package/dist/types/extensibility/extensions/types.d.ts +16 -2
  7. package/dist/types/extensibility/gjc-plugins/index.d.ts +12 -2
  8. package/dist/types/extensibility/gjc-plugins/installer.d.ts +63 -7
  9. package/dist/types/extensibility/gjc-plugins/lifecycle-reconciliation.d.ts +55 -0
  10. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +64 -0
  11. package/dist/types/extensibility/gjc-plugins/runtime-quarantine.d.ts +67 -0
  12. package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +3 -1
  13. package/dist/types/extensibility/gjc-plugins/types.d.ts +124 -0
  14. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  15. package/dist/types/gjc-runtime/ralplan-review-conflicts.d.ts +114 -0
  16. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +23 -3
  17. package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
  18. package/dist/types/gjc-runtime/team-runtime.d.ts +25 -0
  19. package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +43 -0
  20. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +10 -0
  21. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +37 -80
  22. package/dist/types/modes/acp/acp-agent.d.ts +2 -19
  23. package/dist/types/modes/components/gjc-bundle-settings.d.ts +34 -0
  24. package/dist/types/modes/components/irc-sidebar.d.ts +16 -0
  25. package/dist/types/modes/components/notifications-settings-editor.d.ts +1 -0
  26. package/dist/types/modes/components/session-observer-overlay.d.ts +18 -0
  27. package/dist/types/modes/components/settings-selector.d.ts +9 -0
  28. package/dist/types/modes/components/status-line/gh.d.ts +5 -0
  29. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +18 -1
  30. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  31. package/dist/types/modes/controllers/event-controller.d.ts +1 -1
  32. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -1
  33. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  34. package/dist/types/modes/interactive-mode.d.ts +9 -0
  35. package/dist/types/modes/theme/theme.d.ts +3 -1
  36. package/dist/types/modes/types.d.ts +32 -0
  37. package/dist/types/reminders/star-reminder.d.ts +3 -13
  38. package/dist/types/runtime-mcp/manager.d.ts +1 -0
  39. package/dist/types/sdk/acp/adapter.d.ts +3 -1
  40. package/dist/types/sdk/broker/transport.d.ts +2 -0
  41. package/dist/types/sdk/bus/attachment-registry.d.ts +1 -0
  42. package/dist/types/sdk/bus/chat-daemon-control.d.ts +5 -1
  43. package/dist/types/sdk/bus/config.d.ts +6 -2
  44. package/dist/types/sdk/bus/index.d.ts +29 -0
  45. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +8 -1
  46. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
  47. package/dist/types/sdk/bus/reconciliation-store.d.ts +7 -3
  48. package/dist/types/sdk/bus/rich-draft.d.ts +1 -0
  49. package/dist/types/sdk/bus/rich-render.d.ts +2 -0
  50. package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +165 -0
  51. package/dist/types/sdk/bus/telegram-cli.d.ts +12 -0
  52. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -4
  53. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -1
  54. package/dist/types/sdk/bus/telegram-reference.d.ts +11 -1
  55. package/dist/types/sdk/bus/threaded-render.d.ts +3 -2
  56. package/dist/types/sdk/bus/topic-registry.d.ts +11 -1
  57. package/dist/types/sdk/host/query/revision-store.d.ts +2 -0
  58. package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
  59. package/dist/types/sdk/prompt-status.d.ts +23 -3
  60. package/dist/types/sdk/session.d.ts +6 -0
  61. package/dist/types/session/agent-session.d.ts +40 -2
  62. package/dist/types/session/artifacts.d.ts +8 -0
  63. package/dist/types/session/blob-store.d.ts +57 -4
  64. package/dist/types/session/internal/managed-session-storage.d.ts +30 -4
  65. package/dist/types/session/session-manager.d.ts +23 -6
  66. package/dist/types/session/session-storage.d.ts +4 -0
  67. package/dist/types/session/streaming-output.d.ts +36 -7
  68. package/dist/types/setup/credential-import.d.ts +12 -0
  69. package/dist/types/skill-state/active-state.d.ts +12 -0
  70. package/dist/types/skill-state/workflow-hud.d.ts +6 -0
  71. package/dist/types/task/executor.d.ts +3 -2
  72. package/dist/types/task/index.d.ts +7 -1
  73. package/dist/types/task/output-manager.d.ts +1 -0
  74. package/dist/types/task/provider-retry-status.d.ts +8 -0
  75. package/dist/types/task/receipt.d.ts +9 -1
  76. package/dist/types/task/types.d.ts +23 -0
  77. package/dist/types/tools/ask-answer-registry.d.ts +2 -2
  78. package/dist/types/tools/bash-interactive.d.ts +4 -1
  79. package/dist/types/tools/bash.d.ts +3 -1
  80. package/dist/types/tools/image-gen.d.ts +4 -0
  81. package/dist/types/tools/index.d.ts +6 -0
  82. package/dist/types/tools/output-meta.d.ts +23 -2
  83. package/dist/types/tools/path-utils.d.ts +1 -0
  84. package/dist/types/tools/read.d.ts +0 -6
  85. package/dist/types/tools/review.d.ts +5 -7
  86. package/dist/types/tools/subagent.d.ts +2 -0
  87. package/dist/types/utils/gh.d.ts +13 -0
  88. package/dist/types/utils/image-resize.d.ts +1 -0
  89. package/dist/types/web/search/providers/kimi.d.ts +2 -0
  90. package/dist/types/web/search/providers/searxng.d.ts +7 -0
  91. package/dist/types/web/search/providers/xai.d.ts +2 -0
  92. package/package.json +10 -8
  93. package/scripts/acp-conformance-agent.ts +218 -0
  94. package/scripts/capture-gjc-bundle-settings.ts +306 -0
  95. package/scripts/dogfood-ralplan-review-conflicts.ts +272 -0
  96. package/scripts/generate-sdk-operation-inventory.ts +5 -0
  97. package/scripts/resident-memory-bench.ts +1341 -0
  98. package/scripts/run-acp-conformance.ts +290 -0
  99. package/src/async/job-manager.ts +99 -39
  100. package/src/cli/fast-help.ts +1 -1
  101. package/src/cli/plugin-cli.ts +176 -18
  102. package/src/commands/team.ts +19 -1
  103. package/src/config/file-lock.ts +11 -2
  104. package/src/config/settings-schema.ts +53 -2
  105. package/src/coordinator-mcp/question-gate-codec.ts +1 -1
  106. package/src/coordinator-mcp/server.ts +5 -12
  107. package/src/debug/index.ts +42 -17
  108. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -0
  109. package/src/defaults/gjc/skills/ralplan/SKILL.md +25 -20
  110. package/src/defaults/gjc/skills/team/SKILL.md +2 -0
  111. package/src/defaults/gjc/skills/ultragoal/SKILL.md +39 -26
  112. package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +12 -0
  113. package/src/defaults/gjc-defaults.ts +4 -4
  114. package/src/exa/mcp-client.ts +12 -3
  115. package/src/exec/bash-executor.ts +144 -27
  116. package/src/extensibility/extensions/runner.ts +15 -0
  117. package/src/extensibility/extensions/types.ts +18 -1
  118. package/src/extensibility/gjc-plugins/constrained-hooks.ts +8 -2
  119. package/src/extensibility/gjc-plugins/index.ts +19 -2
  120. package/src/extensibility/gjc-plugins/installer.ts +189 -50
  121. package/src/extensibility/gjc-plugins/lifecycle-reconciliation.ts +199 -0
  122. package/src/extensibility/gjc-plugins/lifecycle.ts +626 -0
  123. package/src/extensibility/gjc-plugins/observability.ts +3 -3
  124. package/src/extensibility/gjc-plugins/prompt-appendix.ts +5 -1
  125. package/src/extensibility/gjc-plugins/runtime-adapters.ts +12 -2
  126. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +121 -0
  127. package/src/extensibility/gjc-plugins/schema.ts +84 -12
  128. package/src/extensibility/gjc-plugins/session-validation.ts +9 -3
  129. package/src/extensibility/gjc-plugins/types.ts +141 -0
  130. package/src/extensibility/gjc-plugins/validation.ts +9 -0
  131. package/src/gjc-runtime/launch-tmux.ts +25 -6
  132. package/src/gjc-runtime/launch-worktree.ts +103 -1
  133. package/src/gjc-runtime/ledger-event-renderer.ts +1 -0
  134. package/src/gjc-runtime/managed-owner-admission.ts +1 -1
  135. package/src/gjc-runtime/ralplan-review-conflicts.ts +472 -0
  136. package/src/gjc-runtime/ralplan-runtime.ts +491 -27
  137. package/src/gjc-runtime/team-launch.ts +8 -0
  138. package/src/gjc-runtime/team-runtime.ts +140 -32
  139. package/src/gjc-runtime/team-worker-heartbeat.ts +149 -0
  140. package/src/gjc-runtime/tmux-owner-isolation.ts +10 -0
  141. package/src/gjc-runtime/tmux-sessions.ts +58 -13
  142. package/src/gjc-runtime/ultragoal-evidence.ts +6 -74
  143. package/src/gjc-runtime/ultragoal-runtime.ts +635 -612
  144. package/src/gjc-runtime/workflow-manifest.generated.json +37 -99
  145. package/src/gjc-runtime/workflow-manifest.ts +21 -70
  146. package/src/internal-urls/docs-index.generated.ts +16 -16
  147. package/src/modes/DESIGN.md +104 -0
  148. package/src/modes/acp/acp-agent.ts +298 -186
  149. package/src/modes/components/gjc-bundle-settings.ts +508 -0
  150. package/src/modes/components/irc-sidebar.ts +30 -1
  151. package/src/modes/components/notifications-settings-editor.ts +12 -1
  152. package/src/modes/components/oauth-selector.ts +68 -30
  153. package/src/modes/components/session-observer-overlay.ts +305 -86
  154. package/src/modes/components/settings-selector.ts +56 -5
  155. package/src/modes/components/status-line/gh.ts +16 -0
  156. package/src/modes/components/tool-status-header.ts +3 -15
  157. package/src/modes/components/transcript-viewer-overlay.ts +99 -24
  158. package/src/modes/controllers/command-controller.ts +42 -34
  159. package/src/modes/controllers/event-controller.ts +27 -16
  160. package/src/modes/controllers/extension-ui-controller.ts +193 -34
  161. package/src/modes/controllers/selector-controller.ts +160 -22
  162. package/src/modes/interactive-mode.ts +189 -62
  163. package/src/modes/jobs-observer.ts +1 -1
  164. package/src/modes/runtime-init.ts +3 -0
  165. package/src/modes/theme/theme.ts +11 -9
  166. package/src/modes/types.ts +88 -0
  167. package/src/prompts/agent-fragments/ralplan-persistence.md +4 -3
  168. package/src/prompts/system/eager-todo.md +8 -4
  169. package/src/prompts/system/system-prompt.md +11 -4
  170. package/src/prompts/tools/bash.md +5 -5
  171. package/src/prompts/tools/search-tool-bm25.md +5 -0
  172. package/src/prompts/tools/task.md +1 -1
  173. package/src/reminders/star-reminder.ts +4 -42
  174. package/src/runtime-mcp/manager.ts +19 -4
  175. package/src/sdk/acp/adapter.ts +22 -6
  176. package/src/sdk/broker/lifecycle.ts +18 -13
  177. package/src/sdk/broker/transport.ts +35 -3
  178. package/src/sdk/bus/attachment-registry.ts +5 -1
  179. package/src/sdk/bus/chat-daemon-cli.ts +4 -39
  180. package/src/sdk/bus/chat-daemon-control.ts +7 -3
  181. package/src/sdk/bus/config.ts +18 -2
  182. package/src/sdk/bus/index.ts +661 -126
  183. package/src/sdk/bus/kind-aware-reconciliation.ts +148 -73
  184. package/src/sdk/bus/lifecycle-commands.ts +4 -1
  185. package/src/sdk/bus/lifecycle-control-runtime.ts +403 -88
  186. package/src/sdk/bus/reconciliation-store.ts +95 -16
  187. package/src/sdk/bus/rich-draft.ts +1 -1
  188. package/src/sdk/bus/rich-render.ts +2 -2
  189. package/src/sdk/bus/telegram-adoption-intent.ts +574 -0
  190. package/src/sdk/bus/telegram-cli.ts +33 -8
  191. package/src/sdk/bus/telegram-daemon-cli.ts +1 -0
  192. package/src/sdk/bus/telegram-daemon-contract.ts +12 -4
  193. package/src/sdk/bus/telegram-daemon.ts +916 -23
  194. package/src/sdk/bus/telegram-reference.ts +40 -3
  195. package/src/sdk/bus/threaded-render.ts +48 -3
  196. package/src/sdk/bus/topic-registry.ts +20 -0
  197. package/src/sdk/host/query/revision-store.ts +36 -2
  198. package/src/sdk/host/reverse-leases.ts +44 -7
  199. package/src/sdk/prompt-status.ts +16 -3
  200. package/src/sdk/protocol/operation-inventory.generated.json +33 -0
  201. package/src/sdk/session.ts +89 -9
  202. package/src/session/agent-session.ts +844 -173
  203. package/src/session/artifacts.ts +22 -0
  204. package/src/session/auth-broker-config.ts +10 -3
  205. package/src/session/blob-store.ts +915 -24
  206. package/src/session/internal/managed-session-scope.ts +200 -111
  207. package/src/session/internal/managed-session-storage.ts +492 -62
  208. package/src/session/session-manager.ts +1614 -435
  209. package/src/session/session-storage.ts +38 -45
  210. package/src/session/streaming-output.ts +238 -68
  211. package/src/setup/credential-import.ts +75 -13
  212. package/src/skill-state/active-state.ts +32 -0
  213. package/src/skill-state/workflow-hud.ts +17 -0
  214. package/src/skill-state/workflow-mutation-guard.ts +496 -2
  215. package/src/task/executor.ts +122 -39
  216. package/src/task/index.ts +280 -45
  217. package/src/task/output-manager.ts +24 -14
  218. package/src/task/provider-retry-status.ts +16 -0
  219. package/src/task/receipt.ts +34 -8
  220. package/src/task/render.ts +6 -2
  221. package/src/task/types.ts +108 -0
  222. package/src/tools/ask-answer-registry.ts +12 -6
  223. package/src/tools/ask.ts +86 -55
  224. package/src/tools/ast-edit.ts +1 -0
  225. package/src/tools/ast-grep.ts +1 -0
  226. package/src/tools/bash-interactive.ts +8 -3
  227. package/src/tools/bash.ts +503 -60
  228. package/src/tools/find.ts +1 -0
  229. package/src/tools/image-gen.ts +29 -5
  230. package/src/tools/index.ts +6 -0
  231. package/src/tools/output-meta.ts +108 -14
  232. package/src/tools/path-utils.ts +3 -2
  233. package/src/tools/read.ts +18 -0
  234. package/src/tools/review.ts +5 -7
  235. package/src/tools/search.ts +1 -0
  236. package/src/tools/subagent-render.ts +6 -2
  237. package/src/tools/subagent.ts +6 -0
  238. package/src/tools/telegram-send.ts +6 -3
  239. package/src/utils/gh.ts +39 -0
  240. package/src/utils/image-resize.ts +3 -0
  241. package/src/web/search/providers/anthropic.ts +4 -4
  242. package/src/web/search/providers/kimi.ts +15 -5
  243. package/src/web/search/providers/searxng.ts +47 -4
  244. package/src/web/search/providers/xai.ts +7 -2
  245. package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +0 -23
  246. package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +0 -30
  247. 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,
@@ -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,6 +83,7 @@ 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";
82
88
  import {
83
89
  hasOnlyKeys as hasOnlyMemoryGuardKeys,
@@ -181,6 +187,107 @@ interface PreparedNewSessionState extends PreparedNewSession {
181
187
  persistenceTempPath?: string;
182
188
  }
183
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
+
184
291
  export interface SessionEntryBase {
185
292
  type: string;
186
293
  id: string;
@@ -222,11 +329,16 @@ export interface SessionManagerObservabilityStats {
222
329
  coldSpillReadCount: number;
223
330
  residentTextReadCount: number;
224
331
  residentImageReadCount: number;
332
+ residentCacheAdoptFallbackCount: number;
333
+ residentCacheTrustRejectCount: number;
334
+ residentCacheWin32FallbackCount: number;
335
+ residentCacheDegradedReason?: string;
225
336
  publicMaterializerCallCount: number;
226
337
  getEntryMaterializerCallCount: number;
227
338
  getBranchMaterializerCallCount: number;
228
339
  getEntriesMaterializerCallCount: number;
229
340
  materializedEntriesCachePopulateCount: number;
341
+ materializedCacheDemotedCount: number;
230
342
  pathOnlyContextBuildCount: number;
231
343
  }
232
344
 
@@ -1615,10 +1727,17 @@ function managedScopeStartupError(
1615
1727
  action: "resolve" | "prepare",
1616
1728
  failure: Extract<ReturnType<typeof resolveManagedScopeForWrite>, { kind: "error" }>,
1617
1729
  ): Error {
1618
- 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, {
1619
1738
  cause: {
1620
- classification: failure.cause?.classification ?? failure.code,
1621
- ...(failure.cause?.diagnostic === undefined ? {} : { diagnostic: failure.cause.diagnostic }),
1739
+ classification,
1740
+ ...(diagnostic === undefined ? {} : { diagnostic }),
1622
1741
  },
1623
1742
  });
1624
1743
  }
@@ -1646,11 +1765,54 @@ export type SessionDestination =
1646
1765
  | { readonly kind: "explicit"; readonly directory: string };
1647
1766
  export type SessionDestinationInput = string | SessionDestination | undefined;
1648
1767
 
1649
- interface ForkArtifactPublication {
1650
- readonly snapshot: native.NativeDirectoryTreeSnapshot;
1651
- readonly store: ManagedSessionDescendantStore;
1652
- readonly cleanupStore: ManagedSessionDescendantStore;
1653
- 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
+ };
1654
1816
  }
1655
1817
 
1656
1818
  function retainedTreeSnapshotEquals(
@@ -1703,6 +1865,51 @@ function retainedTreeSnapshotEqualsAfterRename(
1703
1865
  );
1704
1866
  }
1705
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
+
1706
1913
  function managedFileSnapshotEquals(left: ManagedFileSnapshot | null, right: ManagedFileSnapshot | null): boolean {
1707
1914
  return (
1708
1915
  left !== null &&
@@ -1717,6 +1924,7 @@ function managedFileSnapshotEquals(left: ManagedFileSnapshot | null, right: Mana
1717
1924
  );
1718
1925
  }
1719
1926
  const trustedSessionDestinations = new WeakSet<SessionDestination>();
1927
+ const explicitProfileAgentDirs = new WeakMap<SessionDestination, string>();
1720
1928
  const managedSecurityPolicies = new WeakMap<ManagedSessionSecurityContext, ManagedSessionSecurityPolicy>();
1721
1929
 
1722
1930
  function managedSecurityPolicyForContext(securityContext: ManagedSessionSecurityContext): ManagedSessionSecurityPolicy {
@@ -1729,20 +1937,26 @@ function managedStoreFromContext(
1729
1937
  securityContext: ManagedSessionSecurityContext,
1730
1938
  directory: string,
1731
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);
1732
1944
  return new ManagedSessionDescendantStore(
1733
1945
  securityContext.rootAuthority,
1734
1946
  directory,
1735
- securityContext.retainedAuthority
1947
+ securityContext.retainedAuthority && ownsRetainedAuthority
1736
1948
  ? { authority: securityContext.retainedAuthority, authorityBaseDir: directory }
1737
1949
  : undefined,
1738
1950
  managedSecurityPolicyForContext(securityContext),
1951
+ securityContext.profileAgentDir,
1739
1952
  );
1740
1953
  }
1741
1954
 
1742
- function freezeManagedDestination(scope: ManagedScope): SessionDestination {
1955
+ function freezeManagedDestination(scope: ManagedScope, profileAgentDir: string): SessionDestination {
1743
1956
  const rootAuthority = managedRootForScope(scope);
1744
1957
  const securityContext = createManagedSessionSecurityContext({
1745
1958
  agentDir: scope.agentDir,
1959
+ profileAgentDir,
1746
1960
  sessionsRoot: scope.sessionsRoot,
1747
1961
  sessionDir: scope.directoryPath,
1748
1962
  rootAuthority,
@@ -1760,6 +1974,7 @@ function freezeManagedDestination(scope: ManagedScope): SessionDestination {
1760
1974
  function explicitDestination(directory: string): SessionDestination {
1761
1975
  const destination = Object.freeze({ kind: "explicit" as const, directory });
1762
1976
  trustedSessionDestinations.add(destination);
1977
+ explicitProfileAgentDirs.set(destination, getAgentDir());
1763
1978
  return destination;
1764
1979
  }
1765
1980
 
@@ -1777,7 +1992,7 @@ function managedDestination(cwd: string, storage: SessionStorage, agentDir?: str
1777
1992
  process.platform === "win32" ? "windows-existing-verify-first" : "default",
1778
1993
  );
1779
1994
  if (prepared.kind === "error") throw managedScopeStartupError("prepare", prepared);
1780
- return freezeManagedDestination(prepared.scope);
1995
+ return freezeManagedDestination(prepared.scope, agentDir ?? getAgentDir());
1781
1996
  }
1782
1997
 
1783
1998
  function destinationFor(
@@ -1951,6 +2166,10 @@ function sameResumeStat(left: SessionStorageStat, right: SessionStorageStat): bo
1951
2166
  );
1952
2167
  }
1953
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
+
1954
2173
  interface ResumeInspectionSnapshot {
1955
2174
  identity: ResumeSessionIdentity;
1956
2175
  content: Uint8Array;
@@ -2680,6 +2899,48 @@ function collectResidentImageRefs(value: unknown, refs: Set<string>, seen = new
2680
2899
  for (const child of Object.values(value)) collectResidentImageRefs(child, refs, seen);
2681
2900
  }
2682
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
+
2683
2944
  /**
2684
2945
  * Recursively truncate large strings in an object for session persistence.
2685
2946
  * - Truncates any oversized string fields (key-agnostic)
@@ -2776,13 +3037,13 @@ function shouldExternalizeResidentString(key: string | undefined): boolean {
2776
3037
  interface ResidentBlobStores {
2777
3038
  textStore: BlobStore;
2778
3039
  imageStore: BlobStore;
3040
+ /** Trusted alternate bytes used only while demoting an invalidated resident store. */
3041
+ textFallback?: (hash: string) => Buffer | null;
2779
3042
  sessionId?: string;
2780
3043
  sessionFile?: string;
2781
3044
  onResidentBlobRead?: (kind: ResidentBlobKind) => void;
2782
3045
  }
2783
3046
 
2784
- type ResidentBlobMissingPolicy = "throw" | "placeholder";
2785
-
2786
3047
  function residentBlobMissingPlaceholder(error: ResidentBlobMissingError): string {
2787
3048
  return `[Session resident ${error.kind} blob missing: sha256:${error.hash}; original content unavailable]`;
2788
3049
  }
@@ -2793,7 +3054,7 @@ function externalizeResidentValueSync(obj: unknown, stores: ResidentBlobStores,
2793
3054
  if (key === "image_url" && isImageDataUrl(obj) && obj.length >= BLOB_EXTERNALIZE_THRESHOLD)
2794
3055
  return residentBlobSentinel("imageUrl", externalizeImageDataUrlSync(stores.imageStore, obj));
2795
3056
  if (shouldExternalizeResidentString(key) && obj.length >= BLOB_EXTERNALIZE_THRESHOLD)
2796
- return residentBlobSentinel("text", stores.textStore.putSync(Buffer.from(obj, "utf8")).ref);
3057
+ return residentBlobSentinel("text", stores.textStore.putOwnedSync(Buffer.from(obj, "utf8")).ref);
2797
3058
  return obj;
2798
3059
  }
2799
3060
  if (Array.isArray(obj)) {
@@ -2852,12 +3113,16 @@ function materializeResidentValueSync(
2852
3113
  if (cached !== undefined) return cached;
2853
3114
  let resolved: string;
2854
3115
  try {
2855
- resolved =
2856
- obj.kind === "imageUrl"
2857
- ? resolveResidentImageDataUrlSync(stores.imageStore, obj.ref, stores)
2858
- : obj.kind === "imageData"
2859
- ? resolveResidentImageDataSync(stores.imageStore, obj.ref, stores)
2860
- : 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
+ }
2861
3126
  } catch (err) {
2862
3127
  if (missingPolicy === "placeholder" && err instanceof ResidentBlobMissingError) {
2863
3128
  resolved = residentBlobMissingPlaceholder(err);
@@ -2865,6 +3130,7 @@ function materializeResidentValueSync(
2865
3130
  throw err;
2866
3131
  }
2867
3132
  }
3133
+
2868
3134
  cache.set(cacheKey, resolved);
2869
3135
  stores.onResidentBlobRead?.(obj.kind);
2870
3136
  return resolved;
@@ -2908,6 +3174,35 @@ function materializeResidentEntriesSync<T extends FileEntry | SessionEntry>(
2908
3174
  return entries.map(entry => materializeResidentEntrySync(entry, stores, cache, missingPolicy));
2909
3175
  }
2910
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
+
2911
3206
  function materializeResidentEntryForReadSync<T extends FileEntry | SessionEntry>(
2912
3207
  entry: T,
2913
3208
  stores: ResidentBlobStores,
@@ -2916,6 +3211,63 @@ function materializeResidentEntryForReadSync<T extends FileEntry | SessionEntry>
2916
3211
  return materializeResidentEntrySync(entry, stores, cache, "placeholder");
2917
3212
  }
2918
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
+
2919
3271
  function materializeResidentEntriesForReadSync<T extends FileEntry | SessionEntry>(
2920
3272
  entries: T[],
2921
3273
  stores: ResidentBlobStores,
@@ -3962,7 +4314,6 @@ const SESSION_NAME_MAX_CHARS = 1_000;
3962
4314
  const SESSION_LIST_PARALLEL_THRESHOLD = 64;
3963
4315
  const SESSION_LIST_MAX_WORKERS = 16;
3964
4316
  const sessionListPrefixDecoder = new TextDecoder("utf-8", { fatal: false });
3965
- let residentCacheInstanceCounter = 0;
3966
4317
 
3967
4318
  async function readSessionListPrefix(file: string, storage: SessionStorage, buffer: Buffer): Promise<string> {
3968
4319
  if (!(storage instanceof FileSessionStorage)) {
@@ -4342,6 +4693,34 @@ interface SessionManagerStateSnapshot {
4342
4693
  materializedFileEntries: FileEntry[];
4343
4694
  }
4344
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
+
4345
4724
  export class SessionManager {
4346
4725
  #sessionId: string = "";
4347
4726
  /** True once a lifecycle pre-allocated id has been adopted (consume-once). */
@@ -4380,6 +4759,7 @@ export class SessionManager {
4380
4759
  #artifactManager: ArtifactManager | null = null;
4381
4760
  #artifactManagerSessionFile: string | null = null;
4382
4761
  #managedTranscriptStoreCache: { directory: string; store: ManagedSessionDescendantStore } | null = null;
4762
+ #ownedManagedAuthority: ManagedSessionDescendantStore | undefined;
4383
4763
  // When set, take precedence over the lazily-derived per-session manager.
4384
4764
  // Subagents adopt the parent's manager so artifact IDs are unique across the
4385
4765
  // whole agent tree and all files land in the parent's artifacts dir.
@@ -4400,8 +4780,9 @@ export class SessionManager {
4400
4780
  #labelRevision = 0;
4401
4781
  #replayMetadataRevision = 0;
4402
4782
  #materializedEntriesRevision = -1;
4403
- #materializedEntriesCache: SessionEntry[] | undefined;
4404
- #sessionContextCache: SessionContext | undefined;
4783
+ #materializedEntriesCache: MaterializedCacheReference<SessionEntry[]> | undefined;
4784
+ #sessionContextCache: MaterializedCacheReference<SessionContext> | undefined;
4785
+ #materializedCachesWeaklyHeld = false;
4405
4786
  #sessionContextEntryRevision = -1;
4406
4787
  #sessionContextLeafRevision = -1;
4407
4788
  #sessionContextReplayMetadataRevision = -1;
@@ -4409,11 +4790,15 @@ export class SessionManager {
4409
4790
  #coldSpillReadCount = 0;
4410
4791
  #residentTextReadCount = 0;
4411
4792
  #residentImageReadCount = 0;
4793
+ #residentCacheAdoptFallbackCount = 0;
4794
+ #residentCacheTrustRejectCount = 0;
4795
+ #residentCacheWin32FallbackCount = 0;
4412
4796
  #publicMaterializerCallCount = 0;
4413
4797
  #getEntryMaterializerCallCount = 0;
4414
4798
  #getBranchMaterializerCallCount = 0;
4415
4799
  #getEntriesMaterializerCallCount = 0;
4416
4800
  #materializedEntriesCachePopulateCount = 0;
4801
+ #materializedCacheDemotedCount = 0;
4417
4802
  #pathOnlyContextBuildCount = 0;
4418
4803
  #internalReadAccess: SessionManagerReadAccess = {
4419
4804
  getEntries: () => freezeInternalReadSnapshot(this.#getMaterializedEntriesInternal()),
@@ -4452,6 +4837,41 @@ export class SessionManager {
4452
4837
  };
4453
4838
  }
4454
4839
 
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
+
4455
4875
  #residentBlobStoresForColdRehydrate(): ResidentBlobStores {
4456
4876
  return {
4457
4877
  ...this.#residentBlobStores(),
@@ -4468,24 +4888,151 @@ export class SessionManager {
4468
4888
  return this.#memoryGuardCheckpointBlobs ? this.#residentImageBlobStore : this.#blobStore;
4469
4889
  }
4470
4890
 
4471
- #residentCacheDir(sessionFile: string): string {
4472
- const instance = ++residentCacheInstanceCounter;
4473
- return path.join(
4474
- sessionFile.slice(0, -6),
4475
- "resident-cache",
4476
- `${this.#sessionId || "pending"}-${process.pid}-${instance}`,
4477
- );
4891
+ #residentCacheProfileAgentDir(): string {
4892
+ return this.destination.kind === "managed"
4893
+ ? this.destination.securityContext.profileAgentDir
4894
+ : (explicitProfileAgentDirs.get(this.destination) ?? getAgentDir());
4478
4895
  }
4479
4896
 
4480
- #reexternalizeFileEntriesForResidentStore(): void {
4481
- this.#fileEntries = this.#fileEntries.map(entry =>
4482
- 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,
4483
4945
  );
4484
- this.#buildIndex();
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,
4960
+ );
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
+ }
4485
5032
  }
4486
5033
 
4487
5034
  #preparePreparedNewSessionForCommit(stage: PreparedNewSessionState): void {
4488
- const residentTextBlobStore = new MemoryBlobStore();
5035
+ const residentTextBlobStore = this.#newCanonicalResidentTextStore();
4489
5036
  const residentFileEntries = stage.fileEntries.map(entry =>
4490
5037
  prepareEntryForResidentSync(entry, {
4491
5038
  textStore: residentTextBlobStore,
@@ -4502,6 +5049,21 @@ export class SessionManager {
4502
5049
  #resetMaterializedCaches(): void {
4503
5050
  this.#materializedEntriesRevision = -1;
4504
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);
4505
5067
  }
4506
5068
 
4507
5069
  #bumpEntryRevision(): void {
@@ -4533,26 +5095,170 @@ export class SessionManager {
4533
5095
  };
4534
5096
  }
4535
5097
 
4536
- #disposeResidentTextBlobStore(): void {
4537
- if (this.#residentTextBlobStore instanceof EphemeralBlobStore) {
4538
- 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");
4539
5164
  }
4540
- this.#residentTextBlobStore = new MemoryBlobStore();
4541
- this.#resetMaterializedCaches();
4542
5165
  }
4543
5166
 
4544
- #resetResidentTextBlobStore(): void {
4545
- 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);
4546
5173
  if (
4547
- this.destination.kind === "explicit" &&
4548
- this.persist &&
4549
- this.#sessionFile &&
4550
- 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
+ )
4551
5182
  ) {
4552
- 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();
4553
5249
  }
4554
5250
  }
4555
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
+
4556
5262
  /** Puts a binary blob into the blob store and returns the blob reference */
4557
5263
  async putBlob(data: Buffer): Promise<BlobPutResult> {
4558
5264
  return this.#blobStore.put(data);
@@ -4582,6 +5288,17 @@ export class SessionManager {
4582
5288
 
4583
5289
  restoreState(snapshot: SessionManagerStateSnapshot): void {
4584
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
+ );
4585
5302
  const retainsPersistWriter =
4586
5303
  this.#persistWriter?.isOpen() === true &&
4587
5304
  this.#sessionId === snapshot.sessionId &&
@@ -4594,7 +5311,6 @@ export class SessionManager {
4594
5311
  this.#flushed = snapshot.flushed;
4595
5312
  this.#ensuredOnDisk = snapshot.ensuredOnDisk;
4596
5313
  this.#needsFullRewriteOnNextPersist = snapshot.needsFullRewriteOnNextPersist;
4597
- this.#fileEntries = restoredFileEntries;
4598
5314
  if (!retainsPersistWriter) {
4599
5315
  this.#persistWriter = undefined;
4600
5316
  this.#persistWriterPath = undefined;
@@ -4605,97 +5321,306 @@ export class SessionManager {
4605
5321
  this.#artifactManager = null;
4606
5322
  this.#artifactManagerSessionFile = null;
4607
5323
  this.#adoptedArtifactManager = null;
4608
- this.#resetResidentTextBlobStore();
4609
- this.#reexternalizeFileEntriesForResidentStore();
4610
- this.#bumpAllRevisions();
4611
- if (this.#sessionFile) {
4612
- writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
4613
- }
5324
+ this.#commitResidentTextStoreTransition(prepared);
5325
+ if (this.#sessionFile) writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
4614
5326
  }
4615
5327
 
4616
- /** Initialize with a specific session file (used by factory methods) */
4617
- async #initSessionFile(sessionFile: string): Promise<void> {
4618
- await this.setSessionFile(sessionFile);
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
+ };
4619
5350
  }
4620
5351
 
4621
- async #hydrateExistingSession(sessionFile: string, entries: FileEntry[], migrationApplied: boolean): Promise<void> {
4622
- // Strict inspection creates these entries solely for this hydration path. Adopt that
4623
- // final, validated representation instead of cloning the complete transcript again.
4624
- const header = entries[0] as SessionHeader;
4625
- this.#sessionFile = this.storage instanceof FileSessionStorage ? path.resolve(sessionFile) : sessionFile;
4626
- this.#sessionId = header.id;
4627
- this.#sessionName = header.title;
4628
- this.#titleSource = header.titleSource;
4629
- this.#needsFullRewriteOnNextPersist = migrationApplied;
4630
- await resolveBlobRefsInEntries(entries, this.#blobStore);
4631
- this.#fileEntries = entries;
4632
- this.#resetResidentTextBlobStore();
4633
- this.#fileEntries = this.#fileEntries.map(entry =>
4634
- prepareEntryForResidentSync(entry, this.#residentBlobStores()),
4635
- );
4636
- this.#buildIndex();
4637
- this.#bumpAllRevisions();
4638
- this.#flushed = true;
4639
- this.#ensuredOnDisk = true;
4640
- }
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). */
5389
+ async #initSessionFile(sessionFile: string): Promise<void> {
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();
5429
+ }
4641
5430
 
4642
- /** Initialize with a new session (used by factory methods) */
5431
+ async #hydrateExistingSession(
5432
+ sessionFile: string,
5433
+ entries: FileEntry[],
5434
+ migrationApplied: boolean,
5435
+ policy: Exclude<ResidentTransitionFailurePolicy, "install-staged"> = "memory-fallback",
5436
+ ): Promise<void> {
5437
+ // Strict inspection creates these entries solely for this hydration path. Adopt that
5438
+ // final, validated representation instead of cloning the complete transcript again.
5439
+ const header = entries[0] as SessionHeader;
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;
5455
+ this.#sessionId = header.id;
5456
+ this.#sessionName = header.title;
5457
+ this.#titleSource = header.titleSource;
5458
+ this.#needsFullRewriteOnNextPersist = migrationApplied;
5459
+ this.#commitResidentTextStoreTransition(prepared);
5460
+ this.#flushed = true;
5461
+ this.#ensuredOnDisk = true;
5462
+ }
5463
+
5464
+ /** Initialize with a new session (used by factory methods). */
4643
5465
  #initNewSession(): void {
4644
5466
  this.#newSessionSync();
4645
- this.#bumpAllRevisions();
4646
5467
  }
4647
5468
 
4648
- /** Switch to a different session file (used for resume and branching) */
5469
+ /** Switch to a different session file (used for resume and branching). */
4649
5470
  async setSessionFile(sessionFile: string): Promise<void> {
4650
5471
  this.#assertRecoveryHydrationWritable();
4651
5472
  const resolvedSessionFile = this.storage instanceof FileSessionStorage ? path.resolve(sessionFile) : sessionFile;
4652
5473
  const strictAdoption = this.#pendingStrictAdoption;
4653
- this.#pendingStrictAdoption = undefined;
4654
- if (strictAdoption && resolvedSessionFile === strictAdoption.canonicalPath) {
4655
- const inspected = inspectResumeSessionFile(resolvedSessionFile, this.storage);
4656
- if ("kind" in inspected || !sameResumeIdentity(strictAdoption.identity, inspected.identity))
4657
- throw new Error("Prepared managed session changed before strict adoption.");
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
+ }
4658
5484
  }
4659
- await this.#closePersistWriter();
4660
- this.#persistError = undefined;
4661
- this.#persistErrorReported = false;
4662
- this.#sessionFile = resolvedSessionFile;
4663
- writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
4664
- this.#fileEntries = await loadEntriesFromFile(this.#sessionFile, this.storage);
4665
- if (strictAdoption) {
4666
- const inspected = inspectResumeSessionFile(this.#sessionFile, this.storage);
4667
- if ("kind" in inspected || !sameResumeIdentity(strictAdoption.identity, inspected.identity))
4668
- throw new Error("Prepared managed session changed during strict adoption.");
4669
- }
4670
- if (this.#fileEntries.length > 0) {
4671
- const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
4672
- this.#sessionId = header?.id ?? createSessionId();
4673
- this.#sessionName = header?.title;
4674
- this.#titleSource = header?.titleSource;
4675
-
4676
- this.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(this.#fileEntries);
4677
- await resolveBlobRefsInEntries(this.#fileEntries, this.#blobStore);
4678
- this.#resetResidentTextBlobStore();
4679
-
4680
- this.#fileEntries = this.#fileEntries.map(entry =>
4681
- prepareEntryForResidentSync(entry, this.#residentBlobStores()),
4682
- );
4683
-
4684
- this.#buildIndex();
4685
- this.#bumpAllRevisions();
4686
- this.#flushed = true;
4687
- this.#ensuredOnDisk = true;
4688
- await this.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
4689
- } else {
4690
- const explicitPath = this.#sessionFile;
4691
- this.#newSessionSync();
4692
- this.#sessionFile = explicitPath; // preserve explicit path from --session flag
4693
- 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;
4694
5618
  await this.#rewriteFile();
4695
5619
  this.#flushed = true;
4696
5620
  this.#ensuredOnDisk = true;
4697
- this.#bumpAllRevisions();
4698
- return;
5621
+ } catch (error) {
5622
+ managedTransition?.dispose();
5623
+ throw error;
4699
5624
  }
4700
5625
  }
4701
5626
 
@@ -5092,10 +6017,29 @@ export class SessionManager {
5092
6017
  const index = stage.index;
5093
6018
  if (!residentFileEntries || !residentTextBlobStore || !index)
5094
6019
  throw new Error("Prepared session adoption is incomplete.");
5095
- if (stage.sessionFile) writeTerminalBreadcrumb(this.cwd, stage.sessionFile);
5096
-
5097
- stage.committed = true;
5098
- this.#preparedNewSessions.delete(stage);
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
+ }
5099
6043
  if (!this.#lifecycleIdAdopted && stage.sessionId === lifecyclePreallocatedSessionId())
5100
6044
  this.#lifecycleIdAdopted = true;
5101
6045
  this.#persistChain = Promise.resolve();
@@ -5105,12 +6049,6 @@ export class SessionManager {
5105
6049
  this.#sessionName = stage.sessionName;
5106
6050
  this.#titleSource = stage.titleSource;
5107
6051
  this.#sessionFile = stage.sessionFile;
5108
- this.#fileEntries = residentFileEntries;
5109
- this.#residentTextBlobStore = residentTextBlobStore;
5110
- this.#byId = index.byId;
5111
- this.#labelsById = index.labelsById;
5112
- this.#leafId = index.leafId;
5113
- this.#usageStatistics = index.usageStatistics;
5114
6052
  this.#flushed = stage.flushed;
5115
6053
  this.#needsFullRewriteOnNextPersist = false;
5116
6054
  this.#ensuredOnDisk = stage.flushed;
@@ -5119,7 +6057,9 @@ export class SessionManager {
5119
6057
  this.#artifactManager = null;
5120
6058
  this.#artifactManagerSessionFile = null;
5121
6059
  this.#adoptedArtifactManager = null;
5122
- this.#bumpAllRevisions();
6060
+ this.#commitResidentTextStoreTransition(transition);
6061
+ stage.committed = true;
6062
+ this.#preparedNewSessions.delete(stage);
5123
6063
  }
5124
6064
 
5125
6065
  /** Exact-discard only an uncommitted successor prepared by this manager. @internal */
@@ -5234,9 +6174,9 @@ export class SessionManager {
5234
6174
  * managed logical authorization. This is only for discarding an UNCOMMITTED
5235
6175
  * successor that a transaction (e.g. handoff) created via `newSession()` but
5236
6176
  * never durably authorized — such a session has no managed candidate listing
5237
- * yet, so `dropSession` would refuse it and leak its resident-cache/artifact
5238
- * root. Bounded to this manager's configured session root and refuses to touch
5239
- * 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.
5240
6180
  */
5241
6181
  async discardUncommittedSession(sessionPath: string): Promise<void> {
5242
6182
  // Close the writer FIRST so the canonical checks and the delete run with no
@@ -5275,110 +6215,154 @@ export class SessionManager {
5275
6215
  * @returns { oldSessionFile, newSessionFile } or undefined if not persisting
5276
6216
  */
5277
6217
  async fork(): Promise<{ oldSessionFile: string; newSessionFile: string } | undefined> {
5278
- if (!this.persist || !this.#sessionFile) {
5279
- return undefined;
5280
- }
6218
+ if (!this.persist || !this.#sessionFile) return undefined;
5281
6219
 
5282
6220
  const oldSessionFile = this.#sessionFile;
5283
6221
  const oldSessionId = this.#sessionId;
5284
6222
  const materializedEntries = materializeResidentEntriesForReadSync(this.#fileEntries, this.#residentBlobStores());
5285
- const oldFileEntries = this.#fileEntries;
5286
- const oldSessionName = this.#sessionName;
5287
- const oldTitleSource = this.#titleSource;
5288
-
5289
- // Close the current writer
5290
- await this.#closePersistWriter();
5291
- this.#persistChain = Promise.resolve();
5292
- this.#persistError = undefined;
5293
- this.#persistErrorReported = false;
5294
-
5295
- // Create new session ID and header
5296
- this.#sessionId = createSessionId();
5297
6223
  const timestamp = new Date().toISOString();
5298
- const fileTimestamp = timestamp.replace(/[:.]/g, "-");
5299
- this.#sessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${this.#sessionId}.jsonl`);
5300
-
5301
- // Update the header with new ID but keep all entries
5302
- 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;
5303
6230
  const newHeader: SessionHeader = {
5304
6231
  type: "session",
5305
6232
  version: CURRENT_SESSION_VERSION,
5306
- id: this.#sessionId,
6233
+ id: newSessionId,
5307
6234
  title: oldHeader?.title ?? this.#sessionName,
5308
6235
  titleSource: oldHeader?.titleSource ?? this.#titleSource,
5309
6236
  timestamp,
5310
6237
  cwd: this.cwd,
5311
6238
  parentSession: oldSessionId,
5312
6239
  };
5313
- this.#sessionName = newHeader.title;
5314
- this.#titleSource = newHeader.titleSource;
5315
-
5316
- // Replace the header in fileEntries
5317
- const entries = materializedEntries.filter((e): e is SessionEntry => e.type !== "session");
5318
- this.#fileEntries = [newHeader, ...entries];
5319
- this.#resetResidentTextBlobStore();
5320
- this.#reexternalizeFileEntriesForResidentStore();
5321
- this.#bumpAllRevisions();
6240
+ const entries: FileEntry[] = [
6241
+ newHeader,
6242
+ ...materializedEntries.filter((entry): entry is SessionEntry => entry.type !== "session"),
6243
+ ];
5322
6244
 
5323
- // Write the new session file, import artifacts, and adopt only after the
5324
- // retained destination manifest is complete.
5325
- this.#flushed = false;
6245
+ await this.#closePersistWriter();
6246
+ this.#persistChain = Promise.resolve();
6247
+ this.#persistError = undefined;
6248
+ this.#persistErrorReported = false;
5326
6249
  let forkArtifactPublication: ForkArtifactPublication | undefined;
5327
- let forkTranscriptPublication: ManagedFileSnapshot | null = null;
5328
- let forkTranscriptStore: ManagedSessionDescendantStore | undefined;
6250
+ let forkTranscriptPublication: ForkTranscriptPublication | undefined;
6251
+ let transition: PreparedResidentStoreTransition | undefined;
5329
6252
  try {
5330
- forkArtifactPublication = await this.copyArtifactsForFork(oldSessionFile, this.#sessionFile);
5331
- 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");
5332
6256
  if (this.destination.kind === "managed") {
5333
- forkTranscriptStore = this.#managedTranscriptStore();
5334
- forkTranscriptPublication = forkTranscriptStore.readExpected(path.basename(this.#sessionFile));
5335
- 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 };
5336
6286
  }
5337
- forkArtifactPublication?.store.verifyRootSecurity();
5338
- forkArtifactPublication?.store.assertBound();
5339
- if (forkArtifactPublication) {
6287
+ if (forkArtifactPublication?.kind === "managed") {
6288
+ forkArtifactPublication.store.verifyRootSecurity();
6289
+ forkArtifactPublication.store.assertBound();
5340
6290
  const adoptedSnapshot = forkArtifactPublication.store.fsyncTree();
5341
- 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
+ ) {
5342
6300
  throw new Error("artifact_destination_changed_during_transcript_publication");
5343
6301
  }
5344
6302
  }
5345
- if (forkTranscriptStore && forkTranscriptPublication) {
5346
- const terminalTranscript = forkTranscriptStore.readExpected(path.basename(this.#sessionFile));
5347
- 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
+ ) {
5348
6313
  throw new Error("managed_fork_transcript_changed");
6314
+ }
5349
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
+ );
5350
6327
  } catch (error) {
5351
- 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
+ }
5352
6338
  if (forkArtifactPublication) {
5353
6339
  try {
5354
- forkArtifactPublication.cleanupStore.removeTreeExpected(
5355
- forkArtifactPublication.cleanupRelativePath,
5356
- forkArtifactPublication.snapshot,
5357
- );
6340
+ this.#cleanupForkArtifactPublication(forkArtifactPublication);
5358
6341
  } catch (cleanupError) {
5359
- const cleanupMessage = toError(cleanupError).message;
5360
- if (cleanupMessage !== "cleanup_pending") {
5361
- throw new Error(`Failed to clean up fork artifacts: ${cleanupMessage}`, {
5362
- cause: toError(failure),
5363
- });
5364
- }
6342
+ cleanupErrors.push(toError(cleanupError));
5365
6343
  }
5366
6344
  }
5367
- this.#sessionId = oldSessionId;
5368
- this.#sessionFile = oldSessionFile;
5369
- this.#fileEntries = oldFileEntries;
5370
- this.#sessionName = oldSessionName;
5371
- this.#titleSource = oldTitleSource;
5372
- this.#persistChain = Promise.resolve();
5373
- this.#persistError = undefined;
5374
- this.#persistErrorReported = false;
5375
- this.#resetResidentTextBlobStore();
5376
- this.#reexternalizeFileEntriesForResidentStore();
5377
- 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
+ }
5378
6355
  throw failure;
5379
6356
  }
5380
-
5381
- 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 };
5382
6366
  }
5383
6367
 
5384
6368
  async copyArtifactsForFork(
@@ -5388,15 +6372,98 @@ export class SessionManager {
5388
6372
  const sourceDir = oldSessionFile.slice(0, -6);
5389
6373
  const finalDestinationDir = newSessionFile.slice(0, -6);
5390
6374
  if (this.destination.kind !== "managed") {
6375
+ let sourceSnapshot: native.NativeDirectoryTreeSnapshot;
5391
6376
  try {
5392
6377
  const sourceStat = fs.lstatSync(sourceDir);
5393
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;
5394
6382
  } catch (error) {
5395
6383
  if (isEnoent(error)) return;
5396
6384
  throw error;
5397
6385
  }
5398
- await fs.promises.cp(sourceDir, finalDestinationDir, { recursive: true });
5399
- 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
+ }
5400
6467
  }
5401
6468
 
5402
6469
  const sourceName = path.basename(sourceDir);
@@ -5410,12 +6477,13 @@ export class SessionManager {
5410
6477
  if (error instanceof Error && error.message === "not_found") return;
5411
6478
  throw error;
5412
6479
  }
6480
+ const expectedManifest = pruneResidentCacheEntries(sourceSnapshot);
5413
6481
  let stagingSnapshot: native.NativeDirectoryTreeSnapshot | undefined;
5414
6482
  let publishedSnapshot: native.NativeDirectoryTreeSnapshot | undefined;
5415
6483
  try {
5416
6484
  parentStore.ensureDirectory(stagingName);
5417
6485
  stagingSnapshot = parentStore.captureTree(stagingName);
5418
- for (const entry of sourceSnapshot.entries) {
6486
+ for (const entry of expectedManifest.entries) {
5419
6487
  if (entry.relativePath === "") continue;
5420
6488
  const destinationRelativePath = path.posix.join(stagingName, entry.relativePath);
5421
6489
  if (entry.kind === "directory") {
@@ -5448,7 +6516,7 @@ export class SessionManager {
5448
6516
  size: entry.kind === "file" ? entry.size : undefined,
5449
6517
  sha256: entry.sha256,
5450
6518
  }));
5451
- if (JSON.stringify(comparable(publishedSnapshot)) !== JSON.stringify(comparable(sourceSnapshot)))
6519
+ if (JSON.stringify(comparable(publishedSnapshot)) !== JSON.stringify(comparable(expectedManifest)))
5452
6520
  throw new Error("artifact_destination_terminal_mismatch");
5453
6521
  const publishedStore = parentStore.deriveSubtree(destinationName);
5454
6522
  publishedStore.verifyRootSecurity();
@@ -5457,6 +6525,7 @@ export class SessionManager {
5457
6525
  throw new Error("artifact_destination_retained_identity_changed");
5458
6526
  publishedSnapshot = retainedPublishedSnapshot;
5459
6527
  return {
6528
+ kind: "managed",
5460
6529
  snapshot: publishedSnapshot,
5461
6530
  store: publishedStore,
5462
6531
  cleanupStore: parentStore,
@@ -5496,7 +6565,7 @@ export class SessionManager {
5496
6565
  ? managedDestination(
5497
6566
  resolvedCwd,
5498
6567
  this.storage,
5499
- this.destination.kind === "managed" ? this.destination.securityContext.agentDir : undefined,
6568
+ this.destination.kind === "managed" ? this.destination.securityContext.profileAgentDir : undefined,
5500
6569
  )
5501
6570
  : this.destination;
5502
6571
  const newSessionDir = nextDestination.directory;
@@ -5505,6 +6574,7 @@ export class SessionManager {
5505
6574
  let managedSourceStore: ManagedSessionDescendantStore | undefined;
5506
6575
  let managedDestinationStore: ManagedSessionDescendantStore | undefined;
5507
6576
  let rollbackManagedMove: (() => Promise<void>) | undefined;
6577
+ let residentTransition: PreparedResidentStoreTransition | undefined;
5508
6578
 
5509
6579
  if (this.persist && this.#sessionFile) {
5510
6580
  // Close the persist writer before moving files
@@ -5528,20 +6598,22 @@ export class SessionManager {
5528
6598
  this.#fileEntries,
5529
6599
  this.#residentBlobStores(),
5530
6600
  );
5531
- const restoreResidentStateAfterFailure = (): void => {
5532
- this.#fileEntries = materializedEntries;
5533
- this.#resetResidentTextBlobStore();
5534
- this.#reexternalizeFileEntriesForResidentStore();
5535
- this.#bumpAllRevisions();
5536
- };
5537
- const restoreResidentStateAndThrow = (error: unknown): never => {
5538
- try {
5539
- restoreResidentStateAfterFailure();
5540
- } catch (restoreErr) {
5541
- throw new Error(
5542
- `Failed to restore live session resident state after move failure: ${toError(restoreErr).message}; original error: ${toError(error).message}`,
5543
- );
5544
- }
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();
5545
6617
  throw error;
5546
6618
  };
5547
6619
  const copyManagedTree = async (
@@ -5582,7 +6654,6 @@ export class SessionManager {
5582
6654
  throw new Error("artifact_destination_mismatch");
5583
6655
  destination.fsyncTree();
5584
6656
  };
5585
- this.#disposeResidentTextBlobStore();
5586
6657
  try {
5587
6658
  if (managedMove) {
5588
6659
  if (previousDestination.kind !== "managed" || nextDestination.kind !== "managed")
@@ -5709,7 +6780,7 @@ export class SessionManager {
5709
6780
  )
5710
6781
  await managedSourceStore.publishNoReplace(path.basename(oldSessionFile), managedTranscript.bytes);
5711
6782
  } catch (rollbackErr) {
5712
- restoreResidentStateAndThrow(
6783
+ discardResidentTransitionAndThrow(
5713
6784
  new Error(`Failed to rollback managed move: ${toError(rollbackErr).message}`, {
5714
6785
  cause: toError(err),
5715
6786
  }),
@@ -5719,13 +6790,9 @@ export class SessionManager {
5719
6790
  if (movedArtifactDir) await movePathAcrossDevicesSafe(newArtifactDir, oldArtifactDir);
5720
6791
  if (movedSessionFile) await movePathAcrossDevicesSafe(newSessionFile, oldSessionFile);
5721
6792
  }
5722
- restoreResidentStateAndThrow(err);
6793
+ discardResidentTransitionAndThrow(err);
5723
6794
  }
5724
6795
  this.#sessionFile = newSessionFile;
5725
- this.#fileEntries = materializedEntries;
5726
- this.#resetResidentTextBlobStore();
5727
- this.#reexternalizeFileEntriesForResidentStore();
5728
- this.#bumpAllRevisions();
5729
6796
  }
5730
6797
 
5731
6798
  // Update cwd and sessionDir after physical publication succeeds. Metadata failures restore the source
@@ -5750,6 +6817,7 @@ export class SessionManager {
5750
6817
  await this.#appendHeaderPatch({ cwd: resolvedCwd });
5751
6818
  }
5752
6819
  } catch (error) {
6820
+ residentTransition?.dispose();
5753
6821
  await this.#closePersistWriter().catch(() => {});
5754
6822
  this.#persistChain = Promise.resolve();
5755
6823
  this.#persistError = undefined;
@@ -5783,6 +6851,7 @@ export class SessionManager {
5783
6851
  this.#headerExportRevision++;
5784
6852
  throw error;
5785
6853
  }
6854
+ if (residentTransition) this.#commitResidentTextStoreTransition(residentTransition);
5786
6855
 
5787
6856
  // Update terminal breadcrumb only after the durable cwd transition succeeds.
5788
6857
  if (this.#sessionFile) {
@@ -5790,57 +6859,14 @@ export class SessionManager {
5790
6859
  }
5791
6860
  }
5792
6861
 
5793
- /** Sync version for initial creation (no existing writer to close) */
6862
+ /** Sync version for initial creation (no existing writer to close). */
5794
6863
  #newSessionSync(options?: NewSessionOptions, writeBreadcrumb = true): string | undefined {
5795
- this.#persistChain = Promise.resolve();
5796
- this.#persistError = undefined;
5797
- this.#persistErrorReported = false;
5798
- // Adopt a lifecycle pre-allocated id exactly once (the initial session of a
5799
- // /session_create child); later new-session paths (/new, fork, branch) get
5800
- // fresh ids so they cannot reuse the original GJC_SESSION_ID.
5801
- const preallocated = this.#lifecycleIdAdopted ? undefined : lifecyclePreallocatedSessionId();
5802
- if (preallocated) this.#lifecycleIdAdopted = true;
5803
- this.#sessionId = preallocated ?? createSessionId();
5804
- this.#sessionName = undefined;
5805
- this.#titleSource = undefined;
5806
- const timestamp = new Date().toISOString();
5807
- const header: SessionHeader = {
5808
- type: "session",
5809
- version: CURRENT_SESSION_VERSION,
5810
- id: this.#sessionId,
5811
- timestamp,
5812
- cwd: this.cwd,
5813
- parentSession: options?.parentSession,
5814
- };
5815
- this.#fileEntries = [header];
5816
- this.#byId.clear();
5817
- this.#labelsById.clear();
5818
- this.#leafId = null;
5819
- this.#flushed = false;
5820
- this.#needsFullRewriteOnNextPersist = false;
5821
- this.#ensuredOnDisk = false;
5822
- this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
5823
- this.#inMemoryArtifacts = null;
5824
- this.#inMemoryArtifactCounter = 0;
5825
- this.#artifactManager = null;
5826
- this.#artifactManagerSessionFile = null;
5827
- this.#adoptedArtifactManager = null;
5828
-
5829
- if (this.persist) {
5830
- const fileTimestamp = timestamp.replace(/[:.]/g, "-");
5831
- this.#sessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${this.#sessionId}.jsonl`);
5832
- if (writeBreadcrumb) writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
5833
- }
5834
- this.#resetResidentTextBlobStore();
5835
- return this.#sessionFile;
5836
- }
5837
-
5838
- #buildIndex(): void {
5839
- const index = this.#buildIndexForEntries(this.#fileEntries, this.#sessionFile);
5840
- this.#byId = index.byId;
5841
- this.#labelsById = index.labelsById;
5842
- this.#leafId = index.leafId;
5843
- this.#usageStatistics = index.usageStatistics;
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;
5844
6870
  }
5845
6871
 
5846
6872
  #buildIndexForEntries(fileEntries: FileEntry[], sessionFile: string | undefined): PreparedSessionIndex {
@@ -6156,6 +7182,82 @@ export class SessionManager {
6156
7182
  }
6157
7183
  }
6158
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
+
6159
7261
  #managedTranscriptStore(sessionFile = this.#sessionFile): ManagedSessionDescendantStore {
6160
7262
  if (this.destination.kind !== "managed" || !sessionFile) {
6161
7263
  throw new Error("Managed transcript authority is unavailable");
@@ -6377,8 +7479,27 @@ export class SessionManager {
6377
7479
  ) {
6378
7480
  return { kind: "not_promoted" };
6379
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
+ }
6380
7498
  if (stage.persistsToExistingFile) {
6381
- 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
+ }
6382
7503
  if (this.destination.kind === "managed") {
6383
7504
  try {
6384
7505
  const persistedEntries = materializeResidentEntriesForPersistenceSync(
@@ -6387,17 +7508,23 @@ export class SessionManager {
6387
7508
  ).map(entry => prepareEntryForPersistenceSync(entry, this.#blobStore));
6388
7509
  this.#writeEntriesAtomicallySync(persistedEntries);
6389
7510
  } catch (error) {
7511
+ transition.dispose();
6390
7512
  return { kind: "unknown", error: toError(error) };
6391
7513
  }
6392
7514
  } else {
6393
- 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
+ }
6394
7519
  try {
6395
7520
  this.#closePersistWriterInternalSync();
6396
7521
  const replacement = this.#replaceSessionFileSync(stage.tempPath, this.#sessionFile);
6397
7522
  if (replacement.kind === "restored_previous") {
7523
+ transition.dispose();
6398
7524
  return { kind: "not_promoted", error: replacement.error };
6399
7525
  }
6400
7526
  } catch (error) {
7527
+ transition.dispose();
6401
7528
  if (this.#persistWriter?.getCloseState() === "close_failed_retryable") {
6402
7529
  return { kind: "not_promoted", error: new Error("Session replacement could not be completed.") };
6403
7530
  }
@@ -6405,13 +7532,10 @@ export class SessionManager {
6405
7532
  }
6406
7533
  }
6407
7534
  }
6408
- this.#fileEntries = [...stage.entries];
6409
- this.#resetResidentTextBlobStore();
6410
- this.#reexternalizeFileEntriesForResidentStore();
6411
7535
  this.#needsFullRewriteOnNextPersist = false;
6412
7536
  this.#flushed = stage.persistsToExistingFile;
6413
7537
  this.#ensuredOnDisk = stage.persistsToExistingFile;
6414
- this.#bumpAllRevisions();
7538
+ this.#commitResidentTextStoreTransition(transition);
6415
7539
  return { kind: "promoted" };
6416
7540
  }
6417
7541
 
@@ -6463,7 +7587,8 @@ export class SessionManager {
6463
7587
  this.#flushed = true;
6464
7588
  }
6465
7589
  });
6466
- this.#disposeResidentTextBlobStore();
7590
+ this.#releaseResidentTextStore();
7591
+ this.#releaseOwnedManagedAuthority();
6467
7592
  if (this.#persistError) throw this.#persistError;
6468
7593
  }
6469
7594
  /** Flush while open, then strictly close; retryable close skips the invalid second flush. */
@@ -6520,7 +7645,7 @@ export class SessionManager {
6520
7645
  // Only tear down the resident blob store on a terminal outcome; a retryable
6521
7646
  // close leaves the session live for a genuine retry.
6522
7647
  if (!this.#persistWriter) {
6523
- this.#disposeResidentTextBlobStore();
7648
+ this.#releaseResidentTextStore();
6524
7649
  }
6525
7650
  return outcome;
6526
7651
  }
@@ -6600,7 +7725,7 @@ export class SessionManager {
6600
7725
  #stageMemoryGuardCheckpointBlobs(blobs: Map<string, Buffer>): void {
6601
7726
  const stagedImageStore = new MemoryBlobStore();
6602
7727
  for (const blob of blobs.values()) {
6603
- this.#residentTextBlobStore.putSync(blob);
7728
+ this.#putResidentTextBlobSync(blob);
6604
7729
  stagedImageStore.putSync(blob);
6605
7730
  }
6606
7731
  this.#residentImageBlobStore = stagedImageStore;
@@ -7121,7 +8246,7 @@ export class SessionManager {
7121
8246
  #appendEntry(entry: SessionEntry): void {
7122
8247
  this.#assertRecoveryHydrationWritable();
7123
8248
  const normalizedEntry = normalizeSessionEntryForStorage(entry);
7124
- const residentEntry = prepareEntryForResidentSync(normalizedEntry, this.#residentBlobStores()) as SessionEntry;
8249
+ const residentEntry = this.#prepareEntryForCurrentResidentStore(normalizedEntry) as SessionEntry;
7125
8250
  this.#fileEntries.push(residentEntry);
7126
8251
  this.#byId.set(residentEntry.id, residentEntry);
7127
8252
  this.#leafId = residentEntry.id;
@@ -7342,10 +8467,10 @@ export class SessionManager {
7342
8467
  for (const updated of entries) {
7343
8468
  const canonical = this.#byId.get(updated.id);
7344
8469
  if (canonical?.type !== "message") continue;
7345
- const residentEntry = prepareEntryForResidentSync(
7346
- { ...canonical, message: updated.message },
7347
- this.#residentBlobStores(),
7348
- ) as SessionMessageEntry;
8470
+ const residentEntry = this.#prepareEntryForCurrentResidentStore({
8471
+ ...canonical,
8472
+ message: updated.message,
8473
+ }) as SessionMessageEntry;
7349
8474
  canonical.message = residentEntry.message;
7350
8475
  }
7351
8476
  this.#needsFullRewriteOnNextPersist = true;
@@ -7663,11 +8788,16 @@ export class SessionManager {
7663
8788
  coldSpillReadCount: this.#coldSpillReadCount,
7664
8789
  residentTextReadCount: this.#residentTextReadCount,
7665
8790
  residentImageReadCount: this.#residentImageReadCount,
8791
+ residentCacheAdoptFallbackCount: this.#residentCacheAdoptFallbackCount,
8792
+ residentCacheTrustRejectCount: this.#residentCacheTrustRejectCount,
8793
+ residentCacheWin32FallbackCount: this.#residentCacheWin32FallbackCount,
8794
+ residentCacheDegradedReason: (this.#residentTextBlobStore as ResidentCacheDegradedStore).degradedReason,
7666
8795
  publicMaterializerCallCount: this.#publicMaterializerCallCount,
7667
8796
  getEntryMaterializerCallCount: this.#getEntryMaterializerCallCount,
7668
8797
  getBranchMaterializerCallCount: this.#getBranchMaterializerCallCount,
7669
8798
  getEntriesMaterializerCallCount: this.#getEntriesMaterializerCallCount,
7670
8799
  materializedEntriesCachePopulateCount: this.#materializedEntriesCachePopulateCount,
8800
+ materializedCacheDemotedCount: this.#materializedCacheDemotedCount,
7671
8801
  pathOnlyContextBuildCount: this.#pathOnlyContextBuildCount,
7672
8802
  };
7673
8803
  }
@@ -7854,10 +8984,11 @@ export class SessionManager {
7854
8984
 
7855
8985
  /**
7856
8986
  * Return the revision-keyed context cache for internal read-only consumers.
7857
- * 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.
7858
8989
  */
7859
8990
  #getSessionContextForRead(): Readonly<SessionContext> {
7860
- const cached = this.#sessionContextCache;
8991
+ const cached = dereferenceMaterializedCache(this.#sessionContextCache);
7861
8992
  if (
7862
8993
  cached &&
7863
8994
  this.#sessionContextEntryRevision === this.#entryRevision &&
@@ -7867,18 +8998,22 @@ export class SessionManager {
7867
8998
  return cached;
7868
8999
  }
7869
9000
  this.#pathOnlyContextBuildCount++;
7870
- const context = buildSessionContext(
9001
+ const builtContext = buildSessionContext(
7871
9002
  this.#getActivePathEntriesForProviderContext(),
7872
9003
  this.#leafId,
7873
9004
  undefined,
7874
9005
  this.#sessionId,
7875
9006
  );
7876
- this.#sessionContextCache = freezeInternalReadSnapshot(context);
7877
- 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);
7878
9013
  this.#sessionContextEntryRevision = this.#entryRevision;
7879
9014
  this.#sessionContextLeafRevision = this.#leafRevision;
7880
9015
  this.#sessionContextReplayMetadataRevision = this.#replayMetadataRevision;
7881
- return this.#sessionContextCache;
9016
+ return context;
7882
9017
  }
7883
9018
 
7884
9019
  #getActivePathEntriesForProviderContext(fromId?: string | null): SessionEntry[] {
@@ -7975,17 +9110,21 @@ export class SessionManager {
7975
9110
  * Internal materialized entry cache. Public getters clone this snapshot before returning it.
7976
9111
  */
7977
9112
  #getMaterializedEntriesInternal(): SessionEntry[] {
7978
- if (this.#materializedEntriesRevision === this.#entryRevision && this.#materializedEntriesCache) {
7979
- return this.#materializedEntriesCache;
7980
- }
9113
+ const cached = dereferenceMaterializedCache(this.#materializedEntriesCache);
9114
+ if (this.#materializedEntriesRevision === this.#entryRevision && cached) return cached;
7981
9115
  this.#materializedEntriesCachePopulateCount++;
7982
9116
  const resolvedTextBlobCache = new Map<string, string>();
7983
9117
  const sourceEntries = this.#fileEntries.filter((e): e is SessionEntry => e.type !== "session");
7984
9118
  const materializedEntries = sourceEntries.map(entry =>
7985
9119
  materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), resolvedTextBlobCache),
7986
9120
  );
9121
+ if (jsonLikeValueExceedsCacheLimit(materializedEntries, materializedCacheMaxBytes())) {
9122
+ this.#holdMaterializedCachesWeakly();
9123
+ }
7987
9124
  if (!sourceEntries.some(entry => containsResidentImageSentinel(entry))) {
7988
- this.#materializedEntriesCache = materializedEntries;
9125
+ this.#materializedEntriesCache = this.#materializedCachesWeaklyHeld
9126
+ ? new WeakRef(materializedEntries)
9127
+ : materializedEntries;
7989
9128
  this.#materializedEntriesRevision = this.#entryRevision;
7990
9129
  }
7991
9130
  return materializedEntries;
@@ -8201,27 +9340,35 @@ export class SessionManager {
8201
9340
  labelEntries.push(labelEntry);
8202
9341
  parentId = labelEntry.id;
8203
9342
  }
8204
- if (this.destination.kind === "managed") {
8205
- this.#managedTranscriptStore(newSessionFile).publishNoReplaceSync(
8206
- path.basename(newSessionFile),
8207
- Buffer.from(`${lines.join("\n")}\n`, "utf8"),
8208
- );
8209
- } else {
8210
- 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;
8211
9367
  }
8212
9368
  this.#sessionId = newSessionId;
8213
9369
  this.#sessionFile = newSessionFile;
8214
- this.#resetResidentTextBlobStore();
8215
- this.#fileEntries = [
8216
- header,
8217
- ...materializedPathWithoutLabels.map(
8218
- entry => prepareEntryForResidentSync(entry, this.#residentBlobStores()) as SessionEntry,
8219
- ),
8220
- ...labelEntries,
8221
- ];
8222
9370
  this.#flushed = true;
8223
- this.#buildIndex();
8224
- this.#bumpAllRevisions();
9371
+ this.#commitResidentTextStoreTransition(transition);
8225
9372
  return newSessionFile;
8226
9373
  }
8227
9374
 
@@ -8240,17 +9387,20 @@ export class SessionManager {
8240
9387
  labelEntries.push(labelEntry);
8241
9388
  parentId = labelEntry.id;
8242
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
+ );
8243
9402
  this.#sessionId = newSessionId;
8244
- this.#resetResidentTextBlobStore();
8245
- this.#fileEntries = [
8246
- header,
8247
- ...materializedPathWithoutLabels.map(
8248
- entry => prepareEntryForResidentSync(entry, this.#residentBlobStores()) as SessionEntry,
8249
- ),
8250
- ...labelEntries,
8251
- ];
8252
- this.#buildIndex();
8253
- this.#bumpAllRevisions();
9403
+ this.#commitResidentTextStoreTransition(transition);
8254
9404
  return undefined;
8255
9405
  }
8256
9406
 
@@ -8298,8 +9448,11 @@ export class SessionManager {
8298
9448
  : process.platform === "win32"
8299
9449
  ? "windows-existing-verify-first"
8300
9450
  : "default";
9451
+ const profileAgentDir =
9452
+ authority instanceof ManagedSessionDescendantStore ? authority.profileAgentDir : getAgentDir();
8301
9453
  const securityContext = createManagedSessionSecurityContext({
8302
9454
  agentDir: rootAuthority.canonicalPath,
9455
+ profileAgentDir,
8303
9456
  sessionsRoot: rootAuthority.canonicalPath,
8304
9457
  sessionDir: directory,
8305
9458
  rootAuthority,
@@ -8478,25 +9631,26 @@ export class SessionManager {
8478
9631
  const forkEntries = structuredClone(await loadEntriesFromFile(managedSourcePath, storage)) as FileEntry[];
8479
9632
  migrateToCurrentVersion(forkEntries);
8480
9633
  await resolveBlobRefsInEntries(forkEntries, manager.#blobStore);
8481
- manager.#fileEntries = forkEntries;
8482
- const sourceHeader = manager.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
8483
- const historyEntries = manager.#fileEntries.filter(entry => entry.type !== "session") as SessionEntry[];
8484
- manager.#newSessionSync({ parentSession: sourceHeader?.id });
8485
- manager.#resetResidentTextBlobStore();
8486
- const newHeader = manager.#fileEntries[0] as SessionHeader;
8487
- newHeader.title = sourceHeader?.title;
8488
- newHeader.titleSource = sourceHeader?.titleSource;
8489
- manager.#fileEntries = [
8490
- newHeader,
8491
- ...historyEntries.map(
8492
- entry => prepareEntryForResidentSync(entry, manager.#residentBlobStores()) as SessionEntry,
8493
- ),
8494
- ];
8495
- manager.#sessionName = newHeader.title;
8496
- 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);
8497
9653
  manager.sanitizeLoadedOpenAIResponsesReplayMetadata();
8498
- manager.#buildIndex();
8499
- manager.#bumpAllRevisions();
8500
9654
  await manager.#rewriteFile();
8501
9655
  return manager;
8502
9656
  }
@@ -8574,32 +9728,20 @@ export class SessionManager {
8574
9728
  storage,
8575
9729
  destination,
8576
9730
  );
8577
- manager.#sessionFile = resolved;
8578
- writeTerminalBreadcrumb(manager.cwd, resolved);
8579
- manager.#fileEntries = entries;
8580
9731
  if (entries.length > 0) {
8581
- manager.#sessionId = header?.id ?? createSessionId();
8582
- manager.#sessionName = header?.title;
8583
- manager.#titleSource = header?.titleSource;
8584
- manager.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(manager.#fileEntries);
8585
- await resolveBlobRefsInEntries(manager.#fileEntries, manager.#blobStore);
8586
- manager.#resetResidentTextBlobStore();
8587
- manager.#fileEntries = manager.#fileEntries.map(entry =>
8588
- prepareEntryForResidentSync(entry, manager.#residentBlobStores()),
8589
- );
8590
- manager.#buildIndex();
8591
- manager.#bumpAllRevisions();
8592
- manager.#flushed = true;
8593
- manager.#ensuredOnDisk = true;
9732
+ const migrationApplied = migrateToCurrentVersion(entries);
9733
+ await manager.#hydrateExistingSession(resolved, entries, migrationApplied, "memory-fallback");
9734
+ writeTerminalBreadcrumb(manager.cwd, resolved);
8594
9735
  await manager.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
8595
9736
  } else {
8596
- manager.#newSessionSync();
8597
- manager.#sessionFile = resolved;
8598
- 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);
8599
9742
  await manager.#rewriteFile();
8600
9743
  manager.#flushed = true;
8601
9744
  manager.#ensuredOnDisk = true;
8602
- manager.#bumpAllRevisions();
8603
9745
  }
8604
9746
  store.assertBound();
8605
9747
  return manager;
@@ -8768,24 +9910,25 @@ export class SessionManager {
8768
9910
  try {
8769
9911
  manager = new SessionManager(cwd, privateStagingDir ?? dir, true, snapshot.storage, forkDestination);
8770
9912
  await resolveBlobRefsInEntries(forkEntries, manager.#blobStore);
8771
- manager.#fileEntries = forkEntries;
8772
- const sourceHeader = manager.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
8773
- const historyEntries = manager.#fileEntries.filter(entry => entry.type !== "session") as SessionEntry[];
8774
- manager.#newSessionSync({ parentSession: sourceHeader?.id }, false);
8775
- manager.#resetResidentTextBlobStore();
8776
- const newHeader = manager.#fileEntries[0] as SessionHeader;
8777
- newHeader.title = sourceHeader?.title;
8778
- newHeader.titleSource = sourceHeader?.titleSource;
8779
- const residentBlobStores = manager.#residentBlobStores();
8780
- manager.#fileEntries = [
8781
- newHeader,
8782
- ...historyEntries.map(entry => prepareEntryForResidentSync(entry, residentBlobStores) as SessionEntry),
8783
- ];
8784
- manager.#sessionName = newHeader.title;
8785
- 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);
8786
9931
  manager.sanitizeLoadedOpenAIResponsesReplayMetadata();
8787
- manager.#buildIndex();
8788
- manager.#bumpAllRevisions();
8789
9932
  const beforeWrite = inspectResumeSessionFile(snapshot.sourcePath, snapshot.storage);
8790
9933
  if ("kind" in beforeWrite) {
8791
9934
  authorityFailure = beforeWrite;
@@ -8823,41 +9966,57 @@ export class SessionManager {
8823
9966
  throw new Error("managed_fork_transcript_changed");
8824
9967
  }
8825
9968
  if (privateStagingDir && manager.#sessionFile) {
8826
- const materializedEntries = materializeResidentEntriesForReadSync(
8827
- manager.#fileEntries,
8828
- 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",
8829
9984
  );
8830
- manager.#fileEntries = materializedEntries;
8831
- manager.#disposeResidentTextBlobStore();
8832
- const capturedStaging = native.snapshotDirectoryTree(privateStagingDir);
8833
- if (!capturedStaging.ok || !capturedStaging.snapshot)
8834
- throw new Error(capturedStaging.code ?? "fork_staging_snapshot_failed");
8835
- privateStagingSnapshot = capturedStaging.snapshot;
8836
- fsyncManagedArtifactTree(privateStagingDir);
8837
- const outcome = classifyNativePublishOutcome(native.renameNoReplacePath(privateStagingDir, dir));
8838
- if (!outcome.ok) throw new Error(outcome.code ?? "fork_destination_publish_failed");
8839
- privateStagingPublished = true;
8840
- const terminal = native.snapshotDirectoryTree(dir);
8841
- if (
8842
- !terminal.ok ||
8843
- !terminal.snapshot ||
8844
- !retainedTreeSnapshotEqualsAfterRename(terminal.snapshot, privateStagingSnapshot)
8845
- )
8846
- throw new Error("fork_destination_terminal_identity_changed");
8847
- await syncSessionMoveDirectory(path.dirname(dir));
8848
- const durableTerminal = native.snapshotDirectoryTree(dir);
8849
- if (
8850
- !durableTerminal.ok ||
8851
- !durableTerminal.snapshot ||
8852
- !retainedTreeSnapshotEqualsAfterRename(durableTerminal.snapshot, privateStagingSnapshot)
8853
- )
8854
- throw new Error("fork_destination_durability_identity_changed");
8855
- manager.sessionDir = dir;
8856
- manager.destination = destination;
8857
- manager.#sessionFile = path.join(dir, path.basename(manager.#sessionFile));
8858
- manager.#resetResidentTextBlobStore();
8859
- manager.#reexternalizeFileEntriesForResidentStore();
8860
- manager.#bumpAllRevisions();
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
+ }
8861
10020
  }
8862
10021
  if (manager.#sessionFile) writeTerminalBreadcrumb(manager.cwd, manager.#sessionFile);
8863
10022
  return { kind: "forked", manager };
@@ -9113,7 +10272,7 @@ export class SessionManager {
9113
10272
  storage,
9114
10273
  destination,
9115
10274
  );
9116
- await manager.#hydrateExistingSession(identity.canonicalPath, inspected.entries, false);
10275
+ await manager.#hydrateExistingSession(identity.canonicalPath, inspected.entries, false, "memory-only");
9117
10276
  const revalidated = inspectResumeSessionFile(identity.canonicalPath, storage);
9118
10277
  if ("kind" in revalidated || !sameResumeIdentity(identity, revalidated.identity)) {
9119
10278
  await manager.close();
@@ -9151,20 +10310,40 @@ export class SessionManager {
9151
10310
  if (!promotionPath) throw new Error("Recovery transcript promotion path is unavailable.");
9152
10311
  const promotedSource = inspectResumeSessionFile(context.identity.canonicalPath, this.storage);
9153
10312
  if ("kind" in promotedSource) throw new Error("Recovery transcript became unavailable before publication.");
9154
- if (this.destination.kind === "managed") {
9155
- const store = this.#managedTranscriptStore();
9156
- const sourceName = path.basename(context.identity.canonicalPath);
9157
- const sourceSnapshot = store.readExpected(sourceName);
9158
- if (!sourceSnapshot) throw new Error("Recovery transcript authority changed before publication.");
9159
- await store.publishNoReplace(path.basename(promotionPath), promotedSource.content);
9160
- store.removeExpected(sourceName, sourceSnapshot);
9161
- } else {
9162
- await writeOwnerOnlyFileNoReplace(promotionPath, promotedSource.content);
9163
- await fs.promises.rm(context.identity.canonicalPath, { force: true });
9164
- await fsyncDirectoryPath(path.dirname(context.identity.canonicalPath));
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;
9165
10343
  }
9166
10344
  this.#sessionFile = promotionPath;
9167
10345
  this.#recoveryPromotionTranscriptPath = undefined;
10346
+ this.#commitResidentTextStoreTransition(transition);
9168
10347
  writeTerminalBreadcrumb(this.cwd, promotionPath);
9169
10348
  this.#recoveryHydrationContext = undefined;
9170
10349
  }