@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
@@ -0,0 +1,394 @@
1
+ import {
2
+ chmodSync,
3
+ closeSync,
4
+ constants,
5
+ copyFileSync,
6
+ existsSync,
7
+ fsyncSync,
8
+ linkSync,
9
+ lstatSync,
10
+ mkdirSync,
11
+ openSync,
12
+ opendirSync,
13
+ readFileSync,
14
+ unlinkSync,
15
+ writeSync,
16
+ } from "node:fs";
17
+ import { createHash, randomBytes } from "node:crypto";
18
+ import { join } from "node:path";
19
+ import { getConfigDir } from "../config";
20
+ import { forgetHardenedSecretPath, hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
21
+ import type { OcxProviderContinuationState } from "../types";
22
+
23
+ export const RESPONSE_SPILL_VERSION = 1;
24
+ export const RESPONSE_SPILL_DIR_NAME = "responses-state-spill";
25
+ export const RESPONSE_SPILL_ORPHAN_GRACE_MS = 15 * 60_000;
26
+ export const RESPONSE_SPILL_SCAN_MAX = 4_096;
27
+ export const RESPONSE_SPILL_CLEANUP_MAX = 512;
28
+
29
+ const RESPONSE_SPILL_PUBLISH_RETRIES = 64;
30
+ const OWNED_SPILL_NAME = /^([A-Za-z0-9._-]{1,80})\.([0-9a-f]{12})\.([0-9a-f]{24})\.(\d+)\.(\d+)\.spill\.json$/;
31
+ const OWNED_SPILL_TEMP_NAME = /^\.response-spill\.[0-9]+\.[0-9a-f]{16}\.tmp$/;
32
+
33
+ export interface ResponseSpillPayload {
34
+ version: 1;
35
+ responseId: string;
36
+ createdAt: number;
37
+ items: unknown[];
38
+ providers?: OcxProviderContinuationState;
39
+ }
40
+
41
+ export interface ResponseSpillRef {
42
+ version: 1;
43
+ fileName: string;
44
+ digest: string;
45
+ payloadBytes: number;
46
+ }
47
+
48
+ export type ResponseSpillReadResult =
49
+ | { ok: true; payload: ResponseSpillPayload }
50
+ | { ok: false; reason: "missing" | "corrupt" };
51
+
52
+ export interface ResponseSpillCleanupResult {
53
+ scanned: number;
54
+ removed: number;
55
+ failed: number;
56
+ bytesRemoved: number;
57
+ }
58
+
59
+ export interface ResponseSpillIoForTest {
60
+ write?: (fd: number, bytes: Uint8Array) => void;
61
+ fsync?: (fd: number) => void;
62
+ /** Directory-handle fsync seam for `fsyncDirectoryBestEffort` only. */
63
+ fsyncDir?: (fd: number) => void;
64
+ /** Directory-handle open seam for `fsyncDirectoryBestEffort` only. */
65
+ openDir?: (dir: string) => number;
66
+ /** Directory-handle close seam for `fsyncDirectoryBestEffort` only. */
67
+ closeDir?: (fd: number) => void;
68
+ link?: (tempPath: string, destinationPath: string) => void;
69
+ copyFileExcl?: (tempPath: string, destinationPath: string) => void;
70
+ unlink?: (path: string) => void;
71
+ /** Orphan-GC enumeration seam: returns the next directory entry NAME or
72
+ * null for end-of-directory. Lets tests prove the scan cap binds without
73
+ * materializing thousands of real files. */
74
+ readdirEntry?: () => string | null;
75
+ record?: (event: "write" | "fsync" | "close" | "harden" | "publish" | "dir-fsync" | "stub-swap") => void;
76
+ }
77
+
78
+ let spillIoForTest: ResponseSpillIoForTest | null = null;
79
+ let spillGeneration = 0;
80
+
81
+ export function setSpillIoForTest(io: ResponseSpillIoForTest | null): void {
82
+ spillIoForTest = io;
83
+ }
84
+
85
+ function record(event: "write" | "fsync" | "close" | "harden" | "publish" | "dir-fsync" | "stub-swap"): void {
86
+ spillIoForTest?.record?.(event);
87
+ }
88
+
89
+ function fsyncDirectoryBestEffort(dir: string): void {
90
+ let fd: number | null = null;
91
+ // Record the durability attempt before opening the directory: Windows cannot
92
+ // open/fsync directory handles this way, but callers still cross this seam.
93
+ record("dir-fsync");
94
+ try {
95
+ fd = spillIoForTest?.openDir ? spillIoForTest.openDir(dir) : openSync(dir, "r");
96
+ try {
97
+ if (spillIoForTest?.fsyncDir) spillIoForTest.fsyncDir(fd);
98
+ else if (spillIoForTest?.fsync) spillIoForTest.fsync(fd);
99
+ else fsyncSync(fd);
100
+ } catch {
101
+ // Windows and some filesystems do not support fsync on directory handles.
102
+ }
103
+ } catch {
104
+ // Directory missing or unreadable — nothing further to sync.
105
+ } finally {
106
+ if (fd !== null) {
107
+ try {
108
+ if (spillIoForTest?.closeDir) spillIoForTest.closeDir(fd);
109
+ else closeSync(fd);
110
+ } catch { /* best effort */ }
111
+ }
112
+ }
113
+ }
114
+
115
+ export function noteStubSwapForTest(): void {
116
+ record("stub-swap");
117
+ }
118
+
119
+ export function responseSpillDirectory(dir = getConfigDir()): string {
120
+ return join(dir, RESPONSE_SPILL_DIR_NAME);
121
+ }
122
+
123
+ function sha256(value: string | Uint8Array): string {
124
+ return createHash("sha256").update(value).digest("hex");
125
+ }
126
+
127
+ function sanitizeResponseId(responseId: string): string {
128
+ const visible = responseId
129
+ .normalize("NFC")
130
+ .replace(/[^A-Za-z0-9._-]/g, "_")
131
+ .replace(/_+/g, "_")
132
+ .slice(0, 80);
133
+ return visible || "response";
134
+ }
135
+
136
+ function isErrno(error: unknown, code: string): boolean {
137
+ return !!error && typeof error === "object" && (error as NodeJS.ErrnoException).code === code;
138
+ }
139
+
140
+ function canUseExclusiveCopyFallback(error: unknown): boolean {
141
+ return process.platform === "win32" || ["EPERM", "EACCES", "ENOSYS", "ENOTSUP", "EOPNOTSUPP", "EXDEV"]
142
+ .some(code => isErrno(error, code));
143
+ }
144
+
145
+ function harden(path: string, mode: number): void {
146
+ try {
147
+ chmodSync(path, mode);
148
+ } catch {
149
+ if (process.platform !== "win32") throw new Error("Response spill permission hardening failed");
150
+ }
151
+ if (process.platform === "win32") {
152
+ const result = mode === 0o700
153
+ ? hardenSecretDir(path, { required: true })
154
+ : hardenSecretPath(path, { required: true });
155
+ if (!result.ok) throw new Error("Response spill permission hardening failed");
156
+ }
157
+ }
158
+
159
+ function writeAll(fd: number, bytes: Uint8Array): void {
160
+ if (spillIoForTest?.write) spillIoForTest.write(fd, bytes);
161
+ else {
162
+ let offset = 0;
163
+ while (offset < bytes.byteLength) offset += writeSync(fd, bytes, offset, bytes.byteLength - offset);
164
+ }
165
+ record("write");
166
+ }
167
+
168
+ function fsyncFile(fd: number): void {
169
+ if (spillIoForTest?.fsync) spillIoForTest.fsync(fd);
170
+ else fsyncSync(fd);
171
+ record("fsync");
172
+ }
173
+
174
+ function closeFile(fd: number): void {
175
+ closeSync(fd);
176
+ record("close");
177
+ }
178
+
179
+ function unlink(path: string): void {
180
+ try {
181
+ if (spillIoForTest?.unlink) spillIoForTest.unlink(path);
182
+ else unlinkSync(path);
183
+ forgetHardenedSecretPath(path);
184
+ } catch (error) {
185
+ if (isErrno(error, "ENOENT")) forgetHardenedSecretPath(path);
186
+ throw error;
187
+ }
188
+ }
189
+
190
+ function publishNoReplace(tempPath: string, destinationPath: string): void {
191
+ try {
192
+ if (spillIoForTest?.link) spillIoForTest.link(tempPath, destinationPath);
193
+ else linkSync(tempPath, destinationPath);
194
+ } catch (error) {
195
+ if (isErrno(error, "EEXIST")) throw error;
196
+ if (!canUseExclusiveCopyFallback(error)) throw error;
197
+ let copied = false;
198
+ try {
199
+ if (spillIoForTest?.copyFileExcl) spillIoForTest.copyFileExcl(tempPath, destinationPath);
200
+ else copyFileSync(tempPath, destinationPath, constants.COPYFILE_EXCL);
201
+ copied = true;
202
+ harden(destinationPath, 0o600);
203
+ const copyFd = openSync(destinationPath, "r");
204
+ try {
205
+ if (spillIoForTest?.fsync) spillIoForTest.fsync(copyFd);
206
+ else fsyncSync(copyFd);
207
+ } finally {
208
+ closeSync(copyFd);
209
+ }
210
+ } catch (copyError) {
211
+ if (copied) {
212
+ try { unlink(destinationPath); } catch { /* startup GC reclaims an incomplete publication */ }
213
+ }
214
+ throw copyError;
215
+ }
216
+ }
217
+ record("publish");
218
+ }
219
+
220
+ function validSpillRef(ref: ResponseSpillRef): boolean {
221
+ return ref.version === 1
222
+ && OWNED_SPILL_NAME.test(ref.fileName)
223
+ && /^[0-9a-f]{64}$/.test(ref.digest)
224
+ && Number.isSafeInteger(ref.payloadBytes)
225
+ && ref.payloadBytes >= 0;
226
+ }
227
+
228
+ function validPayload(value: unknown, responseId: string): value is ResponseSpillPayload {
229
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
230
+ const payload = value as Record<string, unknown>;
231
+ const keys = Object.keys(payload);
232
+ if (keys.some(key => !["version", "responseId", "createdAt", "items", "providers"].includes(key))) return false;
233
+ if (payload.version !== 1 || payload.responseId !== responseId) return false;
234
+ if (typeof payload.createdAt !== "number" || !Number.isFinite(payload.createdAt)) return false;
235
+ if (!Array.isArray(payload.items)) return false;
236
+ if (payload.providers !== undefined) {
237
+ if (!payload.providers || typeof payload.providers !== "object" || Array.isArray(payload.providers)) return false;
238
+ for (const providerState of Object.values(payload.providers)) {
239
+ if (!providerState || typeof providerState !== "object" || Array.isArray(providerState)) return false;
240
+ }
241
+ }
242
+ return true;
243
+ }
244
+
245
+ export function writeResponseSpillDurably(
246
+ responseId: string,
247
+ state: Omit<ResponseSpillPayload, "version" | "responseId">,
248
+ ): ResponseSpillRef {
249
+ let tempPath: string | null = null;
250
+ let fd: number | null = null;
251
+ try {
252
+ const payload: ResponseSpillPayload = {
253
+ version: 1,
254
+ responseId,
255
+ createdAt: state.createdAt,
256
+ items: state.items,
257
+ ...(state.providers ? { providers: state.providers } : {}),
258
+ };
259
+ const serialized = JSON.stringify(payload);
260
+ if (serialized === undefined) throw new Error("Response spill serialization failed");
261
+ const bytes = Buffer.from(serialized, "utf8");
262
+ const digest = sha256(bytes);
263
+ const idDigest = sha256(responseId).slice(0, 12);
264
+ const contentDigest = digest.slice(0, 24);
265
+ const dir = responseSpillDirectory();
266
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
267
+ harden(dir, 0o700);
268
+
269
+ tempPath = join(dir, `.response-spill.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
270
+ fd = openSync(tempPath, "wx", 0o600);
271
+ writeAll(fd, bytes);
272
+ fsyncFile(fd);
273
+ closeFile(fd);
274
+ fd = null;
275
+ harden(tempPath, 0o600);
276
+ record("harden");
277
+ const publishTempPath = tempPath;
278
+
279
+ for (let attempt = 0; attempt < RESPONSE_SPILL_PUBLISH_RETRIES; attempt++) {
280
+ spillGeneration += 1;
281
+ const fileName = `${sanitizeResponseId(responseId)}.${idDigest}.${contentDigest}.${spillGeneration}.${bytes.byteLength}.spill.json`;
282
+ if (!OWNED_SPILL_NAME.test(fileName)) throw new Error("Response spill name allocation failed");
283
+ const destinationPath = join(dir, fileName);
284
+ try {
285
+ publishNoReplace(publishTempPath, destinationPath);
286
+ fsyncDirectoryBestEffort(dir);
287
+ unlink(publishTempPath);
288
+ tempPath = null;
289
+ return { version: 1, fileName, digest, payloadBytes: bytes.byteLength };
290
+ } catch (error) {
291
+ if (isErrno(error, "EEXIST")) continue;
292
+ throw error;
293
+ }
294
+ }
295
+ throw new Error("Response spill publication retries exhausted");
296
+ } catch {
297
+ if (fd !== null) {
298
+ try { closeSync(fd); } catch { /* best effort */ }
299
+ }
300
+ if (tempPath) {
301
+ try { unlink(tempPath); } catch { /* best effort */ }
302
+ }
303
+ throw new Error("Response spill write failed");
304
+ }
305
+ }
306
+
307
+ export function readResponseSpill(responseId: string, ref: ResponseSpillRef): ResponseSpillReadResult {
308
+ if (!validSpillRef(ref)) return { ok: false, reason: "corrupt" };
309
+ const match = OWNED_SPILL_NAME.exec(ref.fileName);
310
+ if (!match
311
+ || match[2] !== sha256(responseId).slice(0, 12)
312
+ || match[3] !== ref.digest.slice(0, 24)
313
+ || !Number.isSafeInteger(Number(match[4]))
314
+ || Number(match[4]) <= 0
315
+ || Number(match[5]) !== ref.payloadBytes) return { ok: false, reason: "corrupt" };
316
+ const path = join(responseSpillDirectory(), ref.fileName);
317
+ let bytes: Buffer;
318
+ try {
319
+ const stat = lstatSync(path);
320
+ if (!stat.isFile() || stat.isSymbolicLink()) return { ok: false, reason: "corrupt" };
321
+ if (stat.size !== ref.payloadBytes) return { ok: false, reason: "corrupt" };
322
+ bytes = readFileSync(path);
323
+ } catch (error) {
324
+ return { ok: false, reason: isErrno(error, "ENOENT") ? "missing" : "corrupt" };
325
+ }
326
+ if (bytes.byteLength !== ref.payloadBytes || sha256(bytes) !== ref.digest) return { ok: false, reason: "corrupt" };
327
+ try {
328
+ const payload = JSON.parse(bytes.toString("utf8")) as unknown;
329
+ return validPayload(payload, responseId) ? { ok: true, payload } : { ok: false, reason: "corrupt" };
330
+ } catch {
331
+ return { ok: false, reason: "corrupt" };
332
+ }
333
+ }
334
+
335
+ export function deleteResponseSpill(ref: ResponseSpillRef): void {
336
+ if (!validSpillRef(ref)) return;
337
+ const dir = responseSpillDirectory();
338
+ try {
339
+ unlink(join(dir, ref.fileName));
340
+ fsyncDirectoryBestEffort(dir);
341
+ } catch { /* best effort */ }
342
+ }
343
+
344
+ export function recoverOrphanedResponseSpills(
345
+ referencedFileNames: ReadonlySet<string>,
346
+ dir = responseSpillDirectory(),
347
+ opts?: { graceMs?: number },
348
+ ): ResponseSpillCleanupResult {
349
+ const result: ResponseSpillCleanupResult = { scanned: 0, removed: 0, failed: 0, bytesRemoved: 0 };
350
+ const graceMs = opts?.graceMs ?? RESPONSE_SPILL_ORPHAN_GRACE_MS;
351
+ // ONE loop serves both the real directory handle and the injected test seam
352
+ // (review C2-2: two duplicated loops let the test prove only its own copy).
353
+ // The reader is called strictly AFTER the scan-cap check, so entry
354
+ // SCAN_MAX+1 is never requested from either source.
355
+ let handle: ReturnType<typeof opendirSync> | null = null;
356
+ const injected = spillIoForTest?.readdirEntry;
357
+ if (!injected) {
358
+ try { handle = opendirSync(dir); } catch { return result; }
359
+ }
360
+ const nextName = (): string | null => {
361
+ if (injected) return injected();
362
+ const entry = handle!.readSync();
363
+ return entry ? entry.name : null;
364
+ };
365
+ try {
366
+ while (result.scanned < RESPONSE_SPILL_SCAN_MAX) {
367
+ const name = nextName();
368
+ if (name === null) break;
369
+ result.scanned += 1;
370
+ if (result.removed + result.failed >= RESPONSE_SPILL_CLEANUP_MAX) break;
371
+ const spillMatch = OWNED_SPILL_NAME.exec(name);
372
+ const isOwnedTemp = OWNED_SPILL_TEMP_NAME.test(name);
373
+ if ((!spillMatch && !isOwnedTemp) || referencedFileNames.has(name)) continue;
374
+ const path = join(dir, name);
375
+ let stat: ReturnType<typeof lstatSync>;
376
+ try { stat = lstatSync(path); } catch { continue; }
377
+ if (!stat.isFile() || stat.isSymbolicLink() || Date.now() - stat.mtimeMs < graceMs) continue;
378
+ try {
379
+ unlink(path);
380
+ result.removed += 1;
381
+ result.bytesRemoved += stat.size;
382
+ } catch {
383
+ result.failed += 1;
384
+ }
385
+ }
386
+ } finally {
387
+ try { handle?.closeSync(); } catch { /* best effort */ }
388
+ }
389
+ return result;
390
+ }
391
+
392
+ export function responseSpillExistsForTests(ref: ResponseSpillRef): boolean {
393
+ return validSpillRef(ref) && existsSync(join(responseSpillDirectory(), ref.fileName));
394
+ }