@bitkyc08/opencodex 2.42.0 → 2.43.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 (246) hide show
  1. package/AGENTS_INSTALL.md +2 -2
  2. package/README.md +31 -0
  3. package/bin/ocx.mjs +10 -1
  4. package/gui/dist/assets/index-DS1NE4Jn.css +1 -0
  5. package/gui/dist/assets/index-Djowl68T.js +112 -0
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic-image-codec.ts +304 -0
  9. package/src/adapters/anthropic-image-normalize.ts +8 -298
  10. package/src/adapters/anthropic.ts +30 -7
  11. package/src/adapters/command-code.ts +7 -5
  12. package/src/adapters/cursor/desktop-executor-contract.ts +15 -0
  13. package/src/adapters/cursor/images.ts +36 -6
  14. package/src/adapters/cursor/live-transport.ts +7 -2
  15. package/src/adapters/cursor/native-exec-desktop.ts +2 -15
  16. package/src/adapters/cursor/protobuf-request.ts +13 -3
  17. package/src/adapters/cursor/tool-definitions.ts +5 -670
  18. package/src/adapters/cursor/tool-guidance.ts +236 -0
  19. package/src/adapters/cursor/tool-naming.ts +252 -0
  20. package/src/adapters/cursor/tool-schemas.ts +195 -0
  21. package/src/adapters/cursor/types.ts +6 -3
  22. package/src/adapters/exec-tool-result-normalize.ts +1 -1
  23. package/src/adapters/google-errors.ts +9 -1
  24. package/src/adapters/google.ts +1 -0
  25. package/src/adapters/kiro-calibration.ts +181 -0
  26. package/src/adapters/kiro.ts +135 -3
  27. package/src/adapters/openai-responses.ts +216 -26
  28. package/src/adapters/responses-code-mode.ts +59 -0
  29. package/src/adapters/tool-catalog-nudge.ts +1 -1
  30. package/src/adapters/xai-schema-analysis.ts +86 -0
  31. package/src/adapters/xai-tool-schema.ts +2 -87
  32. package/src/adapters/xai-web-search.ts +1 -1
  33. package/src/bridge.ts +22 -10
  34. package/src/chat/inbound.ts +11 -3
  35. package/src/claude/inbound-content-options.ts +60 -0
  36. package/src/claude/inbound-model-options.ts +142 -0
  37. package/src/claude/inbound-records.ts +7 -0
  38. package/src/claude/inbound.ts +10 -202
  39. package/src/claude/model-info.ts +45 -0
  40. package/src/cli/account-auth.ts +21 -6
  41. package/src/cli/capabilities.ts +13 -2
  42. package/src/cli/claude.ts +232 -39
  43. package/src/cli/config-command.ts +9 -1
  44. package/src/cli/dispatch.ts +5 -1
  45. package/src/cli/doctor.ts +10 -0
  46. package/src/cli/effort.ts +372 -0
  47. package/src/cli/export-command.ts +3 -9
  48. package/src/cli/help.ts +1 -0
  49. package/src/cli/index.ts +13 -0
  50. package/src/cli/init.ts +4 -0
  51. package/src/cli/model-selection-guidance.ts +30 -0
  52. package/src/cli/models-runtime.ts +3 -2
  53. package/src/cli/models.ts +8 -3
  54. package/src/cli/opencode.ts +4 -1
  55. package/src/cli/provider-runtime.ts +65 -0
  56. package/src/cli/provider.ts +8 -0
  57. package/src/cli/registry.ts +16 -2
  58. package/src/cli/runtime-api.ts +3 -1
  59. package/src/cli/star-prompt.ts +22 -6
  60. package/src/cli/status-probes.ts +168 -0
  61. package/src/cli/status.ts +5 -168
  62. package/src/clients/config-export/constants.ts +69 -0
  63. package/src/clients/config-export/contracts.ts +154 -0
  64. package/src/clients/config-export/dsh.ts +132 -0
  65. package/src/clients/config-export/fast-models.ts +29 -0
  66. package/src/clients/config-export/mcode.ts +83 -0
  67. package/src/clients/config-export/model-metadata.ts +108 -0
  68. package/src/clients/config-export/omp.ts +104 -0
  69. package/src/clients/config-export/zcode.ts +92 -0
  70. package/src/clients/config-export.ts +18 -710
  71. package/src/codex/account-lifecycle.ts +20 -3
  72. package/src/codex/account-usability.ts +2 -0
  73. package/src/codex/auth-api.ts +170 -23
  74. package/src/codex/auth-context.ts +328 -24
  75. package/src/codex/catalog/effort.ts +30 -4
  76. package/src/codex/catalog/metadata.ts +39 -9
  77. package/src/codex/catalog/native-models.ts +53 -12
  78. package/src/codex/catalog/parsing.ts +119 -5
  79. package/src/codex/catalog/provider-fetch.ts +150 -20
  80. package/src/codex/catalog/reserve.ts +52 -0
  81. package/src/codex/catalog/sync.ts +89 -16
  82. package/src/codex/convergence-types.ts +1 -0
  83. package/src/codex/convergence.ts +2 -0
  84. package/src/codex/data/upstream-models.json +1 -1
  85. package/src/codex/forward-transport-headers.ts +25 -0
  86. package/src/codex/inject.ts +3 -28
  87. package/src/codex/legacy-config-keys.ts +68 -0
  88. package/src/codex/log-guard/inspect-schema.ts +137 -0
  89. package/src/codex/log-guard/inspect.ts +2 -134
  90. package/src/codex/loopback-target.ts +54 -0
  91. package/src/codex/main-account-cache.ts +63 -1
  92. package/src/codex/main-account-hard-lock.ts +52 -0
  93. package/src/codex/main-account.ts +3 -1
  94. package/src/codex/management-convergence.ts +3 -0
  95. package/src/codex/model-entitlements.ts +54 -4
  96. package/src/codex/project-config-warnings.ts +92 -2
  97. package/src/codex/prompt-layers/encoding.ts +80 -0
  98. package/src/codex/prompt-layers/paths.ts +54 -0
  99. package/src/codex/prompt-layers/revision.ts +55 -0
  100. package/src/codex/prompt-layers/toml-edit.ts +163 -0
  101. package/src/codex/prompt-layers/toml-read.ts +181 -0
  102. package/src/codex/prompt-layers.ts +14 -520
  103. package/src/codex/quota-auto-refresh-state.ts +16 -0
  104. package/src/codex/quota-auto-refresh.ts +219 -0
  105. package/src/codex/quota-types.ts +51 -0
  106. package/src/codex/quota.ts +252 -93
  107. package/src/codex/reserve-availability.ts +177 -0
  108. package/src/codex/routing.ts +28 -9
  109. package/src/codex/shim.ts +53 -11
  110. package/src/codex/subagent-model-fallback.ts +23 -3
  111. package/src/combos/failover.ts +125 -7
  112. package/src/combos/identifiers.ts +89 -0
  113. package/src/combos/index.ts +4 -0
  114. package/src/combos/resolve.ts +80 -9
  115. package/src/combos/types.ts +20 -93
  116. package/src/config/subagent-models.ts +24 -0
  117. package/src/config.ts +156 -13
  118. package/src/generated/compatibility-version.json +454 -166
  119. package/src/generated/model-metadata.ts +1 -1
  120. package/src/integrations/journal.ts +65 -4
  121. package/src/integrations/store.ts +5 -0
  122. package/src/lab/events/limits.ts +4 -0
  123. package/src/lib/destination-policy.ts +31 -2
  124. package/src/lib/errors.ts +39 -0
  125. package/src/lib/provider-outbound.ts +69 -3
  126. package/src/lib/proxy-env.ts +22 -0
  127. package/src/lib/redact-folding.ts +176 -0
  128. package/src/lib/redact.ts +2 -175
  129. package/src/lib/state-store-sweeper.ts +20 -6
  130. package/src/lib/token-estimate.ts +94 -27
  131. package/src/lib/windows-user-principal.ts +53 -5
  132. package/src/oauth/anthropic-routing.ts +99 -3
  133. package/src/oauth/generic-account-failover.ts +36 -13
  134. package/src/oauth/index.ts +127 -26
  135. package/src/oauth/login-cli.ts +5 -0
  136. package/src/oauth/meta-muse.ts +117 -15
  137. package/src/oauth/pool-settings-capability.ts +15 -4
  138. package/src/providers/api-keys.ts +8 -10
  139. package/src/providers/default-aliases.ts +39 -0
  140. package/src/providers/derive.ts +10 -2
  141. package/src/providers/fastwire.ts +36 -7
  142. package/src/providers/initial-model-selection-runtime.ts +90 -0
  143. package/src/providers/initial-model-selection.ts +120 -0
  144. package/src/providers/key-failover.ts +134 -54
  145. package/src/providers/key-store.ts +11 -1
  146. package/src/providers/label.ts +1 -1
  147. package/src/providers/model-discovery.ts +76 -0
  148. package/src/providers/model-rename-startup.ts +72 -8
  149. package/src/providers/openai-sidecar.ts +17 -5
  150. package/src/providers/openai-tiers-destination.ts +102 -0
  151. package/src/providers/openai-tiers.ts +2 -99
  152. package/src/providers/opencode-go-transport.ts +41 -0
  153. package/src/providers/quota-key-accounts.ts +141 -0
  154. package/src/providers/quota-types.ts +9 -0
  155. package/src/providers/quota.ts +535 -104
  156. package/src/providers/registry.ts +60 -17
  157. package/src/providers/xai-responses-opt-in.ts +31 -5
  158. package/src/quota/reset-activation.ts +81 -0
  159. package/src/quota/reset-detector.ts +305 -0
  160. package/src/quota/reset-notify-config.ts +162 -0
  161. package/src/quota/reset-observer.ts +125 -0
  162. package/src/quota/reset-poller.ts +160 -0
  163. package/src/quota/reset-seen-store.ts +385 -0
  164. package/src/quota/reset-sinks.ts +199 -0
  165. package/src/quota/window-mapping.ts +106 -0
  166. package/src/responses/apply-patch-envelope.ts +46 -0
  167. package/src/responses/code-mode-helper-compat.ts +39 -1
  168. package/src/responses/custom-tool-compat.ts +10 -4
  169. package/src/responses/hosted-tool-policy.ts +12 -4
  170. package/src/responses/parser-content.ts +133 -0
  171. package/src/responses/parser-text-format.ts +24 -0
  172. package/src/responses/parser-tools.ts +188 -0
  173. package/src/responses/parser.ts +3 -326
  174. package/src/responses/state.ts +124 -28
  175. package/src/router.ts +48 -13
  176. package/src/routing/analytics.ts +1 -0
  177. package/src/routing/capability.ts +17 -4
  178. package/src/server/auth-cors.ts +7 -1
  179. package/src/server/background-lifecycle.ts +23 -1
  180. package/src/server/chat-completions.ts +25 -3
  181. package/src/server/claude-messages.ts +62 -5
  182. package/src/server/effort-row.ts +1 -1
  183. package/src/server/fast-row.ts +295 -0
  184. package/src/server/gui-static.ts +30 -4
  185. package/src/server/index.ts +107 -21
  186. package/src/server/management/agent-settings-routes.ts +2 -2
  187. package/src/server/management/combo-routes.ts +37 -9
  188. package/src/server/management/config-routes.ts +93 -2
  189. package/src/server/management/integration-routes.ts +108 -0
  190. package/src/server/management/model-routes.ts +13 -3
  191. package/src/server/management/model-rows.ts +20 -1
  192. package/src/server/management/native-integration-routes.ts +4 -1
  193. package/src/server/management/oauth-account-routes.ts +38 -10
  194. package/src/server/management/provider-routes.ts +34 -3
  195. package/src/server/management/quota-reset-routes.ts +57 -0
  196. package/src/server/management/route-registry.ts +7 -4
  197. package/src/server/management/shared.ts +19 -5
  198. package/src/server/management/system-routes.ts +3 -2
  199. package/src/server/management-api.ts +14 -2
  200. package/src/server/ports.ts +12 -2
  201. package/src/server/relay-eager.ts +38 -23
  202. package/src/server/relay.ts +4 -0
  203. package/src/server/request-log.ts +6 -0
  204. package/src/server/responses/codex-ws-correlation.ts +65 -0
  205. package/src/server/responses/codex-ws-exchange.ts +261 -0
  206. package/src/server/responses/codex-ws-metadata.ts +134 -0
  207. package/src/server/responses/codex-ws-pool.ts +162 -0
  208. package/src/server/responses/codex-ws-request.ts +87 -0
  209. package/src/server/responses/codex-ws-session.ts +93 -0
  210. package/src/server/responses/codex-ws-wire.ts +144 -0
  211. package/src/server/responses/collaboration.ts +41 -1
  212. package/src/server/responses/compact.ts +105 -12
  213. package/src/server/responses/core.ts +476 -57
  214. package/src/server/responses/fetch-helpers.ts +10 -3
  215. package/src/server/responses/input-admission.ts +16 -9
  216. package/src/server/responses/responses-field-backfill.ts +1 -1
  217. package/src/server/responses/ws-upstream.ts +34 -318
  218. package/src/server/responses-custom-tool-repair.ts +20 -4
  219. package/src/server/responses-undeclared-tool-guard.ts +100 -8
  220. package/src/server/safe-response-headers.ts +23 -0
  221. package/src/server/search.ts +9 -0
  222. package/src/server/subagent-models-startup.ts +27 -0
  223. package/src/server/system-env-shell.ts +238 -0
  224. package/src/server/system-env.ts +7 -234
  225. package/src/server/ws-bridge.ts +3 -25
  226. package/src/server/xai-responses-startup.ts +21 -0
  227. package/src/service-manager-probe.ts +1 -1
  228. package/src/service.ts +55 -16
  229. package/src/types/config.ts +108 -12
  230. package/src/types/provider.ts +36 -7
  231. package/src/types/tools.ts +26 -1
  232. package/src/types.ts +2 -0
  233. package/src/update/notify.ts +8 -2
  234. package/src/usage/cost.ts +38 -28
  235. package/src/usage/expected-prices.ts +34 -15
  236. package/src/usage/log.ts +2 -0
  237. package/src/usage/model-identity.ts +26 -0
  238. package/src/usage/summary.ts +15 -1
  239. package/src/vision/describe.ts +6 -0
  240. package/src/vision/image-rewrite.ts +108 -0
  241. package/src/vision/index.ts +19 -306
  242. package/src/vision/plan.ts +205 -0
  243. package/src/web-search/executor.ts +6 -0
  244. package/src/web-search/index.ts +8 -1
  245. package/gui/dist/assets/index-BU1tE0sr.js +0 -112
  246. package/gui/dist/assets/index-DL9-iS6J.css +0 -1
@@ -22,9 +22,9 @@ import { extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "../web-search/synt
22
22
  import { buildImageTool, extractHostedImageGeneration, IMAGE_GEN_TOOL_NAME } from "../images/synthetic-tool";
23
23
  import { toolSearchDescription, toolSearchParameters } from "./tool-search-compat";
24
24
 
25
- function isObj(v: unknown): v is Record<string, unknown> {
26
- return typeof v === "object" && v !== null && !Array.isArray(v);
27
- }
25
+ import { isObj, inputContentParts, outputTextOf, outputToToolResultContent, toolOutputContainsEncryptedContent } from "./parser-content";
26
+ import { mapToolChoice, buildTools, customToolNamespaces } from "./parser-tools";
27
+ import { parseTextFormat } from "./parser-text-format";
28
28
 
29
29
  /**
30
30
  * Wrap a remembered proxy-side signature as provider metadata for a replayed tool call.
@@ -41,241 +41,7 @@ function replayThoughtSignatureMetadata(
41
41
  return signature ? { google: { thoughtSignature: signature } } : undefined;
42
42
  }
43
43
 
44
- type InputBlock =
45
- | { type: "input_text"; text: string }
46
- | { type: "text"; text: string }
47
- | { type: "input_image"; image_url?: string; file_id?: string; detail?: string }
48
- | { type: "input_video"; video_url?: string }
49
- | { type: "input_file"; file_id?: string; filename?: string; file_data?: string };
50
-
51
- /** A usable reference string, or undefined. Empty strings and non-strings are not references. */
52
- function nonEmptyString(value: unknown): string | undefined {
53
- return typeof value === "string" && value.length > 0 ? value : undefined;
54
- }
55
-
56
- function inputContentParts(blocks: unknown): string | OcxContentPart[] {
57
- if (typeof blocks === "string") return blocks;
58
- // The catch-all can also hand back a non-array `content` (an object, a number), which would
59
- // throw at the loop below before any per-block guard runs.
60
- if (!Array.isArray(blocks)) return [];
61
- const parts: OcxContentPart[] = [];
62
- for (const raw of blocks) {
63
- // A malformed message item fails its strict schema and falls through to inputItemSchema's
64
- // permissive catch-all, so blocks reaching here are NOT guaranteed to match the declared
65
- // shape. Validate each field before use, as outputToToolResultContent already does.
66
- if (!isObj(raw)) continue;
67
- const block = raw as InputBlock;
68
- if (block.type === "input_text" || block.type === "text") {
69
- if (typeof raw.text === "string") parts.push({ type: "text", text: raw.text });
70
- } else if (block.type === "input_image") {
71
- const b = block as { image_url?: string; file_id?: string; detail?: string };
72
- const imageUrl = nonEmptyString(b.image_url);
73
- const fileId = nonEmptyString(b.file_id);
74
- const detail = nonEmptyString(b.detail);
75
- if (imageUrl) {
76
- // Preserve the image as a structured part — adapters send it as a native image block.
77
- // NEVER inline the (often base64 data-URL) image_url as text: that explodes the token count.
78
- parts.push({ type: "image", imageUrl, ...(detail ? { detail: normalizeImageDetail(detail) } : {}) });
79
- } else if (fileId) {
80
- parts.push({ type: "text", text: `[image: ${fileId}]` }); // file_id ref → no inline data
81
- }
82
- // No usable reference: omit the block. A "[image: ?]" marker would claim an attachment
83
- // the request never carried, which is worse than dropping malformed input.
84
- } else if (block.type === "input_video") {
85
- const videoUrl = nonEmptyString(block.video_url);
86
- if (videoUrl) parts.push({ type: "video", videoUrl });
87
- } else if (block.type === "input_file") {
88
- const b = block as { file_id?: string; filename?: string; file_data?: string };
89
- const fileId = nonEmptyString(b.file_id);
90
- const fileData = nonEmptyString(b.file_data);
91
- const filename = nonEmptyString(b.filename);
92
- if (fileId) {
93
- parts.push({ type: "text", text: `[file: ${fileId}]` });
94
- } else if (fileData) {
95
- // Inline file_data is often large base64. Preserve only its presence and name, never bytes.
96
- parts.push({ type: "text", text: filename ? `[file: ${filename}]` : "[file: inline data]" });
97
- }
98
- // A bare filename is not a file resource in the Responses schema, so omit it rather than
99
- // fabricating a "[file: ...]" marker for an attachment that was never sent.
100
- }
101
- }
102
- // Collapse to a plain string only for a single TEXT part; images must stay structured.
103
- if (parts.length === 1 && parts[0].type === "text") return parts[0].text;
104
- return parts;
105
- }
106
-
107
- type OutputBlock = { type: "output_text"; text: string } | { type: "text"; text: string } | { type: "refusal"; refusal: string };
108
-
109
- function outputTextOf(blocks: unknown): OcxTextContent[] {
110
- if (typeof blocks === "string") return blocks.length > 0 ? [{ type: "text", text: blocks }] : [];
111
- if (!Array.isArray(blocks)) return [];
112
- const out: OcxTextContent[] = [];
113
- for (const raw of blocks) {
114
- // Same catch-all caveat as inputContentParts: validate before use.
115
- if (!isObj(raw)) continue;
116
- const b = raw as OutputBlock;
117
- if (b.type === "output_text" || b.type === "text") {
118
- if (typeof raw.text === "string") out.push({ type: "text", text: raw.text });
119
- } else if (b.type === "refusal") {
120
- if (typeof raw.refusal === "string") out.push({ type: "text", text: `[refusal: ${raw.refusal}]` });
121
- }
122
- }
123
- return out;
124
- }
125
-
126
- function mapToolChoice(value: unknown): OcxRequestOptions["toolChoice"] {
127
- if (value === undefined || value === null) return undefined;
128
- if (value === "auto" || value === "none" || value === "required") return value;
129
- if (isObj(value) && "type" in value) {
130
- const t = (value as { type: string }).type;
131
- if ((t === "function" || t === "custom") && "name" in value) {
132
- return { name: (value as { name: string }).name };
133
- }
134
- // Hosted image tool types (with or without a name) map to the synthetic image_gen wire name.
135
- if (t === "image_generation" || t === "image_gen") {
136
- return { name: IMAGE_GEN_TOOL_NAME };
137
- }
138
- if (t === "allowed_tools" && Array.isArray(value.tools)) {
139
- const names = value.tools
140
- .map(allowedToolName)
141
- .filter((name): name is string => Boolean(name));
142
- return names.length > 0
143
- ? { allowedTools: [...new Set(names)], mode: value.mode === "required" ? "required" : "auto" }
144
- : "none";
145
- }
146
- return "auto";
147
- }
148
- return undefined;
149
- }
150
44
 
151
- function allowedToolName(tool: unknown): string | undefined {
152
- if (!isObj(tool)) return undefined;
153
- if (typeof tool.name === "string" && tool.name.length > 0) return tool.name;
154
- if (tool.type === "web_search" || tool.type === "web_search_preview") return WEB_SEARCH_TOOL_NAME;
155
- if (tool.type === "image_generation" || tool.type === "image_gen") return IMAGE_GEN_TOOL_NAME;
156
- if (tool.type === "tool_search") return "tool_search";
157
- return undefined;
158
- }
159
-
160
- function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined {
161
- if (!tools) return undefined;
162
- const out: OcxTool[] = [];
163
- const normalizeParameters = (raw: unknown): Record<string, unknown> => {
164
- if (isObj(raw) && raw.type === "object") return raw;
165
- return { ...(isObj(raw) ? raw : {}), type: "object" };
166
- };
167
- const pushFn = (t: Record<string, unknown>, namespace?: string) => {
168
- // Hosted image_generation already installed the synthetic root tool. A later
169
- // ordinary root `image_gen` must not create a second un-namespaced identity.
170
- if (
171
- !namespace
172
- && t.name === IMAGE_GEN_TOOL_NAME
173
- && out.some(tool => tool.name === IMAGE_GEN_TOOL_NAME && !tool.namespace && tool.imageGeneration)
174
- ) {
175
- return;
176
- }
177
- const tool: OcxTool = {
178
- name: t.name as string,
179
- description: (t.description as string) ?? "",
180
- parameters: normalizeParameters(t.parameters),
181
- };
182
- if (t.strict !== undefined) tool.strict = t.strict as boolean;
183
- if (namespace) tool.namespace = namespace;
184
- out.push(tool);
185
- };
186
- const pushCustom = (t: Record<string, unknown>, namespace?: string) => {
187
- // Hosted image_generation already installed the synthetic root tool. A later
188
- // root custom `image_gen` would collide on the same wire name with a different
189
- // `freeform` flag and throw `ambiguous tool catalog`.
190
- if (
191
- !namespace
192
- && t.name === IMAGE_GEN_TOOL_NAME
193
- && out.some(tool => tool.name === IMAGE_GEN_TOOL_NAME && !tool.namespace && tool.imageGeneration)
194
- ) {
195
- return;
196
- }
197
- // Freeform custom tools are lowered to a single string `input` because chat models cannot
198
- // emit Responses grammar payloads directly. Keep tool-specific input guidance scoped to the
199
- // tool that owns it: leaking apply_patch syntax into `exec` or another freeform tool teaches
200
- // routed models that the nested helper name is itself a callable top-level tool.
201
- const inputDescription = t.name === "apply_patch"
202
- ? "Raw tool input. For apply_patch, begin exactly with `*** Begin Patch` (no trailing `***`), then use its standard patch envelope."
203
- : "Raw freeform input for this tool.";
204
- const tool: OcxTool = {
205
- name: t.name as string,
206
- description: (t.description as string) ?? "",
207
- parameters: { type: "object", properties: { input: { type: "string", description: inputDescription } }, required: ["input"] },
208
- freeform: true,
209
- };
210
- if (namespace) tool.namespace = namespace;
211
- out.push(tool);
212
- };
213
- for (const t of tools) {
214
- if (!isObj(t)) continue;
215
- if (t.type === "function" && isObj(t.function) && typeof t.function.name === "string" && t.function.name.length > 0) {
216
- pushFn(t.function as Record<string, unknown>);
217
- continue;
218
- }
219
- if (t.type === "function" && typeof t.name === "string") {
220
- pushFn(t);
221
- } else if (t.type === "namespace" && Array.isArray(t.tools)) {
222
- // Codex 0.147 groups its ordinary client tools under the reserved `functions` namespace,
223
- // including freeform custom tools such as code-mode `exec`. Those children are still
224
- // top-level Responses tools, so flatten them without a namespace. Other namespace groups
225
- // are MCP-style and keep their namespace for round-trip routing.
226
- const builtinFunctions = t.name === "functions";
227
- const ns = typeof t.name === "string" && !builtinFunctions ? t.name : undefined;
228
- for (const inner of t.tools as unknown[]) {
229
- if (isObj(inner) && inner.type === "function" && typeof inner.name === "string") pushFn(inner, ns);
230
- else if (isObj(inner) && inner.type === "custom" && typeof inner.name === "string") pushCustom(inner, ns);
231
- }
232
- }
233
- else if (t.type === "custom" && typeof t.name === "string") {
234
- pushCustom(t);
235
- }
236
- else if (t.type === "tool_search") {
237
- // Client-executed tool discovery — the gateway to deferred tools (subagents, extra MCP tools).
238
- // Expose as a function so chat models can call it; the bridge relays it as a tool_search_call.
239
- out.push({
240
- name: "tool_search",
241
- description: toolSearchDescription(t),
242
- parameters: normalizeParameters(toolSearchParameters(t)),
243
- toolSearch: true,
244
- });
245
- }
246
- else if (t.type === "image_generation" || t.type === "image_gen") {
247
- // Keep Codex's image_gen visible to routed chat models. The hosted OpenAI tool
248
- // cannot execute on Grok; the model still has to see a callable image_gen so
249
- // Codex's client-side /v1/images request can fire and be relayed to xAI.
250
- // Identity is the un-namespaced synthetic root (`imageGeneration: true`), not
251
- // the bare name: a namespaced ordinary `image_gen` must not suppress it.
252
- const synthetic = buildImageTool();
253
- // Every un-namespaced `image_gen` collides on one wire name, so removing only
254
- // the first leaves a second root behind and the catalog stays ambiguous.
255
- // Drop all root collisions, keep namespaced entries, then insert exactly one
256
- // synthetic root — at the earliest colliding position so declaration order is
257
- // preserved for models that read the catalog positionally.
258
- let insertAt = -1;
259
- for (let i = out.length - 1; i >= 0; i -= 1) {
260
- const tool = out[i]!;
261
- if (tool.name !== IMAGE_GEN_TOOL_NAME || tool.namespace) continue;
262
- out.splice(i, 1);
263
- insertAt = i;
264
- }
265
- if (insertAt >= 0) out.splice(insertAt, 0, synthetic);
266
- else out.push(synthetic);
267
- }
268
- else if (typeof t.name === "string" && t.type !== "web_search" && t.type !== "image_generation") {
269
- // Any OTHER named tool (e.g. a native/computer-use tool type opencodex doesn't explicitly
270
- // model) is client-executed — pass it through as a function so the routed model can read and
271
- // call it naturally; the bridge relays its call as a function_call. Previously such tools were
272
- // silently dropped, so the model never saw them.
273
- pushFn(t);
274
- }
275
- // Hosted web_search is still dropped here — the web-search sidecar re-injects it.
276
- }
277
- return out.length > 0 ? out : undefined;
278
- }
279
45
 
280
46
  function ensureAssistantPlaceholder(messages: OcxMessage[], modelId: string, now: number): OcxAssistantMessage {
281
47
  const last = messages[messages.length - 1];
@@ -285,45 +51,6 @@ function ensureAssistantPlaceholder(messages: OcxMessage[], modelId: string, now
285
51
  return placeholder;
286
52
  }
287
53
 
288
- /**
289
- * Tool-call output content. Preserves images (e.g. Codex `view_image` returns
290
- * `input_image` items): returns content parts when any image is present, else a plain joined string.
291
- * Never inlines an image_url as text (that would explode the token count).
292
- */
293
- function outputToToolResultContent(output: string | unknown[] | undefined): string | OcxContentPart[] {
294
- if (typeof output === "string") return output;
295
- if (!Array.isArray(output)) return "";
296
- const parts: OcxContentPart[] = [];
297
- let hasImage = false;
298
- for (const raw of output) {
299
- if (!isObj(raw)) continue;
300
- if (raw.type === "output_text" || raw.type === "text" || raw.type === "input_text") {
301
- if (typeof raw.text === "string") parts.push({ type: "text", text: raw.text });
302
- } else if (raw.type === "refusal" && typeof raw.refusal === "string") {
303
- parts.push({ type: "text", text: `[refusal: ${raw.refusal}]` });
304
- } else if (raw.type === "input_image" && typeof raw.image_url === "string") {
305
- parts.push({ type: "image", imageUrl: raw.image_url, ...(typeof raw.detail === "string" ? { detail: normalizeImageDetail(raw.detail) } : {}) });
306
- hasImage = true;
307
- } else if (raw.type === "encrypted_content") {
308
- // codex-rs FunctionCallOutputContentItem::EncryptedContent — opaque to routed models.
309
- parts.push({ type: "text", text: "[encrypted content omitted]" });
310
- }
311
- }
312
- if (!hasImage) return parts.map(p => (p.type === "text" ? p.text : "")).join("");
313
- return parts;
314
- }
315
-
316
- function toolOutputContainsEncryptedContent(output: string | unknown[] | undefined): boolean {
317
- return Array.isArray(output) && output.some(raw => isObj(raw) && raw.type === "encrypted_content");
318
- }
319
-
320
- /**
321
- * codex-rs ImageDetail allows "original", but chat-completions providers only accept
322
- * auto|low|high on image_url.detail — degrade "original" to "high" (the codex default).
323
- */
324
- function normalizeImageDetail(detail: string): string {
325
- return detail === "original" ? "high" : detail;
326
- }
327
54
 
328
55
  function findToolById(messages: OcxMessage[], callId: string): { name: string; namespace?: string } {
329
56
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -366,34 +93,6 @@ function attachPendingReasoningToCallOwner(
366
93
 
367
94
  const REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max"]);
368
95
 
369
- /**
370
- * Namespace a custom tool was declared under, by its bare name.
371
- *
372
- * A `custom_tool_call` echoed back by the client carries only the bare name — the bridge
373
- * emits `{"type":"custom_tool_call","name":"exec"}` even when the tool was declared as
374
- * `mcp__functions__exec`. Without this lookup the namespace is lost on the return trip,
375
- * and the adapters replay history through `namespacedToolName(namespace, name)`, which
376
- * then produces a bare `exec` the provider may not have. Ordinary `function_call` items
377
- * do not need this: they carry `namespace` on the wire.
378
- */
379
- function customToolNamespaces(tools: unknown): Map<string, string> {
380
- const out = new Map<string, string>();
381
- if (!Array.isArray(tools)) return out;
382
- for (const spec of tools) {
383
- if (!isObj(spec) || spec.type !== "namespace" || !Array.isArray(spec.tools)) continue;
384
- const namespace = typeof spec.name === "string" ? spec.name : undefined;
385
- // Codex 0.147 groups ordinary client tools under the reserved `functions` namespace and
386
- // buildTools deliberately flattens those without a namespace. Mirror that here, or the
387
- // reconstruction would invent a namespace the request never advertised.
388
- if (!namespace || namespace === "functions") continue;
389
- for (const inner of spec.tools) {
390
- if (!isObj(inner) || inner.type !== "custom" || typeof inner.name !== "string") continue;
391
- // Ambiguous bare names are already rejected upstream, so first declaration wins.
392
- if (!out.has(inner.name)) out.set(inner.name, namespace);
393
- }
394
- }
395
- return out;
396
- }
397
96
 
398
97
  export function parseRequest(
399
98
  body: unknown,
@@ -859,25 +558,3 @@ export function parseRequest(
859
558
  ...(contextCompactionBoundary ? { _contextCompactionBoundary: true } : {}),
860
559
  };
861
560
  }
862
-
863
- /**
864
- * The Responses `text.format` object when it requests structured output (json_schema or
865
- * json_object), undefined otherwise. Acceptance is identical to the boolean detector this
866
- * replaces; unknown or malformed formats are ignored, never rejected, so the native
867
- * passthrough keeps forwarding whatever the caller sent via `_rawBody`.
868
- */
869
- function parseTextFormat(text: unknown): OcxRequestOptions["textFormat"] {
870
- if (!isObj(text)) return undefined;
871
- const format = (text as { format?: unknown }).format;
872
- if (!isObj(format)) return undefined;
873
- const f = format as { type?: unknown; name?: unknown; description?: unknown; schema?: unknown; strict?: unknown };
874
- if (f.type === "json_object") return { type: "json_object" };
875
- if (f.type !== "json_schema") return undefined;
876
- return {
877
- type: "json_schema",
878
- ...(typeof f.name === "string" ? { name: f.name } : {}),
879
- ...(typeof f.description === "string" ? { description: f.description } : {}),
880
- ...(isObj(f.schema) ? { schema: f.schema as Record<string, unknown> } : {}),
881
- ...(typeof f.strict === "boolean" ? { strict: f.strict } : {}),
882
- };
883
- }
@@ -170,6 +170,77 @@ async function snapshotOnDiskMatches(path: string, payload: string, payloadBytes
170
170
  }
171
171
  }
172
172
  const spillCounters = { writes: 0, writeFailures: 0, readFailures: 0 };
173
+
174
+ export type ResponseSpillWriteFailureCode =
175
+ | "EACLRETRYEXHAUSTED"
176
+ | "ETIMEDOUT"
177
+ | "EACCES"
178
+ | "ENOSPC"
179
+ | "EFBIG"
180
+ | "EIO"
181
+ | "ECAPACITY"
182
+ | "ELOOP"
183
+ | "EUNKNOWN";
184
+
185
+ export type ResponseSpillWriteStatus = "initial" | "healthy" | "degraded";
186
+
187
+ interface ResponseSpillWriteHealth {
188
+ consecutiveFailures: number;
189
+ lastFailureCode: ResponseSpillWriteFailureCode | null;
190
+ lastFailureAt: number | null;
191
+ lastSuccessAt: number | null;
192
+ }
193
+
194
+ const spillWriteHealth: ResponseSpillWriteHealth = {
195
+ consecutiveFailures: 0,
196
+ lastFailureCode: null,
197
+ lastFailureAt: null,
198
+ lastSuccessAt: null,
199
+ };
200
+
201
+ /**
202
+ * Collapse filesystem/runtime errors into a fixed privacy-safe diagnostic union.
203
+ * Messages and paths are deliberately ignored: this projection is returned by the
204
+ * authenticated memory endpoint, and a nested `cause` can contain a username or
205
+ * workspace path even when the public wrapper does not.
206
+ */
207
+ function classifySpillWriteFailure(error: unknown): ResponseSpillWriteFailureCode {
208
+ let cursor = error;
209
+ for (let depth = 0; depth < 4 && cursor && typeof cursor === "object"; depth += 1) {
210
+ const record = cursor as { code?: unknown; cause?: unknown };
211
+ const code = typeof record.code === "string" ? record.code.toUpperCase() : "";
212
+ switch (code) {
213
+ case "EACLRETRYEXHAUSTED": return "EACLRETRYEXHAUSTED";
214
+ case "ETIMEDOUT": return "ETIMEDOUT";
215
+ case "EACCES":
216
+ case "EPERM": return "EACCES";
217
+ case "ENOSPC":
218
+ case "EDQUOT": return "ENOSPC";
219
+ case "EFBIG": return "EFBIG";
220
+ case "EIO": return "EIO";
221
+ case "ECAPACITY": return "ECAPACITY";
222
+ case "ELOOP": return "ELOOP";
223
+ }
224
+ cursor = record.cause;
225
+ }
226
+ return "EUNKNOWN";
227
+ }
228
+
229
+ function noteSpillWriteSuccess(): void {
230
+ spillCounters.writes += 1;
231
+ spillWriteHealth.consecutiveFailures = 0;
232
+ spillWriteHealth.lastSuccessAt = now();
233
+ }
234
+
235
+ function noteSpillWriteFailure(
236
+ error: unknown,
237
+ override?: ResponseSpillWriteFailureCode,
238
+ ): void {
239
+ spillCounters.writeFailures += 1;
240
+ spillWriteHealth.consecutiveFailures += 1;
241
+ spillWriteHealth.lastFailureCode = override ?? classifySpillWriteFailure(error);
242
+ spillWriteHealth.lastFailureAt = now();
243
+ }
173
244
  /**
174
245
  * Admission-boundary observability (test-visible). directSpills: oversized
175
246
  * candidates routed straight to durable spill without a resident stay or
@@ -346,6 +417,7 @@ async function runPendingResponseSpill(job: PendingResponseSpill): Promise<void>
346
417
  job.running = true;
347
418
  const candidate = job.candidate;
348
419
  let ref: ResponseSpillRef | null = null;
420
+ let exhaustedAclRetry = false;
349
421
  try {
350
422
  const state = spillPayloadForResident(candidate);
351
423
  try {
@@ -357,11 +429,16 @@ async function runPendingResponseSpill(job: PendingResponseSpill): Promise<void>
357
429
  if (!isAclTimeout(error)) throw error;
358
430
  // The ACL helper permits exactly one caller-owned recovery budget. The resident generation
359
431
  // remains replayable during both attempts, so a transient timeout never becomes a tombstone.
360
- ref = await writeResponseSpillDurablyAsync(job.id, state, {
361
- aclBudgetMs: responseSpillAsyncAclAttemptBudgetMs(),
362
- retryTimedOutOnce: true,
363
- publicationControl: job.publicationControl,
364
- });
432
+ try {
433
+ ref = await writeResponseSpillDurablyAsync(job.id, state, {
434
+ aclBudgetMs: responseSpillAsyncAclAttemptBudgetMs(),
435
+ retryTimedOutOnce: true,
436
+ publicationControl: job.publicationControl,
437
+ });
438
+ } catch (retryError) {
439
+ exhaustedAclRetry = isAclTimeout(retryError);
440
+ throw retryError;
441
+ }
365
442
  }
366
443
  if (ref.payloadBytes > responseSpillPayloadCap()) {
367
444
  deleteResponseSpill(ref);
@@ -376,14 +453,14 @@ async function runPendingResponseSpill(job: PendingResponseSpill): Promise<void>
376
453
  }
377
454
  if (swapResidentForSpill(job.id, candidate, ref)) {
378
455
  ref = null;
379
- spillCounters.writes += 1;
456
+ noteSpillWriteSuccess();
380
457
  if (job.directAdmission) admissionCounters.directSpills += 1;
381
458
  deferSupersededSpill(job.supersededSpill);
382
459
  }
383
- } catch {
460
+ } catch (error) {
384
461
  if (ref) deleteResponseSpill(ref);
385
462
  if (states.get(job.id) === candidate && !job.cancelled) {
386
- spillCounters.writeFailures += 1;
463
+ noteSpillWriteFailure(error, exhaustedAclRetry ? "EACLRETRYEXHAUSTED" : undefined);
387
464
  replaceWithSpillFailure(job.id, candidate);
388
465
  deferSupersededSpill(job.supersededSpill);
389
466
  }
@@ -406,7 +483,7 @@ function queuePendingResponseSpill(
406
483
  ): void {
407
484
  const inheritedSpill = cancelPendingResponseSpill(id) ?? options.supersededSpill;
408
485
  if (pendingResponseSpillBytes + candidate.sizeBytes > MAX_PENDING_RESPONSE_SPILL_BYTES) {
409
- spillCounters.writeFailures += 1;
486
+ noteSpillWriteFailure(null, "ECAPACITY");
410
487
  replaceWithSpillFailure(id, candidate);
411
488
  deferSupersededSpill(inheritedSpill);
412
489
  return;
@@ -424,7 +501,7 @@ function queuePendingResponseSpill(
424
501
  if (accountedResponseSpillBytes() + footprint + inheritedBytes > spillByteCap()) {
425
502
  enforceSpilledResponseBudget();
426
503
  if (accountedResponseSpillBytes() + footprint + inheritedBytes > spillByteCap()) {
427
- spillCounters.writeFailures += 1;
504
+ noteSpillWriteFailure(null, "ECAPACITY");
428
505
  replaceWithSpillFailure(id, candidate);
429
506
  deferSupersededSpill(inheritedSpill);
430
507
  return;
@@ -560,7 +637,7 @@ function installShutdownFallbackSpill(
560
637
  enforceSpilledResponseBudget();
561
638
  if (accountedResponseSpillBytes() + supersededBytes > spillByteCap()) {
562
639
  if (states.get(job.id) === candidate) {
563
- spillCounters.writeFailures += 1;
640
+ noteSpillWriteFailure(null, "ECAPACITY");
564
641
  replaceWithSpillFailure(job.id, candidate);
565
642
  deferSupersededSpill(job.supersededSpill);
566
643
  }
@@ -581,14 +658,14 @@ function installShutdownFallbackSpill(
581
658
  }
582
659
  if (swapResidentForSpill(job.id, candidate, ref)) {
583
660
  ref = null;
584
- spillCounters.writes += 1;
661
+ noteSpillWriteSuccess();
585
662
  if (job.directAdmission) admissionCounters.directSpills += 1;
586
663
  deferSupersededSpill(job.supersededSpill);
587
664
  }
588
665
  } catch (error) {
589
666
  if (ref) deleteResponseSpill(ref);
590
667
  if (states.get(job.id) === candidate) {
591
- spillCounters.writeFailures += 1;
668
+ noteSpillWriteFailure(error);
592
669
  replaceWithSpillFailure(job.id, candidate);
593
670
  deferSupersededSpill(job.supersededSpill);
594
671
  }
@@ -601,9 +678,10 @@ function installShutdownFallbackSpill(
601
678
  function terminalizeShutdownFallbackCandidate(
602
679
  job: PendingResponseSpill,
603
680
  candidate: ResidentResponseState,
681
+ failureCode: ResponseSpillWriteFailureCode = "ETIMEDOUT",
604
682
  ): void {
605
683
  if (states.get(job.id) !== candidate) return;
606
- spillCounters.writeFailures += 1;
684
+ noteSpillWriteFailure(null, failureCode);
607
685
  replaceWithSpillFailure(job.id, candidate);
608
686
  deferSupersededSpill(job.supersededSpill);
609
687
  }
@@ -652,11 +730,11 @@ function stopAtShutdownTerminalizationPassLimit(
652
730
  failures.push(Object.assign(new Error("Response spill shutdown terminalization pass limit exceeded"), { code: "ELOOP" }));
653
731
  supersedeShutdownFallbackBatch(pending, failures);
654
732
  for (const { job, candidate } of pending) {
655
- terminalizeShutdownFallbackCandidate(job, candidate);
733
+ terminalizeShutdownFallbackCandidate(job, candidate, "ELOOP");
656
734
  }
657
735
  for (const [id, state] of [...states]) {
658
736
  if (state.kind !== "resident") continue;
659
- spillCounters.writeFailures += 1;
737
+ noteSpillWriteFailure(null, "ELOOP");
660
738
  replaceWithSpillFailure(id, state);
661
739
  }
662
740
  recomputeOldestResident();
@@ -986,7 +1064,7 @@ function replaceSpillEntryAtomically(
986
1064
  deleteResponseSpill(ref);
987
1065
  return;
988
1066
  }
989
- spillCounters.writes += 1;
1067
+ noteSpillWriteSuccess();
990
1068
  noteStubSwapForTest();
991
1069
  // The old generation is NOT unlinked here (review C1-1): the new stub is
992
1070
  // only durable once the debounced snapshot flushes — a crash before that
@@ -996,8 +1074,8 @@ function replaceSpillEntryAtomically(
996
1074
  while (pendingSpillUnlinks.length > PENDING_SPILL_UNLINKS_MAX) {
997
1075
  deleteResponseSpill(pendingSpillUnlinks.shift()!);
998
1076
  }
999
- } catch {
1000
- spillCounters.writeFailures += 1;
1077
+ } catch (error) {
1078
+ noteSpillWriteFailure(error);
1001
1079
  // deferSpillUnlink: the durable snapshot may still reference the old
1002
1080
  // generation; deleting it now would strand the old stub after a crash.
1003
1081
  replaceWithSpillFailure(id, expected, { deferSpillUnlink: true });
@@ -1087,7 +1165,7 @@ function admitOversizedCandidate(
1087
1165
  deleteResponseSpill(ref);
1088
1166
  return;
1089
1167
  }
1090
- spillCounters.writes += 1;
1168
+ noteSpillWriteSuccess();
1091
1169
  admissionCounters.directSpills += 1;
1092
1170
  noteStubSwapForTest();
1093
1171
  if (expected?.kind === "spill") {
@@ -1099,8 +1177,8 @@ function admitOversizedCandidate(
1099
1177
  deleteResponseSpill(pendingSpillUnlinks.shift()!);
1100
1178
  }
1101
1179
  }
1102
- } catch {
1103
- spillCounters.writeFailures += 1;
1180
+ } catch (error) {
1181
+ noteSpillWriteFailure(error);
1104
1182
  replaceWithSpillFailure(id, expected, { deferSpillUnlink: true });
1105
1183
  }
1106
1184
  }
@@ -1812,9 +1890,9 @@ function pruneResponses(at = now()): void {
1812
1890
  ...(entry.providerOutputStart !== undefined ? { providerOutputStart: entry.providerOutputStart } : {}),
1813
1891
  ...(entry.providers ? { providers: entry.providers } : {}),
1814
1892
  });
1815
- if (swapResidentForSpill(oldestId, entry, ref)) spillCounters.writes += 1;
1816
- } catch {
1817
- spillCounters.writeFailures += 1;
1893
+ if (swapResidentForSpill(oldestId, entry, ref)) noteSpillWriteSuccess();
1894
+ } catch (error) {
1895
+ noteSpillWriteFailure(error);
1818
1896
  replaceWithSpillFailure(oldestId, entry);
1819
1897
  }
1820
1898
  }
@@ -1929,9 +2007,9 @@ export function evictOldestResponseContinuationForBudget(): number {
1929
2007
  ...(entry.providerOutputStart !== undefined ? { providerOutputStart: entry.providerOutputStart } : {}),
1930
2008
  ...(entry.providers ? { providers: entry.providers } : {}),
1931
2009
  });
1932
- if (swapResidentForSpill(id, entry, ref)) spillCounters.writes += 1;
1933
- } catch {
1934
- spillCounters.writeFailures += 1;
2010
+ if (swapResidentForSpill(id, entry, ref)) noteSpillWriteSuccess();
2011
+ } catch (error) {
2012
+ noteSpillWriteFailure(error);
1935
2013
  replaceWithSpillFailure(id, entry);
1936
2014
  }
1937
2015
  schedulePersist();
@@ -2107,6 +2185,11 @@ export interface ResponseStateMetrics {
2107
2185
  oldestAgeMs: number;
2108
2186
  spillWrites: number;
2109
2187
  spillWriteFailures: number;
2188
+ spillWriteStatus: ResponseSpillWriteStatus;
2189
+ spillWriteConsecutiveFailures: number;
2190
+ spillLastWriteFailureCode: ResponseSpillWriteFailureCode | null;
2191
+ spillLastWriteFailureAt: number | null;
2192
+ spillLastWriteSuccessAt: number | null;
2110
2193
  spillReadFailures: number;
2111
2194
  replayScopeMismatchDrops: number;
2112
2195
  }
@@ -2150,6 +2233,15 @@ export function responseStateMetrics(): ResponseStateMetrics {
2150
2233
  oldestAgeMs: states.size > 0 ? at - oldestCreatedAt : 0,
2151
2234
  spillWrites: spillCounters.writes,
2152
2235
  spillWriteFailures: spillCounters.writeFailures,
2236
+ spillWriteStatus: spillWriteHealth.consecutiveFailures > 0
2237
+ ? "degraded"
2238
+ : spillWriteHealth.lastSuccessAt !== null
2239
+ ? "healthy"
2240
+ : "initial",
2241
+ spillWriteConsecutiveFailures: spillWriteHealth.consecutiveFailures,
2242
+ spillLastWriteFailureCode: spillWriteHealth.lastFailureCode,
2243
+ spillLastWriteFailureAt: spillWriteHealth.lastFailureAt,
2244
+ spillLastWriteSuccessAt: spillWriteHealth.lastSuccessAt,
2153
2245
  spillReadFailures: spillCounters.readFailures,
2154
2246
  replayScopeMismatchDrops,
2155
2247
  };
@@ -2268,6 +2360,10 @@ export function clearResponseStateMemoryForTests(): void {
2268
2360
  spillCounters.writes = 0;
2269
2361
  spillCounters.writeFailures = 0;
2270
2362
  spillCounters.readFailures = 0;
2363
+ spillWriteHealth.consecutiveFailures = 0;
2364
+ spillWriteHealth.lastFailureCode = null;
2365
+ spillWriteHealth.lastFailureAt = null;
2366
+ spillWriteHealth.lastSuccessAt = null;
2271
2367
  replayScopeMismatchDrops = 0;
2272
2368
  replayOverlapSkips = 0;
2273
2369
  persistAttemptHookForTests = null;