@bitkyc08/opencodex 2.65.0 → 2.66.0-preview.20260925

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/README.md +14 -0
  2. package/gui/dist/assets/App-BVjA0T6O.js +51 -0
  3. package/gui/dist/assets/App-gkoJIEOV.css +1 -0
  4. package/gui/dist/assets/{Tray-D4bA1vff.js → Tray-BCFtiWwD.js} +1 -1
  5. package/gui/dist/assets/index-ComVsdSk.css +1 -0
  6. package/gui/dist/assets/index-vO4dgfiU.js +86 -0
  7. package/gui/dist/assets/{tray-data-BZXhB3qz.js → tray-data-C4FiNaxk.js} +1 -1
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -2
  10. package/src/adapters/anthropic/beta-allowlist.ts +80 -0
  11. package/src/adapters/anthropic/passthrough.ts +221 -0
  12. package/src/adapters/anthropic-image-codec.ts +56 -19
  13. package/src/adapters/anthropic-image-normalize.ts +20 -10
  14. package/src/adapters/anthropic.ts +55 -24
  15. package/src/adapters/coding-agent/protocol.ts +29 -19
  16. package/src/adapters/cursor/live-transport.ts +18 -3
  17. package/src/adapters/cursor/native-exec-shell.ts +18 -63
  18. package/src/adapters/cursor/native-exec.ts +5 -2
  19. package/src/adapters/cursor/native-foreground-shell.ts +44 -0
  20. package/src/adapters/devin/cloud-direct/chat.ts +7 -1
  21. package/src/adapters/exec-tool-result-normalize.ts +4 -1
  22. package/src/adapters/google.ts +12 -0
  23. package/src/adapters/ollama-native.ts +32 -1
  24. package/src/adapters/openai-chat/serialized-tool-call-content.ts +131 -34
  25. package/src/adapters/openai-chat.ts +6 -4
  26. package/src/adapters/openai-responses/passthrough.ts +1 -0
  27. package/src/adapters/openai-responses/reasoning.ts +45 -1
  28. package/src/bridge/response-json.ts +10 -2
  29. package/src/chat/outbound.ts +104 -52
  30. package/src/claude/claude-code-block.ts +16 -0
  31. package/src/claude/context-windows.ts +52 -4
  32. package/src/claude/desktop-first-party.ts +25 -8
  33. package/src/claude/inbound.ts +22 -0
  34. package/src/claude/intercept/connect-proxy.ts +37 -4
  35. package/src/claude/intercept/proxy-auth.ts +166 -0
  36. package/src/claude/intercept/runtime.ts +21 -0
  37. package/src/claude/intercept/settings.ts +33 -12
  38. package/src/claude/outbound.ts +45 -27
  39. package/src/cli/account-extended.ts +47 -15
  40. package/src/cli/account-target.ts +115 -0
  41. package/src/cli/account.ts +20 -28
  42. package/src/cli/agent.ts +10 -1
  43. package/src/cli/api-protocols.ts +206 -0
  44. package/src/cli/capabilities.ts +86 -0
  45. package/src/cli/combo.ts +2 -2
  46. package/src/cli/connect.ts +127 -3
  47. package/src/cli/dispatch.ts +8 -0
  48. package/src/cli/doctor.ts +33 -0
  49. package/src/cli/help.ts +2 -0
  50. package/src/cli/link.ts +288 -0
  51. package/src/cli/registry.ts +23 -0
  52. package/src/cli/runtime-api.ts +79 -0
  53. package/src/cli/system-command.ts +21 -2
  54. package/src/client/connect.ts +125 -23
  55. package/src/client/hub-relay.ts +12 -8
  56. package/src/client/link-join.ts +323 -0
  57. package/src/client/link-relay.ts +241 -0
  58. package/src/client/link-state.ts +110 -0
  59. package/src/client/link-teardown.ts +63 -0
  60. package/src/client/link-tunnel.ts +465 -0
  61. package/src/client/machine-listener.ts +14 -5
  62. package/src/client/runtime.ts +91 -42
  63. package/src/client/state.ts +4 -0
  64. package/src/codex/catalog/build-entries.ts +4 -0
  65. package/src/codex/catalog/derive-entry.ts +5 -0
  66. package/src/codex/catalog/parsing.ts +4 -0
  67. package/src/codex/catalog/retained-sync.ts +4 -1
  68. package/src/codex/desktop-switches.ts +88 -8
  69. package/src/codex/inject/plan.ts +16 -3
  70. package/src/codex/inject.ts +3 -0
  71. package/src/codex/internal/catalog-writer.ts +13 -2
  72. package/src/combos/failover.ts +3 -2
  73. package/src/combos/index.ts +12 -0
  74. package/src/combos/jev.ts +646 -0
  75. package/src/combos/request.ts +5 -0
  76. package/src/combos/resolve.ts +17 -15
  77. package/src/combos/types.ts +47 -3
  78. package/src/config/atomic-write.ts +3 -0
  79. package/src/config/live-reconcile.ts +20 -4
  80. package/src/config/persist-unlocked.ts +20 -7
  81. package/src/config/schema/config-schema.ts +16 -0
  82. package/src/config/schema/leaf-validators.ts +38 -1
  83. package/src/generated/compatibility-version.json +416 -128
  84. package/src/integrations/omp-yaml-source.ts +109 -4
  85. package/src/lab/automation/orchestrator.ts +60 -4
  86. package/src/lab/events/limits.ts +1 -1
  87. package/src/lib/bounded-body.ts +55 -38
  88. package/src/lib/config-ownership.ts +11 -22
  89. package/src/lib/local-desktop-snapshot-capability.ts +56 -0
  90. package/src/lib/local-management-capability.ts +25 -3
  91. package/src/lib/optional-shutdown-hooks.ts +17 -0
  92. package/src/lib/state-store-registrations.ts +2 -2
  93. package/src/lib/windows-elevation.ts +62 -25
  94. package/src/lib/windows-secret-acl.ts +114 -23
  95. package/src/link/admission-wait.ts +73 -0
  96. package/src/link/compensation.ts +100 -0
  97. package/src/link/fingerprint.ts +21 -0
  98. package/src/link/paths.ts +19 -0
  99. package/src/link/ports.ts +12 -0
  100. package/src/link/routes.ts +32 -0
  101. package/src/link/ssh-argv.ts +170 -0
  102. package/src/link/ssh-config.ts +152 -0
  103. package/src/link/ssh-runner.ts +161 -0
  104. package/src/link/status-projection.ts +96 -0
  105. package/src/link/store.ts +139 -0
  106. package/src/link/supervisor.ts +404 -0
  107. package/src/link/tunnel-state.ts +91 -0
  108. package/src/protocols/baseline.ts +88 -0
  109. package/src/protocols/codecs/chat.ts +17 -0
  110. package/src/protocols/codecs/messages.ts +18 -0
  111. package/src/protocols/codecs/responses.ts +17 -0
  112. package/src/protocols/contract.ts +185 -0
  113. package/src/protocols/dto.ts +267 -0
  114. package/src/protocols/encoders/adapter-events.ts +876 -0
  115. package/src/protocols/encoders/chat.ts +243 -0
  116. package/src/protocols/encoders/messages.ts +462 -0
  117. package/src/protocols/envelope.ts +43 -0
  118. package/src/protocols/features.ts +295 -0
  119. package/src/protocols/guard.ts +33 -0
  120. package/src/protocols/opaque-state.ts +132 -0
  121. package/src/protocols/path.ts +39 -0
  122. package/src/protocols/plan-snapshot.ts +226 -0
  123. package/src/protocols/plan.ts +192 -0
  124. package/src/protocols/provider-summary.ts +101 -0
  125. package/src/protocols/settings.ts +118 -0
  126. package/src/protocols/shadow-plan.ts +36 -0
  127. package/src/protocols/shadow.ts +62 -0
  128. package/src/protocols/trace.ts +335 -0
  129. package/src/providers/model-rename-migration.ts +3 -3
  130. package/src/providers/registry/entries-core.ts +6 -1
  131. package/src/providers/registry/entries-extended.ts +15 -0
  132. package/src/providers/registry/model-ids.ts +1 -0
  133. package/src/providers/registry/types.ts +6 -0
  134. package/src/providers/registry.ts +1 -1
  135. package/src/remote-control/workspace-hub.ts +5 -4
  136. package/src/responses/apply-patch-envelope.ts +6 -1
  137. package/src/responses/code-mode-shell-input.ts +4 -2
  138. package/src/responses/freeform-wrapper-scan.ts +51 -24
  139. package/src/responses/progressive-freeform-input.ts +14 -20
  140. package/src/responses/spill-store.ts +187 -33
  141. package/src/responses/state/snapshot-select.ts +49 -0
  142. package/src/responses/state/spill-inspect.ts +46 -0
  143. package/src/responses/state/spill-queue.ts +16 -0
  144. package/src/responses/state.ts +51 -27
  145. package/src/server/audio-client.ts +8 -3
  146. package/src/server/audio-upstream.ts +9 -4
  147. package/src/server/auth-cors.ts +25 -8
  148. package/src/server/chat-completions.ts +71 -11
  149. package/src/server/chat-native-eligibility.ts +74 -0
  150. package/src/server/chat-native.ts +139 -74
  151. package/src/server/claude-messages.ts +140 -22
  152. package/src/server/hub-usage.ts +4 -3
  153. package/src/server/index/link-listener.ts +195 -0
  154. package/src/server/index/optional-listeners.ts +115 -0
  155. package/src/server/index/serve-options.ts +44 -10
  156. package/src/server/index.ts +10 -9
  157. package/src/server/inference/attempt.ts +51 -0
  158. package/src/server/inference/client-encoder-delivery.ts +235 -0
  159. package/src/server/inference/client-wire-log.ts +49 -0
  160. package/src/server/inference/client-wire.ts +69 -0
  161. package/src/server/inference/context.ts +15 -0
  162. package/src/server/inference/final-log.ts +37 -0
  163. package/src/server/local-desktop-snapshot-auth.ts +57 -0
  164. package/src/server/management/agent-settings-routes.ts +15 -13
  165. package/src/server/management/api-access.ts +11 -1
  166. package/src/server/management/config-routes.ts +3 -6
  167. package/src/server/management/context.ts +25 -0
  168. package/src/server/management/link-routes.ts +535 -0
  169. package/src/server/management/logs-usage-routes.ts +31 -1
  170. package/src/server/management/native-integration-routes.ts +6 -11
  171. package/src/server/management/oauth-account-routes.ts +42 -16
  172. package/src/server/management/protocol-routes.ts +159 -0
  173. package/src/server/management/protocol-settings-patch.ts +145 -0
  174. package/src/server/management/provider-patch-transaction.ts +61 -0
  175. package/src/server/management/provider-routes.ts +39 -18
  176. package/src/server/management/route-registry.ts +12 -0
  177. package/src/server/management/sidebar-routes.ts +8 -3
  178. package/src/server/management/system-routes.ts +18 -1
  179. package/src/server/management/usage-aggregate-cache.ts +206 -6
  180. package/src/server/management-api.ts +26 -1
  181. package/src/server/management-auth.ts +19 -5
  182. package/src/server/messages-native-eligibility.ts +161 -0
  183. package/src/server/messages-native-oauth.ts +149 -0
  184. package/src/server/messages-native.ts +790 -0
  185. package/src/server/relay.ts +9 -0
  186. package/src/server/request-log-filter.ts +92 -0
  187. package/src/server/request-log.ts +31 -70
  188. package/src/server/responses/adapter-delivery.ts +43 -16
  189. package/src/server/responses/agent-task-recovery.ts +90 -22
  190. package/src/server/responses/codex-ws-exchange.ts +4 -0
  191. package/src/server/responses/codex-ws-wire.ts +10 -1
  192. package/src/server/responses/core-combo-native.ts +323 -0
  193. package/src/server/responses/core-combo.ts +224 -13
  194. package/src/server/responses/core-options.ts +22 -0
  195. package/src/server/responses/core.ts +14 -26
  196. package/src/server/responses/request-sidecar-auth.ts +8 -3
  197. package/src/server/responses/run-turn-execution.ts +257 -63
  198. package/src/server/responses/sidecar-execution.ts +7 -4
  199. package/src/server/system-env.ts +7 -0
  200. package/src/service/diagnostics.ts +7 -1
  201. package/src/service/launchd.ts +26 -37
  202. package/src/service/windows-ops.ts +24 -20
  203. package/src/types/config.ts +58 -1
  204. package/src/types.ts +2 -0
  205. package/src/update/transactional-install.d.mts +0 -1
  206. package/src/update/transactional-install.mjs +17 -17
  207. package/src/usage/jev-stats.ts +495 -0
  208. package/src/usage/log.ts +24 -3
  209. package/src/web-search/run-turn-loop.ts +568 -0
  210. package/gui/dist/assets/App-8NMiZxT0.css +0 -1
  211. package/gui/dist/assets/App-DGLte6IR.js +0 -50
  212. package/gui/dist/assets/index--EWgGQvZ.css +0 -1
  213. package/gui/dist/assets/index-Bi1K37Wl.js +0 -86
@@ -19,7 +19,8 @@
19
19
  // `raw` no wrapper can apply, because the text is not an object or because it is one that
20
20
  // `JSON.parse` will reject. Completion returns the buffer, so streaming it agrees.
21
21
  // `hold` undecided. A key that has not arrived yet can still change the answer, so nothing
22
- // is published until the object parses and completion's own rule decides.
22
+ // is published until the object closes — where the scan itself already holds the
23
+ // member table completion's own rule consults, so no parse of the buffer is needed.
23
24
  //
24
25
  // The bound matters as much as the classification. A scan that walks the whole buffer on every
25
26
  // delta is quadratic in the argument size, so classification gives up after
@@ -53,7 +54,7 @@ const HOLD = -1;
53
54
  const NEVER = -2;
54
55
 
55
56
  export type FreeformWrapperScan =
56
- | { kind: "hold"; parse: boolean }
57
+ | { kind: "hold" }
57
58
  | { kind: "raw" }
58
59
  | { kind: "input"; valueStart: number };
59
60
 
@@ -192,38 +193,36 @@ function scanValue(text: string, from: number): number {
192
193
  /**
193
194
  * Which wrapper the completed text will unwrap to, as far as this prefix can say.
194
195
  *
195
- * Fallback keys are deliberately not recognized here. They only unwrap when exactly one of them
196
- * carries a string, and a second one can still arrive, so no prefix decides them — which makes
197
- * them indistinguishable from any other undecided object and lets one HOLD cover both.
196
+ * `fallbackKeys` is the tool's alternate-field vocabulary — `freeformFallbackKeys`, the same
197
+ * list `unwrapFreeformToolInput` filters against. No open prefix can decide a fallback key,
198
+ * because it unwraps only as the SINGLE string field and a second one can still arrive; so the
199
+ * scan keeps a table of the members it has already walked and consults it once, at the close.
198
200
  */
199
- export function scanFreeformWrapper(text: string): FreeformWrapperScan {
201
+ export function scanFreeformWrapper(text: string, fallbackKeys: readonly string[]): FreeformWrapperScan {
200
202
  // One clamp rather than a budget threaded through every helper. Every helper already holds
201
203
  // when it runs off the end of what it can see, so a buffer whose classification needs more
202
204
  // than this holds for exactly the right reason, and no scan can cost more than this many
203
205
  // characters however large the arguments grow. Indices into the clamp are indices into the
204
206
  // full text, because the clamp is a prefix of it.
205
- const bounded = text.length > MAX_FREEFORM_WRAPPER_SCAN_CHARS
206
- ? text.slice(0, MAX_FREEFORM_WRAPPER_SCAN_CHARS)
207
- : text;
208
- // `parse` is true only where this scan actually SAW the object close. Every other hold ran
209
- // out of buffer or out of budget, and in both cases asking `JSON.parse` is work with no
210
- // possible payoff: the first is provably incomplete, and the second would re-read a growing
211
- // buffer on every delta that happens to end in a brace — repeated braces inside a long
212
- // unterminated string are enough to make that quadratic. Holding a budget-exhausted prefix
213
- // costs nothing that matters, because the value it would release arrives in the same instant
214
- // as the authoritative completion that follows it.
215
- const hold = (): FreeformWrapperScan => ({ kind: "hold", parse: false });
207
+ const wholeText = text.length <= MAX_FREEFORM_WRAPPER_SCAN_CHARS;
208
+ const bounded = wholeText ? text : text.slice(0, MAX_FREEFORM_WRAPPER_SCAN_CHARS);
209
+ const hold = (): FreeformWrapperScan => ({ kind: "hold" });
216
210
  const open = skipWhitespace(bounded, 0);
217
211
  if (open === HOLD) return hold();
218
212
  // Not an object, so no wrapper rule reaches it: arrays, scalars and ordinary bodies all
219
213
  // complete as themselves.
220
214
  if (bounded[open] !== "{") return { kind: "raw" };
221
215
 
216
+ // Every top-level member is recorded as the scan passes it, last occurrence winning — the
217
+ // keep-last rule `JSON.parse` applies to duplicate keys. When the object closes, this table
218
+ // answers the only question completion asks of it — which members carried strings — so the
219
+ // closed object classifies directly instead of reparsing a buffer the scan just walked.
220
+ const members = new Map<string, { stringValue: boolean; valueStart: number }>();
222
221
  let i = open + 1;
223
222
  for (;;) {
224
223
  const at = skipWhitespace(bounded, i);
225
224
  if (at === HOLD) return hold();
226
- if (bounded[at] === "}") return afterTopLevelClose(bounded, at + 1);
225
+ if (bounded[at] === "}") return afterTopLevelClose(bounded, at + 1, members, fallbackKeys, wholeText);
227
226
  if (bounded[at] !== '"') return { kind: "raw" };
228
227
 
229
228
  const nameEnd = scanString(bounded, at);
@@ -254,6 +253,7 @@ export function scanFreeformWrapper(text: string): FreeformWrapperScan {
254
253
  : { kind: "raw" };
255
254
  }
256
255
 
256
+ members.set(name, { stringValue: bounded[valueAt] === '"', valueStart: valueAt + 1 });
257
257
  const valueEnd = scanValue(bounded, valueAt);
258
258
  if (valueEnd === HOLD) return hold();
259
259
  if (valueEnd === NEVER) return { kind: "raw" };
@@ -261,19 +261,46 @@ export function scanFreeformWrapper(text: string): FreeformWrapperScan {
261
261
  const next = skipWhitespace(bounded, valueEnd);
262
262
  if (next === HOLD) return hold();
263
263
  if (bounded[next] === ",") {
264
- i = next + 1;
264
+ // A comma promises another member, so the next non-whitespace character must open a
265
+ // member name. `{"code":"cmd",}` is not a completed object — `JSON.parse` rejects the
266
+ // trailing comma — and treating its `}` as the close would unwrap a preview completion
267
+ // hands back unchanged. Nested members already reject this through `scanMemberKey`;
268
+ // the top level has to ask the same question itself.
269
+ const member = skipWhitespace(bounded, next + 1);
270
+ if (member === HOLD) return hold();
271
+ if (bounded[member] !== '"') return { kind: "raw" };
272
+ i = member;
265
273
  continue;
266
274
  }
267
- if (bounded[next] === "}") return afterTopLevelClose(bounded, next + 1);
275
+ if (bounded[next] === "}") return afterTopLevelClose(bounded, next + 1, members, fallbackKeys, wholeText);
268
276
  return { kind: "raw" };
269
277
  }
270
278
  }
271
279
 
272
280
  /**
273
281
  * The object closed without a canonical key. Only whitespace may follow one that parses, so
274
- * anything else makes the text raw; otherwise the completed parse decides between a fallback
275
- * wrapper and no wrapper at all.
282
+ * anything else makes the text raw. Whitespace to the end IS legal, but the member table
283
+ * already says which way completion goes on it: exactly one string-valued fallback field is
284
+ * the wrapper's value, and any other member shape streams the raw text — byte-exact through
285
+ * whatever trailing whitespace arrived, where a reparsed growing buffer was quadratic work on
286
+ * every whitespace delta and a held one swallowed the whitespace bytes entirely.
276
287
  */
277
- function afterTopLevelClose(text: string, from: number): FreeformWrapperScan {
278
- return skipWhitespace(text, from) === HOLD ? { kind: "hold", parse: true } : { kind: "raw" };
288
+ function afterTopLevelClose(
289
+ text: string,
290
+ from: number,
291
+ members: ReadonlyMap<string, { stringValue: boolean; valueStart: number }>,
292
+ fallbackKeys: readonly string[],
293
+ wholeText: boolean,
294
+ ): FreeformWrapperScan {
295
+ const trailing = skipWhitespace(text, from);
296
+ if (trailing !== HOLD) return { kind: "raw" };
297
+ // A clamped prefix ends inside this whitespace, so what follows is unseen: hold rather than
298
+ // guess at a tail that could still invalidate the close. When the whole text fit, the object
299
+ // plus its trailing whitespace is complete — and its members decide it without a parse.
300
+ if (!wholeText) return { kind: "hold" };
301
+ const candidates = fallbackKeys.filter(key => members.get(key)?.stringValue === true);
302
+ if (candidates.length === 1) {
303
+ return { kind: "input", valueStart: members.get(candidates[0]!)!.valueStart };
304
+ }
305
+ return { kind: "raw" };
279
306
  }
@@ -1,4 +1,4 @@
1
- import { unwrapFreeformToolInput } from "./apply-patch-envelope";
1
+ import { freeformFallbackKeys } from "./apply-patch-envelope";
2
2
  import { JSON_ESCAPES, scanFreeformWrapper } from "./freeform-wrapper-scan";
3
3
 
4
4
  /**
@@ -92,11 +92,12 @@ function decodeJsonStringPrefix(body: string): string | null {
92
92
  * damage is what is available without giving up progressive streaming, and the args are
93
93
  * unusable in that case whichever representation wins.
94
94
  *
95
- * A fallback key is not decidable. It only unwraps when it is the SINGLE string field, and a
96
- * second key can still arrive — so a value emitted early would have to be taken back. That is
97
- * the rewind this holds instead: stream nothing until the object closes, then publish the one
98
- * repaired body. The routed passthrough in `responses-custom-tool-repair.ts` already holds
99
- * any object prefix for the same reason (#5047).
95
+ * A fallback key decides only at the object's close. It unwraps as the SINGLE string field,
96
+ * and a second key can still arrive while the object is open — so a value emitted early would
97
+ * have to be taken back. The scan therefore keeps a member table and consults it at the
98
+ * close, publishing the wrapped value (or the raw text) without ever reparsing the buffer.
99
+ * The routed passthrough in `responses-custom-tool-repair.ts` already holds any object prefix
100
+ * for the same reason (#5047).
100
101
  *
101
102
  * An object that has not reached a canonical key YET is in exactly that position, and used to
102
103
  * be treated as raw because it did not match the literal `{"input":"`. It holds now: `input`
@@ -106,7 +107,7 @@ function decodeJsonStringPrefix(body: string): string | null {
106
107
  * not holding was publishing bytes the completed item removes.
107
108
  */
108
109
  export function progressiveFreeformInput(args: string, toolName: string): string | null {
109
- const scan = scanFreeformWrapper(args);
110
+ const scan = scanFreeformWrapper(args, freeformFallbackKeys(toolName));
110
111
  if (scan.kind === "input") {
111
112
  const decoded = decodeJsonStringPrefix(args.slice(scan.valueStart));
112
113
  if (decoded === null) return null;
@@ -114,17 +115,10 @@ export function progressiveFreeformInput(args: string, toolName: string): string
114
115
  }
115
116
  if (scan.kind === "raw") return mayBecomeFencedBody(args, toolName) ? null : args;
116
117
 
117
- // Undecided: some wrapper may still apply, and only the completed object says which one.
118
- // The parse runs exactly where the scan SAW the object close, so it reads a buffer the scan
119
- // already walked and its cost is bounded by the same clamp. A hold from either limit stays
120
- // held: an incomplete object has nothing to parse, and re-reading a budget-exhausted buffer
121
- // on every delta is quadratic work for a delta that would arrive in the same instant as the
122
- // authoritative completion behind it.
123
- if (!scan.parse) return null;
124
- try {
125
- JSON.parse(args);
126
- } catch {
127
- return null;
128
- }
129
- return unwrapFreeformToolInput(args, toolName);
118
+ // Undecided: an incomplete object, or a close whose tail is clamped out of view. Both stay
119
+ // held — the first is provably incomplete, and re-walking a budget-exhausted buffer on every
120
+ // delta is work for a preview that arrives in the same instant as the authoritative
121
+ // completion behind it. A closed object the scan could see whole never reaches here: its
122
+ // member table already resolved it to `input` or `raw`.
123
+ return null;
130
124
  }
@@ -13,6 +13,7 @@ import {
13
13
  readFileSync,
14
14
  unlinkSync,
15
15
  writeSync,
16
+ type Stats,
16
17
  } from "node:fs";
17
18
  import { createHash, randomBytes } from "node:crypto";
18
19
  import { dirname, join } from "node:path";
@@ -34,6 +35,20 @@ export const RESPONSE_SPILL_DIR_NAME = "responses-state-spill";
34
35
  export const RESPONSE_SPILL_ORPHAN_GRACE_MS = 15 * 60_000;
35
36
  export const RESPONSE_SPILL_SCAN_MAX = 4_096;
36
37
  export const RESPONSE_SPILL_CLEANUP_MAX = 512;
38
+ // The liveness-tick sweep runs synchronously on the serving event loop every
39
+ // 60 s, so it gets a tighter bound than the startup pass that blocks one call.
40
+ export const PERIODIC_SPILL_SWEEP_OPTS = {
41
+ scanMax: 512,
42
+ cleanupMax: 64,
43
+ deadlineMs: 25,
44
+ } as const;
45
+
46
+ /** Open directory iterator the next liveness-tick sweep resumes from; null
47
+ * after a full pass, an iteration error, or a directory change. Holding the
48
+ * real iterator (instead of an offset re-skipped from a fresh opendir) keeps
49
+ * each tick's cost bounded by its own budget, so a slow enumeration cannot
50
+ * pin the cursor at the same prefix forever. */
51
+ let periodicSweepCursor: { dir: string; scan: SpillDirScan } | null = null;
37
52
 
38
53
  const RESPONSE_SPILL_PUBLISH_RETRIES = 64;
39
54
  const OWNED_SPILL_NAME = /^([A-Za-z0-9._-]{1,80})\.([0-9a-f]{12})\.([0-9a-f]{24})\.(\d+)\.(\d+)\.spill\.json$/;
@@ -95,6 +110,8 @@ export interface ResponseSpillCleanupResult {
95
110
  removed: number;
96
111
  failed: number;
97
112
  bytesRemoved: number;
113
+ /** The scan reached the end of the directory. */
114
+ exhausted?: boolean;
98
115
  }
99
116
 
100
117
  export interface ResponseSpillIoForTest {
@@ -750,53 +767,153 @@ export function deleteResponseSpill(ref: ResponseSpillRef): void {
750
767
  } catch { /* best effort */ }
751
768
  }
752
769
 
753
- export function recoverOrphanedResponseSpills(
754
- referencedFileNames: ReadonlySet<string>,
755
- dir = responseSpillDirectory(),
756
- opts?: { graceMs?: number },
757
- ): ResponseSpillCleanupResult {
758
- const result: ResponseSpillCleanupResult = { scanned: 0, removed: 0, failed: 0, bytesRemoved: 0 };
759
- const graceMs = opts?.graceMs ?? RESPONSE_SPILL_ORPHAN_GRACE_MS;
760
- // ONE loop serves both the real directory handle and the injected test seam
761
- // (review C2-2: two duplicated loops let the test prove only its own copy).
762
- // The reader is called strictly AFTER the scan-cap check, so entry
763
- // SCAN_MAX+1 is never requested from either source.
770
+ type SpillDirNameKind = "spill" | "temp";
771
+
772
+ /** Directory-iteration plumbing shared by reclaim and the dry-run inspector.
773
+ * ONE loop serves both the real directory handle and the injected test seam
774
+ * (review C2-2: two duplicated loops let the test prove only its own copy).
775
+ * Callers must request the next name strictly AFTER their scan-cap check, so
776
+ * entry SCAN_MAX+1 is never requested from either source. */
777
+ interface SpillDirScan { nextName: () => string | null; close: () => void }
778
+
779
+ function openSpillDirScan(dir: string): SpillDirScan | null {
764
780
  let handle: ReturnType<typeof opendirSync> | null = null;
765
781
  const injected = spillIoForTest?.readdirEntry;
766
782
  if (!injected) {
767
- try { handle = opendirSync(dir); } catch { return result; }
783
+ try { handle = opendirSync(dir); } catch { return null; }
768
784
  }
769
- const nextName = (): string | null => {
770
- if (injected) return injected();
771
- const entry = handle!.readSync();
772
- return entry ? entry.name : null;
785
+ return {
786
+ nextName: () => {
787
+ if (injected) return injected();
788
+ const entry = handle!.readSync();
789
+ return entry ? entry.name : null;
790
+ },
791
+ close: () => { try { handle?.closeSync(); } catch { /* best effort */ } },
792
+ };
793
+ }
794
+
795
+ /** Orphan predicate, name half: a spill-shaped name nobody references. Shared by
796
+ * reclaim and the dry-run inspector so the report and the unlink cannot disagree. */
797
+ function orphanSpillNameKind(name: string, referencedFileNames: ReadonlySet<string>): SpillDirNameKind | null {
798
+ if (referencedFileNames.has(name)) return null;
799
+ if (OWNED_SPILL_NAME.test(name)) return "spill";
800
+ if (OWNED_SPILL_TEMP_NAME.test(name)) return "temp";
801
+ return null;
802
+ }
803
+
804
+ /** Orphan predicate, stat half: a regular file (never a symlink) past the grace window. */
805
+ function spillEntryPastGrace(stat: Stats, graceMs: number): boolean {
806
+ return stat.isFile() && !stat.isSymbolicLink() && Date.now() - stat.mtimeMs >= graceMs;
807
+ }
808
+
809
+ export interface ResponseSpillDirInspection {
810
+ scanned: number;
811
+ /** The walk stopped at RESPONSE_SPILL_SCAN_MAX, so every count describes a
812
+ * prefix of the directory and the real totals are higher. */
813
+ truncated: boolean;
814
+ /** Every regular file present, spill-shaped or not — the honest disk total. */
815
+ files: number;
816
+ bytes: number;
817
+ /** Regular files named in the caller's referenced set. */
818
+ ownedFiles: number;
819
+ ownedBytes: number;
820
+ /** Spill/temp-shaped regular files unreferenced and past the orphan grace. */
821
+ orphanFiles: number;
822
+ orphanBytes: number;
823
+ }
824
+
825
+ /**
826
+ * Dry-run counterpart of recoverOrphanedResponseSpills: same directory walk and
827
+ * the same orphan predicate, but it counts and never unlinks. It stats every
828
+ * entry (bounded by RESPONSE_SPILL_SCAN_MAX) because `bytes` is the disk total
829
+ * an operator sizes the directory by — reclaim only stats orphan candidates.
830
+ */
831
+ export function inspectResponseSpillDir(
832
+ referencedFileNames: ReadonlySet<string>,
833
+ dir = responseSpillDirectory(),
834
+ opts?: { graceMs?: number },
835
+ ): ResponseSpillDirInspection {
836
+ const result: ResponseSpillDirInspection = {
837
+ scanned: 0, truncated: false, files: 0, bytes: 0,
838
+ ownedFiles: 0, ownedBytes: 0, orphanFiles: 0, orphanBytes: 0,
773
839
  };
840
+ const graceMs = opts?.graceMs ?? RESPONSE_SPILL_ORPHAN_GRACE_MS;
841
+ const scan = openSpillDirScan(dir);
842
+ if (!scan) return result;
774
843
  try {
775
844
  while (result.scanned < RESPONSE_SPILL_SCAN_MAX) {
776
- const name = nextName();
845
+ const name = scan.nextName();
777
846
  if (name === null) break;
778
847
  result.scanned += 1;
779
- if (result.removed + result.failed >= RESPONSE_SPILL_CLEANUP_MAX) break;
780
- const spillMatch = OWNED_SPILL_NAME.exec(name);
781
- const isOwnedTemp = OWNED_SPILL_TEMP_NAME.test(name);
782
- if ((!spillMatch && !isOwnedTemp) || referencedFileNames.has(name)) continue;
783
848
  const path = join(dir, name);
784
- let stat: ReturnType<typeof lstatSync>;
849
+ let stat: Stats;
785
850
  try { stat = lstatSync(path); } catch { continue; }
786
- if (!stat.isFile() || stat.isSymbolicLink() || Date.now() - stat.mtimeMs < graceMs) continue;
787
- try {
788
- // Orphaned publish temps get the full ephemeral release; stable
789
- // orphaned spills keep destination-keyed timeout memos.
790
- if (isOwnedTemp) unlinkEphemeral(path);
791
- else unlink(path);
792
- result.removed += 1;
793
- result.bytesRemoved += stat.size;
794
- } catch {
795
- result.failed += 1;
851
+ if (!stat.isFile() || stat.isSymbolicLink()) continue;
852
+ result.files += 1;
853
+ result.bytes += stat.size;
854
+ if (referencedFileNames.has(name)) {
855
+ result.ownedFiles += 1;
856
+ result.ownedBytes += stat.size;
857
+ } else if (orphanSpillNameKind(name, referencedFileNames) !== null && spillEntryPastGrace(stat, graceMs)) {
858
+ result.orphanFiles += 1;
859
+ result.orphanBytes += stat.size;
796
860
  }
797
861
  }
798
862
  } finally {
799
- try { handle?.closeSync(); } catch { /* best effort */ }
863
+ scan.close();
864
+ }
865
+ result.truncated = result.scanned >= RESPONSE_SPILL_SCAN_MAX;
866
+ return result;
867
+ }
868
+
869
+ export function recoverOrphanedResponseSpills(
870
+ referencedFileNames: ReadonlySet<string>,
871
+ dir = responseSpillDirectory(),
872
+ opts?: { graceMs?: number; scanMax?: number; cleanupMax?: number; deadlineMs?: number },
873
+ ): ResponseSpillCleanupResult {
874
+ const scan = openSpillDirScan(dir);
875
+ if (!scan) return { scanned: 0, removed: 0, failed: 0, bytesRemoved: 0 };
876
+ try {
877
+ return reclaimFromSpillDirScan(scan, referencedFileNames, dir, opts);
878
+ } finally {
879
+ scan.close();
880
+ }
881
+ }
882
+
883
+ /** Reclaim loop over an already-open scan. It never reads a name it will not
884
+ * process, so a caller that keeps the scan open resumes at the first entry the
885
+ * previous call left untouched. Throws only if the iterator itself throws. */
886
+ function reclaimFromSpillDirScan(
887
+ scan: SpillDirScan,
888
+ referencedFileNames: ReadonlySet<string>,
889
+ dir: string,
890
+ opts?: { graceMs?: number; scanMax?: number; cleanupMax?: number; deadlineMs?: number },
891
+ ): ResponseSpillCleanupResult {
892
+ const result: ResponseSpillCleanupResult = { scanned: 0, removed: 0, failed: 0, bytesRemoved: 0 };
893
+ const graceMs = opts?.graceMs ?? RESPONSE_SPILL_ORPHAN_GRACE_MS;
894
+ const scanMax = opts?.scanMax ?? RESPONSE_SPILL_SCAN_MAX;
895
+ const cleanupMax = opts?.cleanupMax ?? RESPONSE_SPILL_CLEANUP_MAX;
896
+ const deadline = opts?.deadlineMs === undefined ? Infinity : Date.now() + opts.deadlineMs;
897
+ while (result.scanned < scanMax && result.removed + result.failed < cleanupMax && Date.now() < deadline) {
898
+ const name = scan.nextName();
899
+ if (name === null) { result.exhausted = true; break; }
900
+ result.scanned += 1;
901
+ const orphanKind = orphanSpillNameKind(name, referencedFileNames);
902
+ if (orphanKind === null) continue;
903
+ const path = join(dir, name);
904
+ let stat: Stats;
905
+ try { stat = lstatSync(path); } catch { continue; }
906
+ if (!spillEntryPastGrace(stat, graceMs)) continue;
907
+ try {
908
+ // Orphaned publish temps get the full ephemeral release; stable
909
+ // orphaned spills keep destination-keyed timeout memos.
910
+ if (orphanKind === "temp") unlinkEphemeral(path);
911
+ else unlink(path);
912
+ result.removed += 1;
913
+ result.bytesRemoved += stat.size;
914
+ } catch {
915
+ result.failed += 1;
916
+ }
800
917
  }
801
918
  return result;
802
919
  }
@@ -804,3 +921,40 @@ export function recoverOrphanedResponseSpills(
804
921
  export function responseSpillExistsForTests(ref: ResponseSpillRef): boolean {
805
922
  return validSpillRef(ref) && existsSync(join(responseSpillDirectory(), ref.fileName));
806
923
  }
924
+
925
+ /**
926
+ * Bounded liveness-tick reclaim that resumes where the previous tick stopped,
927
+ * so orphans behind the first `scanMax` owned entries are still reached. The
928
+ * directory iterator stays open between ticks; it is dropped at end of
929
+ * directory, on an iteration error, or when the spill directory changes.
930
+ */
931
+ export function sweepOrphanedResponseSpillsPeriodically(
932
+ referencedFileNames: ReadonlySet<string>,
933
+ dir = responseSpillDirectory(),
934
+ ): ResponseSpillCleanupResult {
935
+ if (periodicSweepCursor && periodicSweepCursor.dir !== dir) closePeriodicSweepCursor();
936
+ if (!periodicSweepCursor) {
937
+ const scan = openSpillDirScan(dir);
938
+ if (!scan) return { scanned: 0, removed: 0, failed: 0, bytesRemoved: 0 };
939
+ periodicSweepCursor = { dir, scan };
940
+ }
941
+ let result: ResponseSpillCleanupResult;
942
+ try {
943
+ result = reclaimFromSpillDirScan(periodicSweepCursor.scan, referencedFileNames, dir, PERIODIC_SPILL_SWEEP_OPTS);
944
+ } catch {
945
+ closePeriodicSweepCursor();
946
+ return { scanned: 0, removed: 0, failed: 0, bytesRemoved: 0 };
947
+ }
948
+ // A full pass restarts from a fresh listing so files created mid-pass are seen.
949
+ if (result.exhausted) closePeriodicSweepCursor();
950
+ return result;
951
+ }
952
+
953
+ function closePeriodicSweepCursor(): void {
954
+ periodicSweepCursor?.scan.close();
955
+ periodicSweepCursor = null;
956
+ }
957
+
958
+ export function resetPeriodicSpillSweepCursorForTests(): void {
959
+ closePeriodicSweepCursor();
960
+ }
@@ -0,0 +1,49 @@
1
+ import type { StoredResponseState } from "../state";
2
+
3
+ /**
4
+ * Pick the snapshot entries that fit the byte budgets, in `states` order.
5
+ *
6
+ * Bounded stubs and tombstones are selected before residents: they are the
7
+ * only durable references a spill file has, and demotion is oldest-first, so a
8
+ * single newest-first pass would let resident payloads consume the whole
9
+ * budget ahead of them. Residents then fill what remains, newest-first so the
10
+ * most recent chains survive both legacy snapshot caps.
11
+ */
12
+ export function selectSnapshotEntries(
13
+ states: ReadonlyMap<string, StoredResponseState>,
14
+ totalMaxBytes: number,
15
+ residentEntryMaxBytes: number,
16
+ ): Array<[string, unknown]> {
17
+ const ordered = [...states].reverse();
18
+ const persisted = new Map<string, [string, unknown]>();
19
+ let total = 0;
20
+ // UTF-8 bytes, not UTF-16 code units: multibyte items otherwise slip past
21
+ // both snapshot caps at up to 2x the intended size.
22
+ const sizeOf = (entry: [string, unknown]): number => Buffer.byteLength(JSON.stringify(entry), "utf8");
23
+ for (const [id, state] of ordered) {
24
+ if (state.kind === "resident") continue;
25
+ const { sizeBytes: _sizeBytes, ...smallState } = state;
26
+ const entry: [string, unknown] = [id, smallState];
27
+ const size = sizeOf(entry);
28
+ if (total + size > totalMaxBytes) continue;
29
+ total += size;
30
+ persisted.set(id, entry);
31
+ }
32
+ for (const [id, state] of ordered) {
33
+ if (state.kind !== "resident") continue;
34
+ const { sizeBytes: _sizeBytes, kind: _kind, ...resident } = state;
35
+ const entry: [string, unknown] = [id, resident];
36
+ const size = sizeOf(entry);
37
+ if (size > residentEntryMaxBytes) continue;
38
+ if (total + size > totalMaxBytes) break;
39
+ total += size;
40
+ persisted.set(id, entry);
41
+ }
42
+ // Emit in map order so reload and count eviction keep the same relative order as `states`.
43
+ const entries: Array<[string, unknown]> = [];
44
+ for (const [id] of states) {
45
+ const kept = persisted.get(id);
46
+ if (kept) entries.push(kept);
47
+ }
48
+ return entries;
49
+ }
@@ -0,0 +1,46 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import type { ResponseSpillRef } from "../spill-store";
3
+ import type { StoredResponseState } from "../state";
4
+
5
+ /**
6
+ * File names still owned by the in-memory store: live "spill" stubs plus
7
+ * superseded generations queued for unlink only after a durable snapshot
8
+ * (pendingSpillUnlinks — see replaceSpillEntryAtomically in state.ts).
9
+ */
10
+ export function collectReferencedSpillFileNames(
11
+ states: Iterable<StoredResponseState>,
12
+ pendingUnlinks: Iterable<ResponseSpillRef>,
13
+ ): Set<string> {
14
+ const referenced = new Set<string>();
15
+ for (const state of states) {
16
+ if (state.kind === "spill") referenced.add(state.spill.fileName);
17
+ }
18
+ for (const ref of pendingUnlinks) referenced.add(ref.fileName);
19
+ return referenced;
20
+ }
21
+
22
+ /**
23
+ * File names a restart would re-own: "spill" stubs inside the persisted
24
+ * snapshot. This is the only ownership evidence a separate process (ocx
25
+ * doctor) can see, and it stays authoritative in-process too — a restart
26
+ * reloads the snapshot before the store drops anything from it.
27
+ */
28
+ export function snapshotReferencedSpillFileNames(path: string, maxBytes: number): Set<string> {
29
+ const referenced = new Set<string>();
30
+ try {
31
+ const stat = statSync(path);
32
+ if (!stat.isFile() || stat.size > maxBytes) return referenced;
33
+ const raw = JSON.parse(readFileSync(path, "utf-8")) as { version?: unknown; states?: unknown };
34
+ if ((raw.version !== 1 && raw.version !== 2) || !Array.isArray(raw.states)) return referenced;
35
+ for (const entry of raw.states) {
36
+ if (!Array.isArray(entry) || entry.length !== 2) continue;
37
+ const value = entry[1] as { kind?: unknown; spill?: { fileName?: unknown } };
38
+ if (value?.kind === "spill" && typeof value.spill?.fileName === "string") {
39
+ referenced.add(value.spill.fileName);
40
+ }
41
+ }
42
+ } catch {
43
+ /* missing or corrupt snapshot: nothing is referenced */
44
+ }
45
+ return referenced;
46
+ }
@@ -1,4 +1,5 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { basename } from "node:path";
2
3
  import {
3
4
  cleanupSupersededResponseSpillPublication,
4
5
  createResponseSpillPublicationControl,
@@ -658,6 +659,21 @@ export function spillQueueSupersededSpillFor(id: string): ResponseSpillRef | und
658
659
  return pendingResponseSpillById.get(id)?.supersededSpill;
659
660
  }
660
661
 
662
+ /**
663
+ * File names an orphan sweep must keep: each queued job's superseded generation,
664
+ * its in-flight temp, and its destination — all still owned but not yet named by
665
+ * `states` or `pendingSpillUnlinks`.
666
+ */
667
+ export function spillQueueReferencedSpillFileNames(): Set<string> {
668
+ const names = new Set<string>();
669
+ for (const job of pendingResponseSpills) {
670
+ if (job.supersededSpill) names.add(job.supersededSpill.fileName);
671
+ if (job.publicationControl.tempPath) names.add(basename(job.publicationControl.tempPath));
672
+ if (job.publicationControl.destinationPath) names.add(basename(job.publicationControl.destinationPath));
673
+ }
674
+ return names;
675
+ }
676
+
661
677
  /** Test-only: release queued jobs and zero the queue-owned byte accounting. */
662
678
  export function resetSpillQueueForTests(): void {
663
679
  for (const id of [...pendingResponseSpillById.keys()]) cancelPendingResponseSpill(id);