@bitkyc08/opencodex 2.76.0 → 2.77.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 (189) hide show
  1. package/gui/dist/assets/App-COdEeqSj.css +1 -0
  2. package/gui/dist/assets/App-DEpCJEP_.js +54 -0
  3. package/gui/dist/assets/{Tray-DjNwwrWS.js → Tray-gloIYNaW.js} +1 -1
  4. package/gui/dist/assets/index-Cd7ittMv.css +1 -0
  5. package/gui/dist/assets/index-DwJKFJm5.js +94 -0
  6. package/gui/dist/assets/quota-summary-DGcEJU3h.js +1 -0
  7. package/gui/dist/index.html +4 -3
  8. package/package.json +1 -1
  9. package/src/adapters/antigravity-refusal.ts +28 -0
  10. package/src/adapters/cursor/protobuf-request.ts +6 -0
  11. package/src/adapters/cursor/request-builder.ts +3 -0
  12. package/src/adapters/cursor/structured-output.ts +13 -0
  13. package/src/adapters/cursor/types.ts +2 -0
  14. package/src/adapters/ollama-native.ts +29 -2
  15. package/src/adapters/openai-responses/passthrough.ts +133 -103
  16. package/src/adapters/openai-responses/request-strips.ts +10 -4
  17. package/src/adapters/zed.ts +26 -4
  18. package/src/bridge/response-json.ts +14 -14
  19. package/src/bridge/sse.ts +15 -5
  20. package/src/chatgpt/app-server-shim/bundle-trust.ts +40 -10
  21. package/src/chatgpt/app-server-shim/gate-rewrite.ts +3 -0
  22. package/src/claude/agents-inject.ts +4 -43
  23. package/src/claude/gateway-cache.ts +16 -13
  24. package/src/claude/inbound-model-options.ts +9 -2
  25. package/src/claude/intercept/cli-catalog.ts +27 -16
  26. package/src/claude/intercept/picker-bootstrap.ts +37 -16
  27. package/src/claude/intercept/picker-budget.ts +41 -0
  28. package/src/claude/outbound.ts +2 -0
  29. package/src/claude/subagent-force-status.ts +53 -0
  30. package/src/claude/subagent-model.ts +93 -0
  31. package/src/cli/account-api.ts +3 -0
  32. package/src/cli/account.ts +3 -1
  33. package/src/cli/agent.ts +10 -0
  34. package/src/cli/capabilities.ts +9 -0
  35. package/src/cli/chatgpt-command.ts +10 -7
  36. package/src/cli/claude.ts +22 -6
  37. package/src/cli/codex-shim-readiness.ts +89 -0
  38. package/src/cli/connect.ts +19 -0
  39. package/src/cli/dispatch.ts +3 -3
  40. package/src/cli/export-command.ts +20 -9
  41. package/src/cli/gui.ts +8 -3
  42. package/src/cli/help-catalog.ts +84 -0
  43. package/src/cli/help-models-context.ts +17 -0
  44. package/src/cli/help-navigation.ts +50 -0
  45. package/src/cli/help-recovery.ts +66 -0
  46. package/src/cli/help.ts +52 -8
  47. package/src/cli/models-runtime.ts +2 -1
  48. package/src/cli/opencode.ts +54 -10
  49. package/src/cli/provider.ts +3 -29
  50. package/src/cli/registry.ts +29 -5
  51. package/src/cli/root.ts +30 -9
  52. package/src/cli/runtime-api.ts +1 -1
  53. package/src/cli/tray-proxy.ts +3 -2
  54. package/src/clients/config-export/contracts.ts +100 -0
  55. package/src/clients/config-export/droid.ts +54 -2
  56. package/src/clients/config-export/kilo.ts +12 -2
  57. package/src/clients/config-export/model-metadata.ts +10 -4
  58. package/src/clients/config-export/reasoning-metadata.ts +47 -0
  59. package/src/clients/config-export.ts +46 -66
  60. package/src/clients/omo-role-models.ts +17 -3
  61. package/src/codex/account-credit-use.ts +4 -2
  62. package/src/codex/account-runtime-state.ts +21 -0
  63. package/src/codex/account-store.ts +11 -47
  64. package/src/codex/account-usability.ts +11 -8
  65. package/src/codex/auth-api/account-list.ts +73 -63
  66. package/src/codex/auth-api/main-account-probe.ts +25 -12
  67. package/src/codex/auth-api/pool-mode-gate.ts +6 -9
  68. package/src/codex/auth-api/pool-quota-probe.ts +2 -4
  69. package/src/codex/auth-api/reset-credit-service.ts +3 -2
  70. package/src/codex/auth-context.ts +30 -11
  71. package/src/codex/catalog/aggregation.ts +3 -0
  72. package/src/codex/catalog/model-hints.ts +2 -1
  73. package/src/codex/catalog/model-visibility.ts +6 -3
  74. package/src/codex/catalog/parsing.ts +2 -0
  75. package/src/codex/catalog/provider-models.ts +13 -4
  76. package/src/codex/catalog/retained-sync.ts +2 -1
  77. package/src/codex/chatgpt-refresh-failure.ts +56 -0
  78. package/src/codex/convergence.ts +2 -1
  79. package/src/codex/main-account.ts +144 -11
  80. package/src/codex/quota-refresh-outcome.ts +17 -2
  81. package/src/codex/quota-types.ts +33 -1
  82. package/src/codex/quota.ts +56 -15
  83. package/src/codex/routing/cooldown-math.ts +10 -4
  84. package/src/codex/routing/selection.ts +11 -4
  85. package/src/codex/routing.ts +9 -9
  86. package/src/codex/shim-path-resolution.ts +133 -0
  87. package/src/codex/shim.ts +39 -39
  88. package/src/codex/subagent-model-fallback.ts +2 -1
  89. package/src/combos/failover.ts +82 -1
  90. package/src/combos/jev.ts +13 -3
  91. package/src/config/diagnostics.ts +4 -0
  92. package/src/config/load-degrade.ts +4 -0
  93. package/src/config/provider-validation.ts +3 -0
  94. package/src/config/schema/compaction-triggers.ts +8 -0
  95. package/src/config/schema/config-schema.ts +11 -0
  96. package/src/config/schema/leaf-validators.ts +5 -1
  97. package/src/config/subagent-models.ts +12 -0
  98. package/src/generated/compatibility-version.json +245 -153
  99. package/src/grok/session-identity.ts +33 -0
  100. package/src/integrations/catalog-refresh.ts +1 -1
  101. package/src/integrations/mutation-plan.ts +4 -2
  102. package/src/integrations/state.ts +68 -6
  103. package/src/integrations/writer.ts +18 -4
  104. package/src/lib/gui-pair-capability.ts +17 -0
  105. package/src/lib/provider-client-headers.ts +62 -0
  106. package/src/oauth/anthropic-continuity.ts +65 -0
  107. package/src/oauth/anthropic-identity.ts +75 -0
  108. package/src/oauth/anthropic-routing.ts +3 -3
  109. package/src/oauth/anthropic.ts +7 -1
  110. package/src/oauth/generic-account-failover.ts +21 -0
  111. package/src/oauth/health.ts +32 -5
  112. package/src/oauth/index.ts +36 -27
  113. package/src/oauth/local-token-detect.ts +7 -0
  114. package/src/oauth/refresh-policy.ts +2 -0
  115. package/src/oauth/store.ts +55 -6
  116. package/src/oauth/types.ts +9 -0
  117. package/src/protocols/encoders/messages.ts +4 -3
  118. package/src/providers/antigravity-effort-families.ts +75 -0
  119. package/src/providers/antigravity-models.ts +54 -16
  120. package/src/providers/antigravity-wire-snapshot.ts +66 -0
  121. package/src/providers/derive.ts +2 -0
  122. package/src/providers/model-rename-fields.ts +3 -0
  123. package/src/providers/new-model-policy.ts +48 -10
  124. package/src/providers/registry/entries-extended.ts +3 -0
  125. package/src/providers/registry/model-ids.ts +2 -0
  126. package/src/providers/registry/model-seeds.ts +5 -0
  127. package/src/providers/registry/types.ts +4 -0
  128. package/src/responses/compaction.ts +19 -0
  129. package/src/router.ts +6 -0
  130. package/src/server/auth-cors.ts +9 -0
  131. package/src/server/chat-completions.ts +10 -0
  132. package/src/server/claude-messages.ts +18 -11
  133. package/src/server/droid-reasoning-default.ts +50 -0
  134. package/src/server/github-copilot-responses-repair.ts +3 -6
  135. package/src/server/grok-responses-control-frame.ts +2 -2
  136. package/src/server/grok-responses-snapshot-repair.ts +4 -5
  137. package/src/server/gui-session.ts +33 -11
  138. package/src/server/index/claude-intercept-lifecycle.ts +4 -0
  139. package/src/server/index/serve-options.ts +2 -1
  140. package/src/server/management/agent-settings-routes.ts +6 -129
  141. package/src/server/management/config-routes.ts +1 -1
  142. package/src/server/management/integration-routes.ts +64 -6
  143. package/src/server/management/link-routes.ts +20 -13
  144. package/src/server/management/model-row-export-metadata.ts +150 -0
  145. package/src/server/management/model-rows.ts +148 -9
  146. package/src/server/management/route-registry.ts +4 -3
  147. package/src/server/management/shared.ts +19 -5
  148. package/src/server/management/subagent-model-routes.ts +214 -0
  149. package/src/server/relay.ts +14 -44
  150. package/src/server/request-log-generation-window.ts +38 -0
  151. package/src/server/request-log.ts +13 -0
  152. package/src/server/responses/adapter-dispatch.ts +100 -1
  153. package/src/server/responses/codex-auth-error.ts +10 -2
  154. package/src/server/responses/combo-stream-preflight.ts +29 -3
  155. package/src/server/responses/compaction-routing.ts +10 -1
  156. package/src/server/responses/core-auth.ts +57 -26
  157. package/src/server/responses/core-codex-account.ts +6 -3
  158. package/src/server/responses/core-combo-failure.ts +5 -1
  159. package/src/server/responses/core-combo-native.ts +7 -1
  160. package/src/server/responses/core-combo.ts +18 -1
  161. package/src/server/responses/core-normalize.ts +7 -0
  162. package/src/server/responses/core-options.ts +5 -0
  163. package/src/server/responses/fetch-helpers.ts +9 -0
  164. package/src/server/responses/passthrough-delivery.ts +1 -1
  165. package/src/server/responses/policy-fallback.ts +1 -1
  166. package/src/server/responses/request-prepare.ts +5 -0
  167. package/src/server/responses/request-spend.ts +30 -30
  168. package/src/server/responses-custom-tool-repair.ts +2 -2
  169. package/src/server/responses-hosted-image-display.ts +70 -9
  170. package/src/server/responses-terminal-repair.ts +15 -2
  171. package/src/server/sse-frame-buffer.ts +38 -36
  172. package/src/server/sse-payload-rewrite.ts +78 -49
  173. package/src/server/ws-bridge.ts +1 -11
  174. package/src/service/windows-taskxml.ts +1 -2
  175. package/src/service-manager-probe.ts +36 -2
  176. package/src/types/config.ts +4 -0
  177. package/src/types/provider.ts +18 -0
  178. package/src/update/npm-invocation.mjs +35 -3
  179. package/src/update/proxy-liveness-probe.mjs +38 -8
  180. package/src/usage/expected-prices.ts +9 -0
  181. package/src/usage/log.ts +7 -0
  182. package/src/usage/summary.ts +82 -1
  183. package/src/web-search/loop.ts +11 -8
  184. package/src/web-search/run-turn-loop.ts +10 -7
  185. package/gui/dist/assets/App-CddorHdA.js +0 -51
  186. package/gui/dist/assets/App-DMudtISW.css +0 -1
  187. package/gui/dist/assets/index-Btze1I3T.css +0 -1
  188. package/gui/dist/assets/index-CvnY7E6i.js +0 -86
  189. package/gui/dist/assets/quota-summary-B_AWpHfP.js +0 -1
@@ -13,6 +13,7 @@ import {
13
13
  } from "../responses/custom-tool-compat";
14
14
  import {
15
15
  replaceSseDataPayload,
16
+ splitSseBlock,
16
17
  sseDataPayload,
17
18
  type SseBlockRewrite,
18
19
  } from "./sse-payload-rewrite";
@@ -22,8 +23,7 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
22
23
  }
23
24
 
24
25
  function replaceSseEventName(block: string, type: string): string {
25
- const newline = block.includes("\r\n") ? "\r\n" : "\n";
26
- const lines = block.split(/\r?\n/);
26
+ const { newline, lines } = splitSseBlock(block);
27
27
  let replaced = false;
28
28
  const next = lines.map(line => {
29
29
  if (!replaced && line.startsWith("event:")) {
@@ -2,6 +2,8 @@ import { mkdirSync, writeFileSync, unlinkSync } from "node:fs";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { join, posix } from "node:path";
4
4
  import { getConfigDir } from "../config";
5
+ import { jsonUtf8Bytes } from "../lib/json-byte-size";
6
+ import { TRANSLATOR_MAX_TURN_BYTES } from "../lib/translator-budget";
5
7
  import { recordOwnedConfigPath } from "../lib/config-ownership";
6
8
  import {
7
9
  chargeImageBudget, createImageBudget, decodeValidatedImageBase64,
@@ -12,6 +14,16 @@ import { sseDataPayload, replaceSseDataPayload, type SseBlockRewrite } from "./s
12
14
  type Row = Record<string, any>;
13
15
  const object = (v: unknown): v is Row => !!v && typeof v === "object" && !Array.isArray(v);
14
16
  const originators = new Set(["codex_cli_rs", "Codex Desktop", "codex_app", "codex_work_desktop"]);
17
+ const MAX_DISPLAY_ITEMS = 128;
18
+ const MAX_ITEM_METADATA_BYTES = 64 * 1024;
19
+ const MAX_RETAINED_METADATA_BYTES = 1024 * 1024;
20
+ const displayKey = (item: Row, index: number) => typeof item.id === "string" ? "id:" + item.id : "index:" + index;
21
+
22
+ function displayBytes(value: unknown, limit: number): number {
23
+ try { return jsonUtf8Bytes(value, limit); }
24
+ catch { throw new RangeError("hosted image result exceeds local display limits"); }
25
+ }
26
+
15
27
  const generatedName = /^img-codex-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\.(png|jpg|webp|gif)$/;
16
28
 
17
29
  /** One spelling per local path: file URL or plain, either separator, dot segments, folded case. */
@@ -71,13 +83,20 @@ export function createHostedImageDisplayRewrite(): SseBlockRewrite & { json(text
71
83
  const states = new Map<string, { message: Row; added: boolean; done: boolean }>();
72
84
  let extraSequence = 0;
73
85
  let wroteArtifact = false;
86
+ let retainedMetadataBytes = 0;
74
87
 
75
88
  function stateFor(item: Row, index: number) {
76
- const key = typeof item.id === "string" ? item.id : "index:" + index;
89
+ // Validate every occurrence, including repeats whose state is already complete.
90
+ const metadataBytes = displayBytes({ id: item.id,
91
+ metadata: item.internal_chat_message_metadata_passthrough }, MAX_ITEM_METADATA_BYTES);
92
+ const key = displayKey(item, index);
77
93
  let state = states.get(key);
78
94
  if (state) return state;
79
- // Bound metadata independently of the existing 100 MiB decoded-image budget.
80
- if (states.size >= 128) throw new RangeError("hosted image result count exceeds local display limit");
95
+ if (states.size >= MAX_DISPLAY_ITEMS) throw new RangeError("hosted image result count exceeds local display limit");
96
+ if (metadataBytes > MAX_RETAINED_METADATA_BYTES - retainedMetadataBytes) {
97
+ throw new RangeError("hosted image result exceeds local display limits");
98
+ }
99
+ retainedMetadataBytes += metadataBytes;
81
100
  const id = "msg_ocx_img_" + createHash("sha256").update(key).digest("hex").slice(0, 32);
82
101
  state = { added: false, done: false, message: {
83
102
  // Persist image results outside the client's collapsible progress messages.
@@ -147,15 +166,51 @@ export function createHostedImageDisplayRewrite(): SseBlockRewrite & { json(text
147
166
  return events;
148
167
  }
149
168
 
150
- function snapshot(response: Row, terminal: boolean, events?: Row[]): Row {
169
+ function projectedMessage(message: Row): Row {
170
+ if (message.status !== "in_progress") return message;
171
+ // Longest possible local filename and JSON text size, without decoding or writing an image.
172
+ const pathText = "![Generated image](<" + join(getArtifactsDir(), "img-codex-" + "0".repeat(36) + ".webp") + ">)";
173
+ const fallback = "The completed image result has no supported image data for local display.";
174
+ const text = displayBytes(pathText, TRANSLATOR_MAX_TURN_BYTES) > displayBytes(fallback, TRANSLATOR_MAX_TURN_BYTES)
175
+ ? pathText : fallback;
176
+ return { ...message, status: "incomplete", content: [{ type: "output_text", text, annotations: [] }] };
177
+ }
178
+
179
+ function lifecycleBytes(message: Row): number {
180
+ // At most two item copies and four text copies, plus fixed event fields, ids and numeric indices.
181
+ return 2 * displayBytes(message, TRANSLATOR_MAX_TURN_BYTES)
182
+ + 4 * displayBytes(message.content[0].text, TRANSLATOR_MAX_TURN_BYTES) + 2048;
183
+ }
184
+
185
+ function snapshot(response: Row, terminal: boolean, events?: Row[], envelope?: Row): Row {
151
186
  if (!Array.isArray(response.output)) return response;
152
- // Reject oversized snapshots before saving artifacts that cannot be delivered.
153
- const keys = new Set(states.keys());
187
+ // Preflight the complete snapshot before any artifact writes or expanded serialization.
188
+ const keys = new Set<string>();
189
+ const ids = new Set<string>();
154
190
  for (const [index, item] of response.output.entries()) {
155
191
  if (!object(item) || item.type !== "image_generation_call") continue;
156
- keys.add(typeof item.id === "string" ? item.id : "index:" + index);
157
- if (keys.size > 128) throw new RangeError("hosted image result count exceeds local display limit");
192
+ const key = displayKey(item, index);
193
+ if (keys.has(key)) throw new RangeError("duplicate hosted image identity in local display snapshot");
194
+ keys.add(key);
195
+ if (typeof item.id === "string") ids.add(item.id);
196
+ stateFor(item, index);
197
+ }
198
+ if (keys.size === 0) return response;
199
+ const seen = new Set<string>();
200
+ for (const item of response.output) {
201
+ if (!object(item) || !ids.has(item.id)) continue;
202
+ if (seen.has(item.id)) throw new RangeError("conflicting hosted image identity in local display snapshot");
203
+ seen.add(item.id);
158
204
  }
205
+ let extraBytes = 32; // Sequence-number growth on a rewritten SSE envelope.
206
+ const preview = { ...response, output: response.output.map((item: unknown, index: number) => {
207
+ if (!object(item) || item.type !== "image_generation_call") return item;
208
+ const state = stateFor(item, index);
209
+ const message = terminal ? projectedMessage(state.message) : state.message;
210
+ if (events && terminal && !state.done) extraBytes += lifecycleBytes(message);
211
+ return message;
212
+ }) };
213
+ displayBytes(envelope ? { ...envelope, response: preview } : preview, TRANSLATOR_MAX_TURN_BYTES - extraBytes);
159
214
  let changed = false;
160
215
  const output = response.output.map((item: unknown, index: number) => {
161
216
  if (!object(item) || item.type !== "image_generation_call") return item;
@@ -182,9 +237,14 @@ export function createHostedImageDisplayRewrite(): SseBlockRewrite & { json(text
182
237
  && (hasIndex || typeof event.item.id === "string")) {
183
238
  if (event.type === "response.output_item.added") {
184
239
  const state = stateFor(event.item, index);
240
+ if (!state.added) displayBytes({ ...event, item: state.message }, TRANSLATOR_MAX_TURN_BYTES - 32);
185
241
  result = state.added ? [] : [{ ...event, item: state.message }];
186
242
  state.added = true;
187
243
  } else if (event.type === "response.output_item.done") {
244
+ const state = stateFor(event.item, index);
245
+ if (!state.done && lifecycleBytes(projectedMessage(state.message)) > TRANSLATOR_MAX_TURN_BYTES) {
246
+ throw new RangeError("hosted image result exceeds local display limits");
247
+ }
188
248
  result = doneEvents(event.item, index);
189
249
  }
190
250
  } else if (typeof event.type === "string" && event.type.startsWith("response.image_generation_call.")) {
@@ -193,7 +253,7 @@ export function createHostedImageDisplayRewrite(): SseBlockRewrite & { json(text
193
253
  } else if (object(event.response)) {
194
254
  const terminal = ["response.completed", "response.incomplete", "response.failed"].includes(event.type);
195
255
  const injected: Row[] = [];
196
- const response = snapshot(event.response, terminal, injected);
256
+ const response = snapshot(event.response, terminal, injected, event);
197
257
  if (response !== event.response) result = [...injected, { ...event, response }];
198
258
  }
199
259
  const sequence = Number.isSafeInteger(event.sequence_number) ? event.sequence_number + extraSequence : undefined;
@@ -216,6 +276,7 @@ export function createHostedImageDisplayRewrite(): SseBlockRewrite & { json(text
216
276
  };
217
277
  rewrite.dispose = () => {
218
278
  states.clear();
279
+ retainedMetadataBytes = 0;
219
280
  if (wroteArtifact) {
220
281
  wroteArtifact = false;
221
282
  pruneArtifacts();
@@ -91,6 +91,7 @@ export function relayResponsesSseWithTerminalRepair(
91
91
  let maxSequence = -1;
92
92
  let buffer = "";
93
93
  let bufferBytes = 0;
94
+ let pendingLineFeed = false;
94
95
  let timer: unknown;
95
96
  let timerGeneration = 0;
96
97
  let realTerminalSeen = false;
@@ -284,6 +285,7 @@ export function relayResponsesSseWithTerminalRepair(
284
285
  let emitted = false;
285
286
  while ((next = nextSseBlock(buffer))) {
286
287
  replaceBuffer(next.rest);
288
+ pendingLineFeed = next.delimiter.endsWith("\r") && buffer.length === 0;
287
289
  const kind = inspectPayload(sseDataPayload(next.block));
288
290
  if (kind === "done" && !realTerminalSeen) {
289
291
  emitSynthetic(completeCandidate() ? "completed" : "incomplete", controller);
@@ -327,9 +329,20 @@ export function relayResponsesSseWithTerminalRepair(
327
329
  controller.close();
328
330
  return;
329
331
  }
330
- appendBuffer(decoder.decode(value, { stream: true }));
332
+ let fragment = decoder.decode(value, { stream: true });
333
+ let continuedDelimiter = false;
334
+ if (pendingLineFeed && fragment.length > 0) {
335
+ pendingLineFeed = false;
336
+ if (fragment.startsWith("\n")) {
337
+ // This byte completes an already-forwarded CR delimiter, not a new EOF suffix.
338
+ controller.enqueue(encoder.encode("\n"));
339
+ fragment = fragment.slice(1);
340
+ continuedDelimiter = true;
341
+ }
342
+ }
343
+ appendBuffer(fragment);
331
344
  const result = emitBlocks(controller);
332
- if (result.closed || result.emitted) return;
345
+ if (result.closed || result.emitted || continuedDelimiter) return;
333
346
  }
334
347
  } catch (error) {
335
348
  if (disposed) return;
@@ -1,13 +1,14 @@
1
1
  import { isCyberPolicyCode, isCyberPolicyMessage } from "../lib/errors";
2
+ import { sseDataPayload } from "./sse-payload-rewrite";
2
3
 
3
4
  export const MAX_CLIENT_SSE_FRAME_BYTES = 4 * 1024 * 1024;
4
5
 
5
6
  export const EMPTY_BYTES = new Uint8Array(0);
6
7
 
7
- const LF_LF = Uint8Array.of(10, 10);
8
- const LF_CR_LF = Uint8Array.of(10, 13, 10);
9
- const CR_LF_LF = Uint8Array.of(13, 10, 10);
10
- const CR_LF_CR_LF = Uint8Array.of(13, 10, 13, 10);
8
+ // Intern every legal pair; a split CRLF may leave a framing-only LF continuation.
9
+ const DELIMITERS = new Map([
10
+ "\n", "\n\n", "\n\r", "\n\r\n", "\r\r", "\r\r\n", "\r\n\n", "\r\n\r", "\r\n\r\n",
11
+ ].map(text => [text, Uint8Array.from(text, char => char.charCodeAt(0))]));
11
12
 
12
13
  export class SseFrameTooLargeError extends Error {
13
14
  readonly maxBytes: number;
@@ -32,6 +33,8 @@ export class SseFrameCountLimitError extends Error {
32
33
  export type BoundedSseFrame = {
33
34
  block: Uint8Array;
34
35
  delimiter: Uint8Array;
36
+ /** A late LF extending a previously returned CR; no new event. */
37
+ continuation?: true;
35
38
  };
36
39
 
37
40
  /**
@@ -40,29 +43,22 @@ export type BoundedSseFrame = {
40
43
  * Returns the delimiter length in bytes, `0` when `index` does not start a
41
44
  * delimiter, and `undefined` when more bytes are required to decide.
42
45
  */
43
- function delimiterLengthAt(
46
+ export function sseDelimiterLengthAt(
44
47
  index: number,
45
48
  length: number,
46
49
  byteAt: (index: number) => number,
47
50
  ): number | undefined {
48
51
  const first = byteAt(index);
49
- if (first === 10) {
50
- if (index + 1 >= length) return undefined;
51
- const second = byteAt(index + 1);
52
- if (second === 10) return 2;
53
- if (second !== 13) return 0;
54
- if (index + 2 >= length) return undefined;
55
- return byteAt(index + 2) === 10 ? 3 : 0;
56
- }
57
- if (first !== 13) return 0;
58
- if (index + 1 >= length) return undefined;
59
- if (byteAt(index + 1) !== 10) return 0;
60
- if (index + 2 >= length) return undefined;
61
- const third = byteAt(index + 2);
62
- if (third === 10) return 3;
63
- if (third !== 13) return 0;
64
- if (index + 3 >= length) return undefined;
65
- return byteAt(index + 3) === 10 ? 4 : 0;
52
+ if (first !== 10 && first !== 13) return 0;
53
+ let next = index + 1;
54
+ if (next >= length) return undefined;
55
+ if (first === 13 && byteAt(next) === 10) next += 1;
56
+ if (next >= length) return undefined;
57
+ const second = byteAt(next);
58
+ if (second !== 10 && second !== 13) return 0;
59
+ next += 1;
60
+ if (second === 13 && next < length && byteAt(next) === 10) next += 1;
61
+ return next - index;
66
62
  }
67
63
 
68
64
  function delimiterBytesAt(
@@ -70,9 +66,9 @@ function delimiterBytesAt(
70
66
  delimiterLength: number,
71
67
  byteAt: (index: number) => number,
72
68
  ): Uint8Array {
73
- if (delimiterLength === 2) return LF_LF;
74
- if (delimiterLength === 4) return CR_LF_CR_LF;
75
- return byteAt(index) === 10 ? LF_CR_LF : CR_LF_LF;
69
+ let text = "";
70
+ for (let offset = 0; offset < delimiterLength; offset++) text += String.fromCharCode(byteAt(index + offset));
71
+ return DELIMITERS.get(text)!;
76
72
  }
77
73
 
78
74
  function copyRange(
@@ -100,14 +96,8 @@ function copyRange(
100
96
  * cost on ordinary framing.
101
97
  */
102
98
  function isResponsesTerminalFrame(block: Uint8Array): boolean {
103
- const data: string[] = [];
104
- for (const line of new TextDecoder().decode(block).split(/\r?\n/)) {
105
- if (!line.startsWith("data:")) continue;
106
- const value = line.slice(5);
107
- data.push(value.startsWith(" ") ? value.slice(1) : value);
108
- }
109
- if (data.length === 0) return false;
110
- const payload = data.join("\n");
99
+ const payload = sseDataPayload(new TextDecoder().decode(block));
100
+ if (payload === null) return false;
111
101
  if (payload === "[DONE]") return false;
112
102
  try {
113
103
  const parsed = JSON.parse(payload) as {
@@ -161,6 +151,7 @@ export class BoundedSseFrameBuffer {
161
151
  private candidate: Uint8Array = EMPTY_BYTES;
162
152
  private candidateBytes = 0;
163
153
  private disposed = false;
154
+ private pendingLineFeed = false;
164
155
 
165
156
  constructor(maxFrameBytes = MAX_CLIENT_SSE_FRAME_BYTES) {
166
157
  if (!Number.isSafeInteger(maxFrameBytes) || maxFrameBytes <= 0) {
@@ -177,6 +168,7 @@ export class BoundedSseFrameBuffer {
177
168
  this.delimiterTail = EMPTY_BYTES;
178
169
  this.candidate = EMPTY_BYTES;
179
170
  this.candidateBytes = 0;
171
+ this.pendingLineFeed = false;
180
172
  }
181
173
 
182
174
  private ensureCapacity(requiredBytes: number): void {
@@ -226,6 +218,7 @@ export class BoundedSseFrameBuffer {
226
218
  if (chunk.byteLength === 0) return [];
227
219
 
228
220
  const frames: BoundedSseFrame[] = [];
221
+ let completedFrames = 0;
229
222
  const previousTail = this.delimiterTail;
230
223
  this.delimiterTail = EMPTY_BYTES;
231
224
  const tailLength = previousTail.byteLength;
@@ -245,12 +238,19 @@ export class BoundedSseFrameBuffer {
245
238
 
246
239
  try {
247
240
  let index = 0;
248
- let retainedThrough = 0;
241
+ if (this.pendingLineFeed && byteAt(0) === 10) {
242
+ // The prior event was already dispatched on CR. Preserve its later LF without
243
+ // charging it to the next event or letting it pair with another line ending.
244
+ frames.push({ block: EMPTY_BYTES, delimiter: DELIMITERS.get("\n")!, continuation: true });
245
+ index = 1;
246
+ }
247
+ this.pendingLineFeed = false;
248
+ let retainedThrough = index;
249
249
  while (index < totalLength) {
250
- const delimiterLength = delimiterLengthAt(index, totalLength, byteAt);
250
+ const delimiterLength = sseDelimiterLengthAt(index, totalLength, byteAt);
251
251
  if (delimiterLength === undefined) break;
252
252
  if (delimiterLength > 0) {
253
- if (frames.length >= this.maxFramesPerFeed) {
253
+ if (completedFrames >= this.maxFramesPerFeed) {
254
254
  this.clear();
255
255
  this.disposed = true;
256
256
  throw new SseFrameCountLimitError(this.maxFramesPerFeed);
@@ -259,7 +259,9 @@ export class BoundedSseFrameBuffer {
259
259
  const block = this.takeCandidate();
260
260
  const delimiter = delimiterBytesAt(index, delimiterLength, byteAt);
261
261
  frames.push({ block, delimiter });
262
+ completedFrames += 1;
262
263
  index += delimiterLength;
264
+ this.pendingLineFeed = index === totalLength && byteAt(index - 1) === 13;
263
265
  retainedThrough = index;
264
266
  continue;
265
267
  }
@@ -65,7 +65,8 @@ export function composeSseBlockRewrites(...rewrites: SseBlockRewrite[]): SseBloc
65
65
 
66
66
  /** Split one complete SSE event block while retaining its original blank-line delimiter. */
67
67
  export function nextSseBlock(buffer: string): { block: string; delimiter: string; rest: string } | null {
68
- const match = buffer.match(/\r?\n\r?\n/);
68
+ // The negative lookahead prevents backtracking from splitting one CRLF into two endings.
69
+ const match = buffer.match(/(?:\r\n|\r(?!\n)|\n){2}/);
69
70
  if (!match || match.index === undefined) return null;
70
71
  return {
71
72
  block: buffer.slice(0, match.index),
@@ -87,6 +88,7 @@ export function createSseBlockBuffer(
87
88
  append(fragment: string): void;
88
89
  next(): { block: string; delimiter: string } | null;
89
90
  tail(): string;
91
+ isEmpty(): boolean;
90
92
  compact(): void;
91
93
  clear(): void;
92
94
  } {
@@ -95,6 +97,7 @@ export function createSseBlockBuffer(
95
97
  let offset = 0;
96
98
  let scanOffset = 0;
97
99
  let bufferBytes = 0;
100
+ const delimiterPattern = /(?:\r\n|\r(?!\n)|\n){2}/g;
98
101
 
99
102
  const compact = (): void => {
100
103
  if (offset === 0) return;
@@ -128,43 +131,33 @@ export function createSseBlockBuffer(
128
131
  }
129
132
  },
130
133
  next() {
131
- for (;;) {
132
- const newline = buffer.indexOf("\n", scanOffset);
133
- if (newline < 0) {
134
- scanOffset = buffer.length;
135
- return null;
136
- }
137
- let end = newline + 1;
138
- if (buffer[end] === "\r") end += 1;
139
- if (end === buffer.length) {
140
- // Keep the candidate first newline until its possible blank-line delimiter arrives.
141
- scanOffset = newline;
142
- return null;
143
- }
144
- if (buffer[end] !== "\n") {
145
- scanOffset = newline + 1;
146
- continue;
147
- }
148
- end += 1;
149
- const start = newline > offset && buffer[newline - 1] === "\r" ? newline - 1 : newline;
150
- const block = buffer.slice(offset, start);
151
- const delimiter = buffer.slice(start, end);
152
- const nextBytes = bufferBytes - Buffer.byteLength(block, "utf8") - delimiter.length;
153
- const reservation = budget.reserveTransient(nextBytes, scope);
154
- reservation.commitRetained();
155
- budget.releaseRetained(bufferBytes, scope);
156
- bufferBytes = nextBytes;
157
- offset = end;
158
- scanOffset = end;
159
- if (offset === buffer.length) {
160
- buffer = "";
161
- offset = 0;
162
- scanOffset = 0;
163
- }
164
- return { block, delimiter };
134
+ delimiterPattern.lastIndex = scanOffset;
135
+ const match = delimiterPattern.exec(buffer);
136
+ if (!match) {
137
+ // A delimiter is at most four code units. Revisit only its possible split prefix.
138
+ scanOffset = Math.max(offset, buffer.length - 3);
139
+ return null;
165
140
  }
141
+ const start = match.index;
142
+ const end = start + match[0].length;
143
+ const block = buffer.slice(offset, start);
144
+ const delimiter = buffer.slice(start, end);
145
+ const nextBytes = bufferBytes - Buffer.byteLength(block, "utf8") - delimiter.length;
146
+ const reservation = budget.reserveTransient(nextBytes, scope);
147
+ reservation.commitRetained();
148
+ budget.releaseRetained(bufferBytes, scope);
149
+ bufferBytes = nextBytes;
150
+ offset = end;
151
+ scanOffset = end;
152
+ if (offset === buffer.length) {
153
+ buffer = "";
154
+ offset = 0;
155
+ scanOffset = 0;
156
+ }
157
+ return { block, delimiter };
166
158
  },
167
159
  tail: () => buffer.slice(offset),
160
+ isEmpty: () => offset === buffer.length,
168
161
  compact,
169
162
  clear() {
170
163
  budget.releaseRetained(bufferBytes, scope);
@@ -184,11 +177,11 @@ export function sseDataPayload(block: string): string | null {
184
177
  const len = block.length;
185
178
 
186
179
  while (lineStart < len) {
187
- const nextNewline = block.indexOf("\n", lineStart);
188
- let lineEnd = nextNewline === -1 ? len : nextNewline;
189
- const nextStart = nextNewline === -1 ? len : nextNewline + 1;
190
- if (lineEnd > lineStart && block.charCodeAt(lineEnd - 1) === 13) {
191
- lineEnd -= 1;
180
+ let lineEnd = lineStart;
181
+ while (lineEnd < len && block.charCodeAt(lineEnd) !== 13 && block.charCodeAt(lineEnd) !== 10) lineEnd += 1;
182
+ let nextStart = lineEnd;
183
+ if (nextStart < len) {
184
+ nextStart += block.charCodeAt(lineEnd) === 13 && block.charCodeAt(lineEnd + 1) === 10 ? 2 : 1;
192
185
  }
193
186
 
194
187
  const lineLen = lineEnd - lineStart;
@@ -218,10 +211,19 @@ export function sseDataPayload(block: string): string | null {
218
211
  return found ? result : null;
219
212
  }
220
213
 
214
+ /** Shared line rules for field-mutating repairs; identity paths keep the original block. */
215
+ export function sseLineEnding(block: string): "\r\n" | "\r" | "\n" {
216
+ return block.match(/\r\n|\r|\n/)?.[0] as "\r\n" | "\r" | "\n" ?? "\n";
217
+ }
218
+
219
+ export function splitSseBlock(block: string): { newline: string; lines: string[] } {
220
+ return { newline: sseLineEnding(block), lines: block.split(/\r\n|\r|\n/) };
221
+ }
222
+
221
223
  /** Replace an SSE event's data field while preserving non-data fields and newline style. */
222
224
  export function replaceSseDataPayload(block: string, payload: string): string {
223
- const newline = block.includes("\r\n") ? "\r\n" : "\n";
224
- const lines = block.split(/\r?\n/);
225
+ if (sseDataPayload(block) === payload) return block;
226
+ const { newline, lines } = splitSseBlock(block);
225
227
  const rewritten: string[] = [];
226
228
  let replaced = false;
227
229
  for (const line of lines) {
@@ -230,7 +232,7 @@ export function replaceSseDataPayload(block: string, payload: string): string {
230
232
  continue;
231
233
  }
232
234
  if (!replaced) {
233
- rewritten.push(...payload.split(/\r?\n/).map(line => `data: ${line}`));
235
+ rewritten.push(...payload.split(/\r\n|\r|\n/).map(line => `data: ${line}`));
234
236
  replaced = true;
235
237
  }
236
238
  }
@@ -278,6 +280,8 @@ export function relaySseWithBlockRewrite(
278
280
  // Relays have several independent teardown paths; disposal is exactly once.
279
281
  let disposed = false;
280
282
  let cancelled = false;
283
+ let pendingLineFeed = false;
284
+ let pendingLineFeedVisible = false;
281
285
  const disposeRewrite = (): void => {
282
286
  if (disposed) return;
283
287
  disposed = true;
@@ -308,10 +312,18 @@ export function relaySseWithBlockRewrite(
308
312
  let emitted = 0;
309
313
  let next: { block: string; delimiter: string } | null;
310
314
  while (!cancelled && (next = buffer.next())) {
311
- const outBlocks = rewrite(next.block);
315
+ const { block, delimiter } = next;
316
+ // A CR followed by buffered text is already settled; only an end-of-buffer CR
317
+ // can still receive the LF that extends its delimiter in the next fragment.
318
+ pendingLineFeed = delimiter.endsWith("\r") && buffer.isEmpty();
319
+ const outBlocks = rewrite(block);
320
+ pendingLineFeedVisible = outBlocks.length > 0;
312
321
  if (cancelled) return emitted;
313
- for (const outBlock of outBlocks) {
314
- enqueueText(controller, outBlock + next.delimiter);
322
+ for (let index = 0; index < outBlocks.length; index++) {
323
+ // Synthetic earlier blocks need a settled delimiter; only the final one can
324
+ // inherit an upstream LF in a later chunk. Completing CR as CRLF is equivalent.
325
+ const settled = index < outBlocks.length - 1 && delimiter.endsWith("\r") ? delimiter + "\n" : delimiter;
326
+ enqueueText(controller, outBlocks[index]! + settled);
315
327
  emitted += 1;
316
328
  }
317
329
  }
@@ -332,6 +344,23 @@ export function relaySseWithBlockRewrite(
332
344
  return emitted;
333
345
  };
334
346
 
347
+ const appendFragment = (controller: ReadableStreamDefaultController<Uint8Array>, fragment: string): number => {
348
+ let continuation = false;
349
+ if (pendingLineFeed && fragment.length > 0) {
350
+ continuation = fragment.startsWith("\n");
351
+ pendingLineFeed = false;
352
+ if (continuation) fragment = fragment.slice(1);
353
+ }
354
+ // Consume a late LF before scanning, so it cannot pair with a new LF to create
355
+ // a phantom empty callback. Any following line ending stays in the input buffer.
356
+ buffer.append(fragment);
357
+ if (continuation && pendingLineFeedVisible) {
358
+ enqueueText(controller, "\n");
359
+ return 1;
360
+ }
361
+ return 0;
362
+ };
363
+
335
364
  return new ReadableStream<Uint8Array>({
336
365
  async pull(controller) {
337
366
  try {
@@ -346,7 +375,7 @@ export function relaySseWithBlockRewrite(
346
375
  // after its disposal (#893 review).
347
376
  if (cancelled) return;
348
377
  if (done) {
349
- buffer.append(decoder.decode());
378
+ appendFragment(controller, decoder.decode());
350
379
  emitProcessedBlocks(controller, true);
351
380
  if (cancelled) return;
352
381
  buffer.clear();
@@ -354,8 +383,8 @@ export function relaySseWithBlockRewrite(
354
383
  controller.close();
355
384
  return;
356
385
  }
357
- buffer.append(decoder.decode(value, { stream: true }));
358
- const emitted = emitProcessedBlocks(controller);
386
+ const continuation = appendFragment(controller, decoder.decode(value, { stream: true }));
387
+ const emitted = continuation + emitProcessedBlocks(controller);
359
388
  if (cancelled || emitted > 0) return;
360
389
  }
361
390
  } catch (error) {
@@ -8,6 +8,7 @@ import type { ResponsesTerminalStatus } from "../bridge";
8
8
  import type { DataPlaneAdmission } from "./auth-cors";
9
9
  import type { AdmissionLease, AdmissionReservation } from "../lib/admission";
10
10
  import { BoundedSseFrameBuffer } from "./sse-frame-buffer";
11
+ import { sseDataPayload as parseSseBlock } from "./sse-payload-rewrite";
11
12
  import { safeResponseHeaders } from "./safe-response-headers";
12
13
  import type { AudioSocketTarget } from "./audio-dictation";
13
14
 
@@ -183,17 +184,6 @@ export function buildWsErrorFrame(
183
184
  };
184
185
  }
185
186
 
186
- function parseSseBlock(block: string): string | null {
187
- const data: string[] = [];
188
- for (const line of block.split(/\r?\n/)) {
189
- if (line.startsWith("data:")) {
190
- const value = line.slice(5);
191
- data.push(value.startsWith(" ") ? value.slice(1) : value);
192
- }
193
- }
194
- return data.length > 0 ? data.join("\n") : null;
195
- }
196
-
197
187
  function payloadType(payload: string): string | null {
198
188
  try {
199
189
  const json = JSON.parse(payload) as { type?: unknown };
@@ -154,7 +154,6 @@ export function buildWindowsServiceScript(
154
154
  ' if exist "%OCX_PKG_DIR%" rmdir /s /q "%OCX_PKG_DIR%" 2>nul',
155
155
  ' move "%OCX_PKG_DIR%\\..\\%%B\\opencodex" "%OCX_PKG_DIR%" >nul 2>&1',
156
156
  ' if exist "%OCX_PKG_DIR%\\package.json" (',
157
- ' set "OCX_RESTORED_BACKUP=%%B"',
158
157
  " goto backup_restored",
159
158
  " )",
160
159
  " )",
@@ -162,7 +161,7 @@ export function buildWindowsServiceScript(
162
161
  '>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] no restorable backup found',
163
162
  "goto :eof",
164
163
  ":backup_restored",
165
- '>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] restored previous install from %OCX_RESTORED_BACKUP%',
164
+ '>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] restored previous install from transactional-update backup',
166
165
  "goto :eof",
167
166
  ].filter((line): line is string => Boolean(line));
168
167
  return `${lines.join("\r\n")}\r\n`;
@@ -29,6 +29,7 @@ import {
29
29
  import { decodeWindowsTextBytes } from "./lib/windows-text";
30
30
  import { WINSW_SERVICE_ID } from "./lib/winsw";
31
31
  import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV } from "./lib/bun-runtime";
32
+ import { REAL_BUN_MIN_BYTES } from "./lib/bun-binary-validator.mjs";
32
33
  import { WINDOWS_WRAPPER_PROTOCOL_ENV, WINDOWS_WRAPPER_STAY_OUT_EXIT_CODE } from "./service/windows-wrapper-exit";
33
34
  import { buildWindowsServiceScript, windowsTaskActionMatches } from "./service/windows-taskxml";
34
35
  import { inspectServiceStateEvidence, serviceStatePathsForOpenCodexHome } from "./service/state";
@@ -613,8 +614,41 @@ function matchesGeneratedStandaloneControlFlow(body: string, port: number): bool
613
614
  scriptLines.filter((line, index) => index >= end || line === 'set "ERRORLEVEL="' || !line.startsWith('set "'));
614
615
  const actualFlow = withoutPrefixSets(lines, boundary);
615
616
  const generatedFlow = withoutPrefixSets(expected, expectedBoundary);
616
- return actualFlow.length === generatedFlow.length
617
- && actualFlow.every((line, index) => line === generatedFlow[index]);
617
+ // Read-only recognition of the exact previous generator output keeps installed
618
+ // standalone services identifiable across the backup-log hardening update.
619
+ // Never emit or execute this legacy variant; every other control line still matches.
620
+ const legacyFlow = generatedFlow.flatMap(line => {
621
+ if (line === " goto backup_restored") return [' set "OCX_RESTORED_BACKUP=%%B"', line];
622
+ if (line === '>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] restored previous install from transactional-update backup') {
623
+ return ['>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] restored previous install from %OCX_RESTORED_BACKUP%'];
624
+ }
625
+ return [line];
626
+ });
627
+ // Also recognize the exact pre-placeholder version, not a partial size-gate
628
+ // hybrid. Only remove these complete known blocks from trusted generator output.
629
+ const beforePlaceholderGate = (flow: string[]): string[] | null => {
630
+ const blocks = [
631
+ ['set "OCX_BUN_BYTES="', 'for %%F in ("%OCX_BUN%") do set "OCX_BUN_BYTES=%%~zF"',
632
+ 'if not defined OCX_BUN_BYTES goto bun_not_ready', `if %OCX_BUN_BYTES% LSS ${REAL_BUN_MIN_BYTES} goto bun_not_ready`],
633
+ [":bun_not_ready",
634
+ '>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] bundled Bun is not ready (%OCX_BUN_BYTES% bytes, npm placeholder or mid-install); waiting for its postinstall, retrying in 5s - if this persists, reinstall opencodex with bun scripts allowed',
635
+ "ping -n 6 127.0.0.1 >nul", "goto loop"],
636
+ ];
637
+ let earlier = flow;
638
+ for (const block of blocks) {
639
+ const at = earlier.indexOf(block[0]!);
640
+ if (at < 0 || !block.every((line, offset) => earlier[at + offset] === line)) return null;
641
+ earlier = [...earlier.slice(0, at), ...earlier.slice(at + block.length)];
642
+ }
643
+ return earlier;
644
+ };
645
+ const recognized = [generatedFlow, legacyFlow];
646
+ for (const flow of [...recognized]) {
647
+ const earlier = beforePlaceholderGate(flow);
648
+ if (earlier) recognized.push(earlier);
649
+ }
650
+ return recognized.some(expectedFlow => actualFlow.length === expectedFlow.length
651
+ && actualFlow.every((line, index) => line === expectedFlow[index]));
618
652
  }
619
653
 
620
654
  /** Validate the generated launch shape before interpreting omitted optional homes. */