@gajae-code/coding-agent 0.18.7 → 0.18.8

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 (279) hide show
  1. package/CHANGELOG.md +279 -1
  2. package/dist/types/async/job-manager.d.ts +1 -1
  3. package/dist/types/capability/mcp.d.ts +2 -0
  4. package/dist/types/capability/skill.d.ts +2 -0
  5. package/dist/types/capability/types.d.ts +2 -0
  6. package/dist/types/cli/public-command-errors.d.ts +3 -1
  7. package/dist/types/cli/setup-cli.d.ts +1 -1
  8. package/dist/types/commands/deep-interview.d.ts +3 -0
  9. package/dist/types/config/autorouting-tier-map.d.ts +348 -9
  10. package/dist/types/config/file-lock.d.ts +1 -0
  11. package/dist/types/config/model-bindings-applier.d.ts +2 -0
  12. package/dist/types/config/model-profile-activation.d.ts +11 -3
  13. package/dist/types/config/model-registry.d.ts +4 -9
  14. package/dist/types/config/skill-settings-defaults.d.ts +2 -0
  15. package/dist/types/cursor-delete-path.d.ts +1 -0
  16. package/dist/types/cursor-mcp-name.d.ts +8 -0
  17. package/dist/types/customization/inventory.d.ts +2 -0
  18. package/dist/types/debug/report-bundle-parent.d.ts +2 -0
  19. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +1 -0
  20. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.d.ts +31 -0
  21. package/dist/types/defaults/gjc-skills.generated.d.ts +5 -6
  22. package/dist/types/discovery/helpers.d.ts +6 -1
  23. package/dist/types/export/html/css-color.d.ts +2 -0
  24. package/dist/types/export/html/template.generated.d.ts +1 -1
  25. package/dist/types/extensibility/extensions/types.d.ts +7 -0
  26. package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +14 -1
  27. package/dist/types/extensibility/plugins/marketplace/cache.d.ts +2 -0
  28. package/dist/types/extensibility/plugins/plugin-link-path.d.ts +2 -0
  29. package/dist/types/extensibility/runtime-skill-discovery.d.ts +3 -1
  30. package/dist/types/extensibility/skill-management.d.ts +3 -1
  31. package/dist/types/extensibility/skills.d.ts +2 -0
  32. package/dist/types/gjc-runtime/deep-interview-crystallize.d.ts +74 -0
  33. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +11 -0
  34. package/dist/types/gjc-runtime/gc-runtime.d.ts +5 -0
  35. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +0 -5
  36. package/dist/types/gjc-runtime/state-argv.d.ts +1 -1
  37. package/dist/types/gjc-runtime/state-migrations.d.ts +8 -0
  38. package/dist/types/gjc-runtime/state-runtime.d.ts +95 -0
  39. package/dist/types/gjc-runtime/state-writer.d.ts +11 -1
  40. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +8 -0
  41. package/dist/types/internal-urls/skill-path.d.ts +1 -0
  42. package/dist/types/lsp/workspace-path.d.ts +1 -0
  43. package/dist/types/modes/acp/acp-agent.d.ts +7 -0
  44. package/dist/types/modes/components/custom-editor.d.ts +6 -0
  45. package/dist/types/modes/components/customization/customization-dashboard.d.ts +1 -0
  46. package/dist/types/modes/components/extensions/state-manager.d.ts +4 -3
  47. package/dist/types/modes/controllers/input-controller.d.ts +9 -0
  48. package/dist/types/modes/interactive-mode.d.ts +1 -0
  49. package/dist/types/modes/utils/interrupt-ownership.d.ts +3 -0
  50. package/dist/types/modes/utils/keybinding-matchers.d.ts +1 -0
  51. package/dist/types/modes/utils/macos-option-forwarding.d.ts +17 -0
  52. package/dist/types/progress/collect-project-progress.d.ts +38 -0
  53. package/dist/types/progress/progress-contract.d.ts +154 -0
  54. package/dist/types/progress/progress-snapshot.d.ts +11 -0
  55. package/dist/types/progress/project-progress.d.ts +141 -0
  56. package/dist/types/progress/render-progress.d.ts +28 -0
  57. package/dist/types/runtime-mcp/oauth-public-url.d.ts +1 -0
  58. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +1 -0
  59. package/dist/types/runtime-mcp/smithery-registry.d.ts +24 -0
  60. package/dist/types/runtime-mcp/types.d.ts +6 -0
  61. package/dist/types/sdk/acp/adapter.d.ts +1 -1
  62. package/dist/types/sdk/broker/broker-exit.d.ts +6 -2
  63. package/dist/types/sdk/broker/broker.d.ts +18 -1
  64. package/dist/types/sdk/broker/session-index.d.ts +12 -6
  65. package/dist/types/sdk/bus/index.d.ts +12 -1
  66. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -1
  67. package/dist/types/sdk/client/client.d.ts +2 -0
  68. package/dist/types/sdk/host/query/handlers.d.ts +3 -0
  69. package/dist/types/sdk/index.d.ts +1 -0
  70. package/dist/types/sdk/router/session-router.d.ts +2 -0
  71. package/dist/types/sdk/session-list.d.ts +1 -1
  72. package/dist/types/sdk/session.d.ts +8 -0
  73. package/dist/types/session/agent-session.d.ts +38 -9
  74. package/dist/types/session/artifacts.d.ts +2 -2
  75. package/dist/types/session/internal/managed-gc-retirement-codec.d.ts +32 -0
  76. package/dist/types/session/internal/managed-session-scope.d.ts +79 -2
  77. package/dist/types/session/internal/managed-session-storage.d.ts +26 -1
  78. package/dist/types/session/internal/managed-task-owner-cleanup.d.ts +62 -0
  79. package/dist/types/session/internal/task-artifact-owner-access.d.ts +2 -0
  80. package/dist/types/session/internal/task-artifact-owner-transcript.d.ts +9 -1
  81. package/dist/types/session/session-manager.d.ts +31 -5
  82. package/dist/types/session/session-retirement.d.ts +31 -7
  83. package/dist/types/session/session-storage.d.ts +62 -2
  84. package/dist/types/session/task-artifact-owner-codec.d.ts +2 -0
  85. package/dist/types/setup/credential-auto-import.d.ts +1 -1
  86. package/dist/types/setup/credential-import.d.ts +6 -4
  87. package/dist/types/skill-state/active-state.d.ts +38 -0
  88. package/dist/types/slash-commands/helpers/progress-report.d.ts +8 -0
  89. package/dist/types/task/executor.d.ts +3 -0
  90. package/dist/types/tools/bash-skill-urls.d.ts +1 -1
  91. package/dist/types/tools/index.d.ts +3 -0
  92. package/dist/types/tools/python.d.ts +9 -1
  93. package/dist/types/tools/recipe/recipe-guard.d.ts +7 -0
  94. package/dist/types/tools/restricted-cwd.d.ts +1 -0
  95. package/dist/types/tools/todo-write.d.ts +6 -0
  96. package/dist/types/utils/tools-checksum.d.ts +7 -0
  97. package/package.json +7 -7
  98. package/scripts/generate-sdk-operation-inventory.ts +21 -0
  99. package/scripts/verify-managed-empty-stop.ts +28 -0
  100. package/src/async/job-manager.ts +228 -122
  101. package/src/capability/index.ts +17 -0
  102. package/src/capability/mcp.ts +2 -0
  103. package/src/capability/skill.ts +2 -0
  104. package/src/capability/types.ts +2 -0
  105. package/src/cli/auth-broker-cli.ts +1 -1
  106. package/src/cli/customize-doctor.ts +15 -2
  107. package/src/cli/public-command-errors.ts +21 -1
  108. package/src/cli/setup-cli.ts +4 -4
  109. package/src/cli/skills-cli.ts +5 -0
  110. package/src/commands/deep-interview.ts +47 -8
  111. package/src/commands/sdk.ts +28 -8
  112. package/src/config/autorouting-tier-map.ts +117 -7
  113. package/src/config/file-lock.ts +307 -68
  114. package/src/config/mcp-schema.json +4 -0
  115. package/src/config/model-bindings-applier.ts +20 -0
  116. package/src/config/model-profile-activation.ts +240 -46
  117. package/src/config/model-registry.ts +47 -153
  118. package/src/config/skill-settings-defaults.ts +7 -0
  119. package/src/coordinator-mcp/policy.ts +3 -2
  120. package/src/cursor-delete-path.ts +10 -0
  121. package/src/cursor-mcp-name.ts +15 -0
  122. package/src/cursor.ts +13 -2
  123. package/src/customization/inventory.ts +3 -0
  124. package/src/debug/report-bundle-parent.ts +22 -0
  125. package/src/debug/report-bundle.ts +3 -0
  126. package/src/debug/system-info.ts +13 -1
  127. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +67 -4
  128. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.ts +80 -0
  129. package/src/defaults/gjc/skills/deep-interview/SKILL.md +105 -10
  130. package/src/defaults/gjc/skills/ralplan/SKILL.md +6 -4
  131. package/src/defaults/gjc-defaults.test.ts +18 -1
  132. package/src/defaults/gjc-defaults.ts +3 -0
  133. package/src/defaults/gjc-skills.generated.ts +58 -36
  134. package/src/discovery/agents.ts +6 -1
  135. package/src/discovery/builtin.ts +10 -0
  136. package/src/discovery/helpers.ts +159 -20
  137. package/src/discovery/mcp-json.ts +6 -0
  138. package/src/discovery/ssh.ts +1 -1
  139. package/src/discovery/substitute-plugin-root.ts +3 -1
  140. package/src/edit/file-read-cache.ts +1 -1
  141. package/src/eval/py/executor.ts +380 -120
  142. package/src/export/html/css-color.ts +9 -0
  143. package/src/export/html/index.ts +5 -4
  144. package/src/export/html/template.generated.ts +1 -1
  145. package/src/extensibility/extensions/runner.ts +4 -0
  146. package/src/extensibility/extensions/types.ts +7 -0
  147. package/src/extensibility/gjc-plugins/runtime-adapters.ts +73 -8
  148. package/src/extensibility/plugins/manager.ts +3 -2
  149. package/src/extensibility/plugins/marketplace/cache.ts +15 -0
  150. package/src/extensibility/plugins/marketplace/manager.ts +11 -3
  151. package/src/extensibility/plugins/plugin-link-path.ts +12 -0
  152. package/src/extensibility/runtime-skill-discovery.ts +20 -2
  153. package/src/extensibility/skill-management.ts +6 -2
  154. package/src/extensibility/skills.ts +6 -1
  155. package/src/gjc-runtime/deep-interview-crystallize.ts +1814 -0
  156. package/src/gjc-runtime/deep-interview-recorder.ts +20 -2
  157. package/src/gjc-runtime/deep-interview-runtime.ts +1415 -13
  158. package/src/gjc-runtime/deep-interview-stage.ts +98 -21
  159. package/src/gjc-runtime/gc-runtime.ts +206 -9
  160. package/src/gjc-runtime/launch-worktree.ts +14 -4
  161. package/src/gjc-runtime/ralplan-runtime.ts +447 -52
  162. package/src/gjc-runtime/state-argv.ts +2 -0
  163. package/src/gjc-runtime/state-migrations.ts +91 -29
  164. package/src/gjc-runtime/state-runtime.ts +3716 -135
  165. package/src/gjc-runtime/state-writer.ts +83 -8
  166. package/src/gjc-runtime/ultragoal-runtime.ts +16 -4
  167. package/src/gjc-runtime/workflow-manifest.generated.json +70 -42
  168. package/src/gjc-runtime/workflow-manifest.ts +54 -21
  169. package/src/harness-control-plane/operate.ts +12 -4
  170. package/src/harness-control-plane/owner.ts +5 -1
  171. package/src/hashline/parser.ts +48 -9
  172. package/src/hashline/recovery.ts +13 -4
  173. package/src/internal-urls/docs-index.generated.ts +18 -17
  174. package/src/internal-urls/skill-path.ts +19 -0
  175. package/src/internal-urls/skill-protocol.ts +2 -0
  176. package/src/lsp/edits.ts +6 -0
  177. package/src/lsp/workspace-path.ts +51 -0
  178. package/src/main.ts +195 -31
  179. package/src/modes/acp/acp-agent.ts +88 -4
  180. package/src/modes/components/custom-editor.ts +14 -0
  181. package/src/modes/components/customization/customization-dashboard.ts +12 -0
  182. package/src/modes/components/extensions/extension-dashboard.ts +2 -2
  183. package/src/modes/components/extensions/state-manager.ts +15 -5
  184. package/src/modes/components/model-selector.ts +1 -4
  185. package/src/modes/components/provider-onboarding-selector.ts +1 -1
  186. package/src/modes/components/status-line/segments.ts +4 -0
  187. package/src/modes/controllers/event-controller.ts +11 -4
  188. package/src/modes/controllers/input-controller.ts +74 -29
  189. package/src/modes/controllers/runtime-mcp-command-controller.ts +8 -3
  190. package/src/modes/controllers/selector-controller.ts +28 -15
  191. package/src/modes/interactive-mode.ts +4 -0
  192. package/src/modes/runtime-init.ts +4 -0
  193. package/src/modes/utils/abort-message.ts +3 -1
  194. package/src/modes/utils/interrupt-ownership.ts +12 -0
  195. package/src/modes/utils/keybinding-matchers.ts +4 -0
  196. package/src/modes/utils/macos-option-forwarding.ts +80 -0
  197. package/src/progress/collect-project-progress.ts +153 -0
  198. package/src/progress/progress-contract.ts +172 -0
  199. package/src/progress/progress-snapshot.ts +140 -0
  200. package/src/progress/project-progress.ts +446 -0
  201. package/src/progress/render-progress.ts +209 -0
  202. package/src/prompts/system/master-mode.md +1 -0
  203. package/src/prompts/tools/skill-discovery.md +1 -1
  204. package/src/runtime-mcp/config-writer.ts +10 -6
  205. package/src/runtime-mcp/config.ts +1 -0
  206. package/src/runtime-mcp/oauth-flow.ts +5 -0
  207. package/src/runtime-mcp/oauth-public-url.ts +8 -0
  208. package/src/runtime-mcp/plugin-network-boundary.ts +4 -0
  209. package/src/runtime-mcp/smithery-registry.ts +22 -3
  210. package/src/runtime-mcp/transports/http.ts +2 -2
  211. package/src/runtime-mcp/types.ts +6 -0
  212. package/src/sdk/acp/adapter.ts +93 -8
  213. package/src/sdk/broker/broker-exit.ts +52 -23
  214. package/src/sdk/broker/broker.ts +75 -6
  215. package/src/sdk/broker/ensure.ts +2 -0
  216. package/src/sdk/broker/lifecycle.ts +711 -66
  217. package/src/sdk/broker/session-index.ts +191 -81
  218. package/src/sdk/bus/chat-daemon-cli.ts +38 -1
  219. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  220. package/src/sdk/bus/index.ts +238 -43
  221. package/src/sdk/bus/telegram-daemon-contract.ts +6 -1
  222. package/src/sdk/cli/session-cli.ts +17 -3
  223. package/src/sdk/client/client.ts +28 -8
  224. package/src/sdk/host/query/handlers.ts +9 -1
  225. package/src/sdk/host/session-runtime.test.ts +2647 -294
  226. package/src/sdk/host/session-runtime.ts +426 -109
  227. package/src/sdk/host/surface-policy.ts +1 -0
  228. package/src/sdk/index.ts +8 -0
  229. package/src/sdk/lifecycle-session.ts +3 -1
  230. package/src/sdk/prompt-deadline-manager.ts +15 -0
  231. package/src/sdk/protocol/operation-inventory.generated.json +146 -0
  232. package/src/sdk/protocol/operation-registry.ts +9 -2
  233. package/src/sdk/router/session-router.ts +75 -12
  234. package/src/sdk/session-list.ts +1 -0
  235. package/src/sdk/session.ts +67 -38
  236. package/src/session/agent-session.ts +623 -224
  237. package/src/session/artifacts.ts +174 -70
  238. package/src/session/internal/managed-gc-retirement-codec.ts +319 -0
  239. package/src/session/internal/managed-session-scope.ts +3457 -462
  240. package/src/session/internal/managed-session-storage.ts +376 -61
  241. package/src/session/internal/managed-task-owner-cleanup.ts +473 -0
  242. package/src/session/internal/task-artifact-owner-access.ts +19 -3
  243. package/src/session/internal/task-artifact-owner-transcript.ts +547 -1
  244. package/src/session/session-manager.ts +939 -133
  245. package/src/session/session-retirement.ts +573 -14
  246. package/src/session/session-storage.ts +417 -14
  247. package/src/session/task-artifact-owner-codec.ts +71 -8
  248. package/src/session/task-artifact-owner-retirement.ts +21 -19
  249. package/src/setup/credential-auto-import.ts +12 -3
  250. package/src/setup/credential-import.ts +176 -5
  251. package/src/setup/paseo/announce.ts +6 -1
  252. package/src/skill-state/active-state.ts +132 -2
  253. package/src/slash-commands/builtin-registry.ts +39 -9
  254. package/src/slash-commands/helpers/progress-report.ts +23 -0
  255. package/src/stt/setup.ts +3 -5
  256. package/src/task/commands.ts +3 -1
  257. package/src/task/executor.ts +4 -0
  258. package/src/task/index.ts +6 -0
  259. package/src/tools/ask.ts +205 -6
  260. package/src/tools/ast-edit.ts +4 -0
  261. package/src/tools/bash-allowed-prefixes.ts +15 -0
  262. package/src/tools/bash-skill-urls.ts +56 -3
  263. package/src/tools/bash.ts +48 -79
  264. package/src/tools/descriptors.ts +5 -1
  265. package/src/tools/eval.ts +56 -36
  266. package/src/tools/image-gen.ts +1 -1
  267. package/src/tools/index.ts +3 -0
  268. package/src/tools/python.ts +187 -66
  269. package/src/tools/recipe/index.ts +9 -0
  270. package/src/tools/recipe/recipe-guard.ts +19 -0
  271. package/src/tools/restricted-cwd.ts +10 -0
  272. package/src/tools/skill-discovery.ts +5 -0
  273. package/src/tools/skill.ts +28 -1
  274. package/src/tools/todo-write.ts +10 -1
  275. package/src/tools/tool-catalog.generated.ts +1 -1
  276. package/src/tools/write.ts +1 -0
  277. package/src/utils/tools-checksum.ts +34 -0
  278. package/src/utils/tools-manager.ts +24 -6
  279. package/src/web/search/providers/codex.ts +3 -2
@@ -117,6 +117,7 @@ import {
117
117
  import {
118
118
  assertManagedDirectoryRoot,
119
119
  captureManagedFileNoFollow,
120
+ captureManagedFilePrefixNoFollow,
120
121
  fsyncManagedArtifactTree,
121
122
  MANAGED_ARTIFACT_MAX_FILE_BYTES,
122
123
  ManagedAppendIdentityMismatchError,
@@ -130,6 +131,7 @@ import {
130
131
  type ManagedSessionSecurityPolicy,
131
132
  managedDirectoryRoot,
132
133
  mayCleanManagedTreeStaging,
134
+ publishManagedFileNoReplaceSync,
133
135
  retainManagedDirectoryAuthority,
134
136
  } from "./internal/managed-session-storage";
135
137
  import { classifyNativePublishOutcome, formatNativePublishDiagnostic } from "./internal/native-publish-outcome";
@@ -247,6 +249,7 @@ import {
247
249
  replaceSessionCommitMarkerCheckedSync,
248
250
  SESSION_RANGE_READ_MAX_BYTES,
249
251
  } from "./session-storage";
252
+ import { DEFAULT_ARTIFACT_MAX_BYTES, truncateHeadBytes } from "./streaming-output";
250
253
 
251
254
  export const CURRENT_SESSION_VERSION = 5;
252
255
 
@@ -2140,6 +2143,23 @@ export interface ResumeSessionIdentity {
2140
2143
  sha256: string;
2141
2144
  }
2142
2145
 
2146
+ /** Descriptor-bound bounded fingerprint for private explicit-session rollback authority.
2147
+ * JSON-safe: all bigint fields are stored as strings to survive JSON serialization,
2148
+ * spread, structuredClone, and JSON round-trip. */
2149
+ export type ExplicitPersistIdentity = {
2150
+ // All bigint fields stored as base-10 strings for JSON safety
2151
+ dev: string;
2152
+ ino: string;
2153
+ nlink?: string;
2154
+ size: number;
2155
+ mtimeMs: number;
2156
+ mtimeNs: string;
2157
+ ctimeNs?: string;
2158
+ canonicalPath: string;
2159
+ sessionId: string;
2160
+ fingerprintSha256: string;
2161
+ };
2162
+
2143
2163
  export interface ResumeTailResumable {
2144
2164
  kind: "resumable";
2145
2165
  identity: ResumeSessionIdentity;
@@ -2175,6 +2195,7 @@ export const RESUME_TRANSCRIPT_MAX_BYTES = MANAGED_ARTIFACT_MAX_FILE_BYTES;
2175
2195
  */
2176
2196
  export const BOUNDED_RESUME_TRANSCRIPT_MAX_BYTES = 2 * 1024 * 1024 * 1024 + 1024 * 1024;
2177
2197
  const EAGER_RESUME_TRANSCRIPT_MAX_BYTES = MANAGED_ARTIFACT_MAX_FILE_BYTES;
2198
+ const EXPLICIT_PERSIST_FINGERPRINT_RANGE_BYTES = 64 * 1024;
2178
2199
 
2179
2200
  /**
2180
2201
  * Recovery actions offered when a live transcript is at or past the managed
@@ -2639,8 +2660,10 @@ export function createReadonlySessionManager(manager: SessionManager): ReadonlyS
2639
2660
  }
2640
2661
 
2641
2662
  /** Internal artifact-writing capability. Read-only facades expose it only through a private weak-map lookup. */
2663
+ export type SessionArtifactPublication = (content: string, toolType: string) => Promise<string | undefined>;
2664
+
2642
2665
  export type SessionArtifactCapability = Readonly<
2643
- Pick<SessionManager, "allocateArtifactPath" | "saveArtifact" | "putBlob">
2666
+ Pick<SessionManager, "allocateArtifactPath" | "saveArtifact" | "putBlob" | "captureArtifactPublication">
2644
2667
  >;
2645
2668
 
2646
2669
  const sessionArtifactCapabilities = new WeakMap<SessionManager, SessionArtifactCapability>();
@@ -2658,6 +2681,7 @@ export function sessionArtifactCapability(value: unknown): SessionArtifactCapabi
2658
2681
  allocateArtifactPath: value.allocateArtifactPath.bind(value),
2659
2682
  saveArtifact: value.saveArtifact.bind(value),
2660
2683
  putBlob: value.putBlob.bind(value),
2684
+ captureArtifactPublication: value.captureArtifactPublication.bind(value),
2661
2685
  });
2662
2686
  sessionArtifactCapabilities.set(value, capability);
2663
2687
  }
@@ -3404,11 +3428,10 @@ function retainedTreeSnapshotEqualsAfterRename(
3404
3428
  /**
3405
3429
  * True when a cleanup error only reports the authorized POSIX quarantine.
3406
3430
  *
3407
- * `exact_remove_directory_tree` and `removeManagedTree` cannot bind the final
3408
- * unlink to the verified root descriptor on POSIX, so they detach the tree to a
3409
- * no-replace `<name>.removing` name and report `cleanup_pending`. No live
3410
- * artifact survives that outcome, so it is a SUCCESSFUL cleanup and must never
3411
- * supersede the primary failure that triggered it.
3431
+ * Exact managed-tree removal durably detaches its captured tree into native
3432
+ * quarantine and reports `cleanup_pending`. This is a completed cleanup
3433
+ * disposition, not proof of physical reclamation or authority to replay the
3434
+ * quarantine. Callers may accept it only at the specific cleanup boundary.
3412
3435
  */
3413
3436
  function isAuthorizedPendingCleanup(cleanupError: Error): boolean {
3414
3437
  return cleanupError.message === "cleanup_pending";
@@ -6510,17 +6533,110 @@ class NdjsonFileWriter {
6510
6533
  const PROJECT_SESSION_SCAN_MAX_DIRECTORIES = 4096;
6511
6534
  const PROJECT_SESSION_SCAN_MAX_FILES = 1000;
6512
6535
 
6513
- function isProjectSessionTranscriptPath(projectGjcDir: string, filePath: string): boolean {
6536
+ export function isProjectSessionTranscriptPath(projectGjcDir: string, filePath: string): boolean {
6514
6537
  if (isStagedSessionPath(filePath)) return false;
6515
6538
  const relative = path.relative(projectGjcDir, filePath);
6516
6539
  if (relative.startsWith("..") || path.isAbsolute(relative)) return false;
6517
6540
  const segments = relative.split(path.sep);
6518
6541
  if (segments.includes(SESSION_STAGING_DIRNAME)) return false;
6519
- if (segments.length === 1) return true;
6520
6542
  const parent = segments.at(-2);
6521
6543
  return parent === "agent-session" || segments.includes("sessions");
6522
6544
  }
6523
6545
 
6546
+ export function readAuthorizedProjectSessionTranscript(
6547
+ projectGjcDir: string,
6548
+ filePath: string,
6549
+ maxBytes: number,
6550
+ ): Buffer | undefined {
6551
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0 || maxBytes === Number.MAX_SAFE_INTEGER) return undefined;
6552
+ const root = path.resolve(projectGjcDir);
6553
+ const candidate = path.resolve(filePath);
6554
+ if (!isProjectSessionTranscriptPath(root, candidate)) return undefined;
6555
+ const relativePath = path.relative(root, candidate).split(path.sep).join("/");
6556
+ let authority: native.RecoveryFsRoot | undefined;
6557
+ try {
6558
+ authority = nativeSessionManager().openRecoveryFsRoot(root);
6559
+ } catch (error) {
6560
+ if (!(error instanceof Error) || error.message !== "unsupported_platform") throw error;
6561
+ }
6562
+ try {
6563
+ const result: native.RecoveryFsResult = authority?.readManaged(relativePath) ?? {
6564
+ ok: false,
6565
+ code: "unsupported_platform",
6566
+ };
6567
+ if (!result.ok && result.code === "unsupported_platform") {
6568
+ const relativeParts = path.relative(root, path.dirname(candidate)).split(path.sep).filter(Boolean);
6569
+ const parentPaths = [root];
6570
+ for (const part of relativeParts) parentPaths.push(path.join(parentPaths[parentPaths.length - 1]!, part));
6571
+ const parentIdentities = parentPaths.map(parentPath => {
6572
+ const stat = fs.lstatSync(parentPath, { bigint: true });
6573
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("project transcript parent is unsafe");
6574
+ return { path: parentPath, dev: stat.dev, ino: stat.ino, mode: stat.mode, ctimeNs: stat.ctimeNs };
6575
+ });
6576
+ let captured: ManagedFileSnapshot;
6577
+ if (os.platform() === "win32" || os.platform() === "darwin") {
6578
+ // Windows and Darwin cannot traverse directory handles through /dev/fd.
6579
+ // Bind the bounded capture to the inspected file and recheck every
6580
+ // parent (including junctions) before allowing bytes to leave this reader.
6581
+ const expected = fs.lstatSync(candidate, { bigint: true });
6582
+ if (!expected.isFile() || expected.isSymbolicLink() || expected.nlink > 1)
6583
+ throw new Error("project transcript file is unsafe");
6584
+ if (expected.size > BigInt(maxBytes)) return undefined;
6585
+ captured = captureManagedFilePrefixNoFollow(candidate, maxBytes + 1);
6586
+ if (
6587
+ captured.identity.dev !== BigInt.asUintN(64, expected.dev) ||
6588
+ captured.identity.ino !== BigInt.asUintN(64, expected.ino) ||
6589
+ captured.identity.nlink !== expected.nlink ||
6590
+ captured.identity.size !== Number(expected.size) ||
6591
+ captured.identity.mtimeNs !== expected.mtimeNs ||
6592
+ captured.identity.ctimeNs !== expected.ctimeNs
6593
+ )
6594
+ throw new Error("project transcript file identity changed during read");
6595
+ } else {
6596
+ const parentPath = path.dirname(candidate);
6597
+ const parentFd = fs.openSync(
6598
+ parentPath,
6599
+ fs.constants.O_RDONLY | (fs.constants.O_DIRECTORY ?? 0) | (fs.constants.O_NOFOLLOW ?? 0),
6600
+ );
6601
+ try {
6602
+ const expectedParent = parentIdentities[parentIdentities.length - 1]!;
6603
+ const openedParent = fs.fstatSync(parentFd, { bigint: true });
6604
+ if (
6605
+ openedParent.dev !== expectedParent.dev ||
6606
+ openedParent.ino !== expectedParent.ino ||
6607
+ openedParent.mode !== expectedParent.mode
6608
+ )
6609
+ throw new Error("project transcript parent handle identity mismatch");
6610
+ captured = captureManagedFilePrefixNoFollow(
6611
+ path.join("/dev/fd", String(parentFd), path.basename(candidate)),
6612
+ maxBytes + 1,
6613
+ );
6614
+ } finally {
6615
+ fs.closeSync(parentFd);
6616
+ }
6617
+ }
6618
+ for (const expected of parentIdentities) {
6619
+ const stat = fs.lstatSync(expected.path, { bigint: true });
6620
+ if (
6621
+ !stat.isDirectory() ||
6622
+ stat.isSymbolicLink() ||
6623
+ stat.dev !== expected.dev ||
6624
+ stat.ino !== expected.ino ||
6625
+ stat.mode !== expected.mode ||
6626
+ stat.ctimeNs !== expected.ctimeNs
6627
+ )
6628
+ throw new Error("project transcript parent identity changed during read");
6629
+ }
6630
+ if (captured.identity.size > BigInt(maxBytes) || captured.bytes.byteLength > maxBytes) return undefined;
6631
+ return Buffer.from(captured.bytes);
6632
+ }
6633
+ if (!result.ok || !result.data || result.data.byteLength > maxBytes) return undefined;
6634
+ return Buffer.from(result.data);
6635
+ } finally {
6636
+ authority?.close();
6637
+ }
6638
+ }
6639
+
6524
6640
  /**
6525
6641
  * Discover resumable transcripts intentionally stored inside a project's `.gjc`.
6526
6642
  * Runtime token/audit JSONL files are excluded by requiring a known transcript
@@ -7209,10 +7325,12 @@ interface SessionManagerStateSnapshot {
7209
7325
  flushed: boolean;
7210
7326
  ensuredOnDisk: boolean;
7211
7327
  needsFullRewriteOnNextPersist: boolean;
7212
- fileEntries: FileEntry[];
7213
- materializedFileEntries: FileEntry[];
7328
+ fileEntries: readonly FileEntry[];
7329
+ materializedFileEntries: readonly FileEntry[];
7214
7330
  adoptedArtifactManager: ArtifactManager | null;
7215
7331
  coldRestoreFile?: string;
7332
+ /** Explicit persistence identity for snapshot serialization safety. Preserved across cross-manager adoptions. */
7333
+ readonly explicitPersistIdentity?: ExplicitPersistIdentity;
7216
7334
  }
7217
7335
 
7218
7336
  /** Benchmark-derived cap for strong materialized session snapshots. */
@@ -7236,6 +7354,7 @@ export const SessionManagerTestHooks: {
7236
7354
  beforeManagedMissingReturn?: (filePath: string, storage: SessionStorage) => void | Promise<void>;
7237
7355
  afterManagedMissingAssertion?: (filePath: string, storage: SessionStorage) => void | Promise<void>;
7238
7356
  beforeManagedSwitchIdentity?: (filePath: string, storage: SessionStorage) => void | Promise<void>;
7357
+ beforeColdRollbackCommit?: (filePath: string, storage: SessionStorage) => void | Promise<void>;
7239
7358
  /** Internal first-open GC strategy override; omitted means current. */
7240
7359
  firstOpenGcStrategy?: SessionMemoryGcStrategy;
7241
7360
  /** Internal first-open secondary-artifact mode override; omitted means auto. */
@@ -7517,6 +7636,22 @@ function validateEvictedToolOutputHandle(
7517
7636
  return { ok: true, handle: value as EvictedToolOutputHandle };
7518
7637
  }
7519
7638
  export class SessionManager {
7639
+ readonly #stateSnapshots = new WeakMap<
7640
+ SessionManagerStateSnapshot,
7641
+ Readonly<SessionManagerStateSnapshot> & { readonly explicitPersistIdentity?: ExplicitPersistIdentity }
7642
+ >();
7643
+ #artifactLifecycle = Symbol("session-artifact-lifecycle");
7644
+ #artifactLifecycleSessionId = "";
7645
+ #artifactLifecycleSessionFile: string | undefined;
7646
+ #artifactClosing = false;
7647
+ #managedRollbackIdentity:
7648
+ | Readonly<{
7649
+ sessionId: string;
7650
+ sessionFile: string;
7651
+ before: Readonly<ManagedFileIdentity>;
7652
+ after: Readonly<ManagedFileIdentity>;
7653
+ }>
7654
+ | undefined;
7520
7655
  #sessionId: string = "";
7521
7656
  /** True once a lifecycle pre-allocated id has been adopted (consume-once). */
7522
7657
  #lifecycleIdAdopted: boolean = false;
@@ -7552,6 +7687,7 @@ export class SessionManager {
7552
7687
  #persistWriterPath: string | undefined;
7553
7688
  #persistChain: Promise<void> = Promise.resolve();
7554
7689
  #persistError: Error | undefined;
7690
+ #closeRetryOriginError: Error | undefined;
7555
7691
  #persistErrorReported = false;
7556
7692
  /** Defense-in-depth (#4443): one-shot warn for adjacent private thinking blocks in persisted assistant transcripts. */
7557
7693
  #warnedAdjacentThinkingPersist = false;
@@ -8213,6 +8349,16 @@ export class SessionManager {
8213
8349
  prepared.releaseReferences();
8214
8350
  }
8215
8351
 
8352
+ /**
8353
+ * Adopt the resident store of a fully prepared rollback candidate. The candidate
8354
+ * owns the store it built for the restored lifecycle, so the swap happens here,
8355
+ * inside the resident-store seams, instead of at the call site.
8356
+ */
8357
+ #installRollbackCandidateResidentStore(candidate: SessionManager): void {
8358
+ this.#residentTextBlobStore = candidate.#residentTextBlobStore;
8359
+ candidate.#residentTextBlobStore = new MemoryBlobStore();
8360
+ }
8361
+
8216
8362
  #releaseResidentTextStore(): void {
8217
8363
  const predecessor = this.#residentTextBlobStore;
8218
8364
  this.#residentTextBlobStore = new MemoryBlobStore();
@@ -8302,10 +8448,157 @@ export class SessionManager {
8302
8448
  return this.#blobStore.put(data);
8303
8449
  }
8304
8450
 
8451
+ #issueStateSnapshot(snapshot: SessionManagerStateSnapshot): SessionManagerStateSnapshot {
8452
+ const cloneEntry = (entry: FileEntry): FileEntry =>
8453
+ entry.type === "session" ? { ...entry } : cloneSessionEntry(entry);
8454
+ // Public rollback data must not alias either the live graph or its private issuer copy.
8455
+ const fileEntries = snapshot.fileEntries.map(cloneEntry);
8456
+ const materializedFileEntries = snapshot.materializedFileEntries.map(cloneEntry);
8457
+ snapshot.fileEntries = fileEntries;
8458
+ snapshot.materializedFileEntries = materializedFileEntries;
8459
+ snapshot.managedPersistExpectedIdentity = snapshot.managedPersistExpectedIdentity
8460
+ ? { ...snapshot.managedPersistExpectedIdentity }
8461
+ : undefined;
8462
+ const explicitPersistIdentity =
8463
+ this.destination.kind === "explicit" &&
8464
+ this.#storage instanceof FileSessionStorage &&
8465
+ snapshot.ensuredOnDisk &&
8466
+ snapshot.sessionFile
8467
+ ? this.#captureExplicitPersistIdentity(snapshot.sessionFile)
8468
+ : undefined;
8469
+ if (explicitPersistIdentity && explicitPersistIdentity.sessionId !== snapshot.sessionId)
8470
+ throw new Error("Session rollback persistence identity is unavailable.");
8471
+ // Store the explicit identity as an enumerable property on the snapshot itself
8472
+ // so that it survives documented caller-adjusted copies (spread, JSON round-trip,
8473
+ // structuredClone, etc.) and cross-manager adoptions can access the captured
8474
+ // identity instead of reconstructing it from the current file state.
8475
+ if (explicitPersistIdentity) {
8476
+ Object.defineProperty(snapshot, "explicitPersistIdentity", {
8477
+ value: Object.freeze({ ...explicitPersistIdentity }),
8478
+ enumerable: true,
8479
+ configurable: false,
8480
+ writable: false,
8481
+ });
8482
+ }
8483
+ this.#stateSnapshots.set(
8484
+ snapshot,
8485
+ Object.freeze({
8486
+ ...snapshot,
8487
+ fileEntries: Object.freeze(fileEntries.map(cloneEntry)),
8488
+ materializedFileEntries: Object.freeze(materializedFileEntries.map(cloneEntry)),
8489
+ managedPersistExpectedIdentity: snapshot.managedPersistExpectedIdentity
8490
+ ? Object.freeze({ ...snapshot.managedPersistExpectedIdentity })
8491
+ : undefined,
8492
+ explicitPersistIdentity: explicitPersistIdentity
8493
+ ? Object.freeze({ ...explicitPersistIdentity })
8494
+ : undefined,
8495
+ }),
8496
+ );
8497
+ return snapshot;
8498
+ }
8499
+
8500
+ /**
8501
+ * Resolve the state source for an explicit adoption (`restoreState`). Adoption is
8502
+ * caller-driven state, not rollback authority: a snapshot may come from another
8503
+ * manager or from a caller-adjusted copy of one, so those are adopted as given and
8504
+ * constrained only by the live-state assertions inside `restoreState`. A snapshot
8505
+ * issued by this manager resolves to its frozen issuer copy, which carries the
8506
+ * explicit persistence identity captured at issuance. The rollback lane
8507
+ * (`restoreRollbackState`) keeps requiring an authenticated issuance.
8508
+ */
8509
+ #resolveAdoptedStateSnapshot(
8510
+ snapshot: SessionManagerStateSnapshot,
8511
+ ): Readonly<SessionManagerStateSnapshot> & { readonly explicitPersistIdentity?: ExplicitPersistIdentity } {
8512
+ const issued = this.#stateSnapshots.get(snapshot);
8513
+ if (issued) return issued;
8514
+ if (snapshot.adoptedArtifactManager !== null && !(snapshot.adoptedArtifactManager instanceof ArtifactManager)) {
8515
+ throw new Error("Session rollback adopted artifact manager is not live.");
8516
+ }
8517
+ // Preserve explicit identity from cross-manager adoptions. The identity is now
8518
+ // stored as an enumerable property on the snapshot so it survives documented
8519
+ // caller-adjusted copies (spread, JSON round-trip, structuredClone, etc.).
8520
+ // Adoption must restore the identity captured in the snapshot, not the current
8521
+ // state of the sessionFile, to ensure stale file checks use the captured identity.
8522
+ let explicit = snapshot.explicitPersistIdentity;
8523
+ // Only attempt reconstruction for explicit-storage sessions that don't already
8524
+ // have an explicit identity (e.g., snapshots captured before this change).
8525
+ if (!explicit && snapshot.sessionFile && !snapshot.managedPersistExpectedIdentity) {
8526
+ try {
8527
+ explicit = this.#captureExplicitPersistIdentity(snapshot.sessionFile);
8528
+ // Verify the reconstructed identity matches the snapshot's sessionId
8529
+ if (explicit.sessionId !== snapshot.sessionId) {
8530
+ // Session ID mismatch indicates the snapshot is for a different session
8531
+ explicit = undefined;
8532
+ }
8533
+ } catch {
8534
+ // If reconstruction fails (e.g., storage doesn't support readRangeSync),
8535
+ // continue without it; validation will handle missing identity
8536
+ explicit = undefined;
8537
+ }
8538
+ }
8539
+ if (explicit) {
8540
+ return Object.freeze({
8541
+ ...snapshot,
8542
+ explicitPersistIdentity: explicit,
8543
+ }) as Readonly<SessionManagerStateSnapshot> & { readonly explicitPersistIdentity?: ExplicitPersistIdentity };
8544
+ }
8545
+ return snapshot;
8546
+ }
8547
+
8548
+ #authenticateStateSnapshot(snapshot: SessionManagerStateSnapshot): Readonly<SessionManagerStateSnapshot> {
8549
+ this.#assertArtifactOpen();
8550
+ const issued = this.#stateSnapshots.get(snapshot);
8551
+ if (
8552
+ !issued ||
8553
+ snapshot.sessionId !== issued.sessionId ||
8554
+ snapshot.sessionFile !== issued.sessionFile ||
8555
+ snapshot.sessionName !== issued.sessionName ||
8556
+ snapshot.titleSource !== issued.titleSource ||
8557
+ snapshot.coldRestoreFile !== issued.coldRestoreFile ||
8558
+ snapshot.adoptedArtifactManager !== issued.adoptedArtifactManager ||
8559
+ snapshot.flushed !== issued.flushed ||
8560
+ snapshot.ensuredOnDisk !== issued.ensuredOnDisk ||
8561
+ snapshot.needsFullRewriteOnNextPersist !== issued.needsFullRewriteOnNextPersist ||
8562
+ !util.isDeepStrictEqual(snapshot.managedPersistExpectedIdentity, issued.managedPersistExpectedIdentity) ||
8563
+ !util.isDeepStrictEqual(snapshot.fileEntries, issued.fileEntries) ||
8564
+ !util.isDeepStrictEqual(snapshot.materializedFileEntries, issued.materializedFileEntries)
8565
+ ) {
8566
+ throw new Error("Session rollback snapshot is not authentic.");
8567
+ }
8568
+ return issued;
8569
+ }
8570
+
8571
+ #assertSnapshotPersistenceIdentity(
8572
+ snapshot: Readonly<SessionManagerStateSnapshot> & { readonly explicitPersistIdentity?: ExplicitPersistIdentity },
8573
+ store?: ManagedSessionDescendantStore,
8574
+ ): ManagedFileIdentity | undefined {
8575
+ if (!snapshot.managedPersistExpectedIdentity || !snapshot.sessionFile) {
8576
+ if (snapshot.explicitPersistIdentity && snapshot.sessionFile) {
8577
+ const current = this.#captureExplicitPersistIdentity(snapshot.sessionFile);
8578
+ if (!util.isDeepStrictEqual(current, snapshot.explicitPersistIdentity))
8579
+ throw new Error("Session rollback persistence identity changed.");
8580
+ }
8581
+ return undefined;
8582
+ }
8583
+ const current = this.#captureManagedPersistIdentity(snapshot.sessionFile, store);
8584
+ if (util.isDeepStrictEqual(current, snapshot.managedPersistExpectedIdentity)) return current;
8585
+ const rollback = this.#managedRollbackIdentity;
8586
+ if (
8587
+ rollback &&
8588
+ rollback.sessionId === snapshot.sessionId &&
8589
+ rollback.sessionFile === snapshot.sessionFile &&
8590
+ util.isDeepStrictEqual(rollback.before, snapshot.managedPersistExpectedIdentity) &&
8591
+ util.isDeepStrictEqual(rollback.after, current)
8592
+ )
8593
+ return current;
8594
+ throw new Error("Session rollback persistence identity changed.");
8595
+ }
8596
+
8305
8597
  /** Capture rollback authority without materializing an active cold transcript. @internal */
8306
8598
  async captureRollbackState(): Promise<SessionManagerStateSnapshot> {
8599
+ this.#assertArtifactOpen();
8307
8600
  if (this.#coldSidecarActive() && this.#sessionFile) {
8308
- return {
8601
+ return this.#issueStateSnapshot({
8309
8602
  sessionId: this.#sessionId,
8310
8603
  sessionName: this.#sessionName,
8311
8604
  titleSource: this.#titleSource,
@@ -8318,46 +8611,171 @@ export class SessionManager {
8318
8611
  materializedFileEntries: [],
8319
8612
  adoptedArtifactManager: this.#adoptedArtifactManager,
8320
8613
  coldRestoreFile: this.#sessionFile,
8321
- };
8614
+ });
8322
8615
  }
8323
8616
  return this.captureState();
8324
8617
  }
8325
8618
 
8326
8619
  /** Restore a rollback snapshot, reopening cold authority instead of hydrating it. @internal */
8327
8620
  async restoreRollbackState(snapshot: SessionManagerStateSnapshot): Promise<void> {
8328
- if (!snapshot.coldRestoreFile) {
8621
+ const issued = this.#authenticateStateSnapshot(snapshot);
8622
+ if (!issued.coldRestoreFile) {
8329
8623
  this.restoreState(snapshot);
8330
8624
  return;
8331
8625
  }
8332
- await this.#closePersistWriter();
8333
- this.#persistChain = Promise.resolve();
8334
- this.#persistError = undefined;
8335
- this.#persistErrorReported = false;
8626
+ const lifecycle = this.#syncArtifactLifecycle();
8627
+ const predecessorId = this.#sessionId;
8628
+ const predecessorFile = this.#sessionFile;
8336
8629
  const managedTransition =
8337
8630
  this.destination.kind === "managed"
8338
- ? this.#prepareManagedDestinationTransition(path.resolve(path.dirname(snapshot.coldRestoreFile)))
8631
+ ? this.#prepareManagedDestinationTransition(path.resolve(path.dirname(issued.coldRestoreFile)))
8339
8632
  : undefined;
8633
+ const candidateDestination = managedTransition?.destination ?? this.destination;
8634
+ let candidate: SessionManager;
8635
+ try {
8636
+ candidate = new SessionManager(
8637
+ this.cwd,
8638
+ this.sessionDir,
8639
+ this.persist,
8640
+ this.#storage,
8641
+ candidateDestination,
8642
+ true,
8643
+ );
8644
+ } catch (error) {
8645
+ managedTransition?.dispose();
8646
+ throw error;
8647
+ }
8648
+ candidate.#sessionMemoryMode = this.#sessionMemoryMode;
8649
+ candidate.#sessionMemoryAutoDisabledReason = this.#sessionMemoryAutoDisabledReason;
8650
+ candidate.#residentImageBlobStore = this.#residentImageBlobStore;
8651
+ candidate.#memoryGuardCheckpointBlobs = this.#memoryGuardCheckpointBlobs;
8652
+ let installed = false;
8653
+ const disposeCandidate = (): void => {
8654
+ const residentStore = candidate.#residentTextBlobStore;
8655
+ candidate.#residentTextBlobStore = new MemoryBlobStore();
8656
+ candidate.#disposeResidentTextStore(residentStore);
8657
+ try {
8658
+ candidate.#releaseManagedSidecarCache();
8659
+ } catch (error) {
8660
+ logger.warn("Failed to dispose staged rollback sidecar authority", { error: toError(error).message });
8661
+ }
8662
+ try {
8663
+ candidate.#managedTranscriptStoreCache?.store.close();
8664
+ } catch (error) {
8665
+ logger.warn("Failed to dispose staged rollback transcript authority", { error: toError(error).message });
8666
+ }
8667
+ candidate.#managedTranscriptStoreCache = null;
8668
+ try {
8669
+ candidate.#releaseOwnedManagedAuthority();
8670
+ candidate.#clearBoundedManagedSource();
8671
+ } catch (error) {
8672
+ logger.warn("Failed to dispose staged rollback managed authority", { error: toError(error).message });
8673
+ }
8674
+ };
8340
8675
  try {
8676
+ this.#assertSnapshotPersistenceIdentity(issued, managedTransition?.store);
8677
+ await candidate.#initSessionFile(issued.coldRestoreFile, false, undefined, true, true, true);
8678
+ await SessionManagerTestHooks.beforeColdRollbackCommit?.(issued.coldRestoreFile, this.#storage);
8679
+ this.#authenticateStateSnapshot(snapshot);
8680
+ if (
8681
+ lifecycle !== this.#syncArtifactLifecycle() ||
8682
+ this.#sessionId !== predecessorId ||
8683
+ this.#sessionFile !== predecessorFile
8684
+ )
8685
+ throw new Error("Session changed before rollback commit.");
8686
+ this.#assertSnapshotPersistenceIdentity(issued, managedTransition?.store);
8687
+ if (candidate.#sessionId !== issued.sessionId || candidate.#sessionFile !== issued.coldRestoreFile)
8688
+ throw new Error("Session rollback candidate identity changed.");
8689
+ await this.#closePersistWriter();
8690
+ this.#assertArtifactOpen();
8691
+ this.#authenticateStateSnapshot(snapshot);
8692
+ if (
8693
+ lifecycle !== this.#syncArtifactLifecycle() ||
8694
+ this.#sessionId !== predecessorId ||
8695
+ this.#sessionFile !== predecessorFile
8696
+ )
8697
+ throw new Error("Session changed before rollback commit.");
8698
+ const restoredIdentity = this.#assertSnapshotPersistenceIdentity(issued, managedTransition?.store);
8341
8699
  managedTransition?.adopt();
8342
- await this.#initSessionFile(snapshot.coldRestoreFile);
8343
- managedTransition?.settle();
8700
+ const predecessorStore = this.#residentTextBlobStore;
8701
+ try {
8702
+ this.#releaseManagedSidecarCache();
8703
+ } catch (error) {
8704
+ logger.warn("Failed to release predecessor rollback sidecar authority", { error: toError(error).message });
8705
+ }
8706
+ this.#sessionId = issued.sessionId;
8707
+ this.#sessionName = issued.sessionName;
8708
+ this.#titleSource = issued.titleSource;
8709
+ this.#sessionFile = issued.coldRestoreFile;
8710
+ this.#fileEntries = candidate.#fileEntries;
8711
+ this.#installRollbackCandidateResidentStore(candidate);
8712
+ this.#byId = candidate.#byId;
8713
+ this.#labelsById = candidate.#labelsById;
8714
+ this.#leafId = candidate.#leafId;
8715
+ this.#usageStatistics = candidate.#usageStatistics;
8716
+ this.#sidecarRuntime = candidate.#sidecarRuntime;
8717
+ candidate.#sidecarRuntime = undefined;
8718
+ this.#commitGen = candidate.#commitGen;
8719
+ this.#managedRangeExpectedDescriptor = candidate.#managedRangeExpectedDescriptor;
8720
+ this.#managedPersistExpectedIdentity = restoredIdentity;
8721
+ this.#flushed = issued.flushed;
8722
+ this.#ensuredOnDisk = issued.ensuredOnDisk;
8723
+ this.#needsFullRewriteOnNextPersist = issued.needsFullRewriteOnNextPersist;
8724
+ this.#sessionMemoryMode = candidate.#sessionMemoryMode;
8725
+ this.#sessionMemoryAutoDisabledReason = candidate.#sessionMemoryAutoDisabledReason;
8726
+ this.#lazyReopenAttempted = candidate.#lazyReopenAttempted;
8727
+ this.#lazyReopenSucceeded = candidate.#lazyReopenSucceeded;
8728
+ this.#lazyReopenFallbackReason = candidate.#lazyReopenFallbackReason;
8729
+ this.#boundedFirstOpenBuildSuppressed = candidate.#boundedFirstOpenBuildSuppressed;
8730
+ this.#consecutiveSidecarBuildFailures = candidate.#consecutiveSidecarBuildFailures;
8731
+ this.#retirementFallbackReason = candidate.#retirementFallbackReason;
8732
+ this.#sidecarBranchActivationDirty = candidate.#sidecarBranchActivationDirty;
8733
+ this.#firstOpenTelemetry = candidate.#firstOpenTelemetry;
8734
+ this.#managedSidecarAuthorityStore = candidate.#managedSidecarAuthorityStore;
8735
+ this.#managedSidecarSecurityContext = candidate.#managedSidecarSecurityContext;
8736
+ this.#managedSidecarCacheStore = candidate.#managedSidecarCacheStore;
8737
+ this.#managedSidecarCacheSessionFile = candidate.#managedSidecarCacheSessionFile;
8738
+ candidate.#managedSidecarAuthorityStore = undefined;
8739
+ candidate.#managedSidecarSecurityContext = undefined;
8740
+ candidate.#managedSidecarCacheStore = undefined;
8741
+ candidate.#managedSidecarCacheSessionFile = undefined;
8742
+ candidate.#managedRangeExpectedDescriptor = undefined;
8743
+ this.#boundedReadStorageProxy = undefined;
8744
+ this.#bumpAllRevisions();
8745
+ this.#residentBlobRevision++;
8746
+ this.#artifactManager = null;
8747
+ this.#artifactManagerSessionFile = null;
8748
+ this.#adoptedArtifactManager = issued.adoptedArtifactManager;
8749
+ this.#artifactLifecycle = Symbol("session-artifact-rollback");
8750
+ installed = true;
8751
+ this.#resetCloseRetryOriginForLifecycleCommit();
8752
+ this.#disposeResidentTextStore(predecessorStore);
8753
+ try {
8754
+ managedTransition?.settle();
8755
+ } catch (error) {
8756
+ logger.warn("Failed to settle managed authority after cold rollback", { error: toError(error).message });
8757
+ }
8758
+ try {
8759
+ this.#writeTerminalBreadcrumb(issued.coldRestoreFile);
8760
+ } catch (error) {
8761
+ logger.warn("Failed to update terminal breadcrumb after cold rollback", { error: toError(error).message });
8762
+ }
8344
8763
  } catch (error) {
8345
- managedTransition?.rollback();
8764
+ if (!installed) managedTransition?.rollback();
8346
8765
  throw error;
8766
+ } finally {
8767
+ managedTransition?.dispose();
8768
+ disposeCandidate();
8347
8769
  }
8348
- this.#flushed = snapshot.flushed;
8349
- this.#ensuredOnDisk = snapshot.ensuredOnDisk;
8350
- this.#needsFullRewriteOnNextPersist = snapshot.needsFullRewriteOnNextPersist;
8351
- this.#managedPersistExpectedIdentity = snapshot.managedPersistExpectedIdentity;
8352
- this.#adoptedArtifactManager = snapshot.adoptedArtifactManager;
8353
8770
  }
8354
8771
  captureState(): SessionManagerStateSnapshot {
8772
+ this.#assertArtifactOpen();
8355
8773
  this.#ensureFullHotView();
8356
8774
  const materializedFileEntries = materializeResidentEntriesForReadSync(
8357
8775
  this.#fileEntries,
8358
8776
  this.#residentBlobStores(),
8359
8777
  );
8360
- return {
8778
+ return this.#issueStateSnapshot({
8361
8779
  sessionId: this.#sessionId,
8362
8780
  sessionName: this.#sessionName,
8363
8781
  titleSource: this.#titleSource,
@@ -8366,54 +8784,76 @@ export class SessionManager {
8366
8784
  ensuredOnDisk: this.#ensuredOnDisk,
8367
8785
  needsFullRewriteOnNextPersist: this.#needsFullRewriteOnNextPersist,
8368
8786
  managedPersistExpectedIdentity: this.#managedPersistExpectedIdentity,
8369
- // Snapshot entry objects by reference: switch/reload replaces the active entry array,
8370
- // so rollback does not need structured cloning of extension/custom details.
8371
- fileEntries: [...this.#fileEntries],
8787
+ fileEntries: this.#fileEntries.map(entry => (entry.type === "session" ? { ...entry } : entry)),
8372
8788
  // Rollback snapshots must own resident data before another session reset disposes
8373
8789
  // the ephemeral store backing the resident sentinels above.
8374
- materializedFileEntries,
8790
+ materializedFileEntries: materializedFileEntries.map(entry =>
8791
+ entry.type === "session" ? { ...entry } : entry,
8792
+ ),
8375
8793
  adoptedArtifactManager: this.#adoptedArtifactManager,
8376
- };
8794
+ });
8377
8795
  }
8378
8796
 
8379
8797
  restoreState(snapshot: SessionManagerStateSnapshot): void {
8380
- const restoredFileEntries = [...snapshot.materializedFileEntries];
8381
- const prepared = this.#prepareResidentTextStoreTransition(
8382
- {
8383
- target: { sessionId: snapshot.sessionId, sessionFile: snapshot.sessionFile ?? "" },
8384
- primary: {
8385
- mode: "materialize",
8386
- sourceEntries: restoredFileEntries,
8387
- sourceStores: { textStore: null, imageStore: this.#residentImageBlobStore },
8798
+ this.#assertArtifactOpen();
8799
+ const issued = this.#resolveAdoptedStateSnapshot(snapshot);
8800
+ if (issued.coldRestoreFile) throw new Error("Cold rollback requires restoreRollbackState.");
8801
+ const managedTransition =
8802
+ this.destination.kind === "managed" && issued.sessionFile
8803
+ ? this.#prepareManagedDestinationTransition(path.resolve(path.dirname(issued.sessionFile)))
8804
+ : undefined;
8805
+ try {
8806
+ const restoredIdentity = this.#assertSnapshotPersistenceIdentity(issued, managedTransition?.store);
8807
+ const restoredFileEntries = issued.materializedFileEntries.map(entry =>
8808
+ entry.type === "session" ? { ...entry } : entry,
8809
+ );
8810
+ const prepared = this.#prepareResidentTextStoreTransition(
8811
+ {
8812
+ target: { sessionId: issued.sessionId, sessionFile: issued.sessionFile ?? "" },
8813
+ primary: {
8814
+ mode: "materialize",
8815
+ sourceEntries: restoredFileEntries,
8816
+ sourceStores: { textStore: null, imageStore: this.#residentImageBlobStore },
8817
+ },
8388
8818
  },
8389
- },
8390
- "retain-and-throw",
8391
- );
8392
- const retainsPersistWriter =
8393
- this.#persistWriter?.isOpen() === true &&
8394
- this.#sessionId === snapshot.sessionId &&
8395
- this.#sessionFile === snapshot.sessionFile &&
8396
- this.#persistWriterPath === snapshot.sessionFile;
8397
- this.#sessionId = snapshot.sessionId;
8398
- this.#sessionName = snapshot.sessionName;
8399
- this.#titleSource = snapshot.titleSource;
8400
- this.#sessionFile = snapshot.sessionFile;
8401
- this.#flushed = snapshot.flushed;
8402
- this.#ensuredOnDisk = snapshot.ensuredOnDisk;
8403
- this.#needsFullRewriteOnNextPersist = snapshot.needsFullRewriteOnNextPersist;
8404
- this.#managedPersistExpectedIdentity = snapshot.managedPersistExpectedIdentity;
8405
- if (!retainsPersistWriter) {
8406
- this.#persistWriter = undefined;
8407
- this.#persistWriterPath = undefined;
8819
+ "retain-and-throw",
8820
+ );
8821
+ const retainsPersistWriter =
8822
+ this.#persistWriter?.isOpen() === true &&
8823
+ this.#sessionId === issued.sessionId &&
8824
+ this.#sessionFile === issued.sessionFile &&
8825
+ this.#persistWriterPath === issued.sessionFile;
8826
+ if (!retainsPersistWriter) {
8827
+ try {
8828
+ this.#closePersistWriterInternalSync();
8829
+ } catch (error) {
8830
+ prepared.dispose();
8831
+ throw this.#recordPersistError(error);
8832
+ }
8833
+ }
8834
+ managedTransition?.adopt();
8835
+ this.#sessionId = issued.sessionId;
8836
+ this.#sessionName = issued.sessionName;
8837
+ this.#titleSource = issued.titleSource;
8838
+ this.#sessionFile = issued.sessionFile;
8839
+ this.#flushed = issued.flushed;
8840
+ this.#ensuredOnDisk = issued.ensuredOnDisk;
8841
+ this.#needsFullRewriteOnNextPersist = issued.needsFullRewriteOnNextPersist;
8842
+ this.#managedPersistExpectedIdentity = restoredIdentity;
8843
+ this.#artifactManager = null;
8844
+ this.#artifactManagerSessionFile = null;
8845
+ this.#adoptedArtifactManager = issued.adoptedArtifactManager;
8846
+ this.#artifactLifecycle = Symbol("session-artifact-rollback");
8847
+ this.#commitResidentTextStoreTransition(prepared);
8848
+ this.#resetCloseRetryOriginForLifecycleCommit();
8849
+ managedTransition?.settle();
8850
+ if (this.#sessionFile) writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
8851
+ } catch (error) {
8852
+ managedTransition?.rollback();
8853
+ throw error;
8854
+ } finally {
8855
+ managedTransition?.dispose();
8408
8856
  }
8409
- this.#persistChain = Promise.resolve();
8410
- this.#persistError = undefined;
8411
- this.#persistErrorReported = false;
8412
- this.#artifactManager = null;
8413
- this.#artifactManagerSessionFile = null;
8414
- this.#adoptedArtifactManager = snapshot.adoptedArtifactManager;
8415
- this.#commitResidentTextStoreTransition(prepared);
8416
- if (this.#sessionFile) writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
8417
8857
  }
8418
8858
 
8419
8859
  #freshSessionState(options?: NewSessionOptions, sessionFileOverride?: string): FreshSessionState {
@@ -8445,6 +8885,7 @@ export class SessionManager {
8445
8885
  if (state.adoptsLifecycleId) this.#lifecycleIdAdopted = true;
8446
8886
  this.#persistChain = Promise.resolve();
8447
8887
  this.#persistError = undefined;
8888
+ this.#closeRetryOriginError = undefined;
8448
8889
  this.#persistErrorReported = false;
8449
8890
  this.#sessionId = state.sessionId;
8450
8891
  this.#sessionName = state.header.title;
@@ -8479,7 +8920,7 @@ export class SessionManager {
8479
8920
  );
8480
8921
  }
8481
8922
 
8482
- async #tryInitSessionFileFromSidecar(sessionFile: string): Promise<boolean> {
8923
+ async #tryInitSessionFileFromSidecar(sessionFile: string, stagedCandidate = false): Promise<boolean> {
8483
8924
  SessionManagerTestHooks.lastSidecarInitError = undefined;
8484
8925
  if (
8485
8926
  (this.#sessionMemoryMode !== "enabled" && this.#sessionMemoryMode !== "auto") ||
@@ -8498,7 +8939,7 @@ export class SessionManager {
8498
8939
  this.#lazyReopenSucceeded = false;
8499
8940
  this.#lazyReopenFallbackReason = "proof_invalid";
8500
8941
  this.#sessionFile = sessionFile;
8501
- const runtime = this.#resetSidecarRuntime();
8942
+ const runtime = this.#resetSidecarRuntime(false, stagedCandidate);
8502
8943
  runtime.enabled = true;
8503
8944
  let initialized = false;
8504
8945
  try {
@@ -9571,6 +10012,7 @@ export class SessionManager {
9571
10012
  strictResume?: { inspection: ResumeInspectionSnapshot; storage: SessionStorage; reuseEntries?: boolean },
9572
10013
  requireExisting = false,
9573
10014
  deferPersistenceUntilAccepted = false,
10015
+ stagedRollbackCandidate = false,
9574
10016
  ): Promise<void> {
9575
10017
  let strictManagedFallbackEntries: FileEntry[] | undefined;
9576
10018
  let strictManagedFallbackMigrationApplied = false;
@@ -9602,18 +10044,25 @@ export class SessionManager {
9602
10044
  boundedTranscriptAdmitted &&
9603
10045
  this.#effectiveSessionMemoryMode(transcriptSize) === "enabled" &&
9604
10046
  this.#storage.existsSync(`${sidecarRoot}/.session-memory.spill.commit`);
9605
- if (boundedTranscriptAdmitted && (await this.#tryInitSessionFileFromSidecar(resolvedSessionFile))) {
9606
- this.#writeTerminalBreadcrumb(resolvedSessionFile);
10047
+ if (
10048
+ boundedTranscriptAdmitted &&
10049
+ (await this.#tryInitSessionFileFromSidecar(resolvedSessionFile, stagedRollbackCandidate))
10050
+ ) {
10051
+ if (!stagedRollbackCandidate) this.#writeTerminalBreadcrumb(resolvedSessionFile);
9607
10052
  revalidateStrictResume();
9608
10053
  return;
9609
10054
  }
9610
10055
 
9611
- if (boundedTranscriptAdmitted && (await this.#tryBoundedFirstOpen(resolvedSessionFile))) {
10056
+ if (
10057
+ !stagedRollbackCandidate &&
10058
+ boundedTranscriptAdmitted &&
10059
+ (await this.#tryBoundedFirstOpen(resolvedSessionFile))
10060
+ ) {
9612
10061
  if (publishedSidecarWasPresent && this.#lazyReopenAttempted && !this.#lazyReopenSucceeded) {
9613
10062
  this.#sessionMemoryMode = "shadow";
9614
10063
  this.#sessionMemoryAutoDisabledReason = "sidecar_reload_failures";
9615
10064
  }
9616
- this.#writeTerminalBreadcrumb(resolvedSessionFile);
10065
+ if (!stagedRollbackCandidate) this.#writeTerminalBreadcrumb(resolvedSessionFile);
9617
10066
  revalidateStrictResume();
9618
10067
  return;
9619
10068
  }
@@ -9624,12 +10073,13 @@ export class SessionManager {
9624
10073
  !this.#storage.existsSync(resolvedSessionFile);
9625
10074
  if ((strictResume || requireExisting) && transcriptMissing) throw new Error("Could not open session: unstable");
9626
10075
  if (initializeMissing && transcriptMissing) {
10076
+ if (stagedRollbackCandidate) throw new Error("Could not open session: unstable");
9627
10077
  const fresh = this.#freshSessionState(undefined, resolvedSessionFile);
9628
10078
  const prepared = this.#prepareFreshSessionTransition(fresh, "memory-fallback");
9629
10079
  this.#applyFreshSessionMetadata(fresh);
9630
10080
  this.#commitResidentTextStoreTransition(prepared);
9631
10081
  this.#retireEphemeralArtifacts();
9632
- this.#writeTerminalBreadcrumb(resolvedSessionFile);
10082
+ if (!stagedRollbackCandidate) this.#writeTerminalBreadcrumb(resolvedSessionFile);
9633
10083
  await this.#rewriteFile();
9634
10084
  this.#flushed = true;
9635
10085
  this.#ensuredOnDisk = true;
@@ -9652,12 +10102,13 @@ export class SessionManager {
9652
10102
  const entries = strictManagedFallbackEntries ?? (await loadEntriesFromFile(resolvedSessionFile, this.#storage));
9653
10103
  revalidateStrictResume();
9654
10104
  if (entries.length === 0) {
10105
+ if (stagedRollbackCandidate) throw new Error("Could not open session: unstable");
9655
10106
  const fresh = this.#freshSessionState(undefined, resolvedSessionFile);
9656
10107
  const prepared = this.#prepareFreshSessionTransition(fresh, "memory-fallback");
9657
10108
  this.#applyFreshSessionMetadata(fresh);
9658
10109
  this.#commitResidentTextStoreTransition(prepared);
9659
10110
  this.#retireEphemeralArtifacts();
9660
- this.#writeTerminalBreadcrumb(resolvedSessionFile);
10111
+ if (!stagedRollbackCandidate) this.#writeTerminalBreadcrumb(resolvedSessionFile);
9661
10112
  await this.#rewriteFile();
9662
10113
  this.#flushed = true;
9663
10114
  this.#ensuredOnDisk = true;
@@ -9690,7 +10141,7 @@ export class SessionManager {
9690
10141
  this.#titleSource = header?.titleSource;
9691
10142
  this.#needsFullRewriteOnNextPersist = migrationApplied;
9692
10143
  this.#commitResidentTextStoreTransition(prepared);
9693
- this.#writeTerminalBreadcrumb(resolvedSessionFile);
10144
+ if (!stagedRollbackCandidate) this.#writeTerminalBreadcrumb(resolvedSessionFile);
9694
10145
  this.#flushed = true;
9695
10146
  this.#ensuredOnDisk = true;
9696
10147
  this.#adoptManagedPersistIdentity(resolvedSessionFile);
@@ -9748,8 +10199,12 @@ export class SessionManager {
9748
10199
 
9749
10200
  /** Switch to a different session file (used for resume and branching). */
9750
10201
  async setSessionFile(sessionFile: string, options?: { deferEphemeralArtifactRetirement?: boolean }): Promise<void> {
10202
+ this.#assertArtifactOpen();
9751
10203
  this.#assertRecoveryHydrationWritable();
9752
10204
  const resolvedSessionFile = this.#storage instanceof FileSessionStorage ? path.resolve(sessionFile) : sessionFile;
10205
+ const lifecycle = this.#syncArtifactLifecycle();
10206
+ const predecessorId = this.#sessionId;
10207
+ const predecessorFile = this.#sessionFile;
9753
10208
  const strictAdoption = this.#pendingStrictAdoption;
9754
10209
  let managedTransition: ManagedDestinationTransition | undefined;
9755
10210
  if (this.destination.kind === "managed") {
@@ -9782,31 +10237,115 @@ export class SessionManager {
9782
10237
  targetSize <= BOUNDED_RESUME_TRANSCRIPT_MAX_BYTES &&
9783
10238
  this.#effectiveSessionMemoryMode(targetSize) === "enabled";
9784
10239
  if (boundedTarget) {
10240
+ const expectedIdentity =
10241
+ this.destination.kind === "managed"
10242
+ ? this.#captureManagedPersistIdentity(resolvedSessionFile, managedTransition?.store)
10243
+ : undefined;
10244
+ const expectedStat = this.#storage.statSync(resolvedSessionFile);
9785
10245
  const previous = await this.captureRollbackState();
10246
+ let installedGeneration: symbol | undefined;
10247
+ let installedSessionId: string | undefined;
10248
+ let installedSessionFile: string | undefined;
9786
10249
  try {
9787
10250
  await this.#closePersistWriter();
10251
+ await SessionManagerTestHooks.beforeManagedSwitchIdentity?.(resolvedSessionFile, this.#storage);
10252
+ this.#assertArtifactOpen();
10253
+ if (
10254
+ lifecycle !== this.#syncArtifactLifecycle() ||
10255
+ predecessorId !== this.#sessionId ||
10256
+ predecessorFile !== this.#sessionFile
10257
+ )
10258
+ throw new Error("Session changed before adoption commit.");
10259
+ if (!util.isDeepStrictEqual(expectedStat, this.#storage.statSync(resolvedSessionFile)))
10260
+ throw new Error("Prepared session changed before adoption commit.");
10261
+ if (
10262
+ expectedIdentity &&
10263
+ !util.isDeepStrictEqual(
10264
+ expectedIdentity,
10265
+ this.#captureManagedPersistIdentity(resolvedSessionFile, managedTransition?.store),
10266
+ )
10267
+ )
10268
+ throw new Error("Prepared session changed before adoption commit.");
9788
10269
  managedTransition?.adopt();
9789
- await this.#initSessionFile(resolvedSessionFile);
10270
+ await this.#initSessionFile(resolvedSessionFile, false, undefined, true, true);
10271
+ if (this.#artifactLifecycle === lifecycle && this.#sessionFile === resolvedSessionFile) {
10272
+ installedGeneration = this.#artifactLifecycle;
10273
+ installedSessionId = this.#sessionId;
10274
+ installedSessionFile = this.#sessionFile;
10275
+ }
10276
+ this.#assertArtifactOpen();
10277
+ if (this.#artifactLifecycle !== lifecycle || this.#sessionFile !== resolvedSessionFile)
10278
+ throw new Error("Session changed before adoption commit.");
10279
+ if (expectedIdentity) {
10280
+ if (
10281
+ !util.isDeepStrictEqual(
10282
+ expectedIdentity,
10283
+ this.#captureManagedPersistIdentity(resolvedSessionFile),
10284
+ )
10285
+ )
10286
+ throw new Error("Prepared session changed before adoption commit.");
10287
+ } else if (!util.isDeepStrictEqual(expectedStat, this.#storage.statSync(resolvedSessionFile))) {
10288
+ throw new Error("Prepared session changed before adoption commit.");
10289
+ }
10290
+ this.#artifactManager = null;
10291
+ this.#artifactManagerSessionFile = null;
10292
+ this.#artifactLifecycle = Symbol("session-artifact-adoption");
10293
+ installedGeneration = this.#artifactLifecycle;
10294
+ installedSessionId = this.#sessionId;
10295
+ installedSessionFile = this.#sessionFile;
10296
+ this.#persistError = undefined;
10297
+ this.#closeRetryOriginError = undefined;
10298
+ this.#persistErrorReported = false;
9790
10299
  if (!options?.deferEphemeralArtifactRetirement) this.#retireEphemeralArtifacts();
9791
- managedTransition?.settle();
9792
10300
  this.#pendingStrictAdoption = undefined;
10301
+ try {
10302
+ managedTransition?.settle();
10303
+ } catch (settleError) {
10304
+ logger.warn("Failed to settle managed authority after bounded adoption", {
10305
+ error: toError(settleError).message,
10306
+ });
10307
+ }
9793
10308
  return;
9794
10309
  } catch (error) {
9795
- managedTransition?.rollback();
9796
- await this.restoreRollbackState(previous);
10310
+ const ownsInstalledGeneration =
10311
+ installedGeneration !== undefined &&
10312
+ this.#artifactLifecycle === installedGeneration &&
10313
+ this.#sessionId === installedSessionId &&
10314
+ this.#sessionFile === installedSessionFile;
10315
+ const ownsPredecessorGeneration =
10316
+ this.#artifactLifecycle === lifecycle &&
10317
+ this.#sessionId === predecessorId &&
10318
+ this.#sessionFile === predecessorFile;
10319
+ if (ownsInstalledGeneration) {
10320
+ managedTransition?.rollback();
10321
+ await this.restoreRollbackState(previous);
10322
+ } else if (ownsPredecessorGeneration) {
10323
+ managedTransition?.rollback();
10324
+ } else {
10325
+ try {
10326
+ managedTransition?.settle();
10327
+ } catch (settleError) {
10328
+ logger.warn("Failed to settle superseded managed authority", {
10329
+ error: toError(settleError).message,
10330
+ });
10331
+ }
10332
+ }
9797
10333
  throw error;
9798
10334
  }
9799
10335
  }
9800
10336
  }
9801
10337
  let entries: FileEntry[];
10338
+ let candidateIdentity: ResumeSessionIdentity | undefined;
9802
10339
  let candidateMigrationApplied = false;
9803
10340
  if (strictAdoption?.inspection && resolvedSessionFile === strictAdoption.canonicalPath) {
9804
10341
  entries = strictAdoption.inspection.entries;
10342
+ candidateIdentity = strictAdoption.identity;
9805
10343
  candidateMigrationApplied = strictAdoption.inspection.migrationApplied;
9806
10344
  } else if (this.#storage.existsSync(resolvedSessionFile)) {
9807
10345
  const inspected = inspectResumeSessionFile(resolvedSessionFile, this.#storage);
9808
10346
  if ("kind" in inspected) throw new Error(`Could not switch session: ${inspected.reason}`);
9809
10347
  entries = inspected.entries;
10348
+ candidateIdentity = inspected.identity;
9810
10349
  candidateMigrationApplied = inspected.migrationApplied;
9811
10350
  } else {
9812
10351
  entries = await loadEntriesFromFile(resolvedSessionFile, this.#storage);
@@ -9839,45 +10378,43 @@ export class SessionManager {
9839
10378
  prepared.dispose();
9840
10379
  throw error;
9841
10380
  }
9842
- if (strictAdoption) {
9843
- const inspected = inspectResumeSessionFile(resolvedSessionFile, this.#storage);
9844
- if ("kind" in inspected || !sameResumeIdentity(strictAdoption.identity, inspected.identity)) {
9845
- prepared.dispose();
9846
- throw new Error("Prepared session changed before strict adoption commit.");
10381
+ let expectedIdentity: ManagedFileIdentity | undefined;
10382
+ try {
10383
+ await SessionManagerTestHooks.beforeManagedSwitchIdentity?.(resolvedSessionFile, this.#storage);
10384
+ this.#assertArtifactOpen();
10385
+ if (
10386
+ lifecycle !== this.#syncArtifactLifecycle() ||
10387
+ predecessorId !== this.#sessionId ||
10388
+ predecessorFile !== this.#sessionFile
10389
+ )
10390
+ throw new Error("Session changed before adoption commit.");
10391
+ const expected = strictAdoption?.identity ?? candidateIdentity;
10392
+ if (expected) {
10393
+ const inspected = inspectResumeSessionFile(resolvedSessionFile, this.#storage);
10394
+ if ("kind" in inspected || !sameResumeIdentity(expected, inspected.identity))
10395
+ throw new Error("Prepared session changed before adoption commit.");
9847
10396
  }
10397
+ if (this.destination.kind === "managed")
10398
+ expectedIdentity = this.#captureManagedPersistIdentity(resolvedSessionFile, managedTransition?.store);
10399
+ writeTerminalBreadcrumb(this.cwd, resolvedSessionFile);
10400
+ } catch (error) {
10401
+ prepared.dispose();
10402
+ throw error;
9848
10403
  }
9849
- const previous = {
9850
- sessionId: this.#sessionId,
9851
- sessionName: this.#sessionName,
9852
- titleSource: this.#titleSource,
9853
- sessionFile: this.#sessionFile,
9854
- needsFullRewriteOnNextPersist: this.#needsFullRewriteOnNextPersist,
9855
- managedPersistExpectedIdentity: this.#managedPersistExpectedIdentity,
9856
- };
9857
- this.#persistError = undefined;
9858
- this.#persistErrorReported = false;
10404
+ managedTransition?.adopt();
9859
10405
  this.#sessionFile = resolvedSessionFile;
9860
10406
  this.#sessionId = sessionId;
9861
10407
  this.#sessionName = header?.title;
9862
10408
  this.#titleSource = header?.titleSource;
9863
10409
  this.#needsFullRewriteOnNextPersist = migrationApplied;
9864
- try {
9865
- managedTransition?.adopt();
9866
- writeTerminalBreadcrumb(this.cwd, resolvedSessionFile);
9867
- await SessionManagerTestHooks.beforeManagedSwitchIdentity?.(resolvedSessionFile, this.#storage);
9868
- this.#adoptManagedPersistIdentity(resolvedSessionFile);
9869
- this.#commitResidentTextStoreTransition(prepared);
9870
- } catch (error) {
9871
- managedTransition?.rollback();
9872
- this.#sessionId = previous.sessionId;
9873
- this.#sessionName = previous.sessionName;
9874
- this.#titleSource = previous.titleSource;
9875
- this.#sessionFile = previous.sessionFile;
9876
- this.#needsFullRewriteOnNextPersist = previous.needsFullRewriteOnNextPersist;
9877
- this.#managedPersistExpectedIdentity = previous.managedPersistExpectedIdentity;
9878
- prepared.dispose();
9879
- throw error;
9880
- }
10410
+ this.#managedPersistExpectedIdentity = expectedIdentity;
10411
+ this.#artifactManager = null;
10412
+ this.#artifactManagerSessionFile = null;
10413
+ this.#artifactLifecycle = Symbol("session-artifact-adoption");
10414
+ this.#commitResidentTextStoreTransition(prepared);
10415
+ this.#persistError = undefined;
10416
+ this.#closeRetryOriginError = undefined;
10417
+ this.#persistErrorReported = false;
9881
10418
  entries.length = 0;
9882
10419
  if (!options?.deferEphemeralArtifactRetirement) this.#retireEphemeralArtifacts();
9883
10420
  managedTransition?.settle();
@@ -9891,6 +10428,15 @@ export class SessionManager {
9891
10428
  const prepared = this.#prepareFreshSessionTransition(fresh, "retain-and-throw");
9892
10429
  try {
9893
10430
  await this.#closePersistWriter();
10431
+ this.#assertArtifactOpen();
10432
+ if (
10433
+ lifecycle !== this.#syncArtifactLifecycle() ||
10434
+ predecessorId !== this.#sessionId ||
10435
+ predecessorFile !== this.#sessionFile
10436
+ )
10437
+ throw new Error("Session changed before adoption commit.");
10438
+ if (this.#storage.existsSync(resolvedSessionFile))
10439
+ throw new Error("Prepared missing session changed before adoption commit.");
9894
10440
  } catch (error) {
9895
10441
  prepared.dispose();
9896
10442
  throw error;
@@ -9899,6 +10445,7 @@ export class SessionManager {
9899
10445
  lifecycleIdAdopted: this.#lifecycleIdAdopted,
9900
10446
  persistChain: this.#persistChain,
9901
10447
  persistError: this.#persistError,
10448
+ closeRetryOriginError: this.#closeRetryOriginError,
9902
10449
  persistErrorReported: this.#persistErrorReported,
9903
10450
  sessionId: this.#sessionId,
9904
10451
  sessionName: this.#sessionName,
@@ -9917,12 +10464,16 @@ export class SessionManager {
9917
10464
  managedTransition?.adopt();
9918
10465
  writeTerminalBreadcrumb(this.cwd, resolvedSessionFile);
9919
10466
  this.#commitResidentTextStoreTransition(prepared);
10467
+ // A session replacement starts a new writer lifecycle; never let a
10468
+ // certified close error from the predecessor authorize clearing its state.
10469
+ this.#closeRetryOriginError = undefined;
9920
10470
  if (!options?.deferEphemeralArtifactRetirement) this.#retireEphemeralArtifacts();
9921
10471
  } catch (error) {
9922
10472
  managedTransition?.rollback();
9923
10473
  this.#lifecycleIdAdopted = previous.lifecycleIdAdopted;
9924
10474
  this.#persistChain = previous.persistChain;
9925
10475
  this.#persistError = previous.persistError;
10476
+ this.#closeRetryOriginError = previous.closeRetryOriginError;
9926
10477
  this.#persistErrorReported = previous.persistErrorReported;
9927
10478
  this.#sessionId = previous.sessionId;
9928
10479
  this.#sessionName = previous.sessionName;
@@ -10372,6 +10923,14 @@ export class SessionManager {
10372
10923
  transition.dispose();
10373
10924
  throw error;
10374
10925
  }
10926
+ let expectedIdentity: ManagedFileIdentity | undefined;
10927
+ try {
10928
+ if (stage.flushed && stage.sessionFile && this.destination.kind === "managed")
10929
+ expectedIdentity = this.#captureManagedPersistIdentity(stage.sessionFile);
10930
+ } catch (error) {
10931
+ transition.dispose();
10932
+ throw error;
10933
+ }
10375
10934
  const predecessorRuntime = this.#sidecarRuntime;
10376
10935
  if (predecessorRuntime) this.#clearColdRuntimeAfterHydration(predecessorRuntime);
10377
10936
  this.#sidecarRuntime = undefined;
@@ -10382,6 +10941,7 @@ export class SessionManager {
10382
10941
  this.#lifecycleIdAdopted = true;
10383
10942
  this.#persistChain = Promise.resolve();
10384
10943
  this.#persistError = undefined;
10944
+ this.#closeRetryOriginError = undefined;
10385
10945
  this.#persistErrorReported = false;
10386
10946
  this.#sessionId = stage.sessionId;
10387
10947
  this.#sessionName = stage.sessionName;
@@ -10396,8 +10956,8 @@ export class SessionManager {
10396
10956
  this.#artifactManager = null;
10397
10957
  this.#artifactManagerSessionFile = null;
10398
10958
  this.#adoptedArtifactManager = null;
10959
+ this.#managedPersistExpectedIdentity = expectedIdentity;
10399
10960
  this.#commitResidentTextStoreTransition(transition);
10400
- if (stage.flushed) this.#adoptManagedPersistIdentity(stage.sessionFile);
10401
10961
  this.#retireEphemeralArtifacts();
10402
10962
  stage.committed = true;
10403
10963
  this.#preparedNewSessions.delete(stage);
@@ -10607,6 +11167,7 @@ export class SessionManager {
10607
11167
  await this.#closePersistWriter();
10608
11168
  this.#persistChain = Promise.resolve();
10609
11169
  this.#persistError = undefined;
11170
+ this.#closeRetryOriginError = undefined;
10610
11171
  this.#persistErrorReported = false;
10611
11172
  let forkArtifactPublication: ForkArtifactPublication | undefined;
10612
11173
  let forkTranscriptPublication: ForkTranscriptPublication | undefined;
@@ -11480,6 +12041,7 @@ export class SessionManager {
11480
12041
  const previousSessionDir = this.sessionDir;
11481
12042
  const previousSessionFile = this.#sessionFile;
11482
12043
  const previousDestination = this.destination;
12044
+ const previousPersistIdentity = this.#managedPersistExpectedIdentity;
11483
12045
 
11484
12046
  const nextDestination =
11485
12047
  this.#storage instanceof FileSessionStorage
@@ -11529,6 +12091,7 @@ export class SessionManager {
11529
12091
  }
11530
12092
  this.#persistChain = Promise.resolve();
11531
12093
  this.#persistError = undefined;
12094
+ this.#closeRetryOriginError = undefined;
11532
12095
  this.#persistErrorReported = false;
11533
12096
 
11534
12097
  const oldSessionFile = this.#sessionFile;
@@ -11801,6 +12364,7 @@ export class SessionManager {
11801
12364
  throw error;
11802
12365
  }
11803
12366
  }
12367
+ this.#artifactLifecycle = Symbol("session-artifact-move");
11804
12368
  this.#cwdGeneration += 1;
11805
12369
  const readLease = cwdReadLeaseAls.getStore();
11806
12370
  if (readLease?.owner === this.#cwdReadLeaseOwner) {
@@ -11839,6 +12403,7 @@ export class SessionManager {
11839
12403
  await this.#closePersistWriter().catch(() => {});
11840
12404
  this.#persistChain = Promise.resolve();
11841
12405
  this.#persistError = undefined;
12406
+ this.#closeRetryOriginError = undefined;
11842
12407
  this.#persistErrorReported = false;
11843
12408
  if (rollbackManagedMove) {
11844
12409
  try {
@@ -11863,6 +12428,14 @@ export class SessionManager {
11863
12428
  // transcript actually existed (and was thus restorable) before the failed move.
11864
12429
  if (hadSessionFile) this.#adoptManagedPersistIdentity(previousSessionFile);
11865
12430
  else this.#managedPersistExpectedIdentity = undefined;
12431
+ if (previousSessionFile && previousPersistIdentity && this.#managedPersistExpectedIdentity) {
12432
+ this.#managedRollbackIdentity = Object.freeze({
12433
+ sessionId: this.#sessionId,
12434
+ sessionFile: previousSessionFile,
12435
+ before: Object.freeze({ ...previousPersistIdentity }),
12436
+ after: Object.freeze({ ...this.#managedPersistExpectedIdentity }),
12437
+ });
12438
+ }
11866
12439
  const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
11867
12440
  if (header) applyHeaderPatch(header, { cwd: previousCwd });
11868
12441
  this.#headerExportRevision++;
@@ -12037,6 +12610,15 @@ export class SessionManager {
12037
12610
  }
12038
12611
  return normalized;
12039
12612
  }
12613
+ /** A successful lifecycle commit replaces the writer/session that certified this retry origin. */
12614
+ #resetCloseRetryOriginForLifecycleCommit(): void {
12615
+ const closeRetryOriginError = this.#closeRetryOriginError;
12616
+ this.#closeRetryOriginError = undefined;
12617
+ if (closeRetryOriginError && this.#persistError === closeRetryOriginError) {
12618
+ this.#persistError = undefined;
12619
+ this.#persistErrorReported = false;
12620
+ }
12621
+ }
12040
12622
 
12041
12623
  #queuePersistTask(task: () => Promise<void>, options?: { ignoreError?: boolean }): Promise<void> {
12042
12624
  const next = this.#persistChain.then(async () => {
@@ -12123,7 +12705,19 @@ export class SessionManager {
12123
12705
 
12124
12706
  async #closePersistWriterInternal(): Promise<void> {
12125
12707
  if (this.#persistWriter) {
12126
- await this.#persistWriter.close();
12708
+ const writer = this.#persistWriter;
12709
+ try {
12710
+ await writer.close();
12711
+ } catch (error) {
12712
+ if (writer.getCloseState() === "close_failed_retryable" && !this.#closeRetryOriginError) {
12713
+ const closeError = writer.getCloseError() ?? toError(error);
12714
+ this.#closeRetryOriginError = closeError;
12715
+ // Record the error immediately so the identity check in close() will pass
12716
+ // when the retry succeeds, even if toError() is called again later.
12717
+ if (!this.#persistError) this.#persistError = closeError;
12718
+ }
12719
+ throw error;
12720
+ }
12127
12721
  this.#persistWriter = undefined;
12128
12722
  }
12129
12723
  this.#persistWriterPath = undefined;
@@ -12670,9 +13264,9 @@ export class SessionManager {
12670
13264
  // =========================================================================
12671
13265
 
12672
13266
  /** Create (or reset) the sidecar runtime for the current lifecycle. */
12673
- #resetSidecarRuntime(ineligible = false): SessionMemorySidecarRuntime {
13267
+ #resetSidecarRuntime(ineligible = false, preserveLegacySidecars = false): SessionMemorySidecarRuntime {
12674
13268
  this.#sidecarBranchActivationDirty = false;
12675
- if (this.#sessionFile && this.destination.kind !== "managed") {
13269
+ if (!preserveLegacySidecars && this.#sessionFile && this.destination.kind !== "managed") {
12676
13270
  for (const legacyPath of [
12677
13271
  `${this.#sessionFile}.spill.idx`,
12678
13272
  `${this.#sessionFile}.spill.tail`,
@@ -15708,9 +16302,92 @@ export class SessionManager {
15708
16302
  this.#managedPersistExpectedIdentity = this.#captureManagedPersistIdentity(sessionFile);
15709
16303
  }
15710
16304
 
16305
+ #captureExplicitPersistIdentity(sessionFile: string): ExplicitPersistIdentity {
16306
+ const storage = this.#storage;
16307
+ if (!(storage instanceof FileSessionStorage) || !storage.readRangeSync)
16308
+ throw new Error("Session rollback persistence identity is unavailable.");
16309
+ const canonicalPath = resolveEquivalentPath(path.resolve(sessionFile));
16310
+ let before: SessionStorageStat;
16311
+ try {
16312
+ before = storage.statSync(canonicalPath);
16313
+ } catch {
16314
+ throw new Error("Session rollback persistence identity is unavailable.");
16315
+ }
16316
+ const nlink = before.nlink;
16317
+ if (
16318
+ !before.isFile ||
16319
+ typeof nlink !== "bigint" ||
16320
+ nlink !== 1n ||
16321
+ before.size <= 0 ||
16322
+ before.size > BOUNDED_RESUME_TRANSCRIPT_MAX_BYTES
16323
+ )
16324
+ throw new Error("Session rollback persistence identity is unavailable.");
16325
+
16326
+ const fingerprint = crypto.createHash("sha256");
16327
+ const addRange = (start: number, length: number): Uint8Array => {
16328
+ const range = storage.readRangeSync!(canonicalPath, start, length);
16329
+ if (range.bytes.byteLength !== length || !sameResumeStat(before, range.stat))
16330
+ throw new Error("Session rollback persistence identity is unavailable.");
16331
+ const frame = Buffer.allocUnsafe(12);
16332
+ frame.writeBigUInt64BE(BigInt(start), 0);
16333
+ frame.writeUInt32BE(length, 8);
16334
+ fingerprint.update(frame).update(range.bytes);
16335
+ return range.bytes;
16336
+ };
16337
+ let sessionId: string | undefined;
16338
+ try {
16339
+ const prefixLength = Math.min(before.size, BOUNDED_FIRST_OPEN_MAX_LINE_BYTES + 1);
16340
+ const prefix = addRange(0, prefixLength);
16341
+ const consumer = createBoundedLineChunkConsumer((_lineStart, lineBytes) => {
16342
+ if (lineBytes.byteLength === 1 && lineBytes[0] === 0x0a) return;
16343
+ if (lineBytes.at(-1) !== 0x0a && prefixLength !== before.size) return false;
16344
+ try {
16345
+ const header = JSON.parse(decodeBoundedJsonLine(lineBytes)) as Partial<SessionHeader>;
16346
+ if (header.type !== "session" || typeof header.id !== "string") return false;
16347
+ sessionId = header.id;
16348
+ return false;
16349
+ } catch {
16350
+ return false;
16351
+ }
16352
+ });
16353
+ const consumeFailure = consumer.consume(Buffer.from(prefix.buffer, prefix.byteOffset, prefix.byteLength), 0);
16354
+ const prefixFailure = consumeFailure ?? consumer.finish(prefixLength === before.size);
16355
+ if (prefixFailure !== "aborted" || sessionId === undefined) throw new Error("invalid_session_header");
16356
+
16357
+ const rangeLength = Math.min(EXPLICIT_PERSIST_FINGERPRINT_RANGE_BYTES, before.size);
16358
+ const lastStart = before.size - rangeLength;
16359
+ const starts = new Set([Math.floor(lastStart / 3), Math.floor((lastStart * 2) / 3), lastStart]);
16360
+ for (const start of starts) {
16361
+ if (start + rangeLength <= prefixLength) continue;
16362
+ addRange(start, rangeLength);
16363
+ }
16364
+ const after = storage.statSync(canonicalPath);
16365
+ if (!sameResumeStat(before, after)) throw new Error("source_changed");
16366
+ } catch {
16367
+ throw new Error("Session rollback persistence identity is unavailable.");
16368
+ }
16369
+ if (sessionId === undefined) throw new Error("Session rollback persistence identity is unavailable.");
16370
+ return {
16371
+ canonicalPath,
16372
+ sessionId,
16373
+ // Convert bigints to strings for JSON safety
16374
+ dev: String(before.dev),
16375
+ ino: String(before.ino),
16376
+ nlink: nlink !== undefined ? String(nlink) : undefined,
16377
+ size: before.size,
16378
+ mtimeMs: before.mtimeMs,
16379
+ mtimeNs: String(before.mtimeNs),
16380
+ ctimeNs: before.ctimeNs !== undefined ? String(before.ctimeNs) : undefined,
16381
+ fingerprintSha256: fingerprint.digest("hex"),
16382
+ };
16383
+ }
16384
+
15711
16385
  /** Capture one descriptor-bound digest for a future metadata-drift comparison. */
15712
- #captureManagedPersistIdentity(sessionFile: string): ManagedFileIdentity {
15713
- const store = this.#managedTranscriptStore(sessionFile);
16386
+ #captureManagedPersistIdentity(
16387
+ sessionFile: string,
16388
+ candidateStore?: ManagedSessionDescendantStore,
16389
+ ): ManagedFileIdentity {
16390
+ const store = candidateStore ?? this.#managedTranscriptStore(sessionFile);
15714
16391
  const relativePath = path.basename(sessionFile);
15715
16392
  const bounded = store.captureBoundedAppendExpectation(relativePath);
15716
16393
  const descriptor = store.descriptorExpected(relativePath);
@@ -16371,6 +17048,7 @@ export class SessionManager {
16371
17048
 
16372
17049
  /** Close the persistent writer after flushing all pending data. */
16373
17050
  async close(): Promise<void> {
17051
+ this.#beginArtifactClose();
16374
17052
  await this.closeCwdMoveAdmission();
16375
17053
  await this.joinCwdTransition();
16376
17054
  await this.joinCwdReaders();
@@ -16386,6 +17064,7 @@ export class SessionManager {
16386
17064
  }
16387
17065
  let closeError: unknown;
16388
17066
  let taskStarted = false;
17067
+ const closeRetryOriginError = this.#closeRetryOriginError;
16389
17068
  try {
16390
17069
  await this.#queuePersistTask(
16391
17070
  async () => {
@@ -16398,8 +17077,19 @@ export class SessionManager {
16398
17077
  },
16399
17078
  { ignoreError: this.#closeRetryPending },
16400
17079
  );
16401
- this.#persistError = undefined;
16402
- this.#persistErrorReported = false;
17080
+ if (
17081
+ closeRetryOriginError &&
17082
+ !this.#persistWriter &&
17083
+ !this.#needsFullRewriteOnNextPersist &&
17084
+ !this.#strictResumeMutationPending &&
17085
+ this.#persistError === closeRetryOriginError
17086
+ ) {
17087
+ // Only the original certified writer-close failure becomes obsolete
17088
+ // after closure. Lifecycle/publication errors must remain observable.
17089
+ this.#persistError = undefined;
17090
+ this.#persistErrorReported = false;
17091
+ this.#closeRetryOriginError = undefined;
17092
+ }
16403
17093
  this.#closeRetryPending = false;
16404
17094
  this.#retireEphemeralArtifacts();
16405
17095
  await this.#drainEphemeralArtifactCleanups();
@@ -16409,6 +17099,8 @@ export class SessionManager {
16409
17099
  }
16410
17100
  const terminalError = closeError ?? this.#persistError;
16411
17101
  if (terminalError) throw terminalError;
17102
+ this.#persistError = undefined;
17103
+ this.#persistErrorReported = false;
16412
17104
  this.#releaseResidentTextStore();
16413
17105
  if (this.#preparedNewSessions.size === 0) this.#releaseOwnedManagedAuthority();
16414
17106
  this.#releaseClosedSessionState();
@@ -16438,6 +17130,7 @@ export class SessionManager {
16438
17130
  * writer closure before any destructive operation.
16439
17131
  */
16440
17132
  async closeStrict(): Promise<SessionManagerCloseOutcome> {
17133
+ this.#beginArtifactClose();
16441
17134
  this.#strictClosePending = true;
16442
17135
  await this.closeCwdMoveAdmission();
16443
17136
  await this.joinCwdTransition();
@@ -16466,6 +17159,7 @@ export class SessionManager {
16466
17159
  this.#strictResumeMutationPending = false;
16467
17160
  this.#managedPersistExpectedIdentity = undefined;
16468
17161
  this.#persistError = undefined;
17162
+ this.#closeRetryOriginError = undefined;
16469
17163
  this.#persistErrorReported = false;
16470
17164
  }
16471
17165
  let priorPersistError = this.#persistError;
@@ -17077,27 +17771,72 @@ export class SessionManager {
17077
17771
  * the parent session's artifact directory and ID counter.
17078
17772
  */
17079
17773
  adoptArtifactManager(manager: ArtifactManager, parent?: ArtifactManager): void {
17774
+ this.#assertArtifactOpen();
17775
+ if (this.#adoptedArtifactManager !== manager) this.#artifactLifecycle = Symbol("artifact-adoption");
17080
17776
  this.#adoptedArtifactManager = manager;
17081
17777
  if (parent) this.#stagedArtifactParent = parent;
17082
17778
  }
17083
17779
 
17084
17780
  /** Release only the matching externally adopted manager. */
17085
17781
  releaseArtifactManager(manager: ArtifactManager): void {
17086
- if (this.#adoptedArtifactManager === manager) this.#adoptedArtifactManager = null;
17782
+ if (this.#adoptedArtifactManager === manager) {
17783
+ this.#adoptedArtifactManager = null;
17784
+ this.#artifactLifecycle = Symbol("artifact-release");
17785
+ }
17087
17786
  }
17088
17787
 
17089
17788
  /** Temporarily release adopted authority while an outer transition validates its successor. */
17090
17789
  stageAdoptedArtifactManagerForTransition(): void {
17091
- this.#adoptedArtifactManager = null;
17790
+ if (this.#adoptedArtifactManager) this.releaseArtifactManager(this.#adoptedArtifactManager);
17092
17791
  }
17093
17792
 
17094
17793
  /** Prove manager authority by exact object identity, never by pathname shape. */
17095
17794
  isArtifactManagerAuthorized(manager: ArtifactManager): boolean {
17096
- return (
17097
- manager === this.#adoptedArtifactManager ||
17098
- manager === this.#artifactManager ||
17099
- manager === this.#ephemeralArtifactManager
17100
- );
17795
+ if (this.#artifactClosing || this.#strictClosePending) return false;
17796
+ if (this.#adoptedArtifactManager) return manager === this.#adoptedArtifactManager;
17797
+ if (this.#sessionFile)
17798
+ return manager === this.#artifactManager && this.#artifactManagerSessionFile === this.#sessionFile;
17799
+ return manager === this.#ephemeralArtifactManager;
17800
+ }
17801
+
17802
+ #syncArtifactLifecycle(): symbol {
17803
+ if (
17804
+ this.#artifactLifecycleSessionId !== this.#sessionId ||
17805
+ this.#artifactLifecycleSessionFile !== this.#sessionFile
17806
+ ) {
17807
+ this.#artifactLifecycleSessionId = this.#sessionId;
17808
+ this.#artifactLifecycleSessionFile = this.#sessionFile;
17809
+ this.#artifactLifecycle = Symbol("session-artifact-transition");
17810
+ }
17811
+ return this.#artifactLifecycle;
17812
+ }
17813
+
17814
+ #assertArtifactOpen(): void {
17815
+ if (this.#artifactClosing || this.#strictClosePending) throw new Error("Session manager is closing.");
17816
+ }
17817
+
17818
+ #beginArtifactClose(): void {
17819
+ if (!this.#artifactClosing) {
17820
+ this.#artifactClosing = true;
17821
+ this.#artifactLifecycle = Symbol("session-artifact-close");
17822
+ }
17823
+ }
17824
+
17825
+ #assertArtifactContinuation(
17826
+ token: symbol,
17827
+ sessionId: string,
17828
+ sessionFile: string | undefined,
17829
+ manager: ArtifactManager,
17830
+ ): void {
17831
+ this.#assertArtifactOpen();
17832
+ if (
17833
+ token !== this.#syncArtifactLifecycle() ||
17834
+ sessionId !== this.#sessionId ||
17835
+ sessionFile !== this.#sessionFile ||
17836
+ !this.isArtifactManagerAuthorized(manager)
17837
+ )
17838
+ throw new Error("Session artifact continuation is no longer authorized.");
17839
+ manager.assertManagedBinding();
17101
17840
  }
17102
17841
 
17103
17842
  /**
@@ -17105,16 +17844,26 @@ export class SessionManager {
17105
17844
  * one bound to the current session file unless an external manager was
17106
17845
  * adopted via `adoptArtifactManager`. Falls back to the lazily created
17107
17846
  * ephemeral filesystem store once a non-persistent session has saved an
17108
- * artifact, so `artifact://` stays resolvable. Returns null only when no
17109
- * store has been established yet.
17847
+ * artifact, so `artifact://` stays resolvable. Returns null when no store has
17848
+ * been established yet and once the manager has released its artifact
17849
+ * authority while closing — released authority stays observable as absence
17850
+ * (matching `isArtifactManagerAuthorized`), while artifact *operations* are
17851
+ * fenced by `#assertArtifactOpen()` and keep throwing.
17110
17852
  */
17111
17853
  getArtifactManager(): ArtifactManager | null {
17854
+ if (this.#artifactClosing || this.#strictClosePending) return null;
17112
17855
  return this.#getOrCreateArtifactManager() ?? this.#ephemeralArtifactManager;
17113
17856
  }
17114
17857
 
17115
17858
  /** Linearizably establish this session's persistent or ephemeral artifact manager. */
17116
17859
  async ensureArtifactManager(): Promise<ArtifactManager | null> {
17117
- return this.#getOrCreateArtifactManager() ?? (await this.#ensureEphemeralArtifactManager());
17860
+ this.#assertArtifactOpen();
17861
+ const token = this.#syncArtifactLifecycle();
17862
+ const sessionId = this.#sessionId;
17863
+ const sessionFile = this.#sessionFile;
17864
+ const manager = this.#getOrCreateArtifactManager() ?? (await this.#ensureEphemeralArtifactManager());
17865
+ if (manager) this.#assertArtifactContinuation(token, sessionId, sessionFile, manager);
17866
+ return manager;
17118
17867
  }
17119
17868
 
17120
17869
  /**
@@ -17122,6 +17871,8 @@ export class SessionManager {
17122
17871
  * Recreates the manager when the active session file changes.
17123
17872
  */
17124
17873
  #getOrCreateArtifactManager(): ArtifactManager | null {
17874
+ this.#assertArtifactOpen();
17875
+ this.#syncArtifactLifecycle();
17125
17876
  if (this.#adoptedArtifactManager) return this.#adoptedArtifactManager;
17126
17877
  const sessionFile = this.#sessionFile;
17127
17878
  if (!sessionFile) {
@@ -17152,7 +17903,14 @@ export class SessionManager {
17152
17903
  async allocateArtifactPath(toolType: string): Promise<{ id?: string; path?: string }> {
17153
17904
  const manager = this.#getOrCreateArtifactManager();
17154
17905
  if (!manager) return {};
17155
- return manager.allocatePath(toolType);
17906
+ const token = this.#syncArtifactLifecycle();
17907
+ const sessionId = this.#sessionId;
17908
+ const sessionFile = this.#sessionFile;
17909
+ const allocation = await manager.allocatePath(toolType);
17910
+ // A reservation is not a grant to a caller whose session changed during
17911
+ // initialization. No writable pathname escapes before the final fence.
17912
+ this.#assertArtifactContinuation(token, sessionId, sessionFile, manager);
17913
+ return allocation;
17156
17914
  }
17157
17915
 
17158
17916
  /**
@@ -17162,8 +17920,49 @@ export class SessionManager {
17162
17920
  * read back from the filesystem instead of being retained in memory.
17163
17921
  */
17164
17922
  async saveArtifact(content: string, toolType: string): Promise<string | undefined> {
17923
+ this.#assertArtifactOpen();
17924
+ if (!/^[a-zA-Z0-9_-]+$/.test(toolType)) throw new Error("Unsafe artifact tool type");
17925
+ const token = this.#syncArtifactLifecycle();
17926
+ const sessionId = this.#sessionId;
17927
+ const sessionFile = this.#sessionFile;
17165
17928
  const manager = this.#getOrCreateArtifactManager() ?? (await this.#ensureEphemeralArtifactManager());
17166
- return manager ? manager.save(content, toolType) : undefined;
17929
+ if (!manager) return undefined;
17930
+ this.#assertArtifactContinuation(token, sessionId, sessionFile, manager);
17931
+ const { id } = await manager.allocatePath(toolType);
17932
+ this.#assertArtifactContinuation(token, sessionId, sessionFile, manager);
17933
+ const contentBytes = Buffer.byteLength(content, "utf-8");
17934
+ const truncated =
17935
+ contentBytes > DEFAULT_ARTIFACT_MAX_BYTES ? truncateHeadBytes(content, DEFAULT_ARTIFACT_MAX_BYTES) : undefined;
17936
+ const published = truncated
17937
+ ? `${truncated.text}\n[artifact truncated after ${truncated.bytes} bytes; omitted at least ${contentBytes - truncated.bytes} bytes]\n`
17938
+ : content;
17939
+ const filename = `${id}.${toolType}.log`;
17940
+ const store = manager.getManagedStore();
17941
+ if (store) store.publishNoReplaceSync(filename, Buffer.from(published, "utf8"));
17942
+ else publishManagedFileNoReplaceSync(path.join(manager.dir, filename), Buffer.from(published, "utf8"));
17943
+ return id;
17944
+ }
17945
+
17946
+ /** Capture artifact publication authority for one session lifecycle. */
17947
+ captureArtifactPublication(): SessionArtifactPublication {
17948
+ this.#assertArtifactOpen();
17949
+ const lifecycle = this.#syncArtifactLifecycle();
17950
+ const sessionId = this.#sessionId;
17951
+ const sessionFile = this.#sessionFile;
17952
+ const manager = this.#getOrCreateArtifactManager() ?? this.#ephemeralArtifactManager;
17953
+ if (manager) this.#assertArtifactContinuation(lifecycle, sessionId, sessionFile, manager);
17954
+
17955
+ return async (content, toolType) => {
17956
+ this.#assertArtifactOpen();
17957
+ if (
17958
+ lifecycle !== this.#syncArtifactLifecycle() ||
17959
+ sessionId !== this.#sessionId ||
17960
+ sessionFile !== this.#sessionFile
17961
+ )
17962
+ throw new Error("Session artifact continuation is no longer authorized.");
17963
+ if (manager) this.#assertArtifactContinuation(lifecycle, sessionId, sessionFile, manager);
17964
+ return this.saveArtifact(content, toolType);
17965
+ };
17167
17966
  }
17168
17967
 
17169
17968
  async #validatedEvictedToolOutputHandle(
@@ -17271,6 +18070,7 @@ export class SessionManager {
17271
18070
  * Returns null when the artifact is missing.
17272
18071
  */
17273
18072
  async getArtifactPath(id: string): Promise<string | null> {
18073
+ this.#assertArtifactOpen();
17274
18074
  const manager = this.getArtifactManager();
17275
18075
  if (!manager) return null;
17276
18076
  return manager.getPath(id);
@@ -20653,7 +21453,13 @@ export class SessionManager {
20653
21453
  await parentArtifacts.commitAttemptStaging(stagedManager, staged.attemptId, {
20654
21454
  beforePublish: idMap => this.remapStagedArtifactReferences(idMap),
20655
21455
  });
20656
- } else await stagedManager.discardAttemptStaging();
21456
+ } else {
21457
+ try {
21458
+ await stagedManager.discardAttemptStaging();
21459
+ } catch (error) {
21460
+ if (!isAuthorizedPendingCleanup(toError(error))) throw error;
21461
+ }
21462
+ }
20657
21463
  }
20658
21464
  if (staged.managedParentStore && staged.managedStagingStore) {
20659
21465
  const stagedName = path.basename(staged.stagedSessionFile);