@bitkyc08/opencodex 2.8.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/README.md +24 -0
  2. package/bin/ocx.mjs +32 -4
  3. package/gui/dist/assets/index-CHwf3tTD.css +1 -0
  4. package/gui/dist/assets/index-u5eFOv2y.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic-image-normalize.ts +114 -20
  8. package/src/adapters/anthropic.ts +126 -10
  9. package/src/adapters/azure.ts +3 -3
  10. package/src/adapters/base.ts +7 -3
  11. package/src/adapters/cursor/discovery.ts +14 -4
  12. package/src/adapters/cursor/effort-map.ts +18 -6
  13. package/src/adapters/cursor/framing.ts +102 -27
  14. package/src/adapters/cursor/kv-store.ts +30 -3
  15. package/src/adapters/cursor/live-models.ts +22 -2
  16. package/src/adapters/cursor/live-transport.ts +245 -49
  17. package/src/adapters/cursor/mcp-manager.ts +105 -8
  18. package/src/adapters/cursor/native-exec-mcp.ts +5 -3
  19. package/src/adapters/cursor/native-exec-shell.ts +296 -14
  20. package/src/adapters/cursor/native-exec.ts +381 -33
  21. package/src/adapters/cursor/protobuf-events.ts +28 -1
  22. package/src/adapters/cursor/protobuf-request.ts +71 -39
  23. package/src/adapters/cursor/request-builder.ts +2 -2
  24. package/src/adapters/cursor/transport.ts +2 -0
  25. package/src/adapters/cursor.ts +13 -2
  26. package/src/adapters/google-antigravity-replay.ts +184 -17
  27. package/src/adapters/google.ts +58 -8
  28. package/src/adapters/kiro-thinking.ts +23 -9
  29. package/src/adapters/kiro-tools.ts +49 -18
  30. package/src/adapters/kiro.ts +377 -133
  31. package/src/adapters/mimo-free.ts +36 -4
  32. package/src/adapters/openai-chat.ts +143 -17
  33. package/src/adapters/openai-responses.ts +130 -14
  34. package/src/adapters/run-turn-queue.ts +7 -1
  35. package/src/bridge.ts +466 -69
  36. package/src/chat/outbound.ts +144 -38
  37. package/src/claude/inbound-debug.ts +53 -8
  38. package/src/claude/outbound.ts +224 -38
  39. package/src/cli/agent-driven.ts +34 -1
  40. package/src/cli/catalog-prewarm.ts +5 -2
  41. package/src/cli/claude-desktop.ts +2 -2
  42. package/src/cli/doctor.ts +12 -0
  43. package/src/cli/export-command.ts +187 -0
  44. package/src/cli/help.ts +11 -0
  45. package/src/cli/index.ts +13 -3
  46. package/src/cli/init.ts +129 -102
  47. package/src/cli/opencode.ts +36 -151
  48. package/src/cli/star-prompt.ts +13 -4
  49. package/src/cli/status-oauth.ts +12 -2
  50. package/src/clients/config-export.ts +377 -0
  51. package/src/codex/account-runtime-state.ts +19 -1
  52. package/src/codex/account-store.ts +162 -82
  53. package/src/codex/auth-api.ts +467 -159
  54. package/src/codex/auth-context.ts +15 -2
  55. package/src/codex/catalog/aggregation.ts +15 -0
  56. package/src/codex/catalog/effort.ts +16 -6
  57. package/src/codex/catalog/metadata.ts +6 -0
  58. package/src/codex/catalog/parsing.ts +3 -1
  59. package/src/codex/catalog/provider-fetch.ts +29 -0
  60. package/src/codex/catalog/sync.ts +64 -7
  61. package/src/codex/catalog.ts +2 -2
  62. package/src/codex/inject.ts +5 -5
  63. package/src/codex/main-account-cache.ts +8 -1
  64. package/src/codex/model-cache.ts +81 -2
  65. package/src/codex/pool-rotation.ts +39 -0
  66. package/src/codex/project-config-warnings.ts +12 -1
  67. package/src/codex/quota.ts +35 -3
  68. package/src/codex/routing.ts +46 -1
  69. package/src/codex/shim.ts +10 -4
  70. package/src/codex/subagent-model-fallback.ts +12 -0
  71. package/src/codex/websocket-registry.ts +27 -0
  72. package/src/combos/failover.ts +31 -1
  73. package/src/combos/request.ts +9 -0
  74. package/src/combos/resolve.ts +60 -4
  75. package/src/combos/types.ts +12 -0
  76. package/src/config.ts +510 -55
  77. package/src/github/star-state.ts +13 -1
  78. package/src/images/fulfill.ts +39 -1
  79. package/src/images/loop.ts +52 -12
  80. package/src/lib/admission.ts +83 -0
  81. package/src/lib/app-owned-memory-stores.ts +173 -0
  82. package/src/lib/app-owned-memory.ts +265 -0
  83. package/src/lib/bun-stream-caps.ts +31 -7
  84. package/src/lib/config-ownership.ts +33 -0
  85. package/src/lib/crash-guard.ts +65 -5
  86. package/src/lib/debug-log-buffer.ts +47 -6
  87. package/src/lib/destination-policy.ts +12 -1
  88. package/src/lib/errors.ts +3 -0
  89. package/src/lib/gcp-adc.ts +40 -2
  90. package/src/lib/injection-debug-log.ts +26 -2
  91. package/src/lib/provider-outbound.ts +3 -0
  92. package/src/lib/sidecar-tracker.ts +5 -2
  93. package/src/lib/sse-decoder.ts +257 -37
  94. package/src/lib/state-store-registrations.ts +109 -0
  95. package/src/lib/state-store-sweeper.ts +184 -0
  96. package/src/lib/translator-budget.ts +356 -0
  97. package/src/lib/windows-secret-acl.ts +33 -12
  98. package/src/lib/winsw.ts +14 -1
  99. package/src/oauth/anthropic-routing.ts +31 -7
  100. package/src/oauth/google-antigravity.ts +2 -1
  101. package/src/oauth/health.ts +30 -12
  102. package/src/oauth/index.ts +127 -23
  103. package/src/oauth/kiro-credentials.ts +72 -1
  104. package/src/oauth/kiro.ts +23 -4
  105. package/src/oauth/store.ts +165 -18
  106. package/src/oauth/token-guardian.ts +43 -4
  107. package/src/oauth/types.ts +2 -1
  108. package/src/providers/base-url-choices.ts +10 -0
  109. package/src/providers/derive.ts +12 -0
  110. package/src/providers/free-directory.ts +4 -1
  111. package/src/providers/key-failover.ts +12 -0
  112. package/src/providers/openai-sidecar.ts +4 -1
  113. package/src/providers/quota.ts +68 -7
  114. package/src/providers/registry.ts +279 -3
  115. package/src/responses/parser.ts +5 -1
  116. package/src/responses/spill-store.ts +394 -0
  117. package/src/responses/state.ts +520 -102
  118. package/src/router.ts +18 -1
  119. package/src/server/adapter-resolve.ts +20 -3
  120. package/src/server/auth-cors.ts +121 -28
  121. package/src/server/chat-completions.ts +57 -12
  122. package/src/server/claude-messages.ts +85 -13
  123. package/src/server/index.ts +242 -100
  124. package/src/server/lifecycle.ts +155 -25
  125. package/src/server/management/agent-settings-routes.ts +79 -36
  126. package/src/server/management/api-key-usage.ts +167 -0
  127. package/src/server/management/body.ts +35 -0
  128. package/src/server/management/combo-routes.ts +5 -1
  129. package/src/server/management/config-routes.ts +42 -12
  130. package/src/server/management/logs-usage-routes.ts +41 -21
  131. package/src/server/management/model-routes.ts +188 -54
  132. package/src/server/management/oauth-account-routes.ts +115 -26
  133. package/src/server/management/provider-routes.ts +56 -6
  134. package/src/server/management/shared.ts +16 -3
  135. package/src/server/management/sidebar-routes.ts +50 -1
  136. package/src/server/management/system-restart.ts +13 -6
  137. package/src/server/management/system-routes.ts +15 -3
  138. package/src/server/management/usage-summary-cache.ts +86 -0
  139. package/src/server/management-api.ts +39 -5
  140. package/src/server/management-auth.ts +65 -14
  141. package/src/server/port-reclaim.ts +58 -12
  142. package/src/server/ports.ts +2 -0
  143. package/src/server/proxy-liveness.ts +60 -14
  144. package/src/server/relay-eager.ts +20 -4
  145. package/src/server/relay.ts +548 -154
  146. package/src/server/request-decompress.ts +51 -4
  147. package/src/server/request-log.ts +134 -15
  148. package/src/server/responses/collaboration.ts +15 -4
  149. package/src/server/responses/compact.ts +3 -0
  150. package/src/server/responses/core.ts +241 -66
  151. package/src/server/responses-image-gen-repair.ts +19 -5
  152. package/src/server/responses-item-id-repair.ts +23 -5
  153. package/src/server/sse-payload-rewrite.ts +71 -12
  154. package/src/server/startup-health-cache.ts +14 -1
  155. package/src/server/system-env.ts +8 -1
  156. package/src/server/windows-tcp-drop.ts +15 -5
  157. package/src/server/ws-bridge.ts +25 -0
  158. package/src/service.ts +179 -13
  159. package/src/storage/policy-job.ts +93 -23
  160. package/src/storage/policy-worker.ts +6 -0
  161. package/src/storage/restore-job.ts +62 -16
  162. package/src/storage/restore-worker.ts +6 -0
  163. package/src/storage/storage-mutation-coordinator.ts +36 -6
  164. package/src/storage/worker-lifecycle.ts +181 -47
  165. package/src/tray/windows.ts +97 -25
  166. package/src/types.ts +39 -6
  167. package/src/update/index.ts +24 -5
  168. package/src/update/job.ts +598 -73
  169. package/src/usage/log.ts +115 -17
  170. package/src/usage/summary.ts +67 -2
  171. package/src/vision/index.ts +112 -22
  172. package/src/web-search/loop.ts +38 -6
  173. package/src/web-search/progress-stream.ts +14 -3
  174. package/gui/dist/assets/index-BDjpkcRN.js +0 -67
  175. package/gui/dist/assets/index-BHsKRFh9.css +0 -1
@@ -1,7 +1,17 @@
1
- import { chmodSync, existsSync, lstatSync, mkdirSync, opendirSync, readFileSync, unlinkSync } from "node:fs";
1
+ import { chmodSync, existsSync, lstatSync, mkdirSync, opendirSync, readFileSync, rmSync, unlinkSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { atomicWriteFileAsync, getConfigDir } from "../config";
4
+ import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../lib/app-owned-memory";
4
5
  import type { OcxProviderContinuationState } from "../types";
6
+ import {
7
+ deleteResponseSpill,
8
+ noteStubSwapForTest,
9
+ readResponseSpill,
10
+ recoverOrphanedResponseSpills,
11
+ responseSpillDirectory,
12
+ type ResponseSpillRef,
13
+ writeResponseSpillDurably,
14
+ } from "./spill-store";
5
15
 
6
16
  const MAX_STORED_RESPONSES = 1_000;
7
17
  const RESPONSE_TTL_MS = 60 * 60 * 1_000;
@@ -9,31 +19,65 @@ const SNAPSHOT_DEBOUNCE_MS = 2_000;
9
19
  /** In-memory high-water byte cap across all entries. Forced store:false retention (kiro/cursor
10
20
  * continuation chains) stores the full expanded input each turn — ~quadratic bytes per chain —
11
21
  * so a count cap alone cannot bound memory. Oldest-first eviction applies past this mark. */
12
- const MAX_STORED_RESPONSE_BYTES = 64 * 1024 * 1024;
13
- /** Entries whose serialized size exceeds this are kept in memory but skipped on disk: inputs can
14
- * carry base64 `input_image` data URLs, and one screenshot-heavy thread must not balloon the file. */
22
+ export const MAX_STORED_RESPONSE_BYTES = 64 * 1024 * 1024;
23
+ /** Legacy snapshot selection only. Spill demotion is governed solely by the RAM cap above. */
15
24
  const SNAPSHOT_ENTRY_MAX_BYTES = 2 * 1024 * 1024;
16
25
  const SNAPSHOT_TOTAL_MAX_BYTES = 24 * 1024 * 1024;
17
26
  const STALE_TEMP_GRACE_MS = 15 * 60 * 1_000;
18
27
  const STALE_TEMP_MAX_ENTRIES = 4_096;
19
28
  const STALE_TEMP_MAX_CLEANUPS = 512;
20
29
  const RESPONSE_STATE_TEMP_NAME = /^responses-state\.json\.ocx\.(\d+)\.(\d+)\.tmp$/;
30
+ const MAX_SNAPSHOT_REWRITE_ATTEMPTS = 4;
21
31
 
22
- interface StoredResponseState {
32
+ interface ResidentResponseState {
33
+ kind: "resident";
23
34
  createdAt: number;
24
35
  items: unknown[];
25
- /** v2 provider-keyed continuation metadata. */
26
36
  providers?: OcxProviderContinuationState;
27
- /** v1 Cursor-only metadata, accepted only while loading old snapshots. */
28
- conversationId?: string;
29
- cursorCheckpointUsable?: boolean;
30
- /** Approximate in-memory size, computed locally at insert time (never trusted from disk). */
31
- sizeBytes?: number;
37
+ sizeBytes: number;
32
38
  }
33
39
 
40
+ interface SpilledResponseState {
41
+ kind: "spill";
42
+ createdAt: number;
43
+ providers?: OcxProviderContinuationState;
44
+ spill: ResponseSpillRef;
45
+ sizeBytes: number;
46
+ }
47
+
48
+ interface SpillFailedResponseState {
49
+ kind: "spill-failed";
50
+ createdAt: number;
51
+ sizeBytes: number;
52
+ }
53
+
54
+ type StoredResponseState = ResidentResponseState | SpilledResponseState | SpillFailedResponseState;
55
+ type ResidentInput = Omit<ResidentResponseState, "kind" | "sizeBytes">;
56
+
57
+ export type PreviousResponseReplayFailure = {
58
+ code: "previous_response_not_found";
59
+ reason: "spill_missing" | "spill_corrupt" | "spill_failed";
60
+ };
61
+
34
62
  const states = new Map<string, StoredResponseState>();
35
63
  let storedResponseBytes = 0;
64
+ let residentResponseBytes = 0;
65
+ let oldestResidentId: string | undefined;
66
+ let oldestResidentAt: number | null = null;
36
67
  let byteCapOverride: number | null = null;
68
+ let stateRevision = 0;
69
+ const spillCounters = { writes: 0, writeFailures: 0, readFailures: 0 };
70
+ // Superseded spill generations awaiting a durable snapshot before unlink
71
+ // (review C1-1: unlinking at swap time races a crash against the debounced
72
+ // snapshot — the reloaded OLD stub would point at a deleted file).
73
+ const pendingSpillUnlinks: ResponseSpillRef[] = [];
74
+ // The queue itself must stay bounded (review C2-2: repeated replacements with
75
+ // a persistently failing snapshot write would otherwise grow it without
76
+ // limit). Beyond the cap the OLDEST superseded generation is unlinked
77
+ // immediately: the accepted worst case is that a crash inside that window
78
+ // reloads a stub whose file is gone, which fails replay with the explicit
79
+ // structured 400 — bounded-loss, never silent corruption or unbounded disk.
80
+ const PENDING_SPILL_UNLINKS_MAX = 128;
37
81
 
38
82
  function byteCap(): number {
39
83
  return byteCapOverride ?? MAX_STORED_RESPONSE_BYTES;
@@ -49,42 +93,184 @@ export function getStoredResponseBytesForTests(): number {
49
93
  return storedResponseBytes;
50
94
  }
51
95
 
52
- /** The ONLY size computation: approximate entry weight from its items payload. */
53
- function measuredEntry(entry: Omit<StoredResponseState, "sizeBytes">): StoredResponseState {
54
- let sizeBytes = 0;
96
+ function serializedBytes(value: unknown): number | null {
55
97
  try {
56
- sizeBytes = JSON.stringify(entry.items).length;
98
+ const serialized = JSON.stringify(value);
99
+ return serialized === undefined ? null : Buffer.byteLength(serialized, "utf8");
57
100
  } catch {
58
- /* unserializable items: weightless rather than fatal */
101
+ return null;
102
+ }
103
+ }
104
+
105
+ function measureResidentEntry(id: string, entry: ResidentInput): ResidentResponseState | null {
106
+ const sizeBytes = serializedBytes({
107
+ responseId: id,
108
+ createdAt: entry.createdAt,
109
+ items: entry.items,
110
+ ...(entry.providers ? { providers: entry.providers } : {}),
111
+ });
112
+ return sizeBytes === null ? null : { kind: "resident", ...entry, sizeBytes };
113
+ }
114
+
115
+ function recomputeOldestResident(): void {
116
+ oldestResidentId = undefined;
117
+ oldestResidentAt = null;
118
+ for (const [id, state] of states) {
119
+ if (state.kind !== "resident") continue;
120
+ if (oldestResidentAt !== null && state.createdAt >= oldestResidentAt) continue;
121
+ oldestResidentId = id;
122
+ oldestResidentAt = state.createdAt;
59
123
  }
60
- return { ...entry, sizeBytes };
61
124
  }
62
125
 
63
- /** The ONLY insertion point: keeps the byte counter consistent on replacement. */
64
- function setEntry(id: string, entry: Omit<StoredResponseState, "sizeBytes">): void {
65
- deleteEntry(id);
66
- const measured = measuredEntry(entry);
67
- storedResponseBytes += measured.sizeBytes ?? 0;
68
- states.set(id, measured);
126
+ function replaceMapEntry(id: string, next: StoredResponseState, expected?: StoredResponseState): boolean {
127
+ const existing = states.get(id);
128
+ if (expected && existing !== expected) return false;
129
+ storedResponseBytes -= existing?.sizeBytes ?? 0;
130
+ storedResponseBytes += next.sizeBytes;
131
+ if (existing?.kind === "resident") {
132
+ residentResponseBytes -= existing.sizeBytes;
133
+ }
134
+ if (next.kind === "resident") {
135
+ residentResponseBytes += next.sizeBytes;
136
+ }
137
+ if (storedResponseBytes < 0) storedResponseBytes = 0;
138
+ if (residentResponseBytes < 0) residentResponseBytes = 0;
139
+ if (existing) states.delete(id);
140
+ states.set(id, next);
141
+ if (oldestResidentId === id) {
142
+ recomputeOldestResident();
143
+ } else if (next.kind === "resident" && (oldestResidentAt === null || next.createdAt < oldestResidentAt)) {
144
+ oldestResidentId = id;
145
+ oldestResidentAt = next.createdAt;
146
+ }
147
+ stateRevision += 1;
148
+ return true;
149
+ }
150
+
151
+ function stubSize(id: string, entry: Omit<SpilledResponseState, "sizeBytes">): number {
152
+ return serializedBytes({ responseId: id, ...entry }) ?? 0;
153
+ }
154
+
155
+ function tombstone(id: string, createdAt: number): SpillFailedResponseState {
156
+ const base = { kind: "spill-failed" as const, createdAt };
157
+ return { ...base, sizeBytes: serializedBytes({ responseId: id, ...base }) ?? 0 };
158
+ }
159
+
160
+ function deleteOwnedSpills(entry: StoredResponseState): void {
161
+ if (entry.kind === "spill") deleteResponseSpill(entry.spill);
69
162
  }
70
163
 
71
164
  /** The ONLY deletion point: TTL, count, byte, and explicit deletes all route here. */
72
- function deleteEntry(id: string): void {
165
+ function deleteEntry(id: string, options: { deleteSpill?: boolean } = {}): void {
73
166
  const existing = states.get(id);
74
167
  if (!existing) return;
75
- storedResponseBytes -= existing.sizeBytes ?? 0;
168
+ storedResponseBytes -= existing.sizeBytes;
169
+ if (existing.kind === "resident") {
170
+ residentResponseBytes -= existing.sizeBytes;
171
+ }
76
172
  if (storedResponseBytes < 0) storedResponseBytes = 0;
173
+ if (residentResponseBytes < 0) residentResponseBytes = 0;
77
174
  states.delete(id);
175
+ if (oldestResidentId === id) recomputeOldestResident();
176
+ stateRevision += 1;
177
+ if (options.deleteSpill !== false) deleteOwnedSpills(existing);
178
+ }
179
+
180
+ function replaceWithSpillFailure(id: string, expected?: StoredResponseState): void {
181
+ const existing = states.get(id);
182
+ if (expected && existing !== expected) return;
183
+ const failed = tombstone(id, expected?.createdAt ?? existing?.createdAt ?? now());
184
+ if (replaceMapEntry(id, failed, expected)) {
185
+ if (existing) deleteOwnedSpills(existing);
186
+ }
187
+ }
188
+
189
+ function swapResidentForSpill(id: string, expected: ResidentResponseState, ref: ResponseSpillRef): boolean {
190
+ const base: Omit<SpilledResponseState, "sizeBytes"> = {
191
+ kind: "spill",
192
+ createdAt: expected.createdAt,
193
+ ...(expected.providers ? { providers: expected.providers } : {}),
194
+ spill: ref,
195
+ };
196
+ const next: SpilledResponseState = { ...base, sizeBytes: stubSize(id, base) };
197
+ if (!replaceMapEntry(id, next, expected)) {
198
+ deleteResponseSpill(ref);
199
+ return false;
200
+ }
201
+ noteStubSwapForTest();
202
+ return true;
203
+ }
204
+
205
+ function replaceSpillEntryAtomically(
206
+ id: string,
207
+ expected: SpilledResponseState,
208
+ candidate: ResidentResponseState,
209
+ ): void {
210
+ try {
211
+ const ref = writeResponseSpillDurably(id, {
212
+ createdAt: candidate.createdAt,
213
+ items: candidate.items,
214
+ ...(candidate.providers ? { providers: candidate.providers } : {}),
215
+ });
216
+ const base: Omit<SpilledResponseState, "sizeBytes"> = {
217
+ kind: "spill",
218
+ createdAt: candidate.createdAt,
219
+ ...(candidate.providers ? { providers: candidate.providers } : {}),
220
+ spill: ref,
221
+ };
222
+ const next: SpilledResponseState = { ...base, sizeBytes: stubSize(id, base) };
223
+ if (!replaceMapEntry(id, next, expected)) {
224
+ deleteResponseSpill(ref);
225
+ return;
226
+ }
227
+ spillCounters.writes += 1;
228
+ noteStubSwapForTest();
229
+ // The old generation is NOT unlinked here (review C1-1): the new stub is
230
+ // only durable once the debounced snapshot flushes — a crash before that
231
+ // reloads the OLD stub, which must still find its file. Queue the unlink;
232
+ // persistNow() drains the queue only after the snapshot write succeeds.
233
+ pendingSpillUnlinks.push(expected.spill);
234
+ while (pendingSpillUnlinks.length > PENDING_SPILL_UNLINKS_MAX) {
235
+ deleteResponseSpill(pendingSpillUnlinks.shift()!);
236
+ }
237
+ } catch {
238
+ spillCounters.writeFailures += 1;
239
+ replaceWithSpillFailure(id, expected);
240
+ }
241
+ }
242
+
243
+ function setResidentEntry(id: string, entry: ResidentInput): void {
244
+ const expected = states.get(id);
245
+ const candidate = measureResidentEntry(id, entry);
246
+ if (!candidate) {
247
+ replaceWithSpillFailure(id, expected);
248
+ // A tombstone is tiny but still resident state: the hard-cap invariant
249
+ // must hold on EVERY mutation path (review C2-1 — with a test cap below
250
+ // tombstone size, skipping the prune leaves the store over cap).
251
+ pruneResponses();
252
+ return;
253
+ }
254
+ if (expected?.kind === "spill") {
255
+ replaceSpillEntryAtomically(id, expected, candidate);
256
+ pruneResponses();
257
+ return;
258
+ }
259
+ if (!replaceMapEntry(id, candidate, expected)) return;
260
+ pruneResponses();
78
261
  }
262
+
79
263
  // Expansion provenance must stay proxy-private: a WeakMap distinguishes replayed history from the
80
264
  // newly appended input suffix without adding an unknown field that native passthrough could send
81
265
  // upstream. The parser uses this boundary to acknowledge historical compaction markers exactly once.
82
266
  const replayedInputPrefixLengths = new WeakMap<object, number>();
267
+ const replayFailures = new WeakMap<object, PreviousResponseReplayFailure>();
83
268
  let loaded = false;
84
269
  let persistTimer: ReturnType<typeof setTimeout> | null = null;
85
270
  let pendingPersistPath: string | null = null;
86
271
  /** Single-flight gate: overlapping response-state writes serialize (#612). */
87
272
  let persistGate: Promise<void> = Promise.resolve();
273
+ let persistAttemptHookForTests: (() => void) | null = null;
88
274
 
89
275
  function now(): number {
90
276
  return Date.now();
@@ -94,6 +280,64 @@ function snapshotPath(): string {
94
280
  return join(getConfigDir(), "responses-state.json");
95
281
  }
96
282
 
283
+ interface LegacySnapshotState {
284
+ createdAt?: unknown;
285
+ items?: unknown;
286
+ providers?: OcxProviderContinuationState;
287
+ conversationId?: unknown;
288
+ cursorCheckpointUsable?: unknown;
289
+ }
290
+
291
+ function isSpillRef(value: unknown): value is ResponseSpillRef {
292
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
293
+ const ref = value as ResponseSpillRef;
294
+ return ref.version === 1
295
+ && typeof ref.fileName === "string"
296
+ && /^[0-9a-f]{64}$/.test(ref.digest)
297
+ && Number.isSafeInteger(ref.payloadBytes)
298
+ && ref.payloadBytes >= 0;
299
+ }
300
+
301
+ function loadSnapshotEntry(id: string, value: unknown): void {
302
+ if (!value || typeof value !== "object" || Array.isArray(value)) return;
303
+ const rec = value as LegacySnapshotState & { kind?: unknown; spill?: unknown };
304
+ if (typeof rec.createdAt !== "number" || !Number.isFinite(rec.createdAt)) return;
305
+ if (rec.kind === "spill") {
306
+ if (!isSpillRef(rec.spill)) return;
307
+ const base: Omit<SpilledResponseState, "sizeBytes"> = {
308
+ kind: "spill",
309
+ createdAt: rec.createdAt,
310
+ ...(rec.providers ? { providers: rec.providers } : {}),
311
+ spill: rec.spill,
312
+ };
313
+ replaceMapEntry(id, { ...base, sizeBytes: stubSize(id, base) });
314
+ return;
315
+ }
316
+ if (rec.kind === "spill-failed") {
317
+ replaceMapEntry(id, tombstone(id, rec.createdAt));
318
+ return;
319
+ }
320
+ if (rec.kind !== undefined && rec.kind !== "resident") return;
321
+ if (!Array.isArray(rec.items)) return;
322
+ const providers = rec.providers ?? (typeof rec.conversationId === "string"
323
+ ? {
324
+ cursor: {
325
+ conversationId: rec.conversationId,
326
+ ...(typeof rec.cursorCheckpointUsable === "boolean"
327
+ ? { checkpointUsable: rec.cursorCheckpointUsable }
328
+ : {}),
329
+ },
330
+ }
331
+ : undefined);
332
+ const resident = measureResidentEntry(id, {
333
+ createdAt: rec.createdAt,
334
+ items: rec.items,
335
+ ...(providers ? { providers } : {}),
336
+ });
337
+ if (resident) replaceMapEntry(id, resident);
338
+ else replaceMapEntry(id, tombstone(id, rec.createdAt));
339
+ }
340
+
97
341
  export interface ResponseStateTempRecoveryResult {
98
342
  matched: number;
99
343
  removed: number;
@@ -216,97 +460,121 @@ function ensureLoaded(): void {
216
460
  /* best-effort cleanup only; snapshot loading must remain independent */
217
461
  }
218
462
  try {
219
- if (!existsSync(path)) return;
220
- const raw = JSON.parse(readFileSync(path, "utf-8")) as { version?: unknown; states?: unknown };
221
- if ((raw.version !== 1 && raw.version !== 2) || !Array.isArray(raw.states)) return;
222
- for (const entry of raw.states) {
223
- if (!Array.isArray(entry) || entry.length !== 2) continue;
224
- const [id, state] = entry as [unknown, unknown];
225
- if (typeof id !== "string" || !state || typeof state !== "object") continue;
226
- const rec = state as StoredResponseState;
227
- if (typeof rec.createdAt !== "number" || !Array.isArray(rec.items)) continue;
228
- const providers = rec.providers ?? (rec.conversationId
229
- ? {
230
- cursor: {
231
- conversationId: rec.conversationId,
232
- ...(rec.cursorCheckpointUsable !== undefined
233
- ? { checkpointUsable: rec.cursorCheckpointUsable }
234
- : {}),
235
- },
236
- }
237
- : undefined);
238
- // Recompute sizes locally while loading — persisted sizeBytes (absent in v1/v2
239
- // snapshots anyway) is never trusted.
240
- setEntry(id, {
241
- createdAt: rec.createdAt,
242
- items: rec.items,
243
- ...(providers ? { providers } : {}),
244
- });
463
+ if (existsSync(path)) {
464
+ const raw = JSON.parse(readFileSync(path, "utf-8")) as { version?: unknown; states?: unknown };
465
+ if ((raw.version === 1 || raw.version === 2) && Array.isArray(raw.states)) {
466
+ for (const entry of raw.states) {
467
+ if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string") continue;
468
+ loadSnapshotEntry(entry[0], entry[1]);
469
+ }
470
+ }
245
471
  }
246
- pruneResponses();
247
472
  } catch {
248
473
  /* missing/corrupt snapshot: start empty */
249
474
  }
475
+ const referenced = new Set<string>();
476
+ for (const state of states.values()) {
477
+ if (state.kind === "spill") referenced.add(state.spill.fileName);
478
+ }
479
+ try { recoverOrphanedResponseSpills(referenced); } catch { /* best effort */ }
480
+ pruneResponses();
250
481
  }
251
482
 
252
- async function persistNow(path: string): Promise<void> {
253
- if (persistTimer) {
254
- clearTimeout(persistTimer);
255
- persistTimer = null;
256
- }
257
- pendingPersistPath = null;
483
+ type SnapshotWriteOutcome = "stable" | "unstable" | "failed";
258
484
 
485
+ async function writeBoundedSnapshot(path: string): Promise<SnapshotWriteOutcome> {
259
486
  // Serialize writers so concurrent flush + debounce cannot race on temps / ACL (#612).
260
487
  const previous = persistGate;
261
488
  let release!: () => void;
262
489
  persistGate = new Promise<void>(resolve => { release = resolve; });
263
490
  await previous;
264
491
  try {
265
- const entries: [string, StoredResponseState][] = [];
266
- let total = 0;
267
- // Newest-first so the most recent chains survive both caps.
268
- for (const entry of [...states].reverse()) {
269
- // sizeBytes is in-memory accounting only; keep it out of the disk snapshot.
270
- const [id, state] = entry;
271
- const { sizeBytes: _sizeBytes, ...persistable } = state;
272
- const persistEntry: [string, StoredResponseState] = [id, persistable];
273
- const size = JSON.stringify(persistEntry).length;
274
- if (size > SNAPSHOT_ENTRY_MAX_BYTES) continue;
275
- if (total + size > SNAPSHOT_TOTAL_MAX_BYTES) break;
276
- total += size;
277
- entries.push(persistEntry);
492
+ for (let attempt = 0; attempt < MAX_SNAPSHOT_REWRITE_ATTEMPTS; attempt += 1) {
493
+ const revision = stateRevision;
494
+ const entries: Array<[string, unknown]> = [];
495
+ let total = 0;
496
+ // Newest-first so the most recent chains survive both legacy snapshot caps.
497
+ for (const [id, state] of [...states].reverse()) {
498
+ let persistable: unknown;
499
+ if (state.kind === "resident") {
500
+ const { sizeBytes: _sizeBytes, kind: _kind, ...resident } = state;
501
+ persistable = resident;
502
+ } else {
503
+ const { sizeBytes: _sizeBytes, ...smallState } = state;
504
+ persistable = smallState;
505
+ }
506
+ const persistEntry: [string, unknown] = [id, persistable];
507
+ const size = JSON.stringify(persistEntry).length;
508
+ if (state.kind === "resident" && size > SNAPSHOT_ENTRY_MAX_BYTES) continue;
509
+ if (total + size > SNAPSHOT_TOTAL_MAX_BYTES) break;
510
+ total += size;
511
+ entries.push(persistEntry);
512
+ }
513
+ entries.reverse();
514
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
515
+ try { chmodSync(dirname(path), 0o700); } catch { /* best-effort (e.g. Windows) */ }
516
+ await atomicWriteFileAsync(path, JSON.stringify({ version: 2, states: entries }));
517
+ persistAttemptHookForTests?.();
518
+ if (revision === stateRevision) return "stable";
278
519
  }
279
- entries.reverse();
280
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
281
- // mkdirSync's mode only applies on creation — re-harden an existing config dir so the
282
- // conversation-content snapshot never lands in a group/world-readable directory.
283
- try { chmodSync(dirname(path), 0o700); } catch { /* best-effort (e.g. Windows) */ }
284
- await atomicWriteFileAsync(path, JSON.stringify({ version: 2, states: entries }));
520
+ return "unstable";
285
521
  } catch {
286
- /* best-effort: disk trouble must never affect request handling */
522
+ return "failed";
287
523
  } finally {
288
524
  release();
289
525
  }
290
526
  }
291
527
 
528
+ function drainPendingSpillUnlinks(): void {
529
+ while (pendingSpillUnlinks.length > 0) {
530
+ const ref = pendingSpillUnlinks.shift()!;
531
+ deleteResponseSpill(ref);
532
+ }
533
+ }
534
+
535
+ function schedulePersistAt(path: string, replace = false): void {
536
+ if (persistTimer && !replace) return;
537
+ if (persistTimer) clearTimeout(persistTimer);
538
+ pendingPersistPath = path;
539
+ persistTimer = setTimeout(() => { void persistNow(path); }, SNAPSHOT_DEBOUNCE_MS);
540
+ (persistTimer as { unref?: () => void }).unref?.();
541
+ }
542
+
543
+ async function persistNow(path: string, awaitFollowUp = false): Promise<void> {
544
+ if (persistTimer) {
545
+ clearTimeout(persistTimer);
546
+ persistTimer = null;
547
+ }
548
+ pendingPersistPath = null;
549
+ let outcome = await writeBoundedSnapshot(path);
550
+ if (outcome === "unstable" && awaitFollowUp) {
551
+ if (persistTimer) clearTimeout(persistTimer);
552
+ persistTimer = null;
553
+ pendingPersistPath = null;
554
+ outcome = await writeBoundedSnapshot(path);
555
+ }
556
+ if (outcome === "stable") drainPendingSpillUnlinks();
557
+ else if (outcome === "unstable" && !awaitFollowUp) schedulePersistAt(path, true);
558
+ }
559
+
292
560
  function schedulePersist(): void {
293
- if (persistTimer) return;
294
561
  // Resolve the target path NOW: tests (and anything else) may swap OPENCODEX_HOME before the
295
562
  // debounce fires, and a late write must land in the home that owned the recorded state.
296
- pendingPersistPath = snapshotPath();
297
- const path = pendingPersistPath;
298
- persistTimer = setTimeout(() => { void persistNow(path); }, SNAPSHOT_DEBOUNCE_MS);
299
- (persistTimer as { unref?: () => void }).unref?.();
563
+ schedulePersistAt(snapshotPath());
300
564
  }
301
565
 
302
566
  /** Flush any pending debounced snapshot write (graceful shutdown / deterministic tests). */
303
567
  export async function flushResponseState(): Promise<void> {
304
568
  if (persistTimer) {
305
- await persistNow(pendingPersistPath ?? snapshotPath());
569
+ await persistNow(pendingPersistPath ?? snapshotPath(), true);
306
570
  return;
307
571
  }
308
572
  // No pending timer: still await any in-flight write so shutdown does not race (#612).
309
573
  await persistGate;
574
+ // A bounded background pass may have scheduled its same-path follow-up while
575
+ // this flush was waiting on the single-flight gate. Shutdown owns one awaited
576
+ // bounded follow-up rather than returning behind that unref'd timer.
577
+ if (persistTimer) await persistNow(pendingPersistPath ?? snapshotPath(), true);
310
578
  }
311
579
 
312
580
  function inputItems(input: unknown): unknown[] {
@@ -325,12 +593,107 @@ function pruneResponses(at = now()): void {
325
593
  if (!oldest) break;
326
594
  deleteEntry(oldest);
327
595
  }
328
- // Byte high-water eviction, oldest-first (Map preserves insertion order).
329
- while (storedResponseBytes > byteCap() && states.size > 1) {
330
- const oldest = states.keys().next().value;
331
- if (!oldest) break;
332
- deleteEntry(oldest);
596
+ // Unconditional RAM cap. Resident payloads demote durably; stubs/tombstones are
597
+ // deleted only when even their bounded metadata cannot fit the override.
598
+ while (storedResponseBytes > byteCap() && states.size > 0) {
599
+ const oldestResident = [...states].find(([, entry]) => entry.kind === "resident");
600
+ const oldestId = oldestResident?.[0] ?? states.keys().next().value as string | undefined;
601
+ if (!oldestId) break;
602
+ const entry = states.get(oldestId)!;
603
+ if (entry.kind !== "resident") {
604
+ deleteEntry(oldestId);
605
+ continue;
606
+ }
607
+ try {
608
+ const ref = writeResponseSpillDurably(oldestId, {
609
+ createdAt: entry.createdAt,
610
+ items: entry.items,
611
+ ...(entry.providers ? { providers: entry.providers } : {}),
612
+ });
613
+ if (swapResidentForSpill(oldestId, entry, ref)) spillCounters.writes += 1;
614
+ } catch {
615
+ spillCounters.writeFailures += 1;
616
+ replaceWithSpillFailure(oldestId, entry);
617
+ }
618
+ }
619
+ }
620
+
621
+ /** Periodic TTL-only sweep; count/byte eviction remains owned by mutation paths. */
622
+ export function sweepExpiredResponseStates(at = now()): number {
623
+ let removed = 0;
624
+ for (const [id, state] of states) {
625
+ if (at - state.createdAt <= RESPONSE_TTL_MS) continue;
626
+ deleteEntry(id);
627
+ removed += 1;
628
+ }
629
+ if (removed > 0) schedulePersist();
630
+ return removed;
631
+ }
632
+
633
+ export function responseContinuationRetainedStoreSnapshot(): RetainedStoreSnapshot {
634
+ return {
635
+ count: states.size,
636
+ bytes: storedResponseBytes,
637
+ evictableBytes: residentResponseBytes,
638
+ pinnedBytes: Math.max(0, storedResponseBytes - residentResponseBytes),
639
+ oldestAt: oldestResidentAt,
640
+ };
641
+ }
642
+
643
+ export function evictOldestResponseContinuationForBudget(): number {
644
+ if (oldestResidentId === undefined) return 0;
645
+ const id = oldestResidentId;
646
+ const entry = states.get(id);
647
+ if (!entry || entry.kind !== "resident") return 0;
648
+ try {
649
+ const ref = writeResponseSpillDurably(id, {
650
+ createdAt: entry.createdAt,
651
+ items: entry.items,
652
+ ...(entry.providers ? { providers: entry.providers } : {}),
653
+ });
654
+ if (swapResidentForSpill(id, entry, ref)) spillCounters.writes += 1;
655
+ } catch {
656
+ spillCounters.writeFailures += 1;
657
+ replaceWithSpillFailure(id, entry);
333
658
  }
659
+ schedulePersist();
660
+ const replacement = states.get(id);
661
+ return !replacement || replacement.kind === "resident"
662
+ ? 0
663
+ : Math.max(0, entry.sizeBytes - replacement.sizeBytes);
664
+ }
665
+
666
+ function materializeEntry(
667
+ id: string,
668
+ entry: StoredResponseState,
669
+ ): { ok: true; state: ResidentResponseState } | { ok: false; failure: PreviousResponseReplayFailure } {
670
+ if (entry.kind === "resident") return { ok: true, state: entry };
671
+ if (entry.kind === "spill-failed") {
672
+ return { ok: false, failure: { code: "previous_response_not_found", reason: "spill_failed" } };
673
+ }
674
+ const result = readResponseSpill(id, entry.spill);
675
+ if (!result.ok) {
676
+ spillCounters.readFailures += 1;
677
+ const failure: PreviousResponseReplayFailure = {
678
+ code: "previous_response_not_found",
679
+ reason: result.reason === "missing" ? "spill_missing" : "spill_corrupt",
680
+ };
681
+ replaceWithSpillFailure(id, entry);
682
+ schedulePersist();
683
+ return { ok: false, failure };
684
+ }
685
+ const state = measureResidentEntry(id, {
686
+ createdAt: result.payload.createdAt,
687
+ items: result.payload.items,
688
+ ...(result.payload.providers ? { providers: result.payload.providers } : {}),
689
+ });
690
+ if (!state) {
691
+ spillCounters.readFailures += 1;
692
+ replaceWithSpillFailure(id, entry);
693
+ schedulePersist();
694
+ return { ok: false, failure: { code: "previous_response_not_found", reason: "spill_corrupt" } };
695
+ }
696
+ return { ok: true, state };
334
697
  }
335
698
 
336
699
  export function expandPreviousResponseInput(body: unknown): unknown {
@@ -342,14 +705,24 @@ export function expandPreviousResponseInput(body: unknown): unknown {
342
705
  pruneResponses();
343
706
  const previous = states.get(previousId);
344
707
  if (!previous) return body;
708
+ const materialized = materializeEntry(previousId, previous);
709
+ if (!materialized.ok) {
710
+ replayFailures.set(request, materialized.failure);
711
+ return body;
712
+ }
345
713
  const expanded = {
346
714
  ...request,
347
- input: [...previous.items, ...inputItems(request.input)],
715
+ input: [...materialized.state.items, ...inputItems(request.input)],
348
716
  };
349
- replayedInputPrefixLengths.set(expanded, previous.items.length);
717
+ replayedInputPrefixLengths.set(expanded, materialized.state.items.length);
350
718
  return expanded;
351
719
  }
352
720
 
721
+ export function previousResponseReplayFailure(body: unknown): PreviousResponseReplayFailure | undefined {
722
+ if (!body || typeof body !== "object" || Array.isArray(body)) return undefined;
723
+ return replayFailures.get(body);
724
+ }
725
+
353
726
  /** Number of leading input items restored from previous_response_id state for this exact body. */
354
727
  export function previousResponseReplayPrefixLength(body: unknown): number {
355
728
  if (!body || typeof body !== "object" || Array.isArray(body)) return 0;
@@ -364,22 +737,23 @@ export function previousResponseProviderState(responseId: string | undefined): O
364
737
  if (!responseId) return undefined;
365
738
  ensureLoaded();
366
739
  pruneResponses();
367
- const providers = states.get(responseId)?.providers;
740
+ const state = states.get(responseId);
741
+ const providers = state?.kind === "spill-failed" ? undefined : state?.providers;
368
742
  return providers ? structuredClone(providers) : undefined;
369
743
  }
370
744
 
371
745
  export interface ResponseStateMetrics {
372
- /** Number of retained continuation entries currently in RAM. */
373
746
  count: number;
374
- /** Sum of serialized item bytes across all entries (the running in-memory byte accounting).
375
- * Because expanded previous_response_id chains share prefix item references, this is an UPPER
376
- * bound on true heap (it re-counts shared history), never an under-count — safe as a
377
- * memory-pressure signal. */
747
+ residentCount: number;
748
+ spillStubCount: number;
749
+ tombstoneCount: number;
378
750
  totalBytes: number;
379
- /** Serialized item bytes of the single largest entry (a long chain's head, or an image-heavy turn). */
751
+ spillPayloadBytes: number;
380
752
  largestBytes: number;
381
- /** Age of the oldest retained entry, in ms (0 when the store is empty). */
382
753
  oldestAgeMs: number;
754
+ spillWrites: number;
755
+ spillWriteFailures: number;
756
+ spillReadFailures: number;
383
757
  }
384
758
 
385
759
  /**
@@ -395,16 +769,33 @@ export function responseStateMetrics(): ResponseStateMetrics {
395
769
  const at = now();
396
770
  let largestBytes = 0;
397
771
  let oldestCreatedAt = at;
772
+ let residentCount = 0;
773
+ let spillStubCount = 0;
774
+ let tombstoneCount = 0;
775
+ let spillPayloadBytes = 0;
398
776
  for (const state of states.values()) {
399
- const bytes = state.sizeBytes ?? 0;
777
+ const bytes = state.sizeBytes;
400
778
  if (bytes > largestBytes) largestBytes = bytes;
401
779
  if (state.createdAt < oldestCreatedAt) oldestCreatedAt = state.createdAt;
780
+ if (state.kind === "resident") {
781
+ residentCount += 1;
782
+ } else if (state.kind === "spill") {
783
+ spillStubCount += 1;
784
+ spillPayloadBytes += state.spill.payloadBytes;
785
+ } else tombstoneCount += 1;
402
786
  }
403
787
  return {
404
788
  count: states.size,
789
+ residentCount,
790
+ spillStubCount,
791
+ tombstoneCount,
405
792
  totalBytes: storedResponseBytes,
793
+ spillPayloadBytes,
406
794
  largestBytes,
407
795
  oldestAgeMs: states.size > 0 ? at - oldestCreatedAt : 0,
796
+ spillWrites: spillCounters.writes,
797
+ spillWriteFailures: spillCounters.writeFailures,
798
+ spillReadFailures: spillCounters.readFailures,
408
799
  };
409
800
  }
410
801
 
@@ -441,7 +832,7 @@ export function rememberResponseState(
441
832
  return !!item && typeof item === "object" && (item as { type?: unknown }).type === "function_call";
442
833
  });
443
834
  }
444
- setEntry(response.id, {
835
+ setResidentEntry(response.id, {
445
836
  createdAt: now(),
446
837
  items: [...inputItems(request.input), ...response.output],
447
838
  // Always preserve the Cursor conversation id so the next tool-result turn can continue the SAME
@@ -451,10 +842,26 @@ export function rememberResponseState(
451
842
  // checkpoint must not be reused — but the conversation id string itself is still valid.
452
843
  ...(Object.keys(normalizedProviderState).length > 0 ? { providers: normalizedProviderState } : {}),
453
844
  });
454
- pruneResponses();
845
+ enforceAppOwnedMemoryBudget();
455
846
  schedulePersist();
456
847
  }
457
848
 
849
+ /** Test-only persistence churn hook; invoked after each atomic snapshot rewrite. */
850
+ export function setResponseStatePersistAttemptHookForTests(hook: (() => void) | null): void {
851
+ persistAttemptHookForTests = hook;
852
+ }
853
+
854
+ /** Test-only: deterministically run the pending background debounce pass. */
855
+ export async function runPendingResponseStatePersistForTests(): Promise<void> {
856
+ if (!persistTimer) return;
857
+ await persistNow(pendingPersistPath ?? snapshotPath());
858
+ }
859
+
860
+ /** Test-only: observe whether a debounce/follow-up pass is pending. */
861
+ export function responseStatePersistPendingForTests(): boolean {
862
+ return persistTimer !== null;
863
+ }
864
+
458
865
  /** Memory-only reset (simulates a process restart: the snapshot file survives). */
459
866
  export function clearResponseStateMemoryForTests(): void {
460
867
  if (persistTimer) {
@@ -464,14 +871,25 @@ export function clearResponseStateMemoryForTests(): void {
464
871
  pendingPersistPath = null;
465
872
  states.clear();
466
873
  storedResponseBytes = 0;
874
+ residentResponseBytes = 0;
875
+ oldestResidentId = undefined;
876
+ oldestResidentAt = null;
877
+ stateRevision = 0;
878
+ pendingSpillUnlinks.length = 0;
879
+ spillCounters.writes = 0;
880
+ spillCounters.writeFailures = 0;
881
+ spillCounters.readFailures = 0;
882
+ persistAttemptHookForTests = null;
467
883
  loaded = false;
468
884
  }
469
885
 
470
886
  export function clearResponseStateForTests(): void {
887
+ for (const entry of states.values()) deleteOwnedSpills(entry);
471
888
  clearResponseStateMemoryForTests();
472
889
  try {
473
890
  unlinkSync(snapshotPath());
474
891
  } catch {
475
892
  /* no snapshot on disk */
476
893
  }
894
+ try { rmSync(responseSpillDirectory(), { recursive: true, force: true }); } catch { /* no spill directory */ }
477
895
  }