@gajae-code/coding-agent 0.11.7 → 0.11.9

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 (213) hide show
  1. package/CHANGELOG.md +73 -1
  2. package/dist/types/cli/args.d.ts +0 -2
  3. package/dist/types/cli.d.ts +1 -0
  4. package/dist/types/config/keybindings.d.ts +33 -3
  5. package/dist/types/config/model-profile-activation.d.ts +4 -3
  6. package/dist/types/config/model-profile-contract.d.ts +38 -0
  7. package/dist/types/config/model-profiles.d.ts +2 -3
  8. package/dist/types/config/settings-schema.d.ts +15 -2
  9. package/dist/types/edit/modes/patch.d.ts +13 -0
  10. package/dist/types/edit/path-mutation-lock.d.ts +15 -0
  11. package/dist/types/eval/py/executor.d.ts +1 -1
  12. package/dist/types/eval/py/tool-bridge.d.ts +1 -2
  13. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +12 -5
  14. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +8 -0
  15. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +41 -0
  16. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +32 -17
  17. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +17 -0
  18. package/dist/types/gjc-runtime/deep-interview-state.d.ts +135 -4
  19. package/dist/types/gjc-runtime/repository-binding.d.ts +63 -0
  20. package/dist/types/gjc-runtime/state-writer.d.ts +44 -0
  21. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +1 -0
  22. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +24 -0
  23. package/dist/types/gjc-runtime/workflow-manifest.d.ts +5 -3
  24. package/dist/types/main.d.ts +8 -0
  25. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
  26. package/dist/types/modes/components/tool-status-header.d.ts +3 -2
  27. package/dist/types/modes/components/welcome.d.ts +4 -0
  28. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  29. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  30. package/dist/types/modes/interactive-mode.d.ts +5 -2
  31. package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
  32. package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
  33. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
  34. package/dist/types/modes/types.d.ts +3 -0
  35. package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
  36. package/dist/types/runtime-mcp/manager.d.ts +1 -1
  37. package/dist/types/runtime-mcp/oauth-flow.d.ts +0 -5
  38. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +14 -0
  39. package/dist/types/runtime-mcp/redaction.d.ts +2 -0
  40. package/dist/types/sdk/broker/broker.d.ts +2 -0
  41. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  42. package/dist/types/sdk/bus/chat-daemon-control.d.ts +7 -1
  43. package/dist/types/sdk/bus/config-commands.d.ts +2 -2
  44. package/dist/types/sdk/bus/config.d.ts +2 -0
  45. package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
  46. package/dist/types/sdk/bus/index.d.ts +25 -0
  47. package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
  48. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +105 -0
  49. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +15 -3
  50. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
  51. package/dist/types/sdk/bus/telegram-daemon.d.ts +128 -1
  52. package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
  53. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  54. package/dist/types/sdk/host/host.d.ts +1 -1
  55. package/dist/types/sdk/host/query/handlers.d.ts +9 -0
  56. package/dist/types/sdk/index.d.ts +2 -0
  57. package/dist/types/sdk/prompt-status.d.ts +88 -0
  58. package/dist/types/sdk/session-directory.d.ts +1 -1
  59. package/dist/types/sdk/startup-capability.d.ts +3 -0
  60. package/dist/types/session/agent-session.d.ts +2 -0
  61. package/dist/types/session/internal/managed-session-scope.d.ts +22 -3
  62. package/dist/types/session/internal/managed-session-storage.d.ts +24 -4
  63. package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
  64. package/dist/types/session/session-manager.d.ts +5 -1
  65. package/dist/types/session/session-storage.d.ts +31 -0
  66. package/dist/types/skill-state/active-state.d.ts +4 -0
  67. package/dist/types/task/index.d.ts +3 -1
  68. package/dist/types/task/receipt.d.ts +2 -0
  69. package/dist/types/task/types.d.ts +94 -0
  70. package/dist/types/tools/ask.d.ts +454 -174
  71. package/dist/types/tools/image-gen.d.ts +47 -1
  72. package/dist/types/tools/index.d.ts +4 -0
  73. package/dist/types/utils/prompt-suggestion.d.ts +33 -0
  74. package/package.json +8 -8
  75. package/scripts/capture-platform-shortcut-labels-showcase.ts +342 -0
  76. package/scripts/dogfood-repository-binding.ts +209 -0
  77. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  78. package/scripts/run-test-manifest.ts +4 -4
  79. package/src/cli/args.ts +2 -92
  80. package/src/cli/mcp-cli.ts +4 -21
  81. package/src/cli/notify-cli.ts +5 -38
  82. package/src/cli.ts +50 -0
  83. package/src/commands/deep-interview.ts +9 -4
  84. package/src/commands/sdk.ts +2 -2
  85. package/src/commit/agentic/index.ts +3 -3
  86. package/src/commit/map-reduce/index.ts +2 -2
  87. package/src/config/keybindings.ts +157 -24
  88. package/src/config/model-profile-activation.ts +7 -18
  89. package/src/config/model-profile-contract.ts +161 -0
  90. package/src/config/model-profiles.ts +2 -3
  91. package/src/config/model-registry.ts +11 -1
  92. package/src/config/settings-schema.ts +17 -2
  93. package/src/coordinator-mcp/model-preset.ts +21 -74
  94. package/src/defaults/gjc/skills/deep-interview/SKILL.md +80 -165
  95. package/src/defaults/gjc/skills/ralplan/SKILL.md +3 -1
  96. package/src/edit/index.ts +3 -6
  97. package/src/edit/modes/patch.ts +38 -1
  98. package/src/edit/modes/replace.ts +42 -0
  99. package/src/edit/path-mutation-lock.ts +112 -0
  100. package/src/eval/py/executor.ts +30 -11
  101. package/src/eval/py/prelude.py +2 -2
  102. package/src/eval/py/tool-bridge.ts +34 -18
  103. package/src/extensibility/gjc-plugins/runtime-adapters.ts +2 -1
  104. package/src/gjc-runtime/deep-interview-ambiguity.ts +68 -47
  105. package/src/gjc-runtime/deep-interview-draft.ts +842 -0
  106. package/src/gjc-runtime/deep-interview-payload.ts +262 -0
  107. package/src/gjc-runtime/deep-interview-recorder.ts +294 -226
  108. package/src/gjc-runtime/deep-interview-repair.ts +1674 -0
  109. package/src/gjc-runtime/deep-interview-runtime.ts +264 -103
  110. package/src/gjc-runtime/deep-interview-state.ts +916 -4
  111. package/src/gjc-runtime/launch-tmux.ts +1 -1
  112. package/src/gjc-runtime/managed-owner-supervisor.ts +83 -4
  113. package/src/gjc-runtime/ralplan-runtime.ts +78 -4
  114. package/src/gjc-runtime/repository-binding.ts +267 -0
  115. package/src/gjc-runtime/session-state-sidecar.ts +25 -9
  116. package/src/gjc-runtime/state-writer.ts +166 -2
  117. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -1
  118. package/src/gjc-runtime/tmux-sessions.ts +45 -6
  119. package/src/gjc-runtime/ultragoal-guard.ts +5 -5
  120. package/src/gjc-runtime/ultragoal-runtime.ts +181 -19
  121. package/src/gjc-runtime/workflow-manifest.generated.json +386 -1
  122. package/src/gjc-runtime/workflow-manifest.ts +191 -5
  123. package/src/internal-urls/docs-index.generated.ts +8 -6
  124. package/src/internal-urls/local-protocol.ts +33 -16
  125. package/src/internal-urls/mcp-protocol.ts +73 -11
  126. package/src/main.ts +25 -10
  127. package/src/modes/DESIGN.md +5 -0
  128. package/src/modes/acp/acp-agent.ts +50 -16
  129. package/src/modes/components/model-selector.ts +42 -8
  130. package/src/modes/components/plan-preview-overlay.ts +1 -1
  131. package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
  132. package/src/modes/components/tool-status-header.ts +20 -4
  133. package/src/modes/components/welcome.ts +32 -20
  134. package/src/modes/controllers/command-controller.ts +97 -54
  135. package/src/modes/controllers/event-controller.ts +10 -0
  136. package/src/modes/controllers/input-controller.ts +7 -2
  137. package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
  138. package/src/modes/controllers/selector-controller.ts +46 -54
  139. package/src/modes/interactive-mode.ts +70 -36
  140. package/src/modes/prompt-action-autocomplete.ts +32 -13
  141. package/src/modes/prompt-suggestion-controller.ts +96 -0
  142. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
  143. package/src/modes/types.ts +3 -0
  144. package/src/modes/utils/hotkeys-markdown.ts +64 -36
  145. package/src/modes/utils/ui-helpers.ts +5 -3
  146. package/src/prompts/agents/architect.md +0 -1
  147. package/src/prompts/agents/critic.md +0 -1
  148. package/src/prompts/system/prompt-suggestion-system.md +32 -0
  149. package/src/prompts/system/system-prompt.md +7 -4
  150. package/src/prompts/tools/cron.md +3 -1
  151. package/src/runtime-mcp/json-rpc.ts +7 -4
  152. package/src/runtime-mcp/manager.ts +51 -53
  153. package/src/runtime-mcp/oauth-flow.ts +0 -72
  154. package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
  155. package/src/runtime-mcp/redaction.ts +30 -0
  156. package/src/runtime-mcp/transports/http.ts +13 -6
  157. package/src/sdk/broker/broker.ts +34 -20
  158. package/src/sdk/broker/lifecycle.ts +185 -36
  159. package/src/sdk/broker/session-index.ts +11 -1
  160. package/src/sdk/bus/chat-daemon-control.ts +20 -5
  161. package/src/sdk/bus/config-commands.ts +2 -2
  162. package/src/sdk/bus/config.ts +9 -1
  163. package/src/sdk/bus/conversation-store.ts +40 -6
  164. package/src/sdk/bus/index.ts +240 -65
  165. package/src/sdk/bus/lifecycle-commands.ts +47 -3
  166. package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
  167. package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
  168. package/src/sdk/bus/notification-orchestration.ts +12 -0
  169. package/src/sdk/bus/notification-service.ts +27 -2
  170. package/src/sdk/bus/prompt-reconciliation.ts +239 -0
  171. package/src/sdk/bus/recent-activity.ts +90 -26
  172. package/src/sdk/bus/telegram-daemon-contract.ts +15 -3
  173. package/src/sdk/bus/telegram-daemon-control.ts +145 -33
  174. package/src/sdk/bus/telegram-daemon.ts +853 -112
  175. package/src/sdk/bus/telegram-reference.ts +20 -2
  176. package/src/sdk/cli/session-cli.ts +15 -2
  177. package/src/sdk/host/control/dispatch.ts +1 -1
  178. package/src/sdk/host/control/operations.ts +1 -1
  179. package/src/sdk/host/host.ts +2 -6
  180. package/src/sdk/host/query/handlers.ts +70 -5
  181. package/src/sdk/index.ts +12 -0
  182. package/src/sdk/prompt-status.ts +97 -0
  183. package/src/sdk/protocol/operation-inventory.generated.json +47 -0
  184. package/src/sdk/protocol/operation-registry.ts +18 -4
  185. package/src/sdk/session-directory.ts +5 -1
  186. package/src/sdk/session.ts +2 -0
  187. package/src/sdk/startup-capability.ts +9 -1
  188. package/src/session/agent-session.ts +138 -5
  189. package/src/session/blob-store.ts +7 -2
  190. package/src/session/internal/managed-session-scope.ts +998 -133
  191. package/src/session/internal/managed-session-storage.ts +268 -203
  192. package/src/session/internal/native-publish-outcome.ts +266 -0
  193. package/src/session/session-manager.ts +406 -360
  194. package/src/session/session-storage.ts +151 -11
  195. package/src/skill-state/active-state.ts +10 -0
  196. package/src/task/executor.ts +13 -5
  197. package/src/task/gjc-command.ts +2 -2
  198. package/src/task/index.ts +121 -15
  199. package/src/task/receipt.ts +3 -0
  200. package/src/task/types.ts +34 -3
  201. package/src/tools/ask.ts +199 -88
  202. package/src/tools/ast-edit.ts +2 -2
  203. package/src/tools/bash-pty-selection.ts +2 -2
  204. package/src/tools/browser/screenshot-format.ts +6 -1
  205. package/src/tools/cron.ts +1 -1
  206. package/src/tools/image-gen.ts +178 -6
  207. package/src/tools/index.ts +4 -0
  208. package/src/tools/render-utils.ts +7 -1
  209. package/src/utils/edit-mode.ts +2 -2
  210. package/src/utils/prompt-suggestion.ts +265 -0
  211. package/src/utils/sixel.ts +3 -3
  212. package/src/web/insane/url-guard.ts +3 -1
  213. 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,
@@ -543,6 +547,13 @@ export class SessionMigrationPolicyError extends Error {
543
547
  }
544
548
  }
545
549
 
550
+ export class SessionArtifactCapacityError extends Error {
551
+ constructor(message: string) {
552
+ super(message);
553
+ this.name = "SessionArtifactCapacityError";
554
+ }
555
+ }
556
+
546
557
  export type ResumeTailInspection = ResumeTailResumable | ResumeTailTerminal | ResumeTailError;
547
558
  export interface StrictSessionOpenSuccess {
548
559
  kind: "opened";
@@ -551,7 +562,8 @@ export interface StrictSessionOpenSuccess {
551
562
 
552
563
  export interface StrictSessionOpenFailure {
553
564
  kind: "error";
554
- reason: ResumeTailError["reason"] | "identity-mismatch" | "migration-required";
565
+ reason: ResumeTailError["reason"] | "identity-mismatch" | "migration-required" | "artifact_capacity_exceeded";
566
+ message?: string;
555
567
  }
556
568
 
557
569
  /** Exact transcript bytes and authority captured from one descriptor-bound read. */
@@ -1320,6 +1332,18 @@ function freezeInternalReadSnapshot<T>(value: T): T {
1320
1332
  return copy;
1321
1333
  }
1322
1334
 
1335
+ function managedScopeStartupError(
1336
+ action: "resolve" | "prepare",
1337
+ failure: Extract<ReturnType<typeof resolveManagedScopeForWrite>, { kind: "error" }>,
1338
+ ): Error {
1339
+ return new Error(`Could not ${action} managed session scope.`, {
1340
+ cause: {
1341
+ classification: failure.cause?.classification ?? failure.code,
1342
+ ...(failure.cause?.diagnostic === undefined ? {} : { diagnostic: failure.cause.diagnostic }),
1343
+ },
1344
+ });
1345
+ }
1346
+
1323
1347
  /** Resolve and prepare the default v2 session scope before any managed writer exists. */
1324
1348
  function computeDefaultSessionDir(
1325
1349
  cwd: string,
@@ -1328,12 +1352,12 @@ function computeDefaultSessionDir(
1328
1352
  ): string {
1329
1353
  if (!(storage instanceof FileSessionStorage)) throw new SessionManagedStorageError();
1330
1354
  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}`);
1355
+ if (resolved.kind === "error") throw managedScopeStartupError("resolve", resolved);
1332
1356
  const prepared = prepareManagedSessionScopeForWriteSync(
1333
1357
  resolved.scope,
1334
1358
  process.platform === "win32" ? "windows-existing-verify-first" : "default",
1335
1359
  );
1336
- if (prepared.kind === "error") throw new Error(`Could not prepare managed session scope: ${prepared.message}`);
1360
+ if (prepared.kind === "error") throw managedScopeStartupError("prepare", prepared);
1337
1361
  return prepared.scope.directoryPath;
1338
1362
  }
1339
1363
 
@@ -1468,12 +1492,12 @@ function managedDestination(cwd: string, storage: SessionStorage, agentDir?: str
1468
1492
  agentDir: agentDir ?? path.resolve(sessionsRoot, ".."),
1469
1493
  sessionsRoot,
1470
1494
  });
1471
- if (resolved.kind === "error") throw new Error(`Could not resolve managed session scope: ${resolved.message}`);
1495
+ if (resolved.kind === "error") throw managedScopeStartupError("resolve", resolved);
1472
1496
  const prepared = prepareManagedSessionScopeForWriteSync(
1473
1497
  resolved.scope,
1474
1498
  process.platform === "win32" ? "windows-existing-verify-first" : "default",
1475
1499
  );
1476
- if (prepared.kind === "error") throw new Error(`Could not prepare managed session scope: ${prepared.message}`);
1500
+ if (prepared.kind === "error") throw managedScopeStartupError("prepare", prepared);
1477
1501
  return freezeManagedDestination(prepared.scope);
1478
1502
  }
1479
1503
 
@@ -2094,41 +2118,90 @@ export async function recoverOrphanedBackups(sessionDir: string, storage: Sessio
2094
2118
  }
2095
2119
 
2096
2120
  /**
2097
- * Reads all session files from the directory and returns them sorted by mtime (newest first).
2098
- * Uses low-level file I/O to efficiently read only the first 4KB of each file
2099
- * to extract the JSON header and first user message without loading entire session logs into memory.
2121
+ * Returns session metadata sorted by mtime (newest first).
2122
+ *
2123
+ * Directory entries are cheap to stat, but opening every transcript before applying a
2124
+ * small caller limit makes welcome-screen startup scale with the entire session history.
2125
+ * Rank paths first, then read bounded batches until the requested number of valid
2126
+ * sessions has been found. Invalid or future-version files do not prevent older valid
2127
+ * sessions from filling the result.
2100
2128
  */
2101
- async function getSortedSessions(sessionDir: string, storage: SessionStorage): Promise<RecentSessionInfo[]> {
2129
+ async function getSortedSessions(
2130
+ sessionDir: string,
2131
+ storage: SessionStorage,
2132
+ limit?: number,
2133
+ ): Promise<RecentSessionInfo[]> {
2102
2134
  await recoverOrphanedBackups(sessionDir, storage);
2103
2135
  try {
2104
- const files: string[] = storage.listFilesSync(sessionDir, "*.jsonl");
2136
+ let candidates: Array<{ path: string; mtime: number }> | undefined;
2137
+ if (storage.listFilesByMtime) {
2138
+ try {
2139
+ candidates = (await storage.listFilesByMtime(sessionDir, "*.jsonl")).map(candidate => ({
2140
+ path: candidate.path,
2141
+ mtime: candidate.mtimeMs,
2142
+ }));
2143
+ } catch (error) {
2144
+ logger.warn("Native session mtime listing failed; using JavaScript fallback", {
2145
+ sessionDir,
2146
+ error: toError(error).message,
2147
+ });
2148
+ }
2149
+ } else {
2150
+ logger.debug("Native session mtime listing unavailable; using JavaScript fallback", { sessionDir });
2151
+ }
2152
+ candidates ??= storage.listFilesSync(sessionDir, "*.jsonl").flatMap(path => {
2153
+ try {
2154
+ return [{ path, mtime: storage.statSync(path).mtimeMs }];
2155
+ } catch {
2156
+ return [];
2157
+ }
2158
+ });
2159
+ candidates.sort((left, right) => {
2160
+ if (left.mtime !== right.mtime) return right.mtime - left.mtime;
2161
+ return left.path < right.path ? -1 : left.path > right.path ? 1 : 0;
2162
+ });
2163
+
2105
2164
  const sessions: RecentSessionInfo[] = [];
2106
- await Promise.all(
2107
- files.map(async (path: string) => {
2108
- try {
2109
- const buffer = Buffer.allocUnsafe(SESSION_LIST_PREFIX_BYTES);
2110
- const content = await readSessionListPrefix(path, storage, buffer);
2111
- const entries = parseJsonlLenient<Record<string, unknown>>(content);
2112
- if (entries.length === 0) return;
2113
- const header = entries[0] as Record<string, unknown>;
2114
- if (
2115
- header.type !== "session" ||
2116
- typeof header.id !== "string" ||
2117
- !isSupportedSessionVersion(header.version)
2118
- )
2119
- return;
2120
- if (typeof header.version === "number" && header.version >= 4) {
2121
- for (const patch of await readSessionListTrailingPatches(path, storage, buffer)) {
2122
- applySessionListHeaderPatch(header as unknown as SessionListHeader, patch);
2165
+ const batchSize = Math.max(32, limit ?? 0);
2166
+
2167
+ for (
2168
+ let offset = 0;
2169
+ offset < candidates.length && (limit === undefined || sessions.length < limit);
2170
+ offset += batchSize
2171
+ ) {
2172
+ const batch = candidates.slice(offset, offset + batchSize);
2173
+ const parsed = await Promise.all(
2174
+ batch.map(async candidate => {
2175
+ try {
2176
+ const buffer = Buffer.allocUnsafe(SESSION_LIST_PREFIX_BYTES);
2177
+ const content = await readSessionListPrefix(candidate.path, storage, buffer);
2178
+ const entries = parseJsonlLenient<Record<string, unknown>>(content);
2179
+ if (entries.length === 0) return undefined;
2180
+ const header = entries[0] as Record<string, unknown>;
2181
+ if (
2182
+ header.type !== "session" ||
2183
+ typeof header.id !== "string" ||
2184
+ !isSupportedSessionVersion(header.version)
2185
+ )
2186
+ return undefined;
2187
+ if (typeof header.version === "number" && header.version >= 4) {
2188
+ for (const patch of await readSessionListTrailingPatches(candidate.path, storage, buffer)) {
2189
+ applySessionListHeaderPatch(header as unknown as SessionListHeader, patch);
2190
+ }
2123
2191
  }
2192
+ const firstPrompt = header.title ? undefined : extractFirstUserPrompt(entries);
2193
+ return new RecentSessionInfo(candidate.path, candidate.mtime, header, firstPrompt);
2194
+ } catch {
2195
+ return undefined;
2124
2196
  }
2125
- const mtime = storage.statSync(path).mtimeMs;
2126
- const firstPrompt = header.title ? undefined : extractFirstUserPrompt(entries);
2127
- sessions.push(new RecentSessionInfo(path, mtime, header, firstPrompt));
2128
- } catch {}
2129
- }),
2130
- );
2131
- return sessions.sort((a, b) => b.mtime - a.mtime);
2197
+ }),
2198
+ );
2199
+ for (const session of parsed) {
2200
+ if (session) sessions.push(session);
2201
+ if (limit !== undefined && sessions.length >= limit) break;
2202
+ }
2203
+ }
2204
+ return sessions;
2132
2205
  } catch {
2133
2206
  return [];
2134
2207
  }
@@ -2139,7 +2212,7 @@ export async function findMostRecentSession(
2139
2212
  sessionDir: string,
2140
2213
  storage: SessionStorage = new FileSessionStorage(),
2141
2214
  ): Promise<string | null> {
2142
- const sessions = await getSortedSessions(sessionDir, storage);
2215
+ const sessions = await getSortedSessions(sessionDir, storage, 1);
2143
2216
  return sessions[0]?.path || null;
2144
2217
  }
2145
2218
 
@@ -2158,23 +2231,6 @@ function formatTimeAgo(date: Date): string {
2158
2231
  return date.toLocaleDateString();
2159
2232
  }
2160
2233
 
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
2234
  interface SessionMoveDirectoryHandle {
2179
2235
  sync(): Promise<void>;
2180
2236
  close(): Promise<void>;
@@ -2194,40 +2250,6 @@ export async function syncSessionMoveDirectory(
2194
2250
  }
2195
2251
  }
2196
2252
 
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
2253
  type CrossDeviceTreeIdentity = string;
2232
2254
 
2233
2255
  /** Captures content plus topology; a later capture must match before source removal. */
@@ -2275,14 +2297,6 @@ async function captureCrossDeviceTreeIdentity(treePath: string): Promise<CrossDe
2275
2297
  return `directory:${before.dev}:${before.ino}:${before.size}:${before.mtimeNs}:[${children.join(",")}]`;
2276
2298
  }
2277
2299
 
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
2300
  /**
2287
2301
  * An EXDEV retirement must be driven by a native descriptor/handle-bound
2288
2302
  * snapshot-copy-verify transaction. Do not fall back to pathname copy/remove:
@@ -2298,83 +2312,25 @@ class CrossDeviceMoveUnsupportedError extends Error {
2298
2312
  }
2299
2313
  }
2300
2314
 
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
2315
  async function movePathAcrossDevicesSafe(source: string, destination: string): Promise<void> {
2308
2316
  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
- }
2317
+ const outcome = classifyNativePublishOutcome(native.renameNoReplacePath(source, destination));
2318
+ if (outcome.ok) {
2319
+ if ((await captureCrossDeviceTreeIdentity(destination)) !== sourceIdentity)
2320
+ throw new Error("Atomic session rename did not preserve the captured source identity");
2321
+ await syncSessionMoveDirectory(path.dirname(destination));
2322
+ if (path.dirname(source) !== path.dirname(destination)) await syncSessionMoveDirectory(path.dirname(source));
2319
2323
  return;
2320
2324
  }
2321
- if (renamed.code === "quarantine_collision") {
2325
+ if (outcome.reason === "destination_exists") {
2322
2326
  const error = new Error(`Session move destination already exists: ${destination}`) as NodeJS.ErrnoException;
2323
2327
  error.code = "EEXIST";
2324
2328
  throw error;
2325
2329
  }
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
- }
2330
+ if (outcome.reason === "atomic_unavailable")
2331
+ throw new CrossDeviceMoveUnsupportedError(source, destination, new Error(formatNativePublishDiagnostic(outcome)));
2332
+ const message = `Atomic session rename failed: ${outcome.code ?? outcome.reason}`;
2333
+ throw new Error(message, { cause: new Error(formatNativePublishDiagnostic(outcome)) });
2378
2334
  }
2379
2335
 
2380
2336
  const MAX_PERSIST_CHARS = 500_000;
@@ -3528,8 +3484,7 @@ export async function getRecentSessions(
3528
3484
  limit = DEFAULT_WELCOME_RECENT_SESSION_LIMIT,
3529
3485
  storage: SessionStorage = new FileSessionStorage(),
3530
3486
  ): Promise<RecentSessionInfo[]> {
3531
- const sessions = await getSortedSessions(sessionDir, storage);
3532
- return sessions.slice(0, limit);
3487
+ return getSortedSessions(sessionDir, storage, limit);
3533
3488
  }
3534
3489
 
3535
3490
  /**
@@ -3560,6 +3515,64 @@ function getTaskToolUsage(details: unknown): Usage | undefined {
3560
3515
  return usage as Usage;
3561
3516
  }
3562
3517
 
3518
+ interface ValidatedUsageTotals {
3519
+ input: number;
3520
+ output: number;
3521
+ cacheRead: number;
3522
+ cacheWrite: number;
3523
+ premiumRequests: number;
3524
+ cost: number;
3525
+ }
3526
+
3527
+ /** Max malformed-usage entry ids sampled for a single resume-time report. */
3528
+ const MALFORMED_USAGE_SAMPLE_LIMIT = 8;
3529
+
3530
+ function isFiniteNonNegativeNumber(value: unknown): value is number {
3531
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
3532
+ }
3533
+
3534
+ /**
3535
+ * Validate an untrusted persisted `usage` record before it is aggregated into
3536
+ * session usage statistics. `parseSessionEntries` accepts any parseable JSON, so a
3537
+ * torn/corrupt record can carry a `usage` that is still valid JSON yet malformed:
3538
+ * absent (`{}` yields NaN), numeric strings (`"10"` coerces sums into strings), or
3539
+ * negative buckets that silently reduce totals. Returns the validated finite
3540
+ * non-negative totals, or null when any bucket or `cost.total` is malformed — the
3541
+ * caller then skips and reports that record rather than poisoning every
3542
+ * getUsageStatistics() consumer. Absent `premiumRequests`/`cost` default to 0
3543
+ * (backward-compatible with older transcripts); a present-but-invalid field is
3544
+ * rejected.
3545
+ */
3546
+ function validatePersistedUsageTotals(usage: unknown): ValidatedUsageTotals | null {
3547
+ if (typeof usage !== "object" || usage === null) return null;
3548
+ const record = usage as Record<string, unknown>;
3549
+ const input = record.input;
3550
+ const output = record.output;
3551
+ const cacheRead = record.cacheRead;
3552
+ const cacheWrite = record.cacheWrite;
3553
+ if (
3554
+ !isFiniteNonNegativeNumber(input) ||
3555
+ !isFiniteNonNegativeNumber(output) ||
3556
+ !isFiniteNonNegativeNumber(cacheRead) ||
3557
+ !isFiniteNonNegativeNumber(cacheWrite)
3558
+ )
3559
+ return null;
3560
+ // Default only on truly absent (=== undefined) properties; a present-but-null/invalid
3561
+ // premiumRequests is malformed, not zero.
3562
+ const premiumRequests = record.premiumRequests === undefined ? 0 : record.premiumRequests;
3563
+ if (!isFiniteNonNegativeNumber(premiumRequests)) return null;
3564
+ const rawCost = record.cost;
3565
+ let cost: unknown = 0;
3566
+ if (rawCost !== undefined) {
3567
+ // A present `cost` must be a non-array record carrying a finite non-negative `total`;
3568
+ // null, arrays, and an absent/invalid `total` are malformed — never silently zero.
3569
+ if (typeof rawCost !== "object" || rawCost === null || Array.isArray(rawCost)) return null;
3570
+ cost = (rawCost as Record<string, unknown>).total;
3571
+ }
3572
+ if (!isFiniteNonNegativeNumber(cost)) return null;
3573
+ return { input, output, cacheRead, cacheWrite, premiumRequests, cost };
3574
+ }
3575
+
3563
3576
  function extractTextFromContent(content: Message["content"]): string {
3564
3577
  if (typeof content === "string") return content;
3565
3578
  return content
@@ -4331,7 +4344,8 @@ export class SessionManager {
4331
4344
  const candidate = listing.owned.find(candidate => path.resolve(candidate.path) === requestedPath);
4332
4345
  if (!candidate) throw new Error("Managed session deletion requires exact logical authorization.");
4333
4346
  const deleted = await deleteManagedSessionCandidate(resolved.scope, candidate);
4334
- if (deleted.kind === "error") throw new Error(`Could not delete managed session: ${deleted.message}`);
4347
+ if (deleted.kind !== "deleted" && deleted.kind !== "already_deleted")
4348
+ throw new Error(`Could not delete managed session: ${deleted.message}`);
4335
4349
  } else {
4336
4350
  await this.storage.deleteSessionWithArtifacts(sessionPath);
4337
4351
  }
@@ -4460,27 +4474,20 @@ export class SessionManager {
4460
4474
  throw new Error("managed_fork_transcript_changed");
4461
4475
  }
4462
4476
  } catch (error) {
4463
- let failure = error;
4464
- const failedSessionFile = this.#sessionFile;
4465
- if (this.destination.kind === "managed") {
4477
+ const failure = error;
4478
+ if (forkArtifactPublication) {
4466
4479
  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
- );
4480
+ forkArtifactPublication.cleanupStore.removeTreeExpected(
4481
+ forkArtifactPublication.cleanupRelativePath,
4482
+ forkArtifactPublication.snapshot,
4483
+ );
4474
4484
  } 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.
4485
+ const cleanupMessage = toError(cleanupError).message;
4486
+ if (cleanupMessage !== "cleanup_pending") {
4487
+ throw new Error(`Failed to clean up fork artifacts: ${cleanupMessage}`, {
4488
+ cause: toError(failure),
4489
+ });
4490
+ }
4484
4491
  }
4485
4492
  }
4486
4493
  this.#sessionId = oldSessionId;
@@ -4559,8 +4566,7 @@ export class SessionManager {
4559
4566
  }
4560
4567
  if (JSON.stringify(parentStore.captureTree(sourceName)) !== JSON.stringify(sourceSnapshot))
4561
4568
  throw new Error("artifact_source_changed");
4562
- parentStore.moveTreeNoReplace(stagingName, destinationName, stagingSnapshot);
4563
- publishedSnapshot = parentStore.captureTree(destinationName);
4569
+ publishedSnapshot = parentStore.moveTreeNoReplace(stagingName, destinationName, stagingSnapshot);
4564
4570
  const comparable = (tree: native.NativeDirectoryTreeSnapshot) =>
4565
4571
  tree.entries.map(entry => ({
4566
4572
  relativePath: entry.relativePath,
@@ -4584,12 +4590,15 @@ export class SessionManager {
4584
4590
  };
4585
4591
  } catch (error) {
4586
4592
  try {
4587
- if (publishedSnapshot) parentStore.removeTreeExpected(destinationName, publishedSnapshot);
4588
- else if (stagingSnapshot) parentStore.removeTreeExpected(stagingName, stagingSnapshot);
4593
+ if (stagingSnapshot && mayCleanManagedTreeStaging(error))
4594
+ parentStore.removeTreeExpected(stagingName, stagingSnapshot);
4589
4595
  } catch (cleanupError) {
4590
- throw new Error(`Failed to clean up managed fork artifacts: ${toError(cleanupError).message}`, {
4591
- cause: toError(error),
4592
- });
4596
+ const cleanupMessage = toError(cleanupError).message;
4597
+ if (cleanupMessage !== "cleanup_pending") {
4598
+ throw new Error(`Failed to clean up managed fork artifacts: ${cleanupMessage}`, {
4599
+ cause: toError(error),
4600
+ });
4601
+ }
4593
4602
  }
4594
4603
  throw error;
4595
4604
  }
@@ -4607,7 +4616,6 @@ export class SessionManager {
4607
4616
  const previousSessionDir = this.sessionDir;
4608
4617
  const previousSessionFile = this.#sessionFile;
4609
4618
  const previousDestination = this.destination;
4610
- const previousArtifactDir = previousSessionFile?.slice(0, -6);
4611
4619
 
4612
4620
  const nextDestination =
4613
4621
  this.storage instanceof FileSessionStorage
@@ -4719,15 +4727,8 @@ export class SessionManager {
4719
4727
  managedPublishedArtifacts,
4720
4728
  );
4721
4729
  }
4722
- if (managedTranscript)
4730
+ if (managedTranscript && !managedSourceStore.readExpected(path.basename(oldSessionFile)))
4723
4731
  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
4732
  };
4732
4733
  managedTranscript = managedSourceStore.readExpected(path.basename(oldSessionFile));
4733
4734
  hadSessionFile = managedTranscript !== null;
@@ -4764,10 +4765,20 @@ export class SessionManager {
4764
4765
  if (!retainedTreeSnapshotEquals(terminalArtifacts, managedPublishedArtifacts))
4765
4766
  throw new Error("managed_move_destination_artifacts_changed");
4766
4767
  }
4767
- if (managedArtifacts)
4768
- managedSourceStore.removeTreeExpected(path.basename(oldArtifactDir), managedArtifacts);
4769
- if (managedTranscript)
4770
- managedSourceStore.removeExpected(path.basename(oldSessionFile), managedTranscript);
4768
+ if (managedArtifacts) {
4769
+ try {
4770
+ managedSourceStore.removeTreeExpected(path.basename(oldArtifactDir), managedArtifacts);
4771
+ } catch (cleanupError) {
4772
+ if (toError(cleanupError).message !== "cleanup_pending") throw cleanupError;
4773
+ }
4774
+ }
4775
+ if (managedTranscript) {
4776
+ try {
4777
+ managedSourceStore.removeExpected(path.basename(oldSessionFile), managedTranscript);
4778
+ } catch (cleanupError) {
4779
+ if (toError(cleanupError).message !== "cleanup_pending") throw cleanupError;
4780
+ }
4781
+ }
4771
4782
  } else {
4772
4783
  if (hadSessionFile && oldSessionFile !== newSessionFile) {
4773
4784
  await movePathAcrossDevicesSafe(oldSessionFile, newSessionFile);
@@ -4823,13 +4834,6 @@ export class SessionManager {
4823
4834
  !managedSourceStore.readExpected(path.basename(oldSessionFile))
4824
4835
  )
4825
4836
  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
4837
  } catch (rollbackErr) {
4834
4838
  restoreResidentStateAndThrow(
4835
4839
  new Error(`Failed to rollback managed move: ${toError(rollbackErr).message}`, {
@@ -4850,7 +4854,8 @@ export class SessionManager {
4850
4854
  this.#bumpAllRevisions();
4851
4855
  }
4852
4856
 
4853
- // Update cwd and sessionDir after the physical move succeeds, but roll the move back if metadata persistence fails.
4857
+ // Update cwd and sessionDir after physical publication succeeds. Metadata failures restore the source
4858
+ // authority but deliberately retain any destination publication evidence rather than deleting it.
4854
4859
  this.cwd = resolvedCwd;
4855
4860
  this.sessionDir = newSessionDir;
4856
4861
  this.destination = nextDestination;
@@ -4871,60 +4876,37 @@ export class SessionManager {
4871
4876
  await this.#appendHeaderPatch({ cwd: resolvedCwd });
4872
4877
  }
4873
4878
  } 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) {
4879
+ await this.#closePersistWriter().catch(() => {});
4880
+ this.#persistChain = Promise.resolve();
4881
+ this.#persistError = undefined;
4882
+ this.#persistErrorReported = false;
4883
+ if (rollbackManagedMove) {
4884
+ try {
4882
4885
  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);
4886
+ } catch (rollbackError) {
4887
+ if (toError(rollbackError).message !== "cleanup_pending") {
4888
+ throw new Error(`Failed to rollback managed move: ${toError(rollbackError).message}`, {
4889
+ cause: toError(error),
4890
+ });
4891
+ }
4913
4892
  }
4914
4893
  this.#sessionFile = previousSessionFile;
4915
4894
  this.cwd = previousCwd;
4916
4895
  this.sessionDir = previousSessionDir;
4917
4896
  this.destination = previousDestination;
4918
- this.#managedTranscriptStoreCache = null;
4897
+ this.#managedTranscriptStoreCache =
4898
+ managedMove && managedSourceStore
4899
+ ? { directory: path.resolve(previousSessionDir), store: managedSourceStore }
4900
+ : null;
4919
4901
  const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
4920
4902
  if (header) applyHeaderPatch(header, { cwd: previousCwd });
4921
4903
  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
- });
4904
+ throw error;
4927
4905
  }
4906
+ // The destination has already been published. Retain it rather than moving it
4907
+ // back over the source after a metadata failure.
4908
+ this.#managedTranscriptStoreCache = null;
4909
+ this.#headerExportRevision++;
4928
4910
  throw error;
4929
4911
  }
4930
4912
 
@@ -4984,6 +4966,8 @@ export class SessionManager {
4984
4966
  this.#labelsById.clear();
4985
4967
  this.#leafId = null;
4986
4968
  this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
4969
+ let malformedUsageRecords = 0;
4970
+ const malformedUsageSample: string[] = [];
4987
4971
  for (const entry of this.#fileEntries) {
4988
4972
  if (entry.type === "session") continue;
4989
4973
  this.#byId.set(entry.id, entry);
@@ -4995,28 +4979,80 @@ export class SessionManager {
4995
4979
  this.#labelsById.delete(entry.targetId);
4996
4980
  }
4997
4981
  }
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;
4982
+ // Aggregate this entry's persisted usage through the shared validated,
4983
+ // overflow-guarded path (see #accumulateEntryUsage). parseSessionEntries accepts
4984
+ // any parseable JSON, so a torn/corrupt record can carry a still-valid-JSON but
4985
+ // malformed `usage` (absent, {}, numeric strings, negatives, a non-record cost, or
4986
+ // a cumulative overflow); such a record is skipped and reported below instead of
4987
+ // poisoning every getUsageStatistics() consumer.
4988
+ if (this.#accumulateEntryUsage(entry)) {
4989
+ malformedUsageRecords++;
4990
+ if (malformedUsageSample.length < MALFORMED_USAGE_SAMPLE_LIMIT) malformedUsageSample.push(entry.id);
5006
4991
  }
4992
+ }
4993
+ if (malformedUsageRecords > 0) {
4994
+ logger.warn("Skipped malformed or overflowing persisted usage records during resume aggregation", {
4995
+ sessionFile: this.#sessionFile,
4996
+ count: malformedUsageRecords,
4997
+ sampleEntryIds: malformedUsageSample,
4998
+ });
4999
+ }
5000
+ }
5007
5001
 
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
- }
5002
+ /**
5003
+ * Commit a validated record's totals only if every cumulative sum stays finite.
5004
+ * Individually-finite buckets can still overflow to Infinity in aggregate (e.g. two
5005
+ * Number.MAX_VALUE records), which would poison getUsageStatistics(); such a record is
5006
+ * rejected atomically (no partial commit). Returns true when committed, false when
5007
+ * rejected for cumulative overflow.
5008
+ */
5009
+ #addValidatedUsage(totals: ValidatedUsageTotals): boolean {
5010
+ const next = {
5011
+ input: this.#usageStatistics.input + totals.input,
5012
+ output: this.#usageStatistics.output + totals.output,
5013
+ cacheRead: this.#usageStatistics.cacheRead + totals.cacheRead,
5014
+ cacheWrite: this.#usageStatistics.cacheWrite + totals.cacheWrite,
5015
+ premiumRequests: this.#usageStatistics.premiumRequests + totals.premiumRequests,
5016
+ cost: this.#usageStatistics.cost + totals.cost,
5017
+ };
5018
+ if (
5019
+ !Number.isFinite(next.input) ||
5020
+ !Number.isFinite(next.output) ||
5021
+ !Number.isFinite(next.cacheRead) ||
5022
+ !Number.isFinite(next.cacheWrite) ||
5023
+ !Number.isFinite(next.premiumRequests) ||
5024
+ !Number.isFinite(next.cost)
5025
+ )
5026
+ return false;
5027
+ this.#usageStatistics.input = next.input;
5028
+ this.#usageStatistics.output = next.output;
5029
+ this.#usageStatistics.cacheRead = next.cacheRead;
5030
+ this.#usageStatistics.cacheWrite = next.cacheWrite;
5031
+ this.#usageStatistics.premiumRequests = next.premiumRequests;
5032
+ this.#usageStatistics.cost = next.cost;
5033
+ return true;
5034
+ }
5035
+
5036
+ /**
5037
+ * Validate and accumulate one entry's persisted usage through the single shared,
5038
+ * overflow-guarded aggregation path used by both #buildIndex (resume) and #appendEntry
5039
+ * (runtime), covering the assistant and `task` tool-result shapes. Returns true when a
5040
+ * present usage record was skipped as malformed or overflowing (so the caller can report
5041
+ * it); false when the entry has no usage or was aggregated cleanly.
5042
+ */
5043
+ #accumulateEntryUsage(entry: SessionEntry): boolean {
5044
+ if (entry.type !== "message") return false;
5045
+ if (entry.message.role === "assistant") {
5046
+ const totals = validatePersistedUsageTotals(entry.message.usage);
5047
+ return !(totals && this.#addValidatedUsage(totals));
5048
+ }
5049
+ if (entry.message.role === "toolResult" && entry.message.toolName === "task") {
5050
+ const rawTaskUsage = getTaskToolUsage(entry.message.details);
5051
+ if (rawTaskUsage === undefined) return false;
5052
+ const totals = validatePersistedUsageTotals(rawTaskUsage);
5053
+ return !(totals && this.#addValidatedUsage(totals));
5019
5054
  }
5055
+ return false;
5020
5056
  }
5021
5057
 
5022
5058
  #recordPersistError(err: unknown): Error {
@@ -6007,26 +6043,14 @@ export class SessionManager {
6007
6043
  this.#leafRevision++;
6008
6044
  if (entry.type === "label") this.#labelRevision++;
6009
6045
  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
- }
6046
+ // Same validated, overflow-guarded aggregation as the resume path (#buildIndex): a
6047
+ // malformed task-result or assistant usage shape reaching the append path must not
6048
+ // crash or poison getUsageStatistics() either.
6049
+ if (this.#accumulateEntryUsage(entry)) {
6050
+ logger.warn("Skipped malformed or overflowing usage on appended entry", {
6051
+ sessionFile: this.#sessionFile,
6052
+ entryId: entry.id,
6053
+ });
6030
6054
  }
6031
6055
  }
6032
6056
 
@@ -7254,9 +7278,11 @@ export class SessionManager {
7254
7278
  const storage = new FileSessionStorage();
7255
7279
  const managedDestinationStore = managedStoreFromContext(destination.securityContext, destination.directory);
7256
7280
  const assertManagedDestinationBound = (): void => {
7257
- assertManagedDirectoryRoot(destination.securityContext.rootAuthority);
7258
7281
  managedDestinationStore.assertBound();
7282
+ if (!destination.securityContext.retainedAuthority)
7283
+ assertManagedDirectoryRoot(destination.securityContext.rootAuthority);
7259
7284
  };
7285
+
7260
7286
  assertManagedDestinationBound();
7261
7287
  const inspected = inspectResumeSessionFile(filePath, storage);
7262
7288
  if ("kind" in inspected) {
@@ -7312,18 +7338,27 @@ export class SessionManager {
7312
7338
  }
7313
7339
  : {}),
7314
7340
  };
7315
- const opened = await openManagedCandidateForWrite(
7316
- resolved.scope,
7317
- candidate,
7318
- expectedIdentity ?? inspected.identity,
7319
- migrationPolicy,
7320
- authority,
7321
- );
7322
- assertManagedDestinationBound();
7341
+ let opened: ManagedOpenCandidateResult;
7342
+ try {
7343
+ opened = await openManagedCandidateForWrite(
7344
+ resolved.scope,
7345
+ candidate,
7346
+ expectedIdentity ?? inspected.identity,
7347
+ migrationPolicy,
7348
+ authority,
7349
+ );
7350
+ } catch (error) {
7351
+ if (error instanceof Error && error.message.startsWith("Managed root authority changed"))
7352
+ managedDestinationStore.assertBound();
7353
+ throw error;
7354
+ }
7323
7355
  if (opened.kind === "error") {
7356
+ managedDestinationStore.assertBound();
7324
7357
  if (opened.code === "legacy_migration_disabled") throw new SessionMigrationPolicyError();
7358
+ if (opened.code === "artifact_capacity_exceeded") throw new SessionArtifactCapacityError(opened.message);
7325
7359
  throw new Error(`Could not open managed session: ${opened.message}`);
7326
7360
  }
7361
+ assertManagedDestinationBound();
7327
7362
  return opened.path;
7328
7363
  }
7329
7364
 
@@ -7536,7 +7571,8 @@ export class SessionManager {
7536
7571
  const candidate = listing.owned.find(item => path.resolve(item.path) === path.resolve(sessionPath));
7537
7572
  if (!candidate) throw new Error("Session is not an authorized managed candidate.");
7538
7573
  const deleted = await deleteManagedSessionCandidate(resolved.scope, candidate);
7539
- if (deleted.kind === "error") throw new Error(`Could not delete managed session: ${deleted.message}`);
7574
+ if (deleted.kind !== "deleted" && deleted.kind !== "already_deleted")
7575
+ throw new Error(`Could not delete managed session: ${deleted.message}`);
7540
7576
  }
7541
7577
 
7542
7578
  /** Capture exact source content for a strict fork without granting write ownership. */
@@ -7596,16 +7632,22 @@ export class SessionManager {
7596
7632
  }
7597
7633
 
7598
7634
  const dir = destination.directory;
7599
- const removeSessionDirOnFailure =
7635
+ const privateStagingDir =
7600
7636
  destination.kind !== "managed" &&
7601
7637
  snapshot.storage instanceof FileSessionStorage &&
7602
- !snapshot.storage.existsSync(dir);
7638
+ !snapshot.storage.existsSync(dir)
7639
+ ? fs.mkdtempSync(path.join(path.dirname(dir), `.${path.basename(dir)}.fork-staging-`))
7640
+ : undefined;
7641
+ const forkDestination = privateStagingDir ? explicitDestination(privateStagingDir) : destination;
7603
7642
  let managedForkStore: ManagedSessionDescendantStore | undefined;
7604
7643
  let managedForkTranscript: ManagedFileSnapshot | null = null;
7605
7644
  let manager: SessionManager | undefined;
7606
7645
  let authorityFailure: StrictSessionOpenFailure | undefined;
7646
+ let privateStagingSnapshot: native.NativeDirectoryTreeSnapshot | undefined;
7647
+ let privateStagingPublished = false;
7648
+
7607
7649
  try {
7608
- manager = new SessionManager(cwd, dir, true, snapshot.storage, destination);
7650
+ manager = new SessionManager(cwd, privateStagingDir ?? dir, true, snapshot.storage, forkDestination);
7609
7651
  await resolveBlobRefsInEntries(forkEntries, manager.#blobStore);
7610
7652
  manager.#fileEntries = forkEntries;
7611
7653
  const sourceHeader = manager.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
@@ -7635,6 +7677,13 @@ export class SessionManager {
7635
7677
  throw new Error("Captured fork source authority changed before destination write.");
7636
7678
  }
7637
7679
  await manager.#rewriteFile();
7680
+ if (privateStagingDir) {
7681
+ const capturedStaging = native.snapshotDirectoryTree(privateStagingDir);
7682
+ if (!capturedStaging.ok || !capturedStaging.snapshot)
7683
+ throw new Error(capturedStaging.code ?? "fork_staging_snapshot_failed");
7684
+ privateStagingSnapshot = capturedStaging.snapshot;
7685
+ }
7686
+
7638
7687
  if (destination.kind === "managed" && manager.#sessionFile) {
7639
7688
  managedForkStore = manager.#managedTranscriptStore();
7640
7689
  managedForkTranscript = managedForkStore.readExpected(path.basename(manager.#sessionFile));
@@ -7654,66 +7703,57 @@ export class SessionManager {
7654
7703
  if (!managedFileSnapshotEquals(terminalTranscript, managedForkTranscript))
7655
7704
  throw new Error("managed_fork_transcript_changed");
7656
7705
  }
7706
+ if (privateStagingDir && manager.#sessionFile) {
7707
+ const materializedEntries = materializeResidentEntriesForReadSync(
7708
+ manager.#fileEntries,
7709
+ manager.#residentBlobStores(),
7710
+ );
7711
+ manager.#fileEntries = materializedEntries;
7712
+ manager.#disposeResidentTextBlobStore();
7713
+ const capturedStaging = native.snapshotDirectoryTree(privateStagingDir);
7714
+ if (!capturedStaging.ok || !capturedStaging.snapshot)
7715
+ throw new Error(capturedStaging.code ?? "fork_staging_snapshot_failed");
7716
+ privateStagingSnapshot = capturedStaging.snapshot;
7717
+ fsyncManagedArtifactTree(privateStagingDir);
7718
+ const outcome = classifyNativePublishOutcome(native.renameNoReplacePath(privateStagingDir, dir));
7719
+ if (!outcome.ok) throw new Error(outcome.code ?? "fork_destination_publish_failed");
7720
+ privateStagingPublished = true;
7721
+ const terminal = native.snapshotDirectoryTree(dir);
7722
+ if (
7723
+ !terminal.ok ||
7724
+ !terminal.snapshot ||
7725
+ !retainedTreeSnapshotEqualsAfterRename(terminal.snapshot, privateStagingSnapshot)
7726
+ )
7727
+ throw new Error("fork_destination_terminal_identity_changed");
7728
+ await syncSessionMoveDirectory(path.dirname(dir));
7729
+ const durableTerminal = native.snapshotDirectoryTree(dir);
7730
+ if (
7731
+ !durableTerminal.ok ||
7732
+ !durableTerminal.snapshot ||
7733
+ !retainedTreeSnapshotEqualsAfterRename(durableTerminal.snapshot, privateStagingSnapshot)
7734
+ )
7735
+ throw new Error("fork_destination_durability_identity_changed");
7736
+ manager.sessionDir = dir;
7737
+ manager.destination = destination;
7738
+ manager.#sessionFile = path.join(dir, path.basename(manager.#sessionFile));
7739
+ manager.#resetResidentTextBlobStore();
7740
+ manager.#reexternalizeFileEntriesForResidentStore();
7741
+ manager.#bumpAllRevisions();
7742
+ }
7657
7743
  if (manager.#sessionFile) writeTerminalBreadcrumb(manager.cwd, manager.#sessionFile);
7658
7744
  return { kind: "forked", manager };
7659
7745
  } catch (error) {
7660
7746
  if (manager) {
7661
- const sessionFile = manager.#sessionFile;
7662
7747
  const cleanupErrors: Error[] = [];
7663
7748
  try {
7664
7749
  await manager.close();
7665
7750
  } catch (cleanupError) {
7666
- cleanupErrors.push(toError(cleanupError));
7751
+ if (toError(cleanupError).message !== "cleanup_pending") cleanupErrors.push(toError(cleanupError));
7667
7752
  }
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
- }
7708
- }
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
- }
7753
+ if (!privateStagingPublished && privateStagingDir && privateStagingSnapshot) {
7754
+ const removed = native.exactRemoveDirectoryTree(privateStagingDir, privateStagingSnapshot);
7755
+ if (!removed.ok && removed.code !== "not_found" && removed.code !== "cleanup_pending")
7756
+ cleanupErrors.push(new Error(removed.code ?? "fork_staging_cleanup_failed"));
7717
7757
  }
7718
7758
  if (cleanupErrors.length > 0) {
7719
7759
  throw new Error(`Failed to clean up fork destination: ${cleanupErrors[0]!.message}`, {
@@ -7826,6 +7866,8 @@ export class SessionManager {
7826
7866
  } catch (error) {
7827
7867
  if (error instanceof SessionMigrationPolicyError)
7828
7868
  return { kind: "error", reason: "legacy_migration_disabled" };
7869
+ if (error instanceof SessionArtifactCapacityError)
7870
+ return { kind: "error", reason: "artifact_capacity_exceeded", message: error.message };
7829
7871
  if (
7830
7872
  error instanceof Error &&
7831
7873
  (error.message.includes("source_changed") || error.message.includes("changed before migration"))
@@ -7884,6 +7926,10 @@ export class SessionManager {
7884
7926
  );
7885
7927
  if (opened.kind === "error") {
7886
7928
  if (opened.reason === "legacy_migration_disabled") throw new SessionMigrationPolicyError();
7929
+ if (opened.reason === "artifact_capacity_exceeded")
7930
+ throw new SessionArtifactCapacityError(
7931
+ opened.message ?? "Session artifacts exceed the migration capacity.",
7932
+ );
7887
7933
  return undefined;
7888
7934
  }
7889
7935
  return opened.manager;