@gajae-code/coding-agent 0.11.7 → 0.11.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 (180) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/dist/types/cli/args.d.ts +0 -2
  3. package/dist/types/config/keybindings.d.ts +33 -3
  4. package/dist/types/config/model-profile-activation.d.ts +4 -3
  5. package/dist/types/config/model-profile-contract.d.ts +38 -0
  6. package/dist/types/config/settings-schema.d.ts +15 -2
  7. package/dist/types/edit/modes/patch.d.ts +13 -0
  8. package/dist/types/edit/path-mutation-lock.d.ts +15 -0
  9. package/dist/types/eval/py/executor.d.ts +1 -1
  10. package/dist/types/eval/py/tool-bridge.d.ts +1 -2
  11. package/dist/types/gjc-runtime/repository-binding.d.ts +63 -0
  12. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +1 -0
  13. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +24 -0
  14. package/dist/types/main.d.ts +8 -0
  15. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
  16. package/dist/types/modes/components/tool-status-header.d.ts +3 -2
  17. package/dist/types/modes/components/welcome.d.ts +2 -0
  18. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  19. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  20. package/dist/types/modes/interactive-mode.d.ts +5 -2
  21. package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
  22. package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
  23. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
  24. package/dist/types/modes/types.d.ts +3 -0
  25. package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
  26. package/dist/types/runtime-mcp/manager.d.ts +1 -1
  27. package/dist/types/runtime-mcp/oauth-flow.d.ts +0 -5
  28. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +14 -0
  29. package/dist/types/sdk/broker/broker.d.ts +2 -0
  30. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  31. package/dist/types/sdk/bus/chat-daemon-control.d.ts +5 -1
  32. package/dist/types/sdk/bus/config-commands.d.ts +2 -2
  33. package/dist/types/sdk/bus/config.d.ts +2 -0
  34. package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
  35. package/dist/types/sdk/bus/index.d.ts +25 -0
  36. package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
  37. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +105 -0
  38. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +13 -3
  39. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
  40. package/dist/types/sdk/bus/telegram-daemon.d.ts +128 -1
  41. package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
  42. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  43. package/dist/types/sdk/host/host.d.ts +1 -1
  44. package/dist/types/sdk/host/query/handlers.d.ts +9 -0
  45. package/dist/types/sdk/index.d.ts +2 -0
  46. package/dist/types/sdk/prompt-status.d.ts +88 -0
  47. package/dist/types/sdk/session-directory.d.ts +1 -1
  48. package/dist/types/sdk/startup-capability.d.ts +3 -0
  49. package/dist/types/session/agent-session.d.ts +2 -0
  50. package/dist/types/session/internal/managed-session-scope.d.ts +11 -2
  51. package/dist/types/session/internal/managed-session-storage.d.ts +17 -2
  52. package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
  53. package/dist/types/session/session-storage.d.ts +18 -0
  54. package/dist/types/task/index.d.ts +3 -1
  55. package/dist/types/task/receipt.d.ts +2 -0
  56. package/dist/types/task/types.d.ts +94 -0
  57. package/dist/types/tools/ask.d.ts +454 -174
  58. package/dist/types/tools/image-gen.d.ts +47 -1
  59. package/dist/types/tools/index.d.ts +4 -0
  60. package/dist/types/utils/prompt-suggestion.d.ts +33 -0
  61. package/package.json +8 -8
  62. package/scripts/capture-platform-shortcut-labels-showcase.ts +342 -0
  63. package/scripts/dogfood-repository-binding.ts +209 -0
  64. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  65. package/scripts/run-test-manifest.ts +4 -4
  66. package/src/cli/args.ts +2 -92
  67. package/src/cli/notify-cli.ts +5 -38
  68. package/src/cli.ts +18 -0
  69. package/src/commands/sdk.ts +2 -2
  70. package/src/config/keybindings.ts +157 -24
  71. package/src/config/model-profile-activation.ts +7 -18
  72. package/src/config/model-profile-contract.ts +161 -0
  73. package/src/config/model-registry.ts +11 -1
  74. package/src/config/settings-schema.ts +17 -2
  75. package/src/coordinator-mcp/model-preset.ts +21 -74
  76. package/src/defaults/gjc/skills/deep-interview/SKILL.md +2 -2
  77. package/src/defaults/gjc/skills/ralplan/SKILL.md +3 -1
  78. package/src/edit/index.ts +3 -6
  79. package/src/edit/modes/patch.ts +38 -1
  80. package/src/edit/modes/replace.ts +42 -0
  81. package/src/edit/path-mutation-lock.ts +112 -0
  82. package/src/eval/py/executor.ts +30 -11
  83. package/src/eval/py/prelude.py +2 -2
  84. package/src/eval/py/tool-bridge.ts +34 -18
  85. package/src/extensibility/gjc-plugins/runtime-adapters.ts +2 -1
  86. package/src/gjc-runtime/launch-tmux.ts +1 -1
  87. package/src/gjc-runtime/managed-owner-supervisor.ts +83 -4
  88. package/src/gjc-runtime/ralplan-runtime.ts +75 -3
  89. package/src/gjc-runtime/repository-binding.ts +267 -0
  90. package/src/gjc-runtime/session-state-sidecar.ts +25 -9
  91. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -1
  92. package/src/gjc-runtime/tmux-sessions.ts +45 -6
  93. package/src/gjc-runtime/ultragoal-guard.ts +5 -5
  94. package/src/gjc-runtime/ultragoal-runtime.ts +181 -19
  95. package/src/internal-urls/docs-index.generated.ts +6 -5
  96. package/src/internal-urls/local-protocol.ts +33 -16
  97. package/src/internal-urls/mcp-protocol.ts +73 -11
  98. package/src/main.ts +25 -10
  99. package/src/modes/DESIGN.md +5 -0
  100. package/src/modes/acp/acp-agent.ts +50 -16
  101. package/src/modes/components/model-selector.ts +42 -8
  102. package/src/modes/components/plan-preview-overlay.ts +1 -1
  103. package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
  104. package/src/modes/components/tool-status-header.ts +20 -4
  105. package/src/modes/components/welcome.ts +22 -18
  106. package/src/modes/controllers/command-controller.ts +97 -54
  107. package/src/modes/controllers/event-controller.ts +10 -0
  108. package/src/modes/controllers/input-controller.ts +7 -2
  109. package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
  110. package/src/modes/controllers/selector-controller.ts +46 -54
  111. package/src/modes/interactive-mode.ts +41 -27
  112. package/src/modes/prompt-action-autocomplete.ts +32 -13
  113. package/src/modes/prompt-suggestion-controller.ts +96 -0
  114. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
  115. package/src/modes/types.ts +3 -0
  116. package/src/modes/utils/hotkeys-markdown.ts +64 -36
  117. package/src/modes/utils/ui-helpers.ts +5 -3
  118. package/src/prompts/agents/architect.md +0 -1
  119. package/src/prompts/agents/critic.md +0 -1
  120. package/src/prompts/system/prompt-suggestion-system.md +32 -0
  121. package/src/prompts/system/system-prompt.md +6 -4
  122. package/src/prompts/tools/cron.md +3 -1
  123. package/src/runtime-mcp/manager.ts +51 -53
  124. package/src/runtime-mcp/oauth-flow.ts +0 -72
  125. package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
  126. package/src/runtime-mcp/transports/http.ts +13 -6
  127. package/src/sdk/broker/broker.ts +34 -20
  128. package/src/sdk/broker/lifecycle.ts +185 -36
  129. package/src/sdk/bus/chat-daemon-control.ts +18 -5
  130. package/src/sdk/bus/config-commands.ts +2 -2
  131. package/src/sdk/bus/config.ts +9 -1
  132. package/src/sdk/bus/conversation-store.ts +40 -6
  133. package/src/sdk/bus/index.ts +240 -65
  134. package/src/sdk/bus/lifecycle-commands.ts +47 -3
  135. package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
  136. package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
  137. package/src/sdk/bus/notification-orchestration.ts +12 -0
  138. package/src/sdk/bus/notification-service.ts +27 -2
  139. package/src/sdk/bus/prompt-reconciliation.ts +239 -0
  140. package/src/sdk/bus/recent-activity.ts +90 -26
  141. package/src/sdk/bus/telegram-daemon-contract.ts +13 -3
  142. package/src/sdk/bus/telegram-daemon-control.ts +145 -33
  143. package/src/sdk/bus/telegram-daemon.ts +835 -110
  144. package/src/sdk/bus/telegram-reference.ts +20 -2
  145. package/src/sdk/cli/session-cli.ts +15 -2
  146. package/src/sdk/host/control/dispatch.ts +1 -1
  147. package/src/sdk/host/control/operations.ts +1 -1
  148. package/src/sdk/host/host.ts +2 -6
  149. package/src/sdk/host/query/handlers.ts +70 -5
  150. package/src/sdk/index.ts +12 -0
  151. package/src/sdk/prompt-status.ts +97 -0
  152. package/src/sdk/protocol/operation-inventory.generated.json +47 -0
  153. package/src/sdk/protocol/operation-registry.ts +18 -4
  154. package/src/sdk/session-directory.ts +5 -1
  155. package/src/sdk/session.ts +2 -0
  156. package/src/sdk/startup-capability.ts +9 -1
  157. package/src/session/agent-session.ts +52 -5
  158. package/src/session/blob-store.ts +7 -2
  159. package/src/session/internal/managed-session-scope.ts +854 -84
  160. package/src/session/internal/managed-session-storage.ts +251 -199
  161. package/src/session/internal/native-publish-outcome.ts +266 -0
  162. package/src/session/session-manager.ts +310 -327
  163. package/src/session/session-storage.ts +128 -11
  164. package/src/task/gjc-command.ts +2 -2
  165. package/src/task/index.ts +121 -15
  166. package/src/task/receipt.ts +3 -0
  167. package/src/task/types.ts +34 -3
  168. package/src/tools/ask.ts +198 -88
  169. package/src/tools/ast-edit.ts +2 -2
  170. package/src/tools/bash-pty-selection.ts +2 -2
  171. package/src/tools/browser/screenshot-format.ts +6 -1
  172. package/src/tools/cron.ts +1 -1
  173. package/src/tools/image-gen.ts +178 -6
  174. package/src/tools/index.ts +4 -0
  175. package/src/tools/render-utils.ts +7 -1
  176. package/src/utils/edit-mode.ts +2 -2
  177. package/src/utils/prompt-suggestion.ts +265 -0
  178. package/src/utils/sixel.ts +3 -3
  179. package/src/web/insane/url-guard.ts +3 -1
  180. package/src/web/scrapers/docs-rs.ts +30 -5
@@ -59,6 +59,7 @@ import {
59
59
  listManagedCandidates,
60
60
  type ManagedCandidateWriteAuthority,
61
61
  type ManagedMigrationPolicy,
62
+ type ManagedOpenCandidateResult,
62
63
  type ManagedScope,
63
64
  managedDirectoryAuthorityForScope,
64
65
  managedRootForScope,
@@ -69,12 +70,15 @@ import {
69
70
  } from "./internal/managed-session-scope";
70
71
  import {
71
72
  assertManagedDirectoryRoot,
73
+ fsyncManagedArtifactTree,
72
74
  type ManagedDirectoryRoot,
73
75
  type ManagedFileSnapshot,
74
76
  ManagedSessionDescendantStore,
75
77
  type ManagedSessionSecurityPolicy,
78
+ mayCleanManagedTreeStaging,
76
79
  retainManagedDirectoryAuthority,
77
80
  } from "./internal/managed-session-storage";
81
+ import { classifyNativePublishOutcome, formatNativePublishDiagnostic } from "./internal/native-publish-outcome";
78
82
 
79
83
  import {
80
84
  type BashExecutionMessage,
@@ -1320,6 +1324,18 @@ function freezeInternalReadSnapshot<T>(value: T): T {
1320
1324
  return copy;
1321
1325
  }
1322
1326
 
1327
+ function managedScopeStartupError(
1328
+ action: "resolve" | "prepare",
1329
+ failure: Extract<ReturnType<typeof resolveManagedScopeForWrite>, { kind: "error" }>,
1330
+ ): Error {
1331
+ return new Error(`Could not ${action} managed session scope.`, {
1332
+ cause: {
1333
+ classification: failure.cause?.classification ?? failure.code,
1334
+ ...(failure.cause?.diagnostic === undefined ? {} : { diagnostic: failure.cause.diagnostic }),
1335
+ },
1336
+ });
1337
+ }
1338
+
1323
1339
  /** Resolve and prepare the default v2 session scope before any managed writer exists. */
1324
1340
  function computeDefaultSessionDir(
1325
1341
  cwd: string,
@@ -1328,12 +1344,12 @@ function computeDefaultSessionDir(
1328
1344
  ): string {
1329
1345
  if (!(storage instanceof FileSessionStorage)) throw new SessionManagedStorageError();
1330
1346
  const resolved = resolveManagedScopeForWrite({ cwd, agentDir: path.resolve(sessionsRoot, ".."), sessionsRoot });
1331
- if (resolved.kind === "error") throw new Error(`Could not resolve managed session scope: ${resolved.message}`);
1347
+ if (resolved.kind === "error") throw managedScopeStartupError("resolve", resolved);
1332
1348
  const prepared = prepareManagedSessionScopeForWriteSync(
1333
1349
  resolved.scope,
1334
1350
  process.platform === "win32" ? "windows-existing-verify-first" : "default",
1335
1351
  );
1336
- if (prepared.kind === "error") throw new Error(`Could not prepare managed session scope: ${prepared.message}`);
1352
+ if (prepared.kind === "error") throw managedScopeStartupError("prepare", prepared);
1337
1353
  return prepared.scope.directoryPath;
1338
1354
  }
1339
1355
 
@@ -1468,12 +1484,12 @@ function managedDestination(cwd: string, storage: SessionStorage, agentDir?: str
1468
1484
  agentDir: agentDir ?? path.resolve(sessionsRoot, ".."),
1469
1485
  sessionsRoot,
1470
1486
  });
1471
- if (resolved.kind === "error") throw new Error(`Could not resolve managed session scope: ${resolved.message}`);
1487
+ if (resolved.kind === "error") throw managedScopeStartupError("resolve", resolved);
1472
1488
  const prepared = prepareManagedSessionScopeForWriteSync(
1473
1489
  resolved.scope,
1474
1490
  process.platform === "win32" ? "windows-existing-verify-first" : "default",
1475
1491
  );
1476
- if (prepared.kind === "error") throw new Error(`Could not prepare managed session scope: ${prepared.message}`);
1492
+ if (prepared.kind === "error") throw managedScopeStartupError("prepare", prepared);
1477
1493
  return freezeManagedDestination(prepared.scope);
1478
1494
  }
1479
1495
 
@@ -2158,23 +2174,6 @@ function formatTimeAgo(date: Date): string {
2158
2174
  return date.toLocaleDateString();
2159
2175
  }
2160
2176
 
2161
- async function syncPathAndParent(filePath: string): Promise<void> {
2162
- const file = await fs.promises.open(filePath, "r");
2163
- try {
2164
- await file.sync();
2165
- } finally {
2166
- await file.close();
2167
- }
2168
- if (process.platform !== "win32") {
2169
- const parent = await fs.promises.open(path.dirname(filePath), "r");
2170
- try {
2171
- await parent.sync();
2172
- } finally {
2173
- await parent.close();
2174
- }
2175
- }
2176
- }
2177
-
2178
2177
  interface SessionMoveDirectoryHandle {
2179
2178
  sync(): Promise<void>;
2180
2179
  close(): Promise<void>;
@@ -2194,40 +2193,6 @@ export async function syncSessionMoveDirectory(
2194
2193
  }
2195
2194
  }
2196
2195
 
2197
- /** Durably flush every copied file and directory before an EXDEV source removal. */
2198
- async function syncCopiedTree(treePath: string): Promise<void> {
2199
- const stat = await fs.promises.lstat(treePath);
2200
- if (stat.isSymbolicLink()) throw new Error("Refusing to fsync a copied symbolic link during a cross-device move");
2201
- if (stat.isFile()) {
2202
- const file = await fs.promises.open(treePath, "r");
2203
- try {
2204
- await file.sync();
2205
- } finally {
2206
- await file.close();
2207
- }
2208
- return;
2209
- }
2210
- if (!stat.isDirectory()) throw new Error("Copied cross-device move contains an unsupported filesystem entry");
2211
- const directory = await fs.promises.opendir(treePath);
2212
- try {
2213
- for (;;) {
2214
- const entry = await directory.read();
2215
- if (entry === null) break;
2216
- await syncCopiedTree(path.join(treePath, entry.name));
2217
- }
2218
- } finally {
2219
- await directory.close();
2220
- }
2221
- if (process.platform !== "win32") {
2222
- const handle = await fs.promises.open(treePath, "r");
2223
- try {
2224
- await handle.sync();
2225
- } finally {
2226
- await handle.close();
2227
- }
2228
- }
2229
- }
2230
-
2231
2196
  type CrossDeviceTreeIdentity = string;
2232
2197
 
2233
2198
  /** Captures content plus topology; a later capture must match before source removal. */
@@ -2275,14 +2240,6 @@ async function captureCrossDeviceTreeIdentity(treePath: string): Promise<CrossDe
2275
2240
  return `directory:${before.dev}:${before.ino}:${before.size}:${before.mtimeNs}:[${children.join(",")}]`;
2276
2241
  }
2277
2242
 
2278
- async function removeCrossDeviceSourceIfUnchanged(source: string, expected: CrossDeviceTreeIdentity): Promise<void> {
2279
- if ((await captureCrossDeviceTreeIdentity(source)) !== expected)
2280
- throw new Error("Cross-device move source changed before removal");
2281
- const stat = await fs.promises.lstat(source);
2282
- if (stat.isDirectory()) await fs.promises.rm(source, { recursive: true, force: false });
2283
- else await fs.promises.unlink(source);
2284
- }
2285
-
2286
2243
  /**
2287
2244
  * An EXDEV retirement must be driven by a native descriptor/handle-bound
2288
2245
  * snapshot-copy-verify transaction. Do not fall back to pathname copy/remove:
@@ -2298,83 +2255,25 @@ class CrossDeviceMoveUnsupportedError extends Error {
2298
2255
  }
2299
2256
  }
2300
2257
 
2301
- class PathMoveCompletedWithSyncFailure extends Error {
2302
- constructor(readonly cause: unknown) {
2303
- super("Path move completed but final source-directory sync failed.", { cause });
2304
- }
2305
- }
2306
-
2307
2258
  async function movePathAcrossDevicesSafe(source: string, destination: string): Promise<void> {
2308
2259
  const sourceIdentity = await captureCrossDeviceTreeIdentity(source);
2309
- const renamed = native.renameNoReplacePath(source, destination);
2310
- if (renamed.ok) {
2311
- try {
2312
- if ((await captureCrossDeviceTreeIdentity(destination)) !== sourceIdentity)
2313
- throw new Error("Atomic session rename did not preserve the captured source identity");
2314
- await syncSessionMoveDirectory(path.dirname(destination));
2315
- if (path.dirname(source) !== path.dirname(destination)) await syncSessionMoveDirectory(path.dirname(source));
2316
- } catch (error) {
2317
- throw new PathMoveCompletedWithSyncFailure(error);
2318
- }
2260
+ const outcome = classifyNativePublishOutcome(native.renameNoReplacePath(source, destination));
2261
+ if (outcome.ok) {
2262
+ if ((await captureCrossDeviceTreeIdentity(destination)) !== sourceIdentity)
2263
+ throw new Error("Atomic session rename did not preserve the captured source identity");
2264
+ await syncSessionMoveDirectory(path.dirname(destination));
2265
+ if (path.dirname(source) !== path.dirname(destination)) await syncSessionMoveDirectory(path.dirname(source));
2319
2266
  return;
2320
2267
  }
2321
- if (renamed.code === "quarantine_collision") {
2268
+ if (outcome.reason === "destination_exists") {
2322
2269
  const error = new Error(`Session move destination already exists: ${destination}`) as NodeJS.ErrnoException;
2323
2270
  error.code = "EEXIST";
2324
2271
  throw error;
2325
2272
  }
2326
- if (renamed.code !== "atomic_unavailable")
2327
- throw new Error(`Atomic session rename failed: ${renamed.code ?? "unknown"}`);
2328
- const sourceStat = await fs.promises.lstat(source);
2329
- if (!sourceStat.isDirectory()) {
2330
- let destinationCreated = false;
2331
- let sourceRemoved = false;
2332
- try {
2333
- try {
2334
- await fs.promises.link(source, destination);
2335
- destinationCreated = true;
2336
- } catch (error) {
2337
- if (hasFsCode(error, "EXDEV")) throw new CrossDeviceMoveUnsupportedError(source, destination, error);
2338
- if (
2339
- !hasFsCode(error, "EPERM") &&
2340
- !hasFsCode(error, "EACCES") &&
2341
- !hasFsCode(error, "ENOTSUP") &&
2342
- !hasFsCode(error, "EOPNOTSUPP")
2343
- )
2344
- throw error;
2345
- await fs.promises.copyFile(source, destination, fs.constants.COPYFILE_EXCL);
2346
- destinationCreated = true;
2347
- }
2348
- await syncPathAndParent(destination);
2349
- await removeCrossDeviceSourceIfUnchanged(source, sourceIdentity);
2350
- sourceRemoved = true;
2351
- await syncSessionMoveDirectory(path.dirname(source));
2352
- return;
2353
- } catch (error) {
2354
- if (!sourceRemoved && destinationCreated) await fs.promises.unlink(destination).catch(() => undefined);
2355
- if (sourceRemoved && !(error instanceof PathMoveCompletedWithSyncFailure))
2356
- throw new PathMoveCompletedWithSyncFailure(error);
2357
- throw error;
2358
- }
2359
- }
2360
-
2361
- const destinationParent = await fs.promises.lstat(path.dirname(destination));
2362
- if (sourceStat.dev !== destinationParent.dev) throw new CrossDeviceMoveUnsupportedError(source, destination);
2363
-
2364
- try {
2365
- await fs.promises.cp(source, destination, { recursive: true, force: false, errorOnExist: true });
2366
- await syncCopiedTree(destination);
2367
- await removeCrossDeviceSourceIfUnchanged(source, sourceIdentity);
2368
- try {
2369
- await syncSessionMoveDirectory(path.dirname(source));
2370
- } catch (error) {
2371
- throw new PathMoveCompletedWithSyncFailure(error);
2372
- }
2373
- } catch (error) {
2374
- if (!(error instanceof PathMoveCompletedWithSyncFailure))
2375
- await fs.promises.rm(destination, { recursive: true, force: true }).catch(() => undefined);
2376
- throw error;
2377
- }
2273
+ if (outcome.reason === "atomic_unavailable")
2274
+ throw new CrossDeviceMoveUnsupportedError(source, destination, new Error(formatNativePublishDiagnostic(outcome)));
2275
+ const message = `Atomic session rename failed: ${outcome.code ?? outcome.reason}`;
2276
+ throw new Error(message, { cause: new Error(formatNativePublishDiagnostic(outcome)) });
2378
2277
  }
2379
2278
 
2380
2279
  const MAX_PERSIST_CHARS = 500_000;
@@ -3560,6 +3459,64 @@ function getTaskToolUsage(details: unknown): Usage | undefined {
3560
3459
  return usage as Usage;
3561
3460
  }
3562
3461
 
3462
+ interface ValidatedUsageTotals {
3463
+ input: number;
3464
+ output: number;
3465
+ cacheRead: number;
3466
+ cacheWrite: number;
3467
+ premiumRequests: number;
3468
+ cost: number;
3469
+ }
3470
+
3471
+ /** Max malformed-usage entry ids sampled for a single resume-time report. */
3472
+ const MALFORMED_USAGE_SAMPLE_LIMIT = 8;
3473
+
3474
+ function isFiniteNonNegativeNumber(value: unknown): value is number {
3475
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
3476
+ }
3477
+
3478
+ /**
3479
+ * Validate an untrusted persisted `usage` record before it is aggregated into
3480
+ * session usage statistics. `parseSessionEntries` accepts any parseable JSON, so a
3481
+ * torn/corrupt record can carry a `usage` that is still valid JSON yet malformed:
3482
+ * absent (`{}` yields NaN), numeric strings (`"10"` coerces sums into strings), or
3483
+ * negative buckets that silently reduce totals. Returns the validated finite
3484
+ * non-negative totals, or null when any bucket or `cost.total` is malformed — the
3485
+ * caller then skips and reports that record rather than poisoning every
3486
+ * getUsageStatistics() consumer. Absent `premiumRequests`/`cost` default to 0
3487
+ * (backward-compatible with older transcripts); a present-but-invalid field is
3488
+ * rejected.
3489
+ */
3490
+ function validatePersistedUsageTotals(usage: unknown): ValidatedUsageTotals | null {
3491
+ if (typeof usage !== "object" || usage === null) return null;
3492
+ const record = usage as Record<string, unknown>;
3493
+ const input = record.input;
3494
+ const output = record.output;
3495
+ const cacheRead = record.cacheRead;
3496
+ const cacheWrite = record.cacheWrite;
3497
+ if (
3498
+ !isFiniteNonNegativeNumber(input) ||
3499
+ !isFiniteNonNegativeNumber(output) ||
3500
+ !isFiniteNonNegativeNumber(cacheRead) ||
3501
+ !isFiniteNonNegativeNumber(cacheWrite)
3502
+ )
3503
+ return null;
3504
+ // Default only on truly absent (=== undefined) properties; a present-but-null/invalid
3505
+ // premiumRequests is malformed, not zero.
3506
+ const premiumRequests = record.premiumRequests === undefined ? 0 : record.premiumRequests;
3507
+ if (!isFiniteNonNegativeNumber(premiumRequests)) return null;
3508
+ const rawCost = record.cost;
3509
+ let cost: unknown = 0;
3510
+ if (rawCost !== undefined) {
3511
+ // A present `cost` must be a non-array record carrying a finite non-negative `total`;
3512
+ // null, arrays, and an absent/invalid `total` are malformed — never silently zero.
3513
+ if (typeof rawCost !== "object" || rawCost === null || Array.isArray(rawCost)) return null;
3514
+ cost = (rawCost as Record<string, unknown>).total;
3515
+ }
3516
+ if (!isFiniteNonNegativeNumber(cost)) return null;
3517
+ return { input, output, cacheRead, cacheWrite, premiumRequests, cost };
3518
+ }
3519
+
3563
3520
  function extractTextFromContent(content: Message["content"]): string {
3564
3521
  if (typeof content === "string") return content;
3565
3522
  return content
@@ -4331,7 +4288,8 @@ export class SessionManager {
4331
4288
  const candidate = listing.owned.find(candidate => path.resolve(candidate.path) === requestedPath);
4332
4289
  if (!candidate) throw new Error("Managed session deletion requires exact logical authorization.");
4333
4290
  const deleted = await deleteManagedSessionCandidate(resolved.scope, candidate);
4334
- if (deleted.kind === "error") throw new Error(`Could not delete managed session: ${deleted.message}`);
4291
+ if (deleted.kind !== "deleted" && deleted.kind !== "already_deleted")
4292
+ throw new Error(`Could not delete managed session: ${deleted.message}`);
4335
4293
  } else {
4336
4294
  await this.storage.deleteSessionWithArtifacts(sessionPath);
4337
4295
  }
@@ -4460,27 +4418,20 @@ export class SessionManager {
4460
4418
  throw new Error("managed_fork_transcript_changed");
4461
4419
  }
4462
4420
  } catch (error) {
4463
- let failure = error;
4464
- const failedSessionFile = this.#sessionFile;
4465
- if (this.destination.kind === "managed") {
4421
+ const failure = error;
4422
+ if (forkArtifactPublication) {
4466
4423
  try {
4467
- if (forkTranscriptStore && forkTranscriptPublication)
4468
- forkTranscriptStore.removeExpected(path.basename(failedSessionFile), forkTranscriptPublication);
4469
- if (forkArtifactPublication)
4470
- forkArtifactPublication.cleanupStore.removeTreeExpected(
4471
- forkArtifactPublication.cleanupRelativePath,
4472
- forkArtifactPublication.snapshot,
4473
- );
4424
+ forkArtifactPublication.cleanupStore.removeTreeExpected(
4425
+ forkArtifactPublication.cleanupRelativePath,
4426
+ forkArtifactPublication.snapshot,
4427
+ );
4474
4428
  } catch (cleanupError) {
4475
- failure = new Error(`Failed to clean up managed fork destination: ${toError(cleanupError).message}`, {
4476
- cause: toError(error),
4477
- });
4478
- }
4479
- } else {
4480
- try {
4481
- await this.storage.unlink(failedSessionFile);
4482
- } catch {
4483
- // Preserve ambiguous cleanup evidence while restoring the active manager.
4429
+ const cleanupMessage = toError(cleanupError).message;
4430
+ if (cleanupMessage !== "cleanup_pending") {
4431
+ throw new Error(`Failed to clean up fork artifacts: ${cleanupMessage}`, {
4432
+ cause: toError(failure),
4433
+ });
4434
+ }
4484
4435
  }
4485
4436
  }
4486
4437
  this.#sessionId = oldSessionId;
@@ -4559,8 +4510,7 @@ export class SessionManager {
4559
4510
  }
4560
4511
  if (JSON.stringify(parentStore.captureTree(sourceName)) !== JSON.stringify(sourceSnapshot))
4561
4512
  throw new Error("artifact_source_changed");
4562
- parentStore.moveTreeNoReplace(stagingName, destinationName, stagingSnapshot);
4563
- publishedSnapshot = parentStore.captureTree(destinationName);
4513
+ publishedSnapshot = parentStore.moveTreeNoReplace(stagingName, destinationName, stagingSnapshot);
4564
4514
  const comparable = (tree: native.NativeDirectoryTreeSnapshot) =>
4565
4515
  tree.entries.map(entry => ({
4566
4516
  relativePath: entry.relativePath,
@@ -4584,12 +4534,15 @@ export class SessionManager {
4584
4534
  };
4585
4535
  } catch (error) {
4586
4536
  try {
4587
- if (publishedSnapshot) parentStore.removeTreeExpected(destinationName, publishedSnapshot);
4588
- else if (stagingSnapshot) parentStore.removeTreeExpected(stagingName, stagingSnapshot);
4537
+ if (stagingSnapshot && mayCleanManagedTreeStaging(error))
4538
+ parentStore.removeTreeExpected(stagingName, stagingSnapshot);
4589
4539
  } catch (cleanupError) {
4590
- throw new Error(`Failed to clean up managed fork artifacts: ${toError(cleanupError).message}`, {
4591
- cause: toError(error),
4592
- });
4540
+ const cleanupMessage = toError(cleanupError).message;
4541
+ if (cleanupMessage !== "cleanup_pending") {
4542
+ throw new Error(`Failed to clean up managed fork artifacts: ${cleanupMessage}`, {
4543
+ cause: toError(error),
4544
+ });
4545
+ }
4593
4546
  }
4594
4547
  throw error;
4595
4548
  }
@@ -4607,7 +4560,6 @@ export class SessionManager {
4607
4560
  const previousSessionDir = this.sessionDir;
4608
4561
  const previousSessionFile = this.#sessionFile;
4609
4562
  const previousDestination = this.destination;
4610
- const previousArtifactDir = previousSessionFile?.slice(0, -6);
4611
4563
 
4612
4564
  const nextDestination =
4613
4565
  this.storage instanceof FileSessionStorage
@@ -4719,15 +4671,8 @@ export class SessionManager {
4719
4671
  managedPublishedArtifacts,
4720
4672
  );
4721
4673
  }
4722
- if (managedTranscript)
4674
+ if (managedTranscript && !managedSourceStore.readExpected(path.basename(oldSessionFile)))
4723
4675
  await managedSourceStore.publishNoReplace(path.basename(oldSessionFile), managedTranscript.bytes);
4724
- if (managedPublishedArtifacts)
4725
- managedDestinationStore.removeTreeExpected(
4726
- path.basename(newArtifactDir),
4727
- managedPublishedArtifacts,
4728
- );
4729
- if (managedPublishedTranscript)
4730
- managedDestinationStore.removeExpected(path.basename(newSessionFile), managedPublishedTranscript);
4731
4676
  };
4732
4677
  managedTranscript = managedSourceStore.readExpected(path.basename(oldSessionFile));
4733
4678
  hadSessionFile = managedTranscript !== null;
@@ -4764,10 +4709,20 @@ export class SessionManager {
4764
4709
  if (!retainedTreeSnapshotEquals(terminalArtifacts, managedPublishedArtifacts))
4765
4710
  throw new Error("managed_move_destination_artifacts_changed");
4766
4711
  }
4767
- if (managedArtifacts)
4768
- managedSourceStore.removeTreeExpected(path.basename(oldArtifactDir), managedArtifacts);
4769
- if (managedTranscript)
4770
- managedSourceStore.removeExpected(path.basename(oldSessionFile), managedTranscript);
4712
+ if (managedArtifacts) {
4713
+ try {
4714
+ managedSourceStore.removeTreeExpected(path.basename(oldArtifactDir), managedArtifacts);
4715
+ } catch (cleanupError) {
4716
+ if (toError(cleanupError).message !== "cleanup_pending") throw cleanupError;
4717
+ }
4718
+ }
4719
+ if (managedTranscript) {
4720
+ try {
4721
+ managedSourceStore.removeExpected(path.basename(oldSessionFile), managedTranscript);
4722
+ } catch (cleanupError) {
4723
+ if (toError(cleanupError).message !== "cleanup_pending") throw cleanupError;
4724
+ }
4725
+ }
4771
4726
  } else {
4772
4727
  if (hadSessionFile && oldSessionFile !== newSessionFile) {
4773
4728
  await movePathAcrossDevicesSafe(oldSessionFile, newSessionFile);
@@ -4823,13 +4778,6 @@ export class SessionManager {
4823
4778
  !managedSourceStore.readExpected(path.basename(oldSessionFile))
4824
4779
  )
4825
4780
  await managedSourceStore.publishNoReplace(path.basename(oldSessionFile), managedTranscript.bytes);
4826
- if (managedPublishedArtifacts)
4827
- managedDestinationStore.removeTreeExpected(
4828
- path.basename(newArtifactDir),
4829
- managedPublishedArtifacts,
4830
- );
4831
- if (managedPublishedTranscript)
4832
- managedDestinationStore.removeExpected(path.basename(newSessionFile), managedPublishedTranscript);
4833
4781
  } catch (rollbackErr) {
4834
4782
  restoreResidentStateAndThrow(
4835
4783
  new Error(`Failed to rollback managed move: ${toError(rollbackErr).message}`, {
@@ -4850,7 +4798,8 @@ export class SessionManager {
4850
4798
  this.#bumpAllRevisions();
4851
4799
  }
4852
4800
 
4853
- // Update cwd and sessionDir after the physical move succeeds, but roll the move back if metadata persistence fails.
4801
+ // Update cwd and sessionDir after physical publication succeeds. Metadata failures restore the source
4802
+ // authority but deliberately retain any destination publication evidence rather than deleting it.
4854
4803
  this.cwd = resolvedCwd;
4855
4804
  this.sessionDir = newSessionDir;
4856
4805
  this.destination = nextDestination;
@@ -4871,60 +4820,37 @@ export class SessionManager {
4871
4820
  await this.#appendHeaderPatch({ cwd: resolvedCwd });
4872
4821
  }
4873
4822
  } catch (error) {
4874
- const failedSessionFile = this.#sessionFile;
4875
- const failedArtifactDir = failedSessionFile?.slice(0, -6);
4876
- try {
4877
- await this.#closePersistWriter().catch(() => {});
4878
- this.#persistChain = Promise.resolve();
4879
- this.#persistError = undefined;
4880
- this.#persistErrorReported = false;
4881
- if (rollbackManagedMove) {
4823
+ await this.#closePersistWriter().catch(() => {});
4824
+ this.#persistChain = Promise.resolve();
4825
+ this.#persistError = undefined;
4826
+ this.#persistErrorReported = false;
4827
+ if (rollbackManagedMove) {
4828
+ try {
4882
4829
  await rollbackManagedMove();
4883
- this.#sessionFile = previousSessionFile;
4884
- this.cwd = previousCwd;
4885
- this.sessionDir = previousSessionDir;
4886
- this.destination = previousDestination;
4887
- this.#managedTranscriptStoreCache =
4888
- managedMove && managedSourceStore
4889
- ? { directory: path.resolve(previousSessionDir), store: managedSourceStore }
4890
- : null;
4891
- const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
4892
- if (header) applyHeaderPatch(header, { cwd: previousCwd });
4893
- this.#headerExportRevision++;
4894
- throw error;
4895
- }
4896
- if (
4897
- this.storage instanceof FileSessionStorage &&
4898
- failedArtifactDir &&
4899
- previousArtifactDir &&
4900
- failedArtifactDir !== previousArtifactDir &&
4901
- fs.existsSync(failedArtifactDir)
4902
- ) {
4903
- await movePathAcrossDevicesSafe(failedArtifactDir, previousArtifactDir);
4904
- }
4905
- if (
4906
- this.storage instanceof FileSessionStorage &&
4907
- failedSessionFile &&
4908
- previousSessionFile &&
4909
- failedSessionFile !== previousSessionFile &&
4910
- fs.existsSync(failedSessionFile)
4911
- ) {
4912
- await movePathAcrossDevicesSafe(failedSessionFile, previousSessionFile);
4830
+ } catch (rollbackError) {
4831
+ if (toError(rollbackError).message !== "cleanup_pending") {
4832
+ throw new Error(`Failed to rollback managed move: ${toError(rollbackError).message}`, {
4833
+ cause: toError(error),
4834
+ });
4835
+ }
4913
4836
  }
4914
4837
  this.#sessionFile = previousSessionFile;
4915
4838
  this.cwd = previousCwd;
4916
4839
  this.sessionDir = previousSessionDir;
4917
4840
  this.destination = previousDestination;
4918
- this.#managedTranscriptStoreCache = null;
4841
+ this.#managedTranscriptStoreCache =
4842
+ managedMove && managedSourceStore
4843
+ ? { directory: path.resolve(previousSessionDir), store: managedSourceStore }
4844
+ : null;
4919
4845
  const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
4920
4846
  if (header) applyHeaderPatch(header, { cwd: previousCwd });
4921
4847
  this.#headerExportRevision++;
4922
- if (previousSessionFile && this.storage.existsSync(previousSessionFile)) await this.#rewriteFile();
4923
- } catch (rollbackError) {
4924
- throw new Error(`Failed to rollback session move: ${toError(rollbackError).message}`, {
4925
- cause: toError(error),
4926
- });
4848
+ throw error;
4927
4849
  }
4850
+ // The destination has already been published. Retain it rather than moving it
4851
+ // back over the source after a metadata failure.
4852
+ this.#managedTranscriptStoreCache = null;
4853
+ this.#headerExportRevision++;
4928
4854
  throw error;
4929
4855
  }
4930
4856
 
@@ -4984,6 +4910,8 @@ export class SessionManager {
4984
4910
  this.#labelsById.clear();
4985
4911
  this.#leafId = null;
4986
4912
  this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
4913
+ let malformedUsageRecords = 0;
4914
+ const malformedUsageSample: string[] = [];
4987
4915
  for (const entry of this.#fileEntries) {
4988
4916
  if (entry.type === "session") continue;
4989
4917
  this.#byId.set(entry.id, entry);
@@ -4995,28 +4923,80 @@ export class SessionManager {
4995
4923
  this.#labelsById.delete(entry.targetId);
4996
4924
  }
4997
4925
  }
4998
- if (entry.type === "message" && entry.message.role === "assistant") {
4999
- const usage = entry.message.usage;
5000
- this.#usageStatistics.input += usage.input;
5001
- this.#usageStatistics.output += usage.output;
5002
- this.#usageStatistics.cacheRead += usage.cacheRead;
5003
- this.#usageStatistics.cacheWrite += usage.cacheWrite;
5004
- this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
5005
- this.#usageStatistics.cost += usage.cost.total;
4926
+ // Aggregate this entry's persisted usage through the shared validated,
4927
+ // overflow-guarded path (see #accumulateEntryUsage). parseSessionEntries accepts
4928
+ // any parseable JSON, so a torn/corrupt record can carry a still-valid-JSON but
4929
+ // malformed `usage` (absent, {}, numeric strings, negatives, a non-record cost, or
4930
+ // a cumulative overflow); such a record is skipped and reported below instead of
4931
+ // poisoning every getUsageStatistics() consumer.
4932
+ if (this.#accumulateEntryUsage(entry)) {
4933
+ malformedUsageRecords++;
4934
+ if (malformedUsageSample.length < MALFORMED_USAGE_SAMPLE_LIMIT) malformedUsageSample.push(entry.id);
5006
4935
  }
4936
+ }
4937
+ if (malformedUsageRecords > 0) {
4938
+ logger.warn("Skipped malformed or overflowing persisted usage records during resume aggregation", {
4939
+ sessionFile: this.#sessionFile,
4940
+ count: malformedUsageRecords,
4941
+ sampleEntryIds: malformedUsageSample,
4942
+ });
4943
+ }
4944
+ }
5007
4945
 
5008
- if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "task") {
5009
- const usage = getTaskToolUsage(entry.message.details);
5010
- if (usage) {
5011
- this.#usageStatistics.input += usage.input;
5012
- this.#usageStatistics.output += usage.output;
5013
- this.#usageStatistics.cacheRead += usage.cacheRead;
5014
- this.#usageStatistics.cacheWrite += usage.cacheWrite;
5015
- this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
5016
- this.#usageStatistics.cost += usage.cost.total;
5017
- }
5018
- }
4946
+ /**
4947
+ * Commit a validated record's totals only if every cumulative sum stays finite.
4948
+ * Individually-finite buckets can still overflow to Infinity in aggregate (e.g. two
4949
+ * Number.MAX_VALUE records), which would poison getUsageStatistics(); such a record is
4950
+ * rejected atomically (no partial commit). Returns true when committed, false when
4951
+ * rejected for cumulative overflow.
4952
+ */
4953
+ #addValidatedUsage(totals: ValidatedUsageTotals): boolean {
4954
+ const next = {
4955
+ input: this.#usageStatistics.input + totals.input,
4956
+ output: this.#usageStatistics.output + totals.output,
4957
+ cacheRead: this.#usageStatistics.cacheRead + totals.cacheRead,
4958
+ cacheWrite: this.#usageStatistics.cacheWrite + totals.cacheWrite,
4959
+ premiumRequests: this.#usageStatistics.premiumRequests + totals.premiumRequests,
4960
+ cost: this.#usageStatistics.cost + totals.cost,
4961
+ };
4962
+ if (
4963
+ !Number.isFinite(next.input) ||
4964
+ !Number.isFinite(next.output) ||
4965
+ !Number.isFinite(next.cacheRead) ||
4966
+ !Number.isFinite(next.cacheWrite) ||
4967
+ !Number.isFinite(next.premiumRequests) ||
4968
+ !Number.isFinite(next.cost)
4969
+ )
4970
+ return false;
4971
+ this.#usageStatistics.input = next.input;
4972
+ this.#usageStatistics.output = next.output;
4973
+ this.#usageStatistics.cacheRead = next.cacheRead;
4974
+ this.#usageStatistics.cacheWrite = next.cacheWrite;
4975
+ this.#usageStatistics.premiumRequests = next.premiumRequests;
4976
+ this.#usageStatistics.cost = next.cost;
4977
+ return true;
4978
+ }
4979
+
4980
+ /**
4981
+ * Validate and accumulate one entry's persisted usage through the single shared,
4982
+ * overflow-guarded aggregation path used by both #buildIndex (resume) and #appendEntry
4983
+ * (runtime), covering the assistant and `task` tool-result shapes. Returns true when a
4984
+ * present usage record was skipped as malformed or overflowing (so the caller can report
4985
+ * it); false when the entry has no usage or was aggregated cleanly.
4986
+ */
4987
+ #accumulateEntryUsage(entry: SessionEntry): boolean {
4988
+ if (entry.type !== "message") return false;
4989
+ if (entry.message.role === "assistant") {
4990
+ const totals = validatePersistedUsageTotals(entry.message.usage);
4991
+ return !(totals && this.#addValidatedUsage(totals));
4992
+ }
4993
+ if (entry.message.role === "toolResult" && entry.message.toolName === "task") {
4994
+ const rawTaskUsage = getTaskToolUsage(entry.message.details);
4995
+ if (rawTaskUsage === undefined) return false;
4996
+ const totals = validatePersistedUsageTotals(rawTaskUsage);
4997
+ return !(totals && this.#addValidatedUsage(totals));
5019
4998
  }
4999
+ return false;
5020
5000
  }
5021
5001
 
5022
5002
  #recordPersistError(err: unknown): Error {
@@ -6007,26 +5987,14 @@ export class SessionManager {
6007
5987
  this.#leafRevision++;
6008
5988
  if (entry.type === "label") this.#labelRevision++;
6009
5989
  this._persist(residentEntry);
6010
- if (entry.type === "message" && entry.message.role === "assistant") {
6011
- const usage = entry.message.usage;
6012
- this.#usageStatistics.input += usage.input;
6013
- this.#usageStatistics.output += usage.output;
6014
- this.#usageStatistics.cacheRead += usage.cacheRead;
6015
- this.#usageStatistics.cacheWrite += usage.cacheWrite;
6016
- this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
6017
- this.#usageStatistics.cost += usage.cost.total;
6018
- }
6019
-
6020
- if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "task") {
6021
- const usage = getTaskToolUsage(entry.message.details);
6022
- if (usage) {
6023
- this.#usageStatistics.input += usage.input;
6024
- this.#usageStatistics.output += usage.output;
6025
- this.#usageStatistics.cacheRead += usage.cacheRead;
6026
- this.#usageStatistics.cacheWrite += usage.cacheWrite;
6027
- this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
6028
- this.#usageStatistics.cost += usage.cost.total;
6029
- }
5990
+ // Same validated, overflow-guarded aggregation as the resume path (#buildIndex): a
5991
+ // malformed task-result or assistant usage shape reaching the append path must not
5992
+ // crash or poison getUsageStatistics() either.
5993
+ if (this.#accumulateEntryUsage(entry)) {
5994
+ logger.warn("Skipped malformed or overflowing usage on appended entry", {
5995
+ sessionFile: this.#sessionFile,
5996
+ entryId: entry.id,
5997
+ });
6030
5998
  }
6031
5999
  }
6032
6000
 
@@ -7254,9 +7222,11 @@ export class SessionManager {
7254
7222
  const storage = new FileSessionStorage();
7255
7223
  const managedDestinationStore = managedStoreFromContext(destination.securityContext, destination.directory);
7256
7224
  const assertManagedDestinationBound = (): void => {
7257
- assertManagedDirectoryRoot(destination.securityContext.rootAuthority);
7258
7225
  managedDestinationStore.assertBound();
7226
+ if (!destination.securityContext.retainedAuthority)
7227
+ assertManagedDirectoryRoot(destination.securityContext.rootAuthority);
7259
7228
  };
7229
+
7260
7230
  assertManagedDestinationBound();
7261
7231
  const inspected = inspectResumeSessionFile(filePath, storage);
7262
7232
  if ("kind" in inspected) {
@@ -7312,18 +7282,26 @@ export class SessionManager {
7312
7282
  }
7313
7283
  : {}),
7314
7284
  };
7315
- const opened = await openManagedCandidateForWrite(
7316
- resolved.scope,
7317
- candidate,
7318
- expectedIdentity ?? inspected.identity,
7319
- migrationPolicy,
7320
- authority,
7321
- );
7322
- assertManagedDestinationBound();
7285
+ let opened: ManagedOpenCandidateResult;
7286
+ try {
7287
+ opened = await openManagedCandidateForWrite(
7288
+ resolved.scope,
7289
+ candidate,
7290
+ expectedIdentity ?? inspected.identity,
7291
+ migrationPolicy,
7292
+ authority,
7293
+ );
7294
+ } catch (error) {
7295
+ if (error instanceof Error && error.message.startsWith("Managed root authority changed"))
7296
+ managedDestinationStore.assertBound();
7297
+ throw error;
7298
+ }
7323
7299
  if (opened.kind === "error") {
7300
+ managedDestinationStore.assertBound();
7324
7301
  if (opened.code === "legacy_migration_disabled") throw new SessionMigrationPolicyError();
7325
7302
  throw new Error(`Could not open managed session: ${opened.message}`);
7326
7303
  }
7304
+ assertManagedDestinationBound();
7327
7305
  return opened.path;
7328
7306
  }
7329
7307
 
@@ -7536,7 +7514,8 @@ export class SessionManager {
7536
7514
  const candidate = listing.owned.find(item => path.resolve(item.path) === path.resolve(sessionPath));
7537
7515
  if (!candidate) throw new Error("Session is not an authorized managed candidate.");
7538
7516
  const deleted = await deleteManagedSessionCandidate(resolved.scope, candidate);
7539
- if (deleted.kind === "error") throw new Error(`Could not delete managed session: ${deleted.message}`);
7517
+ if (deleted.kind !== "deleted" && deleted.kind !== "already_deleted")
7518
+ throw new Error(`Could not delete managed session: ${deleted.message}`);
7540
7519
  }
7541
7520
 
7542
7521
  /** Capture exact source content for a strict fork without granting write ownership. */
@@ -7596,16 +7575,22 @@ export class SessionManager {
7596
7575
  }
7597
7576
 
7598
7577
  const dir = destination.directory;
7599
- const removeSessionDirOnFailure =
7578
+ const privateStagingDir =
7600
7579
  destination.kind !== "managed" &&
7601
7580
  snapshot.storage instanceof FileSessionStorage &&
7602
- !snapshot.storage.existsSync(dir);
7581
+ !snapshot.storage.existsSync(dir)
7582
+ ? fs.mkdtempSync(path.join(path.dirname(dir), `.${path.basename(dir)}.fork-staging-`))
7583
+ : undefined;
7584
+ const forkDestination = privateStagingDir ? explicitDestination(privateStagingDir) : destination;
7603
7585
  let managedForkStore: ManagedSessionDescendantStore | undefined;
7604
7586
  let managedForkTranscript: ManagedFileSnapshot | null = null;
7605
7587
  let manager: SessionManager | undefined;
7606
7588
  let authorityFailure: StrictSessionOpenFailure | undefined;
7589
+ let privateStagingSnapshot: native.NativeDirectoryTreeSnapshot | undefined;
7590
+ let privateStagingPublished = false;
7591
+
7607
7592
  try {
7608
- manager = new SessionManager(cwd, dir, true, snapshot.storage, destination);
7593
+ manager = new SessionManager(cwd, privateStagingDir ?? dir, true, snapshot.storage, forkDestination);
7609
7594
  await resolveBlobRefsInEntries(forkEntries, manager.#blobStore);
7610
7595
  manager.#fileEntries = forkEntries;
7611
7596
  const sourceHeader = manager.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
@@ -7635,6 +7620,13 @@ export class SessionManager {
7635
7620
  throw new Error("Captured fork source authority changed before destination write.");
7636
7621
  }
7637
7622
  await manager.#rewriteFile();
7623
+ if (privateStagingDir) {
7624
+ const capturedStaging = native.snapshotDirectoryTree(privateStagingDir);
7625
+ if (!capturedStaging.ok || !capturedStaging.snapshot)
7626
+ throw new Error(capturedStaging.code ?? "fork_staging_snapshot_failed");
7627
+ privateStagingSnapshot = capturedStaging.snapshot;
7628
+ }
7629
+
7638
7630
  if (destination.kind === "managed" && manager.#sessionFile) {
7639
7631
  managedForkStore = manager.#managedTranscriptStore();
7640
7632
  managedForkTranscript = managedForkStore.readExpected(path.basename(manager.#sessionFile));
@@ -7654,66 +7646,57 @@ export class SessionManager {
7654
7646
  if (!managedFileSnapshotEquals(terminalTranscript, managedForkTranscript))
7655
7647
  throw new Error("managed_fork_transcript_changed");
7656
7648
  }
7649
+ if (privateStagingDir && manager.#sessionFile) {
7650
+ const materializedEntries = materializeResidentEntriesForReadSync(
7651
+ manager.#fileEntries,
7652
+ manager.#residentBlobStores(),
7653
+ );
7654
+ manager.#fileEntries = materializedEntries;
7655
+ manager.#disposeResidentTextBlobStore();
7656
+ const capturedStaging = native.snapshotDirectoryTree(privateStagingDir);
7657
+ if (!capturedStaging.ok || !capturedStaging.snapshot)
7658
+ throw new Error(capturedStaging.code ?? "fork_staging_snapshot_failed");
7659
+ privateStagingSnapshot = capturedStaging.snapshot;
7660
+ fsyncManagedArtifactTree(privateStagingDir);
7661
+ const outcome = classifyNativePublishOutcome(native.renameNoReplacePath(privateStagingDir, dir));
7662
+ if (!outcome.ok) throw new Error(outcome.code ?? "fork_destination_publish_failed");
7663
+ privateStagingPublished = true;
7664
+ const terminal = native.snapshotDirectoryTree(dir);
7665
+ if (
7666
+ !terminal.ok ||
7667
+ !terminal.snapshot ||
7668
+ !retainedTreeSnapshotEqualsAfterRename(terminal.snapshot, privateStagingSnapshot)
7669
+ )
7670
+ throw new Error("fork_destination_terminal_identity_changed");
7671
+ await syncSessionMoveDirectory(path.dirname(dir));
7672
+ const durableTerminal = native.snapshotDirectoryTree(dir);
7673
+ if (
7674
+ !durableTerminal.ok ||
7675
+ !durableTerminal.snapshot ||
7676
+ !retainedTreeSnapshotEqualsAfterRename(durableTerminal.snapshot, privateStagingSnapshot)
7677
+ )
7678
+ throw new Error("fork_destination_durability_identity_changed");
7679
+ manager.sessionDir = dir;
7680
+ manager.destination = destination;
7681
+ manager.#sessionFile = path.join(dir, path.basename(manager.#sessionFile));
7682
+ manager.#resetResidentTextBlobStore();
7683
+ manager.#reexternalizeFileEntriesForResidentStore();
7684
+ manager.#bumpAllRevisions();
7685
+ }
7657
7686
  if (manager.#sessionFile) writeTerminalBreadcrumb(manager.cwd, manager.#sessionFile);
7658
7687
  return { kind: "forked", manager };
7659
7688
  } catch (error) {
7660
7689
  if (manager) {
7661
- const sessionFile = manager.#sessionFile;
7662
7690
  const cleanupErrors: Error[] = [];
7663
7691
  try {
7664
7692
  await manager.close();
7665
7693
  } catch (cleanupError) {
7666
- cleanupErrors.push(toError(cleanupError));
7667
- }
7668
- if (sessionFile) {
7669
- if (destination.kind === "managed") {
7670
- try {
7671
- if (managedForkStore && managedForkTranscript)
7672
- managedForkStore.removeExpected(path.basename(sessionFile), managedForkTranscript);
7673
- } catch (cleanupError) {
7674
- cleanupErrors.push(toError(cleanupError));
7675
- }
7676
- } else {
7677
- try {
7678
- await manager.storage.unlink(sessionFile);
7679
- } catch (cleanupError) {
7680
- if (!isEnoent(cleanupError)) cleanupErrors.push(toError(cleanupError));
7681
- }
7682
- if (manager.storage instanceof FileSessionStorage) {
7683
- try {
7684
- await fs.promises.rm(sessionFile.slice(0, -6), { recursive: true, force: true });
7685
- } catch (cleanupError) {
7686
- cleanupErrors.push(toError(cleanupError));
7687
- }
7688
- try {
7689
- const sessionFileName = path.basename(sessionFile);
7690
- const transientPrefix = `.${sessionFileName}.`;
7691
- const backupPrefix = `${sessionFileName}.`;
7692
- for (const name of await fs.promises.readdir(path.dirname(sessionFile))) {
7693
- if (
7694
- (name.startsWith(transientPrefix) && name.endsWith(".tmp")) ||
7695
- (name.startsWith(backupPrefix) && name.endsWith(".bak"))
7696
- ) {
7697
- await fs.promises.rm(path.join(path.dirname(sessionFile), name), {
7698
- recursive: true,
7699
- force: true,
7700
- });
7701
- }
7702
- }
7703
- } catch (cleanupError) {
7704
- if (!isEnoent(cleanupError)) cleanupErrors.push(toError(cleanupError));
7705
- }
7706
- }
7707
- }
7694
+ if (toError(cleanupError).message !== "cleanup_pending") cleanupErrors.push(toError(cleanupError));
7708
7695
  }
7709
- if (removeSessionDirOnFailure) {
7710
- try {
7711
- await fs.promises.rmdir(dir);
7712
- } catch (cleanupError) {
7713
- const code = (cleanupError as NodeJS.ErrnoException).code;
7714
- if (code !== "ENOENT" && code !== "ENOTEMPTY" && code !== "EEXIST")
7715
- cleanupErrors.push(toError(cleanupError));
7716
- }
7696
+ if (!privateStagingPublished && privateStagingDir && privateStagingSnapshot) {
7697
+ const removed = native.exactRemoveDirectoryTree(privateStagingDir, privateStagingSnapshot);
7698
+ if (!removed.ok && removed.code !== "not_found" && removed.code !== "cleanup_pending")
7699
+ cleanupErrors.push(new Error(removed.code ?? "fork_staging_cleanup_failed"));
7717
7700
  }
7718
7701
  if (cleanupErrors.length > 0) {
7719
7702
  throw new Error(`Failed to clean up fork destination: ${cleanupErrors[0]!.message}`, {