@bitkyc08/opencodex 2.10.2 → 2.11.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 (121) hide show
  1. package/README.md +31 -0
  2. package/bin/ocx.mjs +10 -0
  3. package/gui/dist/assets/index-Bk-PN-70.css +1 -0
  4. package/gui/dist/assets/index-BynIEIV-.js +70 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +4 -2
  7. package/src/adapters/cursor/effort-map.ts +11 -0
  8. package/src/adapters/cursor/live-transport.ts +11 -0
  9. package/src/adapters/cursor/native-exec-fs.ts +9 -6
  10. package/src/adapters/cursor/native-exec.ts +4 -2
  11. package/src/adapters/cursor/protobuf-events.ts +176 -4
  12. package/src/adapters/cursor/request-builder.ts +15 -4
  13. package/src/adapters/cursor/tool-definitions.ts +118 -2
  14. package/src/adapters/google.ts +15 -5
  15. package/src/adapters/openai-chat.ts +24 -2
  16. package/src/adapters/openai-responses.ts +2 -1
  17. package/src/bridge.ts +9 -5
  18. package/src/chat/outbound.ts +4 -3
  19. package/src/claude/desktop-3p.ts +222 -2
  20. package/src/claude/outbound.ts +15 -6
  21. package/src/cli/account-api.ts +4 -0
  22. package/src/cli/account-extended.ts +112 -0
  23. package/src/cli/account.ts +23 -6
  24. package/src/cli/claude-desktop.ts +26 -3
  25. package/src/cli/config-command.ts +9 -0
  26. package/src/cli/help.ts +18 -2
  27. package/src/cli/index.ts +277 -55
  28. package/src/cli/models.ts +5 -1
  29. package/src/cli/provider.ts +8 -2
  30. package/src/cli/ready.ts +301 -0
  31. package/src/cli/system-restart-client.ts +146 -0
  32. package/src/cli/tray-proxy.ts +153 -6
  33. package/src/clients/config-export.ts +12 -19
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-namespaces.ts +49 -3
  36. package/src/codex/account-priority.ts +83 -0
  37. package/src/codex/auth-api.ts +83 -0
  38. package/src/codex/auth-context.ts +5 -2
  39. package/src/codex/catalog/provider-fetch.ts +11 -0
  40. package/src/codex/catalog/sync.ts +23 -1
  41. package/src/codex/codex-write-lock.ts +16 -4
  42. package/src/codex/desired-state.ts +37 -4
  43. package/src/codex/history-job.ts +15 -5
  44. package/src/codex/history-provider.ts +31 -14
  45. package/src/codex/history-worker.ts +28 -4
  46. package/src/codex/inject-coordination.ts +13 -1
  47. package/src/codex/inject.ts +360 -66
  48. package/src/codex/internal/history-writer.ts +1 -1
  49. package/src/codex/native-main-lock-file.ts +5 -1
  50. package/src/codex/native-main-owner.ts +17 -3
  51. package/src/codex/native-profile-manager.ts +19 -0
  52. package/src/codex/native-profile-startup.ts +8 -0
  53. package/src/codex/native-residue.ts +140 -27
  54. package/src/codex/pool-rotation.ts +74 -4
  55. package/src/codex/refresh.ts +7 -0
  56. package/src/codex/routing.ts +177 -36
  57. package/src/codex/subagent-model-fallback.ts +34 -4
  58. package/src/codex/sync.ts +61 -0
  59. package/src/codex/upstream-host-health.ts +329 -31
  60. package/src/combos/request.ts +2 -0
  61. package/src/config.ts +221 -2
  62. package/src/images/loop.ts +1 -1
  63. package/src/integrations/native/ownership-preflight.ts +39 -2
  64. package/src/lib/bun-stream-caps.ts +3 -3
  65. package/src/lib/sse-decoder.ts +41 -0
  66. package/src/lib/system-restart-contract.ts +73 -0
  67. package/src/lib/windows-secret-acl.ts +141 -39
  68. package/src/lib/windows-user-principal.ts +283 -0
  69. package/src/lib/winsw.ts +18 -2
  70. package/src/oauth/key-providers.ts +12 -0
  71. package/src/providers/derive.ts +54 -2
  72. package/src/providers/free-directory.ts +6 -5
  73. package/src/providers/model-discovery.ts +9 -3
  74. package/src/providers/quota.ts +592 -0
  75. package/src/providers/registry.ts +316 -13
  76. package/src/responses/parser.ts +26 -10
  77. package/src/responses/reasoning-replay-cache.ts +1 -0
  78. package/src/routing/profile-namespace.ts +15 -0
  79. package/src/routing/profile.ts +2 -1
  80. package/src/server/auth-cors.ts +44 -13
  81. package/src/server/chat-completions.ts +0 -4
  82. package/src/server/claude-messages.ts +73 -15
  83. package/src/server/github-copilot-responses-repair.ts +338 -0
  84. package/src/server/index.ts +328 -111
  85. package/src/server/lifecycle.ts +36 -0
  86. package/src/server/management/agent-settings-routes.ts +147 -56
  87. package/src/server/management/config-routes.ts +7 -2
  88. package/src/server/management/context.ts +4 -0
  89. package/src/server/management/native-integration-routes.ts +199 -20
  90. package/src/server/management/provider-routes.ts +41 -0
  91. package/src/server/management/routing-profile-routes.ts +234 -5
  92. package/src/server/management/system-restart.ts +12 -10
  93. package/src/server/management/system-routes.ts +20 -0
  94. package/src/server/management-auth.ts +51 -3
  95. package/src/server/ports.ts +41 -1
  96. package/src/server/proxy-liveness.ts +129 -4
  97. package/src/server/readiness.ts +99 -0
  98. package/src/server/relay.ts +113 -97
  99. package/src/server/request-log.ts +10 -4
  100. package/src/server/responses/compact.ts +107 -12
  101. package/src/server/responses/core.ts +220 -39
  102. package/src/server/responses-item-id-repair.ts +22 -3
  103. package/src/server/responses-model-rewrite.ts +29 -0
  104. package/src/server/sse-frame-buffer.ts +292 -0
  105. package/src/server/sse-payload-rewrite.ts +25 -14
  106. package/src/server/ws-bridge.ts +27 -22
  107. package/src/service-manager-probe.ts +520 -10
  108. package/src/service.ts +134 -2
  109. package/src/storage/worker-lifecycle.ts +14 -14
  110. package/src/tray/windows-tray.ps1 +74 -9
  111. package/src/types.ts +68 -2
  112. package/src/update/index.ts +12 -0
  113. package/src/update/job.ts +392 -18
  114. package/src/update/npm-cache-preflight.d.mts +47 -0
  115. package/src/update/npm-cache-preflight.mjs +201 -0
  116. package/src/usage/log.ts +1 -1
  117. package/src/vision/index.ts +77 -2
  118. package/src/web-search/loop.ts +1 -1
  119. package/src/web-search/parse.ts +4 -1
  120. package/gui/dist/assets/index-BKVqyYqT.js +0 -70
  121. package/gui/dist/assets/index-Ca_3269W.css +0 -1
@@ -0,0 +1,292 @@
1
+ export const MAX_CLIENT_SSE_FRAME_BYTES = 4 * 1024 * 1024;
2
+
3
+ const LF_LF = Uint8Array.of(10, 10);
4
+ const LF_CR_LF = Uint8Array.of(10, 13, 10);
5
+ const CR_LF_LF = Uint8Array.of(13, 10, 10);
6
+ const CR_LF_CR_LF = Uint8Array.of(13, 10, 13, 10);
7
+
8
+ export class SseFrameTooLargeError extends Error {
9
+ readonly maxBytes: number;
10
+
11
+ constructor(maxBytes: number) {
12
+ super(`upstream SSE frame exceeded ${maxBytes} bytes`);
13
+ this.name = "SseFrameTooLargeError";
14
+ this.maxBytes = maxBytes;
15
+ }
16
+ }
17
+
18
+ export class SseFrameCountLimitError extends Error {
19
+ readonly maxFrames: number;
20
+
21
+ constructor(maxFrames: number) {
22
+ super(`upstream SSE chunk exceeded ${maxFrames} frame limit`);
23
+ this.name = "SseFrameCountLimitError";
24
+ this.maxFrames = maxFrames;
25
+ }
26
+ }
27
+
28
+ export type BoundedSseFrame = {
29
+ block: Uint8Array;
30
+ delimiter: Uint8Array;
31
+ };
32
+
33
+ /**
34
+ * Classify the bytes at `index` as an SSE block delimiter.
35
+ *
36
+ * Returns the delimiter length in bytes, `0` when `index` does not start a
37
+ * delimiter, and `undefined` when more bytes are required to decide.
38
+ */
39
+ function delimiterLengthAt(
40
+ index: number,
41
+ length: number,
42
+ byteAt: (index: number) => number,
43
+ ): number | undefined {
44
+ const first = byteAt(index);
45
+ if (first === 10) {
46
+ if (index + 1 >= length) return undefined;
47
+ const second = byteAt(index + 1);
48
+ if (second === 10) return 2;
49
+ if (second !== 13) return 0;
50
+ if (index + 2 >= length) return undefined;
51
+ return byteAt(index + 2) === 10 ? 3 : 0;
52
+ }
53
+ if (first !== 13) return 0;
54
+ if (index + 1 >= length) return undefined;
55
+ if (byteAt(index + 1) !== 10) return 0;
56
+ if (index + 2 >= length) return undefined;
57
+ const third = byteAt(index + 2);
58
+ if (third === 10) return 3;
59
+ if (third !== 13) return 0;
60
+ if (index + 3 >= length) return undefined;
61
+ return byteAt(index + 3) === 10 ? 4 : 0;
62
+ }
63
+
64
+ function delimiterBytesAt(
65
+ index: number,
66
+ delimiterLength: number,
67
+ byteAt: (index: number) => number,
68
+ ): Uint8Array {
69
+ if (delimiterLength === 2) return LF_LF;
70
+ if (delimiterLength === 4) return CR_LF_CR_LF;
71
+ return byteAt(index) === 10 ? LF_CR_LF : CR_LF_LF;
72
+ }
73
+
74
+ function copyRange(
75
+ start: number,
76
+ end: number,
77
+ tailLength: number,
78
+ previousTail: Uint8Array,
79
+ chunk: Uint8Array,
80
+ ): Uint8Array {
81
+ const out = new Uint8Array(end - start);
82
+ for (let index = start; index < end; index += 1) {
83
+ out[index - start] = index < tailLength
84
+ ? previousTail[index]!
85
+ : chunk[index - tailLength]!;
86
+ }
87
+ return out;
88
+ }
89
+
90
+ /**
91
+ * True when a complete SSE block already commits a Responses terminal event.
92
+ *
93
+ * Framing errors in bytes *after* such a block must not retroactively turn an
94
+ * already-completed/failed/incomplete model turn into a transport failure. This
95
+ * helper is used only on the exceptional path, so decoding/JSON parsing has no
96
+ * cost on ordinary framing.
97
+ */
98
+ function isResponsesTerminalFrame(block: Uint8Array): boolean {
99
+ const data: string[] = [];
100
+ for (const line of new TextDecoder().decode(block).split(/\r?\n/)) {
101
+ if (!line.startsWith("data:")) continue;
102
+ const value = line.slice(5);
103
+ data.push(value.startsWith(" ") ? value.slice(1) : value);
104
+ }
105
+ if (data.length === 0) return false;
106
+ const payload = data.join("\n");
107
+ if (payload === "[DONE]") return false;
108
+ try {
109
+ const parsed = JSON.parse(payload) as { type?: unknown };
110
+ return parsed.type === "response.completed"
111
+ || parsed.type === "response.failed"
112
+ || parsed.type === "response.incomplete";
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Byte-bounded SSE block framer for client-facing protocol paths.
120
+ *
121
+ * The delimiter scanner works on raw bytes, so fragmented UTF-8 cannot change
122
+ * accounting and a hostile upstream cannot grow an unterminated JS string
123
+ * without limit. Candidate bytes live in one geometrically grown buffer rather
124
+ * than one allocation per upstream chunk, bounding both bytes and object count.
125
+ * Complete blocks are returned without their delimiter; the exact delimiter
126
+ * bytes are returned separately so callers can relay bytes unchanged.
127
+ */
128
+ export class BoundedSseFrameBuffer {
129
+ private readonly maxFrameBytes: number;
130
+ private readonly maxFramesPerFeed: number;
131
+ private delimiterTail: Uint8Array = new Uint8Array(0);
132
+ private candidate: Uint8Array = new Uint8Array(0);
133
+ private candidateBytes = 0;
134
+ private disposed = false;
135
+
136
+ constructor(maxFrameBytes = MAX_CLIENT_SSE_FRAME_BYTES) {
137
+ if (!Number.isSafeInteger(maxFrameBytes) || maxFrameBytes <= 0) {
138
+ throw new RangeError("maxFrameBytes must be a positive safe integer");
139
+ }
140
+ this.maxFrameBytes = maxFrameBytes;
141
+ // Delimiter-only input otherwise creates an object-amplification path that
142
+ // is independent of candidate bytes. Keep frame count proportional to the
143
+ // configured byte budget while leaving ample room for real Responses events.
144
+ this.maxFramesPerFeed = Math.max(1, Math.ceil(maxFrameBytes / 1024));
145
+ }
146
+
147
+ private clear(): void {
148
+ this.delimiterTail = new Uint8Array(0);
149
+ this.candidate = new Uint8Array(0);
150
+ this.candidateBytes = 0;
151
+ }
152
+
153
+ private ensureCapacity(requiredBytes: number): void {
154
+ if (this.candidate.byteLength >= requiredBytes) return;
155
+ if (requiredBytes > this.maxFrameBytes) {
156
+ throw new SseFrameTooLargeError(this.maxFrameBytes);
157
+ }
158
+ let capacity = this.candidate.byteLength === 0
159
+ ? Math.min(this.maxFrameBytes, Math.max(requiredBytes, 4096))
160
+ : this.candidate.byteLength;
161
+ while (capacity < requiredBytes) {
162
+ capacity = Math.min(this.maxFrameBytes, Math.max(requiredBytes, capacity * 2));
163
+ }
164
+ const grown = new Uint8Array(capacity);
165
+ if (this.candidateBytes > 0) {
166
+ grown.set(this.candidate.subarray(0, this.candidateBytes));
167
+ }
168
+ this.candidate = grown;
169
+ }
170
+
171
+ private retain(slice: Uint8Array): void {
172
+ if (slice.byteLength === 0) return;
173
+ const nextBytes = this.candidateBytes + slice.byteLength;
174
+ if (nextBytes > this.maxFrameBytes) {
175
+ this.clear();
176
+ this.disposed = true;
177
+ throw new SseFrameTooLargeError(this.maxFrameBytes);
178
+ }
179
+ this.ensureCapacity(nextBytes);
180
+ this.candidate.set(slice, this.candidateBytes);
181
+ this.candidateBytes = nextBytes;
182
+ }
183
+
184
+ private takeCandidate(): Uint8Array {
185
+ if (this.candidateBytes === 0) return new Uint8Array(0);
186
+ const block = this.candidate.slice(0, this.candidateBytes);
187
+ // Release the working allocation after each complete frame. This avoids
188
+ // retaining a rare multi-MiB frame allocation for the rest of a long-lived
189
+ // stream; normal small-frame allocation remains bounded by feed's frame cap.
190
+ this.candidate = new Uint8Array(0);
191
+ this.candidateBytes = 0;
192
+ return block;
193
+ }
194
+
195
+ feed(chunk: Uint8Array): BoundedSseFrame[] {
196
+ if (this.disposed) return [];
197
+ if (chunk.byteLength === 0) return [];
198
+
199
+ const frames: BoundedSseFrame[] = [];
200
+ const previousTail = this.delimiterTail;
201
+ this.delimiterTail = new Uint8Array(0);
202
+ const tailLength = previousTail.byteLength;
203
+ const totalLength = tailLength + chunk.byteLength;
204
+ const byteAt = (index: number): number => index < tailLength
205
+ ? previousTail[index]!
206
+ : chunk[index - tailLength]!;
207
+ const retainRange = (start: number, end: number): void => {
208
+ if (end <= start) return;
209
+ if (start < tailLength) {
210
+ this.retain(previousTail.subarray(start, Math.min(end, tailLength)));
211
+ }
212
+ if (end > tailLength) {
213
+ this.retain(chunk.subarray(Math.max(0, start - tailLength), end - tailLength));
214
+ }
215
+ };
216
+
217
+ try {
218
+ let index = 0;
219
+ let retainedThrough = 0;
220
+ while (index < totalLength) {
221
+ const delimiterLength = delimiterLengthAt(index, totalLength, byteAt);
222
+ if (delimiterLength === undefined) break;
223
+ if (delimiterLength > 0) {
224
+ if (frames.length >= this.maxFramesPerFeed) {
225
+ this.clear();
226
+ this.disposed = true;
227
+ throw new SseFrameCountLimitError(this.maxFramesPerFeed);
228
+ }
229
+ retainRange(retainedThrough, index);
230
+ const block = this.takeCandidate();
231
+ const delimiter = delimiterBytesAt(index, delimiterLength, byteAt);
232
+ frames.push({ block, delimiter });
233
+ index += delimiterLength;
234
+ retainedThrough = index;
235
+ continue;
236
+ }
237
+ index += 1;
238
+ }
239
+
240
+ retainRange(retainedThrough, index);
241
+ if (index < totalLength) {
242
+ this.delimiterTail = copyRange(index, totalLength, tailLength, previousTail, chunk);
243
+ }
244
+ return frames;
245
+ } catch (err) {
246
+ const framingError = err instanceof SseFrameTooLargeError
247
+ || err instanceof SseFrameCountLimitError;
248
+ if (framingError && frames.some(frame => isResponsesTerminalFrame(frame.block))) {
249
+ // A terminal frame is the Responses protocol boundary. Ignore malformed
250
+ // or oversized bytes that occur later in the same upstream chunk rather
251
+ // than retroactively replacing the committed terminal with a 502.
252
+ this.clear();
253
+ this.disposed = true;
254
+ return frames;
255
+ }
256
+ throw err;
257
+ }
258
+ }
259
+
260
+ /** Return the final unterminated block bytes and release all retained state. */
261
+ finish(): Uint8Array {
262
+ if (this.disposed) return new Uint8Array(0);
263
+ try {
264
+ this.retain(this.delimiterTail);
265
+ this.delimiterTail = new Uint8Array(0);
266
+ return this.takeCandidate();
267
+ } finally {
268
+ this.clear();
269
+ this.disposed = true;
270
+ }
271
+ }
272
+
273
+ dispose(): void {
274
+ if (this.disposed) return;
275
+ this.clear();
276
+ this.disposed = true;
277
+ }
278
+ }
279
+
280
+ export function joinSseFrameBytes(parts: readonly Uint8Array[]): Uint8Array {
281
+ let byteLength = 0;
282
+ for (const part of parts) byteLength += part.byteLength;
283
+ if (byteLength === 0) return new Uint8Array(0);
284
+ if (parts.length === 1 && parts[0]!.byteLength === byteLength) return parts[0]!;
285
+ const joined = new Uint8Array(byteLength);
286
+ let offset = 0;
287
+ for (const part of parts) {
288
+ joined.set(part, offset);
289
+ offset += part.byteLength;
290
+ }
291
+ return joined;
292
+ }
@@ -198,12 +198,14 @@ export function relaySseWithBlockRewrite(
198
198
  const emitProcessedBlocks = (
199
199
  controller: ReadableStreamDefaultController<Uint8Array>,
200
200
  flushFinal = false,
201
- ): void => {
201
+ ): number => {
202
+ let emitted = 0;
202
203
  let next: { block: string; delimiter: string; rest: string } | null;
203
204
  while ((next = nextSseBlock(buffer))) {
204
205
  replaceBuffer(next.rest);
205
206
  for (const outBlock of rewrite(next.block)) {
206
207
  enqueueText(controller, outBlock + next.delimiter);
208
+ emitted += 1;
207
209
  }
208
210
  }
209
211
  if (flushFinal && buffer.length > 0) {
@@ -213,28 +215,37 @@ export function relaySseWithBlockRewrite(
213
215
  const tailDelimiter = buffer.includes("\r\n") ? "\r\n\r\n" : "\n\n";
214
216
  for (let i = 0; i < tailBlocks.length; i++) {
215
217
  enqueueText(controller, tailBlocks[i]! + (i < tailBlocks.length - 1 ? tailDelimiter : ""));
218
+ emitted += 1;
216
219
  }
217
220
  releaseBuffer();
218
221
  }
222
+ return emitted;
219
223
  };
220
224
 
221
225
  return new ReadableStream<Uint8Array>({
222
226
  async pull(controller) {
223
227
  try {
224
- const { done, value } = await reader.read();
225
- // A cancel raced this pending read: never feed the rewriter again
226
- // after its disposal (#893 review).
227
- if (cancelled) return;
228
- if (done) {
229
- appendBuffer(decoder.decode());
230
- emitProcessedBlocks(controller, true);
231
- releaseBuffer();
232
- disposeRewrite();
233
- controller.close();
234
- return;
228
+ // A network chunk is not an SSE-event boundary. Bun may not issue a
229
+ // second pull after a fulfilled pull enqueues nothing, so keep reading
230
+ // until at least one complete rewritten block is available or EOF is
231
+ // reached. This also handles block rewrites that intentionally drop an
232
+ // event without parking the client stream.
233
+ for (;;) {
234
+ const { done, value } = await reader.read();
235
+ // A cancel raced this pending read: never feed the rewriter again
236
+ // after its disposal (#893 review).
237
+ if (cancelled) return;
238
+ if (done) {
239
+ appendBuffer(decoder.decode());
240
+ emitProcessedBlocks(controller, true);
241
+ releaseBuffer();
242
+ disposeRewrite();
243
+ controller.close();
244
+ return;
245
+ }
246
+ appendBuffer(decoder.decode(value, { stream: true }));
247
+ if (emitProcessedBlocks(controller) > 0) return;
235
248
  }
236
- appendBuffer(decoder.decode(value, { stream: true }));
237
- emitProcessedBlocks(controller);
238
249
  } catch (error) {
239
250
  releaseBuffer();
240
251
  disposeRewrite();
@@ -6,6 +6,7 @@ import { headersForCodexAuthContext } from "../codex/auth-context";
6
6
  import type { ResponsesTerminalStatus } from "../bridge";
7
7
  import type { DataPlaneAdmission } from "./auth-cors";
8
8
  import type { AdmissionLease, AdmissionReservation } from "../lib/admission";
9
+ import { BoundedSseFrameBuffer } from "./sse-frame-buffer";
9
10
 
10
11
  const OPEN = 1;
11
12
  type ResponsesTerminalReporter = (status: ResponsesTerminalStatus) => void;
@@ -163,15 +164,6 @@ function parseSseBlock(block: string): string | null {
163
164
  return data.length > 0 ? data.join("\n") : null;
164
165
  }
165
166
 
166
- function nextSseBlock(buffer: string): { block: string; rest: string } | null {
167
- const match = buffer.match(/\r?\n\r?\n/);
168
- if (!match || match.index === undefined) return null;
169
- return {
170
- block: buffer.slice(0, match.index),
171
- rest: buffer.slice(match.index + match[0].length),
172
- };
173
- }
174
-
175
167
  function payloadType(payload: string): string | null {
176
168
  try {
177
169
  const json = JSON.parse(payload) as { type?: unknown };
@@ -231,7 +223,7 @@ export async function pumpResponsesSseToWebSocket(
231
223
  ws.data.cancel = cancel;
232
224
 
233
225
  const decoder = new TextDecoder();
234
- let buffer = "";
226
+ const framer = new BoundedSseFrameBuffer();
235
227
  let terminalSeen = false;
236
228
 
237
229
  const handlePayload = (payload: string): boolean => {
@@ -266,17 +258,14 @@ export async function pumpResponsesSseToWebSocket(
266
258
  while (!terminalSeen) {
267
259
  const { done, value } = await reader.read();
268
260
  if (done) break;
269
- buffer += decoder.decode(value, { stream: true });
270
- let next: { block: string; rest: string } | null;
271
- while ((next = nextSseBlock(buffer))) {
272
- buffer = next.rest;
273
- const payload = parseSseBlock(next.block);
261
+ for (const frame of framer.feed(value)) {
262
+ const payload = parseSseBlock(decoder.decode(frame.block));
274
263
  if (payload && handlePayload(payload)) break;
275
264
  }
276
265
  }
277
- buffer += decoder.decode();
278
- if (!terminalSeen && buffer.trim()) {
279
- const payload = parseSseBlock(buffer);
266
+ const tail = framer.finish();
267
+ if (!terminalSeen && tail.byteLength > 0) {
268
+ const payload = parseSseBlock(decoder.decode(tail));
280
269
  if (payload) handlePayload(payload);
281
270
  }
282
271
  if (!terminalSeen && isCurrent() && !clientCancelled) {
@@ -284,11 +273,27 @@ export async function pumpResponsesSseToWebSocket(
284
273
  sendProtocolError(ws, 502, "Upstream stream ended before response terminal event");
285
274
  }
286
275
  } catch (err) {
287
- if (!terminalSeen && isCurrent() && ws.readyState === OPEN) {
288
- if (!(err instanceof WsSendDroppedError)) reportTerminal("incomplete");
289
- sendProtocolError(ws, 502, err instanceof Error ? err.message : String(err));
276
+ framer.dispose();
277
+ if (err instanceof WsSendDroppedError) throw err;
278
+ if (!terminalSeen
279
+ && isCurrent()
280
+ && ws.readyState === OPEN
281
+ && !(err instanceof WsSendDroppedError)) {
282
+ reportTerminal("incomplete");
283
+ try {
284
+ sendProtocolError(ws, 502, err instanceof Error ? err.message : String(err));
285
+ } catch (sendErr) {
286
+ // If delivery is already dropped, there is no useful error frame left
287
+ // to send. Swallow only that expected transport signal; other failures
288
+ // still surface to the caller after the upstream reader is released.
289
+ if (!(sendErr instanceof WsSendDroppedError)) throw sendErr;
290
+ }
290
291
  }
291
292
  } finally {
293
+ framer.dispose();
294
+ // Framing errors can occur while the upstream body is still live. Always
295
+ // release the reader, even when terminal/send paths already cancelled it.
296
+ void reader.cancel().catch(() => {});
292
297
  if (ws.data.cancel === cancel) ws.data.cancel = undefined;
293
298
  }
294
299
  }
@@ -462,4 +467,4 @@ export async function readBoundedPrefix(
462
467
  export function looksLikeSse(prefix: Uint8Array): boolean {
463
468
  const text = new TextDecoder().decode(prefix);
464
469
  return /^\s*(event:|data:)/.test(text);
465
- }
470
+ }