@gajae-code/coding-agent 0.11.10 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. package/CHANGELOG.md +86 -2
  2. package/dist/types/cli/read-cli.d.ts +1 -0
  3. package/dist/types/cli.d.ts +8 -1
  4. package/dist/types/commands/deep-interview.d.ts +9 -0
  5. package/dist/types/commands/harness.d.ts +22 -0
  6. package/dist/types/commands/read.d.ts +6 -0
  7. package/dist/types/config/model-profile-activation.d.ts +1 -0
  8. package/dist/types/config/model-profile-contract.d.ts +2 -0
  9. package/dist/types/config/model-registry.d.ts +4 -0
  10. package/dist/types/config/provider-auth-health.d.ts +14 -0
  11. package/dist/types/config/provider-ranking.d.ts +59 -0
  12. package/dist/types/config/settings-schema.d.ts +86 -3
  13. package/dist/types/config/settings.d.ts +3 -0
  14. package/dist/types/extensibility/extensions/types.d.ts +43 -2
  15. package/dist/types/extensibility/shared-events.d.ts +6 -0
  16. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +5 -12
  17. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +17 -32
  18. package/dist/types/gjc-runtime/deep-interview-stage.d.ts +37 -0
  19. package/dist/types/gjc-runtime/deep-interview-state.d.ts +4 -135
  20. package/dist/types/gjc-runtime/gc-runtime.d.ts +1 -1
  21. package/dist/types/gjc-runtime/linux-proc.d.ts +22 -14
  22. package/dist/types/gjc-runtime/memory-guard-owner-claims.d.ts +47 -0
  23. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +112 -1
  24. package/dist/types/gjc-runtime/state-runtime.d.ts +3 -1
  25. package/dist/types/gjc-runtime/state-writer.d.ts +5 -46
  26. package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
  27. package/dist/types/gjc-runtime/team-runtime.d.ts +2 -1
  28. package/dist/types/gjc-runtime/team-store.d.ts +12 -0
  29. package/dist/types/gjc-runtime/team-worker-memory-guard.d.ts +92 -0
  30. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +7 -0
  31. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +8 -0
  32. package/dist/types/gjc-runtime/workflow-manifest.d.ts +3 -5
  33. package/dist/types/internal-urls/local-root-gc.d.ts +2 -0
  34. package/dist/types/main.d.ts +1 -1
  35. package/dist/types/memories/index.d.ts +2 -0
  36. package/dist/types/modes/acp/acp-agent.d.ts +22 -9
  37. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  38. package/dist/types/modes/components/irc-sidebar.d.ts +41 -0
  39. package/dist/types/modes/components/queue-pane.d.ts +8 -0
  40. package/dist/types/modes/components/queued-message-selector.d.ts +6 -0
  41. package/dist/types/modes/components/read-tool-group.d.ts +4 -4
  42. package/dist/types/modes/components/session-observer-overlay.d.ts +4 -0
  43. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  44. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  45. package/dist/types/modes/components/welcome.d.ts +1 -0
  46. package/dist/types/modes/controllers/event-controller.d.ts +3 -0
  47. package/dist/types/modes/interactive-mode.d.ts +7 -1
  48. package/dist/types/modes/irc-observation-ledger.d.ts +17 -1
  49. package/dist/types/modes/types.d.ts +2 -0
  50. package/dist/types/modes/utils/ui-helpers.d.ts +16 -0
  51. package/dist/types/runtime/memory-domain.d.ts +9 -0
  52. package/dist/types/runtime/memory-guard-contract.d.ts +71 -0
  53. package/dist/types/runtime/memory-guard.d.ts +39 -0
  54. package/dist/types/runtime/memory-limit.d.ts +11 -0
  55. package/dist/types/runtime-mcp/manager.d.ts +8 -0
  56. package/dist/types/runtime-mcp/smithery-auth.d.ts +5 -0
  57. package/dist/types/sdk/acp/final-text.d.ts +19 -0
  58. package/dist/types/sdk/acp/mcp.d.ts +23 -0
  59. package/dist/types/sdk/broker/lifecycle.d.ts +7 -0
  60. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
  61. package/dist/types/sdk/bus/control-drain-lease.d.ts +52 -0
  62. package/dist/types/sdk/bus/index.d.ts +18 -2
  63. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +37 -0
  64. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -1
  65. package/dist/types/sdk/bus/rate-limit-pool.d.ts +2 -0
  66. package/dist/types/sdk/bus/reconciliation-store.d.ts +65 -0
  67. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -4
  68. package/dist/types/sdk/bus/telegram-daemon.d.ts +23 -3
  69. package/dist/types/sdk/host/control/operations.d.ts +2 -1
  70. package/dist/types/sdk/host/host.d.ts +2 -0
  71. package/dist/types/sdk/host/query/handlers.d.ts +5 -0
  72. package/dist/types/sdk/lifecycle-session.d.ts +10 -1
  73. package/dist/types/sdk/startup-capability.d.ts +6 -0
  74. package/dist/types/session/agent-session.d.ts +51 -19
  75. package/dist/types/session/fallback-chain-controller.d.ts +18 -2
  76. package/dist/types/session/internal/managed-session-scope.d.ts +27 -0
  77. package/dist/types/session/memory-guard-checkpoint-participant.d.ts +69 -0
  78. package/dist/types/session/session-manager.d.ts +59 -13
  79. package/dist/types/session/streaming-output.d.ts +60 -5
  80. package/dist/types/skill-state/active-state.d.ts +0 -4
  81. package/dist/types/skill-state/workflow-hud.d.ts +3 -0
  82. package/dist/types/task/executor.d.ts +6 -0
  83. package/dist/types/task/types.d.ts +36 -0
  84. package/dist/types/task/ultragoal-redteam-activation.d.ts +33 -0
  85. package/dist/types/tools/browser/launch.d.ts +7 -0
  86. package/dist/types/tools/fetch.d.ts +17 -1
  87. package/dist/types/tools/output-meta.d.ts +21 -1
  88. package/dist/types/tools/read-internals.d.ts +12 -0
  89. package/dist/types/tools/read.d.ts +45 -1
  90. package/dist/types/tools/resource-gc.d.ts +23 -0
  91. package/dist/types/tools/tool-result.d.ts +7 -1
  92. package/package.json +9 -13
  93. package/scripts/benchmark-sticky-viewport-pr1.ts +239 -0
  94. package/scripts/capture-sticky-viewport-showcase.ts +292 -0
  95. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  96. package/scripts/run-test-manifest.ts +6 -4
  97. package/scripts/verify-gjc-sdk-canonicalization.ts +122 -18
  98. package/scripts/verify-sticky-viewport-showcase.ts +611 -0
  99. package/src/capability/index.ts +8 -0
  100. package/src/cli/args.ts +14 -8
  101. package/src/cli/fast-help.ts +1 -1
  102. package/src/cli/read-cli.ts +2 -1
  103. package/src/cli.ts +49 -2
  104. package/src/commands/deep-interview.ts +29 -9
  105. package/src/commands/harness.ts +35 -12
  106. package/src/commands/read.ts +11 -2
  107. package/src/commands/sdk.ts +91 -7
  108. package/src/commands/team.ts +1 -1
  109. package/src/config/model-profile-activation.ts +1 -0
  110. package/src/config/model-profile-contract.ts +13 -0
  111. package/src/config/model-profiles.ts +24 -0
  112. package/src/config/model-registry.ts +21 -2
  113. package/src/config/provider-auth-health.ts +42 -0
  114. package/src/config/provider-ranking.ts +119 -0
  115. package/src/config/settings-schema.ts +84 -3
  116. package/src/config/settings.ts +254 -108
  117. package/src/defaults/gjc/skills/deep-interview/SKILL.md +229 -82
  118. package/src/defaults/gjc/skills/ralplan/SKILL.md +78 -26
  119. package/src/defaults/gjc/skills/team/SKILL.md +1 -1
  120. package/src/defaults/gjc/skills/ultragoal/SKILL.md +26 -16
  121. package/src/eval/py/executor.ts +7 -2
  122. package/src/extensibility/extensions/runner.ts +4 -0
  123. package/src/extensibility/extensions/types.ts +42 -4
  124. package/src/extensibility/shared-events.ts +5 -0
  125. package/src/gjc-runtime/deep-interview-ambiguity.ts +47 -68
  126. package/src/gjc-runtime/deep-interview-recorder.ts +227 -310
  127. package/src/gjc-runtime/deep-interview-runtime.ts +109 -270
  128. package/src/gjc-runtime/deep-interview-stage.ts +1016 -0
  129. package/src/gjc-runtime/deep-interview-state.ts +12 -921
  130. package/src/gjc-runtime/gc-render.ts +1 -0
  131. package/src/gjc-runtime/gc-runtime.ts +11 -1
  132. package/src/gjc-runtime/linux-proc.ts +70 -36
  133. package/src/gjc-runtime/memory-guard-owner-claims.ts +389 -0
  134. package/src/gjc-runtime/ralplan-runtime.ts +979 -71
  135. package/src/gjc-runtime/state-runtime.ts +50 -12
  136. package/src/gjc-runtime/state-writer.ts +70 -222
  137. package/src/gjc-runtime/team-launch.ts +34 -5
  138. package/src/gjc-runtime/team-runtime.ts +970 -33
  139. package/src/gjc-runtime/team-store.ts +32 -0
  140. package/src/gjc-runtime/team-worker-memory-guard.ts +363 -0
  141. package/src/gjc-runtime/team-workers.ts +1 -0
  142. package/src/gjc-runtime/tmux-owner-isolation.ts +18 -0
  143. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +20 -0
  144. package/src/gjc-runtime/ultragoal-runtime.ts +13 -7
  145. package/src/gjc-runtime/workflow-manifest.generated.json +97 -350
  146. package/src/gjc-runtime/workflow-manifest.ts +34 -179
  147. package/src/hooks/native-skill-hook.ts +17 -4
  148. package/src/hooks/skill-state.ts +1 -23
  149. package/src/internal-urls/docs-index.generated.ts +10 -11
  150. package/src/internal-urls/local-protocol.ts +23 -4
  151. package/src/internal-urls/local-root-gc.ts +127 -0
  152. package/src/main.ts +9 -5
  153. package/src/memories/index.ts +10 -0
  154. package/src/modes/DESIGN.md +47 -0
  155. package/src/modes/acp/acp-agent.ts +597 -67
  156. package/src/modes/acp/acp-event-mapper.ts +106 -12
  157. package/src/modes/acp/acp-mode.ts +39 -3
  158. package/src/modes/components/assistant-message.ts +123 -22
  159. package/src/modes/components/irc-sidebar.ts +176 -62
  160. package/src/modes/components/model-selector.ts +76 -5
  161. package/src/modes/components/notifications-settings-editor.ts +1 -1
  162. package/src/modes/components/oauth-selector.ts +42 -12
  163. package/src/modes/components/queue-pane.ts +55 -9
  164. package/src/modes/components/queued-message-selector.ts +53 -10
  165. package/src/modes/components/read-tool-group.ts +82 -14
  166. package/src/modes/components/session-observer-overlay.ts +187 -32
  167. package/src/modes/components/settings-selector.ts +60 -18
  168. package/src/modes/components/tool-execution.ts +138 -27
  169. package/src/modes/components/tool-status-header.ts +1 -4
  170. package/src/modes/components/welcome.ts +18 -3
  171. package/src/modes/controllers/command-controller.ts +4 -0
  172. package/src/modes/controllers/event-controller.ts +109 -27
  173. package/src/modes/controllers/extension-ui-controller.ts +23 -12
  174. package/src/modes/controllers/input-controller.ts +62 -6
  175. package/src/modes/controllers/selector-controller.ts +83 -37
  176. package/src/modes/interactive-mode.ts +127 -29
  177. package/src/modes/irc-observation-ledger.ts +71 -15
  178. package/src/modes/runtime-init.ts +20 -2
  179. package/src/modes/types.ts +2 -0
  180. package/src/modes/utils/ui-helpers.ts +94 -1
  181. package/src/prompts/agents/architect.md +9 -1
  182. package/src/prompts/agents/critic.md +9 -0
  183. package/src/prompts/agents/executor.md +1 -1
  184. package/src/prompts/system/system-prompt.md +0 -1
  185. package/src/prompts/tools/read.md +4 -2
  186. package/src/runtime/memory-domain.ts +66 -0
  187. package/src/runtime/memory-guard-contract.ts +73 -0
  188. package/src/runtime/memory-guard.ts +292 -0
  189. package/src/runtime/memory-limit.ts +50 -0
  190. package/src/runtime-mcp/manager.ts +26 -6
  191. package/src/runtime-mcp/smithery-auth.ts +17 -2
  192. package/src/runtime-mcp/smithery-connect.ts +11 -1
  193. package/src/sdk/acp/adapter.ts +8 -1
  194. package/src/sdk/acp/final-text.ts +40 -0
  195. package/src/sdk/acp/mcp.ts +26 -0
  196. package/src/sdk/broker/lifecycle.ts +107 -7
  197. package/src/sdk/bus/chat-daemon-cli.ts +2 -2
  198. package/src/sdk/bus/chat-daemon-control.ts +4 -3
  199. package/src/sdk/bus/config.ts +1 -1
  200. package/src/sdk/bus/control-drain-lease.ts +104 -0
  201. package/src/sdk/bus/index.ts +503 -60
  202. package/src/sdk/bus/kind-aware-reconciliation.ts +242 -0
  203. package/src/sdk/bus/prompt-reconciliation.ts +2 -1
  204. package/src/sdk/bus/rate-limit-pool.ts +8 -0
  205. package/src/sdk/bus/reconciliation-store.ts +224 -0
  206. package/src/sdk/bus/telegram-daemon-contract.ts +6 -4
  207. package/src/sdk/bus/telegram-daemon.ts +531 -53
  208. package/src/sdk/host/control/dispatch.ts +7 -1
  209. package/src/sdk/host/control/operations.ts +2 -1
  210. package/src/sdk/host/host.ts +25 -3
  211. package/src/sdk/host/query/handlers.ts +55 -0
  212. package/src/sdk/lifecycle-session.ts +19 -3
  213. package/src/sdk/mcp/server.ts +46 -18
  214. package/src/sdk/protocol/operation-inventory.generated.json +76 -0
  215. package/src/sdk/protocol/operation-registry.ts +8 -2
  216. package/src/sdk/session.ts +25 -2
  217. package/src/sdk/startup-capability.ts +6 -0
  218. package/src/session/agent-session.ts +720 -190
  219. package/src/session/fallback-chain-controller.ts +47 -8
  220. package/src/session/internal/managed-session-scope.ts +40 -10
  221. package/src/session/internal/native-publish-outcome.ts +8 -3
  222. package/src/session/memory-guard-checkpoint-participant.ts +131 -0
  223. package/src/session/session-manager.ts +1372 -42
  224. package/src/session/streaming-output.ts +262 -43
  225. package/src/setup/provider-onboarding.ts +19 -4
  226. package/src/skill-state/active-state.ts +0 -10
  227. package/src/skill-state/workflow-hud.ts +25 -2
  228. package/src/skill-state/workflow-mutation-guard.ts +65 -7
  229. package/src/task/executor.ts +11 -3
  230. package/src/task/index.ts +2 -0
  231. package/src/task/types.ts +22 -11
  232. package/src/task/ultragoal-redteam-activation.ts +75 -0
  233. package/src/tools/ask.ts +89 -69
  234. package/src/tools/browser/launch.ts +41 -6
  235. package/src/tools/fetch.ts +114 -23
  236. package/src/tools/output-meta.ts +176 -9
  237. package/src/tools/path-utils.ts +5 -1
  238. package/src/tools/read-internals.ts +44 -0
  239. package/src/tools/read.ts +1542 -310
  240. package/src/tools/resource-gc.ts +665 -125
  241. package/src/tools/tool-result.ts +14 -1
  242. package/src/web/search/providers/codex.ts +1 -1
  243. package/src/workflow/workflow-intent-diff.ts +4 -1
  244. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +0 -8
  245. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +0 -41
  246. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +0 -17
  247. package/src/gjc-runtime/deep-interview-draft.ts +0 -843
  248. package/src/gjc-runtime/deep-interview-payload.ts +0 -262
  249. package/src/gjc-runtime/deep-interview-repair.ts +0 -1674
@@ -31,9 +31,9 @@ import {
31
31
  toError,
32
32
  } from "@gajae-code/utils";
33
33
  import type { TtsrInjectionRecord } from "../export/ttsr";
34
+ import { assertSafePathComponent } from "../gjc-runtime/session-layout";
34
35
  import { writeTextAtomic } from "../gjc-runtime/state-writer";
35
36
  import type { ManagedLegacyLocalMigrationSource } from "../internal-urls/local-protocol";
36
-
37
37
  import * as git from "../utils/git";
38
38
  import { ArtifactManager } from "./artifacts";
39
39
  import {
@@ -79,6 +79,23 @@ import {
79
79
  retainManagedDirectoryAuthority,
80
80
  } from "./internal/managed-session-storage";
81
81
  import { classifyNativePublishOutcome, formatNativePublishDiagnostic } from "./internal/native-publish-outcome";
82
+ import {
83
+ hasOnlyKeys as hasOnlyMemoryGuardKeys,
84
+ isMemoryGuardDecimalString,
85
+ isMemoryGuardRelativePath,
86
+ isMemoryGuardSha256Hex,
87
+ type MemoryGuardCheckpointBlobAuthorityV1,
88
+ type MemoryGuardCheckpointBlobManifestEntryV1,
89
+ type MemoryGuardCheckpointBlobManifestV1,
90
+ type MemoryGuardCreateCheckpointInput,
91
+ type MemoryGuardParticipantDescriptorV1,
92
+ type MemoryGuardParticipantIngressLease,
93
+ type MemoryGuardRestoreInput,
94
+ type MemoryGuardRestoreResult,
95
+ type MemoryGuardSessionManagerCheckpointV1,
96
+ memoryGuardCanonicalJson,
97
+ memoryGuardSha256Hex,
98
+ } from "./memory-guard-checkpoint-participant";
82
99
 
83
100
  import {
84
101
  type BashExecutionMessage,
@@ -134,6 +151,36 @@ export interface NewSessionOptions {
134
151
  drop?: boolean;
135
152
  }
136
153
 
154
+ /** Internal successor prepared without changing the manager's visible identity. */
155
+ export interface PreparedNewSession {
156
+ readonly sessionId: string;
157
+ readonly sessionFile: string | undefined;
158
+ readonly artifactsDir: string | null;
159
+ readonly managedLegacyLocalMigrationSource: ManagedLegacyLocalMigrationSource | null;
160
+ }
161
+
162
+ interface PreparedSessionIndex {
163
+ readonly byId: Map<string, SessionEntry>;
164
+ readonly labelsById: Map<string, string>;
165
+ readonly leafId: string | null;
166
+ readonly usageStatistics: UsageStatistics;
167
+ }
168
+
169
+ interface PreparedNewSessionState extends PreparedNewSession {
170
+ readonly header: SessionHeader;
171
+ readonly fileEntries: FileEntry[];
172
+ readonly sessionName: string | undefined;
173
+ readonly titleSource: "auto" | "user" | undefined;
174
+ residentFileEntries?: FileEntry[];
175
+ residentTextBlobStore?: BlobStore;
176
+ index?: PreparedSessionIndex;
177
+ flushed: boolean;
178
+ committed: boolean;
179
+ discarded: boolean;
180
+ persistenceWriter?: NdjsonFileWriter;
181
+ persistenceTempPath?: string;
182
+ }
183
+
137
184
  export interface SessionEntryBase {
138
185
  type: string;
139
186
  id: string;
@@ -450,6 +497,238 @@ export type DefaultModelSelectionStage = {
450
497
  readonly tempPath: string | undefined;
451
498
  readonly persistsToExistingFile: boolean;
452
499
  };
500
+ export interface SessionManagerRevisionSnapshot {
501
+ entry: number;
502
+ leaf: number;
503
+ headerExport: number;
504
+ label: number;
505
+ replayMetadata: number;
506
+ }
507
+
508
+ export interface SessionManagerCheckpointRevisionStrings {
509
+ entry: string;
510
+ leaf: string;
511
+ headerExport: string;
512
+ label: string;
513
+ replayMetadata: string;
514
+ }
515
+
516
+ function assertMemoryGuardSessionId(sessionId: string): void {
517
+ assertSafePathComponent(sessionId, "memory guard checkpoint session id");
518
+ }
519
+
520
+ function memoryGuardParticipantRoot(checkpointRoot: string, sessionId: string): string {
521
+ assertMemoryGuardSessionId(sessionId);
522
+ return path.join(checkpointRoot, "participants", sessionId);
523
+ }
524
+
525
+ function memoryGuardParticipantRelativePath(sessionId: string, suffix: string): string {
526
+ assertMemoryGuardSessionId(sessionId);
527
+ return `participants/${sessionId}/${suffix}`;
528
+ }
529
+
530
+ async function fsyncDirectoryPath(directoryPath: string): Promise<void> {
531
+ const directory = await fs.promises.open(directoryPath, "r");
532
+ try {
533
+ await directory.sync();
534
+ } finally {
535
+ await directory.close();
536
+ }
537
+ }
538
+
539
+ async function ensureOwnerOnlyDirectory(directoryPath: string): Promise<void> {
540
+ await fs.promises.mkdir(directoryPath, { recursive: true, mode: 0o700 });
541
+ await fs.promises.chmod(directoryPath, 0o700).catch(() => undefined);
542
+ await fsyncDirectoryPath(directoryPath);
543
+ }
544
+ interface CreatedDirectoryIdentity {
545
+ dev: bigint;
546
+ ino: bigint;
547
+ }
548
+
549
+ async function ensureOwnerOnlyDirectoryTracked(directoryPath: string): Promise<CreatedDirectoryIdentity | undefined> {
550
+ const createdPath = await fs.promises.mkdir(directoryPath, { recursive: true, mode: 0o700 });
551
+ await fs.promises.chmod(directoryPath, 0o700).catch(() => undefined);
552
+ await fsyncDirectoryPath(directoryPath);
553
+ if (createdPath === undefined) return undefined;
554
+ const stat = await fs.promises.lstat(directoryPath, { bigint: true });
555
+ return { dev: stat.dev, ino: stat.ino };
556
+ }
557
+
558
+ async function removeCreatedDirectoryIfEmpty(
559
+ directoryPath: string,
560
+ created: CreatedDirectoryIdentity | undefined,
561
+ ): Promise<void> {
562
+ if (!created) return;
563
+ const stat = await fs.promises.lstat(directoryPath, { bigint: true }).catch(() => undefined);
564
+ if (!stat || stat.dev !== created.dev || stat.ino !== created.ino) return;
565
+ await fs.promises.rmdir(directoryPath).catch(() => undefined);
566
+ await fsyncDirectoryPath(path.dirname(directoryPath)).catch(() => undefined);
567
+ }
568
+
569
+ async function writeOwnerOnlyFileNoReplace(filePath: string, content: Uint8Array | string): Promise<void> {
570
+ await ensureOwnerOnlyDirectory(path.dirname(filePath));
571
+ const handle = await fs.promises.open(filePath, "wx", 0o600);
572
+ try {
573
+ await handle.writeFile(content);
574
+ await handle.sync();
575
+ } finally {
576
+ await handle.close();
577
+ }
578
+ await fs.promises.chmod(filePath, 0o600).catch(() => undefined);
579
+ await fsyncDirectoryPath(path.dirname(filePath));
580
+ }
581
+
582
+ const MEMORY_GUARD_CHECKPOINT_FILE_MAX_BYTES = 64 * 1024 * 1024;
583
+ const MEMORY_GUARD_CHECKPOINT_BLOB_MAX_ENTRIES = 4096;
584
+ const MEMORY_GUARD_CHECKPOINT_BLOB_TOTAL_MAX_BYTES = 64 * 1024 * 1024;
585
+
586
+ function collectCheckpointBlobRefs(value: unknown, refs: Set<string> = new Set(), key?: string): Set<string> {
587
+ const addRef = (candidate: unknown): void => {
588
+ if (typeof candidate === "string" && parseBlobRef(candidate)) refs.add(candidate);
589
+ };
590
+ if (Array.isArray(value)) {
591
+ for (const item of value) collectCheckpointBlobRefs(item, refs, key);
592
+ return refs;
593
+ }
594
+ if (!value || typeof value !== "object") return refs;
595
+ if (isRecord(value) && value.kind === "cold_spill" && typeof value.ref === "string" && parseBlobRef(value.ref))
596
+ refs.add(value.ref);
597
+ if (isImageBlock(value) && key === TEXT_CONTENT_KEY) addRef(value.data);
598
+ if (hasImageUrl(value)) {
599
+ if (typeof value.image_url === "string") addRef(value.image_url);
600
+ else addRef(value.image_url.url);
601
+ }
602
+ for (const [childKey, item] of Object.entries(value as Record<string, unknown>)) {
603
+ if (childKey === "data" && key !== TEXT_CONTENT_KEY) addRef(item);
604
+ collectCheckpointBlobRefs(item, refs, childKey);
605
+ }
606
+ return refs;
607
+ }
608
+
609
+ function decodeCheckpointUtf8(data: Uint8Array): string | null {
610
+ try {
611
+ return new TextDecoder("utf-8", { fatal: true }).decode(data);
612
+ } catch {
613
+ return null;
614
+ }
615
+ }
616
+
617
+ function validateMemoryGuardBlobAuthority(value: unknown): value is MemoryGuardCheckpointBlobAuthorityV1 {
618
+ if (
619
+ !isRecord(value) ||
620
+ !hasOnlyMemoryGuardKeys(value, ["kind", "manifest_relative_path", "manifest_sha256", "root_relative_path"])
621
+ )
622
+ return false;
623
+ return (
624
+ value.kind === "checkpoint_blob_tree_v1" &&
625
+ isMemoryGuardRelativePath(value.manifest_relative_path) &&
626
+ isMemoryGuardSha256Hex(value.manifest_sha256) &&
627
+ isMemoryGuardRelativePath(value.root_relative_path)
628
+ );
629
+ }
630
+
631
+ function validateMemoryGuardTranscriptDescriptor(
632
+ value: unknown,
633
+ ): value is MemoryGuardSessionManagerCheckpointV1["transcript"] {
634
+ if (!isRecord(value) || !hasOnlyMemoryGuardKeys(value, ["bytes", "relative_path", "sha256"])) return false;
635
+ return (
636
+ isMemoryGuardDecimalString(value.bytes) &&
637
+ isMemoryGuardRelativePath(value.relative_path) &&
638
+ isMemoryGuardSha256Hex(value.sha256)
639
+ );
640
+ }
641
+
642
+ function validateMemoryGuardRevisions(value: unknown): value is SessionManagerCheckpointRevisionStrings {
643
+ if (!isRecord(value) || !hasOnlyMemoryGuardKeys(value, ["entry", "leaf", "headerExport", "label", "replayMetadata"]))
644
+ return false;
645
+ return (
646
+ isMemoryGuardDecimalString(value.entry) &&
647
+ isMemoryGuardDecimalString(value.leaf) &&
648
+ isMemoryGuardDecimalString(value.headerExport) &&
649
+ isMemoryGuardDecimalString(value.label) &&
650
+ isMemoryGuardDecimalString(value.replayMetadata)
651
+ );
652
+ }
653
+
654
+ function validateMemoryGuardCheckpoint(value: unknown): value is MemoryGuardSessionManagerCheckpointV1 {
655
+ if (
656
+ !isRecord(value) ||
657
+ !hasOnlyMemoryGuardKeys(value, [
658
+ "blob_authority",
659
+ "revisions",
660
+ "schema_version",
661
+ "session_id",
662
+ "session_name",
663
+ "transcript",
664
+ ])
665
+ )
666
+ return false;
667
+ return (
668
+ value.schema_version === 1 &&
669
+ typeof value.session_id === "string" &&
670
+ value.session_id.length > 0 &&
671
+ (value.session_name === null || typeof value.session_name === "string") &&
672
+ validateMemoryGuardBlobAuthority(value.blob_authority) &&
673
+ validateMemoryGuardRevisions(value.revisions) &&
674
+ validateMemoryGuardTranscriptDescriptor(value.transcript)
675
+ );
676
+ }
677
+
678
+ function validateMemoryGuardBlobManifestEntry(value: unknown): value is MemoryGuardCheckpointBlobManifestEntryV1 {
679
+ if (!isRecord(value) || !hasOnlyMemoryGuardKeys(value, ["bytes", "relative_path", "sha256"])) return false;
680
+ return (
681
+ isMemoryGuardDecimalString(value.bytes) &&
682
+ isMemoryGuardRelativePath(value.relative_path) &&
683
+ isMemoryGuardSha256Hex(value.sha256)
684
+ );
685
+ }
686
+
687
+ function validateMemoryGuardBlobManifest(value: unknown): value is MemoryGuardCheckpointBlobManifestV1 {
688
+ if (!isRecord(value) || !hasOnlyMemoryGuardKeys(value, ["entries", "schema_version"])) return false;
689
+ if (value.schema_version !== 1 || !Array.isArray(value.entries)) return false;
690
+ return value.entries.every(validateMemoryGuardBlobManifestEntry);
691
+ }
692
+
693
+ function memoryGuardParticipantMatchesCheckpoint(
694
+ participant: MemoryGuardParticipantDescriptorV1,
695
+ checkpoint: MemoryGuardSessionManagerCheckpointV1,
696
+ ): boolean {
697
+ return (
698
+ participant.session_id === checkpoint.session_id &&
699
+ participant.session_name === checkpoint.session_name &&
700
+ JSON.stringify(participant.checkpoint) === JSON.stringify(checkpoint.blob_authority) &&
701
+ JSON.stringify(participant.revisions) === JSON.stringify(checkpoint.revisions) &&
702
+ JSON.stringify(participant.transcript) === JSON.stringify(checkpoint.transcript)
703
+ );
704
+ }
705
+
706
+ function readCheckpointAuthorityFile(
707
+ authority: native.RecoveryFsRoot,
708
+ relativePath: string,
709
+ maxBytes: number,
710
+ ): Uint8Array | null {
711
+ const result = authority.readManaged(relativePath);
712
+ if (!result.ok || !result.data || result.data.byteLength > maxBytes) return null;
713
+ return result.data;
714
+ }
715
+
716
+ function toCanonicalRevisionString(name: keyof SessionManagerRevisionSnapshot, value: number): string {
717
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error(`invalid_session_manager_revision:${name}`);
718
+ return String(value);
719
+ }
720
+
721
+ export function toSessionManagerCheckpointRevisionStrings(
722
+ snapshot: SessionManagerRevisionSnapshot,
723
+ ): SessionManagerCheckpointRevisionStrings {
724
+ return {
725
+ entry: toCanonicalRevisionString("entry", snapshot.entry),
726
+ leaf: toCanonicalRevisionString("leaf", snapshot.leaf),
727
+ headerExport: toCanonicalRevisionString("headerExport", snapshot.headerExport),
728
+ label: toCanonicalRevisionString("label", snapshot.label),
729
+ replayMetadata: toCanonicalRevisionString("replayMetadata", snapshot.replayMetadata),
730
+ };
731
+ }
453
732
 
454
733
  export type DefaultModelSelectionPromotion =
455
734
  | { readonly kind: "promoted" }
@@ -3476,6 +3755,81 @@ class NdjsonFileWriter {
3476
3755
  }
3477
3756
  }
3478
3757
 
3758
+ const PROJECT_SESSION_SCAN_MAX_DIRECTORIES = 4096;
3759
+ const PROJECT_SESSION_SCAN_MAX_FILES = 1000;
3760
+
3761
+ function isProjectSessionTranscriptPath(projectGjcDir: string, filePath: string): boolean {
3762
+ const relative = path.relative(projectGjcDir, filePath);
3763
+ if (relative.startsWith("..") || path.isAbsolute(relative)) return false;
3764
+ const segments = relative.split(path.sep);
3765
+ if (segments.length === 1) return true;
3766
+ const parent = segments.at(-2);
3767
+ return parent === "agent-session" || segments.includes("sessions");
3768
+ }
3769
+
3770
+ /**
3771
+ * Discover resumable transcripts intentionally stored inside a project's `.gjc`.
3772
+ * Runtime token/audit JSONL files are excluded by requiring a known transcript
3773
+ * container (`agent-session` or `sessions`).
3774
+ */
3775
+ function listProjectSessionTranscriptFiles(cwd: string): string[] {
3776
+ const projectGjcDir = path.join(path.resolve(cwd), ".gjc");
3777
+ let rootStat: fs.Stats;
3778
+ try {
3779
+ rootStat = fs.lstatSync(projectGjcDir);
3780
+ } catch {
3781
+ return [];
3782
+ }
3783
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) return [];
3784
+
3785
+ const directories = [projectGjcDir];
3786
+ const files: string[] = [];
3787
+ let scannedDirectories = 0;
3788
+ while (directories.length > 0 && scannedDirectories < PROJECT_SESSION_SCAN_MAX_DIRECTORIES) {
3789
+ const directory = directories.pop()!;
3790
+ scannedDirectories++;
3791
+ let entries: fs.Dirent[];
3792
+ try {
3793
+ entries = fs.readdirSync(directory, { withFileTypes: true });
3794
+ } catch {
3795
+ continue;
3796
+ }
3797
+ for (const entry of entries) {
3798
+ if (entry.isSymbolicLink()) continue;
3799
+ const entryPath = path.join(directory, entry.name);
3800
+ if (entry.isDirectory()) {
3801
+ directories.push(entryPath);
3802
+ continue;
3803
+ }
3804
+ if (
3805
+ entry.isFile() &&
3806
+ !entry.name.startsWith(".") &&
3807
+ entry.name.endsWith(".jsonl") &&
3808
+ isProjectSessionTranscriptPath(projectGjcDir, entryPath)
3809
+ ) {
3810
+ files.push(entryPath);
3811
+ if (files.length >= PROJECT_SESSION_SCAN_MAX_FILES) return files;
3812
+ }
3813
+ }
3814
+ }
3815
+ return files;
3816
+ }
3817
+
3818
+ async function collectProjectSessions(cwd: string, storage: FileSessionStorage): Promise<SessionInfo[]> {
3819
+ return await collectSessionsFromFiles(listProjectSessionTranscriptFiles(cwd), storage);
3820
+ }
3821
+
3822
+ function mergeSessionInventories(...inventories: SessionInfo[][]): SessionInfo[] {
3823
+ const sessions = new Map<string, SessionInfo>();
3824
+ for (const inventory of inventories) {
3825
+ for (const session of inventory) {
3826
+ const current = sessions.get(session.id);
3827
+ if (!current || session.modified.getTime() > current.modified.getTime()) sessions.set(session.id, session);
3828
+ }
3829
+ }
3830
+ return [...sessions.values()].sort((left, right) => right.modified.getTime() - left.modified.getTime());
3831
+ }
3832
+
3479
3833
  const DEFAULT_WELCOME_RECENT_SESSION_LIMIT = 20;
3480
3834
 
3481
3835
  /** Get recent sessions for display in welcome screen */
@@ -3487,6 +3841,21 @@ export async function getRecentSessions(
3487
3841
  return getSortedSessions(sessionDir, storage, limit);
3488
3842
  }
3489
3843
 
3844
+ export function getRecentSessionDisplay(
3845
+ sessions: readonly SessionInfo[],
3846
+ limit = DEFAULT_WELCOME_RECENT_SESSION_LIMIT,
3847
+ ): Array<{ name: string; timeAgo: string }> {
3848
+ return sessions.slice(0, limit).map(session => {
3849
+ const recent = new RecentSessionInfo(
3850
+ session.path,
3851
+ session.modified.getTime(),
3852
+ { title: session.title, timestamp: session.created.toISOString() },
3853
+ session.firstMessage,
3854
+ );
3855
+ return { name: recent.name, timeAgo: recent.timeAgo };
3856
+ });
3857
+ }
3858
+
3490
3859
  /**
3491
3860
  * Manages conversation sessions as append-only trees stored in JSONL files.
3492
3861
  *
@@ -3977,6 +4346,7 @@ export class SessionManager {
3977
4346
  #sessionId: string = "";
3978
4347
  /** True once a lifecycle pre-allocated id has been adopted (consume-once). */
3979
4348
  #lifecycleIdAdopted: boolean = false;
4349
+ #preparedNewSessions = new Set<PreparedNewSessionState>();
3980
4350
  #sessionName: string | undefined;
3981
4351
  #titleSource: "auto" | "user" | undefined;
3982
4352
  #sessionFile: string | undefined;
@@ -3984,6 +4354,8 @@ export class SessionManager {
3984
4354
  #needsFullRewriteOnNextPersist: boolean = false;
3985
4355
  #ensuredOnDisk: boolean = false;
3986
4356
  #recoveryHydrationContext: RecoveryHydrationContext | undefined;
4357
+ #recoveryPromotionTranscriptPath: string | undefined;
4358
+ #memoryGuardParticipantIngressToken: symbol | undefined;
3987
4359
  #fileEntries: FileEntry[] = [];
3988
4360
  #pendingStrictAdoption: { canonicalPath: string; identity: ResumeSessionIdentity } | undefined;
3989
4361
  #byId: Map<string, SessionEntry> = new Map();
@@ -4002,6 +4374,8 @@ export class SessionManager {
4002
4374
  #persistChain: Promise<void> = Promise.resolve();
4003
4375
  #persistError: Error | undefined;
4004
4376
  #persistErrorReported = false;
4377
+ /** Failed staged persistence retains its exact writer and temporary pathname for retryable cleanup. */
4378
+ #preparedNewSessionCleanupInProgress = false;
4005
4379
 
4006
4380
  #artifactManager: ArtifactManager | null = null;
4007
4381
  #artifactManagerSessionFile: string | null = null;
@@ -4016,7 +4390,8 @@ export class SessionManager {
4016
4390
  #inMemoryArtifactCounter = 0;
4017
4391
  readonly #blobStore: BlobStore;
4018
4392
  #residentTextBlobStore: BlobStore = new MemoryBlobStore();
4019
- readonly #residentImageBlobStore: BlobStore;
4393
+ #residentImageBlobStore: BlobStore;
4394
+ #memoryGuardCheckpointBlobs: Map<string, Buffer> | undefined;
4020
4395
  #entryRevision = 0;
4021
4396
  #leafRevision = 0;
4022
4397
  /** Export/header cache invalidation contract; consumers may arrive after the revision field. */
@@ -4089,6 +4464,9 @@ export class SessionManager {
4089
4464
  },
4090
4465
  };
4091
4466
  }
4467
+ #coldSpillReadStore(): BlobStore {
4468
+ return this.#memoryGuardCheckpointBlobs ? this.#residentImageBlobStore : this.#blobStore;
4469
+ }
4092
4470
 
4093
4471
  #residentCacheDir(sessionFile: string): string {
4094
4472
  const instance = ++residentCacheInstanceCounter;
@@ -4106,6 +4484,21 @@ export class SessionManager {
4106
4484
  this.#buildIndex();
4107
4485
  }
4108
4486
 
4487
+ #preparePreparedNewSessionForCommit(stage: PreparedNewSessionState): void {
4488
+ const residentTextBlobStore = new MemoryBlobStore();
4489
+ const residentFileEntries = stage.fileEntries.map(entry =>
4490
+ prepareEntryForResidentSync(entry, {
4491
+ textStore: residentTextBlobStore,
4492
+ imageStore: this.#residentImageBlobStore,
4493
+ sessionId: stage.sessionId,
4494
+ sessionFile: stage.sessionFile,
4495
+ }),
4496
+ );
4497
+ stage.residentTextBlobStore = residentTextBlobStore;
4498
+ stage.residentFileEntries = residentFileEntries;
4499
+ stage.index = this.#buildIndexForEntries(residentFileEntries, stage.sessionFile);
4500
+ }
4501
+
4109
4502
  #resetMaterializedCaches(): void {
4110
4503
  this.#materializedEntriesRevision = -1;
4111
4504
  this.#materializedEntriesCache = undefined;
@@ -4130,13 +4523,7 @@ export class SessionManager {
4130
4523
  * revision contract). Tests assert the invalidation mapping through this;
4131
4524
  * future export/label-view caches key off their respective domains.
4132
4525
  */
4133
- revisionSnapshot(): {
4134
- entry: number;
4135
- leaf: number;
4136
- headerExport: number;
4137
- label: number;
4138
- replayMetadata: number;
4139
- } {
4526
+ revisionSnapshot(): SessionManagerRevisionSnapshot {
4140
4527
  return {
4141
4528
  entry: this.#entryRevision,
4142
4529
  leaf: this.#leafRevision,
@@ -4260,6 +4647,7 @@ export class SessionManager {
4260
4647
 
4261
4648
  /** Switch to a different session file (used for resume and branching) */
4262
4649
  async setSessionFile(sessionFile: string): Promise<void> {
4650
+ this.#assertRecoveryHydrationWritable();
4263
4651
  const resolvedSessionFile = this.storage instanceof FileSessionStorage ? path.resolve(sessionFile) : sessionFile;
4264
4652
  const strictAdoption = this.#pendingStrictAdoption;
4265
4653
  this.#pendingStrictAdoption = undefined;
@@ -4313,10 +4701,496 @@ export class SessionManager {
4313
4701
 
4314
4702
  /** Start a new session. Closes any existing writer first. */
4315
4703
  async newSession(options?: NewSessionOptions): Promise<string | undefined> {
4704
+ this.#assertRecoveryHydrationWritable();
4705
+ const prepared = await this.prepareNewSession(options);
4706
+ try {
4707
+ this.commitPreparedNewSession(prepared);
4708
+ } catch (error) {
4709
+ try {
4710
+ await this.discardPreparedNewSession(prepared);
4711
+ } catch (cleanupError) {
4712
+ throw new AggregateError(
4713
+ [error, cleanupError],
4714
+ "New session adoption and staged session cleanup both failed.",
4715
+ );
4716
+ }
4717
+ throw error;
4718
+ }
4719
+ return prepared.sessionFile;
4720
+ }
4721
+
4722
+ /**
4723
+ * Allocate a fresh successor without publishing it through the manager's public
4724
+ * getters. The returned authority is deliberately immutable so readiness work
4725
+ * can resolve local:// against the successor while the predecessor stays live.
4726
+ * @internal
4727
+ */
4728
+ async prepareNewSession(options?: NewSessionOptions): Promise<PreparedNewSession> {
4729
+ this.#assertRecoveryHydrationWritable();
4730
+ await this.#retryPreparedNewSessionCleanups();
4316
4731
  await this.#closePersistWriter();
4317
- const sessionFile = this.#newSessionSync(options);
4732
+ const preallocated = this.#lifecycleIdAdopted ? undefined : lifecyclePreallocatedSessionId();
4733
+ const sessionId = preallocated ?? createSessionId();
4734
+ const timestamp = new Date().toISOString();
4735
+ const sessionFile = this.persist
4736
+ ? path.join(this.getSessionDir(), `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`)
4737
+ : undefined;
4738
+ const header: SessionHeader = {
4739
+ type: "session",
4740
+ version: CURRENT_SESSION_VERSION,
4741
+ id: sessionId,
4742
+ timestamp,
4743
+ cwd: this.cwd,
4744
+ parentSession: options?.parentSession,
4745
+ };
4746
+ const stage: PreparedNewSessionState = {
4747
+ sessionId,
4748
+ sessionFile,
4749
+ artifactsDir: sessionFile ? sessionFile.slice(0, -6) : null,
4750
+ managedLegacyLocalMigrationSource: this.#managedLegacyLocalMigrationSourceFor(sessionFile),
4751
+ header,
4752
+ fileEntries: [header],
4753
+ sessionName: undefined,
4754
+ titleSource: undefined,
4755
+ flushed: false,
4756
+ committed: false,
4757
+ discarded: false,
4758
+ };
4759
+ this.#preparedNewSessions.add(stage);
4760
+ this.#freezePreparedNewSessionIdentity(stage);
4761
+ return stage;
4762
+ }
4763
+
4764
+ /** Append a model selection to an unpublished successor. @internal */
4765
+ appendPreparedModelChange(prepared: PreparedNewSession, model: string): string {
4766
+ const stage = this.#getPreparedNewSessionStage(prepared);
4767
+ const entry: ModelChangeEntry = {
4768
+ type: "model_change",
4769
+ id: this.#nextPreparedNewSessionEntryId(stage),
4770
+ parentId: this.#preparedNewSessionLeafId(stage),
4771
+ timestamp: new Date().toISOString(),
4772
+ model,
4773
+ };
4774
+ stage.fileEntries.push(entry);
4775
+ return entry.id;
4776
+ }
4777
+
4778
+ /** Append a thinking-level selection to an unpublished successor. @internal */
4779
+ appendPreparedThinkingLevelChange(prepared: PreparedNewSession, thinkingLevel?: string): string {
4780
+ const stage = this.#getPreparedNewSessionStage(prepared);
4781
+ const entry: ThinkingLevelChangeEntry = {
4782
+ type: "thinking_level_change",
4783
+ id: this.#nextPreparedNewSessionEntryId(stage),
4784
+ parentId: this.#preparedNewSessionLeafId(stage),
4785
+ timestamp: new Date().toISOString(),
4786
+ thinkingLevel: thinkingLevel ?? null,
4787
+ };
4788
+ stage.fileEntries.push(entry);
4789
+ return entry.id;
4790
+ }
4791
+
4792
+ /** Append a service-tier selection to an unpublished successor. @internal */
4793
+ appendPreparedServiceTierChange(prepared: PreparedNewSession, serviceTier: ServiceTier | null): string {
4794
+ const stage = this.#getPreparedNewSessionStage(prepared);
4795
+ const entry: ServiceTierChangeEntry = {
4796
+ type: "service_tier_change",
4797
+ id: this.#nextPreparedNewSessionEntryId(stage),
4798
+ parentId: this.#preparedNewSessionLeafId(stage),
4799
+ timestamp: new Date().toISOString(),
4800
+ serviceTier,
4801
+ };
4802
+ stage.fileEntries.push(entry);
4803
+ return entry.id;
4804
+ }
4805
+
4806
+ /** Append a displayable custom message to an unpublished successor. @internal */
4807
+ appendPreparedCustomMessageEntry<T = unknown>(
4808
+ prepared: PreparedNewSession,
4809
+ customType: string,
4810
+ content: string | (TextContent | ImageContent)[],
4811
+ display: boolean,
4812
+ details?: T,
4813
+ attribution: MessageAttribution = "agent",
4814
+ ): string {
4815
+ const stage = this.#getPreparedNewSessionStage(prepared);
4816
+ const entry: CustomMessageEntry<T> = {
4817
+ type: "custom_message",
4818
+ customType,
4819
+ content,
4820
+ display,
4821
+ details: stripInternalDetailsFields(details),
4822
+ attribution,
4823
+ id: this.#nextPreparedNewSessionEntryId(stage),
4824
+ parentId: this.#preparedNewSessionLeafId(stage),
4825
+ timestamp: new Date().toISOString(),
4826
+ };
4827
+ stage.fileEntries.push(entry);
4828
+ return entry.id;
4829
+ }
4830
+
4831
+ /** Persist an unpublished successor without adopting it. @internal */
4832
+ async ensurePreparedNewSessionOnDisk(prepared: PreparedNewSession): Promise<void> {
4833
+ const stage = this.#getPreparedNewSessionStage(prepared);
4834
+ if (!this.persist || !stage.sessionFile || stage.flushed) return;
4835
+ const entries = await Promise.all(
4836
+ stage.fileEntries.map(entry => prepareEntryForPersistence(entry, this.#blobStore)),
4837
+ );
4838
+ if (this.destination.kind === "managed") {
4839
+ const bytes = Buffer.from(`${entries.map(entry => JSON.stringify(entry)).join("\n")}\n`, "utf8");
4840
+ await this.#managedTranscriptStore(stage.sessionFile).replace(path.basename(stage.sessionFile), bytes);
4841
+ } else {
4842
+ const staleCleanupError = await this.#cleanupPreparedNewSessionPersistence(stage);
4843
+ if (staleCleanupError) throw staleCleanupError;
4844
+ const dir = path.resolve(stage.sessionFile, "..");
4845
+ const tempPath = path.join(dir, `.${path.basename(stage.sessionFile)}.${Snowflake.next()}.tmp`);
4846
+ const writer = new NdjsonFileWriter(this.storage, tempPath, { flags: "w" });
4847
+ stage.persistenceTempPath = tempPath;
4848
+ stage.persistenceWriter = writer;
4849
+ try {
4850
+ for (const entry of entries) await writer.write(entry);
4851
+ await writer.flush();
4852
+ await writer.fsync();
4853
+ await writer.close();
4854
+ stage.persistenceWriter = undefined;
4855
+ await this.#replaceSessionFile(tempPath, stage.sessionFile);
4856
+ stage.persistenceTempPath = undefined;
4857
+ } catch (error) {
4858
+ const cleanupError = await this.#cleanupPreparedNewSessionPersistence(stage);
4859
+ if (cleanupError)
4860
+ throw new AggregateError(
4861
+ [toError(error), cleanupError],
4862
+ "Prepared session persistence and temporary-file cleanup both failed.",
4863
+ );
4864
+ throw toError(error);
4865
+ }
4866
+ }
4867
+ stage.flushed = true;
4868
+ }
4869
+
4870
+ /** Build context from an unpublished successor without reading active manager state. @internal */
4871
+ buildPreparedNewSessionContext(prepared: PreparedNewSession): SessionContext {
4872
+ const stage = this.#getPreparedNewSessionStage(prepared);
4873
+ return buildSessionContext(
4874
+ stage.fileEntries.filter((entry): entry is SessionEntry => entry.type !== "session"),
4875
+ undefined,
4876
+ undefined,
4877
+ stage.sessionId,
4878
+ );
4879
+ }
4880
+
4881
+ #freezePreparedNewSessionIdentity(stage: PreparedNewSessionState): void {
4882
+ // Prevent identity ABA: callers must not rebind staged sessionId/file after prepare.
4883
+ Object.defineProperty(stage, "sessionId", {
4884
+ value: stage.sessionId,
4885
+ writable: false,
4886
+ enumerable: true,
4887
+ configurable: false,
4888
+ });
4889
+ Object.defineProperty(stage, "sessionFile", {
4890
+ value: stage.sessionFile,
4891
+ writable: false,
4892
+ enumerable: true,
4893
+ configurable: false,
4894
+ });
4895
+ Object.defineProperty(stage, "artifactsDir", {
4896
+ value: stage.artifactsDir,
4897
+ writable: false,
4898
+ enumerable: true,
4899
+ configurable: false,
4900
+ });
4901
+ Object.defineProperty(stage, "managedLegacyLocalMigrationSource", {
4902
+ value: stage.managedLegacyLocalMigrationSource,
4903
+ writable: false,
4904
+ enumerable: true,
4905
+ configurable: false,
4906
+ });
4907
+ }
4908
+
4909
+ #getPreparedNewSessionStage(prepared: PreparedNewSession): PreparedNewSessionState {
4910
+ const stage = prepared as PreparedNewSessionState;
4911
+ if (!this.#preparedNewSessions.has(stage) || stage.committed || stage.discarded)
4912
+ throw new Error("Prepared session is no longer available.");
4913
+ return stage;
4914
+ }
4915
+
4916
+ #preparedNewSessionLeafId(stage: PreparedNewSessionState): string | null {
4917
+ for (let index = stage.fileEntries.length - 1; index >= 0; index--) {
4918
+ const entry = stage.fileEntries[index];
4919
+ if (entry.type !== "session") return entry.id;
4920
+ }
4921
+ return null;
4922
+ }
4923
+
4924
+ #nextPreparedNewSessionEntryId(stage: PreparedNewSessionState): string {
4925
+ const ids = new Map<string, SessionEntry>();
4926
+ for (const entry of stage.fileEntries) {
4927
+ if (entry.type !== "session") ids.set(entry.id, entry);
4928
+ }
4929
+ return generateId(ids);
4930
+ }
4931
+
4932
+ /** Prepare a forked successor without publishing it through public manager state. @internal */
4933
+ async prepareFork(): Promise<PreparedNewSession | undefined> {
4934
+ await this.#retryPreparedNewSessionCleanups();
4935
+ if (!this.persist || !this.#sessionFile) return undefined;
4936
+ await this.#closePersistWriter();
4937
+ const sourceFile = this.#sessionFile;
4938
+ const sourceId = this.#sessionId;
4939
+ const timestamp = new Date().toISOString();
4940
+ const sessionId = createSessionId();
4941
+ const sessionFile = path.join(this.getSessionDir(), `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`);
4942
+ const header: SessionHeader = {
4943
+ type: "session",
4944
+ version: CURRENT_SESSION_VERSION,
4945
+ id: sessionId,
4946
+ title: this.#sessionName,
4947
+ titleSource: this.#titleSource,
4948
+ timestamp,
4949
+ cwd: this.cwd,
4950
+ parentSession: sourceId,
4951
+ };
4952
+ const entries: FileEntry[] = [
4953
+ header,
4954
+ ...materializeResidentEntriesForReadSync(this.#fileEntries, this.#residentBlobStores()).filter(
4955
+ (entry): entry is SessionEntry => entry.type !== "session",
4956
+ ),
4957
+ ];
4958
+ const stage: PreparedNewSessionState = {
4959
+ sessionId,
4960
+ sessionFile,
4961
+ artifactsDir: sessionFile.slice(0, -6),
4962
+ managedLegacyLocalMigrationSource: this.#managedLegacyLocalMigrationSourceFor(sessionFile),
4963
+ header,
4964
+ fileEntries: entries,
4965
+ sessionName: this.#sessionName,
4966
+ titleSource: this.#titleSource,
4967
+ flushed: true,
4968
+ committed: false,
4969
+ discarded: false,
4970
+ };
4971
+ this.#preparedNewSessions.add(stage);
4972
+ this.#freezePreparedNewSessionIdentity(stage);
4973
+ try {
4974
+ await this.copyArtifactsForFork(sourceFile, sessionFile);
4975
+ const content = `${entries.map(entry => JSON.stringify(prepareEntryForPersistenceSync(entry, this.#blobStore))).join("\n")}\n`;
4976
+ if (this.destination.kind === "managed") {
4977
+ this.#managedTranscriptStore(sessionFile).publishNoReplaceSync(
4978
+ path.basename(sessionFile),
4979
+ Buffer.from(content, "utf8"),
4980
+ );
4981
+ } else {
4982
+ this.storage.writeTextSync(sessionFile, content);
4983
+ }
4984
+ } catch (error) {
4985
+ try {
4986
+ await this.discardPreparedNewSession(stage);
4987
+ } catch (cleanupError) {
4988
+ throw new AggregateError(
4989
+ [toError(error), toError(cleanupError)],
4990
+ "Fork preparation and staged session cleanup both failed.",
4991
+ );
4992
+ }
4993
+ throw toError(error);
4994
+ }
4995
+ return stage;
4996
+ }
4997
+
4998
+ /** Prepare a path-only branch successor without publishing it through public manager state. @internal */
4999
+ async prepareBranchedSession(leafId: string): Promise<PreparedNewSession> {
5000
+ this.#assertRecoveryHydrationWritable();
5001
+ await this.#retryPreparedNewSessionCleanups();
5002
+ const branchPath = this.#getCanonicalBranchClones(leafId);
5003
+ if (branchPath.length === 0) throw new Error(`Entry ${leafId} not found`);
5004
+ await this.#closePersistWriter();
5005
+ const timestamp = new Date().toISOString();
5006
+ const sessionId = createSessionId();
5007
+ const sessionFile = this.persist
5008
+ ? path.join(this.getSessionDir(), `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`)
5009
+ : undefined;
5010
+ const header: SessionHeader = {
5011
+ type: "session",
5012
+ version: CURRENT_SESSION_VERSION,
5013
+ id: sessionId,
5014
+ timestamp,
5015
+ cwd: this.cwd,
5016
+ parentSession: this.persist ? this.#sessionFile : undefined,
5017
+ };
5018
+ const pathWithoutLabels = branchPath.filter(entry => entry.type !== "label");
5019
+ const pathEntryIds = new Set(pathWithoutLabels.map(entry => entry.id));
5020
+ const labelEntries: LabelEntry[] = [];
5021
+ let labelParentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id ?? null;
5022
+ for (const [targetId, label] of this.#labelsById) {
5023
+ if (!pathEntryIds.has(targetId)) continue;
5024
+ const labelEntry: LabelEntry = {
5025
+ type: "label",
5026
+ id: generateId({ has: id => pathEntryIds.has(id) }),
5027
+ parentId: labelParentId,
5028
+ timestamp: new Date().toISOString(),
5029
+ targetId,
5030
+ label,
5031
+ };
5032
+ pathEntryIds.add(labelEntry.id);
5033
+ labelEntries.push(labelEntry);
5034
+ labelParentId = labelEntry.id;
5035
+ }
5036
+ const entries: FileEntry[] = [
5037
+ header,
5038
+ ...materializeResidentEntriesForReadSync(pathWithoutLabels, this.#residentBlobStores()),
5039
+ ...labelEntries,
5040
+ ];
5041
+ const stage: PreparedNewSessionState = {
5042
+ sessionId,
5043
+ sessionFile,
5044
+ artifactsDir: sessionFile ? sessionFile.slice(0, -6) : null,
5045
+ managedLegacyLocalMigrationSource: this.#managedLegacyLocalMigrationSourceFor(sessionFile),
5046
+ header,
5047
+ fileEntries: entries,
5048
+ sessionName: undefined,
5049
+ titleSource: undefined,
5050
+ flushed: this.persist,
5051
+ committed: false,
5052
+ discarded: false,
5053
+ };
5054
+ this.#preparedNewSessions.add(stage);
5055
+ this.#freezePreparedNewSessionIdentity(stage);
5056
+ try {
5057
+ if (sessionFile) {
5058
+ const content = `${entries.map(entry => JSON.stringify(prepareEntryForPersistenceSync(entry, this.#blobStore))).join("\n")}\n`;
5059
+ if (this.destination.kind === "managed") {
5060
+ this.#managedTranscriptStore(sessionFile).publishNoReplaceSync(
5061
+ path.basename(sessionFile),
5062
+ Buffer.from(content, "utf8"),
5063
+ );
5064
+ } else {
5065
+ this.storage.writeTextSync(sessionFile, content);
5066
+ }
5067
+ }
5068
+ return stage;
5069
+ } catch (error) {
5070
+ try {
5071
+ await this.discardPreparedNewSession(stage);
5072
+ } catch (cleanupError) {
5073
+ throw new AggregateError(
5074
+ [error, cleanupError],
5075
+ "Branch preparation and staged session cleanup both failed.",
5076
+ );
5077
+ }
5078
+ throw error;
5079
+ }
5080
+ }
5081
+
5082
+ /** Publish a prepared successor synchronously after all readiness awaits succeed. @internal */
5083
+ commitPreparedNewSession(prepared: PreparedNewSession): void {
5084
+ this.#assertRecoveryHydrationWritable();
5085
+ const stage = prepared as PreparedNewSessionState;
5086
+ if (!this.#preparedNewSessions.has(stage) || stage.committed || stage.discarded) {
5087
+ throw new Error("Prepared session is no longer available.");
5088
+ }
5089
+ this.#preparePreparedNewSessionForCommit(stage);
5090
+ const residentFileEntries = stage.residentFileEntries;
5091
+ const residentTextBlobStore = stage.residentTextBlobStore;
5092
+ const index = stage.index;
5093
+ if (!residentFileEntries || !residentTextBlobStore || !index)
5094
+ throw new Error("Prepared session adoption is incomplete.");
5095
+ if (stage.sessionFile) writeTerminalBreadcrumb(this.cwd, stage.sessionFile);
5096
+
5097
+ stage.committed = true;
5098
+ this.#preparedNewSessions.delete(stage);
5099
+ if (!this.#lifecycleIdAdopted && stage.sessionId === lifecyclePreallocatedSessionId())
5100
+ this.#lifecycleIdAdopted = true;
5101
+ this.#persistChain = Promise.resolve();
5102
+ this.#persistError = undefined;
5103
+ this.#persistErrorReported = false;
5104
+ this.#sessionId = stage.sessionId;
5105
+ this.#sessionName = stage.sessionName;
5106
+ this.#titleSource = stage.titleSource;
5107
+ this.#sessionFile = stage.sessionFile;
5108
+ this.#fileEntries = residentFileEntries;
5109
+ this.#residentTextBlobStore = residentTextBlobStore;
5110
+ this.#byId = index.byId;
5111
+ this.#labelsById = index.labelsById;
5112
+ this.#leafId = index.leafId;
5113
+ this.#usageStatistics = index.usageStatistics;
5114
+ this.#flushed = stage.flushed;
5115
+ this.#needsFullRewriteOnNextPersist = false;
5116
+ this.#ensuredOnDisk = stage.flushed;
5117
+ this.#inMemoryArtifacts = null;
5118
+ this.#inMemoryArtifactCounter = 0;
5119
+ this.#artifactManager = null;
5120
+ this.#artifactManagerSessionFile = null;
5121
+ this.#adoptedArtifactManager = null;
4318
5122
  this.#bumpAllRevisions();
4319
- return sessionFile;
5123
+ }
5124
+
5125
+ /** Exact-discard only an uncommitted successor prepared by this manager. @internal */
5126
+ async discardPreparedNewSession(prepared: PreparedNewSession): Promise<void> {
5127
+ const stage = prepared as PreparedNewSessionState;
5128
+ if (!this.#preparedNewSessions.has(stage) || stage.committed || stage.discarded) return;
5129
+ const persistenceCleanupError = await this.#cleanupPreparedNewSessionPersistence(stage);
5130
+ let sessionCleanupError: Error | undefined;
5131
+ if (stage.sessionFile) {
5132
+ try {
5133
+ await this.discardUncommittedSession(stage.sessionFile);
5134
+ } catch (error) {
5135
+ sessionCleanupError = toError(error);
5136
+ }
5137
+ }
5138
+ if (persistenceCleanupError || sessionCleanupError) {
5139
+ const errors = [persistenceCleanupError, sessionCleanupError].filter(
5140
+ (error): error is Error => error !== undefined,
5141
+ );
5142
+ throw errors.length === 1 ? errors[0] : new AggregateError(errors, "Prepared session cleanup failed.");
5143
+ }
5144
+ this.#preparedNewSessions.delete(stage);
5145
+ stage.discarded = true;
5146
+ }
5147
+
5148
+ async #retryPreparedNewSessionCleanups(): Promise<void> {
5149
+ if (this.#preparedNewSessionCleanupInProgress) return;
5150
+ this.#preparedNewSessionCleanupInProgress = true;
5151
+ try {
5152
+ const errors: Error[] = [];
5153
+ for (const stage of [...this.#preparedNewSessions]) {
5154
+ if (stage.committed || stage.discarded) continue;
5155
+ try {
5156
+ await this.discardPreparedNewSession(stage);
5157
+ } catch (error) {
5158
+ errors.push(toError(error));
5159
+ }
5160
+ }
5161
+ if (errors.length > 0)
5162
+ throw errors.length === 1
5163
+ ? errors[0]
5164
+ : new AggregateError(errors, "Prepared session cleanup retry failed.");
5165
+ } finally {
5166
+ this.#preparedNewSessionCleanupInProgress = false;
5167
+ }
5168
+ }
5169
+
5170
+ async #cleanupPreparedNewSessionPersistence(stage: PreparedNewSessionState): Promise<Error | undefined> {
5171
+ const errors: Error[] = [];
5172
+ if (stage.persistenceWriter) {
5173
+ try {
5174
+ await stage.persistenceWriter.close();
5175
+ stage.persistenceWriter = undefined;
5176
+ } catch (error) {
5177
+ errors.push(toError(error));
5178
+ }
5179
+ }
5180
+ if (stage.persistenceTempPath && !stage.persistenceWriter) {
5181
+ try {
5182
+ await this.storage.unlink(stage.persistenceTempPath);
5183
+ stage.persistenceTempPath = undefined;
5184
+ } catch (error) {
5185
+ if (!isEnoent(error)) errors.push(toError(error));
5186
+ else stage.persistenceTempPath = undefined;
5187
+ }
5188
+ }
5189
+ return errors.length === 0
5190
+ ? undefined
5191
+ : errors.length === 1
5192
+ ? errors[0]
5193
+ : new AggregateError(errors, "Prepared session temporary-file cleanup failed.");
4320
5194
  }
4321
5195
 
4322
5196
  /** Tombstone and exact-delete managed transcripts, detaching the active transcript first. */
@@ -4962,41 +5836,73 @@ export class SessionManager {
4962
5836
  }
4963
5837
 
4964
5838
  #buildIndex(): void {
4965
- this.#byId.clear();
4966
- this.#labelsById.clear();
4967
- this.#leafId = null;
4968
- this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
5839
+ const index = this.#buildIndexForEntries(this.#fileEntries, this.#sessionFile);
5840
+ this.#byId = index.byId;
5841
+ this.#labelsById = index.labelsById;
5842
+ this.#leafId = index.leafId;
5843
+ this.#usageStatistics = index.usageStatistics;
5844
+ }
5845
+
5846
+ #buildIndexForEntries(fileEntries: FileEntry[], sessionFile: string | undefined): PreparedSessionIndex {
5847
+ const byId = new Map<string, SessionEntry>();
5848
+ const labelsById = new Map<string, string>();
5849
+ let leafId: string | null = null;
5850
+ const usageStatistics: UsageStatistics = {
5851
+ input: 0,
5852
+ output: 0,
5853
+ cacheRead: 0,
5854
+ cacheWrite: 0,
5855
+ premiumRequests: 0,
5856
+ cost: 0,
5857
+ };
5858
+ const addUsage = (totals: ValidatedUsageTotals): boolean => {
5859
+ const next = {
5860
+ input: usageStatistics.input + totals.input,
5861
+ output: usageStatistics.output + totals.output,
5862
+ cacheRead: usageStatistics.cacheRead + totals.cacheRead,
5863
+ cacheWrite: usageStatistics.cacheWrite + totals.cacheWrite,
5864
+ premiumRequests: usageStatistics.premiumRequests + totals.premiumRequests,
5865
+ cost: usageStatistics.cost + totals.cost,
5866
+ };
5867
+ if (Object.values(next).some(value => !Number.isFinite(value))) return false;
5868
+ Object.assign(usageStatistics, next);
5869
+ return true;
5870
+ };
5871
+ const accumulateUsage = (entry: SessionEntry): boolean => {
5872
+ if (entry.type !== "message") return false;
5873
+ const usage =
5874
+ entry.message.role === "assistant"
5875
+ ? entry.message.usage
5876
+ : entry.message.role === "toolResult" && entry.message.toolName === "task"
5877
+ ? getTaskToolUsage(entry.message.details)
5878
+ : undefined;
5879
+ if (usage === undefined) return false;
5880
+ const totals = validatePersistedUsageTotals(usage);
5881
+ return !(totals && addUsage(totals));
5882
+ };
4969
5883
  let malformedUsageRecords = 0;
4970
5884
  const malformedUsageSample: string[] = [];
4971
- for (const entry of this.#fileEntries) {
5885
+ for (const entry of fileEntries) {
4972
5886
  if (entry.type === "session") continue;
4973
- this.#byId.set(entry.id, entry);
4974
- this.#leafId = entry.id;
5887
+ byId.set(entry.id, entry);
5888
+ leafId = entry.id;
4975
5889
  if (entry.type === "label") {
4976
- if (entry.label) {
4977
- this.#labelsById.set(entry.targetId, entry.label);
4978
- } else {
4979
- this.#labelsById.delete(entry.targetId);
4980
- }
5890
+ if (entry.label) labelsById.set(entry.targetId, entry.label);
5891
+ else labelsById.delete(entry.targetId);
4981
5892
  }
4982
- // Aggregate this entry's persisted usage through the shared validated,
4983
- // overflow-guarded path (see #accumulateEntryUsage). parseSessionEntries accepts
4984
- // any parseable JSON, so a torn/corrupt record can carry a still-valid-JSON but
4985
- // malformed `usage` (absent, {}, numeric strings, negatives, a non-record cost, or
4986
- // a cumulative overflow); such a record is skipped and reported below instead of
4987
- // poisoning every getUsageStatistics() consumer.
4988
- if (this.#accumulateEntryUsage(entry)) {
5893
+ if (accumulateUsage(entry)) {
4989
5894
  malformedUsageRecords++;
4990
5895
  if (malformedUsageSample.length < MALFORMED_USAGE_SAMPLE_LIMIT) malformedUsageSample.push(entry.id);
4991
5896
  }
4992
5897
  }
4993
5898
  if (malformedUsageRecords > 0) {
4994
5899
  logger.warn("Skipped malformed or overflowing persisted usage records during resume aggregation", {
4995
- sessionFile: this.#sessionFile,
5900
+ sessionFile,
4996
5901
  count: malformedUsageRecords,
4997
5902
  sampleEntryIds: malformedUsageSample,
4998
5903
  });
4999
5904
  }
5905
+ return { byId, labelsById, leafId, usageStatistics };
5000
5906
  }
5001
5907
 
5002
5908
  /**
@@ -5542,6 +6448,15 @@ export class SessionManager {
5542
6448
 
5543
6449
  /** Close the persistent writer after flushing all pending data. */
5544
6450
  async close(): Promise<void> {
6451
+ // Drain any uncommitted prepared successors before releasing resources so
6452
+ // dispose/shutdown retains exact cleanup authority (#3138).
6453
+ try {
6454
+ await this.#retryPreparedNewSessionCleanups();
6455
+ } catch (error) {
6456
+ logger.warn("Prepared session cleanup during close failed; retained for retry", {
6457
+ error: toError(error).message,
6458
+ });
6459
+ }
5545
6460
  await this.#queuePersistTask(async () => {
5546
6461
  if (this.#persistWriter) {
5547
6462
  await this.#closePersistWriterInternal();
@@ -5566,6 +6481,14 @@ export class SessionManager {
5566
6481
  * writer closure before any destructive operation.
5567
6482
  */
5568
6483
  async closeStrict(): Promise<SessionManagerCloseOutcome> {
6484
+ // Drain staged successors on the strict ACP dispose path as well as best-effort close (#3138).
6485
+ try {
6486
+ await this.#retryPreparedNewSessionCleanups();
6487
+ } catch (error) {
6488
+ logger.warn("Prepared session cleanup during closeStrict failed; retained for retry", {
6489
+ error: toError(error).message,
6490
+ });
6491
+ }
5569
6492
  let outcome: SessionManagerCloseOutcome = { kind: "closed" };
5570
6493
  await this.#queuePersistTask(async () => {
5571
6494
  const writer = this.#persistWriter;
@@ -5651,6 +6574,160 @@ export class SessionManager {
5651
6574
  return this.#sessionFile;
5652
6575
  }
5653
6576
 
6577
+ acquireMemoryGuardParticipantIngressLease(): MemoryGuardParticipantIngressLease {
6578
+ if (this.#memoryGuardParticipantIngressToken)
6579
+ throw new Error("memory_guard_participant_ingress_lease_already_held");
6580
+ const token = Symbol("memory_guard_participant_ingress_lease");
6581
+ this.#memoryGuardParticipantIngressToken = token;
6582
+ let released = false;
6583
+ return Object.freeze({
6584
+ token,
6585
+ release: () => {
6586
+ if (released) return;
6587
+ released = true;
6588
+ if (this.#memoryGuardParticipantIngressToken === token)
6589
+ this.#memoryGuardParticipantIngressToken = undefined;
6590
+ },
6591
+ });
6592
+ }
6593
+
6594
+ #assertMemoryGuardParticipantIngressLease(lease: MemoryGuardParticipantIngressLease): void {
6595
+ if (this.#memoryGuardParticipantIngressToken !== lease.token) {
6596
+ throw new Error("memory_guard_participant_ingress_lease_invalid");
6597
+ }
6598
+ }
6599
+
6600
+ #stageMemoryGuardCheckpointBlobs(blobs: Map<string, Buffer>): void {
6601
+ const stagedImageStore = new MemoryBlobStore();
6602
+ for (const blob of blobs.values()) {
6603
+ this.#residentTextBlobStore.putSync(blob);
6604
+ stagedImageStore.putSync(blob);
6605
+ }
6606
+ this.#residentImageBlobStore = stagedImageStore;
6607
+ this.#memoryGuardCheckpointBlobs = blobs;
6608
+ }
6609
+
6610
+ async createMemoryGuardCheckpoint(
6611
+ input: MemoryGuardCreateCheckpointInput,
6612
+ ): Promise<MemoryGuardSessionManagerCheckpointV1> {
6613
+ this.#assertMemoryGuardParticipantIngressLease(input.ingressLease);
6614
+ if (!this.#sessionFile) throw new Error("memory_guard_checkpoint_session_file_unavailable");
6615
+ await this.flush();
6616
+ const captured = SessionManager.captureTranscriptStrict(this.#sessionFile, this.storage);
6617
+ if (captured.kind !== "captured") {
6618
+ throw new Error(`memory_guard_checkpoint_capture_failed:${captured.reason}`);
6619
+ }
6620
+ if (captured.snapshot.content.byteLength > MEMORY_GUARD_CHECKPOINT_FILE_MAX_BYTES)
6621
+ throw new Error("memory_guard_checkpoint_transcript_capacity_exceeded");
6622
+ const transcriptText = decodeCheckpointUtf8(captured.snapshot.content);
6623
+ if (transcriptText === null) throw new Error("memory_guard_checkpoint_transcript_unreadable");
6624
+ const entries = parseSessionEntries(transcriptText);
6625
+ const sessionId = this.getSessionId();
6626
+ assertMemoryGuardSessionId(sessionId);
6627
+ const sessionName = this.getSessionName() ?? null;
6628
+ const revisions = this.revisionSnapshot();
6629
+ const participantRoot = memoryGuardParticipantRoot(input.checkpointRoot, sessionId);
6630
+ const transcriptRelativePath = memoryGuardParticipantRelativePath(sessionId, "transcript.jsonl");
6631
+ const blobRootRelativePath = memoryGuardParticipantRelativePath(sessionId, "blobs");
6632
+ const blobManifestRelativePath = memoryGuardParticipantRelativePath(sessionId, "blob-manifest.json");
6633
+ const refs = [...collectCheckpointBlobRefs(entries)].sort();
6634
+ if (refs.length > MEMORY_GUARD_CHECKPOINT_BLOB_MAX_ENTRIES)
6635
+ throw new Error("memory_guard_checkpoint_blob_count_exceeded");
6636
+ const blobManifestEntries: MemoryGuardCheckpointBlobManifestEntryV1[] = [];
6637
+ const blobWrites: Array<{ data: Buffer; relativePath: string }> = [];
6638
+ let aggregateBlobBytes = 0;
6639
+ for (const ref of refs) {
6640
+ const hash = parseBlobRef(ref);
6641
+ if (!hash) continue;
6642
+ const data =
6643
+ this.#residentTextBlobStore.getCheckedSync(hash) ??
6644
+ this.#residentImageBlobStore.getCheckedSync(hash) ??
6645
+ this.#blobStore.getCheckedSync(hash);
6646
+ if (!data) throw new Error(`memory_guard_checkpoint_blob_missing:${hash}`);
6647
+ if (data.byteLength > MEMORY_GUARD_CHECKPOINT_FILE_MAX_BYTES)
6648
+ throw new Error(`memory_guard_checkpoint_blob_capacity_exceeded:${hash}`);
6649
+ aggregateBlobBytes += data.byteLength;
6650
+ if (aggregateBlobBytes > MEMORY_GUARD_CHECKPOINT_BLOB_TOTAL_MAX_BYTES)
6651
+ throw new Error("memory_guard_checkpoint_blob_total_capacity_exceeded");
6652
+ const relativePath = hash;
6653
+ blobWrites.push({ data, relativePath });
6654
+ blobManifestEntries.push({
6655
+ bytes: String(data.byteLength),
6656
+ relative_path: relativePath,
6657
+ sha256: memoryGuardSha256Hex(data),
6658
+ });
6659
+ }
6660
+ const publishedCheckpointPaths: string[] = [];
6661
+ const cleanupPublishedCheckpointPaths = async (): Promise<void> => {
6662
+ for (const publishedPath of publishedCheckpointPaths.reverse())
6663
+ await fs.promises.rm(publishedPath, { force: true }).catch(() => undefined);
6664
+ await fs.promises.rmdir(path.join(input.checkpointRoot, blobRootRelativePath)).catch(() => undefined);
6665
+ await fs.promises.rmdir(participantRoot).catch(() => undefined);
6666
+ };
6667
+ const publishCheckpointFile = async (filePath: string, data: Uint8Array | string): Promise<void> => {
6668
+ try {
6669
+ await writeOwnerOnlyFileNoReplace(filePath, data);
6670
+ publishedCheckpointPaths.push(filePath);
6671
+ } catch (error) {
6672
+ await cleanupPublishedCheckpointPaths();
6673
+ throw error;
6674
+ }
6675
+ };
6676
+ await ensureOwnerOnlyDirectory(participantRoot);
6677
+ await publishCheckpointFile(path.join(input.checkpointRoot, transcriptRelativePath), captured.snapshot.content);
6678
+ for (const write of blobWrites)
6679
+ await publishCheckpointFile(
6680
+ path.join(input.checkpointRoot, blobRootRelativePath, write.relativePath),
6681
+ write.data,
6682
+ );
6683
+ blobManifestEntries.sort((left, right) => left.relative_path.localeCompare(right.relative_path));
6684
+ const blobManifest: MemoryGuardCheckpointBlobManifestV1 = {
6685
+ entries: blobManifestEntries,
6686
+ schema_version: 1,
6687
+ };
6688
+ const blobManifestText = memoryGuardCanonicalJson(blobManifest);
6689
+ await publishCheckpointFile(path.join(input.checkpointRoot, blobManifestRelativePath), blobManifestText);
6690
+ const checkpoint: MemoryGuardSessionManagerCheckpointV1 = {
6691
+ blob_authority: {
6692
+ kind: "checkpoint_blob_tree_v1",
6693
+ manifest_relative_path: blobManifestRelativePath,
6694
+ manifest_sha256: memoryGuardSha256Hex(blobManifestText),
6695
+ root_relative_path: blobRootRelativePath,
6696
+ },
6697
+ revisions: toSessionManagerCheckpointRevisionStrings(revisions),
6698
+ schema_version: 1,
6699
+ session_id: sessionId,
6700
+ session_name: sessionName,
6701
+ transcript: {
6702
+ bytes: String(captured.snapshot.content.byteLength),
6703
+ relative_path: transcriptRelativePath,
6704
+ sha256: captured.snapshot.identity.sha256,
6705
+ },
6706
+ };
6707
+ const checkpointPath = path.join(
6708
+ input.checkpointRoot,
6709
+ memoryGuardParticipantRelativePath(sessionId, "session-manager.json"),
6710
+ );
6711
+ await publishCheckpointFile(checkpointPath, memoryGuardCanonicalJson(checkpoint));
6712
+ const currentRevisions = this.revisionSnapshot();
6713
+ const recaptured = SessionManager.captureTranscriptStrict(this.#sessionFile, this.storage);
6714
+ if (
6715
+ this.getSessionId() !== sessionId ||
6716
+ (this.getSessionName() ?? null) !== sessionName ||
6717
+ Object.keys(revisions).some(
6718
+ key =>
6719
+ currentRevisions[key as keyof SessionManagerRevisionSnapshot] !==
6720
+ revisions[key as keyof SessionManagerRevisionSnapshot],
6721
+ ) ||
6722
+ recaptured.kind !== "captured" ||
6723
+ !sameResumeIdentity(captured.snapshot.identity, recaptured.snapshot.identity)
6724
+ ) {
6725
+ await cleanupPublishedCheckpointPaths();
6726
+ throw new Error("memory_guard_checkpoint_state_changed_during_capture");
6727
+ }
6728
+ return checkpoint;
6729
+ }
6730
+
5654
6731
  /**
5655
6732
  * Returns the session artifacts directory path (session file path without .jsonl).
5656
6733
  * Returns null when the session is not persisted to a file.
@@ -5670,9 +6747,13 @@ export class SessionManager {
5670
6747
 
5671
6748
  /** Supplies opaque retained authority for mandatory managed legacy local migration. */
5672
6749
  getManagedLegacyLocalMigrationSource(): ManagedLegacyLocalMigrationSource | null {
5673
- if (this.destination.kind !== "managed" || !this.#sessionFile || this.#adoptedArtifactManager) return null;
5674
- const store = this.#managedTranscriptStore();
5675
- const legacyArtifactsRoot = path.basename(this.#sessionFile.slice(0, -6));
6750
+ return this.#managedLegacyLocalMigrationSourceFor(this.#sessionFile);
6751
+ }
6752
+
6753
+ #managedLegacyLocalMigrationSourceFor(sessionFile: string | undefined): ManagedLegacyLocalMigrationSource | null {
6754
+ if (this.destination.kind !== "managed" || !sessionFile || this.#adoptedArtifactManager) return null;
6755
+ const store = this.#managedTranscriptStore(sessionFile);
6756
+ const legacyArtifactsRoot = path.basename(sessionFile.slice(0, -6));
5676
6757
  const legacyLocalRoot = path.posix.join(legacyArtifactsRoot, "local");
5677
6758
  return {
5678
6759
  capture: async () => {
@@ -5908,7 +6989,11 @@ export class SessionManager {
5908
6989
  * @param source - "user" for explicit renames (/rename command, RPC); "auto" for generated titles.
5909
6990
  * Auto-generated titles are silently ignored when the user has already set a name.
5910
6991
  */
6992
+ #assertRecoveryHydrationWritable(): void {
6993
+ if (this.#recoveryHydrationContext) throw new Error("recovery_hydration_not_promoted");
6994
+ }
5911
6995
  async setSessionName(name: string, source: "auto" | "user" = "auto"): Promise<boolean> {
6996
+ this.#assertRecoveryHydrationWritable();
5912
6997
  // User-set names take permanent precedence over auto-generated ones.
5913
6998
  if (this.#titleSource === "user" && source === "auto") return false;
5914
6999
 
@@ -6034,6 +7119,7 @@ export class SessionManager {
6034
7119
  }
6035
7120
 
6036
7121
  #appendEntry(entry: SessionEntry): void {
7122
+ this.#assertRecoveryHydrationWritable();
6037
7123
  const normalizedEntry = normalizeSessionEntryForStorage(entry);
6038
7124
  const residentEntry = prepareEntryForResidentSync(normalizedEntry, this.#residentBlobStores()) as SessionEntry;
6039
7125
  this.#fileEntries.push(residentEntry);
@@ -6252,6 +7338,7 @@ export class SessionManager {
6252
7338
  * the canonical store. This applies such mutations for real.
6253
7339
  */
6254
7340
  applyEntryMessageUpdates(entries: readonly SessionMessageEntry[]): void {
7341
+ this.#assertRecoveryHydrationWritable();
6255
7342
  for (const updated of entries) {
6256
7343
  const canonical = this.#byId.get(updated.id);
6257
7344
  if (canonical?.type !== "message") continue;
@@ -6268,6 +7355,7 @@ export class SessionManager {
6268
7355
 
6269
7356
  /** Write mutated custom-message entries back into the canonical entry store by id. */
6270
7357
  applyCustomMessageEntryUpdates(entries: readonly CustomMessageEntry[]): void {
7358
+ this.#assertRecoveryHydrationWritable();
6271
7359
  for (const updated of entries) {
6272
7360
  const canonical = this.#byId.get(updated.id);
6273
7361
  if (canonical?.type !== "custom_message") continue;
@@ -6287,6 +7375,7 @@ export class SessionManager {
6287
7375
  * Use sparingly (e.g., pruning old tool outputs).
6288
7376
  */
6289
7377
  async rewriteEntries(): Promise<void> {
7378
+ this.#assertRecoveryHydrationWritable();
6290
7379
  if (!this.persist || !this.#sessionFile) return;
6291
7380
  await this.#rewriteFile();
6292
7381
  }
@@ -6452,6 +7541,7 @@ export class SessionManager {
6452
7541
  }
6453
7542
 
6454
7543
  evictCompactedContent(firstKeptEntryId: string, compactionEntryId: string): EvictCompactedContentResult {
7544
+ this.#assertRecoveryHydrationWritable();
6455
7545
  const firstKept = this.#byId.get(firstKeptEntryId);
6456
7546
  const compaction = this.#byId.get(compactionEntryId);
6457
7547
  if (!firstKept) throw new Error(`Entry ${firstKeptEntryId} not found`);
@@ -6602,7 +7692,7 @@ export class SessionManager {
6602
7692
  ? cloneSessionEntry(
6603
7693
  rehydrateColdSpillEntry(
6604
7694
  materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), new Map()),
6605
- this.#blobStore,
7695
+ this.#coldSpillReadStore(),
6606
7696
  this.#residentBlobStoresForColdRehydrate(),
6607
7697
  ),
6608
7698
  )
@@ -6621,7 +7711,7 @@ export class SessionManager {
6621
7711
  cloneSessionEntry(
6622
7712
  rehydrateColdSpillEntry(
6623
7713
  materializeResidentEntryForReadSync(current, this.#residentBlobStores(), cache),
6624
- this.#blobStore,
7714
+ this.#coldSpillReadStore(),
6625
7715
  this.#residentBlobStoresForColdRehydrate(),
6626
7716
  ),
6627
7717
  ),
@@ -6663,7 +7753,7 @@ export class SessionManager {
6663
7753
  cloneSessionEntry(
6664
7754
  rehydrateColdSpillEntry(
6665
7755
  materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), cache),
6666
- this.#blobStore,
7756
+ this.#coldSpillReadStore(),
6667
7757
  this.#residentBlobStoresForColdRehydrate(),
6668
7758
  ),
6669
7759
  ),
@@ -6829,7 +7919,7 @@ export class SessionManager {
6829
7919
  const materialized = materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), new Map());
6830
7920
  const rehydrated = rehydrateColdSpillEntry(
6831
7921
  materialized,
6832
- this.#blobStore,
7922
+ this.#coldSpillReadStore(),
6833
7923
  this.#residentBlobStoresForColdRehydrate(),
6834
7924
  );
6835
7925
  if (rehydrated !== materialized) this.#coldSpillReadCount += this.#countColdSpillPayloads(entry);
@@ -6845,6 +7935,7 @@ export class SessionManager {
6845
7935
  }
6846
7936
  /** Strip stale OpenAI Responses assistant replay metadata from loaded in-memory entries without persisting it. */
6847
7937
  sanitizeLoadedOpenAIResponsesReplayMetadata(): boolean {
7938
+ this.#assertRecoveryHydrationWritable();
6848
7939
  return this.#sanitizeLoadedOpenAIResponsesReplayMetadata().length > 0;
6849
7940
  }
6850
7941
 
@@ -7000,6 +8091,7 @@ export class SessionManager {
7000
8091
  * are not modified or deleted.
7001
8092
  */
7002
8093
  branch(branchFromId: string): void {
8094
+ this.#assertRecoveryHydrationWritable();
7003
8095
  if (!this.#byId.has(branchFromId)) {
7004
8096
  throw new Error(`Entry ${branchFromId} not found`);
7005
8097
  }
@@ -7013,6 +8105,7 @@ export class SessionManager {
7013
8105
  * Use this when navigating to re-edit the first user message.
7014
8106
  */
7015
8107
  resetLeaf(): void {
8108
+ this.#assertRecoveryHydrationWritable();
7016
8109
  this.#leafId = null;
7017
8110
  this.#leafRevision++;
7018
8111
  }
@@ -7023,6 +8116,7 @@ export class SessionManager {
7023
8116
  * context from the abandoned conversation path.
7024
8117
  */
7025
8118
  branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromExtension?: boolean): string {
8119
+ this.#assertRecoveryHydrationWritable();
7026
8120
  if (branchFromId !== null && !this.#byId.has(branchFromId)) {
7027
8121
  throw new Error(`Entry ${branchFromId} not found`);
7028
8122
  }
@@ -7047,6 +8141,7 @@ export class SessionManager {
7047
8141
  * Returns the new session file path, or undefined if not persisting.
7048
8142
  */
7049
8143
  createBranchedSession(leafId: string): string | undefined {
8144
+ this.#assertRecoveryHydrationWritable();
7050
8145
  const previousSessionFile = this.#sessionFile;
7051
8146
  const branchPath = this.#getCanonicalBranchClones(leafId);
7052
8147
  if (branchPath.length === 0) {
@@ -7529,10 +8624,11 @@ export class SessionManager {
7529
8624
  if (resolved.kind === "error") return [];
7530
8625
  const listing = listManagedCandidates(resolved.scope);
7531
8626
  if (listing.kind === "error") return [];
7532
- return await collectSessionsFromFiles(
8627
+ const managed = await collectSessionsFromFiles(
7533
8628
  listing.owned.map(candidate => candidate.path),
7534
8629
  storage,
7535
8630
  );
8631
+ return mergeSessionInventories(managed, await collectProjectSessions(cwd, storage));
7536
8632
  }
7537
8633
 
7538
8634
  /**
@@ -7553,12 +8649,35 @@ export class SessionManager {
7553
8649
  }
7554
8650
  }
7555
8651
 
7556
- /** Tombstone and exact-delete a default-managed candidate. */
8652
+ /** Delete an authorized managed or project-local picker candidate. */
7557
8653
  static async deleteManagedCandidate(sessionPath: string): Promise<void> {
7558
8654
  const storage = new FileSessionStorage();
7559
8655
  const entries = await loadEntriesFromFile(sessionPath, storage);
7560
8656
  const header = entries.find(entry => entry.type === "session") as SessionHeader | undefined;
7561
- if (!header?.cwd) throw new Error("Session has no valid managed workspace header.");
8657
+ if (!header?.cwd) throw new Error("Session has no valid workspace header.");
8658
+ const projectGjcDir = path.join(path.resolve(header.cwd), ".gjc");
8659
+ if (isProjectSessionTranscriptPath(projectGjcDir, sessionPath)) {
8660
+ const relativePath = path.relative(projectGjcDir, path.resolve(sessionPath)).split(path.sep).join("/");
8661
+ const authority = native.openRecoveryFsRoot(projectGjcDir);
8662
+ try {
8663
+ const captured = authority.readManaged(relativePath);
8664
+ if (!captured.ok || !captured.identity || !captured.data || !captured.identity.sha256)
8665
+ throw new Error("Project session is no longer an authorized candidate.");
8666
+ const removed = authority.removeManaged(
8667
+ relativePath,
8668
+ captured.identity.dev,
8669
+ captured.identity.ino,
8670
+ captured.identity.size,
8671
+ captured.identity.mtimeNs,
8672
+ captured.identity.ctimeNs,
8673
+ captured.identity.sha256,
8674
+ );
8675
+ if (!removed.ok) throw new Error(removed.code ?? "Could not delete project session.");
8676
+ return;
8677
+ } finally {
8678
+ authority.close();
8679
+ }
8680
+ }
7562
8681
  const sessionsRoot = path.resolve(sessionPath, "../..");
7563
8682
  const resolved = resolveManagedScope({
7564
8683
  cwd: header.cwd,
@@ -7766,6 +8885,194 @@ export class SessionManager {
7766
8885
  }
7767
8886
  }
7768
8887
 
8888
+ static async restoreMemoryGuardCheckpoint(input: MemoryGuardRestoreInput): Promise<MemoryGuardRestoreResult> {
8889
+ try {
8890
+ assertMemoryGuardSessionId(input.checkpoint.session_id);
8891
+ } catch {
8892
+ return { kind: "blocked", reason: "checkpoint-mismatch" };
8893
+ }
8894
+ if (!validateMemoryGuardCheckpoint(input.checkpoint)) return { kind: "blocked", reason: "checkpoint-mismatch" };
8895
+ if (!memoryGuardParticipantMatchesCheckpoint(input.participant, input.checkpoint)) {
8896
+ return { kind: "blocked", reason: "participant-mismatch" };
8897
+ }
8898
+ const checkpointText = readCheckpointAuthorityFile(
8899
+ input.incidentAuthority,
8900
+ memoryGuardParticipantRelativePath(input.checkpoint.session_id, "session-manager.json"),
8901
+ 256 * 1024,
8902
+ );
8903
+ if (!checkpointText) return { kind: "blocked", reason: "checkpoint-mismatch" };
8904
+ const checkpointCanonical = memoryGuardCanonicalJson(input.checkpoint);
8905
+ if (decodeCheckpointUtf8(checkpointText) !== checkpointCanonical) {
8906
+ return { kind: "blocked", reason: "checkpoint-mismatch" };
8907
+ }
8908
+ const transcriptBytes = Number(input.checkpoint.transcript.bytes);
8909
+ if (!Number.isSafeInteger(transcriptBytes) || transcriptBytes < 0) {
8910
+ return { kind: "blocked", reason: "transcript-mismatch" };
8911
+ }
8912
+ const transcriptData = readCheckpointAuthorityFile(
8913
+ input.incidentAuthority,
8914
+ input.checkpoint.transcript.relative_path,
8915
+ transcriptBytes + 1,
8916
+ );
8917
+ if (!transcriptData || transcriptData.byteLength !== transcriptBytes) {
8918
+ return { kind: "blocked", reason: "transcript-mismatch" };
8919
+ }
8920
+ if (memoryGuardSha256Hex(transcriptData) !== input.checkpoint.transcript.sha256) {
8921
+ return { kind: "blocked", reason: "transcript-mismatch" };
8922
+ }
8923
+ const transcriptText = decodeCheckpointUtf8(transcriptData);
8924
+ if (transcriptText === null) return { kind: "blocked", reason: "transcript-mismatch" };
8925
+ let transcriptEntries: FileEntry[];
8926
+ try {
8927
+ transcriptEntries = parseSessionEntries(transcriptText);
8928
+ } catch {
8929
+ return { kind: "blocked", reason: "malformed" };
8930
+ }
8931
+ const transcriptHeader = transcriptEntries[0] as SessionHeader | undefined;
8932
+ if (
8933
+ transcriptHeader?.type !== "session" ||
8934
+ transcriptHeader.id !== input.checkpoint.session_id ||
8935
+ (transcriptHeader.title ?? null) !== input.checkpoint.session_name
8936
+ ) {
8937
+ return { kind: "blocked", reason: "transcript-mismatch" };
8938
+ }
8939
+ const manifestData = readCheckpointAuthorityFile(
8940
+ input.incidentAuthority,
8941
+ input.checkpoint.blob_authority.manifest_relative_path,
8942
+ 8 * 1024 * 1024,
8943
+ );
8944
+ if (!manifestData) return { kind: "blocked", reason: "blob-authority-mismatch" };
8945
+ if (memoryGuardSha256Hex(manifestData) !== input.checkpoint.blob_authority.manifest_sha256) {
8946
+ return { kind: "blocked", reason: "blob-authority-mismatch" };
8947
+ }
8948
+ const manifestText = decodeCheckpointUtf8(manifestData);
8949
+ if (manifestText === null) return { kind: "blocked", reason: "blob-manifest-mismatch" };
8950
+ let blobManifest: unknown;
8951
+ try {
8952
+ blobManifest = JSON.parse(manifestText);
8953
+ } catch {
8954
+ return { kind: "blocked", reason: "blob-manifest-mismatch" };
8955
+ }
8956
+ if (!validateMemoryGuardBlobManifest(blobManifest)) return { kind: "blocked", reason: "blob-manifest-mismatch" };
8957
+ if (memoryGuardCanonicalJson(blobManifest) !== manifestText)
8958
+ return { kind: "blocked", reason: "blob-manifest-mismatch" };
8959
+ if (blobManifest.entries.length > MEMORY_GUARD_CHECKPOINT_BLOB_MAX_ENTRIES)
8960
+ return { kind: "blocked", reason: "blob-manifest-mismatch" };
8961
+ let checkpointBlobBytes = 0;
8962
+ const seenBlobPaths = new Set<string>();
8963
+ const checkpointBlobs = new Map<string, Buffer>();
8964
+ for (const entry of blobManifest.entries) {
8965
+ if (
8966
+ entry.relative_path !== entry.sha256 ||
8967
+ seenBlobPaths.has(entry.relative_path) ||
8968
+ checkpointBlobs.has(entry.sha256)
8969
+ )
8970
+ return { kind: "blocked", reason: "blob-manifest-mismatch" };
8971
+ seenBlobPaths.add(entry.relative_path);
8972
+ const blobBytes = Number(entry.bytes);
8973
+ if (!Number.isSafeInteger(blobBytes) || blobBytes < 0)
8974
+ return { kind: "blocked", reason: "blob-manifest-mismatch" };
8975
+ checkpointBlobBytes += blobBytes;
8976
+ if (checkpointBlobBytes > MEMORY_GUARD_CHECKPOINT_BLOB_TOTAL_MAX_BYTES)
8977
+ return { kind: "blocked", reason: "blob-manifest-mismatch" };
8978
+ const blobPath = `${input.checkpoint.blob_authority.root_relative_path}/${entry.relative_path}`;
8979
+ const blobData = readCheckpointAuthorityFile(input.incidentAuthority, blobPath, blobBytes + 1);
8980
+ if (!blobData || blobData.byteLength !== blobBytes) return { kind: "blocked", reason: "blob-missing" };
8981
+ if (memoryGuardSha256Hex(blobData) !== entry.sha256) return { kind: "blocked", reason: "blob-hash-mismatch" };
8982
+ checkpointBlobs.set(entry.sha256, Buffer.from(blobData));
8983
+ }
8984
+ for (const ref of collectCheckpointBlobRefs(transcriptEntries)) {
8985
+ const hash = parseBlobRef(ref);
8986
+ if (!hash || !checkpointBlobs.has(hash)) return { kind: "blocked", reason: "blob-manifest-mismatch" };
8987
+ }
8988
+ const storage = new FileSessionStorage();
8989
+ let destination: SessionDestination;
8990
+ try {
8991
+ destination =
8992
+ input.destination === undefined
8993
+ ? explicitDestination(path.join(os.tmpdir(), `gjc-memory-guard-${input.checkpoint.session_id}`))
8994
+ : destinationFor(getProjectDir(), input.destination, storage);
8995
+ } catch {
8996
+ return { kind: "blocked", reason: "destination-unavailable" };
8997
+ }
8998
+ const transcriptPath = path.join(
8999
+ destination.directory,
9000
+ `.${input.checkpoint.session_id}.memory-guard.${crypto.randomUUID()}.jsonl`,
9001
+ );
9002
+ const transcriptName = path.basename(transcriptPath);
9003
+ const managedStagingStore =
9004
+ destination.kind === "managed"
9005
+ ? managedStoreFromContext(destination.securityContext, destination.directory)
9006
+ : undefined;
9007
+ const cleanupTranscript = async (): Promise<void> => {
9008
+ if (managedStagingStore) {
9009
+ await managedStagingStore.remove(transcriptName);
9010
+ return;
9011
+ }
9012
+ await fs.promises.rm(transcriptPath, { force: true });
9013
+ };
9014
+ let opened: RecoveryHydrationOpenResult;
9015
+ let transcriptIdentity: ResumeSessionIdentity;
9016
+ let createdDestination: CreatedDirectoryIdentity | undefined;
9017
+ try {
9018
+ if (managedStagingStore) await managedStagingStore.publishNoReplace(transcriptName, transcriptData);
9019
+ else {
9020
+ createdDestination = await ensureOwnerOnlyDirectoryTracked(destination.directory);
9021
+ await writeOwnerOnlyFileNoReplace(transcriptPath, transcriptData);
9022
+ }
9023
+ const captured = SessionManager.captureTranscriptStrict(transcriptPath, storage);
9024
+ if (captured.kind !== "captured") {
9025
+ await cleanupTranscript();
9026
+ await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
9027
+ return { kind: "blocked", reason: captured.reason };
9028
+ }
9029
+ transcriptIdentity = captured.snapshot.identity;
9030
+ opened = await SessionManager.openExistingForRecoveryHydrationStrict(
9031
+ transcriptIdentity,
9032
+ input.destination,
9033
+ storage,
9034
+ );
9035
+ } catch {
9036
+ await cleanupTranscript();
9037
+ await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
9038
+ return { kind: "blocked", reason: "destination-unavailable" };
9039
+ }
9040
+ if (opened.kind === "error") {
9041
+ await cleanupTranscript();
9042
+ await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
9043
+ return { kind: "blocked", reason: opened.reason };
9044
+ }
9045
+ if (
9046
+ opened.manager.getSessionId() !== input.checkpoint.session_id ||
9047
+ (opened.manager.getSessionName() ?? null) !== input.checkpoint.session_name
9048
+ ) {
9049
+ await opened.manager.close();
9050
+ await cleanupTranscript();
9051
+ await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
9052
+ return { kind: "blocked", reason: "transcript-mismatch" };
9053
+ }
9054
+ opened.manager.#stageMemoryGuardCheckpointBlobs(checkpointBlobs);
9055
+ opened.manager.#recoveryPromotionTranscriptPath = path.join(
9056
+ destination.directory,
9057
+ `${new Date().toISOString().replace(/[:.]/g, "-")}_${input.checkpoint.session_id}.jsonl`,
9058
+ );
9059
+ let cleanupConsumed = false;
9060
+ const cleanup = async (): Promise<void> => {
9061
+ if (cleanupConsumed) return;
9062
+ await opened.manager.close();
9063
+ await cleanupTranscript();
9064
+ await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
9065
+ cleanupConsumed = true;
9066
+ };
9067
+ return {
9068
+ kind: "staged",
9069
+ manager: opened.manager,
9070
+ hydrationContext: opened.context,
9071
+ transcriptIdentity,
9072
+ cleanup,
9073
+ };
9074
+ }
9075
+
7769
9076
  /** Inspect a selected session without acquiring write-capable ownership. */
7770
9077
  static async inspectSessionTailReadOnly(
7771
9078
  filePath: string,
@@ -7834,8 +9141,31 @@ export class SessionManager {
7834
9141
  if ("kind" in inspected || !sameResumeIdentity(context.identity, inspected.identity)) {
7835
9142
  throw new Error("Recovery transcript authority changed before promotion.");
7836
9143
  }
9144
+ if (this.#memoryGuardCheckpointBlobs) {
9145
+ for (const blob of this.#memoryGuardCheckpointBlobs.values()) this.#blobStore.putImmutableSync(blob);
9146
+ this.#residentImageBlobStore = this.#blobStore;
9147
+ this.#memoryGuardCheckpointBlobs = undefined;
9148
+ }
7837
9149
  await this.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
7838
- writeTerminalBreadcrumb(this.cwd, context.identity.canonicalPath);
9150
+ const promotionPath = this.#recoveryPromotionTranscriptPath;
9151
+ if (!promotionPath) throw new Error("Recovery transcript promotion path is unavailable.");
9152
+ const promotedSource = inspectResumeSessionFile(context.identity.canonicalPath, this.storage);
9153
+ if ("kind" in promotedSource) throw new Error("Recovery transcript became unavailable before publication.");
9154
+ if (this.destination.kind === "managed") {
9155
+ const store = this.#managedTranscriptStore();
9156
+ const sourceName = path.basename(context.identity.canonicalPath);
9157
+ const sourceSnapshot = store.readExpected(sourceName);
9158
+ if (!sourceSnapshot) throw new Error("Recovery transcript authority changed before publication.");
9159
+ await store.publishNoReplace(path.basename(promotionPath), promotedSource.content);
9160
+ store.removeExpected(sourceName, sourceSnapshot);
9161
+ } else {
9162
+ await writeOwnerOnlyFileNoReplace(promotionPath, promotedSource.content);
9163
+ await fs.promises.rm(context.identity.canonicalPath, { force: true });
9164
+ await fsyncDirectoryPath(path.dirname(context.identity.canonicalPath));
9165
+ }
9166
+ this.#sessionFile = promotionPath;
9167
+ this.#recoveryPromotionTranscriptPath = undefined;
9168
+ writeTerminalBreadcrumb(this.cwd, promotionPath);
7839
9169
  this.#recoveryHydrationContext = undefined;
7840
9170
  }
7841
9171