@bitkyc08/opencodex 2.7.36 → 2.7.37

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 (66) hide show
  1. package/README.ja.md +8 -1
  2. package/README.ko.md +7 -1
  3. package/README.md +7 -1
  4. package/README.ru.md +7 -1
  5. package/README.zh-CN.md +7 -1
  6. package/gui/dist/assets/index-BhUTxmCy.js +52 -0
  7. package/gui/dist/assets/index-oOZcqVmj.css +1 -0
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic.ts +22 -2
  11. package/src/adapters/cursor/live-transport.ts +7 -0
  12. package/src/adapters/cursor/message-mapper.ts +3 -0
  13. package/src/adapters/cursor/protobuf-request.ts +223 -27
  14. package/src/adapters/cursor/request-builder.ts +41 -15
  15. package/src/adapters/cursor/thread-continuity.ts +67 -0
  16. package/src/adapters/cursor/types.ts +3 -1
  17. package/src/adapters/cursor.ts +44 -9
  18. package/src/adapters/google.ts +115 -62
  19. package/src/adapters/kiro.ts +3 -17
  20. package/src/adapters/openai-chat.ts +16 -5
  21. package/src/adapters/openai-responses.ts +56 -1
  22. package/src/adapters/run-turn-queue.ts +11 -1
  23. package/src/bridge.ts +139 -69
  24. package/src/chat/outbound.ts +135 -73
  25. package/src/cli/codex-shim-autorestore.ts +45 -0
  26. package/src/cli/doctor.ts +197 -2
  27. package/src/cli/index.ts +17 -3
  28. package/src/cli/status.ts +80 -0
  29. package/src/cli/v2.ts +14 -2
  30. package/src/codex/auth-context.ts +18 -2
  31. package/src/codex/catalog/bundled.ts +83 -27
  32. package/src/codex/catalog/effort.ts +95 -3
  33. package/src/codex/catalog/parsing.ts +17 -0
  34. package/src/codex/catalog/provider-fetch.ts +31 -8
  35. package/src/codex/exec-invocation.ts +22 -0
  36. package/src/codex/model-cache.ts +44 -0
  37. package/src/codex/runtime.ts +529 -0
  38. package/src/codex/shim.ts +608 -10
  39. package/src/combos/resolve.ts +7 -2
  40. package/src/config.ts +32 -1
  41. package/src/lib/bun-stream-caps.ts +88 -0
  42. package/src/lib/crash-guard.ts +3 -1
  43. package/src/lib/sse-decoder.ts +25 -6
  44. package/src/responses/parser.ts +2 -1
  45. package/src/responses/state.ts +10 -2
  46. package/src/server/auth-cors.ts +4 -1
  47. package/src/server/index.ts +191 -1
  48. package/src/server/live.ts +491 -0
  49. package/src/server/management/config-routes.ts +79 -3
  50. package/src/server/management/provider-routes.ts +2 -0
  51. package/src/server/management/shared.ts +6 -6
  52. package/src/server/management/system-routes.ts +65 -0
  53. package/src/server/management-api.ts +3 -1
  54. package/src/server/memory-watchdog.ts +112 -0
  55. package/src/server/relay-eager.ts +199 -0
  56. package/src/server/relay.ts +131 -81
  57. package/src/server/responses/collaboration.ts +20 -3
  58. package/src/server/responses/core.ts +236 -21
  59. package/src/server/responses/encrypted-payload.ts +118 -41
  60. package/src/server/ws-bridge.ts +7 -0
  61. package/src/types.ts +25 -0
  62. package/src/usage/cost.ts +0 -0
  63. package/src/usage/expected-prices.ts +19 -0
  64. package/src/usage/summary.ts +11 -8
  65. package/gui/dist/assets/index-BpX-hoSd.css +0 -1
  66. package/gui/dist/assets/index-ZmFopEYw.js +0 -52
@@ -143,13 +143,18 @@ export function noteComboFailure(comboId: string, target: OcxComboTarget): void
143
143
  export function advanceComboAfterFailure(
144
144
  config: OcxConfig,
145
145
  pick: ComboPick,
146
- options: { retryAfter?: string | null; now?: number } = {},
146
+ options: {
147
+ retryAfter?: string | null;
148
+ now?: number;
149
+ eligible?: (target: Required<OcxComboTarget>) => boolean;
150
+ } = {},
147
151
  ): ComboPick | null {
148
152
  noteComboFailure(pick.comboId, pick.target);
149
153
  coolComboTarget(pick.comboId, pick.target, options);
150
154
  return pickComboTarget(config, pick.comboId, {
151
155
  exclude: pick.attempted,
152
- eligible: target => !isComboTargetInCooldown(pick.comboId, target, options.now),
156
+ eligible: target => !isComboTargetInCooldown(pick.comboId, target, options.now)
157
+ && (options.eligible?.(target) ?? true),
153
158
  });
154
159
  }
155
160
 
package/src/config.ts CHANGED
@@ -442,6 +442,11 @@ const configSchema = z.object({
442
442
  providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(),
443
443
  contextCapValue: z.number().int().positive().optional(),
444
444
  multiAgentGuidanceEnabled: z.boolean().optional(),
445
+ codexShimAutoRestore: z.boolean().optional(),
446
+ // Invalid values degrade to undefined ("auto") instead of failing the whole
447
+ // parse: a hand-edited typo must never trip the backup-and-defaults repair
448
+ // path below and wipe providers/pool accounts. Warning emitted in loadConfig.
449
+ streamMode: z.enum(["auto", "legacy-tee", "eager-relay"]).optional().catch(undefined),
445
450
  }).passthrough().superRefine((config, ctx) => {
446
451
  for (const name of Object.keys(config.providers)) {
447
452
  if (!isValidProviderName(name)) {
@@ -642,6 +647,19 @@ export function hardenExistingSecret(path: string): void {
642
647
  }
643
648
  }
644
649
  }
650
+ /**
651
+ * The schema's `.catch(undefined)` silently degrades an invalid persisted
652
+ * `streamMode` to "auto"; surface that once so a hand-edited typo (e.g.
653
+ * "legacy_tee") is discoverable instead of silently changing stream shape.
654
+ */
655
+ function warnDegradedStreamMode(rawParsed: unknown, validated: OcxConfig): void {
656
+ if (!rawParsed || typeof rawParsed !== "object") return;
657
+ const raw = (rawParsed as Record<string, unknown>).streamMode;
658
+ if (raw !== undefined && validated.streamMode === undefined) {
659
+ console.warn(`⚠️ config.json streamMode ${JSON.stringify(raw)} is invalid (expected "auto", "legacy-tee", or "eager-relay") — falling back to "auto"`);
660
+ }
661
+ }
662
+
645
663
  export function loadConfig(): OcxConfig {
646
664
  const dir = getConfigDir();
647
665
  const configPath = getConfigPath();
@@ -655,7 +673,10 @@ export function loadConfig(): OcxConfig {
655
673
  const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, "");
656
674
  const parsed = JSON.parse(raw);
657
675
  const result = configSchema.safeParse(parsed);
658
- if (result.success) return result.data as OcxConfig;
676
+ if (result.success) {
677
+ warnDegradedStreamMode(parsed, result.data as OcxConfig);
678
+ return result.data as OcxConfig;
679
+ }
659
680
  // Schema validation failed — merge defaults into the raw object instead of
660
681
  // discarding it entirely, so pool accounts and providers survive a missing
661
682
  // field like defaultProvider.
@@ -773,6 +794,15 @@ export function codexAutoStartEnabled(config: Pick<OcxConfig, "codexAutoStart">)
773
794
  return config.codexAutoStart !== false;
774
795
  }
775
796
 
797
+ export const CODEX_SHIM_AUTO_RESTORE_ENV = "OPENCODEX_CODEX_SHIM_AUTO_RESTORE";
798
+
799
+ export function codexShimAutoRestoreEnabled(
800
+ config: Pick<OcxConfig, "codexShimAutoRestore">,
801
+ env: NodeJS.ProcessEnv = process.env,
802
+ ): boolean {
803
+ return config.codexShimAutoRestore !== false && env[CODEX_SHIM_AUTO_RESTORE_ENV] !== "0";
804
+ }
805
+
776
806
  export function multiAgentGuidanceEnabled(
777
807
  config: Pick<OcxConfig, "multiAgentGuidanceEnabled">,
778
808
  ): boolean {
@@ -802,6 +832,7 @@ export function getDefaultConfig(): OcxConfig {
802
832
  multiAgentGuidanceEnabled: true,
803
833
  websockets: false,
804
834
  codexAutoStart: true,
835
+ codexShimAutoRestore: true,
805
836
  };
806
837
  }
807
838
 
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Bun runtime stream-capability gate for the Windows SSE passthrough path (#314).
3
+ *
4
+ * The eager bounded relay (src/server/relay-eager.ts) uses a JS async producer
5
+ * loop — the exact shape of the Bun#32111 use-after-free (fixed upstream by Bun
6
+ * PR #32120, merged 2026-06-21). No RELEASED Bun version is proven to carry
7
+ * that fix yet, so `MIN_FIXED_BUN_VERSION` is null: every runtime is
8
+ * "known-bad" until a bundle-bump commit sets it. Config `streamMode` can force
9
+ * either path (persisted in config.json because Windows services do not
10
+ * inherit shell env — see devlog/_plan/260723_win_mem_safestream/001).
11
+ *
12
+ * Prerelease conservatism: a version carrying a prerelease suffix (e.g.
13
+ * `1.4.0-canary.3`) is NEVER treated as fixed even when its numeric triple
14
+ * reaches the threshold — canaries are exactly the OPENCODEX_BUN_PATH audience
15
+ * and may predate the fix commit.
16
+ */
17
+
18
+ /**
19
+ * Bump in the SAME commit that bumps package.json's bundled Bun to a version
20
+ * verified to include Bun PR #32120. null = no released version is known-fixed.
21
+ */
22
+ export const MIN_FIXED_BUN_VERSION: string | null = null;
23
+
24
+ export type StreamMode = "auto" | "legacy-tee" | "eager-relay";
25
+
26
+ export const STREAM_MODES: readonly StreamMode[] = ["auto", "legacy-tee", "eager-relay"];
27
+
28
+ export function isStreamMode(value: unknown): value is StreamMode {
29
+ return typeof value === "string" && (STREAM_MODES as readonly string[]).includes(value);
30
+ }
31
+
32
+ /** Numeric [major, minor, patch] triple, or null for unparseable input. */
33
+ export function parseBunVersion(version: string): [number, number, number] | null {
34
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(version.trim());
35
+ if (!m) return null;
36
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
37
+ }
38
+
39
+ /** Compare two version strings numerically; null when either is unparseable. */
40
+ export function compareBunVersions(a: string, b: string): number | null {
41
+ const pa = parseBunVersion(a);
42
+ const pb = parseBunVersion(b);
43
+ if (!pa || !pb) return null;
44
+ for (let i = 0; i < 3; i++) {
45
+ if (pa[i]! !== pb[i]!) return pa[i]! - pb[i]!;
46
+ }
47
+ return 0;
48
+ }
49
+
50
+ function hasPrereleaseSuffix(version: string): boolean {
51
+ return /^\d+\.\d+\.\d+-/.test(version.trim());
52
+ }
53
+
54
+ /**
55
+ * True only when `version` is proven to carry the Bun#32120 async-pull cancel
56
+ * fix. Conservative: unknown, unparseable, prerelease, or no threshold → false.
57
+ */
58
+ export function bunHasAsyncPullCancelFix(
59
+ version: string,
60
+ minFixed: string | null = MIN_FIXED_BUN_VERSION,
61
+ ): boolean {
62
+ if (!minFixed) return false;
63
+ if (hasPrereleaseSuffix(version)) return false;
64
+ const cmp = compareBunVersions(version, minFixed);
65
+ return cmp !== null && cmp >= 0;
66
+ }
67
+
68
+ export type EagerRelayDecision = {
69
+ useEagerRelay: boolean;
70
+ reason: "config-legacy" | "config-eager" | "auto-fixed-runtime" | "auto-known-bad";
71
+ };
72
+
73
+ /**
74
+ * Decide the win32 SSE client-path shape. `version`/`minFixed` are injectable
75
+ * for tests. Non-win32 callers never consult this (their default path is
76
+ * unchanged); the caller owns the platform check.
77
+ */
78
+ export function decideEagerRelay(
79
+ mode: StreamMode,
80
+ version: string = Bun.version,
81
+ minFixed: string | null = MIN_FIXED_BUN_VERSION,
82
+ ): EagerRelayDecision {
83
+ if (mode === "legacy-tee") return { useEagerRelay: false, reason: "config-legacy" };
84
+ if (mode === "eager-relay") return { useEagerRelay: true, reason: "config-eager" };
85
+ return bunHasAsyncPullCancelFix(version, minFixed)
86
+ ? { useEagerRelay: true, reason: "auto-fixed-runtime" }
87
+ : { useEagerRelay: false, reason: "auto-known-bad" };
88
+ }
@@ -162,7 +162,9 @@ const BENIGN_LOG_INTERVAL_MS = 5 * 60_000;
162
162
  * disconnects mid-SSE on the tee()'d passthrough path (responses.ts Bun#32111
163
163
  * workaround), Bun's sink-close teardown tries to cancel the tee-locked source body
164
164
  * and rejects off-path. Request lifecycle is already settled at that point; same
165
- * benign handling applies.
165
+ * benign handling applies. The tee path remains the DEFAULT passthrough shape;
166
+ * the gated eager relay (relay-eager.ts, #314) does not tee and may not produce
167
+ * this shape — detection stays unchanged either way.
166
168
  */
167
169
  export function isBenignAbortTeardown(err: unknown): boolean {
168
170
  if (!(err instanceof TypeError)) return false;
@@ -3,6 +3,10 @@ export interface ServerSentEvent {
3
3
  data: string;
4
4
  }
5
5
 
6
+ export type SseRecord =
7
+ | { kind: "event"; event?: string; data: string }
8
+ | { kind: "comment"; comment: string };
9
+
6
10
  /**
7
11
  * Decode text/event-stream records across arbitrary fetch chunk boundaries.
8
12
  *
@@ -10,10 +14,18 @@ export interface ServerSentEvent {
10
14
  * final newline. That matters for compatible APIs that place a terminal event in the last bytes of
11
15
  * the body: dropping that record turns a successful response into an adapter_eof failure.
12
16
  */
17
+ export function decodeServerSentEvents(
18
+ source: ReadableStream<Uint8Array>,
19
+ options: { includeComments: true; signal?: AbortSignal },
20
+ ): AsyncGenerator<SseRecord>;
21
+ export function decodeServerSentEvents(
22
+ source: ReadableStream<Uint8Array>,
23
+ options?: { includeComments?: false; signal?: AbortSignal },
24
+ ): AsyncGenerator<ServerSentEvent>;
13
25
  export async function* decodeServerSentEvents(
14
26
  source: ReadableStream<Uint8Array>,
15
- options?: { signal?: AbortSignal },
16
- ): AsyncGenerator<ServerSentEvent> {
27
+ options?: { includeComments?: boolean; signal?: AbortSignal },
28
+ ): AsyncGenerator<ServerSentEvent | SseRecord> {
17
29
  const reader = source.getReader();
18
30
  const decoder = new TextDecoder();
19
31
  let buffer = "";
@@ -27,7 +39,9 @@ export async function* decodeServerSentEvents(
27
39
  if (signal?.aborted) onAbort();
28
40
  else signal?.addEventListener("abort", onAbort, { once: true });
29
41
 
30
- const dispatch = (): ServerSentEvent | undefined => {
42
+ const includeComments = options?.includeComments === true;
43
+
44
+ const dispatch = (): ServerSentEvent | SseRecord | undefined => {
31
45
  if (dataLines.length === 0) {
32
46
  event = undefined;
33
47
  return undefined;
@@ -35,13 +49,18 @@ export async function* decodeServerSentEvents(
35
49
  const record = { ...(event ? { event } : {}), data: dataLines.join("\n") };
36
50
  event = undefined;
37
51
  dataLines = [];
38
- return record;
52
+ return includeComments ? { kind: "event", ...record } : record;
39
53
  };
40
54
 
41
- const acceptLine = (rawLine: string): ServerSentEvent | undefined => {
55
+ const acceptLine = (rawLine: string): ServerSentEvent | SseRecord | undefined => {
42
56
  const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
43
57
  if (line === "") return dispatch();
44
- if (line.startsWith(":")) return undefined;
58
+ if (line.startsWith(":")) {
59
+ if (!includeComments) return undefined;
60
+ let comment = line.slice(1);
61
+ if (comment.startsWith(" ")) comment = comment.slice(1);
62
+ return { kind: "comment", comment };
63
+ }
45
64
 
46
65
  const colon = line.indexOf(":");
47
66
  const field = colon < 0 ? line : line.slice(0, colon);
@@ -129,7 +129,7 @@ function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined {
129
129
  out.push({
130
130
  name: t.name,
131
131
  description: (t.description as string) ?? "",
132
- parameters: { type: "object", properties: { input: { type: "string", description: "Raw tool input (verbatim body, e.g. the apply_patch envelope)." } }, required: ["input"] },
132
+ parameters: { type: "object", properties: { input: { type: "string", description: "Raw tool input. For apply_patch, begin exactly with `*** Begin Patch` (no trailing `***`), then use its standard patch envelope." } }, required: ["input"] },
133
133
  freeform: true,
134
134
  });
135
135
  }
@@ -594,6 +594,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
594
594
  stream: data.stream === true,
595
595
  options,
596
596
  _rawBody: body,
597
+ ...(replayedInputPrefixLength > 0 ? { _replayPrefixLen: replayedInputPrefixLength } : {}),
597
598
  ...(webSearch ? { _webSearch: webSearch } : {}),
598
599
  ...(structuredOutput ? { _structuredOutput: true } : {}),
599
600
  ...(compactionRequest ? { _compactionRequest: true } : {}),
@@ -241,9 +241,13 @@ export function previousResponseProviderState(responseId: string | undefined): O
241
241
  return providers ? structuredClone(providers) : undefined;
242
242
  }
243
243
 
244
+ /**
245
+ * Cache completed output and max_output_tokens partial output for previous_response_id replay.
246
+ * Content-filtered incomplete and failed output are not authoritative replay history.
247
+ */
244
248
  export function rememberResponseState(
245
249
  requestBody: unknown,
246
- response: { id?: unknown; output?: unknown; status?: unknown },
250
+ response: { id?: unknown; output?: unknown; status?: unknown; incomplete_details?: unknown },
247
251
  providerState?: OcxProviderContinuationState | string,
248
252
  opts?: { force?: boolean },
249
253
  ): void {
@@ -256,7 +260,11 @@ export function rememberResponseState(
256
260
  // real server-side response storage.
257
261
  if (request.store === false && !opts?.force) return;
258
262
  if (typeof response.id !== "string" || !Array.isArray(response.output)) return;
259
- if (response.status !== undefined && response.status !== "completed") return;
263
+ if (response.status === "incomplete") {
264
+ const details = response.incomplete_details;
265
+ if (!details || typeof details !== "object" || Array.isArray(details)
266
+ || (details as { reason?: unknown }).reason !== "max_output_tokens") return;
267
+ } else if (response.status !== undefined && response.status !== "completed") return;
260
268
  ensureLoaded();
261
269
  const normalizedProviderState: OcxProviderContinuationState = typeof providerState === "string"
262
270
  ? { cursor: { conversationId: providerState } }
@@ -80,7 +80,10 @@ export function corsHeaders(req?: Request, config?: OcxConfig): Record<string, s
80
80
  return {
81
81
  "Access-Control-Allow-Origin": allowOrigin,
82
82
  "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
83
- "Access-Control-Allow-Headers": "Content-Type, Authorization, X-OpenCodex-API-Key, X-Api-Key, Anthropic-Version, Anthropic-Beta",
83
+ // ChatGPT-Account-Id is required for browser/Electron ChatGPT & Codex App voice preflights
84
+ // (direct forward auth matches the bearer to this account id). The OpenAI-Alpha .. X-OAI-Attestation
85
+ // block covers GPT-Live voice protocol headers relayed by the /v1/live call-create path.
86
+ "Access-Control-Allow-Headers": "Content-Type, Authorization, X-OpenCodex-API-Key, X-Api-Key, Anthropic-Version, Anthropic-Beta, ChatGPT-Account-Id, OpenAI-Alpha, X-Session-Id, Session-Id, Thread-Id, Originator, X-OAI-Attestation",
84
87
  "Vary": "Origin",
85
88
  };
86
89
  }
@@ -18,6 +18,7 @@ import {
18
18
  } from "../config";
19
19
  import { reconcileOAuthProviders } from "../oauth";
20
20
  import { invalidateCodexModelsCache } from "../codex/catalog";
21
+ import { startMemoryWatchdog } from "./memory-watchdog";
21
22
  import { runOpenAiTierStartupMigration } from "../providers/openai-tier-startup";
22
23
  import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
23
24
  import { providerCodexAccountMode } from "../providers/registry";
@@ -121,11 +122,90 @@ import { handleChatCompletions } from "./chat-completions";
121
122
  import { anthropicErrorResponse } from "../claude/outbound";
122
123
  import { buildDesktop3pRegistry } from "../claude/desktop-3p";
123
124
  import { handleImages } from "./images";
125
+ import { handleLive, parseLiveSidebandTarget, resolveLiveSidebandUpgrade } from "./live";
124
126
  import { handleSearch } from "./search";
125
127
  import { fetchAllModels, handleManagementAPI, VERSION } from "./management-api";
126
128
 
127
129
  const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
128
130
  const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0;
131
+ const LIVE_SIDEBAND_PENDING_MAX = 32;
132
+
133
+ function closeLiveSideband(ws: ServerWebSocket<WsData>, code = 1000, reason = ""): void {
134
+ try {
135
+ ws.data.liveUpstream?.close(code, reason);
136
+ } catch {
137
+ /* upstream already gone */
138
+ }
139
+ ws.data.liveUpstream = undefined;
140
+ ws.data.livePending = undefined;
141
+ try {
142
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
143
+ ws.close(code, reason);
144
+ }
145
+ } catch {
146
+ /* client already gone */
147
+ }
148
+ }
149
+
150
+ function attachLiveSidebandUpstream(ws: ServerWebSocket<WsData>): void {
151
+ const url = ws.data.liveUpstreamUrl;
152
+ if (!url) {
153
+ closeLiveSideband(ws, 1011, "missing upstream");
154
+ return;
155
+ }
156
+ let upstream: WebSocket;
157
+ try {
158
+ // Bun accepts per-handshake headers; the DOM lib types only list protocol arrays.
159
+ upstream = new WebSocket(url, { headers: ws.data.liveUpstreamHeaders ?? {} } as unknown as string[]);
160
+ } catch {
161
+ closeLiveSideband(ws, 1011, "upstream connect failed");
162
+ return;
163
+ }
164
+ ws.data.liveUpstream = upstream;
165
+ ws.data.cancel = () => {
166
+ try {
167
+ upstream.close(1000, "client closed");
168
+ } catch {
169
+ /* ignore */
170
+ }
171
+ };
172
+
173
+ upstream.addEventListener("open", () => {
174
+ ws.data.liveOpened = true;
175
+ const pending = ws.data.livePending ?? [];
176
+ ws.data.livePending = undefined;
177
+ for (const frame of pending) {
178
+ try {
179
+ upstream.send(frame);
180
+ } catch {
181
+ closeLiveSideband(ws, 1011, "upstream send failed");
182
+ return;
183
+ }
184
+ }
185
+ });
186
+ upstream.addEventListener("message", (event) => {
187
+ try {
188
+ if (typeof event.data === "string") ws.send(event.data);
189
+ else if (event.data instanceof ArrayBuffer) ws.send(event.data);
190
+ else if (ArrayBuffer.isView(event.data)) {
191
+ ws.send(event.data.buffer.slice(event.data.byteOffset, event.data.byteOffset + event.data.byteLength));
192
+ } else ws.send(event.data as Buffer);
193
+ } catch {
194
+ closeLiveSideband(ws, 1011, "client send failed");
195
+ }
196
+ });
197
+ upstream.addEventListener("close", (event) => {
198
+ try {
199
+ ws.close(event.code || 1000, event.reason || "");
200
+ } catch {
201
+ /* ignore */
202
+ }
203
+ ws.data.liveUpstream = undefined;
204
+ });
205
+ upstream.addEventListener("error", () => {
206
+ closeLiveSideband(ws, 1011, "upstream error");
207
+ });
208
+ }
129
209
 
130
210
  // GUI static serving extracted to ./server/gui-static. Re-exported below to keep the
131
211
  // "../src/server" import surface stable for tests/callers.
@@ -134,8 +214,12 @@ const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0;
134
214
 
135
215
  // Source invariant for tests/passthrough-abort.test.ts after the pure module split:
136
216
  // if (isEventStream && upstreamResponse.body) {
137
- // upstreamResponse.body.tee()
138
217
  // const repairConfig = route.provider.responsesItemIdRepair;
218
+ // #314 gated shape (win32-no-repair only; default OFF on the bundled known-bad runtime):
219
+ // decideEagerRelay(config.streamMode ?? "auto")
220
+ // relaySseEagerBounded(upstreamResponse.body, turnAc,
221
+ // Default shape (tee + background inspection):
222
+ // upstreamResponse.body.tee()
139
223
  // const repairedBody = hasResponsesItemIdRepair(repairConfig)
140
224
  // process.platform === "win32"
141
225
  // && !hasResponsesItemIdRepair(repairConfig)
@@ -184,6 +268,9 @@ export function startServer(port?: number) {
184
268
  // usage.jsonl already persists every request; rehydrate the in-memory Logs ring so
185
269
  // /api/logs (and the GUI) survive `ocx stop` / `ocx start` process restarts.
186
270
  hydrateRequestLogsFromDisk();
271
+ // #314: warn-only RSS observability (unref'd, idempotent — safe under repeated
272
+ // startServer(0) in tests). Snapshot surfaces via GET /api/system/memory.
273
+ startMemoryWatchdog();
187
274
 
188
275
  const listenPort = port ?? config.port ?? 10100;
189
276
  setCorsOrigin(listenPort);
@@ -490,6 +577,77 @@ export function startServer(port?: number) {
490
577
  return withCors(response, req, config);
491
578
  }
492
579
 
580
+ // ChatGPT / Codex App voice (GPT‑Live / Frameless Bidi) + OpenAI Realtime call-create.
581
+ // Clients hit either /v1/live (Frameless App) or /v1/realtime/calls (codex RealtimeCallClient /
582
+ // public Realtime API). Sideband WS joins are handled just below.
583
+ if (
584
+ req.method === "POST"
585
+ && (url.pathname === "/v1/live" || url.pathname === "/v1/realtime/calls")
586
+ ) {
587
+ disableResponsesRequestTimeout(req, requestServer);
588
+ if (isDraining()) {
589
+ return new Response("Service shutting down", {
590
+ status: 503,
591
+ headers: { ...corsHeaders(req, config), "Retry-After": "5" },
592
+ });
593
+ }
594
+ const apiAuthError = requireApiAuth(req, config, "data-plane");
595
+ if (apiAuthError) return withCors(apiAuthError, req, config);
596
+ if (!isAllowedRequestOrigin(req, config)) {
597
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
598
+ }
599
+ const start = Date.now();
600
+ const requestId = nextRequestLogId(start);
601
+ const logCtx: RequestLogContext = { model: "gpt-live", provider: "unknown" };
602
+ const response = await handleLive(req, config, logCtx);
603
+ addFinalRequestLog(
604
+ requestId,
605
+ start,
606
+ logCtx,
607
+ response.status,
608
+ response.status === 499 ? { closeReason: "client_cancel" } : undefined,
609
+ );
610
+ return withCors(response, req, config);
611
+ }
612
+
613
+ // Voice / Realtime sideband WebSocket: Frameless joins /v1/live/{callId}; Realtime v1 joins
614
+ // /v1/realtime?call_id= (or /v1/realtime/calls/{callId}). Transparent bidirectional relay.
615
+ const liveSidebandTarget = req.headers.get("upgrade")?.toLowerCase() === "websocket"
616
+ ? parseLiveSidebandTarget(url.pathname, url.searchParams)
617
+ : null;
618
+ if (liveSidebandTarget) {
619
+ if (isDraining()) {
620
+ return new Response("Service shutting down", {
621
+ status: 503,
622
+ headers: { ...corsHeaders(req, config), "Retry-After": "5" },
623
+ });
624
+ }
625
+ const apiAuthError = requireApiAuth(req, config, "data-plane");
626
+ if (apiAuthError) return withCors(apiAuthError, req, config);
627
+ if (!isAllowedRequestOrigin(req, config)) {
628
+ return withCors(formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin"), req, config);
629
+ }
630
+ const start = Date.now();
631
+ const requestId = nextRequestLogId(start);
632
+ const logCtx: RequestLogContext = { model: "gpt-live", provider: "unknown" };
633
+ const resolved = await resolveLiveSidebandUpgrade(req, config, logCtx, liveSidebandTarget);
634
+ if (resolved instanceof Response) {
635
+ addFinalRequestLog(requestId, start, logCtx, resolved.status);
636
+ return withCors(resolved, req, config);
637
+ }
638
+ addFinalRequestLog(requestId, start, logCtx, 101);
639
+ if (server.upgrade(req, {
640
+ data: {
641
+ kind: "live-sideband",
642
+ liveUpstreamUrl: resolved.upstreamWsUrl,
643
+ liveUpstreamHeaders: resolved.headers,
644
+ livePending: [],
645
+ liveOpened: false,
646
+ } satisfies WsData,
647
+ })) return undefined as unknown as Response;
648
+ return withCors(formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"), req, config);
649
+ }
650
+
493
651
  // Data-plane guard: unknown /v1/* paths must fail with JSON 404, never fall through to the
494
652
  // GUI static handler (extensionless paths would get index.html with HTTP 200 and codex-rs
495
653
  // endpoint clients — memories/*, realtime/* — would surface confusing
@@ -511,10 +669,37 @@ export function startServer(port?: number) {
511
669
  // Responses WebSocket data plane (phase 120.2). Re-frames the same SSE pipeline onto the
512
670
  // socket: parse response.create → run handleResponses unchanged → pump its SSE body as WS
513
671
  // Text frames. response.processed is a no-op ack. close() aborts the upstream (RC2 parity).
672
+ // Live sideband sockets (kind=live-sideband) are a transparent bidirectional relay instead.
514
673
  open(ws: ServerWebSocket<WsData>) {
674
+ if (ws.data.kind === "live-sideband") {
675
+ attachLiveSidebandUpstream(ws);
676
+ return;
677
+ }
515
678
  registerCodexWebSocket(ws);
516
679
  },
517
680
  message(ws: ServerWebSocket<WsData>, raw: string | Buffer) {
681
+ if (ws.data.kind === "live-sideband") {
682
+ const upstream = ws.data.liveUpstream;
683
+ if (!upstream || upstream.readyState === WebSocket.CONNECTING || !ws.data.liveOpened) {
684
+ const pending = ws.data.livePending ?? (ws.data.livePending = []);
685
+ if (pending.length >= LIVE_SIDEBAND_PENDING_MAX) {
686
+ closeLiveSideband(ws, 1009, "too many pending frames");
687
+ return;
688
+ }
689
+ pending.push(raw);
690
+ return;
691
+ }
692
+ if (upstream.readyState !== WebSocket.OPEN) {
693
+ closeLiveSideband(ws, 1011, "upstream not open");
694
+ return;
695
+ }
696
+ try {
697
+ upstream.send(raw);
698
+ } catch {
699
+ closeLiveSideband(ws, 1011, "upstream send failed");
700
+ }
701
+ return;
702
+ }
518
703
  const rawBytes = typeof raw === "string" ? Buffer.byteLength(raw) : raw.byteLength;
519
704
  if (rawBytes > MAX_WS_FRAME_BYTES) {
520
705
  sendJsonFrame(ws, buildWsErrorFrame(413, {
@@ -631,6 +816,11 @@ export function startServer(port?: number) {
631
816
  })();
632
817
  },
633
818
  close(ws: ServerWebSocket<WsData>) {
819
+ if (ws.data.kind === "live-sideband") {
820
+ ws.data.cancel?.();
821
+ ws.data.liveUpstream = undefined;
822
+ return;
823
+ }
634
824
  unregisterCodexWebSocket(ws);
635
825
  ws.data.cancel?.(); // RC2: abort the upstream when the client disconnects
636
826
  },