@bitkyc08/opencodex 2.7.38-preview.20260724 → 2.7.39
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.
- package/README.ja.md +8 -1
- package/README.ko.md +7 -1
- package/README.md +7 -1
- package/README.ru.md +7 -1
- package/README.zh-CN.md +7 -1
- package/gui/dist/assets/index-B-cheu55.js +52 -0
- package/gui/dist/assets/index-oOZcqVmj.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +22 -2
- package/src/adapters/cursor/live-transport.ts +7 -0
- package/src/adapters/cursor/message-mapper.ts +3 -0
- package/src/adapters/cursor/protobuf-request.ts +223 -27
- package/src/adapters/cursor/request-builder.ts +41 -15
- package/src/adapters/cursor/thread-continuity.ts +67 -0
- package/src/adapters/cursor/types.ts +3 -1
- package/src/adapters/cursor.ts +44 -9
- package/src/adapters/google.ts +115 -62
- package/src/adapters/kiro.ts +3 -17
- package/src/adapters/openai-chat.ts +16 -5
- package/src/adapters/openai-responses.ts +56 -1
- package/src/adapters/run-turn-queue.ts +11 -1
- package/src/bridge.ts +139 -69
- package/src/chat/outbound.ts +135 -73
- package/src/cli/codex-shim-autorestore.ts +45 -0
- package/src/cli/index.ts +6 -2
- package/src/codex/auth-context.ts +18 -2
- package/src/codex/catalog/provider-fetch.ts +31 -8
- package/src/codex/model-cache.ts +44 -0
- package/src/codex/runtime.ts +17 -1
- package/src/codex/shim.ts +608 -10
- package/src/combos/resolve.ts +7 -2
- package/src/config.ts +11 -0
- package/src/lib/sse-decoder.ts +25 -6
- package/src/responses/parser.ts +1 -1
- package/src/responses/state.ts +10 -2
- package/src/server/auth-cors.ts +4 -1
- package/src/server/index.ts +182 -0
- package/src/server/live.ts +491 -0
- package/src/server/management/provider-routes.ts +2 -0
- package/src/server/responses/core.ts +184 -20
- package/src/server/responses/encrypted-payload.ts +118 -41
- package/src/server/ws-bridge.ts +7 -0
- package/src/types.ts +14 -0
- package/gui/dist/assets/index-BpX-hoSd.css +0 -1
- package/gui/dist/assets/index-CprFnVjr.js +0 -52
package/src/combos/resolve.ts
CHANGED
|
@@ -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: {
|
|
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,7 @@ 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(),
|
|
445
446
|
// Invalid values degrade to undefined ("auto") instead of failing the whole
|
|
446
447
|
// parse: a hand-edited typo must never trip the backup-and-defaults repair
|
|
447
448
|
// path below and wipe providers/pool accounts. Warning emitted in loadConfig.
|
|
@@ -793,6 +794,15 @@ export function codexAutoStartEnabled(config: Pick<OcxConfig, "codexAutoStart">)
|
|
|
793
794
|
return config.codexAutoStart !== false;
|
|
794
795
|
}
|
|
795
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
|
+
|
|
796
806
|
export function multiAgentGuidanceEnabled(
|
|
797
807
|
config: Pick<OcxConfig, "multiAgentGuidanceEnabled">,
|
|
798
808
|
): boolean {
|
|
@@ -822,6 +832,7 @@ export function getDefaultConfig(): OcxConfig {
|
|
|
822
832
|
multiAgentGuidanceEnabled: true,
|
|
823
833
|
websockets: false,
|
|
824
834
|
codexAutoStart: true,
|
|
835
|
+
codexShimAutoRestore: true,
|
|
825
836
|
};
|
|
826
837
|
}
|
|
827
838
|
|
package/src/lib/sse-decoder.ts
CHANGED
|
@@ -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
|
|
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(":"))
|
|
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);
|
package/src/responses/parser.ts
CHANGED
|
@@ -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 (
|
|
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
|
}
|
package/src/responses/state.ts
CHANGED
|
@@ -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
|
|
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 } }
|
package/src/server/auth-cors.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -122,11 +122,90 @@ import { handleChatCompletions } from "./chat-completions";
|
|
|
122
122
|
import { anthropicErrorResponse } from "../claude/outbound";
|
|
123
123
|
import { buildDesktop3pRegistry } from "../claude/desktop-3p";
|
|
124
124
|
import { handleImages } from "./images";
|
|
125
|
+
import { handleLive, parseLiveSidebandTarget, resolveLiveSidebandUpgrade } from "./live";
|
|
125
126
|
import { handleSearch } from "./search";
|
|
126
127
|
import { fetchAllModels, handleManagementAPI, VERSION } from "./management-api";
|
|
127
128
|
|
|
128
129
|
const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
|
|
129
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
|
+
}
|
|
130
209
|
|
|
131
210
|
// GUI static serving extracted to ./server/gui-static. Re-exported below to keep the
|
|
132
211
|
// "../src/server" import surface stable for tests/callers.
|
|
@@ -498,6 +577,77 @@ export function startServer(port?: number) {
|
|
|
498
577
|
return withCors(response, req, config);
|
|
499
578
|
}
|
|
500
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
|
+
|
|
501
651
|
// Data-plane guard: unknown /v1/* paths must fail with JSON 404, never fall through to the
|
|
502
652
|
// GUI static handler (extensionless paths would get index.html with HTTP 200 and codex-rs
|
|
503
653
|
// endpoint clients — memories/*, realtime/* — would surface confusing
|
|
@@ -519,10 +669,37 @@ export function startServer(port?: number) {
|
|
|
519
669
|
// Responses WebSocket data plane (phase 120.2). Re-frames the same SSE pipeline onto the
|
|
520
670
|
// socket: parse response.create → run handleResponses unchanged → pump its SSE body as WS
|
|
521
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.
|
|
522
673
|
open(ws: ServerWebSocket<WsData>) {
|
|
674
|
+
if (ws.data.kind === "live-sideband") {
|
|
675
|
+
attachLiveSidebandUpstream(ws);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
523
678
|
registerCodexWebSocket(ws);
|
|
524
679
|
},
|
|
525
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
|
+
}
|
|
526
703
|
const rawBytes = typeof raw === "string" ? Buffer.byteLength(raw) : raw.byteLength;
|
|
527
704
|
if (rawBytes > MAX_WS_FRAME_BYTES) {
|
|
528
705
|
sendJsonFrame(ws, buildWsErrorFrame(413, {
|
|
@@ -639,6 +816,11 @@ export function startServer(port?: number) {
|
|
|
639
816
|
})();
|
|
640
817
|
},
|
|
641
818
|
close(ws: ServerWebSocket<WsData>) {
|
|
819
|
+
if (ws.data.kind === "live-sideband") {
|
|
820
|
+
ws.data.cancel?.();
|
|
821
|
+
ws.data.liveUpstream = undefined;
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
642
824
|
unregisterCodexWebSocket(ws);
|
|
643
825
|
ws.data.cancel?.(); // RC2: abort the upstream when the client disconnects
|
|
644
826
|
},
|